-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Explicit Tail Calls #3407
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
base: master
Are you sure you want to change the base?
Explicit Tail Calls #3407
Conversation
thanks a ton @phi-go for all the work you put into writing the RFC so far! Really appreciated! 🎉 |
While I personally know the abbreviation TCO, I think that it would be helpful to expand the acronym in the issue title for folks who might not know it at first glance. |
An alternative is to mark a function (like in OCaml), not a recursive fn x() {
let a = Box::new(());
let b = Box::new(());
y(a);
} |
The RFC already highlights an alternative design with markers on function declarations and states that tail calls are a property of the function call and not a property of a function declaration since there are use cases where the same function is used in a normal call and a tail call. |
Note: this may be suitable either as a comment on #2691 or here. I'm assuming interested parties are watching both anyway. The restriction on caller and callee having the exact same signature sounds quite restrictive in practice. Comparing it with [Pre-RFC] Safe goto with value (which does a similar thing to the If we translate it to the
I don't think this should be handled by an actual calling convention label though. Calling these "rusttail" functions for discussion purposes, we have the following limitations:
The user doesn't have to see either of these limitations directly though; the compiler can just generate a shim with the usual (or specified) calling convention which calls the "rusttail" function if required. Instead, they just observe the following restrictions, which are strictly weaker than the one in the RFC:
|
To me the |
just for clarity: Replace be with become Accepted RFC:
So, |
Interesting bits of history. :-) In the mean time, someone pointed out to me there was a tailcall crate, which I didn't know. Shows that this choice of name was not entirely irrelevant. I did see the meaning of |
I think, in the end the language team will decide, probably after some crater runs. To be honest, to me the feature would also be welcome under any name. (I see, that I forgot to update the RFC, I'll take some time soon to do that. Also I'm happy to see that the tracking issue seems to be nearing completion.) |
Some wording updates and rewriting the section on local gotos.
Hello, I implemented I would love to see guaranteed tail calls added to Rust. I hope this proposal lands at some point. A few notes on the RFC:
This info is out of date -- GCC added support for
I strongly support the goal of allowing function signatures to be different. Since implementing Many architectures are perfectly capable of performing tail calls in cases where caller and callee do not match, for example: https://godbolt.org/z/hMn74o3nW So why does Clang require the match? We inherited this requirement from LLVM, whose But this "portability" is an illusion. In practice, we've found that many LLVM backends fail to perform tail calls even when functions are strictly matching: https://github.com/protocolbuffers/protobuf/blob/bb454a777e6019f675ea2e0267f7b57b389c6720/src/google/protobuf/port_def.inc#L245-L250 Moreover, some platforms are fundamentally incompatible with tail calls, for example WebAssembly without the Tail Call Extension. So in practice, the rule does not buy us much of a guarantee, but it also keeps us from using non-matching tail calls on platforms that support it. A more useful semantic is "perform a tail call if supported on this platform, otherwise fail to compile." This has been proposed as Unfortunately this would require work in LLVM, and I have no idea what that would take to land. |
Thanks @haberman for that experience report and helpful context. Many of us on the lang team would also like to see these land. That will, though, probably require us to confront those interesting and difficult questions that you raise. |
Indeed, thank you @haberman! To refresh some context. For this RFC, we expected that requiring matching function signatures will make it easier for backends to support However, reading between the lines, do I understand correctly that even LLVM does not actually provide a guarantee to create tail calls for all matching signature calls for backends with support? Or in other words, do we need to let the backend decide for us on a per call basis, instead of allowing / disallowing on a per backend basis on the Rust side? If we need to let the backend decide on a per call basis in any case, then I see no point to require matching signatures. |
Is there any fundamental reason for backends other than the wasm backend? Or just not yet implemented. If the latter, I think we should get it implemented in LLVM before we stabilize explicit tail calls on the rust side. |
IIRC the decision to restrict tail calls to same signature was not just for portability.
Furthermore, introducing new features into Rust as MVP (minimum viable products) is a common pattern. MVPs have higher chances to eventually land at all and with a bit of care it is also kind of easy or at least possible to extend the feature's capabilities later on. Concerning portability, I was under the impression that matching signatures between caller and callee would allows us in more cases to simulate tailcall behavior even on backends that do not have support for them via loop transformations and trampolines instead of lowering to LLVM's |
I haven't dug deep enough to determine whether some architectures have fundamental blockers to That said, things have improved a fair bit in the last few years. I just double checked on the bugs I linked above and several of them have been closed in the meantime. Hopefully this means that But that almost doesn't seem to matter, given that WASM fundamentally forbids tail calls. The existence of even a single such platform means that we can never expect backends to universally support tail calls. This seems to leave three main options for
Clang currently attempts to do (1), but has no emulation, so in practice it is not fully portable. I believe that the proposed Personally my gut is that (3) is the best semantic for a low-level language like Rust, C, or C++. For a higher-level language maybe emulation would make sense, but for low-level applications like our tail-calling parsers you don't really want emulation. If tail calls are not available, we'd want to manually switch to a different strategy. The user feedback I've seen also seems to support the idea that (3) is what users expect. The main downside of (3) is that it seems to require changes in LLVM. The semantics for |
@haberman thank you for the detailed outline for the different options. That really helps and clears things up. My personal hope was for 1) since I find it rather inelegant to have a core language feature that depends on a compilation backend. But I can certainly see why people prefer other options and have no strong arguments for or against them. I clearly see a way forward for a future extension to add support for other options if necessary. In the past there were discussions about people who strongly preferred one over the other options and there were syntax ideas that are similar to Note that WebAssembly has tail-calls since quite a while already. They are included in Wasm 2.0+. Furthermore, Wasm is not meant to be a modular specification like RISC-V, so over time, Wasm will (hopefully) pervasively implement tail-calls everywhere. Though, Wasm 1.0 will probably be kept around in some form and will cause problems forever. |
@Robbepop If I'm understanding you correctly, In all of the options I outlined, the The main difference between (1)-(3) is what circumstances would cause the compiler would throw an error. There are three general classes of error you could get from a. Error: Option (1) would only ever throw error (a). Option (2) could throw errors (a) or (b). Option (3) could throw errors (b) or (c). But with all of these options, |
@haberman Yes you understood that correctly. Glad you agree. Sorry for the confusion about the |
Note that if we have (1), a third party crate could provide (2)/(3) with a macro that essentially checked at compile time if the compilation target supports tail calls natively (with This means that it's okay for the MVP to offer only (1) and, if there is further demand, offer (3) in a later moment (or don't offer it at all) Also I think (3) and (2) looks the same, only that (3) lacks documentation on what platform is not supported. What's important is that either a tail call is fully emulated without growing the stack (option (1)) or compilation fails (options (2) and (3)). It would be terrible if in unsupported platforms, |
I don't think that's quite right, because the constraints imposed by (1) will probably be more restrictive than necessary on some architectures. Take this example from Clang, which mostly does (1) now: https://godbolt.org/z/7dqqYTvEz Without
I think (3) can better accommodate the fact that different platforms have different constraints on when a tail call is possible to perform. For example, here is a variation of the previous example where we compile the code for both x86-64 and x86. For x86-64, the call can be tail-call optimized because that platform uses a register-based calling convention. On x86, the same code cannot be tail call optimized because arguments are passed on the stack: https://godbolt.org/z/xc4oPze4b Since different platforms can have different levels of support for tail calls, it's not granular enough to make it a binary choice whether a platform supports tail calls or not.
I fully agree on this. |
Regarding emulation, I'm curious about two things: Is there a discussion somewhere about the technical details of how this emulation would work? Is there prior art for it? Can tail calls be emulated in cases where the target function is not statically known? Is there a clear use case where a user would benefit from this emulation? In all of the cases where I've used |
Whether tail calls can be performed depends on the function call ABI for the platform, and Rust’s default ABI is unstable. Whether tail calls can be performed on a particular platform must not depend on the unstable implementation details of I think, if we were to go down the “per-call-and-target support” route, it would need to apply only to stable, non-default ABIs like |
(3), maybe, depending on how you interpret it... but (2) could work if the crate's purpose is to abstract over a big |
I think the best approach is to make |
text/0000-explicit-tail-calls.md
Outdated
|
||
Since `#[track_caller]` adds an argument, `#[track_caller]` functions can only tail call other `#[track_caller]` functions. So the following example would not compile. | ||
However, removing `#[track_caller]` should not be a breaking change, as [it is not part of the stable API surface](https://github.com/rust-lang/rust/issues/87401#issuecomment-910628405) for Rust. | ||
For tail calls, however, removing an argument is a breaking change, so functions with `#[track_caller]` are **not** allowed to use `become`. ([Though, loosening this restriction might be possible.](https://github.com/rust-lang/rfcs/pull/3407/files/3304fee7542a56e3c671b8ce8a85070904cbf2ba#r2202286180)). Additionally, tail calling into a `#[track_caller]` function from a non-tracking function [might require growing the stack](https://github.com/rust-lang/rfcs/pull/3407/files/3304fee7542a56e3c671b8ce8a85070904cbf2ba#r2202286180), as this would defeat the purpose of tail calling, this is also **not** allowed. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adding track_caller
should also not be breaking. So tail-calling a track_caller
fn from a non-track_caller
should at most be a warning. (Note that the risk of unexpected stack growth is limited, because the track_caller
callee cannot itself use become
.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just to make sure, you mean adding track_caller
to a tail-called function should not be breaking? So the equivalent of adding track_caller
?
If that is what you meant, I'm a bit confused. On the one hand I can see that we want track_caller
to be usable transparently but on the other hand tail calls are pretty much incompatible with the idea of tracking the caller anyway. Ignoring the matching function signature issue, what caller would you want tracked for a tail called function? So should this just be added as another action that is not allowed? Or should we move track_caller
into the "Unresolved questions" section as we need to figure out how this needs to work?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what caller would you want tracked for a tail called function
The call would have to go through the shim that is also used for function pointers, so no caller would be tracked.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, I didn't know there was precedent for this. If we are just not tracking the caller then we can allow tail calls into caller tracking functions, other than how I understood it. I'll move this to the drawbacks section as it is a complication we need to implement.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated: 4dfb991
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what caller would you want tracked for a tail called function
The call would have to go through the shim that is also used for function pointers, so no caller would be tracked.
The shim however is not going to be tail-calling the actual fn implementation. This means that if I have a track-caller and a non-tracker-caller and have them mutually tail-call each other, the stack usage would grow.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@nbdd0121 As I point out in the thread OP, the #[track_caller]
function cannot itself use become
, so mutual tail calling is impossible.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The RFC was also updated earlier, let me know if anything is unclear.
rework track_caller section and move it into the drawbacks section
Based on the comments made by @haberman and following discussions, I think we need to consider the following open questions:
|
This RFC proposes a feature to provide a guarantee that function calls are tail-call eliminated via the
become
keyword. If this guarantee can not be provided an error is generated instead.Rendered
For reference, previous RFCs #81 and #1888, as well as an earlier issue #271, and the currently active issue #2691.
Rust tracking issue: rust-lang/rust#112788