Skip to content

Replace the SSA middle end with VIR - #344

Open
jserv wants to merge 19 commits into
masterfrom
new-ir
Open

jserv wants to merge 19 commits into
masterfrom
new-ir

Conversation

@jserv

@jserv jserv commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

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, #if and 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_t loses 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/#if nesting.
  • make bootstrap yields byte-identical stage 1 and stage 2 compilers and make check passes on all four targets, with native-throughput, compiler-throughput, sanitizer and fuzzing suites guarding the change.

Written for commit 0301660. Summary will update on new commits.

View guided diff Turn on auto-fix

cubic-dev-ai[bot]

This comment was marked as resolved.

Comment thread lib/c.c Outdated
cubic-dev-ai[bot]

This comment was marked as resolved.

@jserv
jserv force-pushed the new-ir branch 2 times, most recently from fa8a7a0 to 2e6eebb Compare October 7, 2026 10:50
cubic-dev-ai[bot]

This comment was marked as resolved.

Comment thread docs/vir.md

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is it possible to provide a diagram illustrating VIR's design?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes, I plan to write a paper on VIR later. For now, I am revising the VIR documentation to provide a high-level overview.

cubic-dev-ai[bot]

This comment was marked as resolved.

@jserv
jserv force-pushed the new-ir branch 2 times, most recently from 492a56c to 799fb42 Compare October 11, 2026 16:10
jserv added 4 commits October 12, 2026 07:08
malloc() compared a freed chunk's recorded size, which includes the
chunk header, against the requested size, which does not, so a request
in the last header's worth of a freed chunk got a chunk too small for it
and wrote into the neighboring mapping. malloc also checked the raw
mmap2 result for -1, but the system call reports failure as a negative
errno, so a failed mapping was used as a chunk header. Freeing the last
chunk now resets the allocator tail even when it has no predecessor.

fseek() and ftell() test the raw lseek result for any negative errno,
and RV32 passes the high offset word and reads the 64-bit position that
_llseek reports.
The RV32 self-hosted image is already past 1 MiB of code, and direct
calls, including the startup stub's call to main, used a JAL, which
reaches only 1 MiB. Any growth that moved a callee past that limit
aborted the stage 1 build. Call through AUIPC and JALR, as calls into
the PLT already do, and count the extra instruction in the size
estimates.
Compute load segment layout once and route ELF32 and ELF64 program and
file headers through shared serializers, and emit ELF32 section headers
from initialized structs. Byte layout and offsets are unchanged.
Introduce VIR, the value-oriented IR the compiler will be built on:
typed values with reverse uses, block parameters instead of phi
instructions, explicit ordered effects for memory and calls, and
fixed-prototype call signatures. A verifier checks dominance and use-def
consistency, dumps are deterministic, and SCCP, DCE, local CSE, GVN and
LICM run on it.

Nothing in the compiler uses it yet. Keeping the core standalone lets
its invariants be tested by unit tests and a structural fuzzer before a
frontend depends on them.
Replace the legacy SSA middle end, its register allocator and its
standalone SCCP pass. The parser builds VIR directly through a
sealed-block frontend, VIR owns source values, typed memory effects,
control flow, calls and target-independent optimization, and a shared
machine lowering turns it into allocated machine IR that all four
backends emit. That lowering handles ABI arguments and results, paired
32-bit registers, spill homes, coalescing, critical-edge copies,
variadic saves, block layout and persistent callee-saved allocation.

The driver accepts several input files as separate translation units,
whose record definitions must agree in pointer constness and
volatility, hash maps own their keys, and the lexer, preprocessor and
parser share their repeated paths. A missing source file is reported
as an error instead of aborting, an integer stored into a wider object
is widened, a shift takes its type from the left operand alone, and a
function-like macro named at the end of an expansion is invoked.

Optimization runs by default, and --no-opt skips the optional VIR
passes for debugging and for isolating an optimizer fault. Graded
levels would ask a question with one useful answer: a lighter schedule
generates the same code on nearly every program and compiles no
faster, because the unoptimized graph leaves more values to lower.

VIR regression suites, a corpus comparison with and without
optimization and a host-compiler fuzzer guard the new pipeline. They
run target binaries, the stage 2 compiler included, through the
emulator like the rest of the suite, and the cross-target CI lanes get
an hour, since their stage 2 suites run the self-hosted compiler under
qemu for every case.
jserv added 2 commits October 12, 2026 11:04
Reduce repeated address calculations with descending-loop strength
reduction, reuse loads along effect-free predecessor chains, and keep
machine values resident across safe block boundaries. Expand the ARM64
register bank, save only the callee-saved registers a function uses,
fold branches, immediates, selects and indexed memory operations, and
repeat a small loop test at its back edge. On x86-64, fold a memory
read into the arithmetic that consumes it, fix compare and AND branch
fusion to respect both successors' live-outs and invert an edge whose
true successor falls through. Parser expression and qualifier handling
is shared along the way, and a constant shift whose count reaches the
operand's width is rejected for 64-bit operands as well as 32-bit
ones.

The throughput gate requires 80% of GCC -O1 in both wall and child CPU
time, and all seven workloads meet it: ARM64 81.3-92.5%, x86-64
80.3-101.8%.
The verifier counted value and block IDs by rescanning the function,
the SSA builder searched one flat binding list on every read, block
membership walked the block list, SCCP located each edge by scanning
all of them, and constant replacement repeated a CFG walk for every
use it checked. Register pinning walked forward from each writer over
every block it reached, rescanning the other value's uses per block,
and reachability rescanned every function once per newly reached one.
Each of these grew quadratically with function size.

Keep a block table per function, track seen value IDs, index SSA
bindings by block, number SCCP edges once, walk reachability once per
replacement, answer phi affinity from the dominator tree, find
interference by walking backward from the survivor's uses, which its
live range bounds, and drive reachability from a worklist. Compiling
src/main.c takes 24 times fewer instructions by default and 27 times
fewer with --no-opt.

The SSA builder also made nearly a million small allocations per
self-compile, which the self-hosted compiler serves from shecc's own
libc, whose malloc scans its whole free list. Bindings, block states
and edge scratch now come from an arena that grows geometrically, and
parameter edits keep their edge records inline, so the self-hosted
compiler compiles itself in 1.9 seconds instead of 7.0 on x86-64.
Generated code is byte-identical on every target.

Add a script that compares end-to-end compilation time against GCC.
cubic-dev-ai[bot]

This comment was marked as resolved.

jserv added 9 commits October 13, 2026 06:43
Branches and jumps now take their shortest form once the layout is
known: x86-64 relaxes rel32 to rel8 and drops jumps to the next
instruction, RISC-V picks among BEQZ/BNEZ/JAL forms by iterating the
layout to a fixed point, and ARM and ARM64 omit a jump into the block
laid out next. Leaf functions with no frame skip the frame setup on
x86-64 and ARM64, x86-64 addresses frame slots through a biased RBP
with 8-bit displacements, and small constants and immediates use the
shorter encodings. Loop literals in ARM64 leaves stay in caller-saved
registers.

The shared lowerer pins a value whose live range crosses no call to a
caller-saved register, or leaves a parameter in the register it
arrives in, and orders edge copies so that overlapping memory homes
no longer force every argument through the snapshot area. A load
that reads back the value the same block just stored there is
forwarded. RISC-V gains s6-s11 as lanes and saves exactly the
callee-saved registers a function names, since the lowerer stages
through the last of them.

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.
Give VIR one reference page for its name, invariants, compiler
options, optimization schedule, shared machine lowering, validation
commands and implementation map, and point the README at it. The page
also records why shecc moves to VIR: what block parameters, ordered
effects and fixed call signatures buy over classic SSA, and what the
move costs in compile time. The README now describes the VIR pipeline
and its options instead of the retired two-pass SSA design.
The allocated instructions the shared lowerer produces were still
named after the retired two-pass SSA design, defined among the parser
types and built by helpers spread over globals.c, peephole.c and a
one-pass file. They are VIR's machine layer, so they now live with the
lowerer: the operation type and its prototypes in vir-lower.h, and its
creation, flattening, register dataflow and frame queries in
vir-lower.c. Names follow: a machine operation is a vir_mop_t, a block
keeps its mops, and the flat emission order is MOP_FLAT.

The backends share what each had copied: one flattening loop, one
word counter for the size estimate, and one frameless-leaf test.
ARM's fall-through marks move into its own backend, and peephole.c
keeps only rewrites.

Nothing changes in what any target generates: the stage 0 compilers
before and after produce byte-identical output for every test program
and for shecc's own source, with and without optimization.
Many VIR test scripts and fixtures were written during the migration
and now repeat what other tests cover: arm64 fixtures that vary one
qualifier or type of the same case, statistics and large-CFG scripts
that rerun the baseline, and driver cases that duplicate others
exactly. Remove them, fold the ARM verifier check into the stage 0
harness, and name the remaining fallback fixtures after what they
exercise.

The optimization baseline now compares the stage 0 and stage 2 output
checksums directly, and the ABI scripts drop argument counts that the
larger cases already cover.
Every function's VIR graph stayed allocated until the compiler exited,
and the SSA builder, the preprocessed token arena and the allocator's
free list all kept memory that was no longer needed. Release each graph
right after it is lowered, and graphs nothing references before
lowering starts; free the SSA builder once a function is finished;
copy the parser's token stream into a fresh arena so the preprocessor's
dead tokens can be dropped; and remove a block parameter in place
instead of reallocating every incoming edge.

The C library's free list becomes size-segregated bins, so malloc no
longer walks every freed chunk, and chunks of 512 KiB or more go back
to the kernel. A self-hosted compiler relies on it, and the single list
made it slower once more memory was freed.

Peak RSS of a stage 0 self-compile drops from 332 to 194 MB, and the
stage 2 compiler's from 403 to 221 MB.
A second file-scope typedef of the same name was ignored even when it
named a different type, so later uses silently kept the first, and the
qualifier list after a typedef name in a cast accepted restrict on a
non-pointer type. Array and record sizes are ints, and an extent past
INT_MAX wrapped: int a[1073741825] got four bytes and writes through it
clobbered the neighbouring globals. A record member of incomplete type
crashed the compiler when a struct contained itself. Diagnose each.

Deeply nested macro calls, #if parentheses and conditional operator
chains recursed without bound and exhausted the machine stack. Bound
macro expansion at 256 levels and count the other two against the
existing expression depth limit, so each reports an error.
Several checks during VIR construction and lowering scanned whole
lists: whether a value is still linked into its block, whether an
address root conflicts with an earlier one, which SSA binding holds a
variable, and which phi feeders a pinned value affects. Choosing which
values get a fixed register also rescanned every value on each round
for the best score. Each value now records its own membership, address
roots and SSA bindings are hashed, the pin candidates are sorted once
by score with ties kept in ID order, and the pin loop queues only the
values a new pin can affect.

Frontend completion no longer verifies each graph, since every graph
that is dumped or lowered is verified after optimization anyway.

Output is byte-identical. A stage 0 self-compile runs 13 percent fewer
instructions, and 25 percent fewer with optimization off.
Drop functions, macros and a struct field that nothing uses any more,
along with the x86-64 trace flag that was never set. Internal errors
for calls to undefined functions now go through fatal() with the
function's name, and the ARM and RISC-V encoders shift into bit 31 in
unsigned arithmetic instead of overflowing a signed int.

Every target generates byte-identical code.
A loop-carried recurrence such as a = a * k + i + C added the constant
to the chain on every iteration. A reassociation pass now moves the
constant onto the operand that is ready on block entry, taking one
addition off the chain.

Values competing for a fixed register were ranked by how many blocks
used them, so an address that is cheap to rebuild could take the
register from the value an inner loop runs on. A use outside the
defining block now counts eight times more for every loop around it,
up to four levels, unless the value can be rebuilt as a literal.

Against gcc -O1 on x86-64, the rng benchmark goes from 0.81 to 1.00 of
its speed and sort from 0.94 to 0.99; the others are unchanged.
cubic-dev-ai[bot]

This comment was marked as resolved.

@jserv
jserv force-pushed the new-ir branch 3 times, most recently from 55df91f to 783ac3a Compare October 14, 2026 07:51
jserv added 2 commits October 14, 2026 17:45
Generated ARM64 code still spent instructions where a shorter form
exists. Comparisons now take an immediate operand, fuse into CBZ and
CBNZ, or become TST after an AND. Logical operations take any bitmask
immediate, and add, sub and the logical operations take shifted
register operands, so a separate shift disappears. Adds take
immediates shifted by 12, a multiplication by 2^k+1 becomes a shifted
add, a product added at the same width becomes MADD, and literals and
addresses sink next to their single use so these folds can see them.
Stores of zero use the zero register, constant extensions fold at
compile time, and a load followed by a same-width sign extension
becomes one extending load. An int extended and shifted left is one
SBFIZ or UBFIZ, and a load followed by a small step of its base
register writes the base back itself.

Accesses through the address of a global or a frame slot take its
offset directly, an int index extends inside the access, and a load
lands in the register of the copy after it. Small constants and
global addresses used only by such folds are no longer pinned to a
register, scalar globals are laid out ahead of arrays so that a single
offset reaches them, an outer loop's block parameter may share its
register with the inner loop's, and definitions that the folds leave
unread are dropped. The last saved pair of a frame of up to 480 bytes
also allocates the locals, saving a SUB and an ADD.

Blocks jumped to from their only predecessor are merged into it, a
value is computed directly into the register its copy or return wants,
additions whose result is only zero extended use W registers, and
entry tests whose outcome is known on the incoming edge, from literals
or from two offsets of one pointer, are settled there. Large frame
offsets use a shifted add, 32-bit constants with one 0xffff halfword
take a single MOVN, and static images reach rodata, data and functions
with ADR while they stay under 1 MiB. Startup loads argc and argv only
when main takes them and emits the syscall helper only when it is
used, and early returns share an epilogue of at least four words. A
function that starts by testing its arguments and returning one of
them, as fib does, runs that test and return ahead of its prologue.

Shorter code moved a hot loop across a 64-byte fetch block on the
eMAG, which cost a fifth of its throughput. An innermost call-free
loop of up to 64 bytes that would straddle one is now padded to a 32-
or 64-byte boundary, measured by address because code starts after the
ELF headers. Constants hoist only out of real loop bodies.

The frozen stage 1 compiler shrinks by 26%, the throughput fixtures
from 2328 to 1088 bytes against 996 for GCC -O1, and throughput on the
eMAG rises to 95-104% of GCC -O1. A self-compile runs 13% fewer
instructions, as machine liveness reads each operation once.
Generated x86-64 code was 70% larger than GCC -O1 on the throughput
fixtures. Zero is loaded with XOR when no flags are live across it, a
call result that nothing reads stays in RAX, addresses of rodata and
functions and the global base use a 32-bit MOV instead of MOVABS, and
a mask that fits a byte is tested with an 8-bit immediate. Later
returns branch to a function's first epilogue when that is at least
seven bytes long. A comparison sets its byte result directly where
that needs no prefix, and one that is only returned forms it in EAX.
A load whose only use is a comparison becomes the comparison's memory
operand, an access through a global's address that nothing else reads
addresses the global itself, and the entry stub and an empty global
initializer shrink to what main needs. A rotated loop keeps the
constants its single entry left in registers, so a first test of two
known constants becomes a jump. Literals now move to the immediate
operand of an equality comparison as of other commutative operations.

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 that would straddle one
are padded to a 32- or 64-byte boundary by address, and longer loops
start on a 32-byte one, within 32 bytes of padding.

The frozen stage 1 compiler shrinks by 10% and the fixtures from 1675
to 1396 bytes against 988 for GCC -O1, with throughput unchanged.
Every function's VIR graph kept all the memory it was built with until
it was lowered, including values the optimizer had already removed and
the frontend's per-function bookkeeping. Each function is now optimized
as soon as it is parsed, and its live graph is copied into one arena
sized to fit, so the dead nodes go with the old arena. The frontend's
block and source tables come from a scratch arena reset after every
function. A use record holds one tagged owner instead of five owner
pointers, a value keeps its address fields behind a pointer only
address values carry, and var_t's fields are ordered to drop padding.

Parsing also did work nothing used: two join blocks were created for
every expression, though only those with a logical operator need them,
and generated temporaries stayed on their scope's name lookup list.
SSA completion declared a block parameter in every block it reached,
even one with a single predecessor, where the predecessor's value is
the answer; it now follows such chains and binds that value instead.
Those parameters often outlived optimization, so the stage 1 compiler
shrinks by about 4 percent on every target. The native throughput
fixtures compile to the same bytes.

Because every function is optimized and verified when it is parsed, an
optimizer failure in a function nothing calls now stops the compile.

A stage 0 self-compile on x86-64 peaks at 91 MB instead of 204 MB and
runs 12 percent fewer instructions. Stage 0 is now built with -O2,
which takes off a further quarter.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

2 issues found across 16 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/vir-frontend.c">

<violation number="1">
P2: This walk can loop forever on a disconnected CFG cycle whose blocks each have one predecessor and no binding for `var`, such as unreachable `dead: if (c) goto dead;` followed by a local read. `native_finish` reaches this before removing unreachable blocks, so detect and resolve predecessor cycles instead of following them indefinitely.</violation>
</file>

<file name="src/vir.c">

<violation number="1">
P2: `vir_function_addr` initializes only `addr->name`; the `kind`, `slot`, `size`, `alignment`, and `root_next` fields of the new `vir_address_t` are left to whatever `vir_arena_alloc` hands back. `vir_verify_block` requires exactly `addr->kind == VIR_ADDRESS_NONE`, `slot == 0`, `size == 0`, and `alignment == 0` for `FUNC_ADDR` values, so every function-address value now depends on the arena delivering zero-filled memory. The same change's `new_addr` explicitly sets every field, and `vir_root_index_insert` memsets its freshly allocated table — evidence that raw arena allocations are not guaranteed zeroed. Initialize `kind` (and the zero fields) explicitly so correctness does not hinge on the arena's clearing behavior.</violation>
</file>

Tip: Get a Slack DM when cubic finishes reviewing your PRs. Connect Slack

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread src/vir-frontend.c
@@ -0,0 +1,1309 @@
#include "vir-frontend.h"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: This walk can loop forever on a disconnected CFG cycle whose blocks each have one predecessor and no binding for var, such as unreachable dead: if (c) goto dead; followed by a local read. native_finish reaches this before removing unreachable blocks, so detect and resolve predecessor cycles instead of following them indefinitely.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/vir-frontend.c, line 977:

<comment>This walk can loop forever on a disconnected CFG cycle whose blocks each have one predecessor and no binding for `var`, such as unreachable `dead: if (c) goto dead;` followed by a local read. `native_finish` reaches this before removing unreachable blocks, so detect and resolve predecessor cycles instead of following them indefinitely.</comment>

<file context>
@@ -934,6 +958,31 @@ static vir_value_t *native_incoming(native_function_t *ctx,
+    basic_block_t *top = cursor;
+    while (top != ctx->source->bbs && top->prev_idx == 1 && top->prev[0].bb &&
+           !vir_ssa_read(ctx->ssa, native_block(ctx, top)->cursor->vir, id))
+        top = top->prev[0].bb;
+    vir_value_t *value = native_incoming(ctx, top, var);
+    for (basic_block_t *bb = cursor; bb != top; bb = bb->prev[0].bb)
</file context>

Comment thread src/vir.c
@@ -0,0 +1,6789 @@
#include "vir.h"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: vir_function_addr initializes only addr->name; the kind, slot, size, alignment, and root_next fields of the new vir_address_t are left to whatever vir_arena_alloc hands back. vir_verify_block requires exactly addr->kind == VIR_ADDRESS_NONE, slot == 0, size == 0, and alignment == 0 for FUNC_ADDR values, so every function-address value now depends on the arena delivering zero-filled memory. The same change's new_addr explicitly sets every field, and vir_root_index_insert memsets its freshly allocated table — evidence that raw arena allocations are not guaranteed zeroed. Initialize kind (and the zero fields) explicitly so correctness does not hinge on the arena's clearing behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/vir.c, line 1158:

<comment>`vir_function_addr` initializes only `addr->name`; the `kind`, `slot`, `size`, `alignment`, and `root_next` fields of the new `vir_address_t` are left to whatever `vir_arena_alloc` hands back. `vir_verify_block` requires exactly `addr->kind == VIR_ADDRESS_NONE`, `slot == 0`, `size == 0`, and `alignment == 0` for `FUNC_ADDR` values, so every function-address value now depends on the arena delivering zero-filled memory. The same change's `new_addr` explicitly sets every field, and `vir_root_index_insert` memsets its freshly allocated table — evidence that raw arena allocations are not guaranteed zeroed. Initialize `kind` (and the zero fields) explicitly so correctness does not hinge on the arena's clearing behavior.</comment>

<file context>
@@ -727,17 +1142,20 @@ static vir_value_t *new_addr(vir_function_t *func,
+    addr->size = size;
+    addr->alignment = alignment;
+    addr->name = copy;
+    value->addr = addr;
     vir_root_index_insert(func, value);
     return value;
</file context>

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.

3 participants