Add a peephole pass over emitted assembly - #4
Open
erichanwang wants to merge 4 commits into
Open
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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):
unreachable dead code, left behind by things like an explicit early
return followed by the fall-through jump to the same block's end.
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.