Skip to content

perf: ir peephole pass - #222

Open
ascandone wants to merge 2 commits into
mainfrom
perf/ir-peepholes
Open

ascandone wants to merge 2 commits into
mainfrom
perf/ir-peepholes

Conversation

@ascandone

Copy link
Copy Markdown
Contributor

No description provided.

…pruning dead branches

The compiler emits one general template per construct, which leaves every
literal account behind a str_eq/jmp_if_false diamond and every literal amount
behind a max(amount, 0) one. This adds the peephole layer compiler-architecture.md
described, with the two passes that collapse those diamonds:

- ir.Peephole is a func([]ir.Instr) []ir.Instr returning nil when nothing
  changed; ir.Optimize sweeps ir.DefaultPeepholes to a fixed point, bounded by
  maxOptimizeSweeps.
- foldConstConditions turns str_eq, eq_int, lt_int and is_zero over constant
  operands (a register written once, by a load) into true/false.
- pruneDeadBranches folds jumps on a constant bool, drops the instructions no
  reachable path runs, jumps to the next instruction and labels no jump targets.

Optimization is opt-in: compiler.Compile is unchanged, and
compiler.CompileWithOptions(.., Options{Optimize: true}) — numscript.CompileOptions
on the public API — runs the passes and typechecks the result before assembling.

The contract is that the optimized program is behaviorally identical to the
plain one, and three checks enforce it: the spec corpus runs both on every case
and requires identical results and error structs, the verifier/encoding corpus
test runs both, and the differential sweep gets a "vm vs optimized vm" leg
compared with no tolerance at all (CompareIdentical).

Benchmarks (Apple M3 Pro, medians of 6) show the two passes alone are only a
few dispatches per send:

  BenchmarkCompiledVM          692 ns/op  336 B/op  10 allocs/op
  BenchmarkCompiledVMOpt       666 ns/op  336 B/op  10 allocs/op
  BenchmarkCompiledVMCapped   1865 ns/op  864 B/op  23 allocs/op
  BenchmarkCompiledVMCappedOpt 1836 ns/op  864 B/op  23 allocs/op
@NumaryBot NumaryBot added risk: medium NumaryBot classified this pull request as medium risk. bot-reviewed NumaryBot completed its review workflow for the current head. review-approved The NumaryBot review gate is satisfied for the current head. labels Oct 20, 2026
NumaryBot
NumaryBot previously approved these changes Oct 20, 2026

@NumaryBot NumaryBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The required automated review completed with no remaining findings.

@NumaryBot NumaryBot added review-approved The NumaryBot review gate is satisfied for the current head. and removed review-approved The NumaryBot review gate is satisfied for the current head. labels Oct 20, 2026
NumaryBot
NumaryBot previously approved these changes Oct 20, 2026

@NumaryBot NumaryBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The required automated review completed with no remaining findings.

…asses

Three passes join pruneDeadBranches in ir.DefaultPeepholes, sharing one
analysis, walkConsts: a forward walk that knows which registers hold the same
constant on every path reaching an instruction. Jumps only go forward, so one
pass is exact. It replaces the "written once" rule foldConstConditions and
pruneDeadBranches relied on, which the typechecker does not guarantee per path.

- constProp (replaces foldConstConditions) folds every pure op into a load of
  its result, as the VM computes it. check_enough_funds fails unless
  got == needed, so after it got takes needed's value: with a literal amount
  the whole allotment split is computed at compile time.
- dropProvenAsserts removes a check that passes on every path; a check that
  may fail stays in place, so the first error a run hits is unchanged.
  Dropping assert_leftover is what frees the portion arithmetic.
- deadCode removes pure instructions no path reads. Store reads, pulls and
  checks stay; mk_portion goes only with a denominator known not to be zero.

The passes can trade one assembler capacity bound for another, so when the
optimized stream hits ErrRegisterBankOverflow or ErrProgramTooLarge the
compiler assembles the plain one. They can also lift a bound (unused
load_vars go), so the difftest's vm vs optimized vm leg tolerates, and counts,
a plain capacity error the optimized program does not hit.

Benchmarks (Apple M3 Pro, medians of 6, plain -> optimized):

  Allotment 1/2,1/2    1830 -> 1127 ns   42 -> 20 allocs   392 -> 198 B
  AllotmentThirds      2499 -> 1580 ns   58 -> 27 allocs   333 -> 164 B
  FanIn 1/3 x3         2888 -> 1753 ns   56 -> 23 allocs   478 -> 161 B
  Capped               1891 -> 1832 ns   23 -> 23 allocs
  World                 677 ->  640 ns   10 -> 10 allocs
  AllotmentVar ($m)    1743 -> 1740 ns   37 -> 37 allocs

An amount from a var reaches no constant, so its allotment keeps its big.Rat
arithmetic. Optimize costs 20-46us per script.
@NumaryBot NumaryBot added review-approved The NumaryBot review gate is satisfied for the current head. and removed review-approved The NumaryBot review gate is satisfied for the current head. labels Oct 28, 2026

@NumaryBot NumaryBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The required automated review completed with no remaining findings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot-reviewed NumaryBot completed its review workflow for the current head. review-approved The NumaryBot review gate is satisfied for the current head. risk: medium NumaryBot classified this pull request as medium risk.

Development

Successfully merging this pull request may close these issues.

2 participants