Part 10 of How to Make Your Model Fast (Part 9: Perception, VLMs and Robots | Part 11: Serving and MLOps)
A method for finding out what is actually slow, rather than what you assume is slow. This chapter builds a benchmark you can trust, descends through the five levels of measurement from wall clock to instruction sampling, turns hardware performance counters into achieved bandwidth and achieved FLOPs/s, and uses those two numbers to place a kernel on the roofline. From there it gives a decision tree that maps a measurement to a concrete next action, covers the overheads that live outside the model entirely, and works through two case studies with numbers. You should finish able to take an unfamiliar model on an unfamiliar device and say, with evidence, what is limiting it and what to change first.
Every optimisation I have regretted started the same way. I looked at a model, decided from the shape of it what must be slow, and fixed that. Sometimes I was right. More often I spent a fortnight making a convolution 30% faster and moved end to end latency by 2%, because the convolution was 7% of the frame and the other 93% was sitting in a layout conversion, a delegate handoff and a NumPy loop nobody had touched since the prototype.
This chapter is the method for not doing that. It is deliberately not a tool tour. Tools change every eighteen months; the reasoning does not. We will build a benchmark that survives contact with a real device, descend through the levels of measurement until the bottleneck has nowhere left to hide, turn hardware counters into the only two numbers that really matter (achieved FLOPs/s and achieved bandwidth), and use those two numbers to decide what to change. Everything here assumes the roofline from Part 1 and the kernel mechanics from Part 3.
The first rule is that a performance number with no error bar and no reproduction recipe is a rumour. I have watched entire sprints get spent on rumours. Before you profile anything, you need a benchmark whose output you would be willing to defend in a review.
A benchmark is a measurement instrument, and like any instrument it has systematic and random error. Your job is to drive both down until the signal you care about, usually a 5 to 10% change, is comfortably larger than the noise.
Sources of run to run variance, and the control for each.
| Source | Symptom | Control |
|---|---|---|
| Cold caches, cold pages, lazy allocation | First few runs are much slower | 20 to 100 warmup iterations, discarded |
| Dynamic frequency scaling | Latency falls over the first second of running | Pin the governor to performance; where you cannot, soak and measure a stable window |
| Thermal throttling | Latency climbs after 30 to 120 seconds | Soak to steady temperature first; if production runs hot, the throttled number is the real number |
| Thread migration | Bimodal distribution, especially on big.LITTLE | Pin with taskset or sched_setaffinity to named cores |
| Other tenants on the machine | Sporadic long tail | Isolate cores, stop daemons, measure on a quiet device |
| Binary and heap layout | 1 to 5% shift between builds with no code change | Repeat across process launches, not only in-process iterations |
| Boost energy budget | Short runs look faster than long runs | Match benchmark duration to the production duty cycle |
Here is the shape of a timing loop I actually trust. The important parts are the warmup, the synchronisation on both sides of the timed region, and the fact that it keeps every sample rather than accumulating a running mean.
import time
import torch
def bench(fn, *, warmup=50, iters=500, device="cuda"):
"""Time fn() properly. Returns median, p99 and min in milliseconds."""
if device == "cuda":
sync = torch.cuda.synchronize
else:
sync = lambda: None
for _ in range(warmup): # caches, autotuners, JIT, clock ramp
fn()
sync()
samples = []
for _ in range(iters):
sync() # drain anything still outstanding
t0 = time.perf_counter_ns()
fn()
sync() # the work is not done until this returns
samples.append(time.perf_counter_ns() - t0)
samples.sort()
n = len(samples)
return {
"median_ms": samples[n // 2] / 1e6,
"p99_ms": samples[min(n - 1, int(0.99 * n))] / 1e6,
"min_ms": samples[0] / 1e6,
}
Two warnings. Per-iteration synchronisation costs 5 to 20 microseconds on a GPU, so if the thing you are timing takes 30 microseconds you are measuring the synchronisation; time a batch of K iterations with one synchronisation at each end instead, accepting that this hides launch latency.
The mean is the wrong summary for latency because latency distributions are right-skewed and the right tail is usually not your code. It is a page fault, a scheduler decision, an interrupt, a garbage collection in the surrounding application. One 200 millisecond outlier in 500 runs of an 8 millisecond kernel shifts the mean by 0.4 milliseconds, which is exactly the size of the improvement you were trying to detect.
Two versions of the same kernel, summarised four ways. All times in milliseconds.
| Summary | Version A | Version B | What it is good for |
|---|---|---|---|
| min | 8.05 | 8.04 | What the hardware can do with zero interference |
| median | 8.2 | 8.3 | A/B comparison: did my change help? |
| mean | 9.6 | 12.4 | Almost nothing |
| p99 | 19.8 | 41.0 | Will this miss a deadline? |
A and B are indistinguishable in median and min. The kernel is the same. What differs is the tail, and if you only reported the mean you would conclude that B is 29% slower and go hunting inside a kernel that did not change. Report the median when you are iterating on a kernel, and the p99 when you are arguing about whether a system meets a frame budget.
Every measurement perturbs the thing measured, and the perturbation grows sharply as you descend the hierarchy.
A sampling profiler at 1 kHz, at 1 to 2 microseconds of handler cost per sample, is about 0.1 to 0.2% overhead and is fine. The same profiler at 100 kHz is 10 to 20% overhead and pollutes the caches it is trying to observe. An instrumentation profiler that wraps every operator costs 1 to 10 microseconds per operator; on a graph of 300 operators running in 8 milliseconds that is 4 to 37% overhead, concentrated entirely on the small operators, which then look disproportionately expensive. Kernel-level GPU profilers that replay each kernel to collect full counter sets give accurate per-kernel numbers and a fictional timeline, because they serialise work that was originally concurrent.
My working rule: always keep one clean, uninstrumented wall clock number as ground truth, and at every level check that the instrumented total is within about 10% of it. If the profiler says the model takes 14 milliseconds and the stopwatch says 8, you are no longer studying the model. You are studying the profiler.
Takeaway: Fix the clocks, pin the threads, warm up, soak, keep every sample, and report median and p99. A change smaller than your measurement noise is not a change, and a profiler heavy enough to distort the total is not evidence.
Profiling is a search, and the efficient way to search is to narrow by an order of magnitude at each step rather than jumping straight to the finest instrument. There are five levels, each answering a different question.
The five levels of measurement.
| Level | The question it answers | Typical overhead | Blind to |
|---|---|---|---|
| 1. End to end wall clock | Do we meet the budget, and by how much? | ~0% | Everything internal |
| 2. Per stage timers | Which stage of the pipeline? | <1% if coarse | Detail inside a stage, asynchronous work |
| 3. Framework operator profile | Which operator, and how many times? | 1 to 30% | Why that operator is slow |
| 4. Hardware counters | Compute bound, bandwidth bound or stalled? | 0 to 5% | Which line of source |
| 5. Instruction level sampling | Which instruction or source line? | 1 to 20% | Causality on an out-of-order core |
Level 1 is the only number anyone outside the team cares about, and it is the only one you are allowed to claim an improvement in. Level 2 is where most bottlenecks are actually found, and it is criminally underused: ten perf_counter calls around the stages of a pipeline will, more often than not, tell you that the model is a minority of the frame. Level 3 tells you which operator to look at, and importantly how many times each operator was called, which is how you discover that a “cheap” reshape is running 480 times per frame. Level 4 tells you the mechanism. Level 5 tells you the line.
The trap at level 2 is asynchronous execution. A timer around a GPU kernel launch or an accelerator submit measures the time to enqueue the work, not to do it, and that is usually about 5 microseconds regardless of how much work you enqueued. On a GPU you need device events; on an NPU or DSP you need the delegate’s own timestamps; on any asynchronous system, a wall clock measurement without a synchronisation is a measurement of your own optimism.
Descend one level only when the level above has narrowed the search. Going straight to instruction sampling on a 40 millisecond frame gives you a flat profile with 200 entries and no story.
Performance counters are a small set of hardware registers that count architectural events. There are usually only four to eight programmable ones per core, so asking for twelve events means the kernel time-multiplexes them and scales the results, which introduces error.
The counters I actually use, and what a bad value means.
| Counter or ratio | How to read it | Roughly healthy | A bad value suggests |
|---|---|---|---|
| instructions / cycles (IPC) | Issue efficiency | 1.5 to 3 on a 4-wide core | Dependency chains or stalls |
| stalled-cycles-backend / cycles | Waiting on execution or memory | below 40% | Memory pressure, long-latency ops |
| stalled-cycles-frontend / cycles | Waiting on instruction supply | below 10% | I-cache misses, interpreters, huge unrolled code |
| L1-dcache-load-misses / L1-dcache-loads | L1 miss rate | below 5% for blocked kernels | Wrong tile size or wrong layout |
| LLC-load-misses | DRAM read traffic in cache lines | Compare against compulsory traffic | Poor reuse, or unavoidable streaming |
| dTLB-load-misses | Page walk pressure | Near zero | Large strided working set; consider huge pages |
| branch-misses / instructions | Misprediction rate | below 1% | Data-dependent control flow in an inner loop |
| FLOP-producing ops / instructions | Vectorisation | 8 for fp32 on 128-bit SIMD | Scalar code, or a failed vectorisation |
That last row is the one people forget, and it is often the fastest diagnosis in the whole chapter. Divide the algorithmic FLOP count by the retired instruction count. If a fp32 kernel on 128-bit SIMD is producing fewer than about 3 FLOPs per instruction, the compiler did not vectorise your loop and nothing else you measure matters yet.
Here is the invocation I start with on an Arm or x86 Linux target.
#!/usr/bin/env bash
# Fix the clocks, pin to one core, read the counters that diagnose.
sudo cpupower frequency-set -g performance > /dev/null
# Pass 1: issue efficiency and stalls.
perf stat -r 5 -e cycles,instructions,\
stalled-cycles-frontend,stalled-cycles-backend,branch-misses \
-- taskset -c 3 ./bench --iters 500 --warmup 50
# Pass 2: the memory hierarchy. Split from pass 1 so that no
# event gets multiplexed onto a shared counter.
perf stat -r 5 -e L1-dcache-loads,L1-dcache-load-misses,\
LLC-loads,LLC-load-misses,dTLB-load-misses \
-- taskset -c 3 ./bench --iters 500 --warmup 50
# What the generic names map to on this core, plus the raw
# microarchitectural events perf does not alias.
perf list hw cache
perf list | grep -i -E 'stall|mem_access|bus_cycles'
The generic event names are aliases that map to different underlying PMU events on different microarchitectures, and on some cores they do not map to anything sensible at all. When a number looks impossible, check perf list and switch to the raw architectural event.
Achieved DRAM bandwidth is the single most useful derived number in profiling, and you compute it from last-level cache misses:
\[B_{\text{achieved}} = \frac{(\text{LLC-load-misses} + \text{LLC-store-misses}) \times \text{line size}}{t}\]Worked example. A single-threaded depthwise convolution runs 200 iterations in 0.98 seconds and reports 2.10e7 LLC load misses. With 64 byte lines:
\[2.10 \times 10^{7} \times 64 = 1.344 \times 10^{9}\ \text{bytes} \Rightarrow \frac{1.344\ \text{GB}}{0.98\ \text{s}} = 1.37\ \text{GB/s}\]Now compare that to what the algorithm must move. The tensor is 1x64x112x112 fp32, so 802,816 elements, 3.211 MB in and 3.211 MB out per iteration. Because the cache is write-allocate, storing the output also generates read traffic, so expected reads are about \(2 \times 3.211\ \text{MB} \times 200 = 1.284\ \text{GB}\). Measured 1.344 GB is 4.7% above compulsory. The memory system is behaving perfectly. Whatever is slow here is not the cache hierarchy.
There are two ways to get FLOPs/s. You can count floating point operations with the PMU, using events such as fp_arith_inst_retired.* on x86 weighted by vector width, or FP_SCALE_OPS_SPEC and FP_FIXED_OPS_SPEC on recent Arm cores.
Or you compute it analytically from the shapes, which is what I do almost every time. For a GEMM of shape \(M \times K\) by \(K \times N\) it is \(2MKN\); for a convolution it is \(2 \cdot C_{in} \cdot C_{out} \cdot K_h \cdot K_w \cdot H_{out} \cdot W_{out}\). The analytic figure counts useful work, which is exactly the numerator you want. If the hardware retired three times that many operations, that is a fact about your implementation, not about the problem.
With achieved bandwidth and achieved FLOPs/s you can place any kernel on the roofline from Part 1. Here is the illustrative device I will use for the rest of the chapter. It is a plausible mid-range mobile class part; every number is derived from stated parameters rather than measured on anything real.
Illustrative target device.
| Parameter | Value |
|---|---|
| Cores | 4, at 2.0 GHz |
| SIMD | 128-bit, 2 floating point pipes per core |
| fp32 per core per cycle | 2 pipes x 4 lanes x 2 (FMA) = 16 FLOP |
| fp32 peak, one core | 16 x 2.0e9 = 32 GFLOP/s |
| fp32 peak, four cores | 128 GFLOP/s |
| DRAM | 32-bit LPDDR4X-4266: 4266e6 x 4 B = 17.1 GB/s theoretical |
| DRAM, stream-achievable | about 13 GB/s (76% of theoretical) |
| Ridge point, theoretical | 128 / 17.1 = 7.5 FLOP/byte |
| Ridge point, achievable | 128 / 13 = 9.8 FLOP/byte |
Worked example. A decoder projection at batch 1: a 4096 by 4096 fp16 weight matrix times a vector. FLOPs are \(2 \times 4096 \times 4096 = 33.55\) MFLOP. Bytes are dominated by the weights, \(4096 \times 4096 \times 2 = 33.55\) MB. So the arithmetic intensity is exactly
\[I = \frac{33.55 \times 10^{6}}{33.55 \times 10^{6}} = 1.0\ \text{FLOP/byte}\]which is 1/9.8 of the ridge point. This kernel is bandwidth bound by nearly an order of magnitude, and no amount of clever inner-loop work will change that. The attainable rate is \(1.0 \times 13 = 13\) GFLOP/s, which is 10% of the machine’s compute peak, and the ideal time is \(33.55\ \text{MB} / 13\ \text{GB/s} = 2.58\) ms. Measure it and you get 4.6 ms, so achieved bandwidth is \(33.55\ \text{MB} / 4.6\ \text{ms} = 7.29\) GB/s, or 56% of achievable. There is a 1.8x sitting in the memory access pattern before you change a single thing about the model.
Takeaway: Two derived numbers settle almost every argument: achieved FLOPs/s as a fraction of compute peak, and achieved bandwidth as a fraction of stream-achievable peak. Compute the first analytically from shapes and the second from cache-miss counters, then place the kernel on the roofline.
Call those two fractions \(f_c\) and \(f_b\). The diagnosis is then mechanical:
If you are at 70 to 85% of compute peak, congratulations, the implementation is good. Real dense kernels rarely exceed about 85% because of prologues, edges and instruction mix. The remaining 15% is typically a week of work for a 5% gain, and it is almost never the best week available to you.
The only way out is less arithmetic or cheaper arithmetic. In descending order of what has worked for me: drop precision, since int8 typically buys 2 to 4x on the same silicon (Part 5); use a cheaper algorithm, such as Winograd for small-kernel convolutions or a low-rank factorisation of a large projection; remove parameters by structured pruning or distillation (Part 6); shrink the architecture, the largest single lever and the one nobody wants to pull; and skip work at runtime with early exit, cascades or token pruning.
If you are at 70% or more of stream-achievable bandwidth, the kernel is running about as fast as the memory system allows, and the only fix is to move fewer bytes.
Fusion is the biggest lever and the arithmetic is simple. A chain of \(k\) elementwise operations over a tensor of \(N\) bytes, each materialised to memory, moves roughly \(2kN\) bytes. Fused into one pass it moves \(2N\). That is a saving of \(2(k-1)N\), and for a chain of four operations it is a 4x reduction in traffic on those layers. After that: change the layout so the fastest-varying axis is the one you vectorise over, usually NHWC on a CPU; drop precision, since int8 weights halve the traffic of every weight-bound layer; block for cache reuse so a tile is loaded once and used many times; and batch, the only way to amortise weight traffic when the weights dominate, as they do for every matrix-vector product in a decoder.
This is the most common branch in practice and the one people most often misdiagnose as “the model is just slow”. If you are at 9% of compute peak and 20% of bandwidth peak, you are not limited by any resource. You are limited by waiting.
Latency bound sub-causes, the tell for each, and what to do.
| Sub-cause | The tell | First action |
|---|---|---|
| Dependency stalls | High backend stalls, low L1 miss rate, IPC below 1 | Unroll, use multiple accumulators, software pipeline |
| Failed vectorisation | FLOPs per instruction near 2 instead of 8 | Fix the layout, check compiler remarks, write an intrinsic kernel |
| Synchronisation | Wall clock far exceeds the sum of kernel times; gaps aligned to barriers | Remove syncs, overlap with async copies, double buffer |
| Kernels too small | Hundreds of operators each under 20 microseconds | Fuse, batch, capture the graph |
| Poor parallelism | Scaling flat beyond two threads; one thread at 100% | Check the partition axis, load imbalance, false sharing |
| Runtime overhead | Profile shows large time outside any operator | See the next section |
| Instruction supply | High frontend stalls | Compile the graph; stop interpreting it |
The reason this branch matters so much on edge and batch-1 workloads is arithmetic. A frame that issues 300 operators with a 20 microsecond dispatch cost each has spent 6 milliseconds before any arithmetic happens. If the tensors are small, dispatch can exceed compute by a factor of five and every counter will look idle, because the machine genuinely was idle.
Takeaway: If neither compute nor bandwidth is near peak, stop optimising the kernel. You are latency bound, and the fix is structural: fuse, batch, remove synchronisation, fix parallelism, or get out of the interpreter.
The cheapest test in profiling is this: sum the per-operator times your framework reports, and compare to the wall clock. If operators sum to 6.2 milliseconds and the frame takes 11.4, then 5.2 milliseconds, 46% of your budget, is in something no operator profiler will ever show you.
Overheads outside the operators. Figures are illustrative orders of magnitude from common practice on small-batch workloads, not measurements of any particular system.
| Overhead | What it is | Typical scale at batch 1 |
|---|---|---|
| Python interpreter | Per-operator call through the binding layer | 5 to 50 microseconds per operator |
| Framework dispatch | Type and device dispatch, shape inference, kernel selection | 1 to 10 microseconds per operator |
| Tensor allocation | malloc or a caching allocator, possibly under a lock | 1 to 20 microseconds per tensor |
| Layout conversion | NCHW to NHWC, packing, padding to a tile multiple | 5 to 15% of frame time when it appears |
| Precision conversion | fp32 to int8 quantise and dequantise at stage boundaries | Proportional to tensor bytes |
| Delegate handoff | Buffer copy, alignment fixups, driver call per subgraph | 50 microseconds to several milliseconds each |
| Synchronisation | A full pipeline drain at every barrier | Whatever was in flight |
| First inference | Lazy weight load, JIT, autotuning, page faults | 10x to 1000x steady state |
Only a timeline trace shows these honestly. An operator profile is a bar chart of operators; by construction it cannot show the gaps between them. On a trace of a small model driven from Python the shape I expect is a thin spike of real work followed by a wide flat band of dispatch, and the gaps between spikes are the bottleneck.
Two of these deserve special mention. Delegate handoffs are the reason an “8 millisecond NPU model” can take 21 milliseconds: if three operators in the middle of your graph are unsupported, the runtime partitions the graph around them, and you pay a handoff and a buffer copy at every boundary. Always check the partition count, not just the operator support list. And first inference cost is not a benchmarking artefact you are allowed to discard if your product does one inference when a user presses a button. In that case, the first inference is the latency, and the fix is to pre-warm at startup.
Takeaway: Sum the operator times and compare to the wall clock. The gap is your overhead budget, and at batch 1 it is routinely 30 to 60% of the frame. No kernel optimisation touches it.
Sampling profilers, of which perf is the reference implementation, interrupt the program at a fixed rate and record where it was. They are statistical, so they answer “where does time go” with an error bar that shrinks as the square root of the sample count, and they are almost free at sensible rates. They are the right first tool for any CPU workload, they need no code changes, and with perf record -g they give you a call graph. They cannot tell you about time spent waiting on another device, because a stalled thread is not sampled.
Timeline and trace viewers record timestamped begin and end events and draw them on a shared time axis. The PyTorch profiler emitting Chrome trace JSON, loaded into Perfetto, is the workhorse here.
Vendor tools see hardware the generic tools cannot. Arm Streamline correlates PMU counters across cores with the interconnect and memory controller counters and the GPU, which is how you get real bandwidth rather than an LLC-miss estimate. NVIDIA Nsight Systems is the system-level timeline showing CPU, CUDA launches, copies and kernels together, and it is where you find the missing overlap. Nsight Compute is the per-kernel microscope: full counter sets, occupancy, a built-in roofline. It replays kernels to collect everything, so it perturbs the timeline heavily. Use Systems to find the kernel and Compute to understand it, never the other way round.
Framework operator profilers are built into the runtime and are the fastest way to get a ranked operator list on a device you cannot attach anything else to. TFLite’s benchmark_model with --enable_op_profiling gives per-operator times plus, critically, the delegate partitioning summary.enable_profiling writes JSON with per-node times and execution providers, which is how you spot nodes silently assigned to the CPU provider. Their common weakness is attributing everything to an operator, so runtime overhead is smeared across operators rather than shown as itself.
Ask a trace four questions, in order.
Where are the gaps? A gap is a period where nothing is running. Gaps are the highest-value finding in any trace, because they are time you are paying for and getting nothing. Trace the gap back to what ended just before it and what was waiting.
What is serialised that could be concurrent? Preprocessing on the CPU while the accelerator is idle, then the accelerator running while the CPU is idle, is a classic: the frame takes the sum when it could take the maximum. Pipelining across frames turns a 14 plus 9 millisecond serial chain into a 14 millisecond one.
What is on the critical path? The critical path is the longest chain of dependent work from input to output. Speeding up anything off it changes nothing at all, which is why “I made that operator 3x faster and latency did not move” happens so often. Identify the chain before you optimise anything in it.
Is this timeline busy or fast? Those are different. A timeline with no gaps and 100% utilisation can be a system recomputing the same thing four times, spinning on a lock, or converting layouts. Utilisation is a measure of activity, not of progress. The only measure of progress is useful work per second, which is exactly what the achieved FLOPs/s and achieved bandwidth numbers give you.
Both of these are composites. They are patterns I have seen repeatedly rather than any single incident, and every number is illustrative and internally consistent with the device table above rather than a measurement of a real product.
A segmentation network on the four-core device above. Budget 25 milliseconds per frame, measured 41.7. The model had already been quantisation-aware trained and the team’s assumption was that the convolutions needed a better kernel.
Level 3 said otherwise:
Frame breakdown before and after, in milliseconds.
| Stage | Before | After | What changed |
|---|---|---|---|
| Convolutions | 25.4 | 20.1 | NHWC lets the inner loop vectorise over channels |
| Batch normalisation | 4.1 | 0.0 | Folded into the convolution weights at compile time |
| ReLU | 2.8 | 0.0 | Fused into the convolution epilogue |
| Residual add | 2.4 | 0.0 | Fused into the convolution epilogue |
| Layout conversions | 5.2 | 0.0 | Whole graph forced to NHWC |
| Resize, pooling, other | 1.8 | 1.8 | Untouched |
| Total | 41.7 | 21.9 | 1.90x |
The diagnosis came from two pieces of arithmetic. First, the elementwise chain. Each of the four residual blocks operated on a 1x64x128x128 fp32 tensor, exactly 4.194 MB. Batch normalisation reads and writes it (8.39 MB), ReLU reads and writes it (8.39 MB), and the add reads two tensors and writes one (12.58 MB), giving 29.4 MB per block and 117.4 MB per frame. Measured time for those layers was 9.3 milliseconds, so achieved bandwidth was \(117.4\ \text{MB} / 9.3\ \text{ms} = 12.6\) GB/s, which is 97% of the 13 GB/s the device can stream. Those kernels were already perfect. There was no tuning to do, only traffic to delete, and fusion deletes it.
Second, the layout conversions. Six conversions of 4.194 MB, read and written, is 50.3 MB, which at 13 GB/s should take 3.9 milliseconds. It took 5.2, an achieved 9.7 GB/s, and dTLB-load-misses in those regions was roughly 40 times the rate seen in the convolutions. That is the signature of a strided transpose walking pages.
The convolutions themselves were 1.9 GFLOP per frame, so 25.4 milliseconds is \(1.9 / 0.0254 = 74.8\) GFLOP/s, 58% of the 128 GFLOP/s peak. After the layout change, 20.1 milliseconds is 94.5 GFLOP/s, or 74% of peak. That is the compute-bound branch behaving exactly as advertised: a good but not great kernel got better with a layout that suits the SIMD unit, and the remaining 26% is not worth chasing. The whole fix was compiler configuration and fusion patterns, described in Part 4. Nobody wrote a new convolution kernel.
A quantised object detector on a mobile SoC with an NPU delegate. The model card said 8 milliseconds. Camera to bounding box measured 61 milliseconds, 16 frames per second against a 30 fps requirement. The instinct in the room was to shrink the model.
A timeline trace disagreed, comprehensively.
End to end breakdown, in milliseconds.
| Stage | Before | After | Fix |
|---|---|---|---|
| YUV420 to RGB conversion (NumPy) | 14.0 | 2.5 | Colour convert and resize fused into one GPU pass |
| Bilinear resize (CPU, framework op) | 9.0 | (included above) | |
| fp32 to int8 quantise | 3.5 | 0.0 | Folded into the conversion pass |
| Delegate handoff and buffer copies | 6.0 | 1.0 | One partition instead of seven |
| Model execution | 21.0 | 9.0 | Three fallback operators replaced |
| Non-maximum suppression (Python) | 7.0 | 1.2 | Moved to C++, score threshold applied first |
| Other | 0.5 | 0.5 | |
| Total | 61.0 | 14.2 | 4.30x, 70 fps |
Two findings mattered. The first was that the model was never running in 8 milliseconds. The delegate supported most of the graph but not a five-way concatenation, a nearest-neighbour resize and an argmax, so the runtime split the graph into seven partitions with six handoffs, each involving a driver call and a buffer copy. The 8 millisecond figure was the sum of the NPU segments; the 21 milliseconds was reality. Replacing those three operators with supported equivalents, which did not change the network’s outputs, collapsed it to one partition.
The second was that 26.5 milliseconds, 43% of the frame, was preprocessing, and 14 of those were a colour conversion written in NumPy that the pipeline inherited from the prototype. It was doing per-pixel arithmetic in an interpreted loop over a 1920x1080 frame, which is 2.07 million pixels; at even 7 nanoseconds per pixel that is 14.5 milliseconds, and the arithmetic was never in doubt once someone looked. It went to the image signal processor, where colour conversion is free.
The model weights were never touched. This is the single most common shape of performance work I do on a deployed vision system, and it is why Part 7 spends as long on the pipeline as on the network.
performance, threads pinned, other tenants stopped, device soaked to thermal steady state.Takeaway: The fastest wins are almost never inside the model. Check the pipeline, the partitioning and the overheads before you touch a kernel, and change one thing at a time so you can attribute the result.
Use the illustrative device from the table above: 4 cores at 2.0 GHz, 32 GFLOP/s fp32 per core, 128 GFLOP/s aggregate, 17.1 GB/s theoretical DRAM and about 13 GB/s stream-achievable across all cores, with a single thread reaching roughly 6 GB/s.
1. A single-threaded 3x3 depthwise convolution over a 1x64x112x112 fp32 tensor runs 200 iterations in 0.98 seconds. perf stat reports 1.960e9 cycles, 2.352e9 instructions and 2.10e7 LLC load misses with 64 byte lines. Compute IPC, achieved FLOPs/s, achieved bandwidth and FLOPs per instruction. Diagnose it and give the fix.
IPC is \(2.352 \times 10^{9} / 1.960 \times 10^{9} = 1.20\), which on a 4-wide core is 30% of issue width.
FLOPs per iteration are \(2 \times 9 \times 64 \times 112 \times 112 = 14.45\) MFLOP, so 2.890 GFLOP over 200 iterations. Achieved is \(2.890 / 0.98 = 2.95\) GFLOP/s, which is 9.2% of the 32 GFLOP/s single-core peak. So \(f_c = 0.09\).
Achieved bandwidth is \(2.10 \times 10^{7} \times 64 = 1.344\) GB over 0.98 s, so 1.37 GB/s, about 23% of the 6 GB/s a single thread can reach. So \(f_b = 0.23\). Note also that the compulsory traffic, counting read-for-ownership on the output, is \(2 \times 3.211\ \text{MB} \times 200 = 1.284\) GB, so the measured traffic is only 4.7% above the minimum. The cache hierarchy is doing its job.
Both fractions are below 0.4, so this is latency bound. The decisive number is FLOPs per instruction: \(2.890 \times 10^{9} / 2.352 \times 10^{9} = 1.23\). A vectorised fp32 FMLA on 128-bit SIMD produces 8 FLOP per instruction; a scalar FMADD produces 2. At 1.23 the loop is running scalar, with the balance of instructions going on loads, stores and address arithmetic.
The fix is vectorisation, and the layout is why it failed. In NCHW the contiguous axis is W, so a 3x3 stencil gives the compiler overlapping unaligned windows. In NHWC the contiguous axis is C, and depthwise convolution is trivially vectorisable across channels because each channel is independent. Convert and expect close to 4x before any other tuning.
2. The 4096 by 4096 fp16 matrix-vector product measured 4.6 ms. Compute arithmetic intensity, the roofline attainable rate, the ideal time and the roofline efficiency. Then compare two proposals: quantise the weights to int8, or fix the access pattern to reach 85% of achievable bandwidth. Which is worth more, and what does doing both give?
FLOPs are \(2 \times 4096 \times 4096 = 33.55\) MFLOP. Weight bytes in fp16 are \(4096 \times 4096 \times 2 = 33.55\) MB, so \(I = 1.0\) FLOP/byte, far below the 9.8 FLOP/byte ridge point. Firmly bandwidth bound.
Attainable rate is \(1.0 \times 13 = 13\) GFLOP/s. Ideal time is \(33.55\ \text{MB} / 13\ \text{GB/s} = 2.58\) ms. Measured 4.6 ms gives achieved bandwidth \(33.55 / 4.6 = 7.29\) GB/s, which is 56% of achievable. Roofline efficiency is \(2.58 / 4.6 = 56\%\).
Proposal A, int8 weights: bytes halve to 16.78 MB. At the current 7.29 GB/s efficiency that is 2.30 ms, a 2.00x improvement.
Proposal B, fix the access pattern to 85% of 13 GB/s, so 11.05 GB/s: \(33.55 / 11.05 = 3.04\) ms, a 1.51x improvement.
Both: \(16.78\ \text{MB} / 11.05\ \text{GB/s} = 1.52\) ms, a 3.03x improvement, which is very nearly the product 2.00 x 1.51 = 3.02 because the two levers are independent, one acting on the numerator and one on the denominator.
Quantisation is worth more, but it costs accuracy work and validation; fixing the access pattern costs neither. I do B first, because it is free and it makes the benefit of A measurable rather than confounded.
3. The detector from case study two runs at 61 ms per frame with 21 ms in the model. (a) If the model became infinitely fast, what frame rate results? (b) What end to end speedup does a 2x faster model give? (c) If the model stays at 21 ms, how much must everything else shrink to hit 30 fps?
(a) Remove all 21 ms and 40 ms remain, so 25.0 fps. The 30 fps target is unreachable by model optimisation alone, no matter how good the model gets. This one calculation would have saved the team a month.
(b) A 2x faster model saves 10.5 ms, giving 50.5 ms. Speedup is \(61 / 50.5 = 1.21\)x, or 19.8 fps. A hard-won 2x on the model buys 21% end to end. This is Amdahl’s law doing what it always does.
(c) 30 fps means 33.3 ms per frame. With 21 ms fixed, everything else must fit in 12.3 ms, down from 40 ms, which is a 3.25x reduction. That is the actual engineering target, and stating it that way immediately points at the 26.5 ms of preprocessing.
4. A kernel runs for 180 microseconds per iteration at 500 iterations per second. You profile with perf record at 4 kHz. (a) How many samples per second land in that kernel? (b) How long must you record for a 5% relative error on its share, and for 1%? (c) If the sample handler costs 1.5 microseconds, what is the overhead at 4 kHz and at 100 kHz?
(a) The kernel occupies \(500 \times 180 \times 10^{-6} = 0.09\) seconds of every second, a 9% duty cycle. At 4000 samples per second, \(4000 \times 0.09 = 360\) samples per second land in it.
(b) Sample counts are approximately Poisson, so the relative standard error is \(1/\sqrt{N}\). For 5% you need \(N = 400\), which takes \(400 / 360 = 1.11\) seconds. For 1% you need \(N = 10{,}000\), which takes \(10{,}000 / 360 = 27.8\) seconds.
This is why short profiling runs produce unstable rankings. Two operators each holding 4% of the time, recorded for two seconds, have roughly 320 samples each and about 5.6% relative error, so their true order could easily be reversed. Record longer before you believe a ranking.
(c) At 4 kHz: \(4000 \times 1.5 \times 10^{-6} = 6\) ms per second, or 0.6%. Negligible. At 100 kHz: \(100{,}000 \times 1.5 \times 10^{-6} = 0.15\) s per second, or 15%, and that ignores the cache and branch predictor pollution from entering the handler 100,000 times a second, which typically makes the true cost worse. 100 kHz sampling of a cache-sensitive kernel does not measure that kernel.
5. First inference takes 340 ms; steady state is 12 ms. (a) After how many inferences does the running average fall below 15 ms? (b) The product requirement is a p99 of 20 ms and the application does exactly one inference per user session. What do you do?
(a) The average after \(n\) inferences is \((340 + 12(n-1))/n\). Setting that below 15:
\[340 + 12n - 12 \leq 15n \Rightarrow 328 \leq 3n \Rightarrow n \geq 109.3\]so 110 inferences. If your benchmark runs 500 iterations and reports a mean, the first inference is still adding 0.66 ms to the reported figure, which is enough to mask a real 5% regression.
(b) Every inference is a first inference, so the p99 is 340 ms and you are 17x over budget, with no averaging available to rescue you. Move the cost off the measured path: load and warm the model at application startup, keep it resident, and run a dummy inference with correctly shaped input so lazy allocation, kernel selection and autotuning all happen before the user arrives. If startup is itself constrained, the next steps are ahead-of-time compilation to remove the JIT, memory-mapped weights to remove the load, and a cached autotuning plan shipped with the binary. That is a serving problem as much as a model problem, which is where Part 11 picks up.
The habit this chapter is trying to build is small: before you change anything, know which of three things is limiting you, and know it from a number rather than from an impression. Everything else in the book, the quantisation, the fusion, the pruning, the layout choices, is a menu. Profiling is how you order from it. The next chapter takes the same discipline out of a single device and into a fleet, where latency is a distribution across thousands of requests, cost is measured in currency rather than milliseconds, and being wrong has a price you can put on an invoice.