Skip to content

EIR: make tail calls real, from lowering through every backend - #89

Merged
ecto merged 2 commits into
mainfrom
claude/loon-tail-call-optimization-5a3d7d
Aug 10, 2026
Merged

EIR: make tail calls real, from lowering through every backend#89
ecto merged 2 commits into
mainfrom
claude/loon-tail-call-optimization-5a3d7d

Conversation

@ecto

@ecto ecto commented Aug 10, 2026

Copy link
Copy Markdown
Owner

End::Tail existed in the IR and all three backends carried code for it, but nothing ever constructed it. The lowering emits every call the same way — Op::Call into a register, then a jump to whichever block merges the surrounding arms and returns it. Because the variant was unreachable from source, the backend paths handling it had rotted unnoticed.

samples/tco-stress.oo passed its mutual-recursion cases only because the VM keeps frames in a heap Vec — it was using 100K frames of memory, not eliding them. The same program on wasm or native grew the real machine stack.

Lowering

New pass, eir::tailcall, run at the end of lower(). Tail position turned out to be a shape in the finished IR rather than something the lowering needs to track:

b2: ...; Call(r6, odd?, [r5])      b2: ...
    Jmp(b3, [r6])            =>        Tail(odd?, [r5])
b3(p7): Ret(p7)                    b3(p7): Ret(p7)   (now unreachable)

Two rewrites to fixpoint — return threading (a Jmp to a block that does nothing but return one of its own parameters becomes a Ret of the matching argument) and tail marking (a block whose last op is a Call producing exactly the register it then returns becomes End::Tail). That covers if, match, do, when, and multi-clause arity dispatch at once, without threading a flag through all of lower_expr.

Two deliberate exclusions

Op::Invoke is untouched. End::TailInvoke is not simply "Invoke in tail position": with a continuation callee the VM takes the tail-resume path, reusing the frame below instead of establishing a fresh prompt. And handle lowers its body to a thunk called through Op::Invoke specifically to create the prompt frame that delimits captured continuations. Nothing static can tell those cases from an ordinary closure call, so the pass doesn't try. Named functions — the mutual/self recursion that actually overflows stacks — are covered.

The call must be its block's last op. Anything emitted after it (a PopHandler, say) still has to run before the function returns. This falls out as a natural guard, since handle emits its PopHandlers after the call.

Backend fixes the pass made reachable

  • VMEnd::Tail left the caller's captures installed, so a tail-called function's Op::Upval read the wrong frame's upvalues. Latent while nothing emitted End::Tail; live the moment the pass fires. Captures are now an explicit argument to enter_tail_frame: empty for Tail (mirroring Op::Call), the closure's own list for TailInvoke.
  • wasmEnd::Tail emitted call + return instead of return_call, so tail recursion grew the wasm stack; the legacy codegen had been doing this correctly all along. Single-block functions were worse: they fell through to a catch-all returning Unit, so [fn f [n] [g n]] never called g at all.
  • native — tail calls were plain calls, and End::TailInvoke returned Unit without performing the call. Loon functions now use Cranelift's tail calling convention so End::Tail lowers to return_call, with a C-ABI trampoline for the entry point. TailInvoke is now a hard error until the backend models closures.

VM dispatch cost (independent of the above)

  • Register-file size was recomputed by scanning every block and op of the callee on every call and every tail call, so a tail-recursive loop cost O(code size) per iteration. Now computed once per function into a side table on Vm, lazily extended for functions appended later (run() appends the resume closure) so it cannot go stale.
  • The interpreter loop cloned each op before executing it — a heap allocation per instruction for every call, vector, map, ADT and builtin — purely to satisfy the borrow checker. The block is now read out of the loop's own Rc<Module> clone, so ops and terminators are borrowed instead.

Results

10M-deep mutual recursion on the VM:

before after
peak RSS 2.67 GB 9.8 MB
user time 1.55s 0.35s

Microbenchmarks, 200K iterations, user time:

