-
Notifications
You must be signed in to change notification settings - Fork 790
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Tail-recursive function calls that have their parameters passed by the pipe operator are not optimized as loops #6984
Comments
This pattern:
is a soundness behavior. Since Another thing worth mentioning:
can be thought of as:
And this isn't optimized due to what I mentioned earlier. Everything can change, so I wouldn't consider this behavior immutable. But this is the likely rationale. |
I agree, but on our example, we have defined let rec impl x acc = // ... From the moment we have passed But even if we had defined let rec impl x =
printfn "x is %d" x
fun acc ->
printfn "acc is %d" // ... and used it like that: let x = impl 6 17
let y = (impl 10) 3 it would have printed:
The side effects are still exhibited, and in the same order we have been expecting. If however we had called the functions like that: let f = impl 6
let y = impl 10 3
let x = f 7 things are going to change:
But nobody had said we should optimize this case! |
First, don't use the Second, you are correct, we don't guarantee to optimize uses of |
If we won't fix it, could we at least issue a compiler warning in the likes of "using the pipe operator does not optimize the recursive tail-call into a loop"? But we should be careful not to warn in cases of something like this: let rec f x1 x2 =
// [...]
(x1 - 1, x2 - 1) ||> f
// [...] |
I remember that somewhere there's a WIP that attempted to issue a warning if recursive functions were not tail call optimized. I think such warning would've gone a long way helping with analyzing your functions here. I don't recall the status of that effort, though. Btw, I think the Core definitions for |
I'm afraid it isn't true. I just tested it. |
Tagging as feature improvement - I think we'd accept a PR that made progress here, but I don't think it's a priority at the moment. |
Are you thinking of PR #1976? |
@TysonMN, yep, that's one, good find! Iirc, it was far from trivial to implement properly, but maybe someone can resurrect it and continue the effort? It's quite a common mistake that people use the pipe operators and expect recursion to be tail-called (even though it technically isn't, as there's still "work to be done", the the tail is not clean). A warning would certainly help here. |
Oh, I never thought of it like that. Interesting. Knowing that will help me front making this mistake. |
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:
It gets converted to the following C#-equivalent code:
It is a lean and mean
while
loop, nothing to be envied by C# developers.However, let's write our function this way:
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:
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 bigloopsrecursions.I also observed that this function:
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 forf x
. Same with(x1, x2) ||> f
, beingf x1 x2
. The compiler just has to learn thatx2 |> f x1
meansf 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.
The text was updated successfully, but these errors were encountered: