Skip to content

add guaranteed tail call fallback mechanism - #165044

Draft
folkertdev wants to merge 7 commits into
rust-lang:mainfrom
folkertdev:tail-call-fallback
Draft

folkertdev wants to merge 7 commits into
rust-lang:mainfrom
folkertdev:tail-call-fallback

Conversation

@folkertdev

Copy link
Copy Markdown
Contributor

I've been sitting on this for a while, and really just want to know if this is the right direction. This idea was discussed originally in #t-compiler > `become` failing compilation inside `cfg(feature = "...")`.

In summary, there are targets (s390x, powerpc elfv1, current wasm baseline) that fundamentally cannot implement guaranteed tail calls. There are more backends for which LLVM in practice does not (correctly) implement guaranteed tail calls. The idea is to have a fallback mechanism for those targets that at least makes (mutually) recursive functions not blow the stack.

This is unsatisfying in some important ways: guaranteed tail calls are (beyond functional programming homework exercises) a tool for optimization. This fallback mechanism cannot handle indirect (via a function pointer) calls, while that is the main way that tail calls are used in real-world projects. So, the idea here is that the basic uses of become do just work but practically the value of this mechanism is somewhat debatable. Nevertheless, T-lang expressed that they really want a fallback mechanism.

The idea here is that a function that uses become to tail call another function is transformed (via our shim mechanism) into a function that returns a continuation: a piece of data describing the call to make next.

For example, given these mutually recursive functions:

fn is_even(n: u32) -> bool { 
    match n {
        0 => true,
        _ => become is_odd(n - 1),
    }
}

fn is_odd(n: u32) -> bool { 
    match n {
        0 => false,
        _ => become is_even(n - 1),
    }
}

we derive these shims:

#[lang = "tail_next"]
pub enum TailNext<Args, Ret> {
    /// The call chain has bottomed out.
    #[lang = "tail_next_done"]
    Done(Ret),
    /// The call chain continues.
    #[lang = "tail_next_call"]
    Call(fn(Args) -> TailNext<Args, Ret>, Args),
}

fn is_even_shim(args: (u32,)) -> TailNext<(u32,), bool> { 
    let (n,) = args;
    match n {
        0 => TailNext::Done(true),
        _ => TailNext::Call(is_odd_shim, (n - 1,)),
    }
}

fn is_odd_shim(n: (u32,)) -> TailNext<(u32,), bool> { 
    let (n,) = args;
    match n {
        0 => TailNext::Done(false),
        _ => TailNext::Call(is_even_shim, (n - 1,)),
    }
}

And convert the original body into a call to the tail_eval lang item:

#[lang = "tail_eval"]
pub fn tail_eval<Args, Ret>(mut f: fn(Args) -> TailNext<Args, Ret>, mut args: Args) -> Ret {
    loop {
        match f(args) {
            TailNext::Call(next_f, next_args) => {
                f = next_f;
                args = next_args;
            }
            TailNext::Done(ret) => return ret,
        }
    }
}

fn is_even(n: u32) -> bool { 
    tail_eval(is_even_shim, (n,))
}

fn is_odd(n: u32) -> bool { 
    tail_eval(is_odd_shim, (n,))
}

Creating the shim for is_even requires that we know what shim is generated for is_odd, hence the limitation that this transformation does not work for indirect calls: we have no way of finding the shim in that case.


So, that's the idea. My execution of it probably violates a bunch of MIR assumptions at the moment. This branch needs more testing, etc. But before I spend the time on that, I'd like to validate that this looks roughly right.

Currently the fallback can only be triggered by passing -Zforce-tail-call-fallback, I didn't yet want to change behavior by default. A lot of the changes are just adding ShimKind::TailCall in the right places, and then the logic needed to build the shim (verbose but relatively straightforward).

One part I'm less confident in is the query stuff, and whether that is robust across crates. We need to keep the body of a function if a downstream crate may want to generate a shim from it.

@folkertdev folkertdev added the F-explicit_tail_calls `#![feature(explicit_tail_calls)]` label Oct 27, 2026
@rustbot rustbot added A-attributes Area: Attributes (`#[…]`, `#![…]`) A-test-infra-minicore Area: `minicore` test auxiliary and `//@ add-minicore` PG-exploit-mitigations Project group: Exploit mitigations S-waiting-on-author Status: This is awaiting some action (such as code changes or more information) from the author. T-compiler Relevant to the compiler team, which will review and decide on the PR/issue. T-libs Relevant to the library team, which will review and decide on the PR/issue. labels Oct 27, 2026

@folkertdev folkertdev Oct 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Currently the codegen for this function is pretty meh. It doesn't blow the stack, but the indirect calls really obscure the actual logic from the optimizer.

