-
-
Notifications
You must be signed in to change notification settings - Fork 2.5k
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
safe recursion #1006
Comments
Interesting proposal. Do I understand right that both builtins work (and can only work) under a closed world assumption? How does About FWIW, I've been thinking along similar lines as fn f(@rec n: usize) { if (n > 0) g(n-1); }
fn g(@rec n: usize) { if (n > 0) f(n-1); } The idea being that the compiler knows I'm using "trivially" kind of hand-wavy because I'm not sure what the compiler can trivially infer. There is probably something clever that can be done with comptime expressions. |
This could be really cool if you can solve all the corner cases. How would the compiler handle allocating on the stack? |
We will have a builtin function to annotate extra stack usage for the places we need it. For example if you use inline assembly to modify the stack pointer directly then you may need to use External functions could change the stack pointer in this way as well; external functions will support annotations to specify the stack usage upper bound per function. I'm thinking about writing a tool which analyses binaries and disassembles functions to automatically compute these annotations. |
I didn't think this through very well. I'll have to re-think it. It would work for direct recursion where we can add secret parameters, but we wouldn't want to have to change the calling convention of every function in the cycle. |
This is a hard problem to solve generally due to NP completeness. That said, why not start small and work at this for a while? For instance, rather than trying various constraints like recursion limits, just have checked stack allocation only for call graphs that do not have cycles. Over time you either chip away at the various other cases or people start writing code for embedded systems that does not use recursion. There is very little Zig code out there today and it seems like you may be trying to solve the problem without any solid data. Put in the checks you need to double check actual usage in test blocks.
I am typing on a phone so I have no idea how this will get formatted! The idea is that the built-in above would return As more code is written and you see the patterns that are safe, then you can relax the constraints with more complicated and refined rules. |
I did a proof of concept of this and it's now available for use: @newStackCall This proof of concept means that, even though we don't have all the features done yet for safe recursion, the ideas will work. So recursion is OK to use, it's not evil, and the compiler will help you remove the possibility of stack overflow in a future version of zig. What's left to do for this issue:
|
the other day I was reading On Recursion, Continuations and Trampolines ... and looked up CPS were It says:
my gut feeling is that this can't be true, otherwise everyone would just do it, or there is some other major drawback hiding |
The drawback of TCO is that you can't always do it. See #694 We have CPS in the form of coroutines and indeed it solves the recursion issue. I plan to revisit this issue with that in mind. |
If you eliminate the runtime stack, you have to replace it with some other allocator. The proponents of functional languages would probably advocate storing bound variables on the heap, which doesn't seem like an improvement. |
from https://eli.thegreenplace.net/2017/on-recursion-continuations-and-trampolines/
I don't claim I completely understand the issue because there should definitely still be a catch somehow, just reading though it seems as if it would work always and solve the issue. |
That all assumes passing a continuation as a first class function encapsulates all the bound variables for the closure. The concepts in that discussion are definitely not possible without bound variables. Bound variables have to be allocated in memory somewhere. Functional programming literature is hard for me to understand because it all seems to ignore the reality of computers. Where is the memory allocated? That's a really important question that you can't just gloss over. @monouser7dig I think that's the catch you're looking for. |
Ok maybe that’s true, thanks for the pointers. |
Just some thoughts over coffee ☕️
|
which heap allocate their own frames related: #1006
* `await @asyncCall` generates better code. See #3065 * `@asyncCall` works with a real `@Frame(func)` in addition to a byte slice. Closes #3072 * `@asyncCall` allows passing `{}` (a void value) as the result pointer, which uses the result location inside the frame. Closes #3068 * support `await @asyncCall` on a non-async function. This is in preparation for safe recursion (#1006).
The plan to solve this is to use async functions and const frame = try allocator.create(@TypeOf(async func(a, b, c)));
defer allocator.destroy(frame);
await @asyncCall(frame, {}, func, a, b, c); |
If that capability can indeed be reached, a more "implicit magic"-inclined language/compiler would be tempted to "at that point" hide the difference again under the covers and allow the same syntax for both calls. But I'm quite in favour of the explicitness, especially in larger code-bases it can be a nice highlighting of otherwise-hidden circularities / cycles. |
|
|
Ackerman function should be test as it very good in creating stack overflows and impossible to avoid recursion. |
One subtlety: when detecting cycles, we need to count cycles that involve tail calls, but those cycles are only a problem if they involve regular or stack async calls as well. Since converting any one of those calls to an allocated async call will break the cycle, an instance of recursion should be considered a property of a call graph rather than any individual call. Error reporting should list all functions in the cycle and whether they're regular calls, tail calls, or stack async calls -- due to the lazy nature of the compiler, the "entry point" of the cycle may not always be defined. Anyway, that might be obvious, sorry if so. |
Does this mean that, after this change, recursive function calls will be slower in Zig than right now due to the heap allocation? Or would there be a way to allow recursion of up to N depth like in the earlier example with If the goal is "prevent/handle stack overflows", I wonder if a stack overflow could be a required error to handle when recursively calling a function, like |
Since this may happen potentially along the complete program graph Johnsons algorithm sounds like the best solution. Somebody tested Johnson, Tarjan etc and found another solution was more flexible: link to paper. Enumerating Circuits and Loops in Graphs with Self-Arcs and Multiple-Arcs.
Hawick and H.A. James, In Proceedings of FCS. 2008, 14-20 This stuff should be properly benchmarked and not only being used in theoretical limbospace, because no paper so far published their benchmark data and test suite. |
I know it's a bit off-topic, but Nim guys have implemented a full-featured & seamless CPS for Nim (i.e. including support for arbitrary recursion). Of course it's based around captured variables (i.e. "closures"), but it works really well. The only downside is it's currently somewhat slower - but there is a huge potential for optimization (as their implementation focuses on correctness and readability and not at all on optimization). |
The folks from mold are discussing/planning to utilize Tarjan or some related methods/parallelization (https://ppl.stanford.edu/papers/sc13-hong.pdf) to speed up mold rui314/mold#842, so we might be able to Their relevant research question looks like, if the same method(s) are applicable on object code, which I suspect translates very good to source code execution semantics (besides comptime dead code elimination). |
CPS conversion is always possible - if you have a heap and garbage collection. So that isn't really an (automatic) option for Zig. However, I am using CPS conversion in the runtime I'm building, and using explicit tail-calls between the CPS functions. I use an explicit stack and have automatic promotion to the heap where necessary. |
Accepted Proposal
If we solve #157 then we'll know at compile-time the maximum stack usage of the entire call graph.
Except for recursion. Recursion - whether direct or indirect - represents a loop in the graph.
Here you can see indirect recursion
B->C->E->D->B...
Recursion is problematic because it can happen a number of times that is only known at runtime, whereas the stack size is determined at compile-time. If too much recursion happens, we get stack overflow. Apart from being an annoying bug, stack overflow is a real concern for embedded software, which typically must resort to heuristics to find out just how small of a stack size they can get away with using.
But recursion is very useful. It's an intuitive way to reason about control flow. @Hejsil wrote zig's self-hosted parser without recursion, and it is not as straightforward as recursive programming.
So what do we do?
We have our cake and eat it, too. That's what we do.
Looking at the graph above again, we can have every function call in the graph be normal, except for D calling B. That's the only entrance into the cycle. So we can make there be a compile error for a normal function call from D to B. To make a function call which starts a cycle, you have to use a builtin:
It is a compile error to use
@recursiveCall
when you could have used a normal function call, and it is a compile error to use a normal function call when you must use@recursiveCall
. So the compiler always tells you what to do.I've prototyped how this would be emitted to LLVM using inline assembly, and I am talking to the LLVM mailing list to find out what's the best way to accomplish this. http://lists.llvm.org/pipermail/llvm-dev/2018-May/123217.html
There's another, simpler way we can have safe recursion, by limiting the amount of recursion at compile-time. This would be another builtin, that would look like this:
This would work the same way except instead of allocating memory on the heap, we specify the maximum number of cycles, and the function call fails if the cycle limit is exceeded. Then when calculating the stack upper bound, we multiply the cycle stack size by the recursion limit.
So you then would decide at compile time how much stack space you're going to ask for from the OS.
The benefit of this idea is that it actually does not depend on any changes to LLVM and we could implement it even before #157 is finished.
Until #105 is done we would have to specify the Recursive or LimitedRecursive calling convention on the first node getting called in the cycle (B in the picture).
The text was updated successfully, but these errors were encountered: