EVERYTHING AIAI engineering, made visual
0/28 complete
LESSON 15 · COMPUTER VISION × AI · LEARN + BUILD

Thirty frames a second.
Thirty-three milliseconds each.

A training-time vision model is a floating-point monster: 25.6M parameters, 4.1 GFLOPs, 100 MB of weights. An edge device gives you 33.3 ms of frame budget, 2 GB of RAM and no tolerance for a slow tail. This lesson is the discipline of closing that gap — measured percentiles, honest FLOPs, INT8 post-training quantisation, an ONNX export that survives the trip, and the four backbones that fit.

75 MIN · 6 CHAPTERS + CHECKPREREQ · PHASE 4 · LESSON 04 + PHASE 10 · LESSON 11
FIG. 15 / BUDGET · TAIL · VERDICT
served over budget p95
LESSON 15TYPE · LEARN + BUILD~75 MINPREREQ · PHASE 4 · LESSON 04 + PHASE 10 · LESSON 11ORIGINAL LESSON ↗
THE 60-SECOND VERSIONSee the problem ↓
01 / THE 33.3 MS BUDGET

Thirty frames a second is a budget, not a wish.

1000 ÷ 30 = 33.3 ms per frame, split across preprocess, inference and postprocess. The device also has a memory ceiling (peak, not average) and a power ceiling (millijoules per inference). Every number is measured on the target device in percentiles — p50, p95, p99 — because a 12 ms mean with a 41 ms p99 still misses one frame in a hundred.

30 fps → 33.3 ms · 60 fps → 16.7 ms · report the tail
02 / FOUR LEVERS, IN ORDER

Model, precision, runtime — then distillation.

Shrink the architecture (MobileNetV3-Small is ≈68× fewer FLOPs than ResNet-50), quantise to INT8 (≈4× smaller, 2–4× faster, 0.1–1 point of accuracy), and pick the runtime that matches the silicon: ONNX Runtime for neutral, TensorRT for NVIDIA, Core ML or TFLite for phones. Pruning and distillation come last, when the first three still leave you short.

0.06 vs 4.1 GFLOPs · INT8 4× smaller · <1 point budget
03 / EXPORT IS A CONTRACT

The model that worked in Python has to survive the trip.

PyTorch → ONNX → the target runtime. The three failures that eat a week: a shape baked into the graph (fix with dynamic axes), an operator the exporter cannot translate (fix by replacing it, or by scripting the control flow), and an opset version one side of the toolchain does not speak (fix by pinning 17 or upgrading the runtime). None of them is fixed by retraining.

opset 17+ · dynamic axes · verify numerics, not just the file size
MENTAL MODEL IN ONE SENTENCE

A real-time model is a budget you spend, not a benchmark you admire: 33.3 ms of frame, 2 GB of RAM and about one point of accuracy — and every optimisation you skip shows up later as a dropped frame in the p95.

By the end you will be able to measure latency the way production does (warmup, fixed input size, percentiles, synchronisation) and say why the mean is the last number you look at; compute a convolution’s FLOPs by hand and explain why depthwise models beat their own FLOPs estimate on memory bandwidth; run PyTorch post-training static quantisation — fusion, per-channel scales, 100–500 calibration images — and hold the accuracy loss under one point; export to ONNX at opset 17 and diagnose the three classic export failures; and pick between MobileNetV3, EfficientNet-Lite, ConvNeXt-Tiny and MobileViT-S from a device RAM, a frame-rate target and an accuracy floor.

THE 33.3 MS BUDGET

Thirty frames a second.
Thirty-three milliseconds each.

A training-time vision model is a floating-point monster: 100M parameters, 10 GFLOPs, 2 GB of VRAM. A phone, a car’s infotainment unit, an industrial camera and a drone all offer the same thing instead — a hard frame deadline, a hard RAM ceiling, and no patience for the tail. Measurement comes before every optimisation, and the mean is the last number you look at.

The same predictions have to fit in a budget about 100× smaller than the training machine’s. Three knobs do most of the work: model choice (a smaller architecture with the same recipe), precision (INT8 instead of FP32) and the runtime (ONNX Runtime, TensorRT, Core ML, TFLite). Getting them wrong is the difference between a demo that runs on your workstation and a product that ships on a $30 camera module. But before any of the three, you need the discipline of the budget itself.

There are three budgets, and a shipping decision needs all three measured on the target device — never on the workstation:

budgetwhat you measurewhy it kills you
Latencyp50, p95, p99 milliseconds per framereal-time systems fail on tails; the mean hides exactly the frames a human notices
Peak memorythe maximum resident set the device ever seesOOM is fatal on embedded targets — there is no swap file to save you
Power / energymillijoules per inference; usually proxied by utilisation × timea battery device that thermally throttles delivers a different latency curve an hour in

The frame arithmetic is one division, and it is worth writing down because everything else is measured against it:

30 fps → 1000 / 30 = 33.33 ms per frame ← the number this lesson is named after 60 fps → 1000 / 60 = 16.67 ms 15 fps → 1000 / 15 = 66.67 ms one frame, split into its stages: preprocess 4.0 ms + inference 26.0 ms + postprocess 3.0 ms = 33.0 ms 33.0 / 33.33 = 99% of the budget → 0.3 ms of spare frame left if inference creeps to 30.0 ms: total 37.0 ms → 1000 / 37.0 = 27.0 fps the camera still produces 30 frames a second, so 3 of every 30 arrive while the device is busy and get dropped — a 10% frame loss from a 12% overshoot

Note what the last line implies: a pipeline that is 12% over budget loses about 10% of frames, and there is no scheduling trick that recovers them. Frame queues turn dropped frames into latency, and latency is the thing you were trying to bound. The only real fixes are a cheaper model, a smaller frame, or a faster numeric type.

The second discipline is percentiles. Latency distributions in vision are right-skewed — most frames are fast, a few are far slower, because of cache misses, another process on the CPU, thermal throttling or a bus conflict. A lognormal model captures the shape well enough to reason with: with median p50 and log-spread σ, the tail is p95 = p50 · e^(1.645σ) and p99 = p50 · e^(2.326σ). Two models, same median, different σ:

σ = 0.10 p50 12.0 ms → p95 12.0 × e^0.164 = 14.1 ms → p99 15.2 ms σ = 0.35 p50 32.1 ms → p95 32.1 × e^0.576 = 57.1 ms → p99 72.5 ms same budget (33.3 ms): the tight model misses nothing; the jittery one has a median that fits, a p95 that misses 1 frame in 20, and a p99 at 2.2× the budget

This is also where throughput and latency stop being the same thing. Batching amortises the fixed cost of a forward pass — kernel launches, framework dispatch, weight streaming — across several images, which raises throughput and raises latency at the same time:

batch 1 12 ms → 83 frames/s 12.0 ms per image batch 8 75 ms → 117 frames/s 9.4 ms per image ← throughput up 1.4× batch 16 147 ms → 109 frames/s 9.2 ms per image ← bandwidth-bound, worse throughput wants a big batch; a 33.3 ms frame budget wants batch 1 and a one-frame queue. Real-time vision almost always picks latency.
measure_latency — warmup, synchronisation, percentilespython
import time
import torch

def measure_latency(model, input_shape, device="cpu", warmup=10, iters=50):
    """p50 / p95 / p99 in milliseconds, the way production reads them."""
    model = model.to(device).eval()
    x = torch.randn(input_shape, device=device)
    with torch.no_grad():
        for _ in range(warmup):          # cold caches and JIT lie about speed
            model(x)
        if device == "cuda":
            torch.cuda.synchronize()     # otherwise you time dispatch, not execution
        times = []
        for _ in range(iters):
            if device == "cuda":
                torch.cuda.synchronize()
            t0 = time.perf_counter()
            model(x)
            if device == "cuda":
                torch.cuda.synchronize()
            times.append((time.perf_counter() - t0) * 1000)
    times.sort()
    return {
        "p50_ms": times[len(times) // 2],
        "p95_ms": times[int(len(times) * 0.95)],
        "p99_ms": times[-1],
        "mean_ms": sum(times) / len(times),
    }
Three lines do the work: 5–10 warmup passes, torch.cuda.synchronize() around the timed block, and sorted percentiles instead of a mean. Two more habits: fix the input to the production resolution (latency at 224×224 is not latency at 512×512), and run in eval() mode — the source's helper, unchanged in spirit.

The 33.3 ms budget board

Split one frame at 30 fps across preprocess, inference and postprocess. The dashed line is the budget; the top strip samples the latency tail, the bottom strip shows which arriving frames find the device still busy. Add jitter to separate the tail from the median.

total 33.0 ms (99% of 33.3 ms) headroom +0.3 ms sustained 30.0 fps queue drops 0.0% tail p50 33.0 · p95 58.7 · p99 74.5 ms over budget 12 of 24 sampled frames queue 0 of 24 frames arrived while the device was busy It fits at the median, not at the tail. Every spare millisecond is insurance.

Inference is usually 80–95% of the budget; pre- and post-processing are the cheap parts and the ones people forget to count.

Quick check

A model benchmarks at mean 12 ms per image, p99 41 ms, on a 33.3 ms budget. What does the p99 tell you that the mean does not?

PROXIES THAT LIE

FLOPs are a compass.
Latency is the map.

Counting multiply-adds is cheap, device-independent and useful for sorting architectures. It is also wrong as a prediction, because hardware does not charge for arithmetic alone: it charges for weights streaming out of memory, for activations that have to be materialised, and for every kernel launch between them.

A convolution’s FLOP count has a closed form. For a dense layer with C_in input channels, C_out output channels, kernel k×k and output H×W:

dense conv FLOPs = 2 × C_in × C_out × k² × H_out × W_out depthwise conv FLOPs = 2 × C_out × k² × H_out × W_out (one input channel each) (the 2 counts one multiply plus one add) ResNet-50, its 7×7 stem convolution at 112×112 output: 2 × 3 × 64 × 49 × 112² = 236,027,904 ≈ 0.236 GFLOPs 0.236 / 4.1 GFLOPs = 5.8% of the whole model in one layer MobileNetV3-Small, one 3×3 depthwise layer at 112×112 with 16 channels: 2 × 16 × 9 × 112² = 3,612,672 ≈ 0.0036 GFLOPs the same shape as a dense conv would be 2 × 16 × 16 × 9 × 112² ≈ 0.058 GFLOPs → depthwise cuts the arithmetic 16× and leaves the weights almost untouched

That last line is the whole problem with FLOPs as a predictor. Params and FLOPs do not even agree with each other: ResNet-50 is 10.2× the parameters of MobileNetV3-Small but 68× its FLOPs. Params are a memory bill, FLOPs are an arithmetic bill, and on a real device the two are paid from different accounts. Four effects break the correlation, and every one of them has killed a shipping deadline:

  • Memory bandwidth. Every forward pass streams the weights once. ResNet-50’s 25.6M FP32 parameters are 102.4 MB per pass: at 50 GB/s that is 2.05 ms of pure loading before a single multiply, and on a phone-class 20 GB/s link it is 5.1 ms. A depthwise layer does so little arithmetic per weight fetched that the ALUs wait on memory — which is why this lesson’s latency model charges depthwise models ~0.3× of a device’s dense-conv peak, a labelled teaching parameter rather than a measured constant.
  • Activation peaks. Inference memory is weights plus the largest activation, and at high resolution that term wins: a single 512×512×64 activation is 512² × 64 × 4 B = 67.1 MB in FP32 and 16.8 MB in INT8. Training multiplies it by every layer it has to keep for the backward pass; inference frees as it goes, so the peak — not the sum — is what you budget.
  • Kernel launches. Each operator costs a launch and a dispatch. At roughly 5 µs per launch, ResNet-50’s ~200 operators are ~1 ms of pure overhead per frame — 3% of a 33.3 ms budget before any math. On mobile runtimes the per-op cost is higher, which is why graph fusion (Conv + BN + ReLU → one kernel) is the first optimisation every compiler does.
  • Cache behaviour and operator shape. A 7×7 convolution with a large stride is hardware-hostile; a 3×3 stack is hardware-friendly. Two models with identical FLOPs can differ by 2× in wall-clock for this reason alone.
  • The rule the source states is the one to keep: use FLOPs for architecture search, use on-device latency for deployment decisions. FLOPs will tell you that MobileNetV3-Small is worth trying before ConvNeXt-Tiny on a camera-class chip. It cannot tell you whether either one ships at 30 fps — only a stopwatch on the target can.

    Why depthwise convolutions are memory-bound — the arithmetic-intensity check

    Arithmetic intensity is FLOPs divided by bytes moved. Take two 3×3 convolutions with 96 channels at 112×112 and count both sides:

    depthwise (groups = 96) FLOPs 2 × 96 × 9 × 112² = 21.7 MFLOPs bytes weights 96×9×4 B = 3.5 KB + activations read 4.8 MB + write 4.8 MB ≈ 9.6 MB intensity 21.7 MFLOPs / 9.6 MB ≈ 2.3 FLOP per byte dense (96 → 96 channels) FLOPs 2 × 96 × 96 × 9 × 112² = 2.08 GFLOPs (96× more arithmetic) bytes weights 96×96×9×4 B = 332 KB + the same 9.6 MB of activations ≈ 9.9 MB intensity 2.08 GFLOPs / 9.9 MB ≈ 209 FLOP per byte (91× denser) a mobile SoC can do roughly 50-100 FLOPs per byte of bandwidth, and a GPU more: at an intensity of 2.3 the depthwise layer waits on memory for almost every multiply; at 209 the dense layer keeps the units fed. Same kernel size, same tensor shapes, two different bottlenecks — and the FLOPs counter only saw one of them.

    This is the teaching version of the roofline argument, with round activation numbers and a labelled 2× safety factor; the ordering is what matters, because it explains why the FLOPs ranking and the latency ranking disagree in exactly the direction they do.

    flops_estimate — counting FLOPs with hookspython
    import torch
    import torch.nn as nn
    
    def flops_estimate(model, input_shape):
        """Rough conv + linear FLOP count. For production use fvcore or ptflops."""
        total = [0]
    
        def conv_hook(m, inp, out):
            c_out, c_in_per_group, kh, kw = m.weight.shape
            h, w = out.shape[-2:]
            # Groups matter: a depthwise conv reads one input channel per output
            # channel, so c_in_per_group = 1 rather than c_in.
            total[0] += 2 * c_in_per_group * c_out * kh * kw * h * w
    
        def linear_hook(m, inp, out):
            total[0] += 2 * m.in_features * m.out_features
    
        hooks = []
        for m in model.modules():
            if isinstance(m, nn.Conv2d):
                hooks.append(m.register_forward_hook(conv_hook))
            elif isinstance(m, nn.Linear):
                hooks.append(m.register_forward_hook(linear_hook))
    
        model.eval()
        with torch.no_grad():
            model(torch.randn(input_shape))
        for h in hooks:
            h.remove()
        return total[0]
    
    # for real projects: fvcore.nn.FlopCountAnalysis or ptflops handle every module
    Two hooks, one pass. The groups subtlety is the one people get wrong: with groups = C, each output channel reads exactly one input channel, so a depthwise conv’s FLOPs are 1/C of the naive count. For real projects use fvcore.nn.FlopCountAnalysis or ptflops.

    FLOPs, params, latency — pick any two

    Select up to four backbones, pick the target device and the numeric type, and read the three bars against each other. The pale ghost behind each latency bar is what the FLOPs alone would predict; the gap is where memory bandwidth lives.

    device 2 GB phone · 80 INT8 GFLOP/s · weight budget 1024.0 MB budget 33.3 ms per frame (30 fps) MobileNetV3-Small · SHIP weights 2.5 MB (fits) flops 0.06 GFLOPs → implied 1.9 ms predicted 3.7 ms · 270 fps · 1.9× the naive estimate EfficientNet-Lite-B0 · SHIP weights 4.7 MB (fits) flops 0.39 GFLOPs → implied 6.1 ms predicted 15.1 ms · 66.1 fps · 2.5× the naive estimate MobileViT-S · MARGINAL weights 5.6 MB (fits) flops 2 GFLOPs → implied 26.2 ms predicted 46.7 ms · 21.4 fps · 1.8× the naive estimate ConvNeXt-Tiny · NO-SHIP weights 28.6 MB (fits) flops 4.5 GFLOPs → implied 57.5 ms predicted 71.5 ms · 14.0 fps · 1.2× the naive estimate read · FLOPs order and latency order do not have to agree. Depthwise models break the ranking because they wait on memory, not on math.

    The efficiency factors are teaching numbers: depthwise-heavy mobile models land near 0.3 of the device’s dense-conv peak, dense convs near 0.85. Published params, FLOPs and top-1 are the papers’.

    Quick check

    Model A has 0.5 GFLOPs and model B has 2.0 GFLOPs, both trained on the same data with the same top-1. On a phone, A is slower. How?

    FOUR BYTES BECOME ONE

    INT8 is not a smaller model.
    It is a smaller bill.

    Replace FP32 weights and activations with 8-bit integers plus a scale. The file is 4× smaller, the memory traffic is 4× lower, and the arithmetic runs 2–4× faster on every modern SoC, GPU and DSP — for a loss that lands between a tenth of a point and one point when the calibration is done right.

    The idea is a change of units, not a change of model. A tensor’s values are mapped onto the 256 integers a byte can hold, using one scale (and, for activations, one zero point) per block of values:

    quantise q = clamp(round(w / s) + z, 0, 255) s = scale, z = zero point dequantise ŵ = s × (q − z) weights: symmetric (z = 0), one scale per output channel after per-channel activations: asymmetric (uint8, z ≠ 0), one scale per tensor per layer 25.6M-parameter ResNet-50 102.4 MB FP32 → 25.6 MB INT8 4.0× smaller 2.5M-parameter MobileNetV3-Small 10.0 MB → 2.5 MB 4.0× smaller weight stream per forward pass, at 50 GB/s: FP32 102.4 MB / 50 GB/s = 2.05 ms just to read the weights INT8 25.6 MB / 50 GB/s = 0.51 ms 4.0× less traffic

    Two things fall out of that arithmetic. First, quantisation is not only about arithmetic: on bandwidth-bound models it is a memory win, which is exactly where depthwise architectures live. Second, the win scales with how much of the frame is inference. Put it on the budget:

    FP32 pipeline preprocess 4.0 + inference 26.0 + postprocess 3.0 = 33.0 ms (99% of budget) INT8 pipeline preprocess 4.0 + inference 10.4 + postprocess 3.0 = 17.4 ms (52% of budget) 26.0 / 2.5 = 10.4 ms — a conservative 2.5×, mid-range of the documented 2–4× for INT8 kernels the 1% of the frame that was headroom becomes 48%, and the p99 tail moves left with the whole distribution — the tail is usually why you quantise.

    There are three ways to get there, and the difference is who pays for the rounding error:

    flavourwhat is quantisedeffortwhen
    Dynamicweights to INT8; activations computed in FPone function call, no calibrationa quick size win; small speedup, mostly servers
    Static (PTQ)weights and activations, ranges from a calibration setfour steps, 100–500 unlabelled images, minutesthe default for vision — the source calls it 95% of the benefit for 5% of the effort
    QATrounding simulated inside the training looplabels plus a full training runonly when PTQ costs more than the 1-point budget

    Static PTQ is four steps, and the order matters: fuse (Conv + BN + ReLU becomes one module, so the quantiser observes one activation range and the pipeline needs one fewer requantisation), prepare (insert observers that record activation min/max while the model runs), calibrate (100–500 representative images, no labels), convert (turn observed ranges into scales and swap in INT8 kernels). Two details decide whether you land at 0.2 points or 4:

  • Per-channel scales for weights. A single scale per tensor is set by the largest weight in it. One output channel with unusually large weights forces a coarse step on every other channel, and the bulk of the tensor loses resolution it never needed to lose. One scale per output channel costs nothing at runtime and absorbs the outliers.
  • A calibration set that looks like production. The activation ranges are whatever the observer saw: 12 near-black frames produce ranges that clip every real frame, and the accuracy cliff arrives without any error message. 100–500 images drawn from the same distribution as the deployment stream is the source’s guidance, and it is the fix for the most common PTQ failure.
  • The last thing to know is where INT8 struggles: normalisation layers (LayerNorm has no cheap integer version in older runtimes), smooth activations (GELU, swish), un-fused BatchNorm, and attention blocks whose softmax saturates. Convolution stacks with fused batch norm — the MobileNet / EfficientNet / ResNet family — quantise almost for free. Hybrid and transformer-flavoured models (MobileViT, ConvNeXt) quantise well too, but they are where you verify the loss instead of assuming it.

    quantise_ptq — fuse, prepare, calibrate, convertpython
    import torch
    
    def quantise_ptq(model, calibration_loader, backend="x86"):
        """Post-training static INT8: fuse, configure, calibrate, convert."""
        import torch.ao.quantization as tq
    
        model = model.eval().cpu()
    
        # 1. fuse Conv + BN + ReLU into one module: fewer ops, cleaner ranges.
        #    (torch.ao.quantization.fuse_modules handles whole graphs; the point is
        #    that an un-fused BatchNorm leaves two quantised ops where one will do,
        #    doubling the per-op activation ranges and the requantisation steps
        #    between them, and each extra range and rescale adds rounding error.)
        tq.fuse_modules(model, [["features.0.0", "features.0.1"]], inplace=True)
    
        # 2. configure and prepare: observers are inserted after every conv.
        model.qconfig = tq.get_default_qconfig(backend)   # "x86" (FBGEMM) | "qnnpack" (ARM)
        tq.prepare(model, inplace=True)
    
        # 3. calibrate on 100-500 representative images — labels are NOT needed.
        with torch.no_grad():
            for x, _ in calibration_loader:
                model(x)
    
        # 4. convert: observers become scales, fused modules become INT8 kernels.
        tq.convert(model, inplace=True)
        return model
    
    # dynamic: weights INT8, activations computed in FP — weights only, small speedup
    # qat:     simulate rounding inside training — best accuracy, needs labels
    # modern:  torch.ao.quantization.quantize_fx (graph mode) or the torchao toolkit
    #          quantify the same three steps with fewer manual fusions
    The source's helper with the fusion step spelled out. Nothing here needs labels; everything needs a calibration set that resembles production.

    The quantisation playground

    Take one weight tensor to INT8: the accent staircase is what the hardware would actually store. Add wide output channels, drop the calibration set, and watch the predicted accuracy drop cross the one-point budget — then fix it with per-channel scales.

    scheme per-tensor (1 scale) tensor 8 channels × 128 weights widest max|w| = 0.691 → step 5.44e-3 rmse 1.57e-3 in weight units relative 2.10% of the channel std (0.69% as per-channel) model MobileNetV3-Small · 67.4% top-1 weights 10.0 MB FP32 → 2.5 MB INT8 (4× smaller) calibration 100 images predicted 67.40% → 66.07% (−1.33 pts) budget 1.00 pt → OVER BUDGET by 0.33 pts The widest channel is setting one scale for every channel. Switch to per-channel.
    Quick check

    Your PTQ INT8 model loses 4 points of top-1. The calibration loader has 12 near-black images in it. What is the first thing to change?

    EXPORT OR BUST

    It ran in Python.
    Now it has to survive the trip.

    PyTorch is a development runtime; the device will not have it. The production path is PyTorch → ONNX → the runtime that matches the silicon, and the trip has exactly three ways to fail: a shape baked into the graph, an operator the exporter cannot translate, and an opset version one side of the toolchain does not speak.

    ONNX is the lingua franca: one file format that ONNX Runtime, TensorRT, Core ML, TFLite and OpenVINO all read. The runtime is chosen by the hardware, not by taste:

    runtimehardwarenotes
    PyTorch eageryour workstationdevelopment only; 200 dispatcher hops per frame is not a deployment story
    TorchScriptanywhere the ONNX path runslegacy, superseded by torch.compile and ONNX export — but still the tool that puts control flow into a graph when tracing cannot
    ONNX RuntimeCPU, CUDA, Core ML, TensorRT, OpenVINO via providersthe neutral default; start here and profile before reaching for anything else
    TensorRTNVIDIA GPUs, Jetsonbest latency, biggest build effort; engines are compiled per GPU and are not portable
    Core ML / TFLiteApple / Android and ARMquantise before export; the mobile delegates expect INT8
    OpenVINOIntel CPU, iGPU, VPUtakes ONNX and emits its own IR; strong on x86 edge boxes

    The opset is the versioned set of operators the file may use, and it is the single most common source of version pain. Opset 17 is the conservative 2026 default — it is where LayerNormalization became a native op, which matters for every transformer-flavoured backbone in the picker. Newer opsets exist and newer exporters default to them; the rule is to export at the oldest opset that contains every op your model needs, because the runtime on the device is usually older than the toolchain on your laptop. When an op is missing, the exporter silently decomposes it into primitives — “failing fallback strategy chosen” — and the decomposed numerics can cost you points without raising an error.

    The three families of export failure, with the fix for each:

    familywhat you seethe fix
    Dynamic shapes“Got invalid dimensions for input: Expected 1 Actual 8”, or the 320×320 production stream is rejecteddeclare the axes in dynamic_axes (batch, and height/width if the resolution varies); give TensorRT a min/opt/max profile
    Unsupported ops“Unsupported operator: aten::…”, or an exported model that silently takes only one branchreplace the op with supported primitives, script the module so control flow enters the graph, or register a custom op
    Opset mismatch“Failing fallback strategy chosen”, or the target runtime rejects a graph over its opset ceilingpin the export to the opset the device speaks (17 is the safe floor), or upgrade the runtime — then verify the numerics

    Two rituals turn export from a gamble into a step. First, numerical parity: run 50 random inputs through eager and through the runtime and assert the maximum absolute difference is under 1e-3. A file that passes the ONNX checker can still be a different model. Second, profile on the target: the same ONNX file can be 3× faster under TensorRT than under a CPU provider, and a TensorRT engine built on your workstation GPU will not load on a Jetson at all. Both rituals are cheap; both catch exactly the failures that survive to production.

    export_onnx — one sample, declared dynamic axes, opset 17python
    import torch
    
    def export_onnx(model, sample_input, path="model.onnx", dynamic_batch=True):
        """PyTorch → ONNX at opset 17, with the batch axis declared dynamic."""
        model = model.eval()                       # train mode changes the graph
    
        dynamic_axes = (
            {"input": {0: "batch"}, "output": {0: "batch"}} if dynamic_batch else None
        )
        torch.onnx.export(
            model,
            sample_input,                          # traced ONCE: this shape is baked in
            path,                                  # unless you declare it dynamic
            input_names=["input"],
            output_names=["output"],
            dynamic_axes=dynamic_axes,             # {2: "height", 3: "width"} if the
            opset_version=17,                      # resolution varies at runtime too
        )
    
        import onnx
        onnx.checker.check_model(path)             # structural check — not numeric
        return path
    
    # then, in ONNX Runtime:
    #   import onnxruntime as ort
    #   session = ort.InferenceSession("model.onnx", providers=["CPUExecutionProvider"])
    #   ort_out = session.run(None, {"input": x.numpy()})
    #
    # and numerically, because a valid file is not a correct model:
    #   assert (torch_out - torch.from_numpy(ort_out[0])).abs().max() < 1e-3
    The traced sample shape is baked into the graph unless you declare it dynamic. The checker validates structure; the assert at the end validates the model.

    Export-failure triage

    Nine symptoms that show up between a working PyTorch model and a working ONNX file. Pick the one you are staring at; the board names the cause and the one-line fix.

    Dynamic shapes

    The trace baked one input size into the graph, so any other batch or resolution is refused.

    Unsupported op

    An operator in the model has no ONNX mapping, or tracing cannot see the control flow it lives in.

    Opset mismatch

    The op needs a newer (or older) opset than the exporter or runtime supports.

    FAMILY DYNAMIC SHAPES SYMPTOM Batch 1 exports fine; session.run() with batch 8 fails at load time MESSAGE [ONNXRuntimeError] INVALID_ARGUMENT : Got invalid dimensions for input: input Expected: 1 Actual: 8 CAUSE torch.onnx.export traces the model with the sample input you hand it, so a batch of 1 becomes a constant in the graph. There is no dynamic axis unless you declare one.
    torch.onnx.export(
        model,
        sample,                       # traced shape: (1, 3, 224, 224)
        "model.onnx",
        input_names=["input"],
        output_names=["output"],
        dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}},
        opset_version=17,
    )
    FIX Declare the batch axis as dynamic and let the runtime accept any batch. On the modern exporter the same idea is a Dim() annotation; in TensorRT you also have to name a min/opt/max profile.

    The three families cover almost everything: a shape baked into the graph, an operator the exporter cannot translate, or an opset that one side of the toolchain does not speak. The fix is a declaration, an op swap, or a version pin — never a retrain.

    PICK A BACKBONE

    A small zoo.
    One budget each.

    Four backbones do almost all the work on real devices — MobileNetV3, EfficientNet-Lite, MobileViT and ConvNeXt-Tiny — with ResNet-50 as the reference point they beat. The numbers are public; what most teams get wrong is the order. Pick a model by reputation and you discover the weight bill, then the frame rate, then the export. Start from the constraint instead, walk up the accuracy ladder, and stop at the first model that clears the floor.

    These are the published ImageNet-1k numbers for the architectures an edge deployment actually uses. Params set the weight bill; GFLOPs hint at the arithmetic; top-1 is the accuracy ceiling before your own data:

    modelparamsGFLOPstop-1why it exists
    MobileNetV3-Small2.5M0.0667.4%designed for phones: depthwise convs, h-swish, squeeze-excite, quantisation-aware from day one
    MobileNetV3-Large5.4M0.2275.2%the same recipe with more capacity — the default mobile workhorse
    EfficientNet-Lite-B04.7M0.3975.1%best accuracy per parameter in the 3–10M band; Lite variants swapped swish for ReLU6 to be TFLite-friendly
    MobileViT-S5.6M2.078.4%hybrid conv + transformer; the best accuracy per megabyte in the zoo
    ResNet-5025.6M4.176.1%the 2015 default — 2× MobileViT-S’s arithmetic for 2.3 fewer points
    ConvNeXt-Tiny28.6M4.582.1%the accuracy ceiling of the zoo; modern conv design, CPU-friendly, 114 MB of FP32 weights
    Swin-V2-Tiny28.3M4.581.8%window attention — only if your runtime handles the ops and the export survives

    The source’s picker is organised as parameter bands: under 3M take MobileNetV3-Small, 3–10M EfficientNet-Lite-B0, 10–20M ConvNeXt-Tiny, 20–30M MobileViT-S or EfficientViT, 30–80M Swin-V2-Tiny if the stack supports window attention. Read the bands as neighbourhoods rather than exact counts — MobileViT-S itself is 5.6M parameters — and then do the arithmetic that actually decides:

    FLOPs gap 4.1 / 0.06 = 68× ResNet-50 vs MobileNetV3-Small (4.0 / 0.06 = 66× if you round ResNet-50 down to 4 GFLOPs) accuracy per GFLOP, measured against MobileNetV3-Small (67.4%): EfficientNet-Lite-B0 +7.7 pts for +0.33 GFLOPs = 23.3 pts/GFLOP MobileViT-S +11.0 pts for +1.94 GFLOPs = 5.7 pts/GFLOP ConvNeXt-Tiny +14.7 pts for +4.44 GFLOPs = 3.3 pts/GFLOP ResNet-50 +8.7 pts for +4.04 GFLOPs = 2.2 pts/GFLOP and the same comparison per megabyte of weights: MobileNetV3-Small 67.4% at 10.0 MB FP32 6.74 pts/MB MobileViT-S 78.4% at 22.4 MB FP32 3.50 pts/MB ConvNeXt-Tiny 82.1% at 114.4 MB FP32 0.72 pts/MB the frontier flattens: the first 8 points cost 0.33 GFLOPs, the next 7 cost 4.4. MobileViT-S beats ResNet-50 outright — +2.3 points at half the FLOPs and 4.6× fewer parameters — which is the whole reason the old default is not the default.

    That flattening is the real decision rule. Pick the smallest architecture that clears the accuracy floor, quantise it, and only then consider stepping up — because the step from 75% to 78% can cost ten times the step from 67% to 75%. When even the smallest model cannot clear the floor, there are two more levers the source names: pruning (remove unimportant weights, or whole channels, from an over-parameterised model) and distillation (train a small student to mimic a large teacher’s logits — the standard way production edge models recover the points a small architecture lost). Both are training-time investments; quantisation and model choice are configuration-time ones, which is why they come first.

    The order of operations for a real deployment, then: measure the frame budget on the target; set the weight ceiling from device RAM (halve it for the OS and activations); set the accuracy floor from business requirements, not from ImageNet; start at MobileNetV3-Small, quantise, measure; step up one row at a time until the floor is met. The chooser lab below does the same walk with sliders.

    Pick a backbone from the constraints

    Three constraints — how much RAM the device has, how many frames per second it must sustain, and the accuracy floor you cannot go under — plus the numeric type. The board filters the zoo and recommends the most accurate model that clears all three.

    device class 2 GB phone mid-range SoC with an INT8 DSP assumed peak 80 INT8 GFLOP/s · 1.2 ms fixed overhead precision INT8 (accuracy charged 0.5 point for PTQ) constraints RAM 1.0 GB · 30 fps · floor 65.0% PICK MobileNetV3-Large 74.7% top-1 · 9.8 ms (102 fps) · 5.4 MB of weights Ship MobileNetV3-Large: 74.7% at 9.8 ms (102 fps) with 5.4 MB of weights. The next lever is quantisation-aware training if the INT8 accuracy budget matters, or a smaller input resolution if you want more fps headroom.
    modelweightslatencyfpsaccuracyverdict
    MobileNetV3-Small
    fits, fast enough, accurate enough
    2.5 MB3.7 ms270 fps66.9%CLEARS
    MobileNetV3-Large
    fits, fast enough, accurate enough
    5.4 MB9.8 ms102 fps74.7%CLEARS
    EfficientNet-Lite-B0
    fits, fast enough, accurate enough
    4.7 MB15.1 ms66.1 fps74.6%CLEARS
    MobileViT-S
    only 21.4 fps of 30 fps
    5.6 MB46.7 ms21.4 fps77.9%MISSES
    ResNet-50
    only 16.3 fps of 30 fps
    25.6 MB61.5 ms16.3 fps75.6%MISSES
    ConvNeXt-Tiny
    only 14.0 fps of 30 fps
    28.6 MB71.5 ms14.0 fps81.6%MISSES
    Swin-V2-Tiny
    only 10.5 fps of 30 fps
    28.3 MB95.0 ms10.5 fps81.3%MISSES
    read · the floor is the hard constraint, the RAM is the hard budget, the frame rate is the one you can usually buy back with a smaller input resolution or a better runtime build. The 0.5-point charge is illustrative: published PTQ lands in 0.1–1.0.

    The zoo is the source lesson’s picker with published ImageNet numbers, and the device class is inferred from RAM — weights get half the RAM, the operating system and activations take the rest.

    Quick check

    A team needs 30 fps at 224×224 on a $30 camera-class SoC and a 75% top-1 floor. Which zoo entry do they reach for first?

    PROFILE, THEN SHIP

    The runtime is part of the model.
    So is the machine it runs on.

    The last decision is not architectural — it is a profile: which providers are actually engaged, which layer is the slow one, and whether the model you exported is the model you trained. The source converges on three shipping paths, and all three end with the same table: model, latency, memory, accuracy.

    Production stacks converge on one of three paths, and each one is a different promise about where the engineering effort goes:

    • Web and serverless: PyTorch → ONNX → ONNX Runtime with the CPU or CUDA provider. The easiest path by a wide margin, good enough for most requests, and the one to start with. Batch requests to raise throughput; keep latency budgets in mind for interactive work.
    • NVIDIA edge and GPU servers: PyTorch → ONNX → TensorRT, standalone or as an ONNX Runtime provider. The best latency available, the biggest build effort, and engines compiled per GPU — a plan built on the workstation will not load on the Jetson, so the build belongs in the device’s provisioning, not your laptop.
    • Mobile: PyTorch → ONNX → Core ML (iOS) or TFLite (Android), with the model quantised before export so the delegate sees INT8 from the start. This is where MobileNetV3 and EfficientNet-Lite were designed to land.

    For measurement, the tooling is the same discipline as chapter 01 with better instruments: torch.profiler / torch-tb-profiler for per-layer time on the eager side, nsys and nvprof for GPU kernels, Instruments on macOS, trtexec for TensorRT, benchmark_app for OpenVINO, and ONNX Runtime’s profiling flag for a per-node JSON you can diff against PyTorch. The first thing to check in any profile is not the slowest layer — it is whether the accelerator engaged at all. A provider list that falls back to CPU is the most common cause of a mysteriously slow deployment, and it is silent by design.

    The ship checklist that comes out of the source, in order. Each line is cheap; each one catches a failure that costs days:

    1. benchmark on the target device, in eval() mode, after warmup 2. report p50 / p95 / p99 and the peak memory — not the mean 3. quantise to INT8, then re-measure latency AND accuracy on a held-out set 4. hold the accuracy loss to under one point; if not, calibration first, QAT last 5. verify numerical parity between eager and the runtime (max |Δ| < 1e-3) 6. confirm the execution provider that actually ran (not just the one you asked for) 7. write the decision table: model · latency · memory · accuracy 8. only then decide ship / no-ship — and re-run it after any runtime upgrade

    Two artifacts from the source make good habits, because both force the checklist into a reusable form: a deployment planner that takes target device, frame budget and accuracy floor and returns backbone, quantisation strategy and runtime; and a latency profiler that writes the warmup-and-percentile benchmark script with peak-memory tracking included. Neither is complicated — the value is that they are written once and run on every model, instead of being reinvented at 2 a.m. before a demo.

    Runtime selection and the measurement looppython
    # ONNX Runtime: pick the provider, then verify it actually engaged
    import onnxruntime as ort
    
    session = ort.InferenceSession(
        "model.onnx",
        providers=[
            ("TensorrtExecutionProvider", {"trt_int8_enable": True}),
            "CUDAExecutionProvider",
            "CPUExecutionProvider",          # the fallback that hides mistakes
        ],
    )
    print(session.get_providers())            # if CPU shows first, nothing accelerated
    
    # TensorRT standalone: build the engine once, on the target, with a shape profile
    #   trtexec --onnx=model.onnx --int8 --saveEngine=model.plan \
    #     --minShapes=input:1x3x224x224 \
    #     --optShapes=input:1x3x224x224 \
    #     --maxShapes=input:8x3x320x320
    #
    # then measure the way chapter 01 taught: warmup, percentiles, peak memory
    #   trtexec --loadEngine=model.plan --iterations=100 --avgRuns=10 --dumpProfile
    
    # ONNX Runtime's own profiler writes a per-node JSON you can diff against eager
    #   session_options.enable_profiling = True
    Providers are a priority list, not a guarantee: print get_providers() to see which one is actually running. trtexec builds the engine and reports the percentile profile you would otherwise write by hand.
    Quick check

    Your ONNX model loads cleanly, runs fast, and its top-1 drops 6 points against eager. What do you check first?

    CHECK YOURSELF

    Six questions.
    Then the terms worth keeping.

    Answer before you look. The quantisation question and the export question are the two that separate “I followed the tutorial” from “I can debug a deployment at 2 a.m.”

    0 / 6 answered · 0 correct

    01You benchmark a model and report “average 12 ms per image on a 4090”. What is missing?

    02FLOPs and on-device latency do not correlate perfectly. Why?

    03Post-training static INT8 quantisation typically loses how much accuracy on ImageNet-class vision models?

    04Your mobile app needs a vision model under 10 MB with sub-10 ms latency. Which backbone do you pick?

    05You export a PyTorch model to ONNX with opset 17 and it fails with “Unsupported operator”. What are the most likely causes?

    06Your freshly quantised MobileNetV3-Small loses 4 points of top-1, and the calibration loader holds 12 images, several of them near-black. What is the first fix?

    Key terms, demystified

    Click a card to swap the lazy description for what it actually means.

    Exercises from the lesson

    Four problems with exact numbers: benchmark four backbones and read the accuracy-per-millisecond table, quantise MobileNetV3-Small PTQ and hold the loss under a point, race ONNX Runtime against PyTorch eager on ConvNeXt-Tiny, and work out where a four-stage pipeline actually blows its 33.3 ms budget. Try first; a worked answer is one click away.

    1. Measure p50 and p95 latency for resnet18, mobilenet_v3_small, efficientnet_v2_s and convnext_tiny at 224×224 on CPU, using 10 warmup passes and 50 timed iterations. Report the table and say which architecture has the best accuracy-per-millisecond.
      Show one worked answer

      The protocol matters more than the machine: model.eval(), torch.no_grad(), 10 warmup passes, time.perf_counter() around each of 50 passes, then sort and take times[25] and times[47]. Expected shape of the result on a laptop CPU: MobileNetV3-Small lands roughly an order of magnitude below ResNet-18, EfficientNetV2-S sits in between, ConvNeXt-Tiny is slowest per image on CPU but is the most accurate. Accuracy-per-millisecond is just top-1 ÷ p50 ms computed per row — on the published ImageNet values (67.4 / 69.8 / 83.5 / 82.1 for MobileNetV3-Small, ResNet-18, EfficientNetV2-S and ConvNeXt-Tiny) a typical laptop ordering puts MobileNetV3-Small first and ConvNeXt-Tiny last, which is exactly the trade the lesson is about: the smaller model wins on efficiency, the bigger one wins on accuracy, and no FLOPs table tells you where your device lands between them. Report p95 next to p50 for every row; if p95 is more than ~1.5× p50 on an idle machine, another process is sharing the CPU and the numbers are not usable.

    2. Apply post-training static quantisation to mobilenet_v3_small and report FP32 vs INT8 latency and accuracy loss on a held-out subset of CIFAR-10 (or similar). Keep the loss under 1 point.
      Show one worked answer

      The recipe is four lines plus a calibration loop: model.eval(), fuse Conv+BN+ReLU, model.qconfig = torch.ao.quantization.get_default_qconfig("qnnpack" for ARM, "x86" for server CPUs), torch.ao.quantization.prepare, run 100–500 calibration images through it (labels unused), then torch.ao.quantization.convert. Expectations from the source's numbers: weights shrink 4× (2.5M × 4 B = 10 MB → 2.5 MB), CPU latency improves roughly 2–4×, and top-1 moves by 0.1–1.0 points. On a 10-class CIFAR subset the interesting reading is the delta, not the absolute: if the drop is 3–4 points, look at the calibration set before anything else (12 near-black images set ranges that represent nothing), then at per-channel scales for the conv weights, then at whether BatchNorm was actually folded. A drop under 1 point with a 4× smaller file is the normal, expected outcome — and it is the number you quote in the deployment decision, not the FP32 accuracy.

    3. Export convnext_tiny to ONNX, run it through onnxruntime with the CPUExecutionProvider, and compare the latency with the PyTorch eager baseline. Identify the first layer where ONNX Runtime is faster and explain why.
      Show one worked answer

      Export with a fixed 1×3×224×224 sample (ConvNeXt does not need dynamic axes for a first run), input_names/output_names set, opset_version=17, then confirm the graph with onnx.checker and run both sides on the same 20 inputs. Typical finding: ORT is a few percent slower on the first pass and clearly faster after warmup, because the export lets ORT fold constants, fuse Conv+BN and pre-plan memory reuse, while PyTorch eager re-dispatches every op through the Python dispatcher. To find the first faster layer, profile rather than guess: ORT's --enable_profiling gives a per-node JSON and torch.profiler gives the eager side. The layer where the gap opens is usually the first fused Conv-BN block — in ConvNeXt-Tiny that is the stem's 4×4 convolution followed by LayerNorm. The honest takeaway: eager is a development runtime, ORT is a deployment runtime, and the size of the gap depends on your CPU and your batch size — which is why the source's rule is “measure on the target”, never “trust the export”.

    4. A four-stage pipeline measures: capture 2 ms (p95 3), preprocess 6 ms (p95 9), inference 18 ms (p95 31), postprocess 4 ms (p95 6). The frame rate target is 30 fps. Does it fit, and where does the tail come from?
      Show one worked answer

      The medians sum to 2 + 6 + 18 + 4 = 30 ms, which looks like a comfortable 3.3 ms of headroom — and that is the trap, because the p95 of a sum is not the sum of the p95s. Model each stage as lognormal with σ = ln(p95/median) ÷ 1.645: capture 0.246, preprocess 0.246, inference 0.331, postprocess 0.246. The variance of a lognormal is mean² · (e^{σ²} − 1), so the four stages contribute 0.25 + 2.26 + 37.4 + 1.00 ≈ 40.9 ms²; the combined standard deviation (normal approximation to the sum) is 6.4 ms and the p95 of the total is ≈ 30 + 1.645 × 6.4 ≈ 40.5 ms — over the 33.3 ms budget, with the inference stage contributing 91% of the variance. The fixes, in order: trim inference (quantise — a 2.5× speedup takes its mean to 7.2 ms and its variance to 6.0 ms², so the total p95 lands near 24 ms), then measure the end-to-end distribution with a timestamp on every frame rather than trusting the arithmetic. The lesson inside the exercise: means compose by addition, tails compose by quadrature, and the stage with the biggest mean usually owns the tail.

    Terms this lesson borrows from later lessons (or outside)

    You do not need to master these here. Each one gets a proper treatment in its own lesson; the one-line meaning is enough to keep reading. Orange dotted underlines in the prose point back to this list.

    • quantisation (Phase 10, Lesson 11)The general treatment of “making models fit”: affine quantisation, scale and zero point, per-tensor vs per-channel vs per-group, and how integer arithmetic maps back to floats. This lesson is its vision-specific application.
    • inference optimisation (Phase 10, Lesson 12)KV caches, batching, kernel fusion and the throughput/latency trade-off for language models — the same budget arithmetic this lesson applies to a single frame.
    • inference metrics and P99 (Phase 17, Lesson 8)TTFT, TPOT, ITL, goodput and P99 as production metrics. The statistical machinery is identical: report percentiles, watch the tail, and measure on the serving target.
    • edge inference (Phase 17, Lesson 12)Apple Neural Engine, Qualcomm Hexagon, WebGPU/WebLLM and Jetson: the runtime tier below ONNX Runtime that this lesson's export step eventually feeds.
    • vision transformers (Phase 4, Lesson 14)Patch embeddings, attention and the ViT family. MobileViT-S, one of the four backbones in the picker, is a hybrid of this lesson's convnets and that lesson's attention blocks.
    • debugging and profiling (Phase 0, Lesson 12)The profiling habits this lesson leans on — measure before you optimise, warm up, and compare against a baseline — applied to per-layer latency instead of memory or throughput.
    KEEP GOING

    A picture is a start.
    Practice is the rest.

    This lesson is a port of an open course. Everything here traces back to it — and the next step is running the code yourself.

    Lesson text adapted from AI Engineering from Scratch (Phase 04, Lesson 15) and the Math Foundations Notebook reference build. The five labs — the 33.3 ms budget board, the FLOPs/params/latency comparator, the INT8 quantisation playground, the export-failure triage board and the constraint-driven model chooser — are original to this page, as are the stage arithmetic (30 fps = 33.33 ms; a 4 + 26 + 3 ms pipeline spends 99% of the frame; 37 ms of work drops 3 frames in 30), the tail arithmetic (p95 = p50·e^(1.645σ), p99 = p50·e^(2.326σ), with the σ = 0.10 and σ = 0.35 examples), the convolution FLOP examples (ResNet-50's 7×7 stem at 0.236 GFLOPs ≈ 5.8% of the model; depthwise convs with a 16× arithmetic discount), the weight-streaming numbers (102.4 MB FP32 versus 25.6 MB INT8 at 50 GB/s), the accuracy-per-GFLOP frontier (23.3 / 5.7 / 3.3 / 2.2 points per added GFLOP) and the memory hook “PTQ has no labels, QAT does; dynamic leaves activations floating, static does not.” The latency distributions, device throughputs and architecture efficiency factors are labelled teaching models, because a real benchmark suite cannot be replayed inside a canvas; every other number is computed live by the labs or is a published figure quoted from the papers and documentation in the source list.