We could use !callees to convey to LLVM the set of functions that the indirect call could correspond to, combined with some way of making the shim address unique (e.g. local_unnamed_addr, or just removing unnamed_addr). Then the attributor pass will remove the indirect calls (but not the definitions because their address is used in a comparison).

In combination with -jump-threading-across-loop-headers this gives good results, e.g. see https://godbolt.org/z/3hsv1TGr9.

Alternatively, we could lower tail_eval as a computed goto using LLVM's indirectbr.

Both of these approaches need the full set of possible callees for a particular indirect call. Accumulating that information is not straightforward, but seems possible.

If we had that information we could also explicitly branch on the address of the next function
pointer and jump to the corresponding block of logic.

In any case, we have options to get at least somewhat decent performance out of this, all with only backend changes.

View changes since the review

Comment on lines +2143 to +2157
// FIXME: evaluate what targets we want to use the fallback by default.
//
// // Some architectures just fundamentally cannot guarantee tail calls.
// match self.arch {
// Arch::PowerPC | Arch::PowerPC64 | Arch::S390x => return false,
//
// // FIXME(explicit_tail_calls) LLVM lies about its support, so we must
// // validate support across targets manually.
// _ => {}
// }
//
// // On wasm, guaranteed tail calls require the `tail-call` feature.
// if self.is_like_wasm {
// return target_features.contains(&sym::tail_call);
// }

@folkertdev folkertdev Oct 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Should we wait with enabling the fallback for these targets, or just go ahead here?

View changes since the review

Comment on lines +458 to +462
err.note(format!(
"the `{}` target does not support guaranteed tail calls, \
so `become` is lowered to a trampoline",
sess.target.llvm_target,
));

@folkertdev folkertdev Oct 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

note to self: this is all a bit out of date, and depends on whether we enable the fallback by default on some targets.

View changes since the review

@rust-log-analyzer

Copy link
Copy Markdown
Collaborator

The job test-tidy failed! Check out the build log: (web) (plain enhanced) (plain)

Click to see the possible cause of the failure (guessed by this bot)
    ╭▸ compiler/rustc_mir_transform/src/tail_call.rs:137:12
    │
137 │         // Contruct the argument tuple.
    ╰╴           ━━━━━━━━
rerun with `--bless` to fix typos: `./x.py test tidy --extra-checks=spellcheck --bless`
tidy [extra_checks:spellcheck]: checks with external tool 'typos' failed
tidy [extra_checks:spellcheck]: FAIL
yarn install v1.22.22
warning package.json: No license field
warning ../../package.json: License should be a valid SPDX license expression
warning No license field
[1/4] Resolving packages...
---
Running eslint on rustdoc JS files
info: ES-Check: checking 7 files...
info: ✓ ES-Check passed! All files are ES10 compatible.
typechecking javascript files
tidy: The following check failed: extra_checks:spellcheck
Bootstrap failed while executing `test src/tools/tidy tidyselftest --extra-checks=py,cpp,js,spellcheck`
Currently active steps:
test::Tidy {  } at src/bootstrap/src/core/build_steps/test.rs:1744
Command `/checkout/obj/build/x86_64-unknown-linux-gnu/stage1-tools-bin/rust-tidy --root-path=/checkout --cargo-path=/checkout/obj/build/x86_64-unknown-linux-gnu/stage0/bin/cargo --output-dir=/checkout/obj/build --concurrency=4 --npm-path=/node/bin/yarn --ci=true --extra-checks=py,cpp,js,spellcheck` failed with exit code 1
Created at: src/bootstrap/src/core/build_steps/tool.rs:1627:23
Executed at: src/bootstrap/src/core/build_steps/test.rs:1829:24

Command has failed. Rerun with -v to see more details.
Build completed unsuccessfully in 0:02:23
  local time: Tue Oct 27 16:04:53 UTC 2026
  network time: Tue, 27 Oct 2026 16:04:53 GMT
##[error]Process completed with exit code 1.
##[group]Run echo "disk usage:"

@folkertdev

Copy link
Copy Markdown
Contributor Author

cc @scottmcm

@rust-bors

rust-bors Bot commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

☔ The latest upstream changes (presumably #165579) made this pull request unmergeable. Please resolve the merge conflicts by rebasing.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-attributes Area: Attributes (`#[…]`, `#![…]`) A-test-infra-minicore Area: `minicore` test auxiliary and `//@ add-minicore` F-explicit_tail_calls `#![feature(explicit_tail_calls)]` PG-exploit-mitigations Project group: Exploit mitigations S-waiting-on-author Status: This is awaiting some action (such as code changes or more information) from the author. T-compiler Relevant to the compiler team, which will review and decide on the PR/issue. T-libs Relevant to the library team, which will review and decide on the PR/issue.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants