Sort a list of numbers first, and a loop that adds up the big ones runs five times faster: same additions, same chip, same clock. The chip guesses which way each if will go before it knows, and sorted numbers make its guesses right.
Learn how a processor really runs your code: an assembly line that guesses ahead, why a wrong guess costs about twenty ticks, and who on the chip may do what.
Switch the numbers from random order to sorted and watch the chip stop stumbling. Then take the if out of the loop. After that we build, piece by piece, the machine that guesses: from the tick of its clock to who on the chip may do what.
You need to have written a small program with a loop and an if, in any language. We build the rest from zero: what the processor does with your code, why it keeps time, and why it guesses.
Each square is one number from 0 to 255: the stronger the shade, the bigger it is. The loop adds up the big ones. The chip guesses each answer before it has read the number: purple is its guess, red a wrong one.
Toy motion, true times: in this slowed-down toy a number costs 2.5 ticks and a wrong guess 20 more; the measured runs and their setup are in Chapter 0 and the Field Guide.
Chapter 0
Time one loop on sorted and unsorted numbers, and find the only thing that changed.
A robot's camera hands its computer a picture the way a mosaic is built from tiles: as a grid of dots, each dot a pixel, stored as a number from 0 for black to 255 for white. A few lines of the robot's program walk through the numbers and add up the bright ones, every number of 128 or more, to judge how much of the scene is lit. The code is short, and it is correct.
Now the puzzle. In 2012 someone asked on a programming forum why that loop, run over a list of 32,768 such numbers 100,000 times, took 11.54 seconds, but only 1.93 seconds when they sorted the list first, before starting the stopwatch. The same numbers, the same additions, the same computer. Six times faster. This chapter finds out where the other ten seconds went, and the rest of the lesson opens up the machine that lost them.
Here is the heart of the program from the question. It is written in C++, a close cousin of the language C. The first loop fills a list called data with 32,768 numbers: std::rand() % 256 means "a random whole number, keeping only the part from 0 to 255". The second pair of loops is the part that was timed. It walks the whole list 100,000 times, and for each number it checks the if and adds the number to sum when it is 128 or more.
c++// 32,768 numbers from 0 to 255, then read 100,000 times const unsigned arraySize = 32768; for (unsigned c = 0; c < arraySize; ++c) // like the brightness of 32,768 pixels data[c] = std::rand() % 256; for (unsigned i = 0; i < 100000; ++i) // the timed part for (unsigned c = 0; c < arraySize; ++c) if (data[c] >= 128) // the fork sum += data[c];
The sorted run added one line before the timer started: std::sort(data, data + arraySize);.
If you know Python better, here is the same idea in Python. The list is called numbers, and the if is the same test:
pythontotal = 0 for x in numbers: # 32,768 numbers from 0 to 255 if x >= 128: # a bright one? This if is the fork. total += x
In Python the interpreter's own work per step is far larger, so the gap is smaller than in C, but it is still there: run the snippet above with both orders and time it yourself.
Look closely at what sorting did, and what it did not do. Sorting did not remove a single addition. Half the numbers are big in either order, and the loop adds exactly the same ones. Whatever made the sorted run faster, it was not less work.
To find the missing seconds we need three things from underneath the code. Picture a kitchen. The ingredients wait in a pantry. A cook follows a recipe one line at a time. Each line of the recipe is a small, plain action: take an egg, crack it, stir.
The list lives in the computer's large working store, where the numbers wait until the program needs them: its memory. That is the pantry.
The chip that carries out a program, one small step after another, is the processor. That is the cook. It cannot read Python or C. It only knows tiny steps, such as "load the next number from memory", "compare it with 128" or "add it to the total": instructions, the lines of the recipe. A program that translates your code into instructions, the way a translator turns a recipe from one language into another, is a compiler.
One more word. Many chips hold several complete processors, like several cooks in one kitchen, and each one is called a core. Our loop runs on one of them, and everything in this chapter happens inside that one core.
Here is what the compiler makes of the loop, written as plain steps. Every number in the list goes round these six:
Steps 3 and 6 are forks in the road: the processor either jumps somewhere else or carries straight on. An instruction like that, which either jumps to another place in the program or lets the processor carry straight on, is a branch, and every if in your code becomes one. Step 6 is the loop's own fork: it jumps back 32,767 times and falls through once, at the end of the list. Step 3 is the fork this chapter is about, because which way it goes depends on the number.
The processor takes its steps in time with an electrical signal that ticks at a steady rate, like a metronome: the clock. Each tick is one clock cycle, or just a tick. A step may take one tick, or several, or share a tick with other steps (Chapters 2 and 4 show how), but everything the processor does is counted in ticks.
The questioner did not say how fast their computer's clock ticked. The answer to the question re-ran the same test on a computer whose clock ticked 3.5 billion times a second, written 3.5 gigahertz, or GHz, so from here on we use its numbers. One tick lasts 1 ÷ 3,500,000,000 of a second. A billionth of a second is a nanosecond (ns), so one tick is under a third of a nanosecond: 0.286 ns.
Now we can say exactly where a run's time comes from. Every second the clock ticks 3.5 billion times. So a run's time is the number of ticks it took, divided by 3.5 billion; or, the same thing, the number of ticks times the length of one tick.
The number of ticks is the number of instructions carried out, times the ticks each one took on average. How many instructions a run carries out, the number of steps in the recipe, is its instruction count. How many ticks each instruction takes, on average, is its cycles per instruction, or CPI: the minutes per recipe step, if you like.
Put together, a run's time is instructions × ticks per instruction × length of a tick. Hover over or tap each symbol:
Apply it to the puzzle. Sorting changed the order of the numbers, not the program: the same instructions ran, the same number of times. The computer was the same, so the tick was the same length. Two of the three factors are fixed, so the whole five-fold change sits in the middle one. The processor must have spent five times as many ticks on each number of the random list.
For our loop it is handier to count per number than per instruction: ticks per number = time × clock rate ÷ numbers read. The re-run read 32,768 × 100,000 = 3,276,800,000 numbers. In random order it took 11.777 seconds: 3.594 nanoseconds a number, which at 3.5 ticks a nanosecond is 12.58 ticks. Sorted, it took 2.352 seconds: 0.718 nanoseconds a number, 2.51 ticks. The worked example at the end of the chapter writes out every step.
random: 12.58 ticks a number · sorted: 2.51 ticks a number · 5.0 times
So where did ten extra ticks a number come from? The processor does not wait to find out which way your if goes. It guesses, and keeps working.
Here is why. Before the processor can know which way step 3 goes, it has to load the number and compare it, and that takes many ticks. Waiting at every fork would leave it standing idle most of the time. So it does not wait.
It guesses which way the fork will go and carries on down that road, like a walker who reaches a fork, picks the road that looks right, and keeps walking instead of standing still until someone brings a map. Guessing which way a branch will go before it is known, and carrying on down that road, is branch prediction.
When the guess is right, nothing is lost: the work done on the road is exactly the work that needed doing. When it is wrong, the processor throws away everything it did on the wrong road and goes back to the fork, like the walker turning round. A wrong guess is a misprediction, and the ticks it throws away are the mispredict penalty. Chapter 3 opens up how the guessing works; here we need only the fact that it happens.
The answer's own explanation is short. The numbers were spread evenly from 0 to 255. Sorted, the first half are all small: the processor skips, skips, skips. Then they are all big: add, add, add.
A processor that guesses "the same as last time" is wrong about twice in the whole list. In random order every number is a coin flip, big or small, and no guess can be right more than about half the time.
Now the arithmetic. The random run took 9.425 seconds longer. If about half of its 3,276,800,000 numbers were wrong guesses, that is 1,638,400,000 of them. 9.425 seconds shared among them is 5.75 nanoseconds each, and at 3.5 ticks a nanosecond that is 20.1 ticks thrown away per wrong guess.
Is 20 ticks believable? Measured directly on processors of that family, a wrong guess costs at least 17 ticks, so the answer adds up. It adds up the other way too: 2.51 + half of 20.1 = 12.56 ticks a number, and the measurement says 12.58. The sorted run's work, plus a wrong guess on every other number, is the whole random run.
Try it yourself. Set how often the chip guesses wrong and how much each wrong guess costs, and see which of the measured runs your two numbers land on.
The bar is the whole run: teal for the work of adding, red for ticks thrown away on wrong guesses. Set how many guesses are wrong and how many ticks each one throws away, and see which measured run you land on.
The 2.51 ticks of adding per number and the 20 ticks per wrong guess are worked back from the measured runs (the arithmetic is in this chapter). A random order makes about half the guesses wrong because each number is a coin flip; the measured times are the 2012 answer's, on a 3.5 GHz desktop processor.
Two numbers are enough. Half the guesses wrong at about 20 ticks each lands on the random run; no wrong guesses lands on the sorted run. Nothing else is needed to explain the gap: not slower memory, not a slower clock, not more additions.
The same answer tried one more thing: it rewrote the loop without the if, adding either the number or zero with a small arithmetic trick (Chapter 3 shows it). Random order: 2.564 seconds. Sorted: 2.587. No fork, nothing to guess, and the order stopped mattering.
Compilers can do this rewrite by themselves, and many now do. That is why, if you run the 2012 loop on your own laptop today, you may see no difference at all. Chapter 2 shows the instructions a recent compiler really made from this loop.
The same test written in Java ran 10.93 and 5.64 seconds: about 2 times, not 5. The most likely reason is that a different compiler, Java's own, made different instructions, though the 2012 answer does not say so directly. The source code is not what runs. The instructions are.
A robot's code is full of forks like this one: is this pixel part of the marker, is the obstacle closer than a metre, has the whole message arrived. Where the data behind a fork is as unpredictable as camera noise, each fork can cost about 20 ticks, and nothing in the code looks slow.
worked exampleThe 2012 runs on a 3.5 GHz desktop processor
numbers read = 32,768 × 100,000 = 3,276,800,000
random order = 11.777 s ÷ 3,276,800,000
= 3.594 ns a number
sorted = 2.352 s ÷ 3,276,800,000
= 0.718 ns a number
one tick = 1 ÷ 3.5 GHz = 0.286 ns
ticks per number = 3.594 × 3.5 = 12.58 and 0.718 × 3.5 = 2.51
random ÷ sorted = 11.777 ÷ 2.352 = 5.0 times
Where the extra 9.4 seconds went
extra time = 11.777 − 2.352 = 9.425 s
per number = 9.425 s ÷ 3,276,800,000 = 2.876 ns
wrong guesses = half of 3,276,800,000 = 1,638,400,000
per wrong guess = 9.425 s ÷ 1,638,400,000 = 5.75 ns
= 5.75 × 3.5 = 20.1 ticks
check = 2.51 + 0.5 × 20.1 = 12.56 ticks a number
(measured 12.58)
The other runs
the question's own computer: 11.54 s ÷ 1.93 s
= 5.98, about 6 times
the same loop in Java: 10.93 s ÷ 5.64 s
= 1.94, about 2 times
without the if: 2.564 s random, 2.587 s sorted: no gap
Clock speed alone (illustrative, the failure story)
3 GHz board, 1.5 ticks an instruction:
1,000,000,000 × 1.5 ÷ 3,000,000,000 = 0.50 s
2 GHz board, 0.8 ticks an instruction:
1,000,000,000 × 0.8 ÷ 2,000,000,000 = 0.40 s
the board with the slower clock wins by 0.50 ÷ 0.40 = 1.25 timesWhat the team does. A team chooses between two boards, two small computers, for the robot's vision loop. One runs at 3 GHz, the other at 2 GHz. They pick the 3 GHz board without testing: "fifty percent more clock". The vision loop runs slower on it.
What you measure. Count the ticks and the instructions for the same loop on both boards (Chapter 3 shows the Linux tool that counts both). On the 3 GHz board the loop takes 1.5 ticks an instruction; on the 2 GHz board, 0.8 (illustrative numbers). For a billion instructions that is 0.50 seconds against 0.40: the slower clock wins by 1.25 times.
What you change. Compare processors by running your own loop and reading its ticks per instruction, never by the clock alone. The clock is one factor of three.
Every part of that story has machinery behind it: a clock that can only tick so fast, a line of stages that keeps several instructions moving, a guesser, and rules about who on the chip may do what. Here is the road, in order:
Chapter 1
Watch an answer race the clock, then cut the race into stages so the clock can tick faster.
Your laptop's box says 3.5 GHz: three and a half billion ticks a second. Why not 35 billion? Nothing in the chip is lazy. The limit is a race that happens inside the chip on every single tick, and the clock can only tick as fast as the slowest runner in that race. This chapter watches the race, then shows the trick every modern processor uses to shorten it.
Start at the very bottom. A light switch has two positions, on and off. Inside the chip are billions of tiny electrical switches, each either on or off: transistors. On or off is all a computer knows.
A single 0 or 1, one switch's position, is a bit, and a number is a row of bits. The number 200, for example, is the row 11001000: 128 + 64 + 8.
A handful of switches wired together can compute one simple rule on bits, such as "1 only if both inputs are 1": that is a gate. Wire thousands of gates together and you get circuits that add, compare and choose. Engineers call such a network of gates logic. An adder is logic: feed it two numbers as rows of bits, and their sum comes out the other side as another row of bits.
Change an adder's inputs and the answer does not appear at once. The change ripples through the gates like a line of dominoes falling, each gate waiting for the one before it, and for a moment the output flickers through wrong values before it settles. The time an answer takes to ripple through and stop changing is its propagation delay.
These delays are tiny. They are counted in trillionths of a second: picoseconds (ps), each a thousandth of a nanosecond. In this chapter's example the arithmetic part of the processor takes 200 ps, an illustrative value in the style of the classic textbook. That is fast, but it is not zero, and this whole chapter turns on it not being zero.
So how does the chip keep a clean answer from one step to the next, when the logic in between is a flickering blur? It photographs it. A register is a row of storage cells that holds a number and takes a new value only on the tick: at the tick it captures whatever is at its input, and it holds that value steady until the next tick, whatever the logic in front of it does in between. Think of a camera that takes exactly one photograph on every tick of the metronome, and shows that photograph until the next tick.
The gap between two beats of the metronome, the time from one tick to the next, is the clock period. At 3.5 GHz it is 286 ps; at 1 GHz, 1,000 ps. Choosing the clock period is choosing how long every race inside the chip is allowed to take.
Picture two registers, A and B, with some logic between them. At a tick, register A takes its photograph. Its new value appears at its output a moment later, like a camera's shutter lag: the register's clock-to-output delay, tpcq. Then the value ripples through the logic to register B, taking the propagation delay, tpd, of the slowest path between them. And it must arrive, and hold still, a moment before the next tick at B, the way you hold still for a photograph: that moment is B's setup time, tsetup.
So the clock period must cover all three: the time for A to show its value, plus the time for the answer to ripple through, plus the time B needs it to hold still. Add one small term for the tick itself. It reaches different registers at slightly different moments, which engineers call skew, and a tick that reaches B early eats into the margin, the spare time left over once tpcq, tpd and tsetup are subtracted from the clock period.
The slowest runner decides when the whistle can blow. Every pair of registers on the chip runs its own race, and the slowest path between any two registers sets the clock for the whole chip: it is the critical path. Make every other path faster and the clock cannot move; shorten the critical path and it can.
The photograph has a second condition. The input must stay still for a moment after the tick too, the way you must not move until the flash is over: the hold time, thold. The danger here is the fastest path, not the slowest. If a new value can race through a very short path and arrive while register B is still taking its photograph, the photograph is spoiled.
So there is a second rule. The soonest a new value can start to leave A, plus the delay of the fastest path from A to B, must be longer than B's hold time, plus any skew:
no Tc in the hold rule: a slower clock cannot fix it
Look at what is missing from this rule: the clock period. Slowing the clock cannot fix a hold problem; only adding delay to the too-fast path can. Here is one with illustrative numbers: a short path that starts changing 20 ps after the tick and takes 10 ps to cross, 30 ps in all, against a hold time of 25 ps plus 10 ps of skew, 35 ps. It fails at every clock speed, because nothing in that sum is the clock. Adding 10 ps of delay to the path makes it 40 ps, more than 35, and fixes it.
Now the instructions themselves. First, the words memory needs. Memory is counted in bytes of 8 bits, and every byte has a number, its address, like a house number on a long street. A 32-bit number takes 4 bytes, a word. Copying a number from memory into a register is a load, like fetching an ingredient from the pantry; putting a number from a register back into memory is a store.
Making a sandwich takes five jobs: take the order, work out what it needs and gather it, do the cutting, fetch anything missing from the pantry, and hand it over. Every instruction goes through the same five jobs, and engineers shorten them to IF, ID, EX, MEM and WB:
A load uses all five: it fetches its instruction, reads the register holding the address, adds to work out the address, reads the number from memory, and writes it into a register. An add skips the memory job, because it has nothing to fetch from the pantry.
The simplest processor does all five jobs of an instruction inside one tick, like one person making the whole sandwich from order to hand-over: a single-cycle processor. Between its two registers, one at the start of the tick and one at the end, sits all five jobs' worth of logic, so its tick must fit the slowest instruction's whole journey.
Put numbers on it, using the classic textbook's teaching values (illustrative, not any real chip): fetch 200 ps, decode 100, execute 200, memory 200, write back 100. That is 800 ps of logic, plus 30 ps for a register to show its value and 20 ps of setup: 850 ps a tick, 1.18 GHz. Every instruction gets the whole 850 ps, even an add that never uses the memory job.
The full list of instructions a processor understands is its instruction set, like a language's vocabulary. A good one to learn from is RISC-V, an open instruction set anyone may build a processor for. Its simplest version, RV32I, has 40 instructions, each 32 bits long, and every one of them goes through the same five jobs. Forty is few enough to write a processor for in an afternoon, which is what the lab at the end of this chapter does.
Now picture a sandwich counter with five people in a row. One takes the order, one works out what it needs and gathers it, one does the cutting, one fetches anything missing from the pantry, one hands it over. Every few seconds each passes their sandwich to the next. Five sandwiches are in progress at once, and one comes off the end at every step.
Put a register between each pair of jobs and the processor becomes the same counter: a pipeline, with five stages and a pipeline register between each pair. Each stage works on a different instruction, and at every tick each pipeline register photographs its stage's answer and hands it to the next stage. Now the logic between two registers is only one stage, so the tick only has to fit the slowest stage: 200 + 30 + 20 = 250 ps, 4.0 GHz.
one tick per instruction: 30 + 800 + 20 = 850 ps · five stages: 30 + 200 + 20 = 250 ps
Try both below. Start with one tick per instruction at 850 ps and shorten the tick until something breaks. Then switch to five stages and do it again.
The five boxes are the five jobs, each as wide as its delay. The teal front is an answer rippling through the logic; the thin bars are registers taking their photograph on each tick. Choose one tick per instruction or five stages, then shorten the tick until something breaks.
Illustrative delays in the style of the classic textbook example (200, 100, 200, 200 and 100 ps for the five jobs; 30 ps for a register to show its new value and 20 ps of setup). The rule, a tick at least as long as the slowest path plus the registers' own times, is the standard one. The picture runs 2 billion times slower than the chip: 1 ps of chip time is 2 ms on screen.
The line did not make any instruction faster. One instruction now takes five ticks of 250 ps from start to finish: 1,250 ps, longer than the 850 ps of the single-cycle processor. How long one instruction takes from start to finish, one sandwich's time from order to hand-over, is its latency. The line made latency worse, by 1.47 times.
What it improved is throughput: how many instructions finish each second, the sandwiches served per hour. Once the line is full, one instruction finishes every tick. A thousand instructions take (1,000 + 4) × 250 ps = 251 ns, against 1,000 × 850 ps = 850 ns: 3.39 times sooner. The 4 extra ticks are the time it takes to fill the line at the start, before the first instruction reaches the end.
Why 3.39 and not 5? The stages are not equal: the 100 ps stages idle half of every tick, waiting for the 200 ps ones. And every stage pays the registers' 50 ps. Five equal stages of 160 ps with free registers would give exactly five times: 800 ÷ 160 = 5. Here is the whole budget in a few lines of Python, with its real output:
python# timing.py: how fast can the clock tick? # (illustrative delays, in picoseconds) stages = { "fetch": 200, "decode": 100, "execute": 200, "memory": 200, "write back": 100, } # a register's own times: show the new value, # hold still before the tick t_pcq, t_setup = 30, 20 # a whole instruction between two registers one_tick = t_pcq + sum(stages.values()) + t_setup # only the slowest stage between two registers five_stage = t_pcq + max(stages.values()) + t_setup print(f"one tick per instruction: {one_tick} ps a tick, " f"{1000 / one_tick:.2f} GHz") print(f"five-stage line: {five_stage} ps a tick, " f"{1000 / five_stage:.2f} GHz") n = 1000 print(f"{n} instructions: {n * one_tick / 1000:.0f} ns against " f"{(n + 4) * five_stage / 1000:.0f} ns") print(f"one instruction, start to finish: {one_tick} ps against " f"{5 * five_stage} ps")
outputone tick per instruction: 850 ps a tick, 1.18 GHz
five-stage line: 250 ps a tick, 4.00 GHz
1000 instructions: 850 ns against 251 ns
one instruction, start to finish: 850 ps against 1250 psChips cut instructions into more and more stages to shorten the tick. Even the Cortex-M85, a small processor design made for the cheap one-chip computers inside gadgets, uses seven stages for ordinary instructions, and the big processors in laptops use more. One careful measurer notes that on Intel's Core 2 a wrong guess costs about 15 ticks, "corresponding to the length of the pipeline". A deeper line ticks faster, and throws away more work on every wrong guess. That trade is Chapter 3.
worked exampleIllustrative delays (the classic textbook teaching
values, not any real chip)
logic: fetch 200 + decode 100 + execute 200 + memory 200
+ write back 100 = 800 ps
each register: 30 ps to show its new value
+ 20 ps setup = 50 ps
One tick per instruction
tick ≥ 30 + 800 + 20 = 850 ps
clock = 1 ÷ 850 ps = 1.18 GHz
1.18 billion instructions a second
Five stages
tick ≥ 30 + 200 + 20 = 250 ps (the slowest stage)
clock = 1 ÷ 250 ps = 4.0 GHz
one instruction finishes every tick
1,000 instructions: (1,000 + 4) × 250 ps = 251,000 ps
= 251 ns, against 1,000 × 850 = 850 ns: 3.39 times sooner
one instruction alone: 5 × 250 ps = 1,250 ps
against 850 ps: 1.47 times longer
why not 5 times: 850 ÷ 250 = 3.4; five equal 160 ps stages
with free registers: 800 ÷ 160 = 5.0
Pushing the clock too far (setup)
a 240 ps tick on the five-stage line: the 200 ps stages need
250 ps, so answers arrive late: wrong results
Hold (illustrative)
shortest path: 20 ps + 10 ps = 30 ps must be more than hold
25 ps + skew 10 ps = 35 ps: fails at every clock speed
add 10 ps of delay to that path: 40 ps > 35 ps: fixedWhat happens. A builder raises the clock of a robot's board to squeeze more frames a second out of its vision loop: overclocking, running a chip's clock faster than it was sold to run. It works for an hour. Then one multiplication comes out wrong, and the arm twitches.
What you measure. Run a calculation whose right answer you know, billions of times, at each clock setting, and count the wrong answers. They appear only above a certain speed, and only now and then: the slowest paths, with the unluckiest inputs, no longer settle before the tick.
What you change. Back the clock down to where every path settles with room to spare. And know the one timing fault a slower clock cannot fix: a path so fast that it changes a register's input while it is still taking its photograph. That fails at every speed, and only a change to the chip's design, adding delay to that path, fixes it.
The lab below is Chapter 1's single-cycle processor written in Python: it fetches one instruction per tick, decodes it and does what it says. You write the parts that touch memory and the fork, and it sorts a list of numbers from 0 to 255.
Chapter 2
Step our loop through five stages, and find the one wait that no wire can remove.
Back at the sandwich counter, an order comes in: toast the bread, then melt cheese on the toast. The cheese person is next in line after the toaster, and when the cheese person's turn comes, the toast is not ready. They stand and wait, and an empty tray moves down the counter behind them.
Our loop has exactly this problem. Chapter 1's line finishes one instruction every tick, and our loop has six instructions for each number, so a number should take six ticks. Step it through the line and count: a number that gets added takes twelve.
Here are the six steps from Chapter 0, written as RISC-V instructions. Instructions written as short words, one per line, so a person can read them, like a recipe in shorthand, are assembly language. Each line is one instruction; the words after the # are comments for people.
risc-v assemblyloop: lw t0, 0(a0) # load the number at the address in a0 into t0 slti t1, t0, 128 # t1 = 1 if the number is below 128, else 0 bnez t1, skip # if t1 is not 0, jump to skip (do not add) add a1, a1, t0 # total = total + number skip: addi a0, a0, 4 # step a0 on to the next number, 4 bytes along # if a0 has not reached the end (a2), jump back to loop bne a0, a2, loop
Read it line by line, like labelled trays on the counter: t0, t1, a0, a1 and a2 are the names of five of the processor's registers. a0 holds the address of the next number, a1 the running total, a2 the address just past the end of the list. Each number is one word, 4 bytes, so stepping to the next means adding 4 to a0.
lw, load word, copies the word at the address in a0 into t0. slti, set if less than, puts 1 into t1 if the number is below 128, and 0 if not. bnez, branch if not zero, is the fork of our if: when t1 is 1 it jumps to skip, past the add. add adds the number to the total.
addi, add immediate, adds the fixed number 4 to a0. bne, branch if not equal, is the loop's own fork: until a0 reaches a2 it jumps back to loop. Six steps, exactly Chapter 0's list.
Look at the second line. slti needs t0, the number that lw is still fetching from memory. On the line, slti enters decode one tick after lw, and decode is where an instruction reads its registers. At that moment lw has not even been to the memory stage. An instruction that needs a result an earlier instruction has not finished yet, the cheese person waiting for the toast, is a data hazard.
The simplest cure is to hold the waiting instruction back until the result has been written into its register in the write-back stage. The line keeps moving, so the held-back instruction leaves an empty slot travelling down it, like an empty tray on the counter: a bubble, also called a stall.
How long is the wait? A register is written in write-back and may be read in decode in that same tick: the write happens in the first half of the tick, the read in the second. An instruction decoded at tick p executes at p + 1, uses memory at p + 2 and writes back at tick p + 3. The instruction right behind it would decode at p + 1, so it waits 2 ticks; two behind, it would decode at p + 2 and waits 1 tick; three behind, none.
In general: waits = 3 minus the distance, never below zero. Hover over or tap each symbol:
slti after lw: 3 − 1 = 2 · bnez after slti: 2 · bne after addi: 2 · six waits a number
Apply it to our loop. slti waits 2 for lw, bnez waits 2 for slti, and bne waits 2 for addi: 6 bubbles. The add needs t0 too, but it comes three behind lw, so by its turn the number is in the register. A number that gets added costs 6 instructions and 6 bubbles, 12 ticks; a number that is skipped, 5 and 6, 11 ticks. Step it yourself. The device's second setting, Forward the answer, hands each result straight across instead of waiting for write-back; the next section explains why that helps, and where it cannot:
Each row is one instruction of our loop, each column one tick. F, D, X, M and W are fetch, decode, execute, memory and write back; a dot is a tick spent waiting. Step through one number, then choose Forward the answer.
A teaching model of the classic five-stage line: one instruction enters each stage per tick; results are written in write-back and read in decode in the same tick; forwarding carries results from the pipeline registers after execute and after memory. Every fork is treated as guessed right here; Chapter 3 adds wrong guesses.
But the answer exists long before write-back. slti's result is complete at the end of its execute stage, sitting in the pipeline register behind it. Add wires that hand it straight to the execute stage of the instruction behind, and nobody waits for write-back. It is passing the toast straight across the counter instead of sending it round the back.
Extra wires that hand a result straight from one stage to the instruction right behind it, instead of waiting for write-back, are called forwarding, also called bypassing. The course notes this chapter follows name the two paths exactly: from the pipeline register after execute, and from the one after memory, straight into the arithmetic of the execute stage. Turn on "Forward the answer" in the device above: the waits after slti and addi vanish.
One wait survives. Try forwarding on the first pair, tick by tick. lw is decoded at tick p, executes at p + 1 (it works out the address), and reads memory at p + 2: its number arrives at the end of tick p + 2. slti, right behind, would execute at tick p + 2, the same tick.
It needs the number at the start of that tick; the number exists only at the end of it. No wire carries a value backwards in time. So slti executes one tick later: one bubble.
An instruction that uses a loaded number in the very next slot is a load-use hazard. The course notes put it in one line: when the instruction right after a load uses the load's result, the hardware must stall for one cycle. With forwarding, a number that gets added costs 6 + 1 = 7 ticks, and a skipped one 5 + 1 = 6.
with forwarding: 1 tick after a load, 0 after anything else
The processor cannot remove that tick, but the program can fill it, the way a cook makes the salad while the toast browns. addi does not need t0, and lw has already used a0, so addi can move up between them: lw, addi, slti, bnez, add, bne. Now two instructions separate the load from its use, and forwarding covers it: no bubbles, 6 ticks for a number that gets added, 5 for one that is skipped.
Rearranging instructions so a gap is filled with work that does not depend on it is instruction scheduling. You rarely do it by hand: compilers do it for you, reordering code to improve performance. Switch on "Move addi up" in the device above and watch the last bubble go.
One more wait hides in the forks. The line fetches a new instruction every tick, but it only learns which way bnez goes when bnez reaches execute, and the classic design acts on it in the memory stage. Until then it keeps fetching as if the branch were not taken, straight down the page. The line must fetch the next instruction before it knows which way the fork goes: that is a control hazard, the fork in the road again.
When the branch is taken after all, the three instructions fetched behind it are thrown away, like tipping the wrong sandwiches into the bin: a flush, three ticks lost. Designs that decide in execute lose two; those that decide in decode, one.
Now run our loop under that fixed guess, "not taken", with forwarding on. bne jumps back on every number, so every number pays 3 more ticks; bnez jumps on every small number, 3 more. A big number costs 7 + 3 = 10 ticks, a small one 6 + 3 + 3 = 12: 11 on average, sorted or not. A fixed guess cannot see that the numbers were sorted. Chapter 3 builds guesses that can.
The small, cheap processors that run a robot's motor boards are microcontrollers: a small processor on one chip that runs one program. Arm designs processors that other companies build into their chips, and its Cortex-M family is made for microcontrollers. Their lines are short:
| core | stages | note |
|---|---|---|
| Cortex‑M0 | 3 | instructions and data share one path to memory |
| Cortex‑M0+ | 2 | "the low branch penalty nature of the Cortex-M0+ processor (since its pipeline is only two stages)" |
| Cortex‑M3 | 3 | instructions and data fetched at the same time |
| Cortex‑M4 | 3 | the same line as the M3 |
| Cortex‑M7 | 6 | starts two instructions in one tick, and guesses forks |
| Cortex‑M85 | 7 | 9 to 10 for arithmetic on fractions and on many numbers at once |
Being able to start two instructions in the same tick, like two counters side by side, is called superscalar. A two-stage line has almost nothing to throw away at a fork. A six- or seven-stage line ticks faster and has more to lose, which is why the Cortex-M7 guesses its forks.
Everything so far used the six instructions we wrote by hand. But the processor runs what the compiler wrote, and it may have written something else. The instructions as the bytes the processor actually reads are the program's machine code. To see them, you turn the machine code back into assembly with a disassembler; on Linux, objdump -d.
Machine code is printed in hexadecimal: base 16, with the digits 0 to 9 and then a to f, two digits per byte, often written with 0x in front. The hexadecimal 7f, for example, is 7 × 16 + 15 = 127. Here is what objdump -d shows for the hot loop of a recent compiler's RISC-V build of Chapter 0's function (trimmed to the machine code and the instruction):
objdump -d4398 lw a4,0(a5) 0791 addi a5,a5,4 00e65363 bge a2,a4 953a add a0,a0,a4 fed79be3 bne a5,a3
Read it line by line. The first column is the machine code. 4398 is four hex digits, two bytes: a compressed instruction, one of the short 16-bit forms of the most common instructions, from an optional part of RISC-V called the C extension. 00e65363 is eight digits, four bytes, a full-size one.
lw a4,0(a5): load the number at the address in a5 into a4. addi a5,a5,4: step to the next number, and look where it is: the compiler already moved it up behind the load, exactly the fix from a few paragraphs back. bge a2,a4: if a2 is at least the number, jump past the add; for the loop to add only numbers of 128 or more, a2 must hold 127. The compiler folded our compare and branch into one instruction. add a0,a0,a4: the total. bne a5,a3: not at the end yet, jump back. (objdump also prints the address each jump goes to after its operands; the listing here leaves it out.)
Five instructions a number, not six, in 14 bytes, and the fork is still there.
What happens. A team budgets its vision loop from the C source. Six instructions a number, and for noisy camera pixels, 20 ticks lost on half of them. On the Arm board the loop runs far faster than the budget. On the RISC-V board it matches.
What you measure. Disassemble each build with objdump -d and read the loop. On RISC-V: five instructions and a bge; the fork is there, and noisy pixels pay for it. On Arm: ldr, cmp, add, csel, cmp, b.ne. There is no fork around the add.
Picture working out both answers and keeping one: csel, a conditional select, picks the new total or the old one without a fork, so nothing is guessed. On x86-64 the same idea is called cmovg. A64 is Arm's 64-bit instruction set; x86-64 is the instruction set of Intel's and AMD's processors.
Each block is one instruction of the loop's hot part, as wide as its bytes. Purple blocks are forks; teal blocks are plain work, including the select that replaces a fork. Pick an instruction set.
Measured build: gcc 14.2 at -O2 -fno-tree-vectorize on Compiler Explorer, 2026-09-25, for the function long sum_ge128(const int*, long). Another compiler, version or flag may emit a different loop; check yours with objdump -d.
Here is the Arm loop from the same compiler, trimmed the same way:
arm a64ldr w1,[x2],#4 cmp w1,#0x7f add x1,x0,w1,sxtw csel x0,x1,x0,gt cmp x2,x3 b.ne
ldr w1,[x2],#4 loads the number and steps the address on by 4 in one instruction. cmp w1,#0x7f compares the number with 127 (0x7f in hexadecimal), add works out total + number, and csel x0,x1,x0,gt keeps that new total only if the number was greater than 127. No fork around the add.
What you change. Budget from the disassembly of the exact build, with the compiler, its version and its flags (the settings it was run with, such as -O2) written down, never from the source. A new compiler, or one changed flag, can put the fork back or take it away.
The same function, whole, takes 38 bytes on RISC-V with compressed instructions, a build named RV64GC (9 of its 14 instructions are short ones), 56 bytes without them, a build named RV64G, 56 on Arm's A64 and 46 on x86-64. How many bytes a piece of code takes for the same work is its density: fewer bytes, denser code. The short forms save about a third of the space: 38 ÷ 56 = 0.68. On a microcontroller with little room for code, a third is a lot.
worked exampleOur loop on the teaching line, one number, every fork
guessed right
waits without forwarding: 3 minus the distance, never below 0
slti after lw (1 apart): 2 bnez after slti (1 apart): 2
bne after addi (1 apart): 2 = 6 bubbles
a number that is added: 6 instructions + 6 bubbles = 12 ticks
a number that is skipped: 5 instructions + 6 bubbles = 11 ticks
with forwarding: only the load's user waits, 1 tick
added: 6 + 1 = 7 ticks skipped: 5 + 1 = 6 ticks
half and half: 6.5 ticks a number
addi moved up behind the load (lw, addi, slti, bnez, add, bne)
without forwarding: 3 bubbles (slti 1, bnez 2): 9 and 8 ticks
with forwarding: 0 bubbles: 6 and 5 ticks
Forks on the teaching line, always guessing "not taken", 3 ticks
lost per taken branch
bne jumps back on every number (+3), bnez jumps on every small
number (+3)
added: 7 + 3 = 10 skipped: 6 + 3 + 3 = 12
average 11.0 ticks, sorted or random
What gcc 14.2 made of the loop (-O2 -fno-tree-vectorize)
RV64GC hot loop: 5 instructions, 2 + 2 + 4 + 2 + 4 = 14 bytes,
keeps the fork (bge)
A64 hot loop: 6 instructions, 6 × 4 = 24 bytes,
no fork around the add (csel)
x86-64 hot loop: 8 instructions, 3 + 3 + 3 + 3 + 4 + 4 + 3 + 2
= 25 bytes, no fork around the add (cmovg)
whole function: RV64GC 38 bytes (9 of 14 compressed); RV64G 56;
A64 56; x86-64 46
compressed saving: 38 ÷ 56 = 0.68, about a third smallerIn the lab you write the rule that decides when an instruction may enter decode, with forwarding and without, and watch the bubbles appear in the line.
lw t0, 0(a0) is followed straight away by slti t1, t0, 128. Why must slti still wait one tick?Chapter 3
Race three guessers on the patterns a program makes, and learn why random data beats them all.
A barista at a busy café knows that the man in the grey coat always orders a flat white, so she starts steaming the milk as he walks through the door. Most mornings she hands it over the moment he pays. The morning he asks for tea, she tips the milk away and starts again: a little time lost, far less than she wins on all the other mornings. Your processor does the same thing at every fork in your program, billions of times a second.
In Chapter 2's line, the processor learns which way a fork goes two or three ticks after fetching it. A big processor fetches far more per tick: one big Arm core has been measured fetching and decoding ten instructions per tick. The part of the core that fetches and decodes instructions, the order counter of the café, is its front end. Waiting at every fork would leave that front end idle for many ticks, ten instructions' worth of work lost for each one.
So processors like these do what the barista does. They guess, and keep fetching, decoding and even computing down the guessed road, keeping the work only if the guess was right. Working down the guessed road before the fork is settled, and keeping the work only if the guess was right, is speculative execution. A branch that jumps is taken; one that lets the processor carry on is not taken. And the part of the core that makes the guesses, the barista's memory of her regulars, is the branch predictor.
The simplest predictor always makes a flat white. Chapter 2's line used one fixed guess for every fork, "not taken": static prediction. For our loop that is wrong on every backward jump and on every small number: 11 ticks a number, sorted or not. A fixed guess cannot learn anything, including that the numbers were sorted.
The simplest guesser that learns remembers what this fork did last time and guesses the same: a one-bit predictor, since one bit is enough to remember "jumped" or "did not". It is good at forks that keep doing the same thing, and it has one weakness worth watching.
Take a loop of 8: its fork jumps back seven times and then falls out. That fools the one-bit guesser twice per pass. At the exit it guessed "jump", and the loop ended. Then, at the next pass's first jump, it remembers "fall out" and guesses wrong again. Six right out of eight: 75%.
In general a loop of N passes is guessed right 1 − 2 ÷ N of the time, so a loop of 100 gives 98%.
Better: make the guesser need two surprises in a row to change its mind. Keep a counter from 0 to 3. Step it up after a taken branch and down after a not-taken one, and stop it at the ends; 0 and 1 guess not taken, 2 and 3 guess taken. That is a two-bit saturating counter, "saturating" because it stops at 0 and 3 instead of wrapping around.
At a loop's exit the counter drops from 3 to 2 and still guesses "jump", so the next pass costs nothing: one wrong guess per pass, 7 of 8, 87.5%; a loop of 100, 99%. But on a fork that alternates, jump, fall through, jump, fall through, it swings between 0 and 1 and is right only half the time: 50%. Here is the whole counter in a few lines of Python, with its real output:
pythondef two_strikes(pattern): # 0 and 1 guess "no jump", 2 and 3 guess "jump" c, right = 0, 0 for jumped in pattern: right += (c >= 2) == jumped # one step toward what happened, never past 0 or 3 c = min(3, c + 1) if jumped else max(0, c - 1) return right / len(pattern) # one pass of a loop of 8, starting cold print(two_strikes([True] * 7 + [False])) print(two_strikes(([True] * 7 + [False]) * 1000)) # a thousand passes
output0.625
0.87475Cold, on a single pass, it is right 5 times out of 8 while it climbs from 0 to 2 (the first two jumps are guessed wrong, the next five right, the exit wrong); over a thousand passes it settles at 87.5%, the 0.025% gap being those first two wrong guesses.
Some forks follow a pattern that only makes sense in context: an alternation, or a loop's exit that comes after exactly seven jumps. A barista who remembers the last eight orders can tell that after two flat whites the grey coat always asks for tea. So keep the outcomes of the last few forks as a row of bits, the history, and give every different recent history its own counter.
One well-known design from 1993 does exactly this. It combines the history with the fork's own address in memory using XOR, a bit-by-bit rule that gives 1 where two numbers differ and 0 where they agree (1100 XOR 1010 is 0110), and uses the result to pick the counter. Its name is gshare. With 8 bits of history, each position inside the loop of 8, and each phase of the alternation, lands on a counter of its own, because the last eight outcomes are different at every position.
Once the counters have learned, the history guesser is right every time: 100%. Learning takes a while, because each counter starts knowing nothing: the history guesser needs 21 wrong guesses on the loop, and 6 on the alternation, before it settles. Race all three on the same patterns:
The top row is what the fork really does: a filled circle is a jump, an empty one no jump. Below it, three guessers guess each one before it happens: a green tick is right, a red cross wrong. Pick a pattern and watch who learns it.
Made-up patterns, real guessers: the one-bit guesser, the two-bit saturating counter, and McFarling's 1993 gshare with 8 bits of history. Each starts knowing nothing (every counter at 0, "no jump"); the percentages count the last 7,200 guesses of each pattern, after the guessers have settled.
Now feed all three the random pattern from Chapter 0. Each lands near 50%, the same as a coin. There is no pattern to learn, and a guesser can only learn patterns. Then feed them the sorted list: 16,384 small numbers, then 16,384 big ones. At the one switch, "same as last time" is wrong once, "two strikes" twice and the history guesser once, and then right for the rest of the list.
That is Chapter 0's mystery, solved from the inside: the sorted list is one long, easy pattern, and the random list is no pattern at all.
Guessers have kept growing. A 2006 design is named for what it keeps, tagged geometric history lengths: histories of several lengths, each longer than the last by a fixed factor. Its name is TAGE.
Guessing the direction is half the job. The front end must also know where a jump goes before it has decoded it, or it cannot fetch from there. So it keeps a table of where each jump went last time, the branch target buffer. It also keeps a small stack of return addresses for function calls, the return stack, like a stack of bookmarks: when a function finishes, the program must jump back to wherever it was called from, and the top bookmark says where. On the big Arm core measured above, the first table tracks about 2,048 jumps, slower tables up to 16,384 with targets two to three ticks later, and the return stack holds 29 addresses.
Every wrong guess throws away the work in flight behind the fork, so its cost follows the length of the line. Here is what careful measurements found on well-known processors:
| processor | ticks lost per wrong guess |
|---|---|
| Intel Core 2 | about 15 |
| Intel Nehalem | at least 17 |
| Intel Sandy Bridge | 15 or more |
| Intel Haswell, Broadwell, Skylake and the later Lakes | 15 to 20 |
| Intel Alder Lake | sometimes above 20 |
| AMD Zen 1 to 3 | about 18 on average |
| AMD Zen 4 | 15 to 18 (another measurement: 11 to 18, with AMD's claimed 13 in the common case) |
| AMD Zen 5 | 15 to 25 |
| AmpereOne (an Arm server processor) | 10 |
Chapter 0's arithmetic found 20.1 ticks on its desktop processor, which agrees with the at least 17 measured for that family.
The Intel and AMD figures are Agner Fog's measurements in The microarchitecture of Intel, AMD, and VIA CPUs (edition of 23 May 2026); the Sandy Bridge figure is for branches held in the µop cache, a small store of already-decoded instructions this lesson does not otherwise cover. The AmpereOne figure and the second Zen 4 measurement are from Chips and Cheese (29 August 2024), and the big Arm core's front end, jump tables and return stack from their Cortex-X925 measurements on NVIDIA's GB10 (3 March 2026). The history design is McFarling's gshare, from a 1993 technical note; TAGE is from Seznec and Michaud, 2006.
Write the ticks a number costs as the work plus the wrong guesses: ticks = b + m × P, where b is the work per number, m the share of guesses that are wrong and P the ticks each wrong guess throws away.
On Chapter 2's teaching line, with forwarding and half the numbers added, b = 6.5, and a wrong guess settled in the memory stage costs P = 3. Random data costs 6.5 + 0.5 × 3 = 8.0 ticks against 6.5 sorted, only 1.23 times more. On a big core, b = 2.5 and P = 20, the numbers Chapter 0 worked back from the measurement: 12.5 against 2.5, 5.0 times.
teaching line: 6.5 + 0.5 × 3 = 8.0, 1.23 times · big core: 2.5 + 0.5 × 20 = 12.5, 5.0 times
The big core does the ordinary work of a number in fewer ticks, because it overlaps many instructions (Chapter 4), and it throws away more per wrong guess, because its line is deeper. Both push the ratio up. The same fork, the same data: a mild nuisance on a small line, the whole story on a big one.
Two machines run our loop. Teal is the work on each number, red the ticks thrown away on wrong guesses. Slide the share of wrong guesses, and choose where the teaching line settles its forks.
The teaching line's 6.5 ticks a number comes from stepping Chapter 2's loop with forwarding (7 ticks when the number is added, 6 when skipped); its wrong guesses cost 1, 2 or 3 ticks depending on the stage that settles the fork. The big core's 2.5 and 20 are Chapter 0's measurement worked back into ticks, not a model of any named processor.
You do not have to guess which forks are guessed badly. The processor counts, like a tally counter clicking at a door: it has circuits that count events such as ticks, finished instructions and wrong guesses, called performance counters. Linux, the software in charge of many robots' computers (Chapter 6 says more about it), has a tool for reading them around one program: perf stat. You name the events you want counted after -e, and the program to run after them:
shellperf stat -e cycles,instructions,branches,branch-misses ./filterHere is the example output printed in perf's own manual:
perf stat 229,570,665,834 cycles:u # 2.742 GHz
313,163,853,778 instructions:u # 1.36 insn per cycle
69,704,684,856 branches:u # 832.559 M/sec
2,078,861,393 branch-misses:u # 2.98% of all branchesRead it line by line. cycles is ticks: 229.6 billion. instructions is instructions finished: 313.2 billion. perf divides the two and prints 1.36 insn per cycle: instructions finished per tick, which is called IPC, the flip side of Chapter 0's CPI (1 ÷ 1.36 = 0.73 ticks an instruction).
branches is forks: 69.7 billion. branch-misses is wrong guesses: 2.08 billion, which perf prints as 2.98% of all branches. The :u after each name means perf counted only the instructions of the program itself, and the GHz and M/sec figures are perf's own arithmetic on the counts and the time.
2.98% sounds small. But multiply it by the price. At about 20 ticks each, 2,078,861,393 wrong guesses cost 41.6 billion ticks: 18.1% of all 229.6 billion. Remove every one and the program could run at most 1.22 times faster.
worked exampleGuessers on made-up patterns, after they settle
a loop of 8 (7 jumps, then fall out):
same as last time 1 − 2/8 = 75.0% two strikes 1 − 1/8 = 87.5%
history 100%
a loop of 100:
1 − 2/100 = 98% 1 − 1/100 = 99%
alternating:
0.0% 50.0% 100%
random (coin flips): about 49 to 51% for all three
sorted halves: wrong only at the switch (once, twice, once), then
right: 100.0% to one decimal
The same fork on two machines: ticks = b + m × P
teaching line: b = 6.5, P = 3: 6.5 + 0.5 × 3 = 8.0 against 6.5:
1.23 times
deciding in execute (P = 2): 7.5, 1.15 times;
in decode (P = 1): 7.0, 1.08 times
big-core toy: b = 2.5, P = 20: 2.5 + 0.5 × 20 = 12.5 against 2.5:
5.0 times
Reading perf's own example
instructions ÷ cycles = 313,163,853,778 ÷ 229,570,665,834
= 1.36 per tick (IPC), 0.73 ticks an instruction (CPI)
branch-misses ÷ branches = 2,078,861,393 ÷ 69,704,684,856
= 2.98% of all forks
wrong guesses per 1,000 instructions = 6.64
at about 20 ticks each: 2,078,861,393 × 20 = 41.6 billion ticks
= 18.1% of all ticks (15 ticks: 13.6%; 10 ticks: 9.1%)
remove every wrong guess: at most 1 ÷ (1 − 0.181) = 1.22 times fasterWhat happens. A robot's obstacle filter keeps only the pixels above a brightness threshold. On camera frames of a smooth floor it keeps up with the camera; on frames of gravel, with exactly the same number of pixels, it falls behind.
What you measure. Run perf stat -e cycles,instructions,branches,branch-misses on the filter, once on a floor frame and once on a gravel frame. The gravel run shows far more branch-misses and a lower IPC: the threshold's if is a fork on noisy data, and noise is a coin flip.
What you change. Take the fork away. Code that computes the same answer with no fork is branchless code. Write the step so it adds the pixel or zero without an if, like the trick below; or keep the plain if and let the compiler turn it into a conditional select by itself, which compilers call if-conversion.
Check the disassembly (Chapter 2): a recent gcc, told not to use Chapter 5's many-numbers instructions, already turned this if into csel on Arm and cmovg on x86-64, and kept the fork on RISC-V. Sorting also works, as in Chapter 0, but only when the data can be put in order more cheaply than the wrong guesses cost; the 2012 test sorted before starting its stopwatch. Then measure again.
Here is the branchless trick from the 2012 answer, in C:
c/* all ones (-1) if the number is below 128, all zeros if not */ int t = (data[c] - 128) >> 31; /* ~t flips it: keep the number, or keep nothing */ sum += ~t & data[c];
Step by step: data[c] − 128 is negative exactly when the number is below 128. Computers store a negative number so its leftmost bit is 1 (this lesson does not derive why); shifting a 32-bit number right by 31 places copies that leftmost bit all the way across, leaving only its sign: all ones for a negative number on the compilers this trick was written for, all zeros otherwise. ~t flips every bit, and & keeps the number where ~t is all ones and wipes it where it is zero. C leaves the right shift of a negative number to each compiler, which is one more reason to prefer letting the compiler write its own select. (If that bit trick is unfamiliar, the numpy version below keeps or drops each number with no bit shifting at all.)
In Python with numpy the same idea is a select over the whole list at once, with no fork per number:
pythonimport numpy as np # 32,768 numbers from 0 to 255 x = np.random.default_rng(0).integers(0, 256, 32768) # keep each number or 0, then add them all total = int(np.where(x >= 128, x, 0).sum())
In the lab you build the two-strike counter and the history guesser yourself, and race them on the same four patterns.
Chapter 4
Let the core run ahead of a slow load, and meet the chain it can never run ahead of.
An order comes into a kitchen: soup, a salad and bread. The soup needs twenty minutes on the stove. A good cook does not stand watching the pot. She starts the soup, makes the salad and slices the bread while it simmers, and plates everything together at the end. A cook who worked strictly down the ticket would stand idle for twenty minutes.
Now hand her an order where every dish needs the one before it: roast the bones, make the stock from the bones, make the sauce from the stock. No amount of cleverness makes that order faster. This chapter is about both kinds of order, and why a robot's map, stored the wrong way, becomes the second kind.
Chapter 2's line takes instructions strictly in order, so one instruction that waits holds up everything behind it. Most waits are a tick or two, like the load-use bubble. But a number that has to come from far away in memory can take hundreds of ticks, and everything behind it stands still for all of them, even instructions that have nothing to do with that number.
A big core does what the good cook does. It fetches far ahead into a waiting area and runs any instruction whose inputs are ready, even ahead of earlier ones that are still waiting: out-of-order execution. The waiting area of instructions it has fetched but not finished, like the rail of tickets above the stove, is the reorder buffer, or the window.
The shuffle stays inside the core. Results are made official strictly in program order, the way a kitchen plates dishes in the order they were ordered, however they were cooked: this is retiring each instruction in turn, so the program never sees the shuffle. That order is also what makes a wrong guess (Chapter 3) or a fault (Chapter 7) clean to undo: everything after the bad point has not been made official yet, so it is simply thrown away.
Our loop writes t0 for every number. Taken literally, the next number's load into t0 would have to wait until this number's add had finished reading t0, although the two have nothing to do with each other. It is like two dishes told to share one bowl: the second waits only because the bowl is busy. A wait caused only by reusing a register's name, not by needing its value, is a false dependency.
So the core hands out a fresh bowl for every dish. It gives every new result its own hidden register, so reused names stop colliding: register renaming. Each number's t0 is a different box inside the core, and the next number's load can start at once. Renaming removes waits over names. It cannot remove a wait for a value.
Take a run of instructions where each needs the answer of the one before: a dependency chain, like a relay where each runner waits for the baton. If each step takes L ticks from its start to its answer, step k + 1 cannot start before step k's answer exists. So the starts are at least L apart, and n steps take at least n × L ticks, however big the window: at most one step every L ticks.
Look at what is missing from that sum: the window. A bigger window cannot beat it. Independent steps have no such limit: they are held back only by how many can be in flight at once and how fast new ones can start. If one may start every tick and the window is big enough, n independent loads finish about (n − 1) + L ticks after the first one starts.
Memory is not one place. Think of a cook's ingredients: a few on the shelf at her elbow, more in the cupboard across the room, more in the storeroom down the hall, and everything else in a warehouse across town. The core keeps copies of recently used memory on small, fast stores close to it: caches. The nearest and smallest is L1, the shelf at your elbow; then L2 and L3, each bigger and slower, the cupboard and the storeroom.
Some chips add a system-level cache shared by everything on the chip. Behind them all sits the computer's main memory, far bigger and far slower than any cache: the DRAM, the warehouse across town.
Here is the measured ladder of one big Arm core, which ticks at about 4.0 GHz (its L3's 56 ticks are 14 ns), so one tick is a quarter of a nanosecond:
| level | ticks | time |
|---|---|---|
| L1 | 4 | 1 ns |
| L2 | 12 | 3 ns |
| L3 | about 56 | 14 ns |
| system-level cache | 168 to 188 | 42 to 47 ns |
| main memory | 452 | 113 ns |
From the elbow shelf to the warehouse is 113 times as far.
Now a treasure hunt, where each note says where the next note is hidden. A pointer is a number that is an address: a note saying where the next note is. A linked list is a chain of records where each holds the address of the next; each record is a node. To reach node k + 1 you must first load node k, because node k holds its address. Walking a list, one load per node, is pointer chasing: a dependency chain made of loads.
So each step pays the full wait of wherever the nodes live. A million nodes of 64 bytes take 64 MB, bigger than the 16 MB system-level cache of the measured chip, so they live in main memory: 1,000,000 × 113 ns = 113 ms for one walk. Watch the chain against the same loads made independent:
Each row is one load: yellow while it waits for its number, a blue dot when the number lands. The dashed bracket is the window of loads allowed to wait at once. Switch between a chain and independent loads, move the data nearer or farther, and widen the window.
Toy window: one load starts per tick and at most "window" loads wait at once. Real cores also cap how many loads can wait on memory, which the next lesson measures. The load delays are measured on one big Arm core (Cortex-X925 in NVIDIA's GB10, by Chips and Cheese): 4, 12 and about 56 ticks for the three cache levels, about 452 for main memory at 4.0 GHz.
Store the same records side by side in memory, as an array, and every address is known in advance: the next is simply 4 bytes, or 64, further on. The loads no longer depend on each other, so the core can send many to memory at once and wait for all of them together, like a cook with several orders out at the warehouse at once. Many loads waiting on memory at the same time is memory-level parallelism (the next lesson, on caches, takes it apart).
In the device above, with its toy window of 16, 16 loads from main memory take 467 ticks as independent loads and 7,232 as a chain: 15.5 times apart. The window matters only for the independent loads. For the chain it changes nothing, whatever you set it to.
What this lesson does not claim matters too. How much faster a real array walk is depends on the chip's limit on loads in flight, which this lesson does not measure; the chain's cost is the solid number: one full trip per node. Here is the device's rule as Python, and its real output:
python# window.py: 16 loads, each L ticks from start to # data; a chain versus independent loads (the # device's rule) def chain(n, L): # each load starts when the previous one's # data arrives return n * L def independent(n, L, W): done = [] # the tick each load's data arrives for i in range(n): # at most one new load starts per tick start = i # at most W loads may wait at once if len(done) >= W: start = max(start, sorted(done)[-W]) done.append(start + L) return max(done) for name, L in [("L1", 4), ("L2", 12), ("L3", 56), ("main memory", 452)]: c = chain(16, L) print(f"{name:12s} chain {c:5d} ticks | " f"window 4: {independent(16, L, 4):5d} | " f"window 16: {independent(16, L, 16):4d}")
outputL1 chain 64 ticks | window 4: 19 | window 16: 19
L2 chain 192 ticks | window 4: 51 | window 16: 27
L3 chain 896 ticks | window 4: 227 | window 16: 71
main memory chain 7232 ticks | window 4: 1811 | window 16: 467And here is the classic measurement to run on your own machine. It builds a million nodes, links them in a shuffled order, walks them once as a chain and once as an array, and prints the time per node for each. The page claims no result for it: run it and compare the two numbers it prints.
c/* chase.c: walk the same nodes as a chain (each load needs the last) and as an array (each address known ahead) */ #define _POSIX_C_SOURCE 199309L #include <stdio.h> #include <stdlib.h> #include <time.h> typedef struct node { struct node *next; long value; char pad[48]; } node; /* 64 bytes: one cache line, the memory-transfer unit Chapter 5 explains */ static double now(void) { struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t); return t.tv_sec + t.tv_nsec / 1e9; } int main(void) { const long n = 1 << 20; /* 1,048,576 nodes: 64 MB */ node *pool = malloc(n * sizeof *pool); long *order = malloc(n * sizeof *order); for (long i = 0; i < n; i++) order[i] = i; for (long i = n - 1; i > 0; i--) { long j = rand() % (i + 1); long t = order[i]; order[i] = order[j]; order[j] = t; } for (long i = 0; i < n; i++) { pool[order[i]].next = &pool[order[(i + 1) % n]]; pool[i].value = i; } double t0 = now(); long sum = 0; node *p = &pool[order[0]]; for (long i = 0; i < n; i++) { /* the chain: the next address comes from this load */ sum += p->value; p = p->next; } double t1 = now(); for (long i = 0; i < n; i++) sum += pool[i].value; /* the array: every address is known in advance */ double t2 = now(); printf("chain %.1f ns a node, array %.1f ns a node (sum %ld)\n", (t1 - t0) / n * 1e9, (t2 - t1) / n * 1e9, sum); free(pool); free(order); return 0; }
Build it with cc -O2 chase.c -o chase. The shuffle scatters the chain across all 64 MB, so almost every step misses every cache; rand() is good enough to scatter it.
worked exampleOne big Arm core's memory ladder, measured (4.0 GHz: 56 ticks = 14 ns,
so a tick is 0.25 ns)
L1 hit 4 ticks = 1 ns 1 times
L2 12 ticks = 3 ns 3 times
L3 56 ticks = 14 ns 14 times
system cache 168 to 188 = 42 to 47 ns 42 to 47 times
main memory 452 ticks = 113 ns 113 times
A chain of loads (a linked list) pays the full wait at every step
1,000,000 nodes × 64 bytes = 64 MB, more than the 16 MB system
cache: they live in main memory
one walk: 1,000,000 × 113 ns = 113 ms
if every node sat in L2: 1,000,000 × 3 ns = 3 ms
a camera at 30 frames a second (illustrative) gives 33.3 ms a
frame: 113 ÷ 33.3 = 3.4 frames
The toy window: 16 loads from main memory (452 ticks each), one
may start per tick
chain: 16 × 452 = 7,232 ticks, 452 a load
independent, window 4: four rounds of 452 = 1,811 ticks,
113 a load: 4.0 times sooner
independent, window 16: the last starts at tick 15 and lands
at 15 + 452 = 467 ticks: 15.5 times soonerWhat happens. A robot's planner keeps the obstacles it has seen in a linked list, adding each new one wherever memory is free. A sweep over a million obstacles was estimated from the instruction count at a few milliseconds. It takes 113 ms, and the plan arrives more than three camera frames late.
What you measure. Time the sweep and divide by the number of nodes: about 113 ns a node, one full trip to main memory each. The count of instructions per tick (perf stat, Chapter 3) is tiny: the core spends almost every tick waiting on one load whose address came from the load before.
What you change. Store the obstacles in an array, or in a few large blocks, so the next address is known without a load. Keep the part the planner reads every sweep small enough to live in a cache. Then time it again.
Big cores keep widening: the measured Arm core fetches ten instructions a tick and keeps two thousand jumps in its fastest target table. A wider front end and a bigger window help every independent piece of work. None of it moves the chain ceiling: a chain still runs at the speed of its links, which is why the fastest code on a big core is code that gives it independent work to do.
A graphics processor, a GPU, meets slow memory in another way. The lesson on GPUs shows what the rate at which memory delivers bytes, rather than its delay, does there.
Chapter 5
Pack four, sixteen or sixty-four numbers into one instruction, and find what still limits the whole program.
You have 1,024 envelopes to mark with the same return address. With a pen you write 1,024 times. With a rubber stamp as wide as four envelopes laid side by side, you press 256 times. A robot's vision code is full of work like this: the same small step on every pixel of every frame. This chapter is about how wide a stamp a processor can have, and what a wider stamp does, and does not do, for the whole job.
The processor's rubber stamp is a single instruction that does the same thing to several numbers at once: SIMD, short for single instruction, multiple data. It works on a wide register that holds several numbers side by side, like an egg carton holds eggs: a vector register. Each position in it, each egg cup, is a lane.
Arm's SIMD, whose registers are always 128 bits, is called Neon. It has 32 vector registers of exactly 128 bits, four 32-bit numbers each. So one Neon instruction can multiply four numbers by 3 at once, and scaling 1,024 numbers takes 256 vector instructions instead of 1,024.
Arm's newer Scalable Vector Extension, SVE, lets each chip's maker pick the register size: anywhere from 128 to 2048 bits, in steps of 128, which is 16 possible sizes. Its 32 registers keep Neon's 128 bits as their lowest part.
But a wider choice raises a problem. A program written for exactly four lanes cannot use sixteen, and a program written for sixteen breaks on a chip with four. So SVE code is written the other way round: it asks the hardware how many lanes it has and steps through the data by that many, like a stamp that works whatever its width. Code written that way is vector-length agnostic, and the same program file runs on every size: 1,024 numbers take 256 passes at 128 bits, 128 at 256, 64 at 512 and 16 at 2048. Here is the idea as a Python sketch, with its real output:
python# vla.py: one loop, any vector length (a sketch of # how vector-length-agnostic code steps) import numpy as np def scale(xs, k, vl_bits): # how many 32-bit numbers one vector register holds lanes = vl_bits // 32 out, passes = np.empty_like(xs), 0 # step by however many lanes this chip has for i in range(0, len(xs), lanes): # one vector instruction's worth of work out[i:i + lanes] = xs[i:i + lanes] * k passes += 1 return out, passes xs = np.arange(1024, dtype=np.int32) for vl in (128, 256, 512, 2048): out, p = scale(xs, 3, vl) print(f"{vl:5d} bits: {vl // 32:3d} lanes, {p:4d} passes, " f"same answer: {bool((out == xs * 3).all())}")
output 128 bits: 4 lanes, 256 passes, same answer: True
256 bits: 8 lanes, 128 passes, same answer: True
512 bits: 16 lanes, 64 passes, same answer: True
2048 bits: 64 lanes, 16 passes, same answer: TrueRISC-V has a stamp too. Its vector extension, an optional part of the instruction set like Chapter 2's C extension, is called V. Because so many parts are optional, RISC-V names bundles of them: a list of extensions a chip must have to carry a given name is a profile. RISC-V's RVA23 profile, for the big application processors that run general-purpose software, makes V mandatory (in its user-level part, RVA23U64), where the older RVA22 left it optional.
Now a practical snag. Eggs only come in boxes of one fixed size, and memory is the same: it travels between the caches and the core in blocks of 64 bytes, called cache lines. A buffer, a block of memory set aside for data such as one camera frame, that starts at an address that is a multiple of its block size is aligned, like a carton lined up with the edge of the box.
A 16-byte Neon load from an aligned buffer always sits inside one line: its four loads per line start at bytes 0, 16, 32 and 48. Start the same buffer 4 bytes late and the loads sit at bytes 4, 20, 36 and 52 of each line: the one at 52 covers bytes 52 to 67 and crosses into the next line. One load in four now touches two lines. With 32-byte loads it is one in two; with 64-byte loads, every single one; a 256-byte load touches five lines instead of four.
What a straddling load costs depends on the chip: from almost nothing on some big cores to 12 to 16 ticks on older or small ones. One careful measurer found about 12 ticks per straddling read on Intel's Core 2, hardly any on Nehalem, and 16 on the first Atom. Step through it:
The grid is 1,024 numbers, 16 to a 64-byte line; faint lines mark where one line ends. Each pass lights up one vector's worth of numbers in blue. Pick a vector size, then start the buffer 4 bytes late and watch which loads straddle two lines.
The lane and pass counts are exact arithmetic. The cost of a straddling load is Agner Fog's measurement on three Intel generations: about 12 ticks per misaligned read on Core 2, hardly any on Nehalem, 16 on the first Atom. The one tick per load is illustrative.
A trip is a long flight and a slow taxi ride. Make the flight twice as fast and the whole trip is not twice as fast: the taxi takes as long as ever. A program is the same. Vectorizing speeds up the loops you vectorize, and nothing else.
Derive it. Call the program's old running time 1. A share f of it is the part you speed up; the rest, 1 − f, you leave alone. Make the part s times faster: it now takes f ÷ s, while the rest still takes 1 − f.
The new time is (1 − f) + f ÷ s, and the speedup is the old time over the new. The speedup of the whole is held back by the part you did not speed up: Amdahl's law.
f = 0.9, s = 4: 1 ÷ (0.1 + 0.225) = 3.08 · the ceiling as s grows: 1 ÷ 0.1 = 10
Put numbers in. With 90% of the time vectorized at four times, the new time is 0.1 + 0.9 ÷ 4 = 0.325, and the whole runs 3.08 times faster. At sixteen times, 6.40. Now let s grow without limit: f ÷ s shrinks to nothing, and the speedup approaches 1 ÷ (1 − f). With an infinitely wide stamp, a program that is 90% vectorized is never more than 10 times faster, and one that is half vectorized can never pass 2.
You rarely write vector instructions by hand. The settings you hand a compiler, called flags, include how hard to optimize: -O2, -O3. At high optimization, compilers rewrite simple loops to use SIMD by themselves, vectorizing them. The 2012 answer reports that Clang, and gcc from version 5 at -O3, vectorize Chapter 0's loop, and then there is no per-number fork left at all. That is why the build in Chapter 2 was made with -fno-tree-vectorize: to keep the loop simple enough to read.
worked exampleLanes and passes for 1,024 numbers of 32 bits
128 bits ÷ 32 = 4 lanes → 1,024 ÷ 4 = 256 passes
(Neon, and the smallest SVE)
256 bits = 8 lanes → 1,024 ÷ 8 = 128 passes
512 bits = 16 lanes → 1,024 ÷ 16 = 64 passes
2048 bits = 64 lanes → 1,024 ÷ 64 = 16 passes
legal SVE sizes: 128 to 2048 in steps of 128: 2048 ÷ 128 = 16 sizes
Loads that straddle a 64-byte line when the buffer starts 4 bytes late
16-byte loads start at 4, 20, 36, 52: the one at 52 covers 52 to 67
and crosses 64: 1 in 4
32-byte loads start at 4, 36: the one at 36 crosses: 1 in 2
64-byte loads: every one crosses; a 256-byte load touches 5 lines
instead of 4
cost of a straddling load: about 12 ticks (Core 2), hardly any
(Nehalem), 16 (first Atom)
Amdahl: S = 1 ÷ ((1 − f) + f ÷ s)
f = 0.9, s = 4: 1 ÷ (0.1 + 0.225) = 1 ÷ 0.325 = 3.08
f = 0.9, s = 16: 1 ÷ (0.1 + 0.05625) = 6.40
f = 0.9, s → ∞: 1 ÷ 0.1 = 10
f = 0.95, s = 8: 1 ÷ (0.05 + 0.11875) = 5.93
f = 0.5, s → ∞: 1 ÷ 0.5 = 2What happens. A vectorized filter runs at one speed on most camera frames and noticeably slower on others, with the same size and the same kind of data. The slow frames come from one part of the program.
What you measure. Print where each frame's buffer starts within its cache line: (uintptr_t)p % 64, the address divided by 64, keeping the remainder. The fast buffers start at 0; the slow ones at 4, because that part of the program hands over a pointer 4 bytes into a larger block. The chip's own counters for loads that cross a line say the same thing (their names differ from chip to chip). Here is the check as a whole program, with its real output:
c/* align.c: where does a buffer start, relative to a 64-byte cache line? */ #include <stdint.h> #include <stdio.h> #include <stdlib.h> int main(void) { /* C11: start on a 64-byte boundary; the size must be a multiple of 64 */ char *block = aligned_alloc(64, 8192); int *good = (int *)block; /* starts at the start of a line */ int *late = (int *)(block + 4); /* the same memory, 4 bytes later */ printf("good starts %lu bytes into a line, late %lu\n", (unsigned long)((uintptr_t)good % 64), (unsigned long)((uintptr_t)late % 64)); free(block); return 0; }
outputgood starts 0 bytes into a line, late 4What you change. Allocate frame buffers aligned to 64 bytes (C11's aligned_alloc(64, size), with size a multiple of 64), and keep each image row a multiple of the vector's width, so no load straddles two lines. Then time both kinds of frame again.
Arm's 2025 update to the architecture, Armv9.7-A, adds SVE instructions, and instructions for its matrix extension, SME, that work on MXFP6, a 6-bit number format for AI from the Open Compute Project. Wider and narrower at once: more numbers per instruction, fewer bits per number. A GPU takes the same idea to thousands of lanes; the lesson on GPUs shows how.
Chapter 6
Place two threads on one core and then on two, and measure what sharing really buys.
Your robot's computer runs a camera program that must finish every frame on time, and a mapping job that can take as long as it likes. The system monitor lists 16 processors. You put the mapping job on processor 8, far from the camera on processor 0, and the camera starts missing frames. Processors 0 and 8 turn out to be two halves of the same core. (The numbering in this story is illustrative; every machine lists its own.)
Two foundations first. Every building has a management that decides who works where. A computer's management is the software that runs all other software (Linux, Android and Windows are examples): the operating system. Its heart, the part with the most power over the machine, is the kernel.
One of the kernel's jobs is deciding which program runs on which core, and when, like a manager handing out desks: the part that does it is the scheduler. A program can have several lines of work, like several workers' to-do lists, that the scheduler places on cores separately: each is a thread. Put two threads on two cores and they truly run at the same time; put them on one core and they must share it.
A big core can start several instructions every tick (Chapter 3's big Arm core fetches ten). One thread rarely keeps it that busy. It waits on memory (Chapter 4), and it throws work away after wrong guesses (Chapter 3). During those waits, most of the core's machinery sits idle: arithmetic units with nothing to add, slots in the window with nothing ready.
Picture two cooks sharing one stove, each using the burners the other has left free. Some cores do exactly this. They keep the registers of two threads at once, the registers that say where each thread is up to, and fill one thread's idle slots with the other's work: simultaneous multithreading, or SMT. Intel calls it Hyper-Threading.
To the operating system each half looks like a whole processor, a logical processor, and two that share one core are siblings. That is why the system monitor in the opening listed 16 processors on a chip of 8 cores. And it is cheap to build: the first version cost under 5% of the chip's area.
Intel's own measurement, on the big computers that run data centres: up to 30% more work per second on common server benchmarks, and 16 to 28% on three named server workloads. Not double. The two threads share one set of arithmetic units, one set of caches and one set of guessers.
A toy bound shows why it can never be double for a busy thread (illustrative numbers). If one thread alone keeps the core busy a share u of the time, a second can at best fill the rest, so two threads together are at most min(2, 1 ÷ u) times one. For a thread that keeps the core busy 77% of the time, 1 ÷ 0.77 = 1.3.
And some programs run slower on siblings than on separate cores, because two threads that both need the caches push each other's data out. No number is given here for that: you measure your own.
Take two jobs, each needing 10 seconds of a core to itself (illustrative). On two separate cores, both finish at 10 s. On one core's two siblings with sharing worth 30%, the core does 1.3 jobs' worth of work per second, shared between them, so the 20 seconds of work take 20 ÷ 1.3 = 15.4 s: both finish 1.54 times later than on separate cores. At 16%, 17.2 s. On one core with no sharing, taking turns, 20 s.
Picture a garage with a sports car and a scooter: one fast and thirsty, one slow and frugal. Some chips mix fast, power-hungry cores with slow, frugal ones on the same chip: Arm calls this big.LITTLE. Now the scheduler must know how much work each core can do at its top speed, its capacity. The kernel's own definition multiplies two things (a hertz is one tick a second):
a little core at 40% of a big core's capacity (illustrative): 10 s of big-core work takes 10 ÷ 0.4 = 25 s
On such mixed chips, Linux can also predict how much energy each placement will cost and choose the cheapest that still gets the work done: Energy Aware Scheduling (EAS). It works only on mixed chips, and it needs a table of each core's power costs, the Energy Model; without one it does not start. NVIDIA's GB10 pairs ten Cortex-X925 cores with ten Cortex-A725 cores on one chip. The Jetson AGX Thor robot computer has 14 cores of one kind, Neoverse-V3AE; if they all run at the same top speed, the energy-aware mode has no mix to choose between. Place two threads yourself:
Two threads, A and B, each need 10 seconds of a core to themselves. Place them on two cores, on the two siblings of one core, or on one core taking turns, and watch when each finishes.
Toy jobs of 10 s each and a 1 s turn; the 40% little core is illustrative. The only measured number is the size of the sharing gain: Intel's 2002 paper reports up to 30% on common server benchmarks and 16 to 28% on three named server workloads. Some programs run slower on siblings than on separate cores; no published number is used for that here.
So which processors are siblings? The kernel shows the hardware as a tree of small files under /sys, called sysfs, and one of them names the siblings of each processor. Linux numbers the logical processors cpu0, cpu1 and so on (CPU is short for central processing unit, another name for a processor):
shell# the logical processors in the same core as CPU 0 # (also exposed under its older, deprecated name # thread_siblings_list) cat /sys/devices/system/cpu/cpu0/topology/core_cpus_list 0,8
The 0,8 is an illustrative output: read your own.
Read it: cat prints a file, and core_cpus_list lists the logical processors in the same core as CPU 0. Here it says 0,8, so 0 and 8 are siblings. The same file is still exposed under its older name, thread_siblings_list, which the kernel marks deprecated.
Now you can place threads on purpose, like assigning someone a desk. The list of cores a thread may run on is its affinity; setting it by hand is pinning. The command taskset -c runs a program pinned to a list of processors, and taskset -p changes the affinity of one that is already running. The & lets the camera keep running while you type the next line, and time prints how long a program took:
shelltaskset -c 0 ./camera & # the camera on processor 0 # the mapper on its sibling: they share one core time taskset -c 8 ./mapper # the mapper on a core of its own time taskset -c 1 ./mapper # move an already running program, number 4242, to # processor 2 taskset -p -c 2 4242
worked exampleTwo jobs of 10 s each (illustrative), placed three ways
two separate cores: both finish at 10 s
one core's two siblings, +30%: 20 s of work ÷ 1.3 per second
= 15.4 s (1.54 times later)
+28%: 20 ÷ 1.28 = 15.6 s
+16%: 20 ÷ 1.16 = 17.2 s
one core, taking turns: 20 s
The toy bound (illustrative): busy 77% of the time alone → at most
1 ÷ 0.77 = 1.30 times with a sibling
Capacity
capacity = work per hertz × top frequency
a little core at 40% of a big core's capacity (illustrative):
10 s ÷ 0.4 = 25 s
Robot computers
Jetson AGX Thor: 14 Neoverse-V3AE cores; 128 GB of 256-bit
LPDDR5X at 273 GB/s (273 GB/s over 256 bits implies 8,533
million transfers a second, LPDDR5X-8533: 256 bits × 8,533 ÷ 8
= 273.1 GB/s; NVIDIA publishes only the bandwidth)
NVIDIA GB10: 10 Cortex-X925 + 10 Cortex-A725 coresWhat happens. The camera thread misses frames whenever the mapping job runs on processor 8, and never when it runs on processor 1. The team had counted 16 processors and assumed 16 cores.
What you measure. Read /sys/devices/system/cpu/cpu0/topology/core_cpus_list: it prints 0,8, so processors 0 and 8 are siblings in one core. Pin the mapper to 8 with taskset -c 8, then to 1, and time the camera's frames in both: on the sibling, the camera shares its core's machinery with the mapper; on processor 1 it has a core of its own.
What you change. Keep the camera's sibling free, or pin every batch job to cores that share nothing with the camera. The sharing that gives a batch job up to 30% more throughput can cost a deadline thread its frame.
Robot computers are now as big as servers. Thor pairs its 14 cores with 128 GB of main memory, a kind of DRAM called LPDDR5X, that can deliver 273 gigabytes a second over a connection 256 bits wide. With that many cores, the question is rarely whether there is a free core; it is whether the thread that has a deadline shares its core with one that does not. The lesson on Real-Time Linux takes this one step further and clears a core of every other job: a core of its own.
core_cpus_list before you place a thread that has a deadline.Chapter 7
Climb the chip's ladder of privilege one call at a time, and decode the fault you get for touching what you do not own.
Your robot's Linux starts up and loads the driver for its camera, the part of the kernel that talks to the camera. The moment the driver reads the camera interface's first setting, the whole machine stops with an error. The driver's code is correct, and the same camera works on another board. The kernel is the most powerful software on the robot, isn't it? Something on this chip just refused it.
The kernel is powerful, but it is not in charge of the whole chip. This chapter climbs the chip's ladder of who may do what, and ends by reading the error that stopped the robot.
A robot runs code you trust, the kernel, and code you trust less: apps (the ordinary programs), plug-ins, a program downloaded last week. The chip must stop an app from driving the motors directly or reading another app's memory, even if the app is badly written or hostile. Asking nicely is not enough; the hardware itself has to say no.
Think of an office building where your keycard opens the lobby and your own floor, the office floors belong to the management, and the vault has a guard of its own. The chip works the same way. It enforces modes that decide which instructions and which memory a piece of code may use: privilege levels, the floors a keycard opens.
Arm calls its four levels exception levels, EL0 to EL3, running from least to most powerful: EL0 is the lobby, EL1 the office floors, EL2 building management, and EL3, the most powerful of all, the vault. The common arrangement puts apps at EL0 and the kernel, Linux, at EL1.
At EL2 sits a hypervisor, the building management: software that runs several operating systems side by side on one chip, each believing it has the machine to itself. At EL3 sits firmware, software built into the board below the operating system, like the building's own wiring, acting as the secure monitor: the vault's guard, and what it guards comes in a moment. The architecture does not force this assignment; it is the usual one. So the operating system is on the second rung of four.
Code at one level cannot simply call code at a higher one, any more than a visitor can walk into the management offices. It rings the front desk. In the chip, that is an instruction that makes the processor stop what it is doing and jump to a handler, the code that deals with the request, at a higher level: an exception, also called a trap.
To do that, the processor abandons the instructions in flight behind the one that trapped, exactly as it abandons the work down a wrongly guessed road (Chapter 3), and starts fetching at the handler, now at the higher level. The same machinery that throws away a wrong guess is what lets the chip change who is in charge.
There are three such calls, one per rung. An app asks the kernel for something with SVC: that is a system call, the program's question at the front desk. The kernel asks the hypervisor with HVC. The kernel asks the secure firmware with SMC, the secure monitor call.
And there is no skipping rungs. From EL0 an app cannot call EL2 or EL3 directly. In Arm's own words, "the application at EL0 must use an SVC call to the kernel", and the kernel calls further up on its behalf.
The chip also writes down a code for the kind of exception, its exception class; the end of this chapter reads these codes.
Now picture two buildings on one foundation. Arm's TrustZone splits the chip into two worlds: the Non-secure world (also called normal), where Linux and your apps live, and the Secure world (also called trusted). At EL0, EL1 and EL2 the processor can be in either world, chosen by one bit in a control register at EL3 (its name is SCR_EL3.NS); EL3 itself is always Secure.
The Secure world's EL0 and EL1 hold a small, separate operating system that looks after secrets such as keys, like the vault's own staff: a trusted execution environment, or TEE.
Here is who plays each part in one common open-source setup, open-source meaning free for anyone to read and change. One open-source secure monitor is Trusted Firmware-A, whose runtime part, BL31, runs at EL3. BL31 hands a call meant for the trusted OS to a plug-in inside itself, a dispatcher, which switches the chip into the trusted OS at Secure EL1 and carries its answer back: the vault guard buzzing the vault's own staff and relaying their reply.
OP-TEE is an open-source trusted OS designed to sit beside Linux on Arm chips, and it runs at Secure EL1. When Linux executes SMC, the monitor always catches it first and, if the call is for OP-TEE, switches the chip to it. The kernel never talks to the Secure world directly; it rings the vault's guard.
A block on the chip that does one job, like a room with one purpose, is a peripheral: a timer, a serial port, the camera interface. Every access to one travels over the chip's internal road network, like the corridors of the building: the interconnect, which carries every access between cores, memory and peripherals.
Most peripherals do not know about worlds at all: each is simply Secure or Non-secure. On most modern chips the interconnect checks each access, and when Non-secure code touches a Secure-only device, it answers with a fault, without the access ever reaching the device. Some devices are set Secure or Non-secure at start-up, when the computer boots, by the boot firmware, and until then they are Secure.
Memory is split the same way, by a gatekeeper that divides it into regions, each Secure or Non-secure, like a locked storeroom: a TrustZone Address Space Controller, such as Arm's TZC-400, which supports up to nine regions and whose own settings only Secure code may change. Trusted Firmware-A sets it to raise an error when a Non-secure access reaches into Secure memory; on Arm's own development platforms, as Trusted Firmware-A's design describes, the top 16 MB of main memory is kept Secure. Climb the ladder yourself:
Four rungs, EL0 at the bottom, in two worlds: Non-secure on the left, Secure on the right, with the secure monitor spanning the top. Pick a call and follow it up the ladder.
The assignment of software to levels is the common one Arm describes; the architecture does not force it. Trusted Firmware-A's BL31 is the monitor at EL3 and OP-TEE runs at Secure EL1. Class codes from the Linux kernel's arm64 esr.h (v6.12).
RISC-V has a ladder too. It names its levels user, supervisor and machine; machine is the most powerful and the only one every RISC-V chip must have. Its hypervisor extension, H, turns supervisor mode into a hypervisor mode and adds a virtual supervisor mode for guest operating systems. RISC-V's RVA23 profile, announced as ratified in October 2024, makes H mandatory in its supervisor-level profile (RVA23S64), the one an operating-system kernel relies on.
When an exception comes to the kernel, the chip writes down why, like the first box on an incident report, into a register: the exception syndrome register, ESR_EL1. It is a 64-bit register, but everything this lesson reads sits in its lower 32 bits: Linux prints the whole value as sixteen hexadecimal digits, and the upper eight are zero here. The top six of those 32 bits, bits 31 to 26, are the exception class: which kind of exception it was. Here are the classes this lesson reads, with the names Linux's own source code gives them:
| class | in bits | Linux's name | what happened |
|---|---|---|---|
| 0x15 | 010101 | SVC (AArch64) | a system call from an app |
| 0x16 | 010110 | HVC (AArch64) | a hypervisor call |
| 0x17 | 010111 | SMC (AArch64) | a secure monitor call |
| 0x18 | 011000 | MSR/MRS (AArch64) | a forbidden register access |
| 0x24 | 100100 | DABT (lower EL) | a data abort from a lower level, such as an app's bad access |
| 0x25 | 100101 | DABT (current EL) | a data abort at the kernel's own level |
| 0x2F | 101111 | SError | a system error the chip reports |
To read the class, shift the value right by 26 places and keep six bits. Shifting right by 26 moves bits 31 to 26 down to positions 5 to 0 and drops everything below them; on the full 64-bit register it also brings down the bits above 31, always zero here. Then & 0x3F keeps exactly those six bits and drops the rest, because 0x3F is 111111 in binary:
AArch64 is Arm's name for its 64-bit instruction set; the classes above that trap a 64-bit-only operation carry it in their name.
0x96000010 → 100101 = 0x25: DABT (current EL)
Work one through. Take the value 0x96000010 (an illustrative value from a crash report). In bits it is 1001 0110 0000 0000 0000 0000 0001 0000; bits 31 to 26, the top six of its low 32 bits, are 100101, which is 0x25, or 37. Look 0x25 up: DABT (current EL).
DABT stands for a data abort, a data access that the chip refused or that failed, like a keycard reader flashing red. "Current EL" means it was taken at the kernel's own level: the kernel itself made the access. The kernel's name for a system error the chip reports is SError, class 0x2F; a Secure-only device can show up as either.
Type a syndrome value in hexadecimal, or pick one. Bits 31 to 26 of its low 32 bits are the exception class; the rest carry details this lesson does not decode.
Class values and names from the Linux kernel's arm64 esr.h and traps.c (v6.12). The four example values are illustrative; the bits below bit 26 carry details this lesson does not decode.
Here are the kernel's own definitions, and the same decode in Python with its real output:
c#define ESR_ELx_EC_SHIFT (26) #define ESR_ELx_EC_MASK (UL(0x3F) << ESR_ELx_EC_SHIFT) #define ESR_ELx_EC_SVC64 UL(0x15) #define ESR_ELx_EC_DABT_CUR UL(0x25) #define ESR_ELx_EC_SERROR UL(0x2F)
python# esr.py: which kind of exception was it? (class names # from Linux arm64 esr.h and traps.c) NAMES = { 0x00: "unknown", 0x15: "SVC (AArch64)", 0x16: "HVC (AArch64)", 0x17: "SMC (AArch64)", 0x18: "MSR/MRS (AArch64)", 0x24: "DABT (lower EL)", 0x25: "DABT (current EL)", 0x2F: "SError", } def exception_class(esr): # bits 31 to 26 of the syndrome return (esr >> 26) & 0x3F # an illustrative value from a crash report esr = 0x96000010 ec = exception_class(esr) print(f"{esr:#010x} -> bits {esr:032b}") print(f"class {ec:#04x} = {ec:06b}: " f"{NAMES.get(ec, 'not in this table')}")
output0x96000010 -> bits 10010110000000000000000000010000
class 0x25 = 100101: DABT (current EL)worked exampleThe ladder (the common arrangement; the architecture does not
force it)
EL0 apps --SVC--> EL1 kernel --HVC--> EL2 hypervisor
EL1 kernel --SMC--> EL3 secure monitor (Trusted Firmware-A, BL31)
--> Secure EL1 trusted OS (OP-TEE)
Exception classes the chip records (names from Linux's esr.h and
traps.c)
0x15 = 010101 SVC (AArch64): a system call
0x16 = 010110 HVC (AArch64): a hypervisor call
0x17 = 010111 SMC (AArch64): a secure monitor call
0x18 = 011000 MSR/MRS (AArch64): a forbidden register access
0x24 = 100100 DABT (lower EL): a data abort from a lower level
0x25 = 100101 DABT (current EL): a data abort at the kernel's
own level
0x2F = 101111 SError
Decoding 0x96000010 (illustrative)
in bits: 1001 0110 0000 0000 0000 0000 0001 0000
bits 31 to 26, the top six of its low 32 bits: 100101 = 0x25 = 37
(0x96000010 >> 26) & 0x3F = 0x25 → DABT (current EL): the kernel's
own data access failed
Memory and devices
TZC-400: up to 9 regions, each Secure or Non-secure, settings
changeable only from the Secure world
Arm's development platforms (Trusted Firmware-A's design): the
top 16 MB of main memory Secure
a device set at start-up: Secure until the boot firmware says
otherwiseWhat happens. The robot's kernel boots, the camera driver reads the camera interface's first setting, and the machine stops with an abort. The same driver works on another board.
What you measure. Read the crash report's syndrome value and take bits 31 to 26, the top six of its low 32 bits: 0x25, a data abort at the kernel's own level (or the kernel reports an SError). The access never reached the camera: the interconnect refused it, because on this board the camera interface is a device the boot firmware sets up, and it was left Secure, the default.
What you change. Mark the camera interface Non-secure in the boot firmware's platform setup, where Trusted Firmware-A configures the board. Or, if the Secure world is meant to own the camera, for example to protect its images, stop touching it from Linux and ask the trusted OS for frames through an SMC.
Arm's newer chips can add two more security states, one of them for what Arm calls Realms, separate from both worlds, and RISC-V now requires the hypervisor extension in its operating-system profile. The ladder keeps growing rungs above the operating system. Where the secure monitor itself comes from, the chain of firmware that starts the chip, is the subject of a coming lesson on the boot chain.
Chapter 8
Price one trip into the kernel, and use its share of the time to predict the whole program's slowdown.
A robot's logger writes a short line to a file thousands of times a second, and every write is a trip into the kernel. After a routine update that switched on the kernel's full set of security fixes, the logger uses noticeably more of the processor. Not one line of its code changed. Where did the time go, and how much more should you expect?
The short answer: a cost that triples only matters in proportion to how often you pay it. This chapter prices one trip, then turns the price into a prediction for the whole program.
A system call (Chapter 7) enters the kernel and comes back: up the ladder and down again. One careful measurement on two hosts with the same processor priced calls that ask the kernel for almost nothing, so that nearly all their cost is the crossing itself. getuid, which asks the kernel for the number of the user running the program, took 78 ns with the security fixes switched off and 239 ns with them on.
The plainest call in the same table, made through the general-purpose syscall function, took 76 and 233 ns: 3.07 times dearer. close, which closes a file, moved from 93 to 257 ns. Across the 15 machines measured, that plainest call ranged from 76 to 620 ns; in the measurer's words, the switches between a program and the kernel cost "a few hundred nanoseconds, on all hosts".
Go back to Chapters 3 and 4. The processor works ahead on guesses and throws the work away when a guess was wrong. Thrown-away work was supposed to leave no trace. It turned out it can leave traces, like footprints in the snow: for example in which memory is now sitting in the caches, and a program can time its own loads to read those traces. Attacks that read secrets through the traces that thrown-away guessed work leaves behind are speculative execution exploits, and they could read memory across the boundary between a program and the kernel.
The fixes, like a second lock on the door, are called mitigations. They do extra work at the boundary, which is why every crossing got dearer. The kernel's mitigations= setting, given on its command line, the settings handed to the kernel as it starts, chooses how much of it to do. auto, the default, mitigates everything and leaves simultaneous multithreading on. off disables every optional mitigation, which, in the kernel's own words, "improves system performance, but it may also expose users to several CPU vulnerabilities".
Chapter 5 sped a part up. Now a part slows down, and the same arithmetic answers it. Call the old running time 1, with a share f spent crossing into the kernel. Make each crossing r times dearer: the crossing part now takes f × r, while the rest still takes 1 − f. The new time is (1 − f) + f × r, which is the same as 1 + f × (r − 1).
f = 5%: 0.95 + 0.05 × 3.066 = 1.103, 10.3% slower
So a service that spends 5% of its time crossing, each crossing 3.07 times dearer (3.066 before rounding), runs 0.95 + 0.153 = 1.103 of its old time: 10.3% slower. Not 207% slower. The slowdown in percent is 100 × f × (r − 1), which is about 207 × f: small shares stay small.
It is Chapter 5's law with the arrow reversed. Speed a share f up s times and the whole is 1 ÷ ((1 − f) + f ÷ s) times faster; slow it down r times and the whole is (1 − f) + f × r times slower. Both say the same thing: the part you change counts only by its share.
Red Hat measured whole workloads when these fixes first shipped. Workloads with many crossings between programs and the kernel (databases, heavily cached memory work, benchmarks that cross constantly) ran 8 to 19% slower; moderate ones 3 to 7%; work that mostly computes 2 to 5%; and systems that bypass the kernel entirely, under 1%. A later, cheaper software fix, retpoline, cut those to 4 to 8%, 2 to 5% and 1 to 2%.
Now run the formula backwards. A measured slowdown of s percent means a share f = s ÷ (100 × (r − 1)) of the time was spent crossing. At r = 3.07, 8 to 19% slower means 0.08 ÷ 2.07 = 3.9% to 0.19 ÷ 2.07 = 9.2% of the time spent crossing. The measured slowdowns are exactly what a few percent of crossings predicts. Drag the share yourself:
The curve is the whole program's slowdown against the share of time spent crossing into the kernel. Drag the share, set how much dearer each crossing became, and compare with the shaded measurements. Switch to speeding a part up to see Chapter 5's side of the same law.
The 3.07 is one measured pair: a raw system call took 76 ns with mitigations off and 233 ns with them on two hosts with the same Xeon Gold 6256 processor and the same RHEL 7 kernel, one booted with mitigations=off (Sauthoff, 2021). The bands are Red Hat's measured slowdowns for crossing-heavy workloads, 8 to 19% before retpoline and 4 to 8% after. The curves are the formulas; the shares are yours to choose.
Price your own crossings before they surprise you (the rates here are illustrative). A logger writing 10,000 lines a second at 233 ns a crossing spends 2.33 ms of every second crossing: 0.23% of a core, or 0.08% without the fixes. A network loop that crosses once per message, a packet, at 1,000,000 packets a second spends 23.3% of a core crossing, against 7.6% without them.
The cure is fewer crossings, not no fixes. Write many lines per call instead of one. Or let a program talk to the device directly, without crossing into the kernel for every message: kernel bypass, which Red Hat measured under 1%.
And notice that this is still Chapter 0's equation: time = instructions × ticks per instruction × length of a tick. The fixes add instructions and ticks inside the crossing, and nowhere else. That is why the share of time spent crossing is the whole story.
worked exampleOne crossing, measured on two hosts with the same Xeon Gold 6256
processor (2021)
raw system call: 76 ns with mitigations off, 233 ns with them:
233 ÷ 76 = 3.07 times
getuid: 78 → 239 ns (3.06 times) close: 93 → 257 ns (2.76 times)
across 15 machines: 76 to 620 ns for the raw call
Amdahl run backwards: new time = (1 − f) + f × r, with r = 233 ÷ 76
= 3.066 (3.07 rounded)
f = 2%: 0.98 + 0.02 × 3.066 = 1.041 → 4.1% slower
f = 5%: 0.95 + 0.05 × 3.066 = 1.103 → 10.3% slower
f = 10%: 0.90 + 0.10 × 3.066 = 1.207 → 20.7% slower
f = 20%: 0.80 + 0.20 × 3.066 = 1.413 → 41.3% slower
Red Hat's measured 8 to 19%, run backwards
f = 0.08 ÷ 2.07 = 3.9% to f = 0.19 ÷ 2.07 = 9.2% of the time
spent crossing
Budgets (illustrative rates)
10,000 crossings a second: 10,000 × 233 ns = 2.33 ms a second
= 0.23% of a core (at 76 ns: 0.08%)
1,000,000 crossings a second: 1,000,000 × 233 ns = 233 ms a
second = 23.3% of a core (at 76 ns: 7.6%)Here is both directions of the law in a few lines of Python, with its real output:
python# amdahl.py: a share f of the time made s times # faster, or r times slower def speedup(f, s): return 1 / ((1 - f) + f / s) def slowdown(f, r): return (1 - f) + f * r print(f"90% made 4 times faster: {speedup(0.9, 4):.2f} " f"times faster overall") # two hosts, same Xeon Gold 6256, one with # mitigations=off: a system call 3.07 times dearer r = 233 / 76 for f in (0.02, 0.05, 0.10, 0.20): print(f"{f:4.0%} of the time crossing into the " f"kernel: {100 * (slowdown(f, r) - 1):4.1f}% slower")
output90% made 4 times faster: 3.08 times faster overall
2% of the time crossing into the kernel: 4.1% slower
5% of the time crossing into the kernel: 10.3% slower
10% of the time crossing into the kernel: 20.7% slower
20% of the time crossing into the kernel: 41.3% slowerWhat happens. After a kernel update, a service that makes many small system calls runs about a tenth slower. Its code, its data and its load are the same.
What you measure. On a test machine, time a million system calls that do almost nothing, once booted normally and once with mitigations=off on the kernel's command line (the program below). Call the kernel with syscall(SYS_getpid), not plain getpid(): some versions of glibc, the standard C library on Linux, remembered the answer and never crossed at all, and one measured 1 to 2 ns for it. Then find the share of time the service spends in system calls, and multiply: 1 + f × (r − 1). A service that crosses for 5% of its time and pays 3.07 times per crossing should run about 10% slower, and it does.
c/* crossing.c: what one trip into the kernel costs on this machine (Linux) */ #define _GNU_SOURCE #include <stdio.h> #include <time.h> #include <unistd.h> #include <sys/syscall.h> int main(void) { const long n = 1000000; struct timespec t0, t1; clock_gettime(CLOCK_MONOTONIC, &t0); for (long i = 0; i < n; i++) /* a real trip into the kernel; the C library's getpid() may answer from a cache */ syscall(SYS_getpid); clock_gettime(CLOCK_MONOTONIC, &t1); double ns = ((t1.tv_sec - t0.tv_sec) * 1e9 + (t1.tv_nsec - t0.tv_nsec)) / n; printf("%.0f ns per system call\n", ns); return 0; }
Build with cc -O2 crossing.c -o crossing and run it twice, booted normally and booted with mitigations=off on a test machine; the ratio of the two numbers is your r.
What you change. Cut the crossings: batch small writes into large ones, or bypass the kernel for the busiest path. Switching the mitigations off buys the time back, but it reopens the holes; on a robot that talks to a network, that trade is rarely worth it.
The fixes have kept changing since they first shipped, and newer processors take some of the work into the hardware. The measured numbers above are one pair of servers in 2021 and one vendor's measurements of whole workloads. Measure your own robot's computer with the program above, and trust that number over any in this chapter.
Chapter 9
Carry the checklist, the numbers and their sources to your own robot's processor.
A processor is an assembly line that guesses. It ticks as fast as its slowest stage allows, keeps five or more instructions moving at once, hands answers across instead of waiting for them, and works ahead down every fork on a guess. When the guess is right the work is free; when it is wrong, about 20 ticks go in the bin on a big core, which is how the same loop ran 11.8 seconds on random numbers and 2.4 on sorted ones.
A big core also runs later work early, but never ahead of a chain; stamps many numbers at once, but only speeds the part it stamps; shares a core between two threads, but never doubles it. And it keeps a ladder of privilege where the kernel is the second rung of four, where the Secure world can refuse the kernel outright, and where every crossing between rungs has a price you can measure and a share of your time you can predict.
perf stat -e cycles,instructions ./your_loop (Chapters 0 and 3).objdump -d (Chapter 2).csel, cmov) or the branchless trick; sort only if sorting costs less than the misses (Chapter 3).core_cpus_list before placing a thread with a deadline; pin with taskset -c; siblings share one core, worth up to 30% more work, never double (Chapter 6).(esr >> 26) & 0x3F; a 0x25 or an SError on a device access can mean the peripheral was left Secure (Chapter 7): check that device's setting in the boot firmware's platform setup before blaming the driver.syscall(SYS_getpid) in a loop, predict the whole with 1 + f × (r − 1), and cut crossings before you consider mitigations=off (Chapter 8).| what | value |
|---|---|
| The sorted-array loop, the question's computer | 11.54 s random, 1.93 s sorted: 5.98 times |
| The same loop, 3.5 GHz desktop processor (C++) | 11.777 s random, 2.352 s sorted: 5.0 times |
| The same, rewritten without the if | 2.564 s random, 2.587 s sorted |
| The same loop in Java | 10.93 s and 5.64 s: 1.94 times |
| Ticks per number (3.5 GHz) | 12.58 random, 2.51 sorted, 2.74 and 2.76 without the if |
| Ticks per wrong guess, worked back | 20.1 (1,638,400,000 wrong guesses, 5.75 ns each) |
| Wrong-guess cost, Intel big cores | about 15 (Core 2), at least 17 (Nehalem), 15 to 20 (Haswell to the later Lakes), above 20 at times (Alder Lake) |
| Wrong-guess cost, AMD | about 18 (Zen 1 to 3), 15 to 18 (Zen 4), 15 to 25 (Zen 5) |
| Wrong-guess cost, AmpereOne | 10 |
| Five stages | fetch, decode, execute, memory, write back |
| RV32I | 40 instructions (38 in the simplest implementation), 32 bits each |
| Load-use wait with forwarding | 1 tick |
| Taken branch on the classic line | 3 ticks lost (decided in memory) |
| Our loop, teaching line | 12 ticks a big number without forwarding, 7 with, 6 moved up |
| Cortex-M line lengths | M0 3, M0+ 2, M3 3, M4 3, M7 6, M85 7 (9 to 10 for arithmetic on fractions and on many numbers at once) |
| Guessers on a loop of 8 | 75%, 87.5%, 100% (one bit, two bits, history) |
| Cortex-X925 front end and tables | 10 instructions a tick; about 2,048 jumps fast, up to 16,384 slower; return stack 29 |
| Memory ladder, Cortex-X925 in GB10 (4.0 GHz) | L1 4 ticks, L2 12, L3 about 56 (14 ns), system cache 42 to 47 ns, main memory 113 ns |
| A million-node chain in main memory | 113 ms |
| Neon, SVE | Neon 128 bits; SVE 128 to 2048 in steps of 128 (16 sizes) |
| Cache line | 64 bytes |
| Straddling load cost | about 12 ticks (Core 2), hardly any (Nehalem), 16 (first Atom) |
| Amdahl | 90% at 4 times: 3.08; ceiling 10 |
| SMT gain | up to 30% (server benchmarks); 16 to 28% (three server workloads); under 5% extra area |
| Jetson AGX Thor | 14 Neoverse-V3AE cores; 128 GB LPDDR5X at 273 GB/s |
| NVIDIA GB10 | 10 Cortex-X925 + 10 Cortex-A725 |
| Exception classes | 0x15 SVC, 0x16 HVC, 0x17 SMC, 0x18 a forbidden register access, 0x24 and 0x25 data aborts, 0x2F SError |
| TZC-400 | up to 9 regions |
| RVA23 | announced ratified 21 October 2024; ratified document 21 January 2025; V mandatory in RVA23U64, H in RVA23S64 |
| Armv9.7-A (2025) | SVE and SME instructions for MXFP6 |
| A raw system call, one Xeon Gold 6256 pair | 76 ns mitigations off, 233 ns on (3.07 times); 76 to 620 ns across 15 machines |
| Whole workloads with mitigations | 8 to 19% (4 to 8% with retpoline), crossing-heavy |
| Share of time crossing that gives 8 to 19% | 3.9 to 9.2% |
| Code size of Chapter 0's function (gcc 14.2, -O2 -fno-tree-vectorize) | RV64GC 38 bytes, RV64G 56, A64 56, x86-64 46 |
The first is Chapter 0's experiment, whole. It fills a list with 32,768 numbers from 0 to 255, times the loop, sorts the list, and times it again:
c/* sorted_vs_random.c: the 2012 loop, timed with and without sorting first */ #define _POSIX_C_SOURCE 199309L #include <stdio.h> #include <stdlib.h> #include <time.h> static int cmp(const void *a, const void *b) { return *(const int *)a - *(const int *)b; } static double run(const int *data, unsigned n) { struct timespec t0, t1; long long sum = 0; clock_gettime(CLOCK_MONOTONIC, &t0); /* read the whole list 100,000 times */ for (unsigned i = 0; i < 100000; ++i) for (unsigned c = 0; c < n; ++c) if (data[c] >= 128) /* the fork */ sum += data[c]; clock_gettime(CLOCK_MONOTONIC, &t1); /* print it so the compiler keeps the loop */ printf(" sum %lld\n", sum); return (t1.tv_sec - t0.tv_sec) + (t1.tv_nsec - t0.tv_nsec) / 1e9; } int main(void) { const unsigned n = 32768; /* 32,768 numbers */ int *data = malloc(n * sizeof *data); /* 0 to 255, like pixel brightnesses */ for (unsigned c = 0; c < n; ++c) data[c] = rand() % 256; double t_random = run(data, n); /* sort BEFORE the timer starts */ qsort(data, n, sizeof *data, cmp); double t_sorted = run(data, n); printf("random order %.2f s, sorted %.2f s, ratio %.2f\n", t_random, t_sorted, t_random / t_sorted); free(data); return 0; }
Build it with cc -O2 sorted_vs_random.c -o svr, run it, then look before you believe the ratio: objdump -d the binary and find the loop. If there is no conditional jump around the add, your compiler removed the fork (Chapter 2), and both orders will take about the same time. gcc 14.2 at -O2 -fno-tree-vectorize kept the fork on RISC-V and removed it on Arm and x86-64; at -O3, Clang and gcc from version 5 vectorize the loop and there is no fork at all. Then run perf stat -e cycles,instructions,branches,branch-misses on it and watch branch-misses rise in random order.
The second reads the output of perf stat and says where the ticks went, the way Chapter 3 read perf's own example by hand:
python# why_slow.py: read `perf stat -e cycles,instructions, # branches,branch-misses` output, say where the ticks went import re, sys def counts(text): """Pull the four counts out of perf stat's text output (commas and the :u tag are ignored).""" got = {} for line in text.splitlines(): m = re.match(r"\s*([\d,]+)\s+([a-z-]+)(?::u)?\b", line) if m: got[m.group(2)] = int(m.group(1).replace(",", "")) return got def report(c, penalty=20): # instructions finished per tick ipc = c["instructions"] / c["cycles"] # share of forks guessed wrong miss = c["branch-misses"] / c["branches"] # share of all ticks spent recovering lost = c["branch-misses"] * penalty / c["cycles"] print(f"instructions per tick (IPC): {ipc:.2f} " f"ticks per instruction (CPI): {1/ipc:.2f}") print(f"forks guessed wrong: {100*miss:.2f}% " f"per 1,000 instructions: " f"{1000*c['branch-misses']/c['instructions']:.2f}") print(f"at about {penalty} ticks per wrong guess: " f"{100*lost:.1f}% of all ticks were spent recovering") print(f"remove every wrong guess and the program could " f"run at most {1/(1-lost):.2f} times faster") if __name__ == "__main__": penalty = int(sys.argv[1]) if len(sys.argv) > 1 else 20 report(counts(sys.stdin.read()), penalty=penalty)
Its output on the perf manual's example from Chapter 3, with python3 why_slow.py < example.txt:
outputinstructions per tick (IPC): 1.36 ticks per instruction (CPI): 0.73
forks guessed wrong: 2.98% per 1,000 instructions: 6.64
at about 20 ticks per wrong guess: 18.1% of all ticks were spent
recovering
remove every wrong guess and the program could run at most 1.22
times fasterPass the penalty for your own chip from Chapter 3's table: python3 why_slow.py 15 < out.txt gives 13.6% and 1.16 times on the same example.
| source | what it gave this lesson, and its setup |
|---|---|
| "Why is processing a sorted array faster than processing an unsorted array?", Stack Overflow question 11227809 (27 June 2012), and its accepted answer | The loop, the 32,768 numbers from rand() % 256, 100,000 passes; 11.54 and 1.93 s on the asker's computer; 11.777, 2.352, 2.564 and 2.587 s on a Core i7 920 at 3.5 GHz with Visual Studio 2010 (x64 Release); the Java runs (NetBeans 7.1.1, JDK 7); the branchless trick; the compilers that remove the fork (GCC 4.6.1 at -O3 by if-conversion; Clang and GCC 5 and later at -O3 by vectorizing). |
| UC Berkeley CS 61C course notes (the course teaches from Patterson and Hennessy's Computer Organization and Design, RISC-V edition) | The five stages, the forwarding paths, the load-use stall, predict-not-taken with the decision in the memory stage. |
| David Harris, E85 lecture 11 (Harvey Mudd) | The setup and hold rules. |
| Arm Cortex-M0, M0+, M3, M4 and M7 user guides, datasheets and technical reference manuals; the Cortex-M85 manual r0p2; Joseph Yiu, "Cortex-M for Beginners" (2016) | The Cortex-M line lengths. |
| Agner Fog, The microarchitecture of Intel, AMD, and VIA CPUs, edition of 23 May 2026 | Wrong-guess costs and straddling-load costs. |
| Chips and Cheese: "AmpereOne at Hot Chips 2024" (29 August 2024); "Arm's Cortex X925: Reaching Desktop Performance" (3 March 2026, a Dell Pro Max with NVIDIA GB10); "Inside NVIDIA GB10's Memory Subsystem" (31 December 2025) | AmpereOne's and Zen 4's wrong-guess costs; the Cortex-X925's front end, jump tables and return stack; the GB10 memory ladder. |
| Arm Cortex-X925 Core technical reference manual r0p2, and Arm's newsroom post of 23 September 2024 | The X925's caches and 64-byte lines. |
| The Linux kernel, v6.12 | perf-stat documentation and its source (stat-shadow.c, parse-events.c), arm64 esr.h and traps.c, kernel-parameters.txt, the sysfs CPU topology description, sched-energy.rst and sched-capacity.rst, arm64 sigcontext.h. |
| The RISC-V unprivileged and privileged manuals and the RVA23 profile (their sources on GitHub), and RISC-V International's announcement of 21 October 2024 | RV32I's 40 instructions, the three privilege levels, the hypervisor extension, RVA23's mandatory V and H. |
| Arm "Learn the architecture" guides: AArch64 Exception Model v1.3, TrustZone for AArch64 v1.1, Introducing SVE2 v1.0 | The exception levels and the three calls, the two worlds, peripherals and the TZC-400, Neon and SVE. |
| Trusted Firmware-A's firmware design and source; the OP-TEE documentation | BL31 at EL3, OP-TEE at Secure EL1, the TZC-400 set to raise an error, the top 16 MB kept Secure on Arm's development platforms. |
| Marr and others, "Hyper-Threading Technology Architecture and Microarchitecture", Intel Technology Journal 6(1), February 2002 | What SMT is, its gain and its area cost. |
| NVIDIA's Jetson AGX Thor product page; Arm, "Arm A-Profile Architecture Developments 2025" (2 October 2025) | Thor's cores and memory; Armv9.7-A and MXFP6. |
| Scott McFarling, "Combining Branch Predictors", WRL Technical Note TN-36, June 1993; André Seznec and Pierre Michaud, "A case for (partially) TAgged GEometric history length branch prediction", Journal of Instruction-Level Parallelism 8, February 2006 | gshare; TAGE. |
| Georg Sauthoff, "On the costs of syscalls" (30 August 2021); Red Hat, "Speculative Execution Exploit Performance Impacts" | The Xeon Gold 6256 pair and the 15 hosts; the whole-workload slowdowns before and after retpoline. |
The binutils objdump and util-linux taskset manuals; Compiler Explorer, gcc 14.2 builds for RV64GC, A64 and x86-64, run 2026-09-25 | The disassembler and the pinning command; the three hot loops and the code sizes. |
perf stat).Go back to the numbers at the top of the page: switch between random and sorted, then take the if out. Every part of it now has a name. The purple guess is Chapter 3's predictor, and the red flash is its penalty, about 20 ticks of work in flight thrown away. The 2.5 ticks each number costs when the guess is right is Chapter 2's assembly line with Chapter 4's running ahead. The ticks themselves are Chapter 1's clock. Then open Present and explain the five-times gap to someone, or open Teach and draw where the ten seconds went.