Performancememory-boundcompute-boundstallsdiagnosiscounters

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.

Follow the mechanism

Software view, hardware view

The gap between what you wrote and what the machine does is where this whole domain lives.

The question
The core is pinned at 100%. Is it doing work, or is it waiting — and how would I know the difference?
What you wrote
Top shows the process at 100% CPU, so the program is compute-bound, and the fix is a faster algorithm or more cores.
What the hardware does
The core is not halted, which is all "100% CPU" means. It may be retiring four instructions per cycle, or it may be sitting on a dependent chain of last-level misses retiring almost nothing while the memory system does the actual work.
This is the single highest-leverage diagnostic in hardware performance work, because the two diagnoses lead to entirely different and mutually useless fixes. Optimising arithmetic in a memory-bound loop is wasted effort; improving locality in a compute-bound loop is equally wasted.
SourceCompilerInstructionsFront EndExecutionRegistersCachesMemoryI/OBehavior

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.

The same 100% utilisation, two entirely different machines
EvidenceCompute-boundMemory-bound
CPI and IPC: The Number Everyone MisreadsLow — near the core's peak retirement rateHigh — many cycles retire nothing
LLC miss rateLow; the working set fits or prefetch keeps upHigh; misses per instruction is the dominant term
Execution port pressureHigh — the units are the bottleneckLow — units idle waiting for operands
Effect of more coresScales roughly with core countFlattens quickly; can regress from bandwidth contention
Effect of a faster algorithmDirect improvementLittle, unless it also touches less memory
Effect of better layoutLittleOften the largest single win available
What actually helpsFewer operations, Vectorization: Turning a Loop Into Vector Work, better instruction mixSpatial Locality, Array of Structs, or Struct of Arrays?, Matrix Tiling: Same Arithmetic, Ten Times Faster, smaller working set

Reading the counters instead of guessing

MICROARCH-SPECIFICPeak retirement width, the number of outstanding misses a core can sustain, and achievable memory bandwidth all differ by design and by platform; the thresholds that separate these diagnoses must be established per machine.

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.

Two counting runs of loops that take the same wall-clock time. Illustrative of the contrast, not measured.
                        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.

Memory-bound loop — "optimised" by reducing arithmetic
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.
Same computation, memory-bound fix
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.

  1. 1
    Instruction → operand: the core issues a load and needs its result before dependent work can proceed.
  2. 2
    L1 → LLC → DRAM: on a miss the request walks outward, each level costing substantially more than the last.
  3. 3
    Miss → stall: if no independent work remains in the scheduling window, the core retires nothing while waiting.
  4. 4
    Stall → CPI: those cycles accumulate against an unchanged instruction count, driving the ratio up.
  5. 5
    CPI + misses → diagnosis: poor CPI with high LLC misses and idle ports is the memory-bound signature.
What people conclude from this — wrongly
  • 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

What it causes
  • • 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.
What you can do
  • • 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.
How to see it
  • • 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.
What it costs
  • • 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.

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.

Misconceptions

Claim
“The CPU is at 100%, so the program is CPU-bound.”
Reality
Utilisation only reports that the core was not idle. A core stalled on memory for the entire interval reports 100% utilisation while retiring almost nothing.
Claim
“High cache miss counts mean the program is memory-bound.”
Reality
Only if those misses actually stalled the core. Independent misses overlap and can be largely hidden; the diagnosis needs poor CPI and idle execution ports alongside the miss count.
Claim
“If it is memory-bound, adding cores will still help since each core has its own cache.”
Reality
They share the last level and the memory controllers. On a bandwidth-bound workload additional cores contend for a saturated resource and throughput flattens or regresses.

Apply it