Skip to content

compiler: peephole pass with fused and constant opcodes - #403

Merged
davydog187 merged 6 commits into
mainfrom
perf/codegen-peephole
Jul 27, 2026
Merged

compiler: peephole pass with fused and constant opcodes#403
davydog187 merged 6 commits into
mainfrom
perf/codegen-peephole

Conversation

@davydog187

@davydog187 davydog187 commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Stacked PR. Base is perf/vm-call-path (#400), and this branch also
merges perf/compiler-front (#401). Merge order: #400, then #401, then
this one.
The measured deltas below are against the merge of those two,
not against main.

Codegen lowers naively — a fresh temporary for every intermediate value copied
into place, _ENV re-read out of the upvalue table before every global access,
every literal materialised into a register. That is the right call for codegen;
it just leaves a pile of purely local rewrites on the floor. This adds
Lua.Compiler.Peephole, which runs between codegen and bytecode encoding and
picks them up.

Rules

  1. Move elision. A producer writing a scratch register that is later copied
    into its real home writes the real home directly, and the copy disappears.
    The copy is searched for within a bounded window (16 instructions) rather
    than requiring strict adjacency — move base+1, tmp1; move base+2, tmp2
    separates every call argument and every for header from its producer.
  2. Constant folding. load_constant K, k feeding an arithmetic or
    comparison op as its right operand folds into a _k variant carrying the
    literal inline: add_k, subtract_k, multiply_k, less_than_k,
    less_equal_k, equal_k. The register that held the constant goes away
    with it.
  3. Upvalue-field fusion. get_upvalue t, i + get_field d, t, name
    get_field_upvalue d, i, name, and the set_field mirror. Every global
    read and write inside a function is exactly this pair, so this halves the
    instruction count of all of them.
  4. Unreachable code after an unconditional return / break is dropped —
    if n < 2 then return n end carries a close_upvalues codegen appends
    unconditionally.
  5. Upvalue round-trip collapse. set_upvalue i, r immediately followed by
    get_upvalue d, i becomes move d, r; the write stays.
  6. Redundant close_upvalues. A function that builds no closures can never
    have an open cell over one of its own registers, so it has nothing to close.
    The gate is the whole function, deliberately: goto closes at explicit
    levels and loop bodies close at iteration boundaries, and neither is safe to
    reason about block by block.
  7. max_registers re-derived from the rewritten stream — this is where a
    good chunk of the win is, since every call frame allocates that tuple and
    every setelement copies it. Clamped to the incoming value so it can only
    ever shrink, and bounded below by the highest index the rewritten stream
    still touches.

Register compaction (renumbering to close the gaps elision leaves) is not
here; recomputing the peak captures most of it and compaction is a much bigger
change.

fib, before and after

function fib(n)
  if n < 2 then return n end
  return fib(n-1) + fib(n-2)
end

Before — 19 opcodes, max_registers = 6:

 1  load_constant   r1, 2
 2  less_than       r2, r0, r1
 3  test            r2 { return_one r0; close_upvalues 1 } { }
 4  get_upvalue     r1, up[0]
 5  get_field       r2, r1.fib
 6  move            r1, r2
 7  load_constant   r3, 1
 8  subtract        r4, r0, r3        (local 'n')
 9  move            r2, r4
10  call_one        r1, args=1
11  get_upvalue     r2, up[0]
12  get_field       r3, r2.fib
13  move            r2, r3
14  load_constant   r4, 2
15  subtract        r5, r0, r4        (local 'n')
16  move            r3, r5
17  call_one        r2, args=1
18  add             r3, r1, r2
19  return_one      r3

After — 10 opcodes, max_registers = 4:

 1  less_than_k       r2, r0, 2
 2  test              r2 { return_one r0 } { }
 3  get_field_upvalue r1, up[0].fib
 4  subtract_k        r2, r0, 1       (local 'n')
 5  call_one          r1, args=1
 6  get_field_upvalue r2, up[0].fib
 7  subtract_k        r3, r0, 2       (local 'n')
 8  call_one          r2, args=1
 9  add               r3, r1, r2
10  return_one        r3

A run_oop-style loop body goes from 30 instructions to 19. An empty for
header goes from 5 instructions to 3.

Both engines

Lua.VM.Executor (which runs the main chunk, and every prototype the encoder
rejects) and Lua.VM.Dispatcher (which runs everything else) both execute the
rewritten stream, so all eight new opcodes have handlers in both. Their slow
paths bridge to the same dispatcher_binop / dispatcher_cmp /
index_value / table_newindex helpers as the register forms, with the
constant boxed, so __add / __sub / __mul / __lt / __le / __eq /
__index / __newindex fidelity is unchanged. The folded arithmetic keeps
hint_a; the constant side never carried a hint to begin with, so
(local 'n')-style error suffixes render identically.

Safety

Every rewrite is gated on the rewritten temporary being dead — never read again
on any path that can follow. Liveness is answered by scanning the instructions
that can execute after the rewrite site: the rest of the enclosing block, then
each enclosing block outward, plus one more trip around any enclosing loop
spelled out instruction by instruction (so a temporary rewritten at the top of
every iteration is correctly dead at the bottom of the previous one — that back
edge is what takes the oop loop body from 21 down to 19). A break reached
before a killing write suppresses the kill.

reads?/3 defaults to "reads everything" for a shape it does not recognise, so
an opcode added to codegen without a clause here disables the optimisation
rather than miscompiling it. :closure counts as reading every parent register
its child prototype captures. Functions containing goto / ::label:: opt out
entirely.

Validation

  • mix format, mix compile --warnings-as-errors, mix dialyzer — clean.
  • mix test --include slow --include lua53 --include differential — 2771
    passed, 16 skipped. Website suite: 54 passed.
  • New differential (test/lua/compiler/peephole_test.exs): a 48-program
    corpus plus the runnable fixtures compiled twice — peephole: false vs on —
    evaluated both ways, comparing return values and captured stdout. An
    18-program failing battery compares Lua.format_exception/1 output byte for
    byte.
  • Budget invariant: every test/lua53_tests/*.lua file is compiled both
    ways and each prototype asserted to satisfy max_registers(after) <= max_registers(before), instruction_peak(after) <= instruction_peak(before),
    instruction count non-increasing, and max_registers >= instruction_peak (the
    perf(vm): drop +16 register buffer on interpreter :call path #312 backstop invariant).
  • Negative spot checks: no _k form where the right operand is a register,
    none for a literal on the left, none for ops without a _k variant
    (/, %, ^); no upvalue fusion in the chunk (its _ENV lives in a
    register, not an upvalue); a goto-carrying function's instruction stream is
    byte-identical to codegen's.

peephole: false is a real compile option, not test-only scaffolding — it is
what makes the differential meaningful.

Measured

MIX_ENV=prod, min-of-11-rounds × 40 reps, alternating A/B across 6
interleaved passes, against the merge base (perf/vm-call-path +
perf/compiler-front). µs per iteration.

workload base this branch delta speedup
fib(15) chunk 447.45 319.07 −28.7% 1.40×
oop(50) (benchmarks/oop.exs shape) 139.18 120.00 −13.8% 1.16×
closures(100) (benchmarks/closures.exs shape) 422.93 387.48 −8.4% 1.09×
Lua.parse_chunk, small snippet 4.60 5.96 +29.6% 0.77×
Lua.parse_chunk, ~60-line chunk 149.65 169.81 +13.5% 0.88×

Round-to-round spread was tight on both sides (e.g. fib base 447–465, new
319–329), so the separation is well outside the noise. The pass costs ~1.4 µs
on a small snippet
and ~20 µs on a three-function chunk — paid once per
compile, against a 1.09–1.40× win on every execution.

Caveat: the machine had concurrent load throughout. The alternating A/B and
min-of-N are there to absorb it, and the tight per-round spreads suggest they
did, but treat the absolute numbers as indicative rather than publishable.

Also

  • website: the disassembler and the /reference/opcodes page learned the
    eight new opcodes (they degraded gracefully via the generic formatter, but
    the reference should not be silently incomplete). New "Fused forms" category.
  • test/lua/vm/upvalue_test.exs: the sibling-empty-loop-body threshold test now
    compiles with peephole: false. It pins a scope-analysis property observed
    through close_upvalues opcodes that rule 6 correctly removes from that
    closure-free chunk.

@davydog187

Copy link
Copy Markdown
Contributor Author

🤖 Automated review — Opus + Sonnet consolidated findings

Two independent AI reviewers (Opus, Sonnet) reviewed this PR; findings were adversarially verified and the HIGH below was independently reproduced by the orchestrator on this branch before posting.

🔴 HIGH — move elision deletes a write to a register that is live-out of a loop (miscompile)

lib/lua/compiler/peephole.ex:298-314 (body_futures/2), :512-531 (dead?/3/scan/4), :379-392 (elide_move/3)

Minimal reproducer (verified on this branch: peephole on → 0, peephole off → 2, PUC-Lua → 2):

local function id(x) return x end
local c = 0
for i = 1, 2 do
  c = i
  local y = id(c)
end
return c

Mechanism: the liveness scan for a rewrite site inside a loop body follows only the back-edge continuation (cond_body ++ header ++ body), never the loop-exit continuation. The unconditional c = i at the top of the body registers as a kill, so c's register is declared dead, and a second elide_move pass retargets the producer back to the scratch register and deletes the {:move, c_reg, tmp} argument copy — the only write to c in the rewritten body. scan/4's escaped flag models :break but not normal condition-false exit or for-exhaustion, and body_futures/2 omits the exit continuation for while_loop, repeat_loop, numeric_for, and generic_for.

Also reproduced with c = d + 1, c = t[i], c = G, two-arg calls, method calls, while/repeat loops, and inside nested functions (so the Dispatcher path miscompiles identically). Randomized differential hit rate: ~3 in 700 programs. Note the module doc claim "every rewrite only ever removes reads" is falsified by elide_move, which removes a write.

Fix direction: include the loop-exit continuation in body_futures/2 (or make dead?/3 conservative at loop boundaries), plus a regression test for the reproducer and its loop-kind variants.

🟡 LOW

  • lib/lua/compiler/codegen.ex:123instruction_size({:load_nil, dest, count}) returns dest + count, but :load_nil clears count + 1 registers (bytecode.ex:276, dispatcher.ex:258). Currently unreachable (nothing emits :load_nil; peephole's own writes? clause is correct), but this PR turns the benign over-allocation into a shrink floor via recompute_max_registers/3.
  • test/lua/vm/upvalue_test.exs ("sibling empty loop bodies…") — Keyword.fetch! is called on the loop body instruction list, which only works because an empty body is coincidentally [{:close_upvalues, n}]. Introduced on the perf/compiler-front branch; flagged on PR compiler: key scope resolution by node id and dedupe upvalue descriptors #400 where it will be fixed.

Test-coverage notes

  • The failing shape (assign a local in a loop body, pass it as a call argument, read it after the loop) is absent from the 48-program differential corpus.
  • Highest-value addition: a stream_data randomized differential (peephole on/off) over locals + arithmetic + loops + calls — a naive generator found this bug within ~120 programs. stream_data is already a dependency.
  • The test/lua53_tests/*.lua files are compiled both ways for the budget invariant but never executed both ways.

Verified clean by both reviewers: constant folding (operand inlining only — int/float, wrap, metamethod, error-hint fidelity over 44 probes), get/set_field_upvalue equivalence, rules 4/5/6 soundness, goto opt-out, max_registers shrink behavior.

A fix for the HIGH (and the LOW items above where they belong to this branch) will be pushed to this branch shortly.

@davydog187

Copy link
Copy Markdown
Contributor Author

Pushed two commits addressing the automated review's HIGH miscompile. body_futures/2 modelled a loop body's continuation as the back edge alone, with the loop-exit continuation reachable only after that trip, so an unconditional write near the top of the body registered as a kill in scan/4 and ended the liveness scan before the exit path was examined — elide_move/3 then deleted the move that was a local's only write (local c = 0; for i = 1, 2 do c = i; local y = id(c) end; return c returned 0 with the pass on, 2 with it off, and 2 in PUC-Lua). The back-edge trip is now tagged {:back_edge, …}: a read inside it still settles liveness, but a kill inside it settles only the back-edge path, so dead?/3 goes on to scan the exit continuation. Cost across the 5.3 suite and fixtures is 1.3% fewer elisions (13414 → 13233 instructions removed). Regressions cover all four loop kinds, an arithmetic producer, a method-call argument, and the same shape nested in a function (dispatcher path), plus a randomized differential property (300 runs, small integer programs over loops and calls) that fails without the fix. The second commit fixes instruction_size({:load_nil, dest, count}) to dest + count + 1 — the opcode clears count + 1 registers, and while nothing emits it today the peephole's recompute_max_registers/3 would turn the undercount into a too-narrow frame. Two module comments claiming the pass "only ever removes reads" now describe what move elision does to the copy's write. Full suite green, including the slow/differential/lua53 tags and against a trial merge of main.

Base automatically changed from perf/vm-call-path to main July 27, 2026 19:19
Codegen lowers naively: a fresh temporary for every intermediate value
copied into place, _ENV re-read from the upvalue table before every
global access, and every literal materialised into a register. A new
Lua.Compiler.Peephole pass runs between codegen and bytecode encoding
and cleans that up:

  1. move elision (retarget the producer, drop the copy)
  2. constant folding into _k opcode variants
  3. get_upvalue + get_field/set_field fusion (every global access)
  4. unreachable code after an unconditional return/break
  5. set_upvalue + get_upvalue round-trip collapse
  6. close_upvalues removal in closure-free functions

plus a recomputed max_registers, clamped so it can only shrink.

Every rewrite is gated on the rewritten temporary being dead, answered
by scanning the instructions that can follow — including the loop back
edge, spelled out instruction by instruction. reads?/3 defaults to
'reads everything' for unrecognised shapes, and functions containing
goto opt out entirely.

The eight new opcodes (add_k, subtract_k, multiply_k, less_than_k,
less_equal_k, equal_k, get_field_upvalue, set_field_upvalue) have
handlers in both engines, so the interpreter and the dispatcher stay in
lockstep. Their slow paths bridge to the same metamethod helpers as the
register forms, and the folded arithmetic keeps hint_a, so error
rendering is byte-identical.

fib's body goes from 19 opcodes in 6 registers to 10 in 4; a
run_oop-style loop body from 30 instructions to 19.

Compile with peephole: false to skip the pass; the new differential test
compiles a corpus both ways and compares results, output, and rendered
exceptions.
Scanning every register 0..max for a read makes the pass quadratic in
the register count. The answer is almost always within a slot or two of
the incoming bound, so search from the top and stop at the first hit.

Cuts the pass's share of compile time roughly in half on a multi-function
chunk (parse_chunk of a ~60-line chunk: +19% -> +13.5%).
The riskiest thing the peephole pass can break is a slow path: a folded
_k form has to reach the metamethod bridge with its constant boxed, and
a fused upvalue-field form has to reach __index / __newindex on _ENV.
Add those to the differential corpus and the error battery, covering
__add/__sub/__mul/__lt/__le on a folded literal, string-to-number
coercion, int64 wrap, float promotion, and _ENV metatables.
`body_futures/2` modelled a loop body's continuation as the back edge
alone — one more trip around the loop, with whatever follows the loop
reachable only after that trip. An unconditional write near the top of
the body therefore registered as a `:killed` in `scan/4` and ended the
scan before the exit path was ever examined. `dead?/3` then declared the
register dead and `elide_move/3` deleted the `{:move, local, tmp}` that
was its only write. The `escaped` flag modelled `:break` but not normal
condition-false exit or `for` exhaustion, so every loop kind was
affected.

    local function id(x) return x end
    local c = 0
    for i = 1, 2 do
      c = i
      local y = id(c)
    end
    return c    -- 0 with the pass on, 2 with it off

The back-edge trip is now tagged `{:back_edge, instructions}`. A read
inside it still settles liveness, but a kill inside it settles only the
back-edge path: `dead?/3` continues into the exit continuation, so a
register the code after the loop reads stays live regardless of what the
next iteration would do to it. Cost across the 5.3 suite and fixtures is
1.3% fewer elisions (13414 -> 13233 instructions removed).

Regression tests cover the reproducer plus an arithmetic producer,
`while`, `repeat`, `generic_for`, a method-call argument, and the same
shape nested in a function (the dispatcher path). A randomized
differential property generates small integer programs over three
locals — loop bodies that write them unconditionally, calls that copy
them into arguments, reads after the loop — and asserts the pass off and
on agree; it fails on 300 runs without this fix. Two module comments
that claimed the pass only ever removes reads now state what move
elision actually does to the copy's write.

Claude-Session: https://claude.ai/code/session_01BYHGFLoHBJAsUrTzjVp5nC
`instruction_size({:load_nil, dest, count})` returned `dest + count`, but
the opcode clears `count + 1` registers — `dest..dest + count`, per
`Lua.Compiler.Bytecode` and the dispatcher's `clear_nils(regs, dest,
count + 1)`. It undercounted the frame by one slot. Nothing emits
`:load_nil` today, but `Peephole.recompute_max_registers/3` uses
`instruction_peak/1` as the floor it may shrink to, so the undercount
would become a too-narrow register file rather than a benign slack.
`Peephole`'s own `writes?/2` clause already used the correct extent.

Claude-Session: https://claude.ai/code/session_01BYHGFLoHBJAsUrTzjVp5nC
The close-upvalue watermark is a scope-analysis property. This chunk
creates no closures, so the peephole pass drops the `close_upvalues`
opcodes the assertion reads it through — correctly, but that leaves
nothing to observe. Compile with the pass off, matching the sibling
test that covers the same property for parsed source.

Claude-Session: https://claude.ai/code/session_01BYHGFLoHBJAsUrTzjVp5nC
@davydog187
davydog187 force-pushed the perf/codegen-peephole branch from 0911b53 to 4a8fc63 Compare July 27, 2026 19:56
@davydog187
davydog187 merged commit 1aa4369 into main Jul 27, 2026
5 checks passed
@davydog187
davydog187 deleted the perf/codegen-peephole branch July 27, 2026 21:10
@davydog187 davydog187 mentioned this pull request Jul 28, 2026
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