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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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?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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.