Quant Memo
Advanced

Profiling with Hardware Performance Counters

Hardware performance counters are registers built into the CPU that count real events — cache misses, branch mispredictions, instructions executed — letting you find the actual cause of slowness instead of guessing.

Prerequisites: The CPU Cache Hierarchy, Branch Prediction

"It's slow" is not a diagnosis. Code that's slower than expected could be slow because of cache misses (The CPU Cache Hierarchy), branch mispredictions (Branch Prediction), waiting on the network, waiting on a lock held by another thread, or simply doing more arithmetic than it needs to — and guessing which one is true, then rewriting code around that guess, wastes time whenever the guess is wrong. Hardware performance counters exist to replace the guess with a measurement: they are small registers built directly into the CPU that increment every time a specific hardware event happens, and reading them tells you, concretely, what the processor actually spent its cycles doing.

The idea: a dashboard, not a stopwatch

A stopwatch (wall-clock timing, time.perf_counter() or similar) tells you how long something took. It cannot tell you why. Hardware performance counters are more like a car's full instrument dashboard rather than just a speedometer: alongside total time, the CPU can report the number of instructions actually executed, the number of CPU cycles consumed, the number of times a memory access missed the cache and had to go to RAM, the number of times a branch prediction was wrong, and dozens of other specific hardware events — all counted by the silicon itself as the program ran, with essentially no distortion of the program's actual behavior.

On Linux, the standard tool for reading these counters is perf. Two of the most load-bearing numbers it reports are IPC (instructions per cycle — how much useful work the CPU completed per clock tick; modern superscalar CPUs can retire several instructions per cycle when things go well, but IPC well below 1 is a strong sign the CPU is frequently stalled, waiting rather than working) and the cache-miss rate (what fraction of memory accesses had to leave the fast cache hierarchy and go all the way to RAM).

Worked example: diagnosing the cache-miss case from earlier

Take the row-major versus column-major matrix traversal from The CPU Cache Hierarchy — the same total arithmetic, ordered differently. Running both under perf stat on compiled code makes the earlier claim checkable rather than assumed:

perf stat -e instructions,cycles,cache-misses,cache-references ./sum_row_major
perf stat -e instructions,cycles,cache-misses,cache-references ./sum_col_major

Representative output, trimmed:

sum_row_major:
  instructions:      16,000,000,000
  cycles:              4,200,000,000    (IPC ≈ 3.8)
  cache-references:      500,000,000
  cache-misses:             8,000,000   (1.6% miss rate)

sum_col_major:
  instructions:      16,000,000,000
  cycles:             28,000,000,000    (IPC ≈ 0.57)
  cache-references:      500,000,000
  cache-misses:           410,000,000   (82% miss rate)

Trace what this proves, not just suggests. Both versions execute the identical instruction count — confirming the arithmetic really is identical, ruling out "one version just does less work" as the explanation. The cycle counts differ by 6.7x, and the cache-miss rate is the number that explains why: 1.6% versus 82%. Each miss stalls the pipeline waiting on RAM (roughly 100-300 cycles), and with 51x more misses, the column-major version spends most of its cycles waiting rather than computing — which is exactly why its IPC (instructions actually completed per cycle) collapses from 3.8 to 0.57. Without the counters, you'd know the second version was 6.7x slower; with them, you know precisely which hardware resource it was starved of, which tells you the fix is memory access order, not "buy more compute" or "rewrite the arithmetic."

# subprocess wrapper to grab perf counters programmatically
import subprocess

def perf_stat(binary, events="instructions,cycles,cache-misses,cache-references"):
    result = subprocess.run(
        ["perf", "stat", "-e", events, binary],
        capture_output=True, text=True,
    )
    return result.stderr   # perf writes its report to stderr
IPC (higher = better) row-major: 3.8 col-major: 0.57 cache-miss rate row: 1.6% col: 82%
Hardware counters make the cause of slowness visible directly: identical instruction counts, but the column-major traversal stalls the pipeline on cache misses instead of doing useful work.

Why this beats guessing (and beats wall-clock timing alone)

Wall-clock timing answers "is version A faster than version B." It cannot distinguish "A is faster because it does less arithmetic" from "A is faster because it accesses memory more predictably" from "A is faster because it mispredicts fewer branches" — three completely different problems requiring three completely different fixes. Performance counters separate these causes because each is a distinct hardware event, counted independently. This turns optimization from trial-and-error (try a change, re-time, hope it helped, repeat) into a targeted process: look at which counter is abnormal relative to the instruction count, then fix specifically that.

Hardware performance counters are CPU registers that count real, specific events — instructions retired, cycles elapsed, cache misses, branch mispredictions — as a program runs, with near-zero overhead. They convert "this code is slow" into "this code stalls on cache misses 82% of the time," which is the difference between guessing at an optimization and verifying one.

Where this shows up

In a quant-dev interview, being asked "how would you find out why this loop is slow" is testing whether a candidate's answer is "add print statements and re-time it" or "run it under perf and look at IPC and cache-miss rate" — the latter is the answer that signals real low-latency systems experience. In production, hardware counters are the standard tool for tuning anything on the hot path of a trading system: verifying that a new order-book implementation actually reduced cache misses as intended, confirming that a code change removed a branch-misprediction hotspot rather than just moving it, and — critically — providing hard evidence in a performance review rather than an anecdote about which version "felt" faster.

Reading performance counters at all requires OS-level permission (perf_event_paranoid on Linux, often locked down on shared or cloud infrastructure), and even when available, counter values can be noisy on a busy, multi-tenant machine — measure several runs and look at the pattern, not a single number, before trusting a conclusion drawn from them.

Related concepts

Practice in interviews

Further reading

  • Gregg, Systems Performance: Enterprise and the Cloud (2nd ed., ch. 6)
  • Intel, Intel 64 and IA-32 Architectures Software Developer's Manual, Vol. 3B, ch. 19-20
ShareTwitterLinkedIn