vm: cheaper call convention — one-allocation register files, static-arity opcodes, call_self fusion - #405
Conversation
🤖 Automated review — Opus + Sonnet consolidated findingsTwo independent AI reviewers (Opus, Sonnet) reviewed this PR. No live runtime defects: the full suite passed in a clean checkout (2813 tests), ~70 hand-built fusion divergence probes (peephole on/off) all agreed, and 🟠 MEDIUM — invariant test is missing a case for the new opcode (found by both reviewers)
🟡 LOW
Notes
Fixes for the MEDIUM and the first two LOWs will be pushed to this branch shortly. |
|
Pushed 2b2a07c addressing the review findings. The escape-analysis proof now fails closed on |
0911b53 to
4a8fc63
Compare
2b2a07c to
ba13e8c
Compare
Every compiled-closure call allocated the callee's register tuple twice over: `Tuple.duplicate/2` for the blank file, then one `setelement` per argument, each copying the whole tuple again. Generated `mkregs/4` clauses build the finished tuple in a single literal construction for the small (width, parameter-count) shapes ordinary Lua functions have; a parameterless callee now allocates nothing at all, since an all-nil tuple is a compile-time literal. Wider shapes keep the duplicate-and-copy path, and the `grow_regs/2` growth contract for vararg and multi-return writes is untouched.
The argument count at a call site is fixed at encode time, so the zero-, one-, and two-argument forms of `:call` with a fixed result count now encode to dedicated tags. Their handlers read the arguments out of the caller's registers at constant offsets and build the callee's register file as one literal tuple: no copy loop, no clamp against the callee's parameter count, no blank tuple to overwrite. Wider arities keep the generic opcodes. `name_hint` and the baked source line ride along unchanged, so tracebacks and native-call error attribution are byte-identical to the generic forms. The interpreter is untouched: this is an encoding choice inside the dispatcher's own representation, and it runs the same instruction stream as before. The non-compiled-closure branches of the call handlers move into shared `call_one_bridge/17` and `call_zero_bridge/16` helpers so the generic and static-arity opcodes keep exactly one copy of the interpreter and native bridges between them.
A `local function` reaches its own name through an upvalue cell, so
every self-call loads the closure out of the cell into a scratch
register before calling it. When the cell provably can only ever hold
the closure that is already running, that load is pure overhead and the
call needs no callee at all: both engines already hold the prototype and
its upvalue tuple.
`Lua.Compiler.Peephole` proves it, and every step of the proof fails
closed — mutual recursion, a reassigned name, `local f; f = function()`,
a name captured by any second closure, a vararg body, or a `goto`
anywhere in the function all keep the generic call:
* the parent binds the child with the shape codegen emits for
`local function`, and writes that register nowhere else;
* the parent only ever reads it to call it, and the scratch register
the closure passed through dies before anything reads it back;
* inside the child the self-upvalue is only ever loaded to be called,
is never assigned, and is captured by no nested prototype — so
neither the value nor the cell behind it can reach code that could
rebind it, `debug.setupvalue` included.
The dispatcher's handler re-enters `proto.bytecode` with the upvalues it
is already carrying. It is a full call in every other respect: it pushes
a frame, ticks the instruction budget, checks the call depth at the same
point, and starts the callee with an empty open-upvalue map — so
tracebacks, `debug.getinfo`, and the `max_call_depth` trip point are
identical to a generic call's. The interpreter reconstructs the closure
the cell holds and runs an ordinary call.
Extends the peephole differential battery with the shapes the new opcodes introduce: self-recursion in every result shape the fusion accepts, argument counts on both sides of the static-arity encoding boundary, and the shapes the fusion must refuse — mutual recursion, a reassigned `local function`, `local f; f = function() … f() … end`, a name a second closure captures, a name passed as a value, a vararg body, and a body containing `goto`. Adds an error-fidelity battery for the fused frames: a catchable stack overflow, the depth it trips at, an uncaught overflow's rendered traceback, `error()` and type errors raised under the recursion, and `debug.traceback` / `debug.getinfo` read through them. Each asserts the program still fuses before comparing, so a change that quietly stops fusing fails instead of passing vacuously. The differential now compares `Exception.message/1` alongside the rendered exception. One case pins the interpreter's own handler: a self-recursive function containing short-circuit `and`/`or` keeps its prototype off the dispatcher, so `:call_self` runs on the interpreter there.
A `local function` declared in a loop body gets a fresh upvalue cell per iteration, and a closure that escapes the iteration keeps the one it was made with. Both properties matter to the self-call proof: the escape is what makes it decline, and the per-iteration cell is what would break if it did not. Two corpus programs pin the pair — one storing each iteration's closure and calling them all afterwards, one storing a chunk-level recursive function in a table and calling it both ways.
Two bullets described guards the code does not have: the scratch register is proved dead at its next write, not proved unread across the whole function, and `local f; f = function() … f() … end` is refused because an assignment never copies the closure into the local's register, not by a write count.
…ll_self in the register invariant The self-call fusion's child-side proof modelled only :set_upvalue and :get_upvalue on the self index. Field accesses through the name fuse into :get_field_upvalue / :set_field_upvalue before the analysis runs, index the closure straight out of its cell with no :get_upvalue left to see, and fell through the catch-all — so `f.x = 1` inside the body still fused. Unexploitable today (function indexing raises identically on both builds), but the proof is supposed to fail closed, and now it does: both fused field shapes on the self index disqualify the fusion, with @Refused fixtures pinning them plus the previously unexercised captures_upvalue?/2 guard (a closure declared inside the body that captures the self-upvalue). The max-registers invariant test's independent walker gained the six static-arity call opcodes but not :call_self, and its only recursive corpus entry recursed through a global, which never fuses — the opcode was both unhandled and unwalked. Add the walker case (arguments at base+1..base+arg_count, result written at base) and a self-recursive local function corpus entry that actually emits it. Also pass the post-fusion prototype list to recompute_max_registers/3 instead of the pre-fusion one (only upvalue_descriptors are read and fusion does not change them, but reading the stale list was an accident waiting to be relied on), and document that :call_self deliberately rides reads?/3's conservative catch-all. Claude-Session: https://claude.ai/code/session_01BYHGFLoHBJAsUrTzjVp5nC
ba13e8c to
8a6f20d
Compare
Last of the perf stack. The branches it stacked on — #400, #401, #403 —
have all landed, so this is now rebased directly onto
mainand carriesonly its own seven commits. The
basecolumn below is what is nowmain(the old
perf/codegen-peepholetip), so the ratios still read asthis PR against
main.Three changes to the call convention, in ascending risk, one commit each.
1. One-allocation callee register files
Every compiled-closure call built the callee's register tuple twice over:
Tuple.duplicate/2for the blank file, then onesetelementper argument,each copying the whole tuple again. Generated
mkregs/4clauses build thefinished tuple in a single literal construction for the small (width,
parameter-count) shapes ordinary Lua functions have. A parameterless callee
now allocates nothing at all — an all-
niltuple is a compile-time literal.Wider shapes keep the duplicate-and-copy path, and the
grow_regs/2growthcontract for vararg and multi-return writes is untouched.
2. Static-arity call opcodes
arg_countis fixed at encode time, so the 0-, 1-, and 2-argument forms ofeach fixed-result call get their own tag (
70–75). Their handlers readthe arguments at constant offsets into the caller's registers and build the
callee's file as one literal tuple: no copy loop, no
min/2clamp againstthe callee's parameter count, no blank tuple to overwrite. Wider arities
keep the generic opcodes.
name_hintand the baked source line ride along unchanged, so tracebacksand native-call error attribution are identical. The interpreter is
untouched here: this is an encoding choice inside the dispatcher's own
representation, and both engines still run the same instruction stream.
The non-compiled-closure branches of the call handlers moved into shared
call_one_bridge/17/call_zero_bridge/16helpers, so the generic andstatic-arity opcodes keep one copy of the interpreter and native bridges
between them.
3.
call_selffusionA
local functionreaches its own name through an upvalue cell, so everyself-call loads the closure out of the cell into a scratch register before
calling it. When the cell provably can only ever hold the closure that is
already running, that load is pure overhead and the call needs no callee at
all: both engines already hold the prototype and its upvalue tuple.
Lua.Compiler.Peepholeproves it (rule 7), and every step fails closed:local function, and writes that register nowhere else in the function;closure passed through dies before anything reads it back;
never assigned, and is captured by no nested prototype — so neither the
value nor the cell behind it can reach code that could rebind it,
debug.setupvalueincluded;goto.Refused, and tested as refused: mutual recursion, a reassigned
local function,local f; f = function() … f() … end, a name a secondclosure captures (with or without reassigning it), a name passed as a value
or handed to
pcall, a vararg body, a body usinggoto.The dispatcher's handler re-enters
proto.bytecodewith the upvalues it isalready carrying. It is a full call in every other respect: it pushes a
frame, ticks the instruction budget, checks the call depth at the same point
with the same depth, and starts the callee with an empty open-upvalue map —
so tracebacks,
debug.getinfo, and themax_call_depthtrip point areidentical to a generic call's. The interpreter reconstructs the closure the
cell holds and runs an ordinary call; one test pins that path (a
self-recursive body containing
and/orstill falls back off thedispatcher, so
:call_selfruns on the interpreter there).Emitted bytecode for
fibbefore (
main) — 10 opcodes:after — 8 opcodes, and neither call touches the upvalue table:
A non-self statement call shows change 2 on its own:
print(x)goes from{25, base, 1, hint, line}(call_zero, arg count inslot 3) to
{74, base, hint, line}(call_zero_1).Measured
MIX_ENV=prod, one tree per commit built from the stack, runs interleavedacross all four builds so machine drift hits each equally; min of 3 passes ×
7 rounds each. Ratios are speedups (higher is faster);
totalis this PRagainst
base, which is nowmain.fib151.36×,fib201.33×,loopself(tail-recursiveaccumulator) 1.34×,
mutual(mutual recursion, which never fuses)1.11×,
calls3(3-deep non-recursive call chain) 1.08×.closures,oop, andtablesare call-light or table-bound and land at1.00–1.02× — no regression, no win.
(
fib151.32×,fib201.26×,loopself1.30×,calls31.10×,mutual1.11×, the rest flat). This is a laptop with other work on it: treat
single-percent cells as noise and the double-digit ones as signal.
landed above its ~1.03× projection (1.06–1.14× on call-heavy work — it
removes an allocation per argument, not just an instruction), change 2
landed below its ~1.09× projection (1.03–1.07× on the recursive
workloads, flat elsewhere), and change 3 matched its ~1.18× projection on
self-recursive code (1.11–1.14× on fib on top of the first two, 1.20× on
loopself).Validation
mix format --check-formatted,mix compile --warnings-as-errors,mix docs --warnings-as-errors,mix dialyzer(0 errors).mix test --include slow --include lua53 --include differential: 2811passed, 16 skipped.
opcodes; every one of them evaluates identically with
peephole: falseand
peephole: true, and the whole Lua 5.3 suite still compiles with nowidened register file and no grown instruction stream.
the depth it trips at (200 with
max_call_depth: 200, unchanged), anuncaught overflow's rendered traceback,
error()and type errors raisedunder the recursion, and
debug.traceback/debug.getinforead throughthe frames — all byte-identical to the unfused build. Each case asserts the
program still fuses first, so it can't pass vacuously.
Re-validated after the rebase onto
main(which added #416's compile-timenode-id stamping under this PR): the seven commits replayed with no
conflicts, and
mix format --check-formatted,mix compile --warnings-as-errors,mix docs --warnings-as-errors, andmix dialyzer(0 errors) are all clean.
mix test --include slow --include lua53 --include differential: 2869 passed, 16 skipped — the count moved from 2811because the rebase picks up the tests that landed with the rest of the stack.
Website: 54 passed, format clean.