perf(ir/lower): tail-call fusion — :return of a single-use :call → TAIL_CALL - #649
Conversation
mparrett
left a comment
There was a problem hiding this comment.
You flag that this includes [2/4]; the part worth pinning down is which order costs what. Merge #648 first and this rebases cleanly — git reports skipped previously applied commit 37ccb333 and leaves one commit. The other direction is messier: merge this first and #648 does reduce to an empty diff, but only after resolving a pkg/rt/generated.sums conflict by hand. So #648 first, and worth retargeting this PR's base to #648's branch rather than main — right now GitHub shows reviewers the combined diff and both PRs read MERGEABLE independently, which hides the dependency you documented in the body.
The fusion pairs well with where #620 is heading. That issue was rewritten to stack on #645, and its remaining goal is to change what OP_TAIL_CALL does: reuse the current frame instead of allocating a child. This PR increases how much of the IR path reaches that opcode, so more code inherits the constant-space win once #620 lands.
I went looking for an interaction with #638 — a callee-less terminator becoming OP_TAIL_CALL, with the VM then faulting as it resolves f.nth(arity) — and there isn't one. The fusion predicate fires on a :return whose ref is a :call, and a :call always carries fn+args with aux as argc, so a fused emission always has a callee in the right slot. A function-level recur never reaches it: build-recur adds a :tail-call terminator directly, not a :return, so the predicate can't see it before or after #642. I confirmed #638's repro fails identically on main and on this branch. Recording it here so the question doesn't get re-raised.
238d412 to
61558e4
Compare
There was a problem hiding this comment.
Re-review of the current head: the fusion implementation looks sound, the isolated delta against #648 changes the lowered sequence from INVOKE, RETURN to TAIL_CALL, RETURN, targeted tests pass, and required checks are green.
One remaining coverage gap: test/ir_tailcall_fusion.lg only asserts result parity, so it still passes if the fusion is removed; strict mode is also off, so fallback can hide a lowering failure. Please compile the IR fixtures under *ir-compile-strict* and add a disassembly assertion for :TAIL_CALL (and absence of :INVOKE). Also, the line-77 comment that the plain compiler uses regular INVOKE is inaccurate—the plain compiler already emits TAIL_CALL in tail position.
The stack dependency remains: expecting rebase/refresh after #648 lands.
616ee44 to
f6e3646
Compare
regen: resolve generated artifacts after main rebase
f6e3646 to
4d25a20
Compare
|
Non-blocking, same class of ask as #719 just got: the fusion's try-safety is a documented-but-untested invariant. The gate comment says a fusable :call can never sit in a guarded region because the IR models try bodies as inner closures — true today, but nothing pins it, and |
regen: resolve generated artifacts after main rebase
4d25a20 to
cf43894
Compare
regen: resolve generated artifacts after main rebase
cf43894 to
2d17900
Compare
|
Rebased and updated at |
…IL_CALL Extracted from nooga#625 [3/4]. In lower-block!, when a block's :return wraps a single-use :call that is the block's last live instruction, emit TAIL_CALL directly (RETURN kept for shape parity) instead of CALL+RETURN. Stacks on the RPO/block-junk correctness fixes [2/4]; parity-checked against the plain bytecode compiler.
regen: resolve generated artifacts after main rebase
2d17900 to
7ef2a89
Compare
mparrett
left a comment
There was a problem hiding this comment.
Re-reviewed current head 7ef2a89. The prior strict-mode, opcode-shape, comment-correction, and try-frame-isolation asks are addressed; targeted and repository short-mode tests pass, generated artifacts are clean, and current CI is green. No blocking findings.
|
Verified the merge result locally before merging. What the checks covered, plus two ways this PR's fixture reports a pass that asserts nothing. Shape. Head Generated artifacts. Tests and builds. The 2026-08-04 coverage gap is closed. That review noted the fixture still passed with the fusion removed. It no longer does. Reverting Two ways to get a meaningless pass here. Neither detects a missing fusion:
Two pre-existing items on |
Summary
Tail-call fusion emits
TAIL_CALLfor a single-use:callin return position, enabling frame reuse.Test coverage
test/ir_tailcall_fusion.lg: bytecode/IR parity, opcode falsifier, and try-frame isolation coverageBase
Rebased directly onto
mainata759f8655eb8.Verification
make generatetwice: byte-identicalgenerated.sums,generated.manifest, andcore_compiled.lgbmake check-generated: passedgo test ./pkg/ir ./test: passedmake ir-stress-gate: passed; 2505/2516 native-lowering fixtures, 11 known baseline failuresBench ratchet
make bench-ratchetran in the exclusive benchmark lane on the candidate and onmain@a759f8655eb8using Apple M3 / Go 1.26.5. Both runs returned exit 2 against the Go 1.26.3 baseline with the same two benchmark groups over budget.Candidate vs main control wall time:
Reported deterministic candidate-minus-main deltas: