All tutorials Mighty Professional
Build a Game Engine ยท Foundations

x86-64 Assembly
from Scratch

Sixteen general-purpose registers. Sixteen 256-bit vector registers since AVX, thirty-two 512-bit ones on AVX-512 hardware. An instruction encoding that ranges from one byte to fifteen, a calling convention that disagrees between Linux and Windows, and a microarchitecture that turns your serial-looking code into a wide, speculative dispatch across eight or more execution ports. The assembly layer is where you find out why an inner loop runs at the speed it does. This tutorial works from the CPU model up to a SIMD matrix-vector kernel and a ฮผop scheduling demo, with claims cited to vendor manuals, published measurements, and papers.

Time~65 min LevelEngine programmer, intermediate to senior PrereqsYou can read C or C++ comfortably. You know what a stack frame and a function pointer are. The Memory Model tutorial pairs naturally with ยง15 of this one. HardwareAny x86-64 machine from the last decade
โ—‚ Build a Game Engine Phase 0 ยท Foundations Next ยท The C++ Memory Model โ–ธ

01Why assembly still matters when nobody writes it

Very few production games ship hand-written assembly. Compilers produce good enough code for most hot paths, and a single ABI mismatch in hand-written code can corrupt a callee-saved register in a way that crashes three frames later. The reason to learn assembly in 2026 is not to write it. It is to read it. The compiler's output is the ground truth for what your CPU is being asked to do, and reading it answers questions that source-level reasoning can't.

Concrete situations where the disassembly is the only authoritative source:

The history section is short, the CPU model is the standard mental picture engine programmers work from, and the SIMD walkthrough in ยง13 is a variant of the matrix-vector transform that renderers and math libraries implement[2].

What you'll have by the end

A working ability to read AT&T and Intel syntax x86-64 disassembly. Concrete knowledge of the System V AMD64 ABI (Linux, macOS, BSD, PlayStation) and the Microsoft x64 ABI (Windows, Xbox) and where they differ. The instruction encoding well enough to read a hex dump as instructions. The SIMD vocabulary (lanes, packed/scalar, SSE/AVX/AVX-512) and what a 4ร—4 matrix-vector multiply looks like at the assembly level. The microarchitectural vocabulary (ฮผops, ports, dependency chains, retirement) that the Intel and AMD optimization manuals[3][4] and Agner Fog's manuals[5] are written in. Six live, in-browser widgets you can step through.

A tiny disassembly to set the tone

Two lines of C. Compile with gcc -O2 on x86-64 Linux:

add.c
int add(int a, int b) {
  return a + b;
}

The compiler emits:

add.s ยท Intel syntax
add:
  lea   eax, [rdi + rsi]   ; eax = rdi + rsi (load-effective-address used as 3-op add)
  ret                      ; pop return address from [rsp], jump to it

Four lessons hiding in two instructions. First, the function arguments arrived in rdi and rsi because the System V ABI[6] puts the first two integer arguments there; the same code on Windows would have used rcx and rdx[7]. Second, the return value goes in rax (32 bits of which is eax). Third, the compiler used lea (Load Effective Address) as a three-operand non-destructive add: lea is the closest thing x86 has to an ARM-style add dst, src1, src2, and the compiler reaches for it constantly. Fourth, there is no stack frame at all, because nothing here required spilling state and the function is a leaf.

The rest of this tutorial unpacks each of those four observations from first principles. By the end you will be able to look at a fifty-line dump and tell where the register allocator gave up, where a function was inlined, and which loop the compiler vectorized.

02A short history of the architecture you're reading

x86-64 carries nearly fifty years of architectural decisions, each made with the previous decade of code in mind. The instruction encoding has prefixes whose purpose is to extend an earlier 16-bit encoding. The calling conventions changed when 64-bit mode doubled the general-purpose register count. The vector extensions arrived in four named generations. Much of this reads as arbitrary until you see what each piece replaced.

1978
Intel 8086.[9] A 16-bit CISC processor with eight named 16-bit registers (AX, BX, CX, DX, SI, DI, BP, SP) and a segmented address space. The instruction encoding is the basis of every x86 CPU since: most instructions are still recognized in their 1978 form, and the assembler mnemonics are mostly unchanged.
1985
Intel 80386. 32-bit registers (EAX, EBX, โ€ฆ), 32-bit protected mode with paging (the 80286 had introduced a 16-bit protected mode), and the flat 32-bit address space that displaced segmentation in practice. Every register from the 8086 was widened to 32 bits with an "E" prefix, and the encoding was extended with a prefix byte to choose between 16-bit and 32-bit operand sizes. The pattern of "extend by a prefix, keep the old encoding intact" set the precedent for everything that followed.
1997
MMX. Intel's first SIMD extension: 64-bit integer vectors aliased onto the x87 FPU registers. Superseded by SSE2's 128-bit integer operations.
1999
SSE (Streaming SIMD Extensions). Eight new 128-bit registers (XMM0โ€“XMM7), packed-single-precision-float arithmetic, and the first explicit prefetch instructions[10]. SSE became the floating-point baseline; AMD64 made SSE2 (added 128-bit integer ops and packed doubles) part of the mandatory ISA, which is why x86-64 compilers emit XMM-based floating point and the x87 FPU stack (dating to the 8087 of 1980) is left to legacy paths and long double on Linux.
2003
AMD64 (x86-64).[8] AMD's extension to 64-bit, debuting on the K8 Opteron. Sixteen general-purpose registers (R8โ€“R15 added, the eight 8086-derived registers widened to RAX, RBX, โ€ฆ). Sixteen XMM registers. A new REX prefix encodes the extended registers and the 64-bit operand size. RIP-relative addressing makes position-independent code cheap; 32-bit PIC had to materialize its own address with a call/pop sequence and tie up a register (usually ebx) as the GOT pointer. Intel adopted AMD's design the following year on the Prescott-based Xeon (under the name "Intel 64", originally "EM64T").
2008
SSE4.2. The pcmpistri family of string-compare instructions and a hardware crc32 (the CRC-32C polynomial, common in storage and network checksums); popcnt arrived in the same generation.
2011
AVX. 256-bit YMM registers (the lower 128 bits alias XMM) and a new three-operand VEX encoding[10]; the separately introduced FMA3 fused-multiply-add extension followed (AMD Piledriver 2012, Intel Haswell 2013). AMD's Jaguar (PS4/Xbox One, 2013) brought AVX (not AVX2) to consoles; PS5 and Xbox Series X/S (AMD Zen 2, 2020) brought AVX2, the SIMD baseline for current-generation console code.
2016โ€“2022
AVX-512. 512-bit ZMM registers, thirty-two of them, with separate mask registers (K0โ€“K7). First shipped on the Knights Landing Xeon Phi (2016) and the Skylake-SP / Skylake-X server and high-end desktop parts (2017); reached laptops in volume with Ice Lake (2019). Intel disabled it on client P-cores from Alder Lake (2021) through Arrow Lake (2024), because those chips pair the P-cores with E-cores that lack the unit. AMD shipped AVX-512 starting with Zen 4 (2022) and continued it on Zen 5. AVX-512 is the first x86 vector instruction set with first-class predication via the K-mask registers.
2023
APX (Advanced Performance Extensions, announced).[11] Intel's specification for sixteen more general-purpose registers (R16โ€“R31), three-operand forms of the legacy integer instructions, and conditional loads and stores. GCC 14 and recent LLVM already accept the encodings ahead of hardware; mentioned here so the register count below doesn't surprise you when APX hardware appears.

The consequence for what you read in 2026: most disassembly is plain x86-64 with SSE2 floating point. Hot paths use AVX2 (256-bit) on PC and on PS5 / Xbox Series. AVX-512 shows up in compression, simulation, and ML kernels, and rarely in shipped game code because of the Intel client-side gap from Alder Lake through Arrow Lake. Arm, touched on briefly in ยง12, is the other architecture worth knowing: Nintendo Switch and Switch 2, Apple Silicon, and a growing fraction of Windows-on-Arm devices all ship it, and the calling conventions, register file, and SIMD model (NEON, SVE2) follow different rules.

Intel syntax vs AT&T syntax: which is which, and which should I learn?

Two notational conventions exist for x86 assembly. The instructions are the same; the surface syntax differs:

Intel syntax writes mov eax, ebx with the destination first, registers carry no sigils, and memory references look like [rdi + 8]. Used by NASM, MASM, the Intel and AMD reference manuals[10], and Compiler Explorer's default. Also the syntax GCC and Clang emit if you pass -masm=intel.

AT&T syntax writes movl %ebx, %eax with source first. Registers prefixed with %, immediates with $, and a suffix on the mnemonic encodes the operand size (l = long = 32 bits). Memory references look like 8(%rdi). Used by the GNU assembler (as) and the default output of objdump on Linux. Inherited from the assemblers of AT&T Unix.

Read both. Intel syntax matches the vendor manuals; AT&T is what you'll see in objdump -d output from a Linux build. This tutorial uses Intel syntax in code listings and notes AT&T differences where they matter.

03The CPU you're actually programming for

An assembly instruction is not what executes. A modern x86 core decodes each instruction into one or more , schedules those ฮผops across multiple execution units in parallel, and retires them in program order at the back end[3]. The instruction stream you read is the contract; what runs is a heavily reordered, register-renamed, speculatively executed version of it. This section sketches that machine, enough to make sense of why some instructions are nearly free and others stall a whole pipeline.

A modern x86 pipeline, simplified to the parts you care about reading assembly for:

The two practical consequences for reading assembly. First, an instruction's latency (cycles from issue to result available) is different from its throughput (number of times per cycle the CPU can issue it). A vmulps on Skylake has a latency of 4 cycles but a throughput of two per cycle[13]: a chain of eight dependent multiplies takes 8 ร— 4 = 32 cycles; eight independent ones issue in 4 cycles and the last result lands 4 cycles after that, on the order of 8 cycles total. Same instruction count, ~4ร— difference in finish time. Second, the difference between a "fast" and "slow" implementation of the same algorithm at the assembly level is often not the instruction count; it is whether the instructions form a serial dependency chain that leaves most ports idle, or independent work the scheduler can spread across all of them.

The widget below schedules sixteen integer adds onto four ALU ports, once as a single dependent chain and once as four independent ones. ยง16 returns to this picture with a worked example:

Live ยท Port pressure scheduler
Sixteen add ฮผops scheduled on a four-port integer ALU, modeled after Skylake's ports 0/1/5/6 (each accepts one integer add per cycle[13]). In serial mode each add waits for the previous one (one chain of dependent adds; 16 cycles). In parallel mode the same sixteen adds use four independent accumulators (one chain per port; 4 cycles). Same instructions, a 4ร— difference in finish time.

04The register file

x86-64 exposes sixteen general-purpose 64-bit registers and sixteen (with AVX-512: thirty-two) vector registers. Knowing the names is half of reading a disassembly:

64-bit32-bit16-bit8-bit lowConventional role (System V)
raxeaxaxalreturn value (int); scratch
rbxebxbxblcallee-saved
rcxecxcxcl4th integer arg
rdxedxdxdl3rd integer arg; high half of 128-bit return
rsiesisisil2nd integer arg
rdiedididil1st integer arg
rbpebpbpbplframe pointer; callee-saved
rspespspsplstack pointer
r8r8dr8wr8b5th integer arg
r9r9dr9wr9b6th integer arg
r10โ€“r11โ€ฆscratch
r12โ€“r15โ€ฆcallee-saved

Writes to a 32-bit register zero-extend into the 64-bit register. Writes to an 8-bit or 16-bit register do not: they leave the upper bits unchanged. This is an AMD64 architecture rule[8], not a compiler convention, and it is why compilers emit xor eax, eax to zero the full rax: the 32-bit write zero-extends, and the 2-byte encoding is shorter than mov eax, 0 (5 bytes) or mov rax, 0 (7 bytes).

Vector registers come in three sizes that alias each other:

The aliasing is not free. Running legacy-encoded SSE instructions while the upper halves of the YMM registers hold data from VEX-encoded AVX code costs extra on Intel cores: on Sandy Bridge through Broadwell each switch triggers a state save or restore of dozens of cycles, and on Skylake and later each legacy SSE instruction instead carries an extra blend ฮผop and a dependency on the full register[3]. The rule the compiler follows hasn't changed: emit vzeroupper before leaving AVX code. Hand-written assembly that mixes the two has to do the same.

Step through eight instructions and watch the register file update. Each row is one 64-bit register, shown as its high and low 32 bits in hex. The registers start out holding leftover values from earlier code, so the partial-write rules are visible. The arrow marks the next instruction; orange highlights the register just written:

Live ยท Register-file stepper
A small program: add the two integer arguments (edi = 7, esi = 10), double the sum with lea, and return (a + b) * 2 = 34 in eax. The 32-bit writes (mov eax, edi, lea ecx, โ€ฆ) clear bits 63..32 of the full register. The 8-bit write mov dl, cl changes only the low byte of rdx and leaves its other 56 bits holding stale data; movzx edx, cl is the clean way to widen a byte.

05Anatomy of an instruction

An x86-64 instruction is between 1 and 15 bytes. Its decoded form is a sequence of optional and mandatory fields, in this order[10]:

  1. Legacy prefixes (0โ€“4 bytes). Address-size override, operand-size override, segment override, repeat prefix, LOCK.
  2. REX prefix (0โ€“1 byte). Required to encode the 64-bit operand size (for instructions that don't default to it), registers R8โ€“R15, or the SIL/DIL/BPL/SPL byte registers. Starts with the high nibble 0x4; the low nibble carries four bits W, R, X, B.
  3. Opcode (1โ€“3 bytes). Selects the instruction; in the "+r" forms, part of the opcode byte also encodes a register.
  4. ModRM (0โ€“1 byte). For instructions with operands, encodes the addressing mode and one or two register fields.
  5. SIB (0โ€“1 byte). Scale/Index/Base for the memory addressing modes that need it ([rbx + rcx*4 + 0x10]).
  6. Displacement (0, 1, or 4 bytes in 64-bit code; 8 only for the special moffs forms of mov). The constant offset in a memory operand.
  7. Immediate (0, 1, 2, 4, or 8 bytes; 8 only for mov r64, imm64). A literal value baked into the instruction.

AVX adds two more prefix families that replace REX and carry a "non-destructive source" field, letting vaddps ymm0, ymm1, ymm2 compute ymm0 = ymm1 + ymm2 without clobbering either source. The VEX prefix (2 or 3 bytes) covers AVX/AVX2; the EVEX prefix (4 bytes) covers AVX-512 and adds the mask register selector and rounding controls.

Click an instruction below to see its bytes broken out. Most production disassemblers can show this view (objdump -d -M intel --show-raw-insn, llvm-mc --show-encoding); seeing it once builds the muscle memory:

Live ยท Instruction-encoding decoder
Each instruction's encoding is from the Intel SDM Volume 2 instruction reference[10]. Assemblers can pick different, equally valid encodings of the same instruction; for example, add rax, rbx can be encoded with the destination in the ModRM reg field (opcode 03) or in the r/m field (opcode 01). The widget shows the form the GNU assembler emits.
What's a ModRM byte actually?

One byte split into three fields: mod (top 2 bits), reg (middle 3 bits), r/m (bottom 3 bits). The reg field names a register; the r/m field names either a register or a memory operand depending on what mod says. A few examples:

mod=11 means "r/m is a register" (so the instruction has two register operands). mod=00 means "r/m is a memory operand with no displacement" (e.g., [rax]), except that r/m=101 with mod=00 means RIP-relative with a 32-bit displacement. mod=01 and mod=10 add an 8-bit or 32-bit displacement. r/m=100 is a sentinel meaning "a SIB byte follows," used for the indexed addressing modes like [rax + rcx*4].

The reg and r/m fields are 3 bits each. To name the 16 x86-64 registers you need 4 bits; the missing bit comes from the REX prefix's R and B bits. That's why REX is required whenever your instruction touches R8โ€“R15.

06The core instructions, by frequency

Recent x86-64 has on the order of a thousand distinct mnemonics, and thousands of variants once operand sizes and addressing modes are counted[13]. The fifteen or so in the table below account for most of the scalar code a compiler emits. Sorted roughly by how often they appear:

MnemonicWhat it doesForm you'll usually see
movCopy bits. Register-to-register, register-to-memory, memory-to-register, immediate-to-register. The basic data movement instruction.mov rax, [rdi + 8]
leaCompute an address (or any 3-operand arithmetic that fits the addressing modes), don't dereference. See ยง7.lea rax, [rdi + rsi*4]
add / subInteger add/subtract. Two-operand: dst = dst op src.add rsp, 0x28
imulSigned multiply. Most often seen as two-operand (imul dst, src โ†’ dst *= src) or three-operand with an immediate (imul dst, src, imm โ†’ dst = src ร— imm). These forms keep only the low half of the product, which is the same for signed and unsigned inputs, so compilers use them for unsigned multiplies too.imul rax, rcx
shl / shr / sarBit shift left, logical right, arithmetic right. sar preserves the sign bit; shr doesn't.shl rax, 3
and / or / xorBitwise ops. xor reg, reg is the canonical zeroing idiom.xor eax, eax
cmp / testSet the flags register. cmp a, b = subtract without storing; test a, b = AND without storing.cmp rax, 0
jccConditional jump. je = jump if equal (ZF=1), jne, jl, jg, jb, ja (signed vs unsigned). Follows a cmp or test.jne loop
jmpUnconditional jump.jmp .L7
call / retFunction call and return. call pushes the return address and jumps; ret pops and jumps.call malloc
push / popDecrement RSP by 8 and store; or load and increment by 8. Used in function prologues/epilogues for callee-saved registers.push rbp
cmovccConditional move. cmovge dst, src = if SF=OF then dst=src. Branchless conditionals; see ยง11.cmovl rax, rdi
setccSet a byte to 1 if a condition holds, else 0. Often used with movzx to materialize a 0/1 in a register.setl al
movzx / movsxMove with zero-extension or sign-extension. Bridge between 8/16-bit and 32/64-bit registers.movzx eax, byte ptr [rdi]

The cc suffix on jumps, conditional moves, and setcc is one of sixteen condition codes that test the flags register set by the most recent cmp, test, or arithmetic instruction: z/e (zero/equal), nz/ne, l/g (signed less/greater), b/a (unsigned below/above), s (negative), o (overflow), and the rest. A jcc reads the flags and conditionally jumps; a cmovcc reads the flags and conditionally moves; a setcc reads the flags and conditionally writes 1 to a byte[10].

Memory addressing supports one form, used by mov, lea, and most others: [base + index*scale + displacement], where base and index are 64-bit registers, scale is 1/2/4/8, and displacement is a signed 8 or 32-bit constant. Any subset can be omitted. RIP-relative addressing ([rip + offset]) is the special case used for position-independent globals on x86-64.

07The lea swiss army knife

lea (Load Effective Address) computes a memory address and writes it to a register without dereferencing. Because the addressing mode is the same as the one mov uses, lea can be used as a fast three-operand arithmetic instruction: any value that fits the base + index*scale + displacement form can be computed in one instruction without clobbering the source registers and without touching the flags. The compiler uses this constantly:

lea_tricks.s
; a + b without an add, and without trashing flags or either input
lea  rax, [rdi + rsi]

; 5 * x in one instruction (4*x + x)
lea  rax, [rdi + rdi*4]

; 9 * x  โ†’  (8*x + x), via lea with scale=8
lea  rax, [rdi + rdi*8]

; Address arithmetic: &array[index] for an array of 4-byte ints
lea  rax, [rdi + rsi*4]

; Combined: 3*x + 7 in one instruction
lea  rax, [rdi + rdi*2 + 7]

Things to know. lea does not read memory, so the address it computes does not need to be valid. lea does not set flags, which makes it useful in the middle of a chain of conditional code where you can't afford to clobber them. The simple forms ([base + index*scale] or [base + disp]) take one cycle of latency on most recent Intel and AMD cores, with two or more per cycle of throughput[13]. The three-component form ([base + index*scale + disp], any scale) is the "slow LEA" on Sandy Bridge through Skylake-derived cores: port 1 only, three-cycle latency[12]. Ice Lake made it a 1-cycle operation; on Alder Lake's Golden Cove P-cores any LEA with a scaled index takes 2 cycles, and on Zen 3 through Zen 5 the three-component form splits into 2 ฮผops[13]. When tuning for Skylake-class cores, compilers split a three-component LEA into a two-component lea plus an add when it sits on the critical path.

08Calling conventions: System V vs Windows x64

Function calls across a public interface on x86-64 follow one of two main specifications: the System V AMD64 ABI[6] (Linux, macOS, the BSDs, PlayStation 4, PlayStation 5) or the Microsoft x64 ABI[7] (Windows, Xbox One, Xbox Series X/S). They disagree on which registers carry arguments, which are callee-saved, how struct returns work, and how the stack is aligned at the call site:

System V AMD64
Linux ยท macOS ยท BSD ยท PS4/PS5
Int args (in order)rdi, rsi, rdx, rcx, r8, r9
Float argsxmm0โ€“xmm7
Int returnrax (high half: rdx)
Float returnxmm0 (high half: xmm1)
Caller-savedrax, rcx, rdx, rsi, rdi, r8โ€“r11, all xmm*
Callee-savedrbx, rbp, r12โ€“r15, rsp
Stack alignment at call16-byte (so rsp = 16k โˆ’ 8 on entry)
Red zone128 bytes below rsp usable without adjusting
Shadow spaceNone
Microsoft x64
Windows ยท Xbox (MSVC toolchain)
Int args (in order)rcx, rdx, r8, r9
Float argsxmm0โ€“xmm3
Int returnrax
Float returnxmm0
Caller-savedrax, rcx, rdx, r8โ€“r11, xmm0โ€“xmm5
Callee-savedrbx, rbp, rdi, rsi, rsp, r12โ€“r15, xmm6โ€“xmm15
Stack alignment at call16-byte (same)
Red zoneNone
Shadow space32 bytes the caller reserves just above the return address

Two differences cause many porting bugs. First, rdi and rsi are callee-saved on Windows but caller-saved (argument registers) on System V. Code that hand-codes assembly without restoring rdi and rsi works on Linux and silently corrupts state on Windows[7]. Second, Windows requires the caller to reserve 32 bytes of "shadow space" just above the return address, where the callee may spill its four register arguments. Code built for one convention that calls code built for the other needs a thunk that translates between them; GCC and Clang can generate one from an ms_abi or sysv_abi function attribute.

The widget below shows the same function call under both ABIs side by side. Pick a call; each column shows which register or stack slot receives each argument, with a note on why:

Live ยท ABI side-by-side
A vec3 argument is more interesting than it looks. System V classifies a 12-byte struct of three floats as two SSE-class eightbytes and passes it in two XMM registers (xmm0 for x,y; xmm1 for z)[6]. Microsoft x64 passes any aggregate whose size isn't 1, 2, 4, or 8 bytes by reference: the caller copies it to a temporary in its own frame and passes a pointer in rcx[7].

09Stack frames in practice

The function-call stack on x86-64 grows downward: rsp decreases on a call or a push, increases on a ret or pop. A typical non-leaf function emits a prologue that allocates a frame, saves the registers it needs to preserve, and ends with an epilogue that reverses the prologue:

function_with_frame.s
my_function:
  ; โ”€โ”€ Prologue โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  push rbp                  ; save the previous frame pointer
  mov  rbp, rsp             ; rbp now points at this frame's base
  push rbx                  ; save the callee-saved registers this function uses
  push r12
  sub  rsp, 0x30            ; 48 bytes of locals: return address + 3 pushes = 32 bytes,
                            ; so a multiple of 16 here keeps rsp 16-byte aligned

  ; โ”€โ”€ Body โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  ...

  ; โ”€โ”€ Epilogue โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  add  rsp, 0x30            ; deallocate locals
  pop  r12                  ; restore callee-saved registers in reverse
  pop  rbx
  pop  rbp
  ret

Three rules to remember when reading frames:

The red zone is the System V detail that catches Windows porters: a leaf function can legitimately have local_a at [rsp - 8], with rsp never adjusted. On Windows, memory below rsp may be overwritten at any time (by the OS or a debugger, for example), so the Microsoft ABI has no red zone and the compiler must sub rsp, ... for any local storage.

10Reading compiler output: a worked vector normalize

A canonical 3-D engine primitive: take a vec3, divide each component by its length, return the unit vector. Source:

normalize.cpp
struct vec3 { float x, y, z; };

vec3 normalize(vec3 v) {
  float lenSquared = v.x * v.x + v.y * v.y + v.z * v.z;
  float invLen    = 1.0f / sqrtf(lenSquared);
  return { v.x * invLen, v.y * invLen, v.z * invLen };
}

With clang -O2 -mavx2 -mfma -fno-math-errno on System V (illustrative; exact instruction selection varies across versions and surrounding code):

normalize.s ยท illustrative clang -O2 -mavx2 -mfma -fno-math-errno output ยท Intel syntax
normalize(vec3):
  ; System V vec3 ABI: xmm0[0]=x, xmm0[1]=y (upper lanes undefined); xmm1[0]=z.
  vmulss       xmm3, xmm0, xmm0          ; xmm3[0] = x*x
  vmovshdup    xmm2, xmm0                ; xmm2 = {xmm0[1], xmm0[1], โ€ฆ} โ†’ lane 0 holds y
  vfmadd231ss  xmm3, xmm2, xmm2          ; xmm3[0] += y*y   (one rounding for the multiply-add)
  vfmadd231ss  xmm3, xmm1, xmm1          ; xmm3[0] += z*z   โ†’ lenSquared
  vsqrtss      xmm3, xmm3, xmm3          ; xmm3[0] = sqrt(lenSquared)
  vmovss       xmm4, dword ptr [rip + .LC0] ; xmm4[0] = 1.0f from a read-only constant
  vdivss       xmm3, xmm4, xmm3          ; xmm3[0] = invLen
  vmulss       xmm0, xmm0, xmm3          ; xmm0[0] = x*invLen; xmm0[1..3] copied from the first source
  vmulss       xmm2, xmm2, xmm3          ; xmm2[0] = y*invLen
  vmulss       xmm1, xmm1, xmm3          ; xmm1[0] = z*invLen
  vunpcklps    xmm0, xmm0, xmm2          ; xmm0 = {x*invLen, y*invLen, โ€ฆ}
  ret                                    ; return {x', y'} in xmm0, z' in xmm1[0]

A few things are happening here that aren't obvious from the source. The two-XMM vec3 argument convention is from the System V ABI[6]: an aggregate of three floats has its x,y in xmm0 and z in xmm1, and the same two registers carry the result back. The compiler contracted x*x + y*y + z*z into one multiply and two vfmadd231ss (fused multiply-add, scalar single-precision), keeping the source's left-to-right order; an FMA does a += b*c in one ฮผop with one rounding step instead of two[16], so this build's result can differ in the last bit from a build without FMA. The -fno-math-errno flag matters: without it, GCC and Clang on Linux keep a compare and a fallback call to sqrtf so that a negative input still sets errno. The vsqrtss / vdivss pair is the correctly rounded square root and reciprocal; hot paths sometimes replace it with vrsqrtss (an approximate reciprocal square root with about 12 bits of precision) plus one Newton-Raphson step, trading a little accuracy for lower latency. Compilers make that substitution only when told they may (fast-math flags, plus -mrecip on GCC), because it changes results.

Reading conventions: the v prefix on each mnemonic is the AVX (VEX-encoded) three-operand form. Without AVX the same multiply would be mulss xmm3, xmm0, where the destination is always also a source, so keeping x alive would cost an extra movaps to copy it first. Three-operand AVX writes the result to a separate register instead. The ss suffix means "scalar single-precision": one float in the low 32 bits of the XMM register, with the other three lanes passed through[17].

11Branches, mispredictions, and the branchless idiom

The CPU does not wait for a conditional branch to resolve before fetching the next instructions. It predicts the outcome of every branch (using per-branch and global history, an idea refined continuously since the two-level predictors of the early 1990s[18]) and speculatively executes the predicted path. When the prediction is right (the usual case for tight loops, monotonic conditions, and any pattern with a stable history), the branch is approximately free. When it is wrong, the CPU discards the speculative work and refetches; the cost is roughly ten to twenty cycles on recent Intel and AMD cores[12].

"Approximately free" is worth measuring. Cloudflare's 2021 microbenchmark of long chains of predicted unconditional jumps reports about 2 cycles per jmp on an Intel Xeon Gold 6262 (degrading past the ~4096-entry BTB capacity), about 3.5 cycles on Zen 2 (AMD EPYC 7642), rising to about 10.5 once the chain passes 4096 jumps, and 1 cycle on Apple's M1 when the code fits in 4 KB, 3 cycles beyond that. On the EPYC, never-taken conditional branches cost about 0.3 cycles each no matter how many there were[30]. Two consequences. First, a correctly predicted taken branch still costs one to a few cycles of front-end throughput; a correctly predicted not-taken branch is close to free. Second, the cost is non-linear in the amount of hot code: a loop whose taken branches fit in the BTB and one that just overflows it can differ by about 3ร— with no source change.

For data-dependent branches that the predictor cannot learn (a random check against a 50/50 input, or a comparison against a key that changes per iteration), the misprediction cost dominates. The remedy is branchless code: replace the conditional with a computation that produces the same result regardless of the predicate, using cmovcc or bit tricks. Take the absolute-value-of-int problem:

abs.s ยท branchy vs branchless
; Branchy: easy to read, mispredicts about half the time on random signs
abs_branchy:
  test edi, edi
  jns  .Lpos                   ; jump if the sign flag is clear (x >= 0)
  neg  edi
.Lpos:
  mov  eax, edi
  ret

; Branchless: same answer, no jump. Cost is constant.
abs_branchless:
  mov  eax, edi                 ; eax = x (the default)
  neg  eax                      ; eax = -x; flags reflect -x
  cmovl eax, edi                 ; if -x < 0 (i.e., x was positive), take the original instead
  ret                            ; eax holds |x|; UB for x = INT_MIN, same as the branchy form

The older bit-trick form, from before cmov existed, is mov eax, edi; cdq; xor eax, edx; sub eax, edx. cdq sign-extends eax into edx, producing an all-ones mask if x is negative and an all-zeros mask otherwise; the xor and sub then compute (x XOR mask) โˆ’ mask, which evaluates to x when the mask is zero and to โˆ’x when the mask is all-ones. Four instructions, no jump, and no flags read[19].

When to use which. Branchless is faster when the predictor cannot learn the pattern. It is slower when the predictor can, because cmov introduces an unconditional data dependency: the result waits for both the source and the predicate, even when the predicate could have been predicted and the dependent computation skipped. The standard demonstration is one of the most-upvoted questions on Stack Overflow[20]: summing the elements at or above a threshold in a 32,768-element array runs several times faster when the array is sorted first, because the predictor mispredicts only around the point where the values cross the threshold. The accepted answer's branchless version runs at the same speed on both inputs and, on the sorted one, is slightly slower than the branchy version. (Current compilers at -O3 often emit cmov or vectorize that loop themselves, which erases the difference.)

The widget models this. It runs the same predicate over an array that is either random or sorted; the branchy version is fast on the sorted input and slow on the random one:

Live ยท Branchy vs branchless
branchy cycles
ยทยทยท
branchless cycles
ยทยทยท
branchy รท branchless
ยทยทยท
A simplified model. The predictor is a single 2-bit saturating counter. The branchy version pays 14 cycles per mispredict (within the range Agner Fog measures for recent Intel and AMD cores[12]) and 1 cycle per correct prediction; the branchless version pays 3 cycles per element unconditionally. On sorted data the counter mispredicts only at the start and twice at the crossover, and the branchy version wins; on random data it mispredicts about half the time and the branchless version wins.

12SIMD: SSE, AVX, AVX-512

SIMD (Single Instruction, Multiple Data) replaces a loop body that operates on one scalar with one instruction that operates on a vector of values. Every x86-64 CPU has SSE2; nearly every mainstream Core, Ryzen, and current console chip from the last decade has AVX2; Intel server chips and AMD Zen 4 and later have AVX-512[10]. The mental model:

The suffix on a SIMD mnemonic names the lane format. ps = packed single (4/8/16 floats), pd = packed double, ss = scalar single (one float, others untouched), sd = scalar double. Integer variants use b/w/d/q for byte/word/dword/qword. vpaddd ymm0, ymm1, ymm2 is eight 32-bit integer adds in parallel[17].

The widget below shows one SIMD operation across a register. vaddps on a YMM register adds two eight-lane vectors in a single instruction. A highlight sweeps across the lanes so each lane index is easy to follow; the hardware computes all eight in the same operation:

Live ยท SIMD lanes in flight
A YMM add or FMA is one ฮผop on Skylake and on Zen 2 and later (Zen 1 splits it into two 128-bit halves), and Skylake can start two of them per cycle; the result is ready 4 cycles later[13]. The sweep is slowed down for readability. In the vshufps view, immediate 0xEE takes elements 2 and 3 of the first source, then 2 and 3 of the second, separately in each 128-bit half.
What about ARM? AAPCS64 and NEON in 30 seconds.

Arm (AArch64) is the architecture of Apple Silicon, Nintendo Switch, Nintendo Switch 2, every modern Android phone, and the Arm-based AWS Graviton servers. The ISA differs from x86-64 in three big ways: it is fixed-length 32-bit (no variable-length encoding), it is load-store (no read-modify-write on memory operands), and it has 31 general-purpose 64-bit registers (X0โ€“X30, plus a zero register XZR and the stack pointer SP).

The AArch64 Procedure Call Standard[21] passes integer args in X0โ€“X7 and float args in V0โ€“V7. Callee-saved registers are X19โ€“X28 (plus the frame pointer X29) and the low 64 bits of V8โ€“V15. SIMD lives in 32 "V" registers, each 128 bits; instructions like fmla v0.4s, v1.4s, v2.4s are the rough equivalent of vfmadd231ps xmm0, xmm1, xmm2. The SVE/SVE2 vector extensions (vector-length-agnostic; on Arm Neoverse server cores and recent Cortex phone cores, while Apple's M4 exposes them only inside its SME streaming mode) generalize this to longer registers without recompiling.

Most of this tutorial transfers across by mechanical translation: register names and syntax change, but the same dependency chains, port pressure, and kinds of ABI mistakes apply.

13A worked SIMD example: 4ร—4 matrix-vector multiply

The workhorse transform of 3D code: multiply a 4-component vector by a 4ร—4 matrix. Naively, sixteen multiplies and twelve adds (four dot products of length 4) per vertex. With SSE the same operation is four broadcasts plus four multiplies and three adds on 128-bit vectors, or one multiply and three FMAs. The standard formulation, for a column-major matrix M = [c0 | c1 | c2 | c3] and a vector v = (x, y, z, w):

Mv = c0ยทx + c1ยทy + c2ยทz + c3ยทw

Each ciยทs is a scalar-times-vector, which in SSE is a broadcast of the scalar across four lanes followed by a multiply. The three additions chain together. Source:

matvec.cpp ยท 4x4 column-major mat * vec, SSE + FMA3
// 16-byte aligned so the columns can be loaded with movaps. col[i] is column i of M.
struct alignas(16) mat4 { __m128 col[4]; };

__m128 mul(const mat4& matrix, __m128 vec) {
  // Broadcast each component of vec into all four lanes.
  __m128 splatX = _mm_shuffle_ps(vec, vec, _MM_SHUFFLE(0, 0, 0, 0));
  __m128 splatY = _mm_shuffle_ps(vec, vec, _MM_SHUFFLE(1, 1, 1, 1));
  __m128 splatZ = _mm_shuffle_ps(vec, vec, _MM_SHUFFLE(2, 2, 2, 2));
  __m128 splatW = _mm_shuffle_ps(vec, vec, _MM_SHUFFLE(3, 3, 3, 3));
  __m128 result = _mm_mul_ps(matrix.col[0], splatX);          // c0 * x
  result = _mm_fmadd_ps(matrix.col[1], splatY, result);          // + c1 * y   (FMA3)
  result = _mm_fmadd_ps(matrix.col[2], splatZ, result);          // + c2 * z
  result = _mm_fmadd_ps(matrix.col[3], splatW, result);          // + c3 * w
  return result;
}

With clang -O3 -mavx2 -mfma, the body is nine instructions plus the return:

matvec.s ยท illustrative clang -O3 -mavx2 -mfma output
mul(mat4 const&, __m128):
  ; rdi = const mat4*  (the matrix);  xmm0 = v
  vshufps      xmm1, xmm0, xmm0, 0x00       ; xmm1 = {v.x, v.x, v.x, v.x}
  vshufps      xmm2, xmm0, xmm0, 0x55       ; xmm2 = {v.y, v.y, v.y, v.y}
  vshufps      xmm3, xmm0, xmm0, 0xAA       ; xmm3 = {v.z, v.z, v.z, v.z}
  vshufps      xmm0, xmm0, xmm0, 0xFF       ; xmm0 = {v.w, v.w, v.w, v.w} (last use of v)
  vmulps       xmm1, xmm1, [rdi + 0x00]     ; xmm1  = c0 * v.x
  vfmadd231ps  xmm1, xmm2, [rdi + 0x10]     ; xmm1 += c1 * v.y   (231 form: dst = src2*src3 + dst)
  vfmadd231ps  xmm1, xmm3, [rdi + 0x20]     ; xmm1 += c2 * v.z
  vfmadd132ps  xmm0, xmm1, [rdi + 0x30]     ; xmm0  = v.w * c3 + xmm1   (132 form lands the result in xmm0)
  ret

Two observations worth pulling out. The four multiply-adds form one serial chain: a multiply and three dependent FMAs, about 16 cycles of latency at Skylake's 4 cycles each. Writing the source as two chains, (c0ยทx + c1ยทy) + (c2ยทz + c3ยทw), cuts the critical path to three dependent operations (about 12 cycles) for one extra instruction. Compilers won't regroup it on their own without -ffast-math (specifically -fassociative-math), because the regrouped sum rounds differently. Second, the matrix columns are read directly by the multiply and FMA memory operands ([rdi + 0x10], etc.); the load and the arithmetic travel through the front end as one fused ฮผop on Intel and one macro-op on AMD[13], so a separate vmovaps would only add work.

What's intentionally missing from this code. The 128-bit form processes one vertex at a time. The throughput-tuned form transposes the data to SoA so that one ymm/zmm register holds the same component (x, then y, then z) for many vertices, and a single broadcast-and-FMA transforms 8 vertices at a time with AVX or 16 with AVX-512. The matrix is also typically loaded once and reused across many vertices; this code reloads it every call. Per-vertex rendering transforms run on the GPU in vertex shaders, so CPU-side vector transforms mostly serve culling, physics, animation pose evaluation, and gameplay queries.

14Intrinsics vs inline assembly vs writing pure asm

Three ways to drop below the C/C++ language barrier, in roughly decreasing order of how often you should use them:

CPUID, used at startup to detect available CPU features, makes a compact example. Production code should use the compiler's wrapper (__get_cpuid_count from <cpuid.h> on GCC and Clang, __cpuidex from <intrin.h> on MSVC); the raw inline-asm form shows how operand constraints work:

cpuid.c ยท inline asm with operand constraints
static inline void cpuid(int leaf, int subleaf,
                         int *eax, int *ebx, int *ecx, int *edx) {
  __asm__ (
    "cpuid"
    : "=a"(*eax),       // output: eax โ†’ *eax
      "=b"(*ebx),       // output: ebx โ†’ *ebx
      "=c"(*ecx),       // output: ecx โ†’ *ecx
      "=d"(*edx)        // output: edx โ†’ *edx
    : "a"(leaf),        // input:  leaf โ†’ eax
      "c"(subleaf)      // input:  subleaf โ†’ ecx (leaf 7, which reports AVX2 and
                        //         AVX-512, reads it; stale ecx gives wrong bits)
  );
}

The output constraints "=a", "=b", "=c", "=d" tell the compiler that cpuid writes those four registers and the values should go to the named C variables. The input constraints "a"(leaf) and "c"(subleaf) tell the compiler to load those values into eax and ecx before executing the instruction. That is the whole mechanism; the rest is constraint syntax[22].

Two reliable failure modes. Forgetting clobbers. If your inline asm modifies a register that isn't an output, or touches memory the compiler can't see through the operands, it has to be declared in the clobber list ("memory" for the latter), or the compiler will assume nothing changed. Treating it as free. Inline asm is a black box to the optimizer: the compiler can't vectorize through it, schedule instructions across it, or constant-fold its inputs. A tight intrinsic-based loop is usually faster than the same loop with one inline-asm instruction in the middle.

15Atomics and fences at the machine level

A pointer to the Memory Model tutorial, condensed: every lock-free std::atomic operation in C++ lowers to a specific machine instruction whose ordering guarantees match the requested memory_order. The mapping on x86-64[24]:

On Arm (AArch64) the lowering is more visible. An acquire load lowers to LDAR (Load-Acquire Register) and a release store to STLR (Store-Release Register). Seq-cst loads and stores use the same two instructions: ARMv8 defined LDAR/STLR so that, used together, they give sequential consistency without extra barriers[24]. 32-bit ARMv7 had no such instructions and surrounded plain loads and stores with DMB barriers instead; P0668 revised the C++ model partly so that fence-based mappings like those, and the ones used on POWER, are actually correct. The 2013 paper by Lรช, Pop, Cohen, and Zappa Nardelli[26] shows what a correct C11-atomics version of a real lock-free structure, the Chase-Lev work-stealing deque, needs on Arm and POWER; see the memory model tutorial for the full picture.

16ฮผops, ports, and dependency chains in practice

ยง3 sketched the out-of-order engine. With the SIMD walkthrough in mind, here is what microarchitectural tuning looks like in practice. Take a reduction: summing an array of floats that fits in L1.

sum_serial.cpp ยท the naive loop
float sumSerial(const float* values, int count) {
  float sum = 0;
  for (int i = 0; i < count; ++i) sum += values[i];   // each add waits for the previous one
  return sum;
}

Compiled with -O2 (no fast-math), the inner loop is one addss per element. addss on Skylake has a latency of 4 cycles and a throughput of 2 per cycle[13]. Because each iteration depends on sum from the previous iteration, the loop is bound by the latency, not the throughput: one element every 4 cycles. The second add port and most of the other ports sit idle.

The fix is to break the dependency chain: use several accumulators that the CPU can work on independently. -O3 -ffast-math on a modern compiler does this for you (-fassociative-math is the specific permission needed), producing something like:

sum_parallel.cpp ยท multi-accumulator + SIMD, after autovectorization
float sumParallel(const float* values, int count) {
  // Four independent vector accumulators, eight floats each. Assumes count is a
  // multiple of 32; the compiler's version adds a tail loop.
  __m256 sum0 = _mm256_setzero_ps(), sum1 = _mm256_setzero_ps();
  __m256 sum2 = _mm256_setzero_ps(), sum3 = _mm256_setzero_ps();
  for (int i = 0; i < count; i += 32) {
    sum0 = _mm256_add_ps(sum0, _mm256_loadu_ps(values + i + 0));
    sum1 = _mm256_add_ps(sum1, _mm256_loadu_ps(values + i + 8));
    sum2 = _mm256_add_ps(sum2, _mm256_loadu_ps(values + i + 16));
    sum3 = _mm256_add_ps(sum3, _mm256_loadu_ps(values + i + 24));
  }
  __m256 total = _mm256_add_ps(_mm256_add_ps(sum0, sum1), _mm256_add_ps(sum2, sum3));
  // horizontal sum of total into a scalar; details elided
  ...
}

Four independent accumulators, each a 256-bit vector of eight floats. Each accumulator is still a chain with 4-cycle latency, so each one takes a new add every 4 cycles, and four chains give one 256-bit add per cycle: 8 floats per cycle. Compared to the naive loop's 0.25 floats per cycle, that is a 32ร— speedup, 8ร— from the SIMD width and 4ร— from running four chains at once. It is still half of what Skylake can do. Skylake has two 256-bit add ports and two 256-bit load ports[13][12], so eight accumulators (latency 4 ร— two adds per cycle, the same arithmetic as the SIMD tutorial's reduction chapter) would reach 16 floats per cycle, where the add ports and the load ports both saturate. That peak holds only while the data is in L1. For a larger array the loop becomes bound by L2, L3, or DRAM bandwidth instead: a million floats is 4 MB, far more than Skylake's L2.

Three tools check this kind of analysis. llvm-mca[27] takes a chunk of assembly and reports the expected steady-state IPC and per-port pressure. uica.uops.info[28] predicts the same from a more detailed simulation of Intel cores. perf stat -e cycles,instructions,branches,branch-misses,... on Linux measures what actually happened, from the CPU's performance counters.

17Pitfalls

18What's next

Where to go from here:

19Sources & further reading

Numbered citations refer to the superscripts above. Most are freely available; a few are vendor manuals that require a no-cost registration.

A note on originality

The prose, code samples, CSS, and interactive widgets on this page are original writing. The instruction-encoding decomposition follows the Intel SDM Volume 2 chapter on instruction format [10]. The two ABI tables in ยง8 are a side-by-side condensation of the System V AMD64 ABI [6] and the Microsoft x64 calling convention reference [7]; consult them before relying on any field. Latency, throughput, and ฮผop counts come from uops.info [13] and Agner Fog's manuals [12]. The branchy-vs-branchless model in ยง11 uses Agner Fog's misprediction penalties [12] and follows the Stack Overflow demonstration [20]; the bit-trick form of abs is from Hacker's Delight ยง2-4 [19]. The C++ atomic lowering in ยง15 matches the mappings documented at [24].

  1. Godbolt, M. Compiler Explorer. godbolt.org. The interactive compile-and-disassemble tool; supports GCC, Clang, MSVC, ICX, and a long tail of other compilers, and is the easiest way to reproduce the listings on this page.
  2. Lengyel, E. (2011). Mathematics for 3D Game Programming and Computer Graphics (3rd ed.). Cengage. A standard reference for the vector and matrix math that engine code implements, including the transforms of ยง13.
  3. Intel Corporation. Intelยฎ 64 and IA-32 Architectures Optimization Reference Manual. Order Number 248966. intel.com. Intel's microarchitecture guide: pipeline structure, ฮผop fusion, zeroing idioms, and the SSE/AVX transition behavior of each generation.
  4. Advanced Micro Devices. Software Optimization Guide for the AMD Zen5 Microarchitecture. Publication #58455. docs.amd.com. AMD's counterpart to the Intel optimization manual, with official instruction latency and throughput tables; the earlier Family 19h guide (#56665, Zen 3) is archived here.
  5. Fog, A. Software optimization resources. agner.org/optimize. Five manuals: optimizing C++, optimizing assembly, the microarchitecture of Intel/AMD/VIA CPUs, instruction tables, and calling conventions. Free; the microarchitecture manual was last revised in 2026.
  6. Matz, M., Hubiฤka, J., Jaeger, A., & Mitchell, M. System V Application Binary Interface, AMD64 Architecture Processor Supplement. gitlab.com/x86-psABIs/x86-64-ABI. The normative document for Linux, macOS, and the BSDs; the PlayStation toolchains use a System V-derived convention. Continuously revised; the LaTeX source is the canonical version.
  7. Microsoft. x64 calling convention. learn.microsoft.com. The Microsoft x64 ABI reference: argument registers, callee/caller-saved registers, shadow space, and struct passing rules.
  8. Advanced Micro Devices. AMD64 Architecture Programmer's Manual, Volume 1: Application Programming. Publication #24592. amd.com (archived copy). The AMD64 architecture reference; describes the REX prefix, the 64-bit operand size rules, and the zero-extension of 32-bit register writes.
  9. Intel Corporation. (1979). The 8086 Family User's Manual. The original 8086 reference, historical only. Scanned at bitsavers.org.
  10. Intel Corporation. Intelยฎ 64 and IA-32 Architectures Software Developer's Manual. Order Numbers 253665โ€“253669. intel.com. Volume 1 is the architecture overview, Volume 2 the instruction set reference (the encoding tables), Volume 3 system programming.
  11. Intel Corporation. (2023). Intelยฎ Advanced Performance Extensions (Intelยฎ APX) Architecture Specification. intel.com. The extension to 32 general-purpose registers and three-operand forms of the legacy integer instructions.
  12. Fog, A. The microarchitecture of Intel, AMD and VIA CPUs. Manual 3 of the agner.org/optimize series, periodically revised: pipeline widths, cache sizes, branch misprediction penalties, and partial-register behavior for each core.
  13. Abel, A., & Reineke, J. (2019). uops.info: Characterizing Latency, Throughput, and Port Usage of Instructions on Intel Microarchitectures. ASPLOS. Project home: uops.info. Machine-measured latency, throughput, and port-usage tables for most x86-64 instructions on Intel cores from Conroe onward and AMD cores from Zen+ onward.
  14. Gregg, B. (2024). The Return of the Frame Pointers. brendangregg.com. Why Fedora 38 and Ubuntu 24.04 build their packages with -fno-omit-frame-pointer, and what frame pointers do for production profiling.
  15. Apple. (2024). Writing ARM64 code for Apple platforms. developer.apple.com. Apple's ARM64 platform conventions, including its frame-pointer requirement, which is stricter than the AAPCS64 baseline.
  16. Muller, J.-M., Brisebarre, N., de Dinechin, F., Jeannerod, C.-P., Lefรจvre, V., Melquiond, G., Revol, N., Stehlรฉ, D., & Torres, S. (2018). Handbook of Floating-Point Arithmetic (2nd ed.). Birkhรคuser. The reference for FMA rounding semantics: a single rounding step after the multiply-then-add, instead of two.
  17. Intel Corporation. Intelยฎ Intrinsics Guide. intel.com/intrinsics-guide. The searchable reference for the intrinsics in the <immintrin.h> family: the instruction each one maps to, its CPUID feature flag, and pseudo-code for the operation.
  18. Yeh, T.-Y., & Patt, Y. N. (1991). Two-Level Adaptive Training Branch Prediction. MICRO. ACM. The two-level adaptive predictor that later designs, from gshare to TAGE and perceptron predictors, build on.
  19. Warren, H. S. (2012). Hacker's Delight (2nd ed.). Addison-Wesley. The standard reference for bit-twiddling: branchless absolute value, sign extension, population count, and more. ยง2-4 has the absolute-value bit trick used in ยง11.
  20. Stack Overflow. Why is processing a sorted array faster than processing an unsorted array? (2012). stackoverflow.com. One of the site's most-upvoted questions; the accepted answer times branchy and branchless versions on sorted and random data.
  21. Arm Limited. Procedure Call Standard for the Armยฎ 64-bit Architecture (AArch64). Document IHI 0055. github.com/ARM-software/abi-aa. The AArch64 ABI: argument registers, callee-saved registers, parameter passing rules, and the vector register conventions.
  22. Free Software Foundation. Extended Asm โ€” Assembler Instructions with C Expression Operands. GCC manual. gcc.gnu.org. The reference for GCC inline assembly constraints and clobbers; Clang follows the same syntax.
  23. Microsoft. Inline assembler. learn.microsoft.com. The MSVC documentation; states that inline assembly is not supported on x64 or ARM64 and points to intrinsics and separate MASM files instead.
  24. Boehm, H.-J., Giroux, O., & Vafeiadis, V. (2018). P0668R5 โ€” Revising the C++ memory model. WG21. open-std.org. The proposal that revised seq_cst semantics so that the fence-based mappings used on POWER and ARMv7 are correct. The instruction mappings for each architecture are collected at the Cambridge C/C++11 mappings to processors page.
  25. Intel Corporation. Intelยฎ 64 and IA-32 Architectures Software Developer's Manual, Volume 3A, chapter on multiple-processor management (memory ordering and serializing instructions). intel.com. The normative source on MFENCE, LFENCE, SFENCE, and locked instructions as full barriers.
  26. Lรช, N. M., Pop, A., Cohen, A., & Zappa Nardelli, F. (2013). Correct and Efficient Work-Stealing for Weak Memory Models. PPoPP. PDF. A proven-correct C11-atomics version of the Chase-Lev work-stealing deque, with its ARM and POWER implementations.
  27. LLVM Project. llvm-mca โ€” LLVM Machine Code Analyzer. llvm.org. A static throughput analyzer that takes a region of assembly and reports per-port pressure and steady-state IPC; ships with LLVM.
  28. Abel, A., & Reineke, J. (2022). uiCA: Accurate Throughput Prediction of Basic Blocks on Recent Intel Microarchitectures. ICS. Project home: uica.uops.info. A simulator with a more detailed model of the front end, ฮผop cache, and back-end ports than llvm-mca.
  29. Paoloni, G. (2010). How to Benchmark Code Execution Times on Intelยฎ IA-32 and IA-64 Instruction Set Architectures. Intel white paper. How to use RDTSC and RDTSCP for timing, including the serialization needed to keep the reads from reordering around the measured code.
  30. Majkowski, M. (2021). Branch predictor: How many "if"s are too many? Cloudflare blog. blog.cloudflare.com. Microbenchmarks of long predicted-branch chains on an Intel Xeon Gold 6262, AMD EPYC 7642 (Zen 2) and 7713 (Zen 3), and Apple M1, with reproducible code. Source for the per-jump cycle costs and the BTB-capacity cliff cited in ยง11.

Further reading

Not cited above, but worth the time:

See also