Hardware Performance Counters
Reading time: ~35 min · Interview relevance: High · Target roles: ML Engineer, Systems Engineer, Performance Engineer
You spent three weeks optimizing a matrix multiplication kernel. Throughput improved 12%. Your manager wants 5x. Without hardware counters, you are flying blind - guessing where cycles go. With perf stat showing 2.1 cache misses per instruction and 0.4 IPC on a machine capable of 4.0, you know exactly what to fix next.
The Production Crisis That Changes How You Think About Code
It is 2:00 AM on a Thursday. A major e-commerce company's recommendation system - a transformer-based model serving 400 million requests per day - has started missing its SLA. P99 latency jumped from 18ms to 47ms after a seemingly routine model update. The on-call ML engineer opens a dashboard. CPU utilization looks normal at 65%. Memory usage is fine. Network is quiet. Nothing in the application logs stands out.
The engineer does what most engineers do: they stare at the code, make a guess, try a fix, and wait for CI to run. Four hours pass. Latency is still 47ms. Dawn arrives. The guess was wrong.
A senior systems engineer joins the bridge call. She types a command into her terminal: perf stat ./server. Fifteen seconds later she has the answer. LLC-load-misses (last-level cache misses) jumped from 0.8% to 14.7% after the model update. The new model's embedding table grew from 180MB to 520MB - it no longer fits in L3 cache. Every embedding lookup now reaches all the way to DRAM, taking 80-100ns instead of 4ns. The math is immediate: 400 lookups per request times 96ns extra latency equals 38ms of added latency. Exactly the observed regression.
This is what hardware performance counters do. They give you ground truth about what the CPU is actually doing, not what you think it is doing. They are the difference between guessing and knowing. Every engineer who works close to performance - ML engineers optimizing training loops, systems engineers tuning inference servers, MLOps engineers investigating throughput regressions - needs to be fluent in reading these numbers.
The good news: the Linux perf tool ships with virtually every distribution, requires no code changes, and gives you access to hundreds of hardware-level events in seconds. The bad news: most engineers never learn it because it looks intimidating. This lesson will fix that. By the end, you will understand what the CPU is measuring, how to read the output, how to script it for automated regression detection, and how to use this data to guide optimization systematically.
Why This Exists - The Problem Before Performance Counters
Before hardware performance counters became standard, performance engineers had two tools: wall-clock timing and educated guessing. You measured a function's execution time. If it was slow, you examined the code, formed a hypothesis about the bottleneck (too many memory accesses? too many branches?), changed something, and measured again. This cycle - guess, change, measure - could take days or weeks for non-obvious bottlenecks.
The deeper problem is that modern CPUs are not simple. A processor executing a loop does not simply "run the instructions." It is simultaneously fetching instructions for the next 100+ cycles, predicting which way branches will go, prefetching cache lines it thinks you will need, executing multiple instructions per clock cycle out of order, and managing a six-level memory hierarchy with latency differences spanning four orders of magnitude. The performance of a piece of code is the emergent result of all these interacting mechanisms. Wall-clock time tells you the result, not the cause.
Hardware performance counters solve this by building measurement circuitry directly into the processor. The Performance Monitoring Unit (PMU) is a dedicated hardware block with a set of programmable counters. Each counter can be configured to increment on specific microarchitectural events: every retired instruction, every clock cycle, every L1 data cache miss, every branch misprediction, every TLB miss, every stall cycle waiting for memory. The PMU can monitor dozens of these events simultaneously, with zero perturbation to the CPU's execution - the measurement is done in hardware, not software.
This hardware capability, exposed through the Linux perf_event_open syscall and the perf userspace tool, gives engineers exactly what they need: not just "this function is slow" but "this function is slow because it is stalling 78% of cycles waiting for L3 cache misses."
Historical Context - From RISC Experiments to Modern PMU
The first hardware performance counters appeared in the early 1990s as the RISC processor designs at MIPS, HP, and Sun began shipping in high-performance workstations. Engineers building compilers and operating systems needed to understand whether their code was actually exploiting the pipeline efficiently. The initial counters were primitive - often just two or four fixed counters measuring cycles and instructions.
Intel's P6 microarchitecture (Pentium Pro, 1995) introduced a more general programmable counter model. Two "Performance Monitoring Counter" (PMC) registers could be configured to count any of 28 different events. The model was accessed through machine-specific registers (MSRs) using RDMSR/WRMSR instructions - kernel-mode only, which meant ordinary programs could not use them.
The critical breakthrough came in 2009 when Ingo Molnar and Thomas Gleixner introduced the perf_events subsystem into the Linux kernel (version 2.6.31). This abstraction layer unified access to PMU counters across Intel, AMD, and ARM architectures, added software-only events (context switches, page faults), and - crucially - allowed unprivileged users to monitor their own processes. Suddenly hardware counters were accessible to any engineer with a Linux shell.
The perf userspace tool followed immediately. It wrapped perf_events in a human-readable interface. Tools like perf stat (aggregate counts), perf record + perf report (sampled call-graph profiling), and later perf annotate (source-level annotation) became the standard toolkit for Linux performance engineering. Since then, every major CPU vendor has extended their PMU significantly - modern Intel Sapphire Rapids CPUs expose over 200 programmable events, with four general-purpose counters and three fixed-function counters available simultaneously.
Understanding the PMU and Its Event Taxonomy
The Performance Monitoring Unit is a hardware block present in every modern CPU. It sits alongside the execution units, monitoring the activity of the entire pipeline. Understanding its structure is essential for interpreting counter data correctly.
Fixed-Function Counters
Modern Intel CPUs have three counters that are permanently wired to specific events. You cannot reprogram them, but they are always available:
INST_RETIRED.ANY- counts every instruction that completes ("retires") execution. This excludes instructions that were fetched and decoded but then flushed due to mispredictions.CPU_CLK_UNHALTED.THREAD- counts every cycle the CPU is running (not in a power-saving halt state).CPU_CLK_UNHALTED.REF_TSC- counts reference cycles at a fixed frequency, independent of CPU frequency scaling.
These three alone give you IPC (instructions per cycle), which is the single most informative ratio in CPU performance analysis.
General-Purpose (Programmable) Counters
Beyond the fixed counters, modern Intel CPUs have four to eight programmable counters. Each can be configured to count any supported microarchitectural event. The events are organized into a taxonomy:
Frontend Events - what happens before instructions reach the execution units:
FRONTEND_RETIRED.LATENCY_GE_*- stalls due to instruction fetch/decode bottlenecksICACHE_16B.IFDATA_STALL- instruction cache misses
Backend Events - stalls while instructions wait for execution resources:
CYCLE_ACTIVITY.STALLS_L1D_MISS- cycles stalled waiting for L1 data cacheCYCLE_ACTIVITY.STALLS_L2_MISS- cycles stalled waiting for L2CYCLE_ACTIVITY.STALLS_L3_MISS- cycles stalled waiting for L3 (goes to DRAM)
Memory Events - detailed cache and TLB behavior:
MEM_LOAD_RETIRED.L1_HIT/MISS- L1 cache hit/miss countsMEM_LOAD_RETIRED.L2_HIT/MISS- L2 hit/miss countsMEM_LOAD_RETIRED.L3_HIT/MISS- L3 hit/miss countsDTLB_LOAD_MISSES.MISS_CAUSES_A_WALK- TLB misses triggering page table walksLLC_MISSES(architectural event) - last-level cache misses going to DRAM
Branch Events:
BR_INST_RETIRED.ALL_BRANCHES- all retired branch instructionsBR_MISP_RETIRED.ALL_BRANCHES- mispredicted branches (must re-fetch correct path)
The IPC Ratio - Your First Diagnostic
A modern out-of-order processor can theoretically retire 4-6 instructions per cycle. In practice:
| IPC Range | Interpretation |
|---|---|
| 3.0 - 5.0 | Excellent - compute bound, good utilization |
| 1.5 - 3.0 | Good - some stalls, but acceptable |
| 0.8 - 1.5 | Moderate - memory pressure or branch issues |
| 0.3 - 0.8 | Poor - significant memory latency stalls |
| Below 0.3 | Critical - almost certainly DRAM-bound |
ML training loops on well-optimized GEMM operations achieve IPC of 3-4. Poorly written data loading code with random memory access patterns often falls below 0.5.
Linux perf - The Engineer's Command Center
The perf tool is your primary interface to hardware counters. Understanding its main subcommands is essential.
perf stat - Aggregate Counting
perf stat runs a command and reports aggregate counter values for its entire execution. This is the first tool to reach for when investigating performance:
# Basic stat - shows the default event set
perf stat ./my_program
# Example output:
# Performance counter stats for './my_program':
#
# 45,823,451,293 cycles
# 18,329,481,023 instructions # 0.40 insn per cycle
# 3,201,847,293 cache-references
# 892,847,120 cache-misses # 27.89 % of all cache refs
# 201,847,291 branches
# 42,847,120 branch-misses # 21.23 % of all branches
#
# 12.847120123 seconds time elapsed
# Custom event set
perf stat -e cycles,instructions,cache-misses,branch-misses,\
mem_load_retired.l3_miss,mem_load_retired.l3_hit,\
dtlb_load_misses.miss_causes_a_walk \
./my_program
# Count for all threads in a running process
perf stat -p <PID> sleep 10
# Per-core breakdown on a multi-core system
perf stat -a -A sleep 5
The IPC of 0.40 in the example above is a serious problem. Combined with 27.89% cache miss rate, the diagnosis is clear: this workload has poor memory access locality.
perf record and perf report - Sampled Profiling
While perf stat gives aggregate counts, perf record samples the program at a specified rate and records which instruction was executing at each sample:
# Record at default sampling rate (~4000 Hz)
perf record ./my_program
# Record with call graph (stack traces) - essential for finding hot functions
perf record -g ./my_program
# Record specific events - sample on L3 misses to find WHERE they occur
perf record -e mem_load_retired.l3_miss:pp -g ./my_program
# Analyze the recording
perf report
# Generate annotated source (shows which source lines are hot)
perf annotate
The :pp suffix requests "precise" sampling using PEBS (Precise Event Based Sampling) - a hardware feature that eliminates the "skid" problem where the instruction pointer at sample time is ahead of the instruction that triggered the event.
perf top - Live System View
# Real-time view of hottest functions across the whole system
perf top
# Show per-instruction breakdown
perf top -z -g --sort=cpu
Common Diagnostic Workflows
# Step 1: Quick health check for a latency regression
perf stat -e cycles,instructions,cache-misses,branch-misses,\
mem_load_retired.l3_miss -r 3 ./server_benchmark
# Step 2: If cache-misses > 5% or L3 miss rate > 2%, profile WHERE
perf record -e mem_load_retired.l3_miss:pp -g ./server_benchmark
perf report --stdio | head -50
# Step 3: Annotate the hottest function
perf annotate hottest_function
# Diagnosing low IPC - measure frontend vs backend stalls
perf stat -e cycles,instructions,\
cycle_activity.stalls_l1d_miss,\
cycle_activity.stalls_l2_miss,\
cycle_activity.stalls_l3_miss,\
cycle_activity.stalls_mem_any \
./program
# If stalls_mem_any >> stalls_l3_miss: L1/L2 bound (fix data layout)
# If stalls_l3_miss is large: fix working set size or access pattern
The perf_event_open Syscall - Direct Hardware Access
For automated monitoring, integration into CI pipelines, and custom analysis tools, you need programmatic access to performance counters. Linux provides this through the perf_event_open(2) syscall.
"""
Python wrapper for perf_event_open syscall.
Allows direct counter reading from Python code without subprocess overhead.
"""
import ctypes
import os
import struct
import fcntl
from contextlib import contextmanager
from typing import Dict, List, Tuple, Optional
# Syscall number for perf_event_open (x86_64 Linux)
PERF_EVENT_OPEN_SYSCALL = 298
# perf_type values
PERF_TYPE_HARDWARE = 0
PERF_TYPE_SOFTWARE = 1
# Hardware event IDs (architecture-independent names)
PERF_COUNT_HW_CPU_CYCLES = 0
PERF_COUNT_HW_INSTRUCTIONS = 1
PERF_COUNT_HW_CACHE_REFERENCES = 2
PERF_COUNT_HW_CACHE_MISSES = 3
PERF_COUNT_HW_BRANCH_INSTRUCTIONS = 4
PERF_COUNT_HW_BRANCH_MISSES = 5
# Software event IDs
PERF_COUNT_SW_CPU_CLOCK = 0
PERF_COUNT_SW_PAGE_FAULTS = 2
PERF_COUNT_SW_CONTEXT_SWITCHES = 3
# ioctl commands for perf counters
PERF_EVENT_IOC_ENABLE = 0x2400
PERF_EVENT_IOC_DISABLE = 0x2401
PERF_EVENT_IOC_RESET = 0x2403
class PerfEventAttr(ctypes.Structure):
"""
Represents the perf_event_attr struct passed to perf_event_open.
Tells the kernel what event to count and how to count it.
"""
_fields_ = [
("type", ctypes.c_uint32),
("size", ctypes.c_uint32),
("config", ctypes.c_uint64),
("sample_period_or_freq", ctypes.c_uint64),
("sample_type", ctypes.c_uint64),
("read_format", ctypes.c_uint64),
("flags", ctypes.c_uint64),
("wakeup_events_or_watermark", ctypes.c_uint32),
("bp_type", ctypes.c_uint32),
("bp_addr_or_config1", ctypes.c_uint64),
("bp_len_or_config2", ctypes.c_uint64),
]
def open_perf_counter(event_type: int, event_config: int,
pid: int = 0, cpu: int = -1) -> int:
"""
Open a hardware performance counter file descriptor.
pid=0 means the current process.
cpu=-1 means all CPUs (requires elevated privilege if pid=-1).
Returns a file descriptor - read 8 bytes from it to get the counter value.
"""
attr = PerfEventAttr()
attr.type = event_type
attr.size = ctypes.sizeof(PerfEventAttr)
attr.config = event_config
# Bit 0: disabled (start paused)
# Bit 5: exclude_kernel (don't count kernel time)
# Bit 6: exclude_hv (don't count hypervisor time)
attr.flags = (1 << 0) | (1 << 5) | (1 << 6)
libc = ctypes.CDLL("libc.so.6", use_errno=True)
fd = libc.syscall(
PERF_EVENT_OPEN_SYSCALL,
ctypes.byref(attr),
ctypes.c_int(pid),
ctypes.c_int(cpu),
ctypes.c_int(-1), # group_fd: -1 = new group
ctypes.c_ulong(0) # flags
)
if fd < 0:
errno = ctypes.get_errno()
raise OSError(errno, f"perf_event_open failed: {os.strerror(errno)}")
return fd
def read_counter(fd: int) -> int:
"""Read current 64-bit value from a performance counter fd."""
buf = ctypes.create_string_buffer(8)
ctypes.CDLL("libc.so.6").read(fd, buf, 8)
return struct.unpack("Q", buf.raw)[0]
def enable_counter(fd: int):
fcntl.ioctl(fd, PERF_EVENT_IOC_ENABLE, 0)
def disable_counter(fd: int):
fcntl.ioctl(fd, PERF_EVENT_IOC_DISABLE, 0)
def reset_counter(fd: int):
fcntl.ioctl(fd, PERF_EVENT_IOC_RESET, 0)
@contextmanager
def measure_hardware_events(events: List[Tuple[int, int, str]]):
"""
Context manager that measures hardware events around a code block.
Usage:
events = [
(PERF_TYPE_HARDWARE, PERF_COUNT_HW_CPU_CYCLES, "cycles"),
(PERF_TYPE_HARDWARE, PERF_COUNT_HW_INSTRUCTIONS, "instructions"),
(PERF_TYPE_HARDWARE, PERF_COUNT_HW_CACHE_MISSES, "cache_misses"),
]
results = {}
with measure_hardware_events(events) as results:
run_my_kernel()
print(results["ipc"])
"""
fds, names = [], []
results = {}
try:
for event_type, event_config, name in events:
fd = open_perf_counter(event_type, event_config)
fds.append(fd)
names.append(name)
reset_counter(fd)
enable_counter(fd)
yield results # caller's code runs here
finally:
raw = {}
for fd, name in zip(fds, names):
disable_counter(fd)
raw[name] = read_counter(fd)
os.close(fd)
results.update(raw)
# Compute derived metrics automatically
if "cycles" in results and "instructions" in results:
c = results["cycles"]
i = results["instructions"]
results["ipc"] = i / c if c > 0 else 0.0
if "cache_misses" in results and "cache_references" in results:
results["cache_miss_rate"] = (
results["cache_misses"] / results["cache_references"]
if results["cache_references"] > 0 else 0.0
)
# Demo: measure IPC of numpy matrix multiplication
import numpy as np
def benchmark_matmul_with_counters(size: int = 1024) -> dict:
"""
Wrap a matrix multiply in hardware counters and print diagnosis.
Requires Linux with perf_event_paranoid <= 1.
"""
A = np.random.randn(size, size).astype(np.float32)
B = np.random.randn(size, size).astype(np.float32)
events = [
(PERF_TYPE_HARDWARE, PERF_COUNT_HW_CPU_CYCLES, "cycles"),
(PERF_TYPE_HARDWARE, PERF_COUNT_HW_INSTRUCTIONS, "instructions"),
(PERF_TYPE_HARDWARE, PERF_COUNT_HW_CACHE_REFERENCES, "cache_references"),
(PERF_TYPE_HARDWARE, PERF_COUNT_HW_CACHE_MISSES, "cache_misses"),
(PERF_TYPE_HARDWARE, PERF_COUNT_HW_BRANCH_MISSES, "branch_misses"),
]
results = {}
with measure_hardware_events(events) as results:
C = np.dot(A, B)
print(f"Matrix multiply {size}x{size} float32:")
print(f" Cycles: {results['cycles']:>15,}")
print(f" Instructions: {results['instructions']:>15,}")
print(f" IPC: {results['ipc']:>15.3f}")
print(f" Cache miss rate: {results.get('cache_miss_rate', 0) * 100:>14.2f}%")
print(f" Branch misses: {results['branch_misses']:>15,}")
# Diagnostic interpretation
ipc = results["ipc"]
if ipc >= 3.0:
print(" Diagnosis: COMPUTE BOUND - excellent utilization")
elif ipc >= 1.0:
print(" Diagnosis: MODERATE - some stalls, check cache miss rate")
else:
print(" Diagnosis: MEMORY BOUND - working set exceeds cache capacity")
return results
Automating perf stat for Regression Detection
Manually running perf stat is fine for ad-hoc investigation. For CI pipelines and automated benchmarks, you need scripted collection and analysis:
"""
Automated perf stat runner with regression detection.
Integrates into CI pipelines to catch performance regressions before merge.
"""
import subprocess
import re
import json
import pandas as pd
from typing import Dict, Optional
from dataclasses import dataclass
@dataclass
class PerfCounters:
"""Parsed output from a single perf stat run."""
cycles: int = 0
instructions: int = 0
cache_references: int = 0
cache_misses: int = 0
branch_instructions: int = 0
branch_misses: int = 0
l3_miss: int = 0
dtlb_misses: int = 0
elapsed_time_sec: float = 0.0
@property
def ipc(self) -> float:
return self.instructions / self.cycles if self.cycles > 0 else 0.0
@property
def cache_miss_rate(self) -> float:
return (self.cache_misses / self.cache_references
if self.cache_references > 0 else 0.0)
@property
def branch_miss_rate(self) -> float:
return (self.branch_misses / self.branch_instructions
if self.branch_instructions > 0 else 0.0)
def to_dict(self) -> Dict:
return {
"cycles": self.cycles,
"instructions": self.instructions,
"ipc": self.ipc,
"cache_miss_rate_pct": self.cache_miss_rate * 100,
"branch_miss_rate_pct": self.branch_miss_rate * 100,
"l3_misses": self.l3_miss,
"dtlb_misses": self.dtlb_misses,
"elapsed_sec": self.elapsed_time_sec,
}
def run_perf_stat(command: list, repeat: int = 5) -> PerfCounters:
"""
Run a command under perf stat and return parsed counters.
Uses repeat=-r flag so perf averages across multiple runs.
"""
perf_cmd = [
"perf", "stat",
"-e", ",".join([
"cycles",
"instructions",
"cache-references",
"cache-misses",
"branch-instructions",
"branch-misses",
"mem_load_retired.l3_miss",
"dtlb_load_misses.miss_causes_a_walk",
]),
"-r", str(repeat),
"--",
] + command
result = subprocess.run(perf_cmd, capture_output=True, text=True)
# perf stat output goes to stderr
return _parse_perf_stat_output(result.stderr)
def _parse_perf_stat_output(output: str) -> PerfCounters:
"""Parse the human-readable text output of perf stat into a dataclass."""
c = PerfCounters()
def extract_int(pattern: str) -> int:
m = re.search(pattern, output, re.MULTILINE)
return int(m.group(1).replace(",", "")) if m else 0
def extract_float(pattern: str) -> float:
m = re.search(pattern, output, re.MULTILINE)
return float(m.group(1).replace(",", "")) if m else 0.0
c.cycles = extract_int(r"([\d,]+)\s+cycles")
c.instructions = extract_int(r"([\d,]+)\s+instructions")
c.cache_references = extract_int(r"([\d,]+)\s+cache-references")
c.cache_misses = extract_int(r"([\d,]+)\s+cache-misses")
c.branch_instructions = extract_int(r"([\d,]+)\s+branch-instructions")
c.branch_misses = extract_int(r"([\d,]+)\s+branch-misses")
c.l3_miss = extract_int(r"([\d,]+)\s+mem_load_retired.l3_miss")
c.dtlb_misses = extract_int(r"([\d,]+)\s+dtlb_load_misses")
c.elapsed_time_sec = extract_float(r"([\d.,]+) seconds time elapsed")
return c
def detect_regression(baseline: PerfCounters, candidate: PerfCounters,
thresholds: Optional[Dict] = None) -> Dict:
"""
Compare two perf stat results and flag regressions.
Returns a dict with regression flags and detailed comparison report.
"""
if thresholds is None:
thresholds = {
"elapsed_sec": 5.0, # flag if 5% slower
"cache_miss_rate_pct": 20.0, # flag if miss rate up 20%
"ipc": -10.0, # flag if IPC drops 10% (negative = less is worse)
"l3_misses": 15.0, # flag if L3 misses up 15%
}
baseline_d = baseline.to_dict()
candidate_d = candidate.to_dict()
report = {"regressions": []}
for metric, threshold in thresholds.items():
b = baseline_d.get(metric, 0)
c = candidate_d.get(metric, 0)
if b == 0:
continue
pct_change = ((c - b) / abs(b)) * 100
is_regression = pct_change > threshold if threshold > 0 else pct_change < threshold
report[metric] = {
"baseline": b,
"candidate": c,
"pct_change": pct_change,
"regression": is_regression,
}
if is_regression:
direction = "increased" if pct_change > 0 else "decreased"
report["regressions"].append(
f"REGRESSION: {metric} {direction} by {abs(pct_change):.1f}% "
f"(baseline={b:.3f}, candidate={c:.3f})"
)
report["passed"] = len(report["regressions"]) == 0
return report
def analyze_perf_history(csv_path: str) -> pd.DataFrame:
"""
Analyze historical perf stat data to track performance trends over time.
Expected CSV columns: date, commit_hash, ipc, cache_miss_rate_pct,
l3_misses, elapsed_sec
"""
df = pd.read_csv(csv_path, parse_dates=["date"])
df = df.sort_values("date")
# Rolling averages smooth out day-to-day noise
df["ipc_7d_avg"] = df["ipc"].rolling(7).mean()
# Z-score based anomaly detection
df["ipc_z_score"] = (df["ipc"] - df["ipc"].mean()) / df["ipc"].std()
df["ipc_anomaly"] = df["ipc_z_score"].abs() > 2.0
miss_z = (
(df["cache_miss_rate_pct"] - df["cache_miss_rate_pct"].mean())
/ df["cache_miss_rate_pct"].std()
)
df["cache_miss_anomaly"] = miss_z.abs() > 2.0
print(f"Performance history: {len(df)} data points")
print(f"IPC anomalies detected: {df['ipc_anomaly'].sum()}")
print(f"Cache miss anomalies: {df['cache_miss_anomaly'].sum()}")
return df
Flamegraph Generation
Flamegraphs, invented by Brendan Gregg at Netflix in 2011, are the best visualization tool for sampled profiling data. The x-axis represents total time proportion. The y-axis is call stack depth. Width of a frame is proportional to time spent in that function and all functions it calls.
"""
Automated flamegraph generation from perf record output.
Requires Brendan Gregg's FlameGraph scripts:
git clone https://github.com/brendangregg/FlameGraph /opt/FlameGraph
"""
import subprocess
import os
from typing import Optional
def generate_flamegraph(
command: list,
output_svg: str = "flamegraph.svg",
flamegraph_dir: str = "/opt/FlameGraph",
sample_freq: int = 997, # prime number avoids aliasing with periodic tasks
events: str = "cycles",
) -> str:
"""
Run perf record on a command and produce a flamegraph SVG.
The workflow has four steps:
1. perf record -g collects samples with call graphs
2. perf script converts binary perf.data to text
3. stackcollapse-perf.pl folds stacks into flamegraph format
4. flamegraph.pl renders the SVG
"""
perf_data = "/tmp/perf_fg.data"
raw_stacks = "/tmp/perf_fg_raw.txt"
folded_stacks = "/tmp/perf_fg_folded.txt"
stackcollapse_pl = os.path.join(flamegraph_dir, "stackcollapse-perf.pl")
flamegraph_pl = os.path.join(flamegraph_dir, "flamegraph.pl")
# Step 1: Record with DWARF call graphs (most accurate for Python/C++ mixed stacks)
subprocess.run([
"perf", "record",
"-F", str(sample_freq),
"-e", events,
"-g",
"--call-graph", "dwarf",
"-o", perf_data,
"--",
] + command, check=True)
# Step 2: Convert to text
with open(raw_stacks, "w") as f:
subprocess.run(["perf", "script", "-i", perf_data], stdout=f, check=True)
# Step 3: Collapse stacks
with open(raw_stacks) as inp, open(folded_stacks, "w") as out:
subprocess.run(["perl", stackcollapse_pl], stdin=inp, stdout=out, check=True)
# Step 4: Render SVG
with open(folded_stacks) as inp, open(output_svg, "w") as out:
subprocess.run([
"perl", flamegraph_pl,
"--title", f"CPU Profile - {events}",
"--width", "1200",
"--colors", "hot",
], stdin=inp, stdout=out, check=True)
print(f"Flamegraph written: {output_svg}")
return output_svg
def generate_diff_flamegraph(
baseline_folded: str,
candidate_folded: str,
output_svg: str = "diff_flamegraph.svg",
flamegraph_dir: str = "/opt/FlameGraph",
) -> str:
"""
Generate a differential flamegraph comparing two profiles.
Red frames = slower in candidate. Blue frames = faster in candidate.
This is the fastest way to see what changed between two builds.
"""
difffolded_pl = os.path.join(flamegraph_dir, "difffolded.pl")
flamegraph_pl = os.path.join(flamegraph_dir, "flamegraph.pl")
diff_folded = "/tmp/perf_diff_folded.txt"
with open(diff_folded, "w") as out:
subprocess.run(
["perl", difffolded_pl, baseline_folded, candidate_folded],
stdout=out, check=True
)
with open(diff_folded) as inp, open(output_svg, "w") as out:
subprocess.run([
"perl", flamegraph_pl,
"--title", "Differential Flamegraph",
"--negate",
"--colors", "rgi",
], stdin=inp, stdout=out, check=True)
return output_svg
eBPF for Production Performance Monitoring
For production systems, attaching perf record is often too invasive - it generates large data files and can disrupt workloads. eBPF (extended Berkeley Packet Filter) provides a safer alternative: small programs that run inside the kernel and can observe performance events with minimal overhead.
# bpftrace one-liners for production use (sub-1% overhead)
# Count L3 cache misses by process name, reported every second
bpftrace -e '
hardware:cache-misses:1000 { @misses[comm] = count(); }
interval:s:1 { print(@misses); clear(@misses); }
'
# Trace TLB misses with stack traces - find WHERE the misses come from
bpftrace -e '
hardware:dtlb-load-misses:10000 { @[ustack] = count(); }
END { print(@, 20); }
' -p $(pgrep python3)
# Monitor IPC per process in real time (5 second windows)
bpftrace -e '
hardware:cpu-cycles:1000000 { @cycles[comm] = count(); }
hardware:instructions:1000000 { @inst[comm] = count(); }
interval:s:5 {
print(@cycles); print(@inst);
clear(@cycles); clear(@inst);
}
'
# Track context switches for a specific process (diagnose scheduling jitter)
bpftrace -e '
tracepoint:sched:sched_switch /comm == "python3"/ {
@switches = count();
@[args->next_comm] = count();
}
interval:s:1 { print(@switches); clear(@switches); }
'
The BCC Python library provides a higher-level interface for more complex eBPF programs:
"""
Using BCC for production performance monitoring.
Install: apt install python3-bpfcc bpfcc-tools
This example counts L3 cache misses per process ID every 5 seconds,
safe to run on production servers.
"""
# Conceptual BCC program structure (requires root or CAP_BPF)
BPF_C_PROGRAM = r"""
#include <uapi/linux/ptrace.h>
BPF_HASH(l3_miss_count, u32, u64);
// Attached to the LLC_MISSES perf event via BCC's attach_perf_event
int count_l3_miss(struct bpf_perf_event_data *ctx) {
u32 pid = bpf_get_current_pid_tgid() >> 32;
u64 zero = 0;
u64 *count = l3_miss_count.lookup_or_init(&pid, &zero);
(*count)++;
return 0;
}
"""
# Python driver (conceptual - requires bcc package)
DRIVER_CODE = """
from bcc import BPF, PerfType, PerfHWConfig
import time
b = BPF(text=BPF_C_PROGRAM)
# Attach to LLC_MISSES hardware event, sampling every 100 misses
b.attach_perf_event(
ev_type=PerfType.HARDWARE,
ev_config=PerfHWConfig.CACHE_MISSES,
fn_name="count_l3_miss",
sample_period=100,
)
while True:
time.sleep(5)
print("L3 cache misses per PID (last 5 seconds):")
for k, v in b["l3_miss_count"].items():
print(f" PID {k.value}: {v.value:,} misses")
b["l3_miss_count"].clear()
"""
pmu-tools and the Top-Down Analysis Method
Intel's pmu-tools (particularly ocperf and toplev) provide access to the full event catalog and implement the Top-Down Microarchitecture Analysis Method:
# Install pmu-tools
git clone https://github.com/andikleen/pmu-tools /opt/pmu-tools
# ocperf: use Intel's full event database with human-readable names
/opt/pmu-tools/ocperf stat -e cycles,instructions,\
CYCLE_ACTIVITY.STALLS_L3_MISS,\
CYCLE_ACTIVITY.STALLS_MEM_ANY,\
MEM_LOAD_RETIRED.L3_MISS,\
FRONTEND_RETIRED.LATENCY_GE_16 \
./your_program
# toplev Level 1: coarse bottleneck classification
/opt/pmu-tools/toplev.py -l1 ./your_program
# Sample Level 1 output:
# FE Frontend_Bound: 12.3%
# BAD Bad_Speculation: 4.8%
# RET Retiring: 32.1%
# BE Backend_Bound: 50.8% <-- dominant bottleneck
# toplev Level 2: break down the Backend bottleneck
/opt/pmu-tools/toplev.py -l2 ./your_program
# BE/Mem Memory_Bound: 43.5% <-- most of backend is memory
# BE/Core Core_Bound: 7.3%
# toplev Level 3: precise memory subsystem breakdown
/opt/pmu-tools/toplev.py -l3 ./your_program
# BE/Mem/L1 L1_Bound: 2.1%
# BE/Mem/L2 L2_Bound: 3.4%
# BE/Mem/L3 L3_Bound: 11.2%
# BE/Mem/DRAM DRAM_Bound: 26.8% <-- fix THIS
The four top-level categories map directly to optimization strategies:
The Roofline Model - Connecting Counters to Theoretical Limits
The roofline model (Williams, Waterman, Patterson, 2009) gives a framework for interpreting counter data in terms of hardware limits.
Where arithmetic intensity (AI) is FLOP count divided by bytes transferred from memory:
"""
Roofline analysis from perf counters.
Computes arithmetic intensity and compares to hardware limits.
"""
import subprocess
import re
def measure_roofline_inputs(command: list) -> dict:
"""
Measure FP operations and DRAM traffic to compute arithmetic intensity.
Events are Intel Skylake-X / Cascade Lake specific.
"""
perf_cmd = [
"perf", "stat",
"-e", ",".join([
# FP operations by vector width
"fp_arith_inst_retired.scalar_single", # 1 float/inst
"fp_arith_inst_retired.128b_packed_single", # 4 floats/inst (SSE)
"fp_arith_inst_retired.256b_packed_single", # 8 floats/inst (AVX)
"fp_arith_inst_retired.512b_packed_single", # 16 floats/inst (AVX-512)
# DRAM traffic (L3 miss = 64-byte cache line from DRAM)
"mem_load_retired.l3_miss",
"l2_lines_out.non_silent", # writebacks to DRAM
"cycles",
]),
"--",
] + command
result = subprocess.run(perf_cmd, capture_output=True, text=True)
output = result.stderr
def extract(pattern: str) -> int:
m = re.search(pattern, output)
return int(m.group(1).replace(",", "")) if m else 0
scalar = extract(r"([\d,]+)\s+fp_arith_inst_retired.scalar_single")
sse = extract(r"([\d,]+)\s+fp_arith_inst_retired.128b_packed_single")
avx = extract(r"([\d,]+)\s+fp_arith_inst_retired.256b_packed_single")
avx512 = extract(r"([\d,]+)\s+fp_arith_inst_retired.512b_packed_single")
total_flops = scalar * 1 + sse * 4 + avx * 8 + avx512 * 16
l3_misses = extract(r"([\d,]+)\s+mem_load_retired.l3_miss")
writebacks = extract(r"([\d,]+)\s+l2_lines_out.non_silent")
CACHE_LINE = 64
dram_bytes = (l3_misses + writebacks) * CACHE_LINE
return {
"total_flops": total_flops,
"dram_bytes": dram_bytes,
"ai": total_flops / dram_bytes if dram_bytes > 0 else 0.0,
"avx512_pct": avx512 * 16 / total_flops * 100 if total_flops > 0 else 0.0,
"scalar_pct": scalar / total_flops * 100 if total_flops > 0 else 0.0,
}
def print_roofline_diagnosis(metrics: dict,
peak_gflops: float = 1000.0,
peak_bw_gbps: float = 50.0):
"""Print roofline diagnosis with actionable guidance."""
ai = metrics["ai"]
ridge_point = peak_gflops / peak_bw_gbps
print(f"\nRoofline Analysis:")
print(f" Arithmetic Intensity: {ai:.2f} FLOP/byte")
print(f" Ridge Point: {ridge_point:.2f} FLOP/byte")
print(f" AVX-512 usage: {metrics['avx512_pct']:.1f}%")
print(f" Scalar usage: {metrics['scalar_pct']:.1f}%")
if ai < ridge_point:
ceiling = ai * peak_bw_gbps
print(f"\n STATUS: MEMORY BOUND")
print(f" Performance ceiling: {ceiling:.0f} GFLOPs/s (memory bandwidth limited)")
print(f" Actions: increase data reuse (block for L2), compress weights,")
print(f" use fused ops to reduce DRAM traffic")
else:
print(f"\n STATUS: COMPUTE BOUND")
print(f" Performance ceiling: {peak_gflops:.0f} GFLOPs/s (FP throughput limited)")
print(f" Actions: vectorize with AVX-512 (currently {metrics['avx512_pct']:.0f}%),")
print(f" reduce FP data dependencies, use FMA instructions")
Production Engineering Notes
Establishing Counter Baselines in CI
The most valuable use of perf counters is systematic regression prevention. Every merge should run a benchmark and compare against the baseline:
# .github/workflows/perf-regression.yml
name: Performance Regression Check
on: [pull_request]
jobs:
perf-check:
runs-on: ubuntu-latest-4-cores
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- name: Build and benchmark baseline
run: |
git stash
cmake -B build-base -DCMAKE_BUILD_TYPE=Release && cmake --build build-base -j4
perf stat -e cycles,instructions,cache-misses,mem_load_retired.l3_miss \
-r 10 -x, ./build-base/bench 2> baseline_counters.csv
- name: Build and benchmark candidate
run: |
git stash pop
cmake -B build-pr -DCMAKE_BUILD_TYPE=Release && cmake --build build-pr -j4
perf stat -e cycles,instructions,cache-misses,mem_load_retired.l3_miss \
-r 10 -x, ./build-pr/bench 2> candidate_counters.csv
- name: Compare and fail on regression
run: python scripts/perf_regression_check.py \
--baseline baseline_counters.csv \
--candidate candidate_counters.csv \
--max-ipc-drop 5 \
--max-latency-increase 5
Counter Multiplexing and Measurement Error
Modern CPUs have 4-8 PMC registers but hundreds of possible events. When you request more events than available counters, the kernel time-multiplexes them. The perf stat output shows a percentage suffix indicating how much time each event was actually counted:
# Multiplexing warning example:
# 45,823,451,293 cycles ( 62.45% ) <-- only counted 62% of the time
# 18,329,481,023 instructions ( 62.45% )
# 892,847,120 cache-misses ( 37.55% ) <-- unreliable
# Fix: use at most 4 events per run (one per PMC register)
perf stat -e cycles,instructions,cache-misses,branch-misses ./program # Group 1
perf stat -e mem_load_retired.l3_miss,dtlb_load_misses.miss_causes_a_walk ./program # Group 2
Noise Reduction for Accurate Benchmarks
For reproducible results, control the main noise sources before collecting counter data:
# 1. Disable CPU frequency scaling (Turbo Boost)
sudo cpupower frequency-set -g performance
# 2. Disable SMT (hyperthreading) - sibling thread shares PMC resources
echo off | sudo tee /sys/devices/system/cpu/smt/control
# 3. Disable ASLR - avoids cache aliasing variation between runs
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
# 4. Pin the benchmark to a specific CPU core (avoid migration overhead)
taskset -c 2 perf stat -r 10 ./benchmark
# 5. Lower perf_event_paranoid for non-root access
sudo sysctl -w kernel.perf_event_paranoid=1
:::warning Perf Counters Require Kernel Permissions
By default, perf_event_paranoid is set to 2 or 3 on many distributions, restricting hardware counter access to root. For development machines, set it to 1: sudo sysctl -w kernel.perf_event_paranoid=1. For production, use a dedicated monitoring process with the CAP_PERFMON capability (added in Linux 5.8) rather than running the monitoring process as root.
:::
:::danger Do Not Run perf record with DWARF Call Graphs on Busy Production Servers
perf record -g --call-graph dwarf generates 2-5 GB of data per minute on a busy server and introduces 5-15% CPU overhead. Running this without testing on a staging replica first can cause OOM kills and latency spikes. For production use, either: (1) use eBPF-based tracing which has sub-1% overhead, or (2) use --call-graph fp (frame pointer, lower overhead) with code compiled with -fno-omit-frame-pointer.
:::
Interview Questions and Answers
Q1: What is IPC and what IPC would you expect from a well-optimized GEMM kernel versus a random-access hash table lookup over a large table?
IPC (Instructions Per Cycle) measures how many instructions the processor completes per clock cycle. A modern out-of-order processor can retire up to 4-6 instructions per cycle when the pipeline is fully fed with work.
A well-optimized GEMM kernel using AVX-512 should achieve IPC of 3.5-4.5. The access pattern is highly predictable (sequential strided), so hardware prefetchers keep caches warm, and back-to-back independent FMA (fused multiply-add) instructions keep the execution units busy.
A random-access hash table lookup over a table larger than L3 cache will show IPC of 0.1-0.4. The CPU issues a load instruction but then waits 200-300 cycles for DRAM to respond. During that wait, it cannot retire useful instructions. The processor spends the vast majority of its time stalled on memory, not executing work.
Q2: Explain what cache-miss rate you would see if your ML model's embedding table grew from 100MB to 600MB on a server with a 32MB L3 cache. How would you diagnose this with perf?
With a 100MB embedding table: roughly 32% fits in L3, so ~68% of embedding lookups miss L3. But since GEMM operations (which are cache-friendly) represent most of the compute, the overall application cache miss rate might be 5-10%.
With a 600MB embedding table: only 5.3% fits in L3. Almost every embedding lookup goes to DRAM. The LLC miss count jumps dramatically and the overall cache miss rate likely exceeds 20-30%.
Diagnosis workflow: first run perf stat -e cycles,instructions,cache-misses,mem_load_retired.l3_miss ./server_benchmark and compare LLC miss counts between old and new model. If the miss count is 5-10x higher, that explains the latency. Then perf record -e mem_load_retired.l3_miss:pp -g ./server with call graph attribution shows the exact code paths responsible, pointing directly to embedding lookup code.
Q3: What is the difference between perf stat and perf record, and when do you use each?
perf stat counts hardware events for an entire program run and reports aggregates. It tells you WHAT is happening but not WHERE in the code. It has very low overhead (reads counters at start and end) and is safe to run repeatedly. Use it first when investigating any performance problem.
perf record samples the instruction pointer at a specified frequency (e.g., 4000 Hz), building a statistical picture of WHERE the CPU spends time. It produces a binary perf.data file that perf report analyzes. Overhead is 1-5% typically. Use it after perf stat identifies a problem category, to locate which specific functions are responsible.
The workflow: perf stat identifies the problem category (cache-bound, branch-bound, compute-bound). Then perf record -e specific_event:pp -g with call graphs locates which code paths are responsible.
Q4: What is PEBS and why does it matter for cache miss attribution?
PEBS (Precise Event-Based Sampling) is an Intel hardware feature that eliminates "instruction pointer skid" in sampled profiling. When a regular sample fires, the CPU records the instruction pointer at interrupt delivery time - which may be 10-50 instructions after the instruction that triggered the event, due to out-of-order execution and interrupt delivery latency. This means the sample is attributed to the wrong instruction.
PEBS hardware buffers the exact IP at the moment the event fires, then delivers it asynchronously. This gives instruction-precise attribution. When sampling on mem_load_retired.l3_miss:pp (the :pp requests precise mode), you get exact attribution of cache misses to specific load instructions.
For ML workloads, this is critical: without PEBS, a cache miss in an embedding lookup might be attributed to surrounding loop control code, sending you in the wrong direction entirely.
Q5: Describe how you would implement an automated perf counter regression detection system for a model serving pipeline.
The system has four components. First, a benchmark harness runs inference on a fixed representative batch with deterministic inputs after every model or code change in CI.
Second, a perf stat wrapper collects 8-10 key metrics: cycles, instructions, IPC, L3 miss count, L3 miss rate, DTLB miss count, branch miss rate, and wall-clock time. Run each benchmark 10 times with -r 10 for stable averages.
Third, a regression detector compares the current run against the stored baseline. Flag when IPC drops more than 5%, L3 miss count increases more than 15%, or wall-clock time increases more than 5%. Store results in a CSV or time-series database to track trends over weeks and months.
Fourth, when a regression is detected, the CI pipeline fails and reports which specific counters regressed. This immediately tells the engineer whether the issue is memory-bound (L3 misses up), branch-bound (branch misses up), or something else - eliminating the guessing phase entirely.
Q6: What is the Top-Down Analysis Method and how does it translate perf counter data into actionable optimization guidance?
The Top-Down Analysis Method, formalized by Intel's Ahmad Yasin, classifies every CPU cycle into four categories and allows you to drill down systematically to find the precise bottleneck.
Level 1: every cycle is either Retiring (useful work), Bad Speculation (wasted on mispredicted branches), Frontend Bound (stalls in instruction fetch/decode), or Backend Bound (stalls in execution or memory).
Each category maps to specific perf events: Backend Bound is computed from stall cycle events minus frontend stalls and bad speculation overhead. The toplev.py tool from pmu-tools automates this calculation across all CPU generations.
Drilling into Backend Bound reveals Memory Bound vs Core Bound. Memory Bound further breaks into L1 Bound, L3 Bound, and DRAM Bound - each pointing to a different fix. For ML workloads, the typical profile is 40-60% Backend Bound with most of that being DRAM Bound (large embedding tables, attention score matrices, activation storage). This points directly to data layout optimization, weight compression, or cache blocking as the right intervention.