bench baseline VM dispatch only + tail calls
tiny mutual recursion 0.03 0.01 0.00
large callee, short path 0.14 0.09 0.05
large callee, long path 0.09 0.05 0.04
1M non-tail calls 0.26 0.16 0.15

The last row is unchanged by the pass, which is the expected result — those calls aren't in tail position. The "large callee, short path" bench holds executed work constant while growing the callee's code size; it ran 4.6x slower than the tiny case at baseline purely because of the rescan.

Reviewer notes

  • Differential check: all 19 samples produce byte-identical output and exit codes against a binary built from main, including multishot.oo, os-handlers.oo, state.oo and user-effects.oo. The lone difference is replay-demo.oo, which computes roll = IO.millis mod 5 and is documented to crash on roughly one run in five (verified by running it eight times).
  • cargo test --workspace green, plus --features native. The existing VM tests are strong coverage here: vm_durable_resume and vm_agent_under_towers both tail-call inside a handled region under multi-shot continuations.
  • New tests include negatives — a call feeding an arithmetic op stays a call, Invoke is never rewritten, a call followed by another op is not marked. vm_tail_call_does_not_inherit_caller_captures was confirmed to fail (returns 7 instead of Unit) with the capture fix reverted, and tail_calls_run_in_constant_stack_from_source runs a million-deep mutual recursion through the Cranelift JIT — it segfaults without both halves of the change.
  • The EIR wasm backend is not yet wired into the CLI, so its return_call output is covered by unit tests (including a wasmparser validation pass) rather than end to end. wasm_tail_call(true) was already set on the wasmtime config for the legacy codegen.

🤖 Generated with Claude Code

`End::Tail` existed in the IR and all three backends carried code for it,
but nothing ever constructed it — the lowering emits every call as
`Op::Call` into a register plus a jump to a merge block that returns it.
Because the variant was unreachable, the backend paths handling it had
rotted unnoticed.

Lowering: a new `eir::tailcall` pass recognizes tail position as a shape
in the finished IR rather than threading a flag through `lower_expr`.
Return threading turns `Jmp(j, args)` into `Ret(args[i])` when `j` does
nothing but return its own `i`th parameter; tail marking then turns a
trailing `Call(r, f, args)` + `Ret(r)` into `End::Tail(f, args)`. Run to
fixpoint, this covers if/match/do/when and multi-clause arity dispatch at
once.

`Op::Invoke` is deliberately left alone. `End::TailInvoke` is not simply
"Invoke in tail position": with a continuation callee it takes the
tail-resume path instead of establishing a prompt, and `handle` lowers its
body to a thunk called through `Op::Invoke` precisely to create the frame
that delimits captured continuations. A static rewrite cannot distinguish
those from an ordinary closure call. The call must also be its block's
last op, so a following `PopHandler` is never skipped.

VM: `End::Tail` left the *caller's* captures installed, so a tail-called
function's `Op::Upval` read the wrong frame's upvalues — latent while
nothing emitted `End::Tail`, live as soon as the pass fires. Captures are
now an explicit argument to `enter_tail_frame`: empty for `Tail`
(mirroring `Op::Call`), the closure's own list for `TailInvoke`.

wasm: `End::Tail` emitted `call` + `return` rather than `return_call`, so
tail recursion grew the wasm stack; the legacy codegen had done this
correctly all along. Single-block functions were worse — they fell through
to a catch-all returning Unit, so `[fn f [n] [g n]]` never called `g`.

native: tail calls were plain calls, and `End::TailInvoke` returned Unit
without performing the call at all. Loon functions now use Cranelift's
`tail` calling convention so `End::Tail` lowers to `return_call`, with a
C-ABI trampoline for the entry point; `TailInvoke` is a hard error until
the backend models closures.

VM dispatch, independent of the above: the register-file size was
recomputed by scanning every block and op of the callee on every call and
every tail call, making a tail-recursive loop cost O(code size) per
iteration. It is now computed once per function. The interpreter loop also
cloned each op before executing it — a heap allocation per instruction for
every call, vector, map, ADT and builtin — which the borrow checker no
longer requires now that the block is read out of the loop's own
`Rc<Module>` clone.

10M-deep mutual recursion on the VM: 2.67 GB peak RSS and 1.55s before,
9.8 MB and 0.35s after. All 19 samples produce byte-identical output
against the baseline binary (except `replay-demo.oo`, which is documented
to branch on the wall clock).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 10, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
loon Ready Ready Preview Aug 10, 2026 12:07pm

Request Review

@chojiai

chojiai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Choji review — Looks good — no findings

Choji review — Looks good

The three prior nits are all addressed — return-threading now checks parameter membership, enter_tail_frame clears before resize, and the trampoline uses namespace 1 — and the new code is correct. This is a clean delta.

No findings — looks good.


Rate findings

Reviewed b65f637 · Choji updates this comment as you push · Mention @chojiai in a comment to discuss, re-review, or request a fix

chojiai[bot]
chojiai Bot previously approved these changes Aug 10, 2026
Cranelift's x64 backend asserts when emitting a tail call without frame
pointers ("the current implementation relies on them being present").
Every Loon function now uses `CallConv::Tail`, so this hit ordinary calls
too — `compile_function_call` and `compile_recursive_function` failed
alongside the tail-call tests. aarch64 maintains a frame pointer
unconditionally, which is why it only showed up on CI's x86_64 runner.

Verified both ways on x86_64 via `--target x86_64-apple-darwin` under
Rosetta: all 454 lib tests pass with the flag, and the four native tests
reproduce the exact CI assertion without it.

Also addresses three review nits:

- `enter_tail_frame` cleared only the argument registers, so a frame
  smaller than its caller's kept the caller's values in the rest of the
  file — `resize` fills only the slots it adds. `Op::Call` installs a
  freshly zeroed file, so a tail call has to hand over the same; this was
  the register-file twin of the capture leak already fixed here. Covered
  by `vm_tail_call_does_not_leak_caller_registers`, confirmed to fail
  without the clear.
- Spell out which of the three return-threading guards carries which part
  of the safety argument.
- Give the entry trampoline its own `UserFuncName` namespace instead of an
  index one past the EIR functions'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ecto

ecto commented Aug 10, 2026

Copy link
Copy Markdown
Owner Author

Pushed b65f637, which fixes the failing check job and addresses all three review nits.

CI failure (x86_64 only). Cranelift's x64 backend asserts when emitting a tail call without frame pointers — "the current implementation relies on them being present". Since every Loon function now uses CallConv::Tail, this hit ordinary calls too, so compile_function_call and compile_recursive_function failed alongside the tail-call tests. aarch64 keeps a frame pointer unconditionally, which is why local runs on Apple silicon were green. Fixed by setting preserve_frame_pointers, and verified both directions on x86_64 via --target x86_64-apple-darwin under Rosetta: 454 lib tests pass with the flag, and the four native tests reproduce the exact CI assertion without it.

Nit 2 — stale registers in enter_tail_frame: fixed, this was a real divergence. Worth flagging that it's more than a hygiene issue — Op::Call installs a freshly zeroed register file, so a tail call reusing the caller's buffer must hand over the same thing, and resize only fills the slots it adds. It's the exact register-file twin of the capture leak already fixed in this PR, and the rule is the same one: a tail call must be indistinguishable from the equivalent call + return. Added vm_tail_call_does_not_leak_caller_registers, confirmed to fail (returns 7 instead of Unit) without the clear.

Nit 1 — return-threading guard: comment added. No behaviour change; the position() lookup already rejects a non-parameter register, so the rewrite was sound. The comment now spells out which of the three guards carries which part of the safety argument (no ops → no skipped work; arity match → args[i] binds params[i]; parameter lookup → the read cannot move to a block where the register is undefined).

Nit 3 — trampoline name: done. Now UserFuncName::user(1, 0) rather than an index one past the EIR functions'.

🤖 Addressed by Claude Code

@chojiai
chojiai Bot dismissed their stale review August 10, 2026 12:08

Dismissing prior approval to re-evaluate b65f637.

@ecto
ecto merged commit 6cdaecb into main Aug 10, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant