Register deprivation: spills and runtime under forced register scarcity

Intel Xeon E-2236 @ 3.40GHz · gcc 15.2.0 · 2026-07-13

We compiled nine small kernels while progressively reserving registers with gcc -ffixed-<reg>, then measured spills and runtime on one machine. Reserving registers reliably increases spills, but the static spill count is a weak predictor of the runtime cost, and one kernel gains 23 spills with no slowdown at all.

Code is at https://github.com/rjpower/spillbench; the numbers behind every figure below are in results.json.

TL;DR

Setup

We test three hypotheses:

We reserve registers with gcc -ffixed-<reg>, which removes a register from the allocator's pool for the whole compilation. We recompile each kernel once per budget, adding one reservation each pass. GP kernels give up r15 r14 r13 r12 rbp rbx r11 r10 r9 r8 in that order (15 allocatable registers down to 5; rsp is never allocatable). XMM kernels give up xmm15 … xmm4 (16 down to 4). We never reserve the ABI registers (rax rcx rdx rsi rdi, xmm0–3), which argument passing and instructions like mul and variable shifts require.

Six integer kernels exercise the GP file, three floating-point kernels exercise the XMM file. Each is a single C function with a known-answer test.

KernelFileWhat it isCorrectness check
ChaCha20GP20-round stream cipher block, 16×32-bit stateRFC 8439 §2.3.2 keystream
SHA-256GPcompression function, 8 vars + 64-word scheduleSHA-256("abc")
SipHash-2-4GPkeyed hash, 4×64-bit ARXreference vector (15-byte msg)
Integer matmulGP4×4 register-blocked int64 micro-kernelvs. naive triple loop
LZ77 compressGPhash-chain match findercompress→decompress round-trip
QuicksortGPin-place recursive sort of int32output is sorted
Double matmulXMM4×4 register-blocked f64 micro-kernelvs. naive triple loop
FIR filterXMM16-tap filter, 8 output lanesvs. naive tap sum
MandelbrotXMMescape-time, few live doubles per pixelescape counts at known points

For the spill metric, we disassemble the hot function (objdump) and count instructions that reference a fixed stack slot (…(%rsp)), excluding indexed array access. This is a static proxy for spill/reload traffic. We do not have dynamic spill counts: hardware performance counters are disabled in this environment (perf_event_paranoid=4).

For timing, a Rust harness dlopens each build, runs its known-answer test, and times the whole workload with a monotonic clock, keeping the minimum over 15 repetitions on one pinned core. Each kernel's workload is sized to ~100 ms. Machine: Intel Xeon E-2236 @ 3.40 GHz, gcc 15.2.0, -O2 -fPIC -shared.

Every build passes its known-answer test, and every kernel returns the same checksum at all budgets. Reservation is a pure allocation constraint here; it never changed a result.

Result 1: removing registers slows most kernels, but not all

Eight of nine kernels slow down as registers are removed; SipHash-2-4 does not.

Runtime vs registers reserved, integer and float kernels
Figure 1. Runtime vs registers reserved, integer and float kernels
Spill count vs registers reserved
Figure 2. Spill count vs registers reserved
Slowdown at the tightest register budget
Figure 3. Slowdown at the tightest register budget
KernelFileRuntime full→min (ms)SlowdownSpills full→min
FIR filterXMM135.5 → 238.9+76%8 → 43
Integer matmulGP140.5 → 199.7+42%60 → 82
Double matmulXMM131.5 → 176.4+34%66 → 100
SHA-256GP89.4 → 119.0+33%8 → 80
QuicksortGP134.2 → 171.7+28%4 → 49
ChaCha20GP88.5 → 110.0+24%55 → 121
LZ77 compressGP123.2 → 146.8+19%39 → 115
MandelbrotXMM101.6 → 116.2+14%0 → 7
SipHash-2-4GP79.4 → 78.1−2%0 → 23

Spill counts rise for all nine kernels. Runtime rises for eight. H1 holds in direction but not in shape: neither curve is cleanly monotonic in the budget. Static spills drop when a register is removed in a few cases (ChaCha20 and Quicksort each have one such step), and the runtime curves have local dips within measurement noise (LZ77 has 5 steps that decrease by more than 0.5 ms as budget falls, SipHash 3, Quicksort 3). The allocator is not monotone, and the runtime signal is noisy at the ~1% level even with minimum-of-15 timing.

Integer matmul starts at 60 spills with a full register file: its 4×4 block holds 16 int64 accumulators, already more than the 15 allocatable GP registers, so it spills before we take anything away.

Result 2: the spill count does not predict the slowdown

The number of added spills explains little of the slowdown.

Added spills vs slowdown, all kernel-budget points
Figure 4. Added spills vs slowdown, all kernel-budget points

Across all 105 (kernel, budget) points, Pearson r between added spills and slowdown is 0.55. The per-kernel cost of a spill varies by about 30×:

KernelAdded spills at minSlowdownSlowdown per added spill
FIR filter+35+76%2.18 %/spill
Mandelbrot+7+14%2.05 %/spill
Integer matmul+22+42%1.92 %/spill
Double matmul+34+34%1.00 %/spill
Quicksort+45+28%0.62 %/spill
SHA-256+72+33%0.46 %/spill
ChaCha20+66+24%0.37 %/spill
LZ77 compress+76+19%0.25 %/spill
SipHash-2-4+23−2%≈0 %/spill

Integer matmul reaches +42% with only 22 added spills; LZ77 adds 76 spills for +19%. SipHash adds 23 spills with no measurable slowdown. We think the explanation is that the stack lives in L1, so a spill that is not on the critical dependency path is nearly free, while a spill inside a tight recurrence (the ARX chain, the accumulator update) serializes on the reload latency. The static count does not distinguish the two.

Result 3: the slowdown is specific to the register file

Reserving the register file a kernel does not use is much cheaper than reserving the file it does.

Reserving the wrong register file barely moves runtime
Figure 5. Reserving the wrong register file barely moves runtime

We reran two kernels against the opposite register file. Reserving GP registers on the double-precision matmul is flat (131.9 → 131.1 ms across 0→10 reserved); reserving XMM on the same kernel costs +34%. This is the clean case: the floating-point work is XMM-bound and has GP registers to spare.

SHA-256 is the messy case. Reserving XMM costs +5.6%, against +33% for GP. But XMM reservation is not inert: SHA-256's stack-spill count rises 8→65. Its hot function contains 155 XMM-referencing instructions at full budget, because GCC auto-vectorizes the message-schedule byte-swapping and word computation with SSE at -O2. Reserving XMM forces that work back onto GP registers and the stack, which shows up as stack spills but barely as runtime, because the schedule is not the critical path. So H2 holds for runtime, with the caveat that "the file the kernel uses" is not clean: GCC uses XMM opportunistically inside integer code.

What surprised us

Historical note: what would this have cost on x86-32?

This section is extrapolation, not measurement. Read it as a back-of-envelope, not a result.

32-bit x86 had eight GP registers, but esp is never allocatable and ebp is usually the frame pointer, so real code had six or seven to work with, against fifteen here. Our sweep has points at exactly those budgets, so we can read the cost off the curve instead of guessing:

Usable GP registersMean, 6 integer kernels32-bit-native kernelsHungriest (int matmul / SHA-256)
7 (≈ x86-32, frame ptr omitted)+13%+16%+27% / +14%
6 (≈ x86-32, frame ptr kept)+18%+15%+32% / +18%
5 (tighter than x86-32 ever was)+24%+26%+42% / +33%

On this machine, going from 15 to 6–7 GP registers costs about 15% on a typical kernel and about 30% on the most register-hungry integer code. The popular "~30%" figure is defensible for the hungry tail, but it is roughly double the average kernel.

Two things make this an under-estimate of the real 1990s cost. First, a 64-bit value occupies a register pair on x86-32, so the int64-heavy kernels (the matmul, SipHash's lanes) would have felt pressure our -ffixed count does not model. Second, and larger: the reason spills are cheap here is that the stack lives in L1 and out-of-order execution overlaps the reload with other work. Period hardware could not do that overlap.

The latency gap itself was, if anything, smaller then. Approximate load-use latency for a first-level-cache hit, in core clocks:

CPU (year)GP registerL1 hitnotes
80386 (1985)0no on-chip cache; mov reg,[mem] ≈ 4 clk to external SRAM, DRAM adds wait states
80486 (1989)0~1 clk8 KB unified, write-through; DRAM miss ~10–20 clk
Pentium (1993)0~1 clkin-order dual pipe; off-chip L2 ~4–8 clk
Pentium II/III (1997–99)0~3 clkout-of-order (P6); spills start to be hideable
Xeon E-2236 (this box)0~4–5 clk~220-entry reorder window hides the latency

A 486 or original Pentium reload that hit L1 cost about one cycle, fewer than the four or five it costs on this Xeon. The problem was not the latency; it was that a single scalar (486) or dual in-order (Pentium) pipeline had no reorder window to fill while the load resolved, so a dependent reload stalled the pipe for its full latency and even an independent one consumed a pipe slot. On this Xeon the same reload issues into an otherwise-idle slot and overlaps unrelated work, which is why our spill count only weakly predicts runtime (r = 0.55). On an in-order 486 that correlation would be tighter and the cost per spill both higher and more uniform. Out-of-order execution, which arrived mid-x86-32-era with the Pentium Pro, is what turned a spill from a stall into an often-free move.

One thing cuts the other way, and it is why eight-register x86 aged better than an eight-register load/store RISC would have: x86 is a two-operand, memory-operand ISA, so a spilled value that is only read folds into the consuming instruction (add eax,[ebp-8]) with no separate load. At a one-cycle L1 hit, a read-once spill was nearly free even in-order. But that helps the cheap case, not the expensive one. The costly spills in our data are recurrences — an accumulator or an ARX chain read and written every iteration (SipHash is the extreme: 23 forced spills, hidden to −2% here by out-of-order execution). A recurrence spill needs a store plus a dependent reload on the critical path. The read-modify-store form (add [mem],reg) does not fold to a free operand — it is ~3 clocks and un-pairable on the Pentium — and two period details make the store side cost more than a cycle: the 486's L1 was write-through, so every spill store hit the bus, and the original Pentium had little store-to-load forwarding, so a value spilled and immediately reloaded could stall until the store drained. Modern cores forward store-to-load in a few cycles and reorder across iterations; an in-order Pentium did neither.

So the floor claim is really about the tail. For streaming, read-once code the memory-operand ISA probably put the historical cost close to what we measure — the folded load and a one-cycle L1 hit largely absorb the pressure. For the recurrence-bound kernels (+30–42% here) in-order hardware likely paid more, because it could neither fold the read-modify-store nor hide it behind other work. As a sanity check, published x86-64-vs-x86-32 comparisons at the AMD64 launch generally landed in the ~5–15% range for general integer code and higher for register-hungry and SIMD work, consistent with our "≈15% average, ≈30% for the hungriest" split, though those numbers also fold in AMD64's other changes (a fixed calling convention, RIP-relative addressing, a doubled XMM file) and so overstate the register-only effect.

Limitations

Reproduce

git clone https://github.com/rjpower/spillbench
cd spillbench/bench
taskset -c 3 cargo run --release --bin bench -- 15   # writes ../static/results.json
cd ../report
uv run --with matplotlib,seaborn,pandas,numpy python make_figures.py

The kernels are in bench/kernels/*.c, the harness in bench/src/main.rs, and the figure script in report/make_figures.py. bench/README.md documents the reserve orders and per-kernel details.