-
Notifications
You must be signed in to change notification settings - Fork 846
Description
Because F# is a language that heavily uses recursion, its compiler employs a trick that is called "tail call optimization". With this trick, the last function a function calls (even when it is not herself) does not burden the stack. This allows tail recursion to be essentially a loop, in terms of stack pressure. Because stack frames are removed, the debugging experience is hindered, so tail call optimization is enabled in the Release mode by default.
What is more, if this last function that gets called is the function itself, in certain circumstances, the compiler removes the recursion completely and converts the function into a real, actual loop. This optimization is done even in Debug mode.
Let's look at the following, tail-recursive factorial function:
let factorial x =
let rec impl x acc =
if x = 0UL then
acc
else
impl (x - 1UL) (x * acc)
impl x 1ULIt gets converted to the following C#-equivalent code:
internal static ulong impl@2-75(ulong x, ulong acc)
{
while (x != 0L)
{
ulong num = x - 1L;
acc = x * acc;
x = num;
}
return acc;
}
public static ulong factorial(ulong x)
{
return impl@2-75(x, 1uL);
}It is a lean and mean while loop, nothing to be envied by C# developers.
However, let's write our function this way:
let factorial x =
let rec impl x acc =
if x = 0UL then
acc
else
impl (x - 1UL) <| x * acc
impl x 1ULSee the penultimate line? It has a pipe operator, that spares us a pair of parentheses. A common practice by functional programmers. The code is semantically the same. However, the generated code is totally different:
internal sealed class impl@2-77 : OptimizedClosures.FSharpFunc<ulong, ulong, ulong>
{
[CompilerGenerated]
[DebuggerNonUserCode]
internal impl@2-77()
{
}
public override ulong Invoke(ulong x, ulong acc)
{
return impl@2-76(x, acc);
}
}
internal static ulong impl@2-76(ulong x, ulong acc)
{
FSharpFunc<ulong, FSharpFunc<ulong, ulong>> fSharpFunc = new impl@2-77();
if (x == 0L)
{
return acc;
}
return fSharpFunc.Invoke(x - 1L).Invoke(x * acc);
}
public static ulong factorial(ulong x)
{
return impl@2-76(x, 1uL);
}With this small change, the function turned into normal recursive one, which also uses linear memory (one allocation per loop step). If we compile it in Release mode, there will be no problems, as the compiler will emit tail. calls. But in Debug mode, there is a very real danger of stack overflow for big loops recursions.
I also observed that this function:
let factorial x =
let rec impl x acc =
if x = 0UL then
acc
else
impl <|| (x - 1UL, x * acc)
impl x 1ULgenerates a loop, just like the first one.
And if we call impl like this: (impl (x-1UL)) (x * acc), the compiler produces suboptimal code once again.
My guess is that the compiler cannot understand that we are partially applying a function and immediately apply the rest of it. We didn't even store the curried function in a variable!
The pipe operator x |> f is a shortcut for f x. Same with (x1, x2) ||> f, being f x1 x2. The compiler just has to learn that x2 |> f x1 means f x1 x2, and not (f x1) x2.
Furthermore, because stack overflows are hard-to-diagnose, a developer would have to dig his code deep enough to find that the pipe operator is responsible. A fix would make this kind of elegant functional code more performant.
Metadata
Metadata
Assignees
Labels
Type
Projects
Status