Part 0 · Orientation
The four fictions
Assembly is a contract, not a description. It presents four convenient lies, and fluency means knowing both the lie and where it breaks — plus a pretest you are meant to fail.
What this lesson is trying to break (3)
- Assembly is what the CPU executes.
- The listing tells you what happens, in the order it happens.
- One instruction is one unit of work.
Here is the organising claim of this course.
Assembly is a contract, not a description. It records what the program asked for. The silicon decides what happens.
Everything else follows from taking that seriously.
The four fictions
Assembly presents four convenient lies. Each is useful — you must reason in them to read anything at all — and each is wrong in a way that eventually costs you money.
Varies: which fiction is being told
| # | The fiction | What is actually true | Where you meet the gap |
|---|---|---|---|
| 1 | There are 16 registers, and each holds one value at a time. | Hundreds of physical registers, dynamically renamed. Two instructions using eax may be completely independent. | Why xor eax, eax is an idiom and not just a zero |
| 2 | Instructions execute in the order listed. | Out-of-order, pipelined and speculative. The listing is a dependency graph. | Why reordering source lines usually changes nothing |
| 3 | A load is a load. Memory is a flat array. | L1 ≈ 4 cycles, DRAM ≈ 200+. One instruction, two orders of magnitude. | Why the fast version has the same instruction count |
| 4 | One instruction is one unit of work. | x86 instructions decode to µops; some fuse, some split. add and div differ ~40×. | Why counting instructions predicts nothing |
Parts 1 to 3 make you fluent in the fictions, because you cannot read a listing without them. Part 4 takes all four apart. Most courses stop after fiction 2 and never mention 3 and 4 — which is how you get someone who can read assembly but cannot explain why the fast code is fast.
Read that table before continuing. The order matters: you have to accept the fictions before you can usefully lose them.
Why the fictions exist
Not incompetence or legacy — they are a deliberate, load-bearing abstraction.
The ISA is a stable interface. Intel and AMD ship radically different silicon that both run the same x86-64 instructions. Apple redesigns its cores every year while ARM64 binaries keep working. If assembly described the machine, every binary would break on every new chip.
So the contract is: you write requests in this fixed vocabulary; we will honour them by whatever means we like, as fast as we can, and we promise the observable result matches what a naive sequential reading would produce.
That promise is why fictions 1 and 2 are safe to reason in for correctness and useless for performance. Your program will behave as if execution were in order with 16 registers. It will not perform as if that were true.
xor eax, eax
Zero — and break the dependency.
Setting a register to zero could be mov eax, 0. Compilers overwhelmingly emit
xor eax, eax instead, and there are two reasons, one from each of the first two
fictions.
The boring reason: the encoding is shorter (2 bytes versus 5).
The real reason: the CPU recognises this exact pattern and treats the register as having no dependency on its previous value. Under fiction 1 that sentence is meaningless — a register just holds what it holds. Under register renaming it is the whole point: the instruction gets a fresh physical register and nothing waiting on the old value can stall it.
This is your first example of an idiom that is inexplicable in the fiction and obvious once you drop it.
Pretest — one question per part
Six questions, one from each remaining part. You have been taught none of it. Do not look anything up, and do not press any compile buttons. Guess, commit, and rate your confidence honestly.
This feels pointless. It is not: being tested on material before studying it measurably improves how much you learn afterwards, including for the items you answer wrong. The wrong answers you are about to give are doing work.
lea eax, [rdi+rdi*2]The mnemonic stands for “Load Effective Address”.
It multiplies by three. It does not touch memory at all.
lea computes what an address would be and puts that number in the
destination. rdi + rdi*2 is 3 × rdi. Nothing is loaded.
This is the single most misread instruction in x86-64, precisely because the name
and the brackets both say “memory”. Compilers use it constantly as a free
multiply-and-add, because the address-generation hardware can do
base + index*scale + displacement in one go.
If you predicted “loads from an address”, you read the name rather than the semantics — which is the correct instinct on your first day and a habit to lose by 1.3.
A C function takes an int as its first parameter, compiled for Linux or macOS
on x86-64. Which register is that parameter in when the function starts?
edi — the low 32 bits of rdi.
The SysV AMD64 calling convention passes the first six integer arguments in
rdi, rsi, rdx, rcx, r8, r9, in that order. Return values come back in rax.
Two things worth flagging now. First, this is convention, not hardware —
nothing in the CPU makes rdi special, and Windows x64 uses a different order
(rcx, rdx, r8, r9), which is why hand-written assembly is not portable across
operating systems on identical hardware. Second, ARM64 uses x0–x7, and x0
is also the return register, which is why the ARM64 version of a small function
often has one fewer instruction — you saw exactly that in
0.1.
Covered in 2.2.
int d(int x) {
return x / 3;
}No division instruction at all. You get this:
"d":
movsxd rax, edi
sar edi, 31
imul rax, rax, 1431655766
shr rax, 32
sub eax, edi
retThe division became a multiplication by 1431655766 (0x55555556), a shift,
and a correction for the sign.
Why: integer division is one of the slowest instructions on the machine — tens of
cycles, and poorly pipelined. Multiplication is a few cycles and fully pipelined.
So the compiler replaces x/3 with “multiply by a magic constant approximating
1/3 in fixed point, then shift”. The sar edi, 31 extracts the sign bit and the
final sub corrects for C’s round-towards-zero rule on negatives.
This is strength reduction, and when you see a multiply by an apparently-random large constant, it is almost always a division. It is one of the most useful beacons in the language.
Covered in 3.3.
Both loops do 1024 additions of the same numbers.
// A — one accumulator
int a(int *p) { int s = 0; for (int i = 0; i < 1024; i++) s += p[i]; return s; }
// B — four accumulators, combined at the end
int b(int *p) {
int s0=0, s1=0, s2=0, s3=0;
for (int i = 0; i < 1024; i += 4) { s0+=p[i]; s1+=p[i+1]; s2+=p[i+2]; s3+=p[i+3]; }
return s0+s1+s2+s3;
}Same instruction count, near enough. Same memory traffic. Is one faster?
In principle B, by up to about 4× — and this is fiction 2 collapsing.
In A, every += needs the previous total. That is a dependency chain 1024
links long, and no amount of out-of-order cleverness can start addition n before
addition n−1 finishes. The loop runs at the latency of one add, repeatedly.
In B the four chains are independent, so the core can have four additions in flight at once. Same work, four times the parallelism.
The honest complication, which you should check yourself: at -O2 gcc will
often autovectorise A anyway, doing exactly this transformation for you with
SIMD registers — accumulating four or eight lanes in parallel and summing them at
the end. Compile A and look for paddd and a little cascade of shifts at the
exit; that cascade is the horizontal sum.
So the lesson is not “hand-unroll your loops”. It is that time is set by dependency chains, not instruction counts, and that the compiler already knows this. Covered in 4.2.
A ymm register on x86-64 is 256 bits wide. How many 32-bit integers does it
hold, and therefore how many additions can a single vpaddd instruction perform?
Eight. 256 / 32 = 8, so vpaddd ymm0, ymm1, ymm2 does eight independent
32-bit additions as one instruction.
The register names tell you the width immediately, which makes them excellent
beacons: xmm is 128-bit (SSE, 4 lanes), ymm is 256-bit (AVX/AVX2, 8 lanes),
zmm is 512-bit (AVX-512, 16 lanes).
Your M1 uses NEON, which is fixed at 128-bit — so ARM64 code here is 4 lanes where AVX2 gets 8. That difference is real and it matters when comparing benchmarks across machines.
Covered in 5.1.
A colleague says: “It executed but never retired.” What are they describing?
A speculatively executed instruction whose branch turned out to be mispredicted — so its work was discarded and never became architecturally visible.
“Retire” is one of the trap words in this vocabulary. It has nothing to do with finishing or ceasing; it means the final, in-order commit where an instruction’s effects officially count. Instructions execute out of order and retire in order, and anything executed down a wrong branch is thrown away before retirement.
This is why the sentence is meaningful rather than contradictory, and why profilers report retired instruction counts — the executed count includes work the CPU decided never happened.
The Glossary flags every term like this. Covered in 6.1.
How many of the six did you get? More usefully: on how many were you confident and wrong?
Write those down. They are not gaps in knowledge — they are places where a model you trust produces wrong output, and they are the highest-value part of this course for you.
Check Calibration now for your baseline, and come back to it after Part 3 and after Part 4. The number that matters is not your accuracy — it is the distance between your confidence and your accuracy.