Interactive course · about 9 hours

Performance Engineering

Two programs. Same numbers going in, same answer coming out, and one of them takes about a hundred times longer than the other. Stretch that gap to the size of a real job and the quick one finishes in a second while the slow one is still going two minutes later. Neither of them is doing anything foolish and the slow one is the one almost everybody writes first. This course is about the gap between them. It starts with the rule that everything else depends on: measure before you touch anything.

Every figure here is measured

There is a model of a processor behind this course. It has three levels of cache with real eviction, a branch predictor that is a real table of counters, a limit on how fast data can arrive, and several cores that keep each other honest. When a widget says a program took 40,000 cycles, that program was built and run on that model. Not one number here was copied out of a table. A course about measuring that quoted its own figures would be teaching the opposite of its subject.

What you need first

Do Bits, Pointers and Memory before this one, or at least be comfortable with the idea that memory is a long row of numbered bytes and that a value has an address. No hardware background is needed, and no maths beyond multiplying and percentages. Every other word gets introduced where it is first used, including the ones that sound as though everybody already knows them.

The steps

Step 1

Measure first, do not guess

A program that is too slow usually has one part making it slow, and that part is hardly ever the part you would bet on. People rewrite the wrong loop for a week and gain nothing. The way out is dull and it works: measure each part on its own, then fix the one that is actually costing you.

Everything here is counted in cycles, the machine's own unit of time. Faster means fewer cycles for the same answer.

What is a cycle, and what does 3.2 GHz mean?

A processor has a clock inside it, ticking at a fixed rate. One tick is one cycle, and it is the smallest slice of time the machine can tell apart. Work gets counted in cycles because that is the unit the hardware itself counts in, and because it does not change when somebody runs the same program on a slightly faster chip.

The machine used in this course ticks 3,200,000,000 times a second, which is written 3.2 GHz. So one cycle lasts about a third of a nanosecond, and a nanosecond is a thousand-millionth of a second. The ladder in Step 2 will convert cycles into nanoseconds for you, and into something easier to feel.

What are the two programs in the race working out?

They multiply two grids of numbers, which is the sort of job that sits underneath weather forecasts, graphics and machine learning. To multiply two grids you take a row from the first and a column from the second, multiply them together pair by pair, and add up the results. That single number goes into one cell of the answer grid, and then you do it again for the next cell.

The grids here are 64 across and 64 down. That makes 4,096 cells in the answer, each one needing 64 multiplications, so 262,144 multiplications in total. Both programs do all of them and both reach the same total, which the widget checks and reports.

Lab 1 · Two programs, one answer
Try this firstPick one of the five guesses, anywhere from about the same up to 1000 times, then press Race them. Two bars fill up while the programs run and the numbers beside them count cycles. Expect one bar to reach the end while the other is still near the start.
Look at the last badge. It says whether the two programs got the same answer, and they did. Nothing was dropped and no corners were cut: the quick version does exactly the same 262,144 multiplications, in a different order and with the numbers arranged differently. Everything between here and Step 11 is about where that difference comes from, and Step 11 hands you the switches so you can rebuild the quick version yourself.

So the gap is real. Now the harder question: in your own program, which part is the slow one? You cannot tell by reading, and you cannot tell by how complicated the code looks. You have to time each part separately while the program runs and the name for doing that is profiling.

I have not met the words byte, array and index

A bit is one switch, on or off, written 0 or 1. Eight bits side by side make a byte, and memory is one enormous row of bytes, each with its own number, called its address. The numbers in this course take 8 bytes each, which is the usual size for a number that can have a decimal point in it. In the code on this page that kind of number is written double. Bytes get counted in thousands and millions often enough to have short names: a KB is about a thousand bytes and an MB about a million.

An array is a run of values of the same kind, stored one after another with no gaps. Because there are no gaps, finding one is arithmetic rather than searching: item 0 sits at the start, item 1 sits 8 bytes further along, item 2 sits 16 bytes further along. That counting number is called an index, and it is why arrays are the shape almost all fast code is built out of.

Lab 2 · Where the time actually went
Try this firstPress Profile it. Four bars appear, one for each part of a program: readScores, buildIndex, scoreAll and writeReport, with the share of the total beside each. Then press halve scoreAll in the row of buttons underneath and watch the total, and press halve writeReport to see what the same effort buys on the wrong part.
Notice which part it is. Nothing in the four descriptions says that scoreAll will dominate. It walks a grid the wrong way round, which you will meet properly in Step 4, and from the outside it looks no worse than the others. Notice also that halving writeReport barely moves the total, however clever the halving was. Use Put it all back to clear every choice and start again.
A program takes 100 seconds. You spend a week making one part of it four times faster, and the whole program now takes 97 seconds. What must have been true of that part before you started?
It was about 4 seconds. Four times faster turns 4 seconds into 1, which saves 3, and that is what you got. Had it been 40 seconds it would have dropped to 10 and saved 30, which you would have noticed. Nothing was measured wrongly: a part that is 4 per cent of the time can never give back more than 4 per cent, no matter how good you are. This is why the profile comes before the week.
Step 2

The wait for memory, to scale

A processor can add two numbers that are already inside it in about one cycle. Fetching a number that is not inside it takes longer. How much longer is the most surprising figure in computing, and this step measures it rather than telling you.

Numbers live in a stack of places, each one bigger and slower than the one above. A register is a named slot inside the core itself, and there are only a few dozen of them. A cache is a small fast copy of part of memory, and there are three here: L1, L2 and L3, getting bigger and slower in that order. Under those sits main memory, and under that a disk.

Why is a bigger store always a slower one?

Two reasons, and neither of them is laziness on the part of the designers. The first is distance. Electricity travels quickly but not instantly, and in one cycle a signal crosses only a few centimetres. A store big enough to hold a whole program cannot fit close enough to the core for its answer to arrive in the same cycle it was asked for.

The second is that finding something takes longer in a bigger pile. A store with more slots needs more machinery to work out which slot you meant, and that machinery is itself circuits that take time. So a designer who wants fast has to want small and the way out of that trap is to have several sizes at once and keep the things you are using now in the small one.

How do you time one single fetch?

Carefully, because a modern core will happily have a dozen fetches in the air at the same time. If you ask for a thousand numbers whose addresses you already know, the waits overlap and you measure the overlap rather than the wait.

The trick used here is to follow a trail. Each address is stored at the address before it, so the machine cannot possibly ask for the next one until the last one has arrived. Nothing can overlap, so the total time divided by the number of steps is one honest wait. An address kept in memory as a value, so that it can be read and then followed, is called a pointer, and this trick has a name to match: pointer chasing. It is how these numbers get measured on real hardware too.

Lab 3 · The ladder, to scale
Try this firstThe six waits are already measured when the step opens, so read them, then press one cycle = one second. The same six waits come back as human time. Expect a register to become one second and the disk to become several days.
The bars are shorter than the truth. A bar drawn to scale would make everything above main memory invisible, so bar length uses a logarithmic scale and the measured value is printed beside it. Read the numbers as well as the bars. The large latency ratio between L1 and main memory is commonly called the memory wall.

Those levels are not printed on the outside of the machine, and a program is never told about them. Which turns out not to matter, because a program can find them for itself by timing.

What is a nanosecond, and why turn cycles into seconds?

A nanosecond is a thousand-millionth of a second. Nobody has any feel for it, which is the problem with these numbers: 4 cycles and 200 cycles both round to nothing at all in human terms, so the difference between them does not land.

Stretching the clock fixes that. Pretend one cycle takes one second instead of a third of a nanosecond, and keep every ratio exactly as it was. Now an L1 hit is four seconds, a walk to main memory is a few minutes, and going to disk is most of a working week. Those are the same ratios a program lives with, and now you can feel them.

Lab 4 · Find the caches by timing
Try this firstGuess how big the L1 cache is by pressing 4 KB, 32 KB, 256 KB or 8 MB, then press Sweep the sizes. The machine chases a trail around regions from 1 KB up to 16 MB and times each one. Expect a line that runs flat, jumps, runs flat again, and jumps again.
Each flat run is a cache, and each corner is where you fell out of it. While the whole trail fits in L1 every step costs an L1 hit. Once the trail is a little too big, the oldest part has been thrown out by the time you come round to it again, so every step costs an L2 hit instead. Reading the sizes off the corners is a real technique with a real name, a cache sweep, and it works on hardware nobody has documented.
A loop reads one number from a region far too big for any cache, adds it to a total, then reads the next. Roughly how much of the loop's time is the adding?
Almost none. One add against a wait of a couple of hundred cycles means the arithmetic is under one per cent of the time and the loop spends the rest standing still. Counting operations in the source tells you nothing here, which is why the first instinct of counting adds and multiplies leads people so badly astray. For most programs on modern hardware, the question is not how much arithmetic there is but where the numbers are.
Step 3

Caches, and the 64 bytes that arrive together

When a program asks for one byte that is not in the cache, the cache does not fetch one byte. It fetches the whole 64-byte block that byte sits inside. That block is called a cache line, and it is the smallest thing the memory system knows how to move. Nearly every trick in this course is a consequence of that one fact.

A line cannot sit wherever it likes. The cache is cut into sets, and part of a line's address decides which set it belongs to, with no choice about it. Inside a set there are a few slots, called ways, and the line may go in any of them. When every way in its set is taken, one of the lines already there has to be thrown out. Throwing a line out to make room for another is called evicting it. You will see the word used both ways round from here on.

Why fetch 64 bytes when the program asked for one?

Because most of the waiting is in the asking, not in the sending. Getting the first byte out of main memory takes a couple of hundred cycles, and the next 63 bytes arrive almost free behind it. Fetching one byte at a time would pay that couple of hundred cycles sixty-four times over for the same data.

There is a bet inside this. The machine is wagering that a program which wanted byte 1,000 will shortly want 1,001 and 1,008 as well. That is true of nearly all real code, because arrays are read in order and the pieces of a record sit together. When the bet is wrong the machine fetches 64 bytes to use 8 of them, and Steps 4 and 5 are entirely about programs that lose this bet over and over.

Lab 5 · Fill a cache by hand
Try this firstPress the quick button marked 0, then the one marked 8. Expect the first to be a miss costing 200 cycles and the second to be a hit costing 4, because both bytes rode in on the same 64-byte line. The grid above shows 8 sets with 2 ways each, and the log below lists what each request cost.
Now make it throw something out. Press Empty the cache, then 0, then 512, then 1024. All three land in set 0, because 0, 512 and 1024 are 8 lines apart and there are 8 sets, so the third one evicts the first even though fourteen of the sixteen slots are standing empty. Type any address you like into the box and press Fetch it to test a guess about which set it lands in.
Why not let a line sit anywhere in the cache?

Because then finding it would mean looking everywhere. A cache has to answer "have you got line 4,271" in a cycle or two, and it does that by looking in one place. Taking part of the address as the set number means there is exactly one set to search, and searching a set of 8 means comparing 8 labels at once, which is a piece of circuitry you can afford.

The price is what you just saw. Addresses a certain distance apart share a set, so they can knock each other out while the rest of the cache sits idle. This is called a conflict miss, and it is why a program can slow down for no visible reason when somebody changes an array size from 1,000 to 1,024.

Which line gets thrown out, and who decides?

The hardware decides, and its rule here is least recently used: of the lines in that set, the one nobody has touched for longest goes. The reasoning is that a line you have not wanted for a while is the one you are least likely to want next.

You cannot override it and there is no instruction to pin a line in place, so the only control you have over what stays in the cache is the order your program asks for things. That sounds like almost no control at all. It turns out to be enough for the whole of Step 4 and most of Step 11.

Lab 6 · Make a 32 KB cache behave like a tiny one
Try this firstLeave the gap where it is and drag the ways slider down to 1. The same 256 numbers are read eight times over in every setting. Expect the misses bar to jump from about one read in eight to nearly every read, and the busiest-set bar to say how many lines wanted the same set and how many were allowed to stay. Then drag ways back up to 4 and watch the misses collapse again.
Nothing about the amount of data changed. It is 256 numbers, 2 KB of them, in every single setting. What the gap slider changes is how many cache lines those 2 KB are smeared across, and which sets those lines land on. At 8 bytes apart they need 32 lines. At 64 apart they need 256, and at 4,096 apart they still need 256 but every one of them wants the same single set, so a 32 KB cache ends up behaving like a 512-byte one. Now try the gap at 512 and 4,096 and drag ways across its whole range: it stops helping, because no cache has 32 ways, let alone 256. Widening the sets rescues a little crowding. Only changing the gap rescues a lot.
An array holds 1,000,000 numbers of 8 bytes each. One loop reads every number. Another reads every eighth number, so one eighth as many. How much less data does the second loop pull in from memory?
Both loops pull in the same amount. Eight numbers of 8 bytes fill one 64-byte line exactly, so reading every eighth number touches every line once and fetches all of it, using 8 bytes of each 64 and throwing away the rest. Doing one eighth of the reads bought nothing at all, because the unit that moves is the line, not the number. The widget above counts it for you: at a gap of 8 bytes its 256 numbers sit on 32 lines, and at a gap of 64 the same 256 numbers sit on 256 lines. Eight times the cache, for exactly the same data.
Step 4

Along the rows, or down the columns

A grid of numbers is not stored as a grid, because memory has no second direction. It is flattened: the whole first row, then the whole second row, and so on to the end. That arrangement is called row-major order, and it is what C, C++, Java and most other languages use.

So a loop that walks along a row walks straight through memory, 8 bytes at a time. A loop that walks down a column jumps over an entire row on every step. Same numbers, same additions, same answer, and a completely different set of addresses.

How does v[i][j] turn into an address?

By arithmetic, every time, and the arithmetic is worth knowing because it explains everything in this step. For a grid N across, the cell at row i and column j sits at item number i × N + j. Multiply that by 8 bytes and add the address the grid starts at. You have the address.

Now look at what happens when you add one to each index. Adding one to j moves the address forward by 8 bytes, the very next number. Adding one to i moves it forward by N × 8 bytes, which for a grid 384 across is 3,072 bytes, or 48 whole cache lines. The index on the inside of the loop is the one that changes most often, so the index on the inside decides whether your program strolls through memory or leaps about in it.

Lab 7 · The same grid, two directions
Try this firstPress Race them. Two bars, along the rows against down the columns, over the same 384 by 384 grid. Expect the row walk to finish while the column walk is still going, then read the four bars underneath for the miss rate and the number of lines each version pulled in.
Count the lines, not the reads. Both versions read 147,456 numbers. The row walk gets eight useful numbers out of every line it fetches, so seven reads in eight cost nothing, and the machine starts fetching further lines ahead of being asked. The column walk gets one useful number per line and throws away the other 56 bytes, then comes back for that same line later when it has long since been evicted.
What is a prefetcher, and why does walking in order get help?

The machine watches which lines it has been fetching. When two misses in a row are for lines that sit next to each other, it guesses that a walk is going on and starts fetching the next few lines before anybody asks for them. That piece of hardware is called a prefetcher, and when it guesses right the wait for memory disappears completely, because the line arrives before it is wanted.

It can only guess from a pattern it can see. A straight walk through memory is the easiest pattern there is, so orderly code gets this help for free. A walk that jumps 3,072 bytes each time gets none of it. Two loops with the same number of reads can therefore differ by more than the miss counts alone would suggest, because one of them is being helped and the other is not.

Will the compiler not fix this for me?

A compiler is the program that turns the code a person writes into the instructions a machine runs, and a good one rearranges a great deal on the way through. Some compilers really will swap two loops around for exactly the reason in this step and the option is usually called loop interchange.

They only do it when they can prove the swap cannot change the answer, and that proof is often out of reach. If the loop writes through a pointer that might, for all the compiler knows, point back into the grid it is reading, then no swap is safe. So the compiler leaves it alone and says nothing. Reading in the right order is your job. It is one of the few pieces of tuning that never stops paying.

Lab 8 · Find the fast loop order
Try this firstPress each of the six loop orders in turn, i j k through to k j i. Every press runs the walk, adds a bar, and rewrites the code above to match. When you think you have found the quick one, press Check my order. There is a Show me one that works button if you get stuck, and using it costs you nothing.
All six do identical work. Every order adds up the same 64,000 numbers and reaches the same total, and the only difference is which index is on the inside. Adding one to the three indexes moves 8 bytes, 320 bytes and 12,800 bytes. Put the 8-byte one on the inside and the program walks. Put the 12,800-byte one there and it leaps. Six orders, one rule.
Two programs add up the same 400 by 400 grid. One goes along the rows, one goes down the columns. They do the same 160,000 reads and the same 160,000 additions. Why is one of them several times slower?
It is the lines, not the reads. Both versions ask for the same numbers; the difference is how many 64-byte lines have to be dragged in to supply them, and the column walk needs roughly eight times as many. The address arithmetic is one multiply and one add either way, and the hardware has no notion of up or down at all. It only sees a list of addresses, and it rewards a list that goes forwards in small steps.
Step 5

Laying out data for the loop that reads it

Real programs rarely hold bare numbers. They hold records: several values kept together under one name, the way one player in a game has a name, a score, a position and a handful of flags. Each of those pieces is called a field.

There are two ways to keep thirty thousand players. One array of records, each record whole and sitting next to the last, which is how nearly everybody writes it. Or one array per field: every name together in one array, every score together in another. The second looks stranger and reads a little worse, and for a loop that wants one field it moves a fraction of the data.

I have seen this called a struct

Same thing. C calls a record a struct, short for structure; other languages call it a class, a record or an object. The idea is the same everywhere: a fixed set of named fields, laid out one after another in memory in the order they were written down. So the whole bundle has one address, and each field sits at a known distance from it.

The two layouts in this step have names that get used in conversation. An array of records is an array of structs, and the other one is a struct of arrays. If you hear somebody say they moved a hot loop from AoS to SoA, this step is what they did.

What is bandwidth, and how is it different from a wait?

A wait is how long one thing takes to arrive: ask for a line, count 200 cycles, get it. Bandwidth is how much can arrive per second when things are queued up back to back. They are different quantities and they run out in different ways, which is why a program can be fixed by one change and untouched by another.

The picture that helps is a road. The wait is how long the journey takes; the bandwidth is how many lorries per hour the road can carry. There is one such road between the caches and main memory, shared by everything, and its name is the bus. When this course says bytes crossed the bus, it means they came all the way from memory rather than out of a cache. Sending fewer lorries is the only fix for a road at capacity, and that is what this step does: it does not make the journey shorter, it stops shipping 56 bytes of nobody's business in every load. Step 9 has a job that runs into the limit of that road head on.

Lab 9 · One array of records against one array per field
Try this firstPress Race them. Thirty-two thousand players, 64 bytes each, and both versions add up every score. Expect the array-per-field version to finish first, then read the badges for how many bytes each version pulled in against how many bytes of scores it actually wanted.
The wanted figure is the one to stare at. The scores add up to 262,144 bytes in both versions, because there are the same thirty-two thousand of them. One version moves roughly that much across the bus and the other moves eight times more, and the extra is names and positions that this loop never once looks at. No arithmetic was removed to get the speedup. Only rubbish.
Lab 10 · How big does a record have to be to hurt
Try this firstThe slider starts at 64 bytes per record. Drag it down to 8, then all the way up to 256. Expect the two bars to sit almost level at 8 bytes and to separate as the record grows. Watch the third bar, the useful fraction of each line, and find where it stops falling.
Two different things stop at two different sizes. The waste stops growing at 64 bytes: once a record is as big as a line, every read already costs a whole line to collect one 8-byte field, and bigger records cannot make that fraction worse. It stays at one useful byte in eight from there on. The time, though, gets worse once more between 64 and 128, and for a different reason. At 64 bytes the records sit on neighbouring lines, so the prefetcher from Step 4 can see the walk and fetch ahead; at 128 the walk skips a line every time and the prefetcher gives up. Past 128 nothing changes at all. So the trouble arrives in full almost as soon as your records outgrow a line, which for anything with a few fields in it is immediately.
So should everything be one array per field?

No, and the deciding question is what the loops actually read. One array per field wins when a loop wants one field of everything, which is the shape of most sums, filters and searches. It loses when a loop wants every field of one record, because then the fields it needs are scattered across a dozen different arrays and it pays for a line in each of them.

So the layout follows the loop, not the other way round. Real code often keeps the fields that get read together in one record and pulls out the one or two fields that get swept over on their own. Games do this constantly: position and velocity live in their own arrays because every frame walks all of them, while the name and the artwork stay in a record because nothing sweeps those.

You have 100,000 monsters, each a record of 128 bytes, and a loop that reads only the 8-byte health field of each one. Of the bytes that cross from memory into the cache, how many are health?
8 out of every 64. The line is what moves, so the loop pays 64 bytes to be handed 8 that it wanted, which is about 12 per cent useful. The record being 128 bytes rather than 64 changes nothing here, because the loop only ever touches the half of each record that holds health and never asks for the other line at all. That is why the widget's third bar, the useful fraction of each line, stops falling at 64 bytes and reads the same at 128 and at 256.
Step 6

Guessing which way an if will go

Every if in a program turns into a branch: an instruction that either carries straight on or jumps somewhere else. By the time the machine knows which, it has already started work on a hundred or so instructions after it, because standing still and waiting would waste most of every cycle. So it guesses which way the if will go and gets on with that.

A right guess costs nothing at all. A wrong guess means throwing away everything started since, and on this machine that is 15 cycles of work in the bin. The hardware that does the guessing keeps a small counter for each branch, and the counter remembers which way that branch went last time.

Why can the machine not simply wait and see?

Because an instruction is not done in one step. It gets fetched, decoded, given its values, worked out, and its answer written down, and each of those happens in a different part of the core. Rather than leave four of the five parts idle, the machine keeps all of them busy on different instructions at once. Step 8 goes into that properly.

The consequence is that the machine is always partway through instructions it has not finished. When one of them is a branch, waiting for it would mean emptying every stage and starting again, which costs the same as guessing wrong. Guessing right costs nothing. So a machine that guesses right most of the time beats a machine that never guesses, and every processor you have used guesses.

Lab 11 · Sorting first, then doing the same work
Try this firstPress Race them. The same loop over the same 24,000 numbers, adding up only the ones that are 128 or more, with one list shuffled and one sorted. Expect the sorted list to finish first, then read the two wrong-guess counts in the badges. After that, press Now try it with no branch at all.
Sorting removed no additions whatsoever. Both runs add up exactly the same numbers and reach the same total. What sorting changed is that the answer to "is this one 128 or more" stopped flipping about. On a sorted list it is no for a long stretch and then yes for a long stretch, so the counter settles and the guess is right nearly every time. The third version does the arithmetic with no if in it, and beats the shuffled list while losing to the sorted one.
How can a two-bit counter be better than remembering last time?

A counter that only remembers last time gets caught out twice by one odd result: once on the odd result itself, and again on the next normal one, because it has changed its mind. A counter that runs from 0 to 3 does not change its mind that easily. Zero and one mean guess no, two and three mean guess yes, taken pushes it up by one, not taken pushes it down by one, and it sticks at both ends.

So a branch that goes the same way nine times out of ten sits at 3, gets pushed to 2 by the odd result, still guesses yes, and is pushed straight back to 3. One wrong guess instead of two. Real predictors add a record of the last several branches on top of this, so the same if can be predicted differently depending on how the program got there, which is how they reach the accuracies they do.

Sorting takes time as well, so how can it ever be a win?

Often it is not, and that is worth saying plainly. Sorting 24,000 numbers costs far more than the wrong guesses the sorting saves, so as a one-off trick it loses.

It pays when the data was going to be sorted anyway. It pays when the loop runs many times over the same list. The lesson to carry away is not "call sort before your loops". It is that unpredictable data has a price, that the price is measurable, and that arranging work so the machine can see a pattern is a real option alongside doing less work.

Lab 12 · Drive the predictor yourself
Try this firstPress always taken, then press alternating. Each box holds the counter as it stood before that pass, with ok or no underneath. Expect every box to say ok for always taken, and every box to say no for alternating. Then type your own run of T and N letters in the box and press Feed it in.
Alternating is the worst case, and it is worth understanding why. The counter is always one step behind a pattern that changes every time, so it is wrong on every single pass, which is worse than a coin toss. Try nine then one for the opposite case, and then find out how few letters it takes to score under half: type TTNN and it manages 25 per cent, because a counter that remembers nothing but its own number cannot tell the second T from the first. Runs of three come out at 36 per cent, and it takes runs of four before this counter is right more often than not. Long runs are what it is good at, and long runs are what most real loops give it.
A loop tests if (x[i] >= 128) on shuffled data and runs slowly. You change nothing except sorting the array first, and the loop speeds up while doing the same additions. What did sorting change?
It made the test predictable. The array is exactly as long as it was and the reads are exactly as spread out, so nothing changed about the memory side at all. What changed is that a sorted list gives one long stretch of no and then one long stretch of yes, and a run is the easiest thing in the world for a counter to predict. The additions are identical: the total at the end proves it.
Step 7

Eight numbers per instruction

An instruction is one command to the machine: add these two, load that from memory, jump there. Every add so far in this course has added one pair of numbers, which is what an instruction called add normally does.

Cores also have registers wide enough to hold eight of these numbers side by side, and add instructions that add all eight pairs in a single go. A register like that is called a vector register, the circuitry behind it the vector unit, and each of the eight positions in it is a lane. The idea is called single instruction, multiple data, which everyone shortens to SIMD, and it is how one add instruction gets eight adds done.

Where do these instructions come from if I never write any?

From the compiler, mostly. Give it a plain loop that walks an array in order and adds up the numbers, and it will quietly turn the body into wide instructions and run the loop an eighth as many times. The name for that is auto-vectorising. It is one of the largest favours a compiler does you without being asked.

It gives up easily, though. If the loop reads in a pattern it cannot widen, or if the additions depend on each other, or if two pointers might overlap, it writes the plain version and says nothing unless you ask for a report. When you need the wide instructions and the compiler will not produce them, you can write them by name, and those named functions are called intrinsics.

Lab 13 · One lane against eight
Try this firstPress 1, then press 8. The row of boxes shows which of the eight lanes are carrying a number, and the two bars compare the same sum done one number at a time and eight at a time. Expect a good speedup, and expect it to be a little under eight times.
Why it falls short of eight. The add instructions went down by a factor of eight, and you can read both counts in the bar labels. The loads and the loop counting did not get any narrower, and the numbers still have to arrive from memory at the same rate as before. A change that makes one part of the work eight times cheaper leaves the other parts exactly where they were, which is the same arithmetic as the profile in Step 1.
Why eight, and not eight hundred?

Because the register has to be real silicon and the adder behind it has to be eight adders. Doubling the lanes doubles that area and the power it burns, and the benefit stops arriving once the loop is limited by how fast data can be delivered rather than by how fast it can be added. Common widths today hold four or eight 8-byte numbers and the widest in ordinary machines holds eight.

The other reason is that the numbers have to be next to each other in memory to be picked up together. Eight neighbouring numbers are one 64-byte line, which the memory system already moves as a unit, so eight lanes and one line fit together neatly. Wanting sixty-four numbers at once would mean wanting eight lines at once, and that is a different and harder problem.

Why is adding in a different order not always allowed?

Numbers with a decimal point are held to a fixed number of digits, so each addition rounds a little. Add a tiny number to an enormous one and the tiny one can vanish entirely; add up a thousand tiny ones first and they amount to something. So the order of the additions can change the last digits of the answer, and adding eight at a time is a different order.

A compiler will not make that change on its own, because it is not allowed to give you a different answer than the one you asked for. You can tell it that you do not mind, and most projects doing heavy arithmetic do. Whole numbers have no such problem: their addition gives the same answer in any order, so a compiler will widen a loop over whole numbers without asking.

Lab 14 · Three loops, and only one of them widens
Try this firstPress each of the three settings in turn: every number in order, every eighth number, each result feeds the next. Expect the first to show two bars of very different lengths and the other two to show bars the same length, and read the line underneath each time for what stopped the widening.
Two different reasons for the same flat result. Every eighth number fails because the eight values sit 64 bytes apart, spread over 512 bytes between them, so no one 64-byte line holds two of them and they cannot be gathered into one register cheaply. Each result feeds the next fails for a reason that has nothing to do with memory: the additions must happen in that exact order, so there is no set of eight that could be done together. Step 8 is entirely about that second problem.
A loop adds up every eighth number of a large array. You turn on the widest vector instructions the machine has and the loop runs at exactly the same speed. What is the most likely reason?
There is nothing to load. A wide load takes one contiguous run of bytes, and the eight numbers this loop wants are scattered across eight separate lines. Getting them into one register would take eight separate loads plus the work of packing them, which costs more than it saves. Vector units handle decimal numbers perfectly well, and eight lanes is a large win when the data is arranged to suit it, as the widget above shows in its first setting.
Step 8

Waiting for your own last answer

An add on this machine takes 4 cycles to produce its answer. The same machine can also start two brand new adds every single cycle. They describe different properties: latency measures one operation's delay, while throughput measures how often independent operations can begin or finish.

Four cycles is the latency: how long one add takes from starting to having an answer. Two per cycle is the throughput: how many can be under way at once. You can only have several under way at once if they do not need each other's answers. A run of adds where each one needs the answer of the one before it is called a dependency chain. A chain runs at latency speed however much spare capacity sits idle beside it.

How can something take four cycles and still start every cycle?

Think about washing clothes. One load takes two hours to wash, dry and fold. That does not mean you can only finish one load every two hours, because as soon as the first load leaves the machine the second can go in. With three stages going at once you finish a load every forty minutes while each individual load still takes two hours.

An adder is built the same way, in stages, and this arrangement is called a pipeline. A new pair of numbers can enter the first stage while the previous pair is in the second. So the answer takes four cycles to come out, and answers come out every cycle once the pipe is full. The catch is the same as with the washing: it only works if you have another load ready to put in.

Lab 15 · One running total against four
Try this firstPress Race them. Both loops add up the same 8,192 numbers and reach the same total; one keeps a single running total and the other keeps four and adds those together at the end. Expect the four-total version to finish far sooner, then read the cycles per number in the badges.
Read the cycles per number. The single-total loop lands near four cycles per number, which is exactly the latency of one add. That is the tell: a loop running at the latency of its own arithmetic is a loop standing in its own way. With four totals there are four separate chains and none of them waits on any other, so four adds can be in the pipeline at once and the same work takes a quarter of the time.
What is a running total, and how do four of them give one answer?

A running total is one variable that starts at zero and has every number added into it as the loop goes past. It is the ordinary way to add up an array and it is also, without meaning to be, a chain: the total after number 500 cannot exist until the total after number 499 does.

Four running totals means four variables. The first takes numbers 0, 4, 8 and so on, the second takes 1, 5, 9, and so on round the four. Nothing links them, so they proceed side by side, and at the very end you add the four together and get the same answer you would have got from one. Programmers call this unrolling the loop and splitting the accumulator, and it is one of the oldest tricks there is.

The machine reorders instructions anyway, so why can it not fix this?

It reorders a great deal. This core looks at a window of the next 128 operations and starts any of them whose values are ready, in whatever order they become ready, which is how it keeps several things going at once without being told to. It will happily run your loop counter and your next load early.

What it cannot do is start an add before the number being added into exists. That is not a scheduling choice, it is arithmetic: the value simply is not there yet. Reordering can hide a wait behind other work, and here there is no other work, because every add in the loop is in the same chain. The only way out is to write a program with more than one chain in it, which is why this is your job rather than the hardware's.

Lab 16 · How many totals is enough
Try this firstDrag the running totals slider from 1 up to 16, one notch at a time. The chart plots cycles per number at every setting and the bar underneath reports the setting you are on. Expect a steep fall down to about four totals, and then a line that flattens out.
The flat part is a different limit taking over. Up to four totals the loop is held back by waiting, and each extra total removes most of the wait. Past four there is very little wait left to remove, and past eight none: the machine can only start two adds in a cycle, and the loads and the loop counter want their turn in that same cycle. Once the limit is how much the machine can start rather than how long each add takes, handing it more independent work is no longer the answer. Knowing which of those two you are looking at is worth more than any single trick.
An add takes 4 cycles to produce its answer, and the machine can start two adds every cycle. A loop adds a million numbers into a single running total. What is the best it can possibly manage per number?
Four cycles each. The two-per-cycle capacity is useless here, because there is never a second add ready to start: add number two needs the total that add number one is still working out. The loop is limited by latency, and the only way past it is to give the machine adds that do not depend on each other. Four running totals is the usual answer and gets you close to four times the speed.
Step 9

Many cores, and the ceiling above them

A processor holds several cores, and each core is a complete processor: its own registers, its own L1 and L2 cache, its own instructions in flight. Eight cores can be doing eight unrelated things at the same instant, and if a job splits into eight independent pieces they can each take one.

Not every job splits. Some of the work has to be done once, in order, by somebody: reading the input, deciding what to hand out, adding up what everyone else produced. That part takes the same time however many cores you own, and it puts a ceiling on the whole thing that no amount of hardware can lift. The arithmetic of that ceiling is known as Amdahl's law.

Why can some work not be split?

Usually because a later part needs the result of an earlier one, which is the same problem as the dependency chain in Step 8, one size up. You cannot start on the second half of a running total until the first half exists, and you cannot hand out slices of a file until you have read enough of it to know where the slices are.

Sometimes it is because two cores would have to take turns. If they both need to add to the same counter, only one of them can be doing it at any moment, and the others queue. Splitting work therefore requires more than cores. The pieces must be arranged so that they do not need each other. That is a design decision made long before anybody counts cycles.

Where does the ceiling number come from?

From dividing the job into the part that shares and the part that does not. Suppose one twentieth of the work, 5 per cent, cannot be shared. With an unlimited number of cores the shareable 95 per cent shrinks towards nothing, and the 5 per cent stays exactly as it was. So the whole job can never take less than 5 per cent of its original time, and 5 per cent of the time means twenty times faster at the very best.

That is the whole law: one divided by the unshareable fraction is the ceiling. One per cent gives a ceiling of one hundred, ten per cent gives a ceiling of ten, and half gives a ceiling of two. It is worth doing this division before buying anything, because the answer often says that the cores are not the problem.

Lab 17 · Push against the ceiling
Try this firstRead the chart as it stands, then drag the one-core-only slider up to 25 per cent. The solid line is the speedup actually measured at 1, 2, 4, 8, 16 and 32 cores, and the dashed line is what perfect sharing would give. Expect the solid line to flatten out well below the dashed one as you drag.
Drag it all the way down to 0 per cent as well. With nothing stuck on one core the two lines lie almost on top of each other, which is worth seeing, because it shows the model is not simply refusing to scale. Then look at 2 per cent, which sounds like nothing at all, and read the figure at 32 cores. A fraction that would be invisible in a profile decides what your last sixteen cores are worth.
What is the bus, and why does it have a limit?

Below the L2 caches all the cores share one road to memory, and one 64-byte line at a time travels along it. Here that takes 6 cycles per line, so however many cores are asking, lines arrive at a fixed maximum rate. That shared road is called the bus and the rate is the bandwidth from Step 5.

This limit is invisible while one core is running, because one core cannot ask fast enough to fill the road. Add cores and it appears out of nowhere: the arithmetic still divides perfectly, but the data cannot be delivered any faster, so the extra cores stand in a queue. Programs where this happens are called memory-bound, and the fix is never more cores. The fix is Steps 3 to 5, moving less data.

Lab 18 · A job with nothing to share, that still stops
Try this firstDecide what speedup you expect at 16 cores, then press Add cores. Every core adds up its own slice of 120,000 numbers and no core ever waits for another, so Amdahl's law predicts a straight line. Expect the measured line to leave the dashed one long before 16.
Compare the last two badges. One says how many cycles of bus time the lines need, the other says how long the run actually took, and they are close together. That is what being limited by the road looks like: the cores are not waiting for each other, they are waiting for data, and a job whose arithmetic divides perfectly can still refuse to go faster.
A job is 95 per cent shareable, and you run it on 100 cores instead of 1. Roughly how much faster does it get?
About 17 times. On 100 cores the shareable 95 per cent becomes 0.95 per cent of the original time. The 5 per cent that does not share stays 5 per cent. Together that is just under 6 per cent of the original, which is about 17 times faster. The ceiling is one divided by 0.05, so 20, and you are already close to it. Ninety-five per cent sounds like almost all of it, and the 5 per cent still decides what you get.
Step 10

Two cores fighting over one line

Each core has its own L1 cache, so the same line of memory can sit in two caches at once. If one core writes to its copy, the other copy is now wrong, and the hardware will not allow that: a write tells every other cache holding that line to throw its copy away. This machinery is called coherence. It is the reason two cores never disagree about what is in memory.

Coherence works on whole 64-byte lines, not on variables, because a line is the only unit it knows. Four counters 8 bytes apart all live inside one line. No core reads another core's counter and none of them knows the others exist, and it makes no difference at all: every write drags the line away from the other three.

What does a stale copy mean, and why does it matter so much?

Stale means out of date. Core 0 has line 40 in its cache and so does core 1. Core 0 writes a new value into its copy. Core 1's copy now shows the old value, and if core 1 were allowed to keep using it, the two cores would be looking at the same address and seeing different numbers.

That would break everything. Almost all shared-memory programming assumes that a value written by one core is the value another core reads, and the hardware guarantees it by marking the other copies invalid the moment a write happens. Getting the line back afterwards means asking the core that owns it, which costs about 70 cycles here. That is much cheaper than main memory, and far dearer than the 4 cycles a hit in your own L1 would have cost.

Lab 19 · Four counters, one line
Try this firstThe slider starts at 8 bytes, so read the four boxes and the two bars first. All four cores show line 0. Now drag the slider to 64 bytes and watch the box labels change to four different lines and the cycle count fall. Expect the fetches from another core to drop to zero.
The cure is wasted space, on purpose. Spreading the counters 64 bytes apart wastes 56 bytes each and buys back most of the time, which is a trade almost nobody would guess at from reading the source. The name for this problem is false sharing, because the cores are sharing nothing that the program cares about and paying the full price of sharing anyway. Adding the padding is a one-line change, and finding out you need it is the hard part.
What is padding?

Unused space put into a layout deliberately, to push the useful parts to addresses you want. Here it means declaring each counter inside a record 64 bytes wide and leaving 56 of them empty, so that no two counters can land in one line however the array is placed.

It looks wasteful and it is: an array of eight padded counters takes 512 bytes to hold 64 bytes of counters. That is a poor trade for something read occasionally, and a very good one for something four cores write to in a tight loop. Which way it goes depends on measurement. This is a case where the widget above answers the question in a few seconds.

Why not track ownership one variable at a time?

Because the record of who owns what has to be kept in hardware, and its size depends on how finely it is kept. Tracking 64-byte lines in a machine with a few gigabytes of memory already means tens of millions of entries. Tracking single bytes would mean sixty-four times as many, all of them consulted within a cycle or two.

So the line is a compromise, chosen because it is the same unit the caches already move and label. Nearly all of the time it is the right choice, and the case in this step is the price of that choice. Hardware is full of decisions like this, and reading them as trades rather than mistakes is most of what it takes to predict how a machine will behave.

Lab 20 · Somewhere to try things
Try this firstSet cores to 8 and gap to 8 B, then change the gap to 256 B and compare the two cycle counts. Nothing here is marked and there is no target. The line underneath reports fetches from another core's cache and lines made stale, and the third setting changes how many increments each core does.
Two things worth finding on your own. First, set the gap to 8 B and step the cores up from 1 to 8: the work per core is unchanged, so watch how much worse it gets with each core you add. Second, at a gap of 64 B or more, check whether four cores are still slower than one, and you have the answer to whether the counters themselves were ever the problem.
Four cores each count in their own variable and never read anybody else's. On four cores the program is slower than it was on one. What is going on?
They are fighting over a line. Each core has its own adder, so that is not the queue, and counting into four separate variables shares perfectly well: the widget shows exactly that as soon as the counters are 64 bytes apart. The trouble is that the hardware protects lines rather than variables, so four unrelated counters in one line behave like four cores hammering a single shared value. The source code gives you no hint, and the fix is 56 bytes of nothing.
Step 11

Tune a matrix multiply yourself

One program, six switches, and every switch is something you have already measured. The program multiplies two 64 by 64 grids. A grid of numbers like that is usually called a matrix, and multiplying two of them the way Step 1 described is such a common job that it has a short name everybody uses: a matrix multiply. This one starts out written the plain way: for each cell of the answer, walk a row of A and a column of B, multiplying and adding as you go.

The plain way reads the answer cell out of memory, adds to it, and writes it back for every one of the 262,144 multiplications. It also walks down a column of B, which you now know costs a whole line per number. Your target is 25 times faster, and the answer has to come out the same, which the widget checks on every run.

What is a block, and why would working in blocks help?

The plain version finishes one cell of the answer completely before starting the next. To do that it walks a whole column of B, and by the time it comes back for that column again, for the next row of A, the column has long since been thrown out of the cache. So B gets fetched from memory once per row of A, which for a big grid is a great deal of fetching.

Working in blocks means taking a small square of the answer, say 8 by 8, and doing all the arithmetic that touches it before moving on. The piece of B that square needs is small enough to stay in the cache the whole time, so it is fetched once and used many times. The real name for this is tiling, and it is the standard cure for a program that walks a large grid more than once. These grids are only 64 by 64, which is 32 KB, so they nearly fit in the caches already: expect blocking to buy little here on its own, and try it with the other switches on before deciding what it is worth.

Why does laying B out by columns need a copy first?

Because you cannot change how a grid is stored by asking. B is in memory row by row, and this loop wants to walk down its columns, so somebody has to write out a second copy in which column j has become row j. That operation is called a transpose. It costs one full pass over B.

Paying a whole extra pass to make later passes quicker is a trade, and the reason it wins here is that the copy is made once and read 64 times. It is the same reasoning as sorting before a loop in Step 6: preparing the data costs something. It pays when the prepared data is used enough times. When it is used once, it does not pay.

Lab 21 · Six switches and a target
Try this firstPress Race it with every switch still off, to time the plain version. Then press keep the total in a register to turn it on and press Race it again. The line under the bars names the next switch worth trying, the code above changes to match every setting, and Check my answer tells you whether you have reached 25 times. Start over puts everything back, and Show me one that works sets all six at once.
Watch the same-answer badge on every run. A version that is quick and wrong is not a version. Once you are past 25 times, go back and turn one switch off at a time. Some of them are worth a great deal alone and almost nothing once the others are on, and one of them does almost nothing until another is on first. That is the reason to measure each change instead of collecting rules.
Does the order I turn the switches on in matter?

For the final speed, no: all six on is all six on. For understanding what each one is worth, very much so. A change that removes waiting for memory makes the arithmetic changes look better than they are on their own, and a change that widens the arithmetic looks worthless while the loop is still waiting on memory.

This is why the advice line in the lab suggests memory first and arithmetic second. Fix what is limiting the program now, measure again, and see what is limiting it next. The waterfall below walks that order for you and shows how much each stage was worth given everything above it.

Lab 22 · What each change was worth
Try this firstPress Measure every stage. Seven bars appear, each one adding a single change on top of the row above it, and the whole multiplication is run again for every row. Expect the bars to shorten sharply near the top, and expect one of them near the bottom to come out longer than the row above it.
Two things to take from this. The first is that a row saying it removed 2 per cent of the original is not a failure: by then there is very little original time left, so a change that halves what remains still shows as a thin slice. The second is the row that goes backwards. Added at that point, blocking makes this multiplication more than twice as slow, because at 64 by 64 the grids very nearly fit in the caches already, so the extra loops buy nothing and are not free. Two rows later the same blocking earns its place, once the work is split across eight cores and each core is working on its own piece. A rule collected from somebody else would have got this wrong in both directions.
You apply six changes one at a time. The first takes 60 per cent off the running time and the sixth takes 2 per cent off it. Was the sixth change a waste of effort?
Judge it against what was left, not against the original. If the first five changes had already cut the time to 5 per cent of where it started, then another 2 per cent of the original is two fifths of everything remaining. That is a large win badly described. It cuts both ways: the same change would have looked pointless had you tried it first. Which is why the only reliable way through is to measure, fix the biggest thing, and measure again.

What the processor model has taught you

  • Profile before changing anything, and work out what a fix is worth before spending a week on it.
  • Recognise a program that is waiting for memory rather than doing arithmetic.
  • Count cache lines instead of counting reads.
  • Choose a loop order, and a data layout, to suit the loop that reads it.
  • Spot an unpredictable branch and a dependency chain, and know the standard cure for each.
  • Tell Amdahl's ceiling apart from a bandwidth ceiling, and know that more cores fixes neither.
  • Find false sharing, which no amount of reading the source will reveal.

Related courses

  • Fault Tolerance. Build small systems, then kill parts of them on purpose. Speed is one property of a system; surviving its own failures is another, and they pull against each other.
  • Operating System Engineering. What happens underneath a program: who decides which core it runs on, where its memory comes from, and what a page fault really costs.
Step 12

Run a benchmark you can believe

A stopwatch produces a number even when the experiment is poor. The first run may fill caches or make a just-in-time compiler produce machine code. A laptop may change clock speed as it warms. Another process can interrupt one version more often than the other. If the compiler notices that a result is unused, it may remove the work completely. Performance evidence begins with controlling these causes.

Define the workload, input distribution, machine, software build and metric before running anything. Warm up when the real workload is normally warm. Repeat independent trials. Interleave A and B in a random order so a slow drift does not always punish B. Check the result, because quick and wrong is still wrong. Keep every sample. A median describes the middle observation; a percentile describes a position in the sorted observations; spread tells you whether a small difference can be separated from noise.

Mean, median, confidence interval and practical importance

The mean uses every value and moves when one outlier is large. The median is less sensitive to an occasional interruption. A confidence interval estimates how precisely the experiment has located a population quantity under its sampling assumptions. Statistical separation does not say the change matters. A reliable 0.2 per cent improvement may not pay for more code, memory or risk. Set a minimum useful gain before looking at the result.

Probability for Engineering develops sampling, intervals, power, sequential tests and reproducible simulation from first principles.

Microbenchmark, component test and end-to-end test

A microbenchmark isolates a small operation and helps explain a mechanism. It can also produce a win that disappears when the operation is a tiny part of the real request. A component test includes nearby costs. An end-to-end load test includes queues, I/O and coordination. Use the small test to understand cause and the larger test to decide whether the change helps the user.

Practical reference: Google Benchmark user guide, including warm-up, repetition and random interleaving.

Lab 23 · Rescue a noisy comparison
Try this firstRun one cold sample with fixed A-then-B order. Then add warm-up, repetitions and random interleaving until the verdict can separate the versions.
Read the samples, not only the verdict. The model uses a fixed noise sequence so the lesson is repeatable. In real work, save machine state and raw runs too.
Version B is always measured after version A while the machine steadily warms and slows. What is the first experimental repair?
Break the link between version and time. Repetition alone does not remove a bias that always places one version later.
Step 13

Ask the processor what stopped it

A sampled profile tells you where the processor was when samples arrived. Hardware performance counters count events such as instructions retired, cycles, cache misses and mispredicted branches. Derived quantities make them easier to compare: instructions per cycle, misses per thousand instructions and the fraction of slots lost to a stalled front end, wrong-path work or a busy execution back end.

One useful top-down classification asks four questions. Did useful instructions retire? Did the front end fail to supply work? Was work discarded after a bad prediction? Was the back end unable to execute ready work? A back-end limit can then be split into core execution and memory waiting. The categories help choose the next measurement. They are not a diagnosis produced by one counter.

Why counter numbers need context

Counters differ by processor, and only a limited number can be measured at once. Tools may multiplex them, scaling partial observations. Speculation can count work that did not retire. Virtual machines and power management add more uncertainty. Record the CPU model, event definitions, command, workload and duration. Check whether the tool reports multiplexing or unsupported events.

Observation can expose data

Profiles can contain process names, memory addresses, paths and timing information. System-wide counter access can reveal activity belonging to other users. Use the least privilege and scope that answers the question, protect captured profiles and follow the machine owner's policy.

Current reference: Linux perf event security documentation.

Lab 24 · Classify a stalled pipeline
Try this firstSelect the pointer chase. Inspect retired, front-end, speculation and back-end slots, then choose the next measurement.
The largest category narrows the search. Use cache and memory events only after the top-level result says the back end is waiting on memory.
A run reports many cache misses. Why is that not enough to conclude that misses dominate its time?
Counts need a denominator and a causal check. Prefetching or independent work may hide some misses.
Step 14

Find the roof above a kernel

Arithmetic intensity is useful arithmetic operations divided by bytes moved from the memory level being studied. A kernel that performs one operation for every eight bytes has little work to hide each transfer. A tiled matrix multiply reuses loaded values and performs many operations per byte. The intensity belongs to an algorithm, implementation, input and chosen memory boundary, not just to the function's name.

The roofline bound is the smaller of two ceilings: peak compute throughput and memory bandwidth multiplied by arithmetic intensity. Below the sloping part, moving or reusing fewer bytes is the likely route upward. Below the flat part, more parallel arithmetic, vectorisation or better instruction scheduling may help. The bound says what the machine cannot exceed under the model. It does not promise that code will reach the roof.

Why there can be several roofs

Data may come from L1, the last-level cache, DRAM or an accelerator link. Each boundary has its own measured bandwidth and its own byte count, so each produces a different intensity and roof. Integer work, tensor operations and floating-point work also have different compute ceilings. Label both axes and the measured boundary before interpreting a roofline plot.

Method reference: Berkeley Lab roofline overview.

Lab 25 · Move a kernel across the ridge point
Try this firstDrag arithmetic intensity from 0.25 to 16 operations per byte. Watch the memory roof rise until the compute roof becomes lower.
The ridge is compute peak divided by bandwidth. Changing machines moves the ridge; changing reuse moves the kernel. Keep units consistent before multiplying.
A kernel is below the memory-bound slope. Which change can raise its roof without changing the machine?
Raise arithmetic intensity. Tiling, fusion or avoiding unnecessary transfers can do that.
Step 15

Measure the queue, not only the worker

A worker may take 8 ms of CPU time while a request takes 80 ms to finish. The missing 72 ms was waiting: for a thread, connection, lock, storage queue or downstream service. Throughput counts completions per unit time. Latency follows one request. Concurrency counts work currently inside the system. Little's Law connects their long-run averages: work in the system equals arrival rate multiplied by time in the system.

As arrival rate approaches service capacity, a small disturbance takes longer to drain and queueing delay rises sharply. A simple M/M/1 model makes that shape visible, but its assumptions are strict: independent arrivals, exponential service times, one server and a stable average rate below capacity. Measure real service distributions and burstiness before using the formula to size a production system.

Why an average hides the slow users

The median is the middle request. The 99th percentile is the value that 99 per cent of requests finish at or below. In a page that calls many services, the chance that at least one call lands in a tail grows with the fan-out. Record a latency histogram rather than averaging already-averaged values. An exemplar can connect a slow histogram observation to its trace.

Further reading: Google SRE on percentile latency and the OpenTelemetry metrics data model.

Lab 26 · Push a queue towards saturation
Try this firstSet arrivals to half the service rate, then move them close to capacity. Compare utilisation, mean response, p99 and average work in the system.
Do not run at the cliff just because the average fits. Leave headroom for bursts, failures, maintenance and uncertain service time.
A single server completes 100 requests per second and receives a steady 99 requests per second. Why can latency still be poor?
Capacity without headroom is fragile. The simple queue model shows the response curve rising as utilisation approaches one.
Step 16

Let measured code guide the compiler

An optimising compiler can inline small calls, remove unused work, unroll loops, schedule independent instructions and vectorise suitable operations. It must preserve the language's rules, and it uses a cost model rather than knowing the real workload. Aliasing, unknown trip counts, calls with side effects and irregular control flow can prevent a transformation. Read optimisation remarks and inspect the generated machine code for the hot loop instead of assuming an option worked.

Profile-guided optimisation builds an instrumented program, runs representative work, then uses the observed branch and call frequencies for a second build. Sample-based profiles gather similar evidence from hardware sampling. Post-link optimisers such as BOLT can reorder functions and basic blocks after linking. A just-in-time compiler can specialise code while the program runs. Every method depends on its observations; a profile that omits an important workload can make that workload slower.

Optimisation levels are bundles, not speed settings

-O2 and -O3 enable groups of transformations. The higher bundle may increase code size, instruction-cache pressure or compile time, and a floating-point option may change numerical rules. Compare the exact compiler, flags and target CPU. Keep correctness tests, and measure the deployed binary rather than the intermediate file you meant to ship.

AI autotuning belongs inside the same test loop

A search tool or coding model can propose tile sizes, layouts or source rewrites. Give it a correctness oracle, representative inputs, a held-out validation workload and a resource budget. Measure all candidates in controlled runs and retain the baseline. A candidate selected on the same noisy samples used to generate it can overfit the benchmark just as PGO can overfit an unrepresentative profile.

Current references: LLVM vectorisation diagnostics and Clang profile-guided optimisation.

Lab 27 · Build, profile, rebuild and validate
Try this firstEnable vectorisation without fixing the aliasing barrier. Read the remark, then repair the barrier and add a representative profile.
The held-out workload decides whether the profile generalises. Code size and compile time remain costs even when the measured path improves.
A PGO build is fast on the training requests and slow on an important request type absent from the profile. What is the direct fix?
The profile is an input to the build. Its coverage must match the deployment, and a separate workload catches overfitting.
Step 17

Include the trip to the accelerator

A GPU runs many similar operations at once and has high memory bandwidth. That does not make every job faster. Work must be prepared, copied or mapped, launched, synchronised and copied back. A tiny kernel can finish before launch overhead is repaid. A branchy graph walk may leave most lanes idle. A large regular matrix or image operation can provide enough parallel work and data reuse to win.

Measure the entire requested operation, not only device kernel time. Batching amortises launch and transfer overhead. Pinned host memory can support asynchronous copies but is a limited resource. Separate streams can overlap independent transfer and compute when hardware and dependencies allow it. Synchronisation belongs at the point where a result is needed; adding it after every launch removes the overlap.

Occupancy is a constraint, not the final score

Registers and shared memory used by each thread block limit how many blocks can reside together. More resident warps can hide latency, but maximum occupancy does not guarantee maximum throughput. Coalesced memory access, divergence, arithmetic intensity, tensor-unit eligibility and data transfer may dominate. Profile the kernel and the host-device timeline.

Current reference: CUDA C++ Best Practices Guide, especially transfer and overlap guidance.

Lab 28 · Decide when offload pays
Try this firstStart with 1,024 items and include both transfers. Increase the batch and then enable overlap. Compare end-to-end CPU and accelerator time.
Kernel speedup is not application speedup. Keep launch, transfer, synchronisation and result checking inside the timed boundary that matches the user.
A GPU kernel is 20 times faster than the CPU loop, but the application is slower after offload. What should be measured next?
Measure end to end. Data movement and fixed launch cost can exceed the saved compute time.
Step 18

Follow work across an I/O pipeline

A request can cross a parser, application thread, network connection, storage queue and remote service. Each stage has service time, capacity, batching rules and a queue. The slowest sustainable stage limits throughput. End-to-end latency also includes time between stages, retries and work that was later discarded. Optimising a stage already faster than the bottleneck will not change throughput.

Blocking I/O ties up a thread until completion. Asynchronous I/O lets a smaller number of threads submit operations and handle completions, but it does not make the device itself faster. Concurrency can keep an idle device busy; too much concurrency builds queues, consumes memory and makes cancellation expensive. Backpressure makes an overloaded downstream stage slow or refuse upstream work before buffers grow without bound.

Throughput, latency and goodput

Throughput counts all completed work. Goodput counts useful, accepted work. Retries can raise traffic and measured throughput while useful results stay flat. Compression may trade CPU time for fewer network bytes. Batching may raise throughput while an individual request waits longer for a batch to fill. Keep the user-visible outcome and resource budget beside the faster number.

Lab 29 · Balance a four-stage pipeline
Try this firstIncrease application workers while storage stays fixed. Find where throughput stops rising and queued work begins to grow.
Improve the current bottleneck, then measure again. Turn on backpressure and compare bounded memory with admitted goodput.
Adding application threads no longer raises completed requests, while the storage queue grows. What is the best explanation?
The fixed storage stage sets the rate. More work in front of it raises delay and memory use, not completion capacity.
Step 19

Ship a faster system without guessing

A benchmark result becomes useful only when the deployed system keeps it. Define a performance budget for latency, throughput, memory, CPU time, energy or cost. Store a representative benchmark with the code. Run it often enough to find regressions near the change that caused them, while keeping noisy tests out of hard pass or fail gates until their variance is understood.

Roll out a change to a small, representative slice first. Compare it with a concurrent control, because traffic and machine conditions change over time. Watch correctness, error rate and resource use beside speed. Expand when prewritten criteria pass; stop or roll back when they fail. A canary is an experiment with a safety boundary, not a smaller launch party.

Energy and cost are performance dimensions

Finishing sooner can reduce energy, but using more cores or an accelerator may raise instantaneous power. Cloud cost can change with instance count, data transfer, storage operations and reserved capacity. Report energy or money per useful result when that is the engineering goal. Also record the hardware and measurement method; power estimates and billing figures are not interchangeable.

Continuous profiles and AI suggestions

A continuous profile shows how CPU or allocation time changes across real builds and workloads. It is sampled evidence, so low-frequency code and short incidents may be absent. AI tools can group stacks or suggest changes, but the same release test applies: inspect the patch, prove output equivalence where possible, run held-out workloads and keep the canary thresholds. Never trade correctness or a security boundary for a benchmark score the user does not experience.

Lab 30 · Make a canary decision from five signals
Try this firstSelect the fastest candidate and send 5 per cent of traffic to it. Decide whether latency, errors, memory, energy and cost satisfy the written gates.
The decision is made from all gates. A candidate that misses one safety or correctness limit does not pass because its median is excellent.
A canary improves median latency by 15 per cent but doubles the error rate beyond the release limit. What should happen?
The release gates were written before the result. Speed does not compensate for failed correctness or reliability.

What you can now do

  • Design a benchmark that separates a change from warm-up, drift and noise.
  • Use profiles and hardware counters to choose the next measurement.
  • Place a kernel on a roofline and distinguish memory from compute limits.
  • Connect arrival rate, utilisation, queueing, latency percentiles and concurrency.
  • Validate compiler profiles and accelerator offload against held-out, end-to-end work.
  • Find a pipeline bottleneck, apply backpressure and report goodput.
  • Ship through budgets, canaries and regression tests that include energy and cost.

Continue with Software at Wire Speed to apply these measurements to C, C++, Rust, Zig, asynchronous I/O, NIC queues, XDP, AF_XDP, DPDK and RDMA.