Skip to content

Add a peephole pass over emitted assembly - #4

Open
erichanwang wants to merge 4 commits into
feature/bytecode-vmfrom
feature/peephole-optimizer
Open

Add a peephole pass over emitted assembly#4
erichanwang wants to merge 4 commits into
feature/bytecode-vmfrom
feature/peephole-optimizer

Conversation

@erichanwang

Copy link
Copy Markdown
Owner

Adds a post-pass over the finished x86-64 instruction stream, applied after
codegen rather than threaded through it.

Two patterns, both safe by construction from line adjacency alone (labels
are always their own line in this codegen, so nothing can jump into the
middle of two consecutive instructions):

  • An unconditional jmp immediately followed by another jmp: the second is
    unreachable dead code, left behind by things like an explicit early
    return followed by the fall-through jump to the same block's end.
  • mov A, B immediately followed by mov B, A: the second reloads a value
    that's already sitting where it's being loaded to.

--no-peephole turns the pass off for comparison, same shape as
--no-regalloc.

Measured across the whole .lang corpus: 3819 instructions before the pass,
3782 after, 0.96% fewer. Small, because the codegen and register pool
already avoid most of what a peephole pass would otherwise clean up -- these
two patterns are what's actually left over.

Also refreshed the register-pool reduction figure in the README (10.1% ->
9.6%, previously 2692->2434, now 4184->3782) since the corpus has grown
since that number was first measured and it had quietly gone stale.

erichanwang and others added 4 commits July 23, 2026 00:10
genBlock leaves a couple of small, mechanical inefficiencies in its output:
an explicit early return followed immediately by the fall-through jump to
the same block's end (dead code, since the first jmp already left), and
occasional mov/reload pairs where a value gets copied somewhere and then
immediately copied right back. Neither needs dataflow analysis to catch --
both are safe by construction from adjacency alone, since labels are always
their own line in this codegen, so nothing can jump into the middle of two
consecutive instructions.

Applied as a post-pass over the finished instruction stream rather than
threaded through codegen, so it stays a small independent pass instead of
something every emit site has to remember. --no-peephole turns it off for
comparison.

Measured across the whole corpus: 3819 instructions before, 3782 after,
0.96% fewer. Small, because the codegen and register pool already avoid most
of what a peephole pass would otherwise clean up. Also refreshed the
register-pool reduction figure in the README (10.1% -> 9.6%) since the
corpus has grown since that number was first measured and it was quietly
out of date.
The 3.0x speedup came from a single program, bench/workload.lang, and
PROGRESS.md and README disagreed with each other about it besides. Add
array-heavy and string-heavy benchmarks and make benchmark.sh loop over all of
bench/*.lang with an aggregate. The honest number across varied workloads is
2.1-2.75x, not 3.0x; fib and primes alone are 3.2-4.1x, which is where the old
figure came from.

That exposed a real regression: on string-concatenation-heavy code the
compiled backend is slower than the interpreter. rt_arith's + path allocated
per concatenation, and every += re-scanned the accumulated string for a length
already known. Route string bytes through the existing per-Value bump
allocator and cache the length in the otherwise-unused arr_len field. This is
safe here because nothing is ever freed: the only free() calls in the codebase
are for getline's buffer in rt_input, and runtime.c documents that Values leak
by design, so an arena has no free site to relocate.

That helps but does not fix it - still roughly 0.4-0.5x against the
interpreter. The remaining cost is the O(n^2) copying inherent to rebuilding
the whole string on every append, which arena allocation does not address;
that needs a rope or an in-place growable buffer.

Harden the differential suite while here: diff the VM against the assembly
backend directly rather than only transitively through the interpreter, and
add stress_edge_cases.lang for negative modulo, division by zero with a
negative dividend, 2000-deep recursion, break/continue nested three loops
deep, precedence, and coercion chains. Add fuzz.py, which generates random
programs and checks all three backends agree - 450 programs, no divergences.

Constant folding for literal + - * is added behind --no-constfold, matching
the existing flags. / and % are excluded so division by zero keeps producing
ERROR rather than being folded away.

Measurements were taken on a machine under heavy load (average 12-18 on 16
threads), so the smaller wall-clock differences here are near the noise floor
and the arena win is corroborated by syscall counts instead. Further codegen
work was left alone rather than tuned against numbers that could not be
trusted.
rt_arith's '+' path rebuilt the whole accumulated string on every
concatenation: each "s = s + x" copied all of s's existing bytes into a
fresh arena buffer, so a loop of that shape was O(n^2) total copying.
Arena allocation (previous commit) cut per-op overhead but couldn't touch
that quadratic shape, leaving the compiled backend at 0.4-0.5x the
interpreter on string-concatenation-heavy code -- slower than the backend
it's supposed to beat.

Make concatenation build a lazy two-child node instead of copying: a
STRING Value is either a flat leaf (str set) or an unmaterialized concat
node (str == NULL, arr[0]/arr[1] hold the operands, arr_len already holds
the correct total length so chains can keep growing without touching
bytes). The bytes are only walked once, by rope_flatten, the first time
the value is actually read (print, comparison, array-to-string), and the
result is memoized back into str so a value read twice doesn't reflatten.
Traversal is iterative with an explicit heap stack rather than recursive,
since a "s = s + x" loop produces a chain as deep as the loop is long and
that can exceed a safe C stack depth.

This only touches runtime.c: the interpreter and bytecode VM each have
their own independent evalArith/vmArith built on std::string and don't
share this code path, so they're unaffected.

Verified zero divergence: run_tests.sh (16/16) and fuzz.py 450 (450/450)
both agree across interpreter/VM/compiled after this change.

Benchmarked on a loaded desktop (background load average ~10-16 on 16
threads from unrelated user activity, not idle) using interleaved A/B
(old runtime.c vs new, 7 rounds each, taskset -c 0,1 for both) on
bench/strings.lang's compiled binary, min/median/spread in ms:
  old: min 48, median 50, range 48-63
  new: min 16, median 20, range 16-21
Ranges don't overlap -- a real win, not noise, on this specific fix.
These are lower bounds (loaded machine), not clean idle timings.

benchmark.sh's own aggregate (best-of-9, same taskset pinning), run
twice: bench/strings.lang went from the documented 0.4-0.5x regression
to 1.7-2.0x; the full-corpus aggregate (interp/x86) came out 2.79x and
3.21x across the two runs (vm/x86: 3.00x and 3.36x) -- noisy run-to-run
under this load, but consistently a clear win over the pre-fix regime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016towgcp2NVGxUBbB9Tmpv4
Constant folding: replace the immediate-NUMBER-op-NUMBER check with a
bottom-up constEval(Expr*, double*) helper that folds any subexpression
built entirely of NUMBER literals combined with +, -, * (e.g. (1+2)*3,
or long chains like a+(b*(c+(d*e))) when a..e are all literals).
Division/modulo still fall through to rt_arith untouched, preserving
the exact "ERROR: division by zero" Value on a by-zero case. Also folds
comparisons between two such constant trees (==, !=, <, >, <=, >=),
reproducing rt_cmp's exact VT_BOOL Value via the same rt_make_bool call
genShortCircuit already uses.

Peephole: add "jmp .Lxxx immediately followed by .Lxxx:" removal. This
is a jump to the very next instruction, so it's a no-op regardless of
what else jumps into it. It fires on every "if" with no else/else-if,
where the branch body's trailing "jmp end" lands directly on "end:".

Considered and dropped: a "mov %reg, %reg" self-mov peephole pattern.
Proved safe (mov doesn't touch flags) but never fires anywhere in the
corpus -- this codegen's temp-register pool and arg/return registers
never share a name, so the pattern is structurally dead code. Dropped
per the rule that an optimization only ships if it measurably helps.

Deliberately not touched (dynamic-typing traps): no folding on any
operand that isn't a compile-time numeric-literal tree -- x*0, x+0,
x*1, and friends stay untouched since x could be a string (changes '+'
to concatenation) or an unassigned name (should raise a type error).

Emitted instructions across run_tests.sh + bench corpus: 6602 -> 6424
(-2.7%). run_tests.sh 16/16 and fuzz.py 1500 (100% agreement, 0
divergences) both before and after.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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