Tail-recursive function calls that have their parameters passed by the pipe operator are not optimized as loops
- Dominant language
- F#
- Stars
- 4.3k
- Forks
- 876
- Avg merge
- 4d 11h
- Merged PRs (30d)
- 131
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:
``` fsharp
let factorial x =
let rec impl x acc =
if x = 0UL then
acc
else
impl (x - 1UL) (x * acc)
impl x 1UL
```
It gets converted to the following C#-equivalent code:
``` csharp
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:
``` fsharp
let factorial x =
let rec impl x acc =
if x = 0UL then
acc
else
impl (x - 1UL) <| x * acc
impl x 1UL
```
See 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:
``` csharp
internal sealed class impl@2-77 : OptimizedClosures.FSharpFunc
{
[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> 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:
``` fsharp
let factorial x =
let rec impl x acc =
if x = 0UL then
acc
else
impl <|| (x - 1UL, x * acc)
impl x 1UL
```
generates 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.
Contributor guide
Assessment
This issue has not been assessed yet.