Teach RegAllocFast to consume SSA MachineIR directly, so that the pipelines it serves — -O0, and -O2 -mllvm -regalloc=fast – can drop PHIElimination and TwoAddressInstructionPass entirely. The allocator lowers PHIs and tied operands itself, instead of having two whole-function passes rewrite the function first and then leaning on the allocator to clean up after them.
That alone cuts llc -O0 codegen time by 4.5% on lld/ELF/Driver.cpp, with .text size mostly unchanged.
A follow-up that lowers PHIs into the stack slots keeps the same time saving and reduces .text section sizeA: .text -1.10% for Driver.cpp and -1.89% for ScalarEvolution.cpp.
My prototype migrates X86 (24 updated tests) and AArch64 (9 updated tests), and adds roughly 450 lines under llvm/include and llvm/lib.
It is up for preview at [RegAllocFast] Consume SSA MachineIR, absorbing PHI and two-address lowering by MaskRay · Pull Request #6 · MaskRay/llvm-project · GitHub.
The remaining targets should be straightforward. AMDGPU needs new anchors for two of its passes first, which is the main open question below.
Why absorb the two passes
RegAllocFast is block-local: it keeps nothing in registers across block boundaries, so every value live across one gets a dedicated stack slot, spilled at its definition and reloaded where used.
No liveness analysis, no live-range splitting, no interference graph, and no RegisterCoalescer.
The only coalescing is its own copy hint: allocating a COPY’s source, it prefers the destination’s already-assigned register and erases the copy when both ends land in the same one.
The two passes are 3.8-4.1% of llc -O0 time (-time-passes on
lld/ELF/Driver.cpp and ScalarEvolution.cpp: Two-Address 3.1-3.3%,
PHIElimination 0.6-0.8%).
The two passes were designed for a smart allocator, RegAllocGreedy, and RegAllocFast then has to undo most of it:
PHIEliminationisolates every PHI through a fresh virtual register:
%incoming = COPY %srcin each predecessor and%dst = COPY %incomingat the
top of the block. That is the correct conservative lowering — it makes the
lost-copy and swap problems impossible without splitting edges — but it
produces copy chains whose only hope of disappearing is the copy hint.TwoAddressInstructionPassinserts a copy for every tied operand it cannot
commute or convert to three-address, which register allocation then tries to
fold away.
Neither pass can tell which of those copies are needed, because neither has liveness.
The allocator does: it knows which values are block-local and which physical register each value is in at each point.
A tied use that dies at its instruction simply takes over the tied def’s register.
And a PHI, where cross-block values live in dedicated slots, is not a register question at all — it is a question about which slot each incoming value is written to.
What the allocator takes over
Three things, in one SSAInput mode selected per function.
1. PHIs
Step 1 — isolation, exactly as PHIElimination does it.
A pre-pass creates the incoming register, inserts the destination copy at the top of the block and one source copy per distinct predecessor, and erases the PHI.
The incoming register is read at exactly one place, so a copy on a not-taken edge cannot be observed: no edge splitting, no lost-copy or swap problem.
Step 2 — lower PHIs into the stack slots the allocator already owns.
Every edge transfer is still a COPY. What changes is how many copies a PHI
needs and which slots its values land in. For a loop-carried PHI in %loop with
predecessors %entry and %latch:
%p = PHI %init, %entry, %next, %latch
| selected when | copies emitted | slots for the web | |
|---|---|---|---|
| A | %next is defined in %latch, dies there, and %p is not read there |
none | 1 |
| B | A does not apply | %p = COPY %next at the end of %latch |
1 |
| C | writing %p’s slot in %latch could be observed on another edge out of %latch |
%incoming = COPY %next in each predecessor, plus %p = COPY %incoming at the top of %loop |
2 |
A gives %next the destination’s slot, so the spill %next needed anyway
is the edge transfer — no copy, no extra slot, no extra store.
B emits the copy and retargets the PHI’s operand to %p, so the PHI
stops counting as a cross-block use of %next. That second half is what makes it
cheap: without it %next keeps a slot of its own and is spilled into it for
nothing; with it %next becomes block-local and the copy hint folds the COPY
into the store %p needed anyway. A and B usually emit identical machine code,
and B costs at most one register-to-register move when the hint misses.
C is step 1’s isolation, kept as the fallback. It is the only case needing
two slots, and it is what PHIElimination does for every PHI today:
%loop, cases A and B %loop, case C
movl -4(%rsp), %eax movl -8(%rsp), %ecx
movl -8(%rsp), %ecx movl -4(%rsp), %eax # reload %incoming
movl %eax, -12(%rsp) # %p = COPY %incoming
cmpl %ecx, %eax cmpl %ecx, %eax
One extra store on every iteration, and -4(%rsp) holds the whole web in A and
B but only %incoming in C, with %p needing -12(%rsp) of its own. The
.text win in the table below is A and B displacing C.
A and B write the destination’s slot inside a predecessor, so both need an
argument that the write cannot be observed on an edge leaving that predecessor
for anywhere but the PHI’s block. That is a cheap, conservative, one-sided test:
every read of the destination is inside the PHI’s own block and none of them is
a PHI operand; or the predecessor cannot reach those reads; or the predecessor
has no other successor. A PHI that fails it takes C.
Where several PHIs share an edge, the copies are ordered so a destination is
written only once nothing left reads it, with a fresh register breaking a cycle
— the classic parallel-copy sequentialization, which is what makes the swap
shapes come out right.
Step 2 is where the code-size win is, and the part that carries a real correctness argument.
2. Tied operands
A tied use that dies at the instruction takes over the tied def’s register: zero copies.
One that lives on, or that reads a subregister, is copied into the def’s register before the instruction, matching what TwoAddressInstructionPass would have emitted.
Copy-hint tracing follows ties as chain links, since a def and its tied use share a register, so argument copies feeding two-address chains still fold.
An early-clobber def whose tied value is also read through another operand keeps that operand in a distinct register and gets the copy, preserving the early-clobber guarantee.
3. REG_SEQUENCE and INSERT_SUBREG
Expanded to subregister COPYs in a pre-pass, mirroring the lowering
TwoAddressInstructionPass performs today; without this they survive to the
AsmPrinter, which cannot print them. These two are the complete list — that pass
expands REG_SEQUENCE, rewrites INSERT_SUBREG as a COPY, and touches no
other subregister pseudo.
Numbers
llc -O0 -filetype=obj wall time on an otherwise idle i7-14700K pinned to one P-core, Release build without assertions.
Step 1 (isolation only), x86-64:
| codegen time | .text |
|
|---|---|---|
lld/ELF/Driver.cpp, clang -O0 IR |
276.2ms → 263.8ms (-4.5%) | +0.00% |
lld/ELF/Driver.cpp, -O2 IR |
170.5ms → 165.7ms (-2.8%) | +0.05% |
ScalarEvolution.cpp, -O2 IR |
346.2ms → 333.8ms (-3.6%) | +0.16% |
Code size is a wash, as it should be: the lowering is the one PHIElimination already performed.
The deltas up to +0.16% come from the allocator creating its
incoming registers in a different order, shifting virtual register numbering and
with it the allocator’s choices.
On clang -O0 IR the 4.5% saved exceeds the passes’ own 4.1% share — the allocator no longer folds away the copies TwoAddressInstructionPass inserted, and there are two fewer passes for the pass manager, and the machine verifier in an assertions build, to run. On the optimized-IR inputs it falls a little short, since the pre-pass lowers five to thirteen times as many PHIs.
Step 2 (slot-based PHI lowering). Timing matches step 1 — the slot machinery
adds nothing measurable (Driver.cpp clang -O0 IR: 276.6ms → 264.2ms, the same
-4.5%) — and the win is code size on PHI-heavy input:
.text |
|
|---|---|
lld/ELF/Driver.cpp, -O2 IR, x86-64 |
-1.10% |
ScalarEvolution.cpp, -O2 IR, x86-64 |
-1.89% |
ScalarEvolution.cpp, -O2 IR, AArch64 |
-0.16% |
AArch64 gains much less by design: with 31 allocatable GPRs, values survive in registers more often and the copy hint already folds most PHI copies away. Even the -0.16% depends on slot sharing testing slot geometry — equal spill size, alignment and stack ID — rather than requiring identical register classes; with the exact-class test AArch64 measured +0.01%.
llvm-test-suite CTMark at -O0, x86-64: all 10 programs build, run and verify
against their reference output, aggregate .text -0.12% (consumer-typeset
best at -0.53%, lencod worst at +0.02%). That sweep predates the slot-geometry
relaxation above, so it is a conservative figure.
Debug info
Measured on llvm-test-suite CTMark: no significance at -O0 -g but measurably better at -O2 -g -mllvm -regalloc=fast
At -O0 -g (the configuration this ships in): statistically identical. Variables with a location: 153,834 on both sides, per-program equal. Scope coverage 99.9999% both; zero local variables without any location, both. The only delta is that scope byte counts shrink ~0.16% — the code got smaller, coverage stayed flat.
At -O2 -g -regalloc=fast (instruction-referencing LiveDebugValues): the SSA path is better on every program — +0.56pp aggregate variables-with-location coverage (62.54% vs 61.98%), 1,391 more variables carrying locations, and 435 fewer variables with no location at all; lencod is the biggest winner at +1.6pp. That direction makes sense: fewer inserted copies and tier-A slot sharing mean a value sits in one register or one stable stack slot for longer, so its location ranges extend instead of fragmenting. Caveat: this arm covered 8 of 10 programs (7zip and ClamAV were lost to the class-mismatch crash); the 10/10 re-run happens right after the fix lands.
Opting in, and the migration path
The mode is per target and initially off by default:
// In the target's TargetMachine constructor
setEnableSSAFastRegAlloc(true);
Both codegen pipelines resolve it through one predicate,
useSSAFastRegAlloc(const TargetMachine &), which also applies the hidden
-regalloc-fast-ssa flag, so the legacy and new pass managers cannot drift:
if (!useSSAFastRegAlloc(*TM)) {
addPass(&PHIEliminationID);
addPass(&TwoAddressInstructionPassID);
}
addRegAssignAndRewriteFast();
-regalloc-fast-ssa=0 and -regalloc-fast-ssa=1 override the target default in either direction.
With =0 the output is byte-identical to today’s pipeline, which makes any suspected regression a one-flag bisect.
The staged plan:
- Plumbing plus step 1, with no target opting in. The mode is reachable
only through the flag, so the patch is NFC: no in-tree configuration takes
the new path, and the only edits to existing tests add RUN lines passing the
flag. - Step 2, the slot-based PHI lowering, as a separate review.
- Targets opt in one at a time. Each is a one-line target change plus regenerating that target’s
-O0tests. X86, AArch64, then other targets. - Once all in-tree targets have opted in,
SSAInputbecomes unconditional, the two passes leave the-O0pipeline for good, andRegAllocFastwill declare SSA as a required MIR property instead of tolerating both shapes.
What a target maintainer should check before flipping the switch:
- Passes anchored on the two pass IDs. This is why AMDGPU has migration difficulty: it inserts
SILowerControlFlowrelative toPHIEliminationIDat
two sites (AMDGPUTargetMachine.cpp:1815and:1841) andSIWholeQuadMode
relative toTwoAddressInstructionPassID(:1817), and documents an
SI_ELSEtied-operand dependency on their timing. Those anchors need
somewhere else to attach before AMDGPU can opt in. - Anything between ISel and register allocation that assumes non-SSA MIR, or
that assumes tied operands have already been rewritten. - Tests that pin the
-O0pass list or use-start-afteron either pass.
Those should pin the standard pipeline with-regalloc-fast-ssa=0.
Testing
- All of
llvm/testpasses with the mode enabled (76.7k tests), and again with
it off, which is what makes step 1 NFC. - CTMark at
-O0builds, runs and verifies, 10/10. - Sweeping
llvm/test/CodeGen/AArch64at-O0with-verify-machineinstrs
gives identical pass/fail on both paths across 3160 files. - New tests drive the mode with
-regalloc-fast-ssa: the PHI shapes isolation
has to keep apart, PHIs reached over an exceptional edge, the tied-operand
cases including a hint traced through a two-address tie, and the
REG_SEQUENCEexpansion. From MIR, since instruction selection does not
produce them: PHI operands carrying a subregister index, an undef flag or a
repeated predecessor; a tied use of a subregister; a tied use whose value the
early-clobber def also reads through another operand; andINSERT_SUBREGon
an undef base.
AMDGPU pipeline challenges
Two AMDGPU passes are anchored on the pass IDs this proposal removes, at four sites across both pass managers:
| pass | fast pipeline | optimized pipeline |
|---|---|---|
SILowerControlFlow |
after PHIEliminationID (AMDGPUTargetMachine.cpp:1815) |
after PHIEliminationID (:1841, :2609 NPM) |
SIWholeQuadMode |
after TwoAddressInstructionPassID (:1817, :2550 NPM) |
after MachineSchedulerID (:1851, :2620 NPM) |
This is only blocker to remove the classical (non-SSA) code path, though two observations that may make this easier than it looks, both of which I would like AMDGPU maintainers to confirm:
- The “before TwoAddressInstructions” half of that comment appears stale.
SI_ELSE(SIInstructions.td:604) declares noConstraints, and neither doesCFPseudoInstSI(SIInstrFormats.td:284), so it has no tied operand left forTwoAddressInstructionPassto process. Running-run-pass=twoaddressinstructionoverlower-control-flow-live-intervals.mirleaves%3 = SI_ELSE killed %2, %bb.1untouched, inserting copies only for genuinely two-address instructions elsewhere in the function. - The “after phi elimination” half may be satisfiable on SSA MIR.
emitElseinserts theS_OR_SAVEEXECatMBB.begin()because it must precede “phis and any spill code inserted before the else”. Pre-allocation there is no spill code, and the PHIs it wants to precede arePHIElimination’s own copies, which the SSA mode never creates. If the insertion point becomesSkipPHIsAndLabels(MBB.begin()), the pass may simply run before the allocator on SSA input.
If both hold, AMDGPU’s migration is a re-anchor rather than a redesign.
Future direction
SSA input makes some optimizations reachable, but they may not be a good compile-time tradeoff. None of this is proposed here.
PHIElimination and TwoAddressInstructionPass get simpler: Once every in-tree target has opted in, they run only in the optimized pipeline, where their analyses always exist — so they can require LiveVariables instead of using it when available.
LLM Disclosure
A great many tokens using both Opus 5 and Fable 5 over multiple days.
The model was used as a reading and cross-checking partner over many rounds.
That went into working through the SSA-destruction literature (Briggs et al. on the lost-copy and swap problems, Sreedhar et al.'s Methods I-III, and the PHI-isolation and parallel-copy sequentialization material in SSA-based Compiler Design), reading what PHIElimination, TwoAddressInstructionPass and RegAllocFast actually do in tree rather than what the folklore says they do, and arguing out which of those designs suits an allocator that has neither liveness nor a coalescer.