Skip to content

Tail-recursive function calls that have their parameters passed by the pipe operator are not optimized as loops #6984

@teo-tsirpanis

Description

@teo-tsirpanis

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 1UL

It 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 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:

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 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Status

    New

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions