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.
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 optimizer didn't do what you assumed. A loop you thought was unrolled wasn't. A condition you thought compiled to
cmovcompiled to a branch. A small struct you thought was returned in registers got spilled to memory. Godbolt[1] exists for these moments: the assembly shows what the compiler actually did. - A bug only reproduces under release optimizations. The compiler reordered or elided code in a way that exposed a latent UB. Reading the assembly is faster than printf-debugging a release build.
- You're integrating native libraries across an ABI. A C++ function calling into FFI code from Rust, an FMOD plugin, or a custom JIT, has to follow the platform's calling convention exactly. The only way to verify is to look at what each side emits.
- Microarchitecture tuning. When a hot SIMD loop is running at half throughput, the answer lives in the dependency chain or port pressure of the emitted instructions, not in the source.
- No symbols at the crash site. Reading a crash dump from a stripped binary in production means reading
add rsp, 0x28; retand inferring the function.
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].
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:
int add(int a, int b) { return a + b; }
The compiler emits:
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.
long double on Linux.
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").
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.
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:
- Fetch. The CPU reads 16 to 32 bytes per cycle from the L1 instruction cache (32 KB on Skylake, Sunny Cove, Golden Cove, and Zen 2 through Zen 5; 64 KB on Zen 1 and Lion Cove[12]) into an aligned fetch window.
- Decode. Four decoders on Skylake and on Zen 3 and Zen 4, six on Golden Cove, eight on Lion Cove convert variable-length x86 instructions into fixed-format ฮผops. Most simple instructions decode to a single ฮผop; string ops, integer divide, and a handful of complex instructions decode to several.
- ฮผop cache. Recent x86 cores keep a cache of already-decoded ฮผops. A hot loop that fits in it (1.5K ฮผops on Skylake, 4K on Golden Cove[12]) bypasses the legacy decoders entirely. AMD's equivalent on Zen 4 holds about 6.75K ops.
- Rename and dispatch. The renamer maps each architectural register named in the instruction to a physical register from a pool of a few hundred. This removes false dependencies: in
imul rax, rbxfollowed later bymov rax, 5, themovwrites a fresh physical register, so it doesn't wait for the slow multiply to finish. True dependencies stay:add rax, 1; add rax, 2still runs in order, because the second add reads the first one's result. - Schedule and execute. The scheduler issues ready ฮผops to a set of execution ports: 8 on Skylake, 10 on Sunny Cove, 12 on Golden Cove, and a comparable number on recent Zen cores[13]. A given port can start one ฮผop per cycle. Ports are specialized; integer divide, store-data, branches, and vector multiply each live on a subset.
- Retire. A reorder buffer holds completed ฮผops until every earlier ฮผop has finished. Retirement happens in program order, at 4 ฮผops per cycle on Skylake and 8 or more on the newest Intel and AMD cores[12]. Once an instruction's ฮผops retire, its writes become architecturally visible; until then the CPU can roll them back (on a branch mispredict, an exception, or a memory-order violation).
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:
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-bit | 32-bit | 16-bit | 8-bit low | Conventional role (System V) |
|---|---|---|---|---|
rax | eax | ax | al | return value (int); scratch |
rbx | ebx | bx | bl | callee-saved |
rcx | ecx | cx | cl | 4th integer arg |
rdx | edx | dx | dl | 3rd integer arg; high half of 128-bit return |
rsi | esi | si | sil | 2nd integer arg |
rdi | edi | di | dil | 1st integer arg |
rbp | ebp | bp | bpl | frame pointer; callee-saved |
rsp | esp | sp | spl | stack pointer |
r8 | r8d | r8w | r8b | 5th integer arg |
r9 | r9d | r9w | r9b | 6th 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:
xmm0โxmm15(with AVX-512:xmm0โxmm31): 128 bits. SSE/SSE2/SSE3/SSE4 baseline.ymm0โymm15(with AVX-512:ymm0โymm31): 256 bits. AVX/AVX2. The lower 128 bits ofymm0isxmm0.zmm0โzmm31: 512 bits. AVX-512. The lower 256 bits isymm.
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:
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]:
- Legacy prefixes (0โ4 bytes). Address-size override, operand-size override, segment override, repeat prefix,
LOCK. - 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. - Opcode (1โ3 bytes). Selects the instruction; in the "+r" forms, part of the opcode byte also encodes a register.
- ModRM (0โ1 byte). For instructions with operands, encodes the addressing mode and one or two register fields.
- SIB (0โ1 byte). Scale/Index/Base for the memory addressing modes that need it (
[rbx + rcx*4 + 0x10]). - Displacement (0, 1, or 4 bytes in 64-bit code; 8 only for the special
moffsforms ofmov). The constant offset in a memory operand. - 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:
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:
| Mnemonic | What it does | Form you'll usually see |
|---|---|---|
mov | Copy bits. Register-to-register, register-to-memory, memory-to-register, immediate-to-register. The basic data movement instruction. | mov rax, [rdi + 8] |
lea | Compute an address (or any 3-operand arithmetic that fits the addressing modes), don't dereference. See ยง7. | lea rax, [rdi + rsi*4] |
add / sub | Integer add/subtract. Two-operand: dst = dst op src. | add rsp, 0x28 |
imul | Signed 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 / sar | Bit shift left, logical right, arithmetic right. sar preserves the sign bit; shr doesn't. | shl rax, 3 |
and / or / xor | Bitwise ops. xor reg, reg is the canonical zeroing idiom. | xor eax, eax |
cmp / test | Set the flags register. cmp a, b = subtract without storing; test a, b = AND without storing. | cmp rax, 0 |
jcc | Conditional jump. je = jump if equal (ZF=1), jne, jl, jg, jb, ja (signed vs unsigned). Follows a cmp or test. | jne loop |
jmp | Unconditional jump. | jmp .L7 |
call / ret | Function call and return. call pushes the return address and jumps; ret pops and jumps. | call malloc |
push / pop | Decrement RSP by 8 and store; or load and increment by 8. Used in function prologues/epilogues for callee-saved registers. | push rbp |
cmovcc | Conditional move. cmovge dst, src = if SF=OF then dst=src. Branchless conditionals; see ยง11. | cmovl rax, rdi |
setcc | Set 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 / movsx | Move 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:
; 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:
| Int args (in order) | rdi, rsi, rdx, rcx, r8, r9 |
|---|---|
| Float args | xmm0โxmm7 |
| Int return | rax (high half: rdx) |
| Float return | xmm0 (high half: xmm1) |
| Caller-saved | rax, rcx, rdx, rsi, rdi, r8โr11, all xmm* |
| Callee-saved | rbx, rbp, r12โr15, rsp |
| Stack alignment at call | 16-byte (so rsp = 16k โ 8 on entry) |
| Red zone | 128 bytes below rsp usable without adjusting |
| Shadow space | None |
| Int args (in order) | rcx, rdx, r8, r9 |
|---|---|
| Float args | xmm0โxmm3 |
| Int return | rax |
| Float return | xmm0 |
| Caller-saved | rax, rcx, rdx, r8โr11, xmm0โxmm5 |
| Callee-saved | rbx, rbp, rdi, rsi, rsp, r12โr15, xmm6โxmm15 |
| Stack alignment at call | 16-byte (same) |
| Red zone | None |
| Shadow space | 32 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:
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:
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 stack must be 16-byte aligned at every
callsite. Becausecallpushes the 8-byte return address,rspon function entry is always 16k โ 8. The prologue'spush rbpbrings it back to 16k. Each further push or local allocation has to leaverspaligned again before the nextcall; that is why frame sizes often look like a multiple of 16 plus 8. - The frame pointer is optional on x86-64. With
-fomit-frame-pointer(default at-O1and above in GCC and Clang for many years), the compiler skipspush rbp; mov rbp, rspand addresses locals offrspdirectly. This frees a register but makes stack unwinding harder; profilers and debuggers fall back to DWARF CFI in.eh_frame, which is slower to walk. Fedora 38 (2023) and Ubuntu 24.04 (2024) reversed their distro defaults and now ship system binaries with frame pointers on, so that production profilers likeperfcan unwind through them cheaply[14]. Apple's AArch64 ABI requires the frame pointer on every non-leaf function[15]. - Simple leaf functions have no prologue at all. A function that calls nothing else and uses only caller-saved registers (like the
addfrom ยง1) emits a single instruction body and aret. On System V it can also use the 128-byte "red zone" belowrspfor scratch storage without subtracting fromrspat all[6].
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:
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(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:
; 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:
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:
- An XMM register holds 128 bits = 4 floats = 2 doubles = 16 bytes = 4 int32s.
- A YMM register holds 256 bits = 8 floats = 4 doubles = 32 bytes = 8 int32s.
- A ZMM register holds 512 bits = 16 floats = 8 doubles = 64 bytes = 16 int32s.
- Each value in the register is a lane. SIMD instructions act on every lane in parallel:
vaddps ymm0, ymm1, ymm2adds eight pairs of single-precision floats simultaneously.
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:
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):
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:
// 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:
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:
- Compiler intrinsics. Header-defined functions (
<immintrin.h>on x86,<arm_neon.h>on Arm) that mostly map to a single instruction each[17]. The compiler still allocates registers, schedules, and inlines. This is the default. Almost every shipping engine math library is intrinsics, not hand assembly. - Inline assembly. GCC/Clang's
asmstatement[22] embeds asm in a C function with constraints describing the inputs, outputs, and clobbers. Useful for instructions your compiler exposes no intrinsic for, for exact instruction sequences the compiler must not rewrite, and occasionally for hand-tuned inner loops where the register allocator's choices are hurting. MSVC does not support inline asm in x64 code[23]; the Microsoft replacement is intrinsics or a separate.asmfile compiled byml64. - Pure assembly files.
.son GCC/Clang via GAS,.asmon MSVC via MASM or NASM. Used for the lowest-level platform code: context switches, fiber and coroutine switching, and startup or signal-handling glue. Rare in application or game code.
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:
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]:
- Relaxed load / store. Plain
mov. x86's TSO memory model already gives every load acquire ordering and every store release ordering, so relaxed and acquire/release emit the same instruction (they still constrain how the compiler may reorder code). - Acquire load, release store. Also a plain
mov, for the TSO reason above. - Sequentially-consistent load. Still a plain
mov; the mapping puts the extra cost on the seq-cst store instead. - Sequentially-consistent store. Either
movfollowed bymfence(GCC's historical choice) orxchg [mem], reg(Clang's choice;xchgwith a memory operand has an implicit LOCK prefix and is a full barrier). Both keep later loads from completing until the store buffer has drained[25]. The two forms are functionally interchangeable;xchgis usually the cheaper of the two on recent cores. - Atomic RMW (
fetch_add,compare_exchange). ALOCK-prefixed instruction (lock xadd,lock cmpxchg). The LOCK prefix asserts cross-core atomicity on the cache line and acts as a full barrier on x86.
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.
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:
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
- Mixing legacy SSE and VEX-encoded AVX. Running non-VEX SSE (
movaps xmm0, ...) while the YMM upper halves are dirty from VEX AVX code (vmulps ymm0, ...) costs a state transition of dozens of cycles on Sandy Bridge through Broadwell, and an extra blend ฮผop plus a false dependency per SSE instruction on Skylake and later[3]. The compiler emitsvzeroupperbefore leaving AVX code; hand-written code must too. - Assuming x86 latency is the cost. Latency only matters on the critical path. A ฮผop with latency 6 and throughput 2-per-cycle can issue every 0.5 cycles when its inputs are independent; the same ฮผop costs 6 cycles per iteration when chained. Read both numbers from uops.info[13], not just the latency.
- Partial-register writes. Writing
aland then readingeaxorraxstalled for several cycles on P6-family cores (Pentium Pro through Core 2 and Nehalem); Sandy Bridge replaced the stall with an inserted merge ฮผop, and later cores mostly just make the byte write depend on the register's old value[12]. That dependency can still serialize code that looked independent. Treatmovzx eax, alas the safe explicit form when widening. - Clobbering a callee-saved register and not restoring it. The ABI tables in ยง8 are the contract; ignoring them produces bugs that surface as data corruption many calls away. The defenses are explicit clobber lists in inline asm and a careful read of every hand-written prologue and epilogue.
- Hand-aligning data when the compiler already did.
alignas(16)in C++ (_Alignasin C11) gives the compiler the alignment it needs for aligned SSE loads. Hand-aligning by adding pad fields is a steady source of bugs after struct layout changes. - Optimizing inner loops the compiler already optimized. Before writing intrinsics, look at the compiler's output at
-O3with-march=native. Modern autovectorizers handle the standard patterns: element-wise maps, integer reductions, and floating-point reductions when allowed to reorder them. Hand-rolled intrinsics are right when the compiler bailed out on the pattern, not when it didn't. - RDTSC doesn't count core cycles. On current chips the TSC ticks at a constant rate regardless of turbo or power states, so it measures elapsed time, not the cycles a kernel took at whatever clock speed it ran. It also isn't serializing: without
rdtscpor anlfence, it can execute before the code being timed has finished[29]. Usestd::chrono::steady_clockfor wall time and the performance counters (perf, VTune, orrdpmconce the OS enables it) for cycle counts.
18What's next
Where to go from here:
- Read your own engine's hot paths. Pick the math, physics, animation, and renderer-glue libraries you ship. Build a release binary with debug info.
objdump -d -M intelon Linux,dumpbin /disasmon MSVC, or the Compiler Explorer[1] "load my source" workflow. Look at what your hot inline functions actually compile to. - Sit with Agner Fog's manuals.[5] The five PDFs at agner.org/optimize are the practitioner reference: manuals 1โ4 cover optimizing C++, optimizing assembly, microarchitecture, and instruction tables; manual 5 is calling conventions. Free, and still updated (the microarchitecture manual was last revised in 2026).
- Run llvm-mca and uica on the loops you write. Both are free; both take a small region of assembly and predict throughput and port pressure. Use them before and after intrinsic refactors to confirm the change you intended is the change the model sees.
- Read a real ABI document. The function-calling chapter of the System V AMD64 ABI[6] gives a very different picture of "what a function call is" than the C language does. The classification of struct fields into register classes, aggregate passing, and the variadic-function rules are all worth a slow read.
- Pair with the Memory Model tutorial. It picks up where ยง15 stops: why each
memory_orderlowers to the instructions shown there, and what goes wrong when the choice is too weak.
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.
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].
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- Microsoft. x64 calling convention. learn.microsoft.com. The Microsoft x64 ABI reference: argument registers, callee/caller-saved registers, shadow space, and struct passing rules.
- 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.
- Intel Corporation. (1979). The 8086 Family User's Manual. The original 8086 reference, historical only. Scanned at bitsavers.org.
- 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.
- 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.
- 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.
- 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.
-
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. - 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.
- 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.
-
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. - 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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:
- Hennessy, J. L., & Patterson, D. A. (2017). Computer Architecture: A Quantitative Approach (6th ed.). Morgan Kaufmann. The textbook on pipelining, out-of-order execution, and cache hierarchies; chapters 2 and 3 are the long-form version of ยง3 of this tutorial.
- Drepper, U. (2007). What Every Programmer Should Know About Memory. PDF. The long-form treatment of the memory hierarchy, prefetch, and cache effects.
- Giesen, F. (ryg). The ryg blog. fgiesen.wordpress.com. Practitioner writeups on SIMD, codec implementation, and the graphics pipeline.
- Muลa, W. Practical SIMD and bit-twiddling notes. 0x80.pl. Hundreds of microbenchmarks and worked examples for SSE, AVX2, AVX-512, and NEON.
- Dawson, B. Random ASCII. randomascii.wordpress.com. Long-running blog by a performance engineer (Microsoft, Valve, Google) with many investigations that end in a disassembly listing.
- Lemire, D. Daniel Lemire's blog. lemire.me/blog. Microbenchmarks and SIMD analyses on modern x86 and Arm, usually with published code.
- Patterson, D. A., & Waterman, A. (2017). The RISC-V Reader: An Open Architecture Atlas. Strawberry Canyon. A short introduction to a much simpler ISA; useful contrast when reading x86 encodings.
-
Intel Corporation. Intelยฎ VTuneโข Profiler User Guide. intel.com. Intel's profiler; reads the same performance counters as
perf, with a more detailed microarchitectural breakdown. - Bendersky, E. Eli Bendersky's website. eli.thegreenplace.net. Long-form articles on linkers, ELF, position-independent code, and how compilers generate the assembly you read.
- Hyde, R. (2010). The Art of Assembly Language (2nd ed.). No Starch Press. A long-running x86 assembly introduction built around Hyde's High Level Assembly (HLA); older editions are free at plantation-productions.com.