Replace the SSA middle end with VIR
This replaces the legacy SSA middle end, its register allocator and the standalone SCCP pass with VIR, a value-oriented IR with block parameters, explicit ordered effects and a verifier. The parser builds VIR directly, and one shared machine lowering produces allocated machine IR for all four backends. The ARM64 and x86-64 backends then gain the folding, branch layout and register residency needed to come within 10% of GCC -O1 on the native throughput fixtures. Optimization is on by default, and --no-opt turns it off for debugging and for isolating an optimizer fault. docs/vir.md describes the IR, why shecc moves to it, its options and the validation commands.
Two independent fixes come first: malloc chunk reuse and libc error checks, and RISC-V direct calls through AUIPC and JALR, because the RV32 self-hosted image is already past the 1 MiB a JAL reaches and the following commits push callees out of range.
"Shrink branches, frames and edge copies" then cuts generated code size on every target:
- Branches take their shortest form once the layout is known: rel8 on x86-64, BEQZ/BNEZ/JAL on RISC-V, and no jump into the next block on ARM and ARM64.
- Frameless leaves skip frame setup on x86-64 and ARM64, and x86-64 frame slots use 8-bit displacements through a biased RBP.
- The shared lowerer pins call-free values to caller-saved registers, keeps parameters where they arrive, orders edge copies to avoid snapshotting, and forwards a stored value to a load of the same location.
- RISC-V gains s6-s11 and saves only the callee-saved registers a function names.
The stage 1 compiler shrinks by 25% on x86-64, 31% on RISC-V, 11% on ARM and 8% on ARM64, for 4% more instructions in a self-compile.
"Fold the phase 2 IR into VIR's machine layer" then retires the last name from the old design. The allocated instructions become VIR machine operations (vir_mop_t), defined in vir-lower.h; their creation, flattening, register dataflow and frame queries live in vir-lower.c. The backends share what each had copied (one flattening loop, one word counter for the size estimate, one frameless-leaf test), ARM's fall-through marks move into its backend, and peephole.c keeps only rewrites.
Stage 0 output is byte-identical across that commit for every test program and for shecc's own source, with and without --no-opt, on all four targets.
Nine follow-up commits come from a review of the whole series and from code-size and throughput measurements:
- "Drop redundant and legacy VIR tests" removes test scripts, fixtures and driver cases that repeat other coverage, folds the ARM verifier check into the stage 0 harness, and names the remaining fallback fixtures after what they exercise.
- "Cut peak memory of self-compilation" releases each function's graph once it is lowered, frees the SSA builder when a function is finished, moves the parsed token stream out of the preprocessor's arena, and gives the libc allocator size-segregated free lists. Stage 0 peak RSS in a self-compile drops from 332 to 194 MB, and stage 2's from 403 to 221 MB.
- "Reject invalid declarations and runaway nesting" reports conflicting file-scope typedefs, restrict on a non-pointer type in a cast, arrays and records whose size overflows an int (
int a[1073741825]used to get four bytes), records that contain themselves, and macro,#ifand conditional-operator nesting deep enough to exhaust the stack. - "Index VIR lookups and rank pin candidates once" replaces list scans in VIR construction and lowering with per-value membership and hashed root and SSA-binding lookups, sorts the values competing for a fixed register once by score instead of rescanning them every round, and verifies each graph once, after optimization. Output is unchanged; a self-compile runs 13% fewer instructions, and 25% fewer with
--no-opt. - "Remove dead code and signed shift overflow" drops unused functions, macros and the x86-64 trace flag, routes calls to undefined functions through
fatal(), and shifts into bit 31 unsigned in the ARM and RISC-V encoders. Every target generates byte-identical code. - "Shorten loop chains and weight pins by loop depth" adds a reassociation pass that takes a constant off a loop-carried chain, and weights register-pin candidates by loop depth. On x86-64, rng moves from 81% to 100% of GCC -O1 and sort from 95% to 99%.
- "Shrink ARM64 code and align short loops" folds immediates, bitmask constants, shifted registers, multiplications by 2^k+1 and multiply-adds into the instructions that use them, turns an AND feeding a zero test into TST, lets loads and stores through a global or frame address take its offset and extend an int index themselves, merges blocks reached by a jump from their only predecessor, computes values directly into the register their copy or return wants, drops definitions the folds leave unread, allocates frames of up to 480 bytes with the last register save, uses SBFIZ/UBFIZ and post-indexed loads, runs a function's leading argument test and return ahead of its prologue (fib's base case), reaches static data with ADR, and trims startup to what main uses. In the shared lowering, scalar globals are laid out ahead of arrays, small constants are no longer pinned to a register, and an outer loop's block parameter may share the inner loop's register. A hot loop that the shrinking moved across a 64-byte fetch block lost a fifth of its throughput on the eMAG, so a short innermost loop that would straddle one is padded to a 32- or 64-byte boundary. The ARM64 stage 1 compiler shrinks by 26%, and a self-compile runs 13% fewer instructions because machine liveness reads each operation once.
- "Shrink x86-64 code and align small loops" zeroes with XOR, leaves an unread call result in RAX, forms rodata, function and global-base addresses with a 32-bit MOV instead of MOVABS, tests byte masks with an 8-bit immediate, shares a function's epilogue between its returns, forms comparison results in place (a returned one straight in EAX), folds a load only compared into the CMP and a global's address into the access, settles a rotated loop's first test when its operands are known constants, and drops the empty global initializer. Equality comparisons now keep a literal in the immediate operand on every target. Because shorter code moves hot code, and on Zen 1 a 57-byte loop straddling a 64-byte fetch block ran at six sevenths of its speed and fib at nine tenths, loops and functions of up to 64 bytes are placed by address to stay within one block and longer loops start on a 32-byte boundary. The x86-64 stage 1 compiler shrinks by 10%.
- "Halve the compiler's peak memory" optimizes each function as soon as it is parsed and copies its live graph into one arena sized to fit, so the nodes the optimizer dropped are freed with the old arena. The frontend's per-function tables come from a scratch arena, a use record holds one tagged owner instead of five owner pointers, address fields move behind a pointer only address values carry, and
var_tloses its padding. The parser stops creating join blocks for expressions without a logical operator and stops keeping generated temporaries on the name lookup list, and SSA completion binds a single predecessor's value instead of declaring a block parameter, which leaves the stage 1 compiler about 4% smaller on every target. Stage 0 is built with-O2. A stage 0 self-compile on x86-64 peaks at 91 MB instead of 204 MB and runs 12% fewer instructions with the same host flags; on the eMAG it peaks at 88 MB. A function nothing calls is now optimized and verified too, so an optimizer failure there stops the compile. The throughput fixtures compile to the same bytes.
Verification: make bootstrap produces byte-identical stage 1 and stage 2 compilers, and make check passes, on arm, arm64, riscv and x64 at the tip. With dynamic linking, make check also passes on arm, arm64 and x64, and make check-sanitizer passes on x64. make check-style passes on every commit. Each of the first six follow-up commits passes make bootstrap and make check on x64, and the memory commit, which rewrites the libc allocator, also on ARM. The ARM64 and x86-64 code-size commits pass make bootstrap and make check on arm, arm64 (statically and dynamically linked), riscv and x64, and make check-sanitizer on x64, and a self-compile by the ARM64 stage 1 matches the previous stage 1's output on a frozen source. The memory commit passes the same set at the tip.
Performance against GCC -O1
shecc runs at its default optimization and GCC at -O1 -fwrapv, each benchmark pinned to one CPU of an otherwise idle host:
- x86-64: AMD Ryzen Threadripper 2990WX, Linux 6.8, GCC 14.2.
- AArch64: Ampere eMAG (32 cores, 3.0 GHz), Linux 6.8, GCC 13.3.
Throughput and code size were measured at b3ef55c; the last commit leaves the fixtures' generated code byte-identical. Compilation time was measured at 0301660, the tip.
Generated-code throughput from tests/native-throughput.py: GCC time divided by shecc time over nine rounds of eight runs, every checksum verified, so 100% means parity with GCC.
| Workload | x86-64 wall | x86-64 CPU | AArch64 wall | AArch64 CPU |
|---|---|---|---|---|
| rng | 100.0% | 100.0% | 99.8% | 100.0% |
| branch | 94.5% | 94.3% | 97.4% | 97.3% |
| pointer | 100.5% | 100.8% | 100.0% | 99.9% |
| recursion | 96.9% | 96.9% | 104.3% | 104.4% |
| calls | 100.0% | 100.0% | 95.6% | 95.0% |
| sort | 98.1% | 98.1% | 97.2% | 97.1% |
| indexed | 100.2% | 100.1% | 100.0% | 100.0% |
Compilation time from tests/compiler-throughput.py, median wall time including process startup, preprocessing and linking; shecc compiles its bundled libc with every program.
| Input | x86-64 shecc | x86-64 GCC | AArch64 shecc | AArch64 GCC |
|---|---|---|---|---|
| each throughput fixture | 9.0-9.3 ms | 33.3-37.3 ms | 18.3-18.8 ms | 72.3-82.7 ms |
| src/main.c (self-compile) | 0.52 s | 5.54 s | 1.18 s | 15.07 s |
On x86-64 shecc compiles 3.7-4.0 times faster than GCC on the fixtures and 10.6 times faster on its own source; on AArch64, 4.0-4.4 and 12.7 times. With --no-libc a fixture takes 1.5-2.3 ms on x86-64 and 2.3-3.1 ms on AArch64; GCC still links its C library there, so that is not a like-for-like ratio.
Code size in bytes. The shecc figure is its whole executable segment after the ELF headers, so it includes shecc's startup code and any read-only data; the GCC figure is .text, including the shared minimal start.S. At fa69209 the x86-64 shecc column was 191-573 bytes and the AArch64 one 292-560.
| Workload | x86-64 shecc | x86-64 GCC | AArch64 shecc | AArch64 GCC |
|---|---|---|---|---|
| rng | 101 | 101 | 100 | 84 |
| branch | 127 | 121 | 124 | 116 |
| pointer | 204 | 103 | 136 | 116 |
| recursion | 193 | 123 | 160 | 156 |
| calls | 159 | 105 | 144 | 136 |
| sort | 346 | 289 | 280 | 256 |
| indexed | 266 | 146 | 144 | 132 |
Against master on the same x86-64 host, generated code moves up from 30.6-80.7% of GCC wall throughput, and a self-compile takes less time than with the legacy pipeline (0.52 s against 0.70 s, each compiling its own source).
Summary by cubic
Replaces the legacy SSA middle end and register allocator with VIR, a value-oriented SSA IR with block parameters, ordered effects and a verifier. The parser builds VIR directly, and one shared lowering pipeline handles ABI, register allocation, spills, coalescing and edge copies for ARMv7, AArch64, RV32 and x86-64. Optimization runs by default (--no-opt disables it); native throughput reaches 95–104% of GCC -O1 on ARM64 and 80–102% on x86-64. Peak memory of a self-compile drops by half and generated code shrinks by up to 31%, with docs/vir.md documenting the IR.
- Fixes malloc chunk reuse, libc fseek/ftell errno checks, RISC-V calls beyond the 1 MiB JAL range, and ELF header serialization; rejects conflicting typedefs, invalid qualifier casts, out-of-range array sizes and runaway macro/
#ifnesting. make bootstrapyields byte-identical stage 1 and stage 2 compilers andmake checkpasses on all four targets, with native-throughput, compiler-throughput, sanitizer and fuzzing suites guarding the change.