Skip to content
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

guaranteed tail call elimination #81

Closed
wants to merge 1 commit into from
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions active/0000-tail-call-elimination.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
- Start Date: 2014-05-19
- RFC PR #: (leave this empty)
- Rust Issue #: (leave this empty)

# Summary

Rust currently lacks support for guaranteed tail call optimization. A tail recursive function *may*
be optimized by LLVM's `tailcallelim` pass, but neither Rust or LLVM provides any guarantees. LLVM
passes will often transform the function in a way that prevents tail call optimization, or simply
miss seemingly possible cases due to strict requirements.

# Motivation

Recursion is often a far more natural way of expressing an algorithm. In Rust, recursion can also be
used to express patterns not otherwise possible in safe code. For example, the `find_mut` method for
`collections::TreeMap` uses a recursive algorithm rather than the iterative one used by `find`. It
should be possible to improve the borrow checker to handle these cases, but the recursive algorithm
will remain easier to read and reason about.

# Drawbacks

This proposal adds an extra keyword to the language, although `be` has been reserved with this in
mind for some time. LLVM now provides this feature, but another backend may need to do extra manual
work to provide this as a guarantee.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not that I'm against accepting this feature merely out of concern for some hypothetical alternative implementation, but how much work are we talking here? Using GCC as an example, say.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The commit adding x86 support for musttail was quite small. I don't know how much work this would involve for GCC. The feature is designed to be portable, but involves work for each new architecture.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. Basically I just want to make sure that it's not so much work that our choices effectively boil down to "never implement Rust on any other backend" vs. "force all alternative implementations to completely ignore a language feature".


# Detailed design

The `become` expression is introduced, usable in place of a `return` expression where the result is
another function call.

```rust
fn foo(x: int, y: int) {
println!("{} {}", x, y);
return foo(x, y)
}
```

This could also be written with `become`:

```rust
fn foo(x: int, y: int) {
println!("{} {}", x, y);
become foo(x, y)
}
```

This will map down to an LLVM `call` instruction marked with `musttail`, followed by a `ret`
instruction. The compiler will perform the necessary type-checking to satisfy the guarantees
required by `musttail`. The required guarantees are as follows:

* The call must immediately precede a ret instruction, or a pointer bitcast followed by a ret
instruction.
* The ret instruction must return the (possibly bitcasted) value produced by the call or void.
* The caller and callee prototypes must match. Pointer types of parameters or return types may
differ in pointee type, but not in address space.
* The calling conventions of the caller and callee must match.
* All ABI-impacting function attributes, such as sret, byval, inreg, returned, and inalloca, must
match.
* The callee does not access allocas or varargs from the caller

The `become` instruction covers part of the requirements itself, by representing a call followed by
a return from the function. The compiler also needs to forbid having any variables with destructors
in scope, as these would need to be called in between the function call and return. An exception is
a type guaranteed to be passed by-value to the callee.

The compiler can force the type signature of the caller and callee to match, resulting in a
guarantee of a matching calling convention / ABI.

The requirement of not accessing the caller's allocas is the hardest to enforce. The compiler would
forbid passing non-immediate types, and a default-deny lint would forbid passing non-primitive types
since the representation may change. The compiler would also forbid passing any lifetime-bounded
types not guaranteed to outlive the caller.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to understand these better. Can you whip up a compile-fail example for each of the three restrictions in this paragraph, along with a short description of why the programs must be rejected? I feel like it's going to be very important to communicate our restrictions up-front, and this will help to that end.


Since unique pointers and references are guaranteed to be pointer-size / immediate (for non-DST
types), these would be the suggested solution for passing around non-primitive types.

This feature only just landed in LLVM, and Rust will need to leave this feature gated as the kinks
are worked out. The support is not completely solid on every platform yet, but it's designed to be a
portable feature and will produce an error if the requirements are not met or the platform support
is incomplete.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What platforms specifically have solid support for this at the moment?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Proper support for x86 was added on April 29th, other platforms haven't had the work done yet. It's a very new feature. That's why I'm proposing the introduction of this feature behind a feature gate.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm somewhat afraid of us drumming up hype for this feature only to be left holding the bag if, six months from now, only x86 and x64 are supported with no ARM patches on the horizon. Having no idea what the development process for LLVM is like, is there any sort of concrete commitment to supporting this feature on less-popular architectures? In the worst-case scenario, would the semantics of become be such that we could gracefully degrade it into a return while "only" losing the promise that it won't blow the stack?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm somewhat afraid of us drumming up hype for this feature only to be left holding the bag if, six months from now, only x86 and x64 are supported with no ARM patches on the horizon

It doesn't matter how long it takes for the feature to be finished in LLVM because it will be behind a feature gate until it's solid enough for Rust. If someone is upset about how long it's taking to finish, they can write the ARM code generation themselves.

Having no idea what the development process for LLVM is like, is there any sort of concrete commitment to supporting this feature on less-popular architectures?

The patch adding x86 / x86_64 support changed 95 lines. It's not like it would a substantial task to implement this for other architectures. It's part of the portable language specification so any target not implementing it can be considered incomplete and not supported upstream. You can consider the fact that it's a defined part of the LLVM language specification to be a strong commitment to implementing it.

In the worst-case scenario, would the semantics of become be such that we could gracefully degrade it into a return while "only" losing the promise that it won't blow the stack?

Degrading to a return would be an LLVM code generation bug. It will currently report a not implemented error for cases not yet correctly handled per the language specification. This isn't something Rust has to worry about, it simply remains feature gated until enough support is implemented.


# Alternatives

An attribute on `return` could replace the `become` keyword, if and when it becomes possible to
attach attributes to expressions. This feature can remain feature gated until this is resolved one
way or the other, with the promise that it will stay around but without a backwards compatible
syntax.

# Unresolved questions

The current requirements proposed above are very strict and could be relaxed:

* The lint could be informed that a type like `Rc` will have a stable representation and
allow passing it to the callee.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean by special-casing it into the compiler, or via an opt-in attribute? Because the former is a little gross, and the latter would be a dismayingly-specific thing for library authors to consider attaching to all of their types.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Via an opt-in attribute for marking ABI stability.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could such an attribute have any uses beyond just allowing your type to play nicely with TCE?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If Rust is ever going to support versioned ABIs where bugs can be fixed without breaking the ABI and recompiling every program linked against the library, something like that would be necessary.

* Locals with destructors could be permitted, and would be destroyed *before* the call when not
passing them to the callee.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems evil: imagine converting a return into a become and suddenly you get issues because the behavior changed ?

Instead, I would propose to make it visible: if you want to have locals that have a destructor: no problem, but wrap them in an explicit {} block so that it's visible they get destroyed before the become instruction.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well that's why it's in unresolved questions rather than part of the proposal. The restriction is painful, and it's unclear if there's a sane way of relaxing it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I recommend that we retain all of the restrictions for the purpose of this RFC. Seeing TCE get used in practice will give us data on which relaxations to pursue, and there's no rush since they're all totally backwards-compatible.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's evil; because become is a control effect, as return is, it's explicit. But it's a different control effect and does things in different orders.

  • return: evaluate argument, then return
  • become: return, then evaluate argument

Edit: note that the alternative is even more of a refactoring hazard: if you simply can't do a become in the presence of destructors then non local changes break code.

* Passing non-immediate types as parameters is likely possible, but the current Rust calling
convention will get in the way.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you elaborate on this last point, of our current calling convention getting in the way?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current convention is to perform a copy in the caller and pass a pointer to the alloca, which is clearly not allowed. AFAICT only passing large values by-value would work.