Skip to content

Match or beat GCC -O1 on the throughput suite - #346

Merged
jserv merged 7 commits into
masterfrom
opt
Oct 17, 2026
Merged

jserv merged 7 commits into
masterfrom
opt

Conversation

@jserv

@jserv jserv commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

This brings shecc level with or ahead of GCC -O1 on most native throughput workloads on x86-64. The first commit adds VIR optimizations at O2: post-SSA integer and bitwise normalization with power-of-two division lowering, closed-form summarization of affine counted loops, and additive tail-recursion elimination. It also makes DCE's pointer scheduling cheaper, guided by uftrace. The second commit closes what remained in the generated code: literals are no longer held in registers from their definition, x86-64 tests a mask with TEST instead of AND plus compare, strength reduction reuses the subscript's address, if/else arms update a shared join register, and even-count pointer add-reductions are unrolled into two accumulators.

The inlining commit implements #285: direct calls to leaf functions defined earlier are cloned into the caller, up to 40 values for a static callee, and 6 values (16 inside a loop) for an extern one, within a 400-value budget per caller; linkage stands in for how many callers keep a copy, and a uniform limit measured larger and slower. Recursive calls stay calls. On a frozen self-compile it costs 3.9 percent more compiler instructions and 2.5 percent more code, and the generated compiler runs 2.6 percent fewer instructions; the calls workload drops from 40M to 32M instructions. --no-inline was dropped since --no-opt already disables it. Enabling it exposed an x86-64 frame-mirror bug, where a store through an override register recorded the wrong register as mirroring the slot; that fix and its regression test are a separate commit.

Two commits cut the compiler's own memory, guided by uftrace and perf, which showed a flat CPU profile and a quarter of a self-compile spent in page faults. The parser's generated temporaries, over a hundred thousand 192-byte var_t that stayed allocated to the end, now live in an arena rewound after each function, and a token's location holds one pointer to a shared file record instead of two filename pointers, shrinking tokens from 56 to 48 bytes. A self-compile peaks at 58.9 MB instead of 78.8 MB, with 24 percent fewer page faults and slightly fewer instructions; output is byte-identical, and a sanitizer build that frees every temporary block after each function found nothing on 394 programs. error_at() also now quotes the physical file under #line.

Throughput against GCC 14.2 -O1 (-fwrapv) on a Threadripper 2990WX, nine rotating rounds of eight executions pinned to CPU 63, wall time: pointer 1.41x, recursion 1.18x, sort 1.11x, indexed 1.00x, calls 1.00x, branch 0.97x, and rng 150x because its loop is summarized away.

Verified with make bootstrap (stage 1/stage 2 byte identity) on arm, arm64, riscv and x64, make check, make check-sanitizer and make check-all-targets, all passing. Refactors were checked by comparing generated code for 351 test programs byte for byte against the compiler before each change.

Left out: a Linux-only huge-page heap setup, which cut a self-compile by 8 percent but is not portable, and a loop-depth weighting for register pinning, which miscompiled pp_stringify in the self-hosted compiler.

Closes #285

cubic-dev-ai[bot]

This comment was marked as resolved.

cubic-dev-ai[bot]

This comment was marked as resolved.

jserv added 7 commits October 17, 2026 22:26
At O2 the VIR pipeline gains four transformations and a normalization
pass:

- Post-SSA normalization reapplies the integer, bitwise and unary
  identities the builders use once parameter simplification and CSE
  expose equal operands, and lowers multiplication, unsigned division
  and remainder by a power of two to shifts and masks. Signed quotients
  are biased before the arithmetic shift so they still truncate toward
  zero. SCCP reruns when normalization changes the graph.
- Affine-loop summarization replaces a single-entry, effect-free loop
  with a constant trip count and a recurrence a = A*a + B*i + C by its
  closed form, computed by repeated squaring modulo the type width.
- Direct and additive tail recursion becomes a loop with an SSA
  accumulator; functions with stack objects, trapping operations or
  effects other than self calls are left alone.
- Calls to small single-block pure leaves defined earlier in the file
  are cloned at the call site. --no-inline keeps the calls, so ABI and
  callee-graph tests still exercise them.

DCE also does less work. uftrace showed pointer scheduling rescanning
effects and use lists in every block; it now keeps a forward barrier
cursor, skips blocks where nothing can move, and repairs positions only
in blocks it changed. A self-compile on x86-64 takes 3.6 percent less
time, with byte-identical output.

On the native throughput workloads against GCC -O1, rng becomes 148.8x
(its whole loop is summarized), recursion 1.19x, and the others stay
within a few percent of their previous throughput. New tests cover the
normalization identities, strength reduction, affine recurrences, tail
recursion and inlining, including narrow types and wrapping arithmetic.
Comparing shecc's hot loops with GCC's on the native throughput
workloads showed that the gaps came from register allocation and a few
missed instruction forms, not from the IR:

- Lowering claimed a register for every literal at its definition and
  held it until the last use, so three constants in sort's outer loop
  pushed live values out to their frame slots. An unpinned literal is
  now built where it is read, as read() already did after a flush.
- x86-64 emits a single TEST for "if ((x & m) == 0)"; it previously
  computed the AND into a register before comparing.
- Strength reduction derived a descending loop's start pointer from a
  fresh multiply. It now scales the index the way normalization rewrote
  the subscript and reuses the subscript's own address, offset by the
  bias.
- A value computed in the block whose edge passes it to an if/else join
  may share the join parameter's register, so each arm updates the
  result in place. Literals are excluded: pinning an entry literal into
  a loop-carried register this way miscompiled a counted loop.
- Counted pointer add-reductions with an even, compile-time trip count
  are unrolled by two into a second accumulator, halving the loop's
  dependency chain. The partial sums are combined ahead of every effect
  in the exit block.

Local CSE also sizes each block's table from its order counter instead
of counting the block's values twice.

Cycles against GCC -O1 on a Zen 1 host, averaged over four code
offsets: pointer goes from 1.00x to 1.48x, branch from 0.93x to 1.01x,
sort from 1.00x to 1.05x, and recursion stays at 1.27x; calls and
indexed remain bound by the same dependency chain as GCC's code. A
self-compile runs 0.4 percent fewer instructions. Generated code changes
only through the transformations above.
When a load is elided into src0_override, the store writes the
override register, yet the frame mirror recorded the IR source
operand, which never received the value. A later reload of that slot
was then satisfied from the wrong register. Record, and test for
redundancy against, the register the store actually writes.

The new vir-store-override fixture returned 1 before this change.
VIR now inlines direct calls to leaf functions defined earlier in the
translation unit. The callee's reachable blocks are cloned in reverse
post-order into the caller, the call block is split at the call, and
returns jump to a rest block whose parameter carries the result.

A static callee is copied when it has at most 40 values; an extern one
only at 6 values, or 16 when the call sits on a cycle of the caller's
CFG. Any callee whose calls are all inlined leaves the image, but
linkage predicts how many callers keep it anyway: on a self-compile, a
uniform limit and dropping the static one both made the generated
compiler larger and slower. A caller absorbs at most 400 cloned
values. Callees that take their address, return an aggregate, call
out, or touch the stack are left alone, so recursion stays a call.

On a frozen self-compile this costs 3.9 percent more compiler
instructions and 2.5 percent more code, and the generated compiler
runs 2.6 percent fewer instructions; the calls workload drops from 40M
to 32M instructions. --no-inline is removed, since --no-opt already
disables it. Fixtures that check a call survives now define their
helper after the caller, and unit tests check inlined results by value.

Closes #285
The README accumulated dated measurement sections and a stale ARM64
table. Replace them with the newest x86-64 run, a command to
reproduce it, and how to compare two compilers.
A token carried two filename pointers in its location, the physical
path and the logical name #line may change. Every token of a file now
points at one shared record holding both, and #line points the tokens
after it at a new record, so a token shrinks from 56 to 48 bytes on a
64-bit host.

error_at() now finds the quoted source by the physical path, which is
where the buffer is stored and what the location's offset indexes, and
still reports the logical name. Before, a #line naming another file
quoted that file's text, or none.
The parser's generated temporaries, over a hundred thousand var_t of
192 bytes on a self-compile, stayed in BLOCK_ARENA until the end,
though nothing reads them once a function's graph is built. Allocate
those of a function in TEMP_VAR_ARENA instead and rewind it after the
function; temporaries at file scope still serve the global
initializer and stay. A temporary is now made by a require_temp_*()
helper, which also keeps it off the scope's lookup list from the start
instead of pushing and popping it.

Together with the smaller tokens, a self-compile peaks at 58.9 MB
instead of 78.8 MB, with 24 percent fewer page faults and slightly
fewer instructions; output is byte-identical.
@jserv
jserv merged commit 90c6e68 into master Oct 17, 2026
55 checks passed
@jserv
jserv deleted the opt branch October 17, 2026 16:16
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.

Implement function inlining optimization in SSA

1 participant