All posts

Intel APX: 32 Registers, Three-Operand Integer Ops, and the Quiet Re-Encoding of x86

6 min read cpu-architecturex86compilersisaperformance

Intel APX: 32 Registers, Three-Operand Integer Ops, and the Quiet Re-Encoding of x86

x86-64 has had 16 general-purpose registers since 2003. AArch64 launched with 31. That gap has been a two-decade-long compiler headache: register allocators targeting x86-64 run out of names in exactly the code that matters — hot loops with many live values, hash functions, interpreters, kernels with unrolled state. When the allocator runs out, it spills, and every spill is a real store plus a real load through the memory pipeline.

Intel's Advanced Performance Extensions (APX) close the gap without a new execution mode. First silicon ships in Diamond Rapids (GCC 15 grew -march=diamondrapids, which enables APX_F alongside AVX10.2), and the toolchain work landed over 2024–2025. APX is worth studying even if you never write assembly, because it's a case study in how much performance was left on the table by an encoding, not by the microarchitecture.

Why architectural registers still matter

A modern out-of-order core already has 300+ physical integer registers. Register renaming eliminates false dependencies — so why would 16 architectural names constrain anything?

Because renaming only helps values that have a name. When the compiler has 20 simultaneously live values and 15 allocatable registers, five of them live in stack slots. Those spills are architecturally visible loads and stores: they occupy load/store ports, compete for store-buffer entries, depend on store-to-load forwarding latency (typically 4–6 cycles even on a forwarding hit), and can alias-stall against genuine memory traffic. The rename engine never sees them as anything but memory operations.

Intel's numbers make the scale concrete: in simulations of SPEC CPU 2017 Integer recompiled for APX, code executes ~10% fewer loads and more than 20% fewer stores, at roughly similar code density. Stores drop more than loads because spill code is store-heavy (every spilled live range writes once, may read several times, and calling conventions force save/restore traffic around calls). No source changes — this is purely the register allocator breathing again.

The four mechanisms

1. EGPR: r16–r31 via the REX2 prefix. APX adds sixteen new GPRs. Legacy-map instructions reach them through a new two-byte REX2 prefix (0xD5 — a byte freed up in 64-bit mode when the BCD instruction AAD was removed). Instructions living in the newer opcode maps get the extra register bits through the EVEX prefix instead, the same 4-byte prefix AVX-512 uses. The new registers are XSAVE-managed — they reuse the state-save space vacated by the deprecated MPX bounds registers, so kernels mostly get context switching for free once they enable the XCR0 bit.

2. NDD: three-operand integer instructions. Legacy x86 arithmetic is destructive: add rax, rbx computes rax += rbx. Preserving an input costs a mov. APX gives nearly every legacy integer instruction an EVEX-promoted form with a new data destination, encoded in the EVEX.vvvv field that vector instructions use for a second source:

; before APX: preserve rbx across the add
mov  rax, rbx
add  rax, rcx        ; rax = rbx + rcx, 2 uops before fusion/elimination

; APX NDD: one instruction, no clobber
add  rax, rbx, rcx   ; rax = rbx + rcx

Move elimination in the rename stage already made many such movs "free" in execution, but they still cost fetch bandwidth, decode slots, and ROB entries. NDD removes them at the source.

3. NF: flag suppression, and conditional compares. Almost every x86 integer op writes EFLAGS as a side effect, which creates a dense web of output dependencies through one architectural resource and forces compilers to order flag-producing and flag-consuming instructions carefully. APX's EVEX-promoted forms carry an NF ("no flags") bit:

{nf} add r16, r17, r18   ; arithmetic, EFLAGS untouched

With flags no longer clobbered by intervening arithmetic, APX can also do what AArch64 has done since day one: chain comparisons without branches. CCMP/CTEST perform a compare only if a flag predicate holds, otherwise they install compiler-chosen default flag values (DFV). That turns if (a == x && b == y) into a straight-line flag chain with a single branch at the end — one mispredictable branch instead of two. CFCMOV extends if-conversion to loads: a conditionally faulting move suppresses the fault when the predicate is false, so the compiler may hoist a load it can't prove safe. (Tellingly, this is the one APX feature GCC 15 shipped without — safe if-conversion of memory ops is the hard part.)

4. PUSH2/POP2 with PPX hints. Prologue/epilogue save-restore is a stream of dependent stack operations. PUSH2/POP2 move two registers per instruction against a 16-byte-aligned slot, and the PPX ("push-pop acceleration") hint marks balanced pairs so hardware can forward the register values from push to matching pop without a memory round trip — spill traffic that never really touches the cache. There's also JMPABS, a 64-bit absolute jump aimed at dynamic linkers and JITs that currently need mov r11, imm64; jmp r11.

The encoding tax

None of this is free. REX2 costs two prefix bytes; EVEX-promoted forms cost four. An APX add r16, r17, r18 is a longer instruction than a legacy add rax, rbx. If code size ballooned, the i-cache and decode-bandwidth losses could eat the spill savings — this is exactly the trade that sank some past ISA extensions.

Intel's claim is that it nets out: the ~10% instruction-count reduction (eliminated movs, spills, and reloads) roughly cancels the longer encodings, leaving code density "similar." That's a simulation claim on SPEC Int and deserves skepticism until Diamond Rapids hardware is in the wild — front-end-bound workloads (browsers, databases with big instruction footprints) may see different arithmetic than loop-dominated benchmarks.

The ABI choice is also telling: the proposal makes r16–r31 caller-saved, so old binaries and new binaries can share a process without the dynamic linker or existing callee-saved contracts breaking. The cost is that values in extended registers don't survive calls — call-heavy code keeps its long-lived state in the old 15, and the new registers mostly absorb leaf-function and loop-local pressure. It's a deliberately conservative migration path: the same "recompile and win" story AVX had, rather than the "new world" story of a mode switch.

Using it today

GCC 14 shipped the first wave (-mapxf: EGPR, NDD, PPX, PUSH2/POP2); GCC 15 completed it (CCMP/CTEST, NF, zero-upper SETcc) except CFCMOV. One sharp edge is inline assembly: GCC deliberately keeps r16–r31 out of inline-asm register allocation unless you pass -mapx-inline-asm-use-gpr32, because the compiler can't know whether your hand-written instructions have REX2/EVEX forms at all — plenty of legacy instructions were not promoted. Anything using asm with GPR clobbers (locking primitives, syscall wrappers, crypto kernels) needs an audit before turning that on.

The realistic adoption curve mirrors AVX-512's: distros won't drop the x86-64 baseline, so early wins come from function multi-versioning, JITs (a runtime knows exactly what CPU it's on), and hyperscalers who compile their whole fleet per-microarchitecture anyway. For that last group, "20% fewer stores for a recompile" is the cheapest performance win of the decade — and it comes from finally fixing a register file size chosen when 64-bit x86 was a skunkworks project at AMD.