The Deployment Gap
Imagine a chef who cooks the single most beautiful dish you have ever tasted — but it takes three hours to plate just one. In a quiet test kitchen, that chef is a genius. During a Friday-night dinner rush with two hundred hungry customers, that chef is useless. The food never arrives. The same gap separates a model that is brilliant in a notebook from one that is genuinely useful in production. Accuracy on a held-out test set is the test kitchen. Production is the dinner rush.
A diagram of a convolutional network pipeline from input image through convolution and pooling stages to a prediction.
Here is the core mindset shift this entire track is built around. During training we optimize essentially one thing: accuracy (or loss). During deployment we still care about accuracy, but we now ALSO optimize for speed, memory footprint, energy use, and dollar cost — and we are usually happy to trade away a little accuracy for a huge efficiency win. Shaving 1% off accuracy to make a model 5x cheaper and 4x faster is, in production, almost always the right call. The notebook never taught you that trade exists, because in the notebook those costs were invisible.
Where does a vision model actually run? Roughly three kinds of homes, each with its own rules. A cloud server has powerful GPUs and lots of memory, but you pay by the second and every request crosses the network. A phone has a capable but modest chip, a battery you must not drain, and a user staring at the screen waiting. A tiny edge device — a smart doorbell, a factory camera, a drone — may have a few megabytes of memory and a sliver of a watt to spend, sometimes with no network at all. The same network that flies on the cloud server can be hopelessly slow on the doorbell. Across this track we will descend that ladder all the way down: from the comfortable cloud to edge deployment on the most constrained hardware, where the difference between a usable inference latency and an unusable one is the whole game.
Two Speeds: Latency vs Throughput
"Fast" hides two completely different numbers, and confusing them is the single most common rookie mistake in deployment. Picture a highway. Latency is how long one car's trip takes from on-ramp to off-ramp — a per-trip stopwatch. Throughput is how many cars pass a checkpoint per hour — a counter at the side of the road. A highway can have terrible latency (every trip is slow because of stop-and-go traffic) while still having huge throughput (a thousand cars an hour crawl past). The two are related but they are not the same number, and a product almost never cares about both equally.
Precisely: inference latency is the wall-clock time to get one prediction back, usually measured in milliseconds — the AR filter on your face, the answer to "what is in this photo," the response a user is waiting for. inference throughput is how many predictions the system completes per unit time, usually images per second — the nightly job that has to label ten million product photos before morning. A live, interactive feature lives and dies by latency. A bulk offline job lives and dies by throughput. Knowing which one your product is should be the first thing you decide, because it determines everything you optimize next.
Throughput equals batch size divided by the time to process one batch.
Read this as: throughput T (images per second) equals the batch size B (how many images we send through the accelerator together) divided by L_batch (the time, in seconds, to push that one batch all the way through). Suppose one image alone takes 10 ms, so a batch of B = 1 gives T = 1 / 0.010 = 100 images/sec. Now we batch B = 32 and the GPU finishes the whole batch in L_batch = 40 ms. Throughput jumps to T = 32 / 0.040 = 800 images/sec — eight times higher! Why? Because the accelerator has thousands of tiny arithmetic units, and one image rarely keeps them all busy; it sits half-idle. A batch fills those idle lanes, so we get far more total work per second for only slightly more time per batch. This is the highway widening from one lane to many.
But notice the trap. Batching raised throughput, yet what did it do to one image's latency? With B = 1 a request was done in 10 ms. With B = 32 your image is not done until the whole 40 ms batch finishes — and worse, if your image arrives first, it may sit and WAIT for 31 other images to show up before the batch even starts. The very thing that made the system efficient (filling all the lanes) made any single rider's trip longer. That is the fundamental tension: batching trades latency for throughput. Great for the nightly job, often terrible for the live AR filter.
Little's Law: the average number of requests in flight equals throughput times residence time.
Little's Law ties the two speeds together with a single, almost magical identity. Here N is the average number of requests "in flight" inside the system at any instant (cars currently on the stretch of highway), X is the throughput (cars passing per hour), and R is the residence time — how long each request spends inside, which for our purposes is essentially its latency. The law says N = X · R. A quick check: if 800 images/sec flow through (X = 800) and each spends R = 0.040 s inside, then on average N = 800 × 0.040 = 32 images are being worked on at once — exactly our batch of 32. The law is intuitive: to push more cars per hour (raise X) without lengthening anyone's trip (hold R fixed), you must keep more cars on the road at once (raise N) — i.e. more parallelism. It is the bookkeeping that makes the latency-throughput trade exact rather than vague.
So there is no universal "fast." The right metric is dictated by the product. A live AR filter that paints whiskers on your face must hit roughly 30 ms per frame or the whiskers lag behind your chin — that is a pure p95-latency problem, and batching is the enemy. A nightly job re-tagging your entire photo library cares only about finishing ten million images by morning at the lowest cost — that is a pure throughput problem, and batching is your best friend. Same model, opposite engineering. Decide which world you live in before you optimize a single line.
Counting the Work: FLOPs and Compute Cost
To reason about speed before we even run the model, we need a currency for "how much arithmetic is in here." That currency is the FLOP — one FLoating-point OPeration, meaning a single multiply or a single add on real numbers. Neural networks are built almost entirely from one move repeated billions of times: take two numbers, multiply them, and add the result onto a running total. That multiply-then-add is called a multiply-accumulate (MAC), and it is the heartbeat of every convolution and matrix product. We count one MAC as two FLOPs — one multiply plus one add — and we measure whole models in GFLOPs (billions) or even TFLOPs (trillions).
Approximate FLOPs for a single 2D convolution layer.
Let us unpack every symbol, because once you see where each comes from, you can estimate any layer by hand. C_in is the number of input channels (e.g. 64 feature maps coming in), C_out is the number of output channels (the number of filters, say 128). K_h and K_w are the kernel's height and width (a 3×3 kernel gives K_h = K_w = 3). H_out and W_out are the height and width of the output feature map (say 56×56). The logic: to produce ONE output number, the convolution multiplies the kernel over its C_in × K_h × K_w little window and sums — that is C_in · K_h · K_w MACs. We do that for every output channel (× C_out) and at every output position (× H_out · W_out). Finally the leading 2 converts MACs to FLOPs, because each MAC is one multiply plus one add. That single factor of 2 is the whole reason the formula starts with a 2 — nothing deeper.
An illustration of a convolution kernel sliding over an input feature map, computing a weighted sum at each position.
Now a fully worked example so you can plug in your own numbers. Take a single 3×3 conv layer with C_in = 64 input channels, C_out = 128 output channels, producing a 56×56 output map. Plug in: FLOPs ≈ 2 · 64 · 128 · 3 · 3 · 56 · 56. Build it up step by step: 2 · 64 · 128 = 16,384; times 3 · 3 = 9 gives 147,456; times 56 · 56 = 3,136 gives 462,422,016. So this ONE layer costs about 4.6 × 10⁸ FLOPs ≈ 0.46 GFLOPs. A real network stacks fifty-plus such layers, which is how a modest classifier reaches several GFLOPs per image — and why running it ten million times per night, or thirty times per second on a phone, becomes a budgeting problem you can now estimate before writing a line of inference code.
def conv_flops(c_in, c_out, kh, kw, h_out, w_out):
"""Approximate FLOPs for one 2D conv layer (1 MAC = 2 FLOPs)."""
macs = c_in * c_out * kh * kw * h_out * w_out
return 2 * macs
# The worked example: a single 3x3 conv, 64 -> 128 channels, 56x56 output
flops = conv_flops(c_in=64, c_out=128, kh=3, kw=3, h_out=56, w_out=56)
print(flops) # 462422016
print(flops / 1e9) # 0.462... GFLOPs for ONE layerWhere the Work Runs: Hardware Accelerators
The very same model, byte-for-byte identical, can be blisteringly fast on one chip and painfully slow on another. To understand why, you need a feel for three families of processor. A CPU is a handful of very clever, very fast workers — brilliant at branchy, do-this-then-that logic, but only a few of them. A GPU is an army of thousands of simpler workers that all do the same arithmetic at the same time. An NPU / TPU (neural processing unit / tensor processing unit) is an even more specialized factory: dedicated tensor or matrix units whose entire job is to grind through multiply-accumulates and almost nothing else. Choosing and exploiting the right one is what a hardware accelerator strategy is about.
Why do GPUs and NPUs win so decisively for vision? Because, as the last section showed, a neural net is mostly a tower of big matrix multiplies and convolutions — and those are embarrassingly parallel. Every output number is an independent dot product, so if you have ten thousand arithmetic units you can compute ten thousand of them at once. A CPU with eight cores can do eight-ish at a time; a GPU does thousands; a dedicated tensor unit chews through an entire small matrix multiply as a single hardware instruction. The whole art of the accelerator is doing thousands of multiply-accumulates in parallel instead of one after another.
But raw arithmetic units are only half the chip. The other half — the one beginners forget — is memory bandwidth: how fast the chip can move weights and activations between memory and those hungry units. Picture ten thousand chefs who can chop infinitely fast, but only one narrow doorway to the pantry: they end up standing idle, waiting for ingredients. This is exactly the memory-bound regime from the last section, and it is why flops alone never predicts speed. Two chips with identical peak FLOPs can differ 3x in real latency purely because one feeds its units faster. When you later hear a model called "memory-bound," this starving-chefs picture is what it means.
One last lever, which we will spend a whole guide on later: numeric precision. The math above assumed full 32-bit floats (FP32), but accelerators run dramatically faster — and use far less memory and bandwidth — in 16-bit (FP16) or 8-bit integer (INT8) arithmetic. A tensor unit can often do 2x to 4x more INT8 operations per second than FP32, and INT8 weights are a quarter the size, which directly eases the bandwidth squeeze. The catch is that fewer bits can cost a little accuracy, which is the exact trade we flagged at the start — and the entire subject of the quantization guide. For now, hold one mental model: the SAME model is fast or slow depending on the chip it lands on, how parallel its work is, how fast that chip feeds its arithmetic units, and what precision you let it use.
Measure, Don't Guess: Profiling a Model
Here is the discipline that separates engineers from hopeful tinkerers: measure before you optimize. Your intuition about which layer is slow is, statistically, wrong — even expert intuition is wrong most of the time, because the bottleneck is often a humble layer doing little math but moving a lot of memory. model profiling is the practice of actually timing your model on real hardware to find where the milliseconds go. It is the instrument that turns the abstract metrics from the earlier sections — latency, throughput, compute-bound, memory-bound — into concrete numbers you can act on.
Beginners get the hygiene wrong in ways that produce confidently-reported nonsense. The fixes are mechanical and worth memorizing.
- Warm up first. The first few runs are abnormally slow — the framework is compiling kernels, allocating memory, and waking the GPU from its idle clocks. Run 10–50 throwaway iterations and discard them before you time anything.
- Measure on the target hardware. A benchmark on your beefy dev GPU tells you almost nothing about the phone or edge chip the model must actually ship on. Profile where it will run.
- Average many runs, and watch the clock correctly. Time hundreds of iterations and report statistics, not one lucky shot. On a GPU the work is asynchronous, so you must synchronize (wait for the device to finish) before reading the timer, or you will time the launch, not the compute.
- Report the distribution, not just the mean. Give p50 and p95 (and p99 if users feel the tail), exactly as in the latency section — the mean hides the stalls your users actually notice.
- Break it down per layer. Use a profiler to get a per-layer (per-operator) timing table, then sort it. The top few rows are the only places worth your effort.
import time, torch
model.eval().to(device)
x = torch.randn(1, 3, 224, 224, device=device)
# 1) Warm up: discard the slow first runs
with torch.no_grad():
for _ in range(20):
model(x)
torch.cuda.synchronize() # wait for the GPU to actually finish
# 2) Time many runs on the target device
times = []
with torch.no_grad():
for _ in range(200):
torch.cuda.synchronize(); t0 = time.perf_counter()
model(x)
torch.cuda.synchronize(); times.append(time.perf_counter() - t0)
times_ms = sorted(t * 1e3 for t in times)
p50 = times_ms[len(times_ms)//2]
p95 = times_ms[int(len(times_ms)*0.95)]
print(f"latency p50={p50:.2f} ms p95={p95:.2f} ms")Once you have a per-layer table you can classify the model — or each layer — as compute-bound or memory-bound, which is the practical heart of the "roofline" idea (no heavy math needed here). A rough test: estimate the layer's FLOPs and the bytes it must move, and ask which one the hardware would finish last. Big dense convolutions tend to be compute-bound (the arithmetic units are the bottleneck — buy more or faster math, or cut FLOPs). Tiny element-wise ops, activations, and skinny layers tend to be memory-bound (the chip is starved for data — the fix is moving fewer bytes, e.g. fusing layers or quantizing, not adding compute). Knowing which kind a bottleneck is tells you which tool from the rest of this track will actually help — and saves you from optimizing the wrong thing.
The Efficiency Triangle and the Road Ahead
Let us compress this whole guide into one durable mental model: the accuracy–latency–cost triangle. Picture three corners pulling against each other. You can usually improve one only by giving up a little of another: push accuracy up and the model grows, so latency and cost rise; squeeze latency and cost down and you typically shave some accuracy. There is no free corner. The mark of a deployment engineer is not refusing to trade — it is choosing the trade deliberately, with measured numbers, toward what the product actually needs.
A scaling-law curve showing accuracy improving with model size and compute, with diminishing returns.
Everything left in this track is a toolbox for moving along that triangle — for buying back latency and cost while giving up as little accuracy as possible. Guide 2, model compression, makes the model structurally smaller through pruning (cutting away weights that barely matter) and distillation (training a small student to imitate a big teacher). Guide 3, quantization, does more with fewer bits, running in INT8 instead of FP32 to slash memory and bandwidth. Guide 4 covers runtimes and accelerators — ONNX, TensorRT, and the hardware stack — squeezing the most out of whatever chip you have. Guide 5 ties it together for edge deployment on phones and tiny devices, the master's playbook for the harshest corner of the triangle.
A quick before/after to make it real. You train a gorgeous detector that scores 92% and runs at 320 ms per frame in FP32 on the test phone — far too slow for the live camera feature, which needs to feel instant. The levers we will pull, in order: prune and distill it down to a leaner network (guide 2), quantize it to INT8 (guide 3), and run it through an optimized mobile runtime on the phone's NPU (guides 4 and 5). A realistic outcome is something like 91% accuracy at 35 ms per frame — one point of accuracy traded for a model that, at last, actually ships. That one point bought you a product.