Busy Is Not the Same as Working
A core showing 100% utilisation may be executing a dense stream of arithmetic, or it may be stalled almost the entire time waiting for data that has not arrived. The operating system reports both as "busy". They are different problems with disjoint fixes, and only the counters can tell them apart.
Software view, hardware view
The gap between what you wrote and what the machine does is where this whole domain lives.
Utilisation is a terrible word
"CPU utilisation" is measured by the operating system, and it means one thing only: what fraction of the interval the core was not idle. It says nothing whatsoever about what the core was doing while not idle. A core that issues one instruction and then stalls for two hundred cycles waiting on DRAM is 100% utilised for that entire stall, because it never went to sleep.
This is why an entire genre of performance surprise exists: adding cores to a memory-bound workload produces almost no speedup, and sometimes a slowdown, while every dashboard insists the CPUs were saturated and therefore more of them should help. The cores were saturated; the *memory system* was the constraint, and adding consumers to a saturated shared resource makes contention worse, not better. The Observability & Performance domain reaches the same conclusion from the outside in Computing or Waiting?; this is the mechanism underneath it.
The honest reformulation is that a core has two ways to spend a cycle: retiring work, or waiting. Compute-bound means the first dominates and the execution ports are the constraint. Memory-bound means the second dominates and the data supply is the constraint. Every practical distinction follows from which of those two is true.
| Evidence | Compute-bound | Memory-bound |
|---|---|---|
| CPI and IPC: The Number Everyone Misreads | Low — near the core's peak retirement rate | High — many cycles retire nothing |
| LLC miss rate | Low; the working set fits or prefetch keeps up | High; misses per instruction is the dominant term |
| Execution port pressure | High — the units are the bottleneck | Low — units idle waiting for operands |
| Effect of more cores | Scales roughly with core count | Flattens quickly; can regress from bandwidth contention |
| Effect of a faster algorithm | Direct improvement | Little, unless it also touches less memory |
| Effect of better layout | Little | Often the largest single win available |
| What actually helps | Fewer operations, Vectorization: Turning a Loop Into Vector Work, better instruction mix | Spatial Locality, Array of Structs, or Struct of Arrays?, Matrix Tiling: Same Arithmetic, Ten Times Faster, smaller working set |
Reading the counters instead of guessing
The diagnosis is mechanical once you have the numbers. Start with CPI. If it is close to the core's peak — meaning the machine is retiring near its maximum instructions per cycle — the machine is flowing and you are compute-bound; the lever is doing less work. If CPI is poor, the core is stalling, and the next counters say why.
For memory-bound confirmation, the pair that matters is last-level misses per instruction together with stall cycles attributable to memory. A high LLC miss rate on its own is suggestive but not conclusive, because independent misses overlap — the point Misses That Overlap Are Nearly Free makes. What settles it is the combination of poor CPI, high LLC misses, and low execution-port utilisation: the units are idle, the data is not there, and the misses are not overlapping enough to hide it.
One further check separates the two flavours of memory-bound, and it changes the fix. If memory *bandwidth* is near the platform's achievable ceiling, you are bandwidth-bound and the answer is to move fewer bytes — compression, smaller types, better layout. If bandwidth is nowhere near the ceiling but misses are still stalling you, you are latency-bound on a dependency chain, and the answer is to break the chain or increase the number of independent accesses in flight. The two look identical in a wall-clock profile.
loop A loop B cycles 8.0e9 8.0e9 instructions 28.0e9 2.6e9 CPI 0.29 3.08 LLC-load-misses 0.4e6 410.0e6 LLC-misses / 1k insn 0.01 157.7 memory bandwidth low near platform ceiling diagnosis compute-bound memory-bound (bandwidth) lever fewer ops, SIMD move fewer bytes
The fixes do not overlap
This is the part that makes the diagnosis worth the effort. For a compute-bound loop the levers are all about doing less: a better algorithm, vectorization so each instruction does more work, strength reduction, removing redundant computation, and only then more cores. Improving cache locality in such a loop changes essentially nothing, because the data was already arriving in time.
For a memory-bound loop the levers are all about touching less memory or touching it in a friendlier order: contiguous layout instead of pointer-linked structures, struct-of-arrays when you only read some fields, blocking so the working set fits a cache level, smaller data types, and eliminating the indirections that force dependent loads. Meanwhile making the arithmetic cheaper is nearly free of effect, because the arithmetic was never the constraint — the units were idle.
The cost of getting this backwards is not merely wasted time; it is a plausible-looking change that ships, does nothing, and consumes the team's belief that performance work is worthwhile. That is a large part of why Measure Before You Optimize exists as a discipline rather than a slogan.
1# Each iteration follows a pointer to somewhere unpredictable.2for (node = head; node; node = node->next)3 sum += node->value * 2; # replaced with << 1 to "save a multiply"4 5# Result: unchanged. The multiply was free; the load was not.1# Same values, contiguous, prefetchable, independent loads.2for (i = 0; i < n; ++i)3 sum += values[i] * 2;4 5# Result: large improvement. The arithmetic is identical;6# the data supply changed completely. See [[array-vs-linked-list]].Both versions perform the same arithmetic. The first is limited by dependent, unpredictable loads and the second is not, so only changes affecting data supply move the runtime. Optimising the multiply in the first version is a null change dressed up as work.
Key points
- "100% CPU" means the core was not idle; it says nothing about whether the core was retiring instructions or stalling.
- Compute-bound means execution units are the constraint; memory-bound means data supply is, and the two require disjoint fixes.
- The diagnosis is CPI first, then LLC misses per instruction and stall attribution — not intuition about what the code looks like.
- Memory-bound splits further into bandwidth-bound and latency-bound, which also have different fixes.
- Adding cores to a memory-bound workload flattens quickly and can regress, even though every dashboard says the CPUs were saturated.
Progressive depth
Overview
A core can be busy doing work or busy waiting for data. Both look like 100% CPU. Compute-bound and memory-bound need opposite fixes, so telling them apart is the first thing to do.
Practical
Read CPI first. Good CPI plus slow runtime means too much work — reduce operations. Poor CPI plus high last-level misses means waiting on data — improve locality, shrink the working set, make accesses contiguous.
Advanced
Split memory-bound into bandwidth-bound and latency-bound by comparing achieved bandwidth against a measured platform ceiling. Bandwidth-bound wants fewer bytes moved; latency-bound wants shorter dependency chains and more independent accesses in flight.
Internals
The core stalls when the scheduling window contains no ready instruction. Its capacity to hide misses is bounded by the reorder buffer size and by how many outstanding fills the memory subsystem tracks. When that structure is full the core cannot issue further misses regardless of available independent work, which is the hard ceiling behind Misses That Overlap Are Nearly Free.
Follow the mechanism
The path through the machine, hop by hop — and the conclusions it invites that are wrong.
- 1Instruction → operand: the core issues a load and needs its result before dependent work can proceed.
- 2L1 → LLC → DRAM: on a miss the request walks outward, each level costing substantially more than the last.
- 3Miss → stall: if no independent work remains in the scheduling window, the core retires nothing while waiting.
- 4Stall → CPI: those cycles accumulate against an unchanged instruction count, driving the ratio up.
- 5CPI + misses → diagnosis: poor CPI with high LLC misses and idle ports is the memory-bound signature.
- • Reading 100% CPU utilisation as evidence of compute-bound work.
- • Concluding memory-bound from a high absolute miss count without checking whether the misses actually stalled the core.
- • Adding cores because utilisation is high, when the shared memory system is the actual constraint.
- • Treating "memory-bound" as one diagnosis rather than two, and applying a bandwidth fix to a latency problem.
Consequences, controls and cost
- • Optimisation effort is routinely spent on arithmetic in loops that were never arithmetic-limited.
- • Scaling out a memory-bound service buys far less than the utilisation figures predict.
- • Two loops with identical runtime and identical CPU utilisation can require opposite changes.
- • Bandwidth saturation is invisible to standard OS metrics, so it surprises teams that only watch utilisation.
- • Measure CPI and LLC misses per instruction before proposing any fix; the diagnosis determines the entire fix set.
- • For memory-bound work, change layout and working-set size first — locality is usually the largest single lever available.
- • For compute-bound work, reduce operations first, then vectorize, then add cores.
- • Distinguish bandwidth-bound from latency-bound before choosing between "move fewer bytes" and "break the dependency chain".
- • Re-measure after the change; a fix aimed at the wrong constraint characteristically produces no movement at all.
- • CPI from cycles and instructions retired, interpreted against the core's peak retirement width.
- • LLC misses per thousand instructions, plus stall cycles attributed to memory.
- • Achieved memory bandwidth compared against a separately measured platform ceiling from a streaming benchmark.
- • A scaling experiment: run at one, two and four threads and observe whether throughput scales or flattens.
- • Layout changes that fix memory-bound loops often cost abstraction and readability, as [[data-oriented-design]] discusses.
- • Blocking and tiling add code complexity and tuning parameters that are machine-specific.
- • Establishing the per-machine thresholds this diagnosis needs takes real setup effort before the first useful answer.
Scope
§224 — what these claims are specific to.
- MICROARCH-SPECIFICPeak retirement width and sustainable outstanding-miss count differ by core design, so the CPI value that indicates stalling must be calibrated per machine rather than copied.
- PLATFORM-SPECIFICAchievable memory bandwidth depends on channel count, DRAM generation and NUMA topology; the bandwidth ceiling to compare against must be measured on the target platform.