EIR: make tail calls real, from lowering through every backend - #89
Conversation
`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>
|
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. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
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. Reviewed |
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>
|
Pushed 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 Nit 2 — stale registers in Nit 1 — return-threading guard: comment added. No behaviour change; the Nit 3 — trampoline name: done. Now 🤖 Addressed by Claude Code |
Dismissing prior approval to re-evaluate b65f637.
End::Tailexisted 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::Callinto 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.oopassed its mutual-recursion cases only because the VM keeps frames in a heapVec— 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 oflower(). Tail position turned out to be a shape in the finished IR rather than something the lowering needs to track:Two rewrites to fixpoint — return threading (a
Jmpto a block that does nothing but return one of its own parameters becomes aRetof the matching argument) and tail marking (a block whose last op is aCallproducing exactly the register it then returns becomesEnd::Tail). That coversif,match,do,when, and multi-clause arity dispatch at once, without threading a flag through all oflower_expr.Two deliberate exclusions
Op::Invokeis untouched.End::TailInvokeis 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. Andhandlelowers its body to a thunk called throughOp::Invokespecifically 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, sincehandleemits itsPopHandlers after the call.Backend fixes the pass made reachable
End::Tailleft the caller's captures installed, so a tail-called function'sOp::Upvalread the wrong frame's upvalues. Latent while nothing emittedEnd::Tail; live the moment the pass fires. Captures are now an explicit argument toenter_tail_frame: empty forTail(mirroringOp::Call), the closure's own list forTailInvoke.End::Tailemittedcall+returninstead ofreturn_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 calledgat all.End::TailInvokereturned Unit without performing the call. Loon functions now use Cranelift'stailcalling convention soEnd::Taillowers toreturn_call, with a C-ABI trampoline for the entry point.TailInvokeis now a hard error until the backend models closures.VM dispatch cost (independent of the above)
Vm, lazily extended for functions appended later (run()appends theresumeclosure) so it cannot go stale.Rc<Module>clone, so ops and terminators are borrowed instead.Results
10M-deep mutual recursion on the VM:
Microbenchmarks, 200K iterations, user time:
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
main, includingmultishot.oo,os-handlers.oo,state.ooanduser-effects.oo. The lone difference isreplay-demo.oo, which computesroll = IO.millis mod 5and is documented to crash on roughly one run in five (verified by running it eight times).cargo test --workspacegreen, plus--features native. The existing VM tests are strong coverage here:vm_durable_resumeandvm_agent_under_towersboth tail-call inside a handled region under multi-shot continuations.Invokeis never rewritten, a call followed by another op is not marked.vm_tail_call_does_not_inherit_caller_captureswas confirmed to fail (returns7instead of Unit) with the capture fix reverted, andtail_calls_run_in_constant_stack_from_sourceruns a million-deep mutual recursion through the Cranelift JIT — it segfaults without both halves of the change.return_calloutput is covered by unit tests (including awasmparservalidation 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