TAI BUI
Back to curriculum
BuildPython

Real-Time Vision — Edge Deployment

Edge inference is the discipline of getting a 90-accuracy model to run at 30 fps on a device with 2 GB of RAM. Every percentage point of accuracy is traded against milliseconds of latency.

TIME: ~75 minutesPREREQUISITES: Phase 4 Lesson 04 (Image Classification), Phase 10 Lesson 11 (Quantization)

Learning Objectives

  • Measure inference latency, peak memory, and throughput for any PyTorch model, and read the FLOPs / params / latency trade-off
  • Quantise a vision model to INT8 using PyTorch's post-training quantisation and verify accuracy loss < 1%
  • Export to ONNX and compile with ONNX Runtime or TensorRT; name the three most common export failures and their fixes
  • Explain when to pick MobileNetV3, EfficientNet-Lite, ConvNeXt-Tiny, or MobileViT for an edge constraint

The Problem

A training-time vision model is a floating-point monster. 100M parameters, 10 GFLOPs per forward pass, 2 GB of VRAM. None of that fits on a phone, a car's infotainment unit, an industrial camera, or a drone. Shipping a vision system means fitting the same predictions into a budget that is 100x smaller.

Three knobs do most of the work: model choice (a smaller architecture with the same recipe), quantisation (INT8 instead of FP32), and the inference runtime (ONNX Runtime, TensorRT, Core ML, TFLite). Getting them right is the difference between a demo that runs on a workstation and a product that ships on a $30 camera module.

This lesson sets up the measurement discipline first (you cannot optimise what you cannot measure), then walks the three knobs. The goal is not to learn every edge runtime but to know what levers exist and how to verify each one does what you think.

Warm-up0/3
1. A model that classifies images perfectly on your workstation's GPU crashes with an out-of-memory error the moment you try to run it on a phone. What's the most likely reason?Warm-up
A workstation GPU panel running the model fine next to a phone panel showing the same model crashing, with a dashed question mark box asking why only the phone fails.
2. Replacing a model's FP32 weights with INT8 versions is a standard move before deployment. What's the basic tradeoff it makes?Warm-up
An FP32 weight box at 32 bits per value with an arrow to a visibly smaller INT8 weight box at 8 bits per value, above a dashed question mark box asking what the swap trades away.
3. You time a function once and it takes far longer than the next nine identical calls. What is the most plausible reason?Warm-up
A bar chart of ten calls to the same function, call 1 much taller than calls 2 through 10, which are all short and roughly equal.
Illustrative relative shape, not a captured timing.

The Concept

The three budgets

Mermaid diagram

  • Latency: p50, p95, p99. Averaging only p50 hides tail behaviour that matters for real-time systems.
  • Peak memory: the maximum the device ever sees, not the steady-state average. Matters because OOMs are fatal on embedded targets.
  • Power / energy: millijoules per inference on a battery-powered device. Often proxied by CPU/GPU utilisation * time.

Throughput falls out of latency instead of needing its own benchmark: at batch size 1, throughput in images/sec is roughly 1000 / mean_latency_ms, and you scale that by the batch size for batched inference. Report it alongside latency, not instead of it. A model can have great single-image latency and still choke on throughput if batching does not parallelise well on the target hardware, or vice versa on hardware that only pays off in large batches.

An edge decision comes down to one table: model, latency, throughput, memory, accuracy. Every cell in it gets measured on the target device, not the workstation.

Measurement discipline

Three rules that every edge profile should follow:

  1. Warm up the model with 5-10 dummy forward passes before measuring. Cold caches and JIT compilation produce unrepresentative first numbers.
  2. Synchronise GPU workloads with torch.cuda.synchronize() before and after the timed block. Without this you measure kernel dispatch, not kernel execution.
  3. Fix input sizes to the production resolution. Latency on 224x224 is not latency on 512x512.

FLOPs as a proxy

FLOPs (floating-point operations per inference) is a cheap, device-independent proxy for latency. Useful for architecture comparison, misleading as absolute wall-clock. A model with 10% more FLOPs can be 2x faster in practice because it uses hardware-friendly ops (depthwise convs compile well, large 7x7 convs do not).

Rule of thumb: FLOPs for architecture search, on-device latency for deployment decisions.

Quantisation in one paragraph

Replace FP32 weights and activations with INT8. Model size drops 4x, memory bandwidth drops 4x, compute drops 2-4x on hardware that has INT8 kernels (every modern mobile SoC, every NVIDIA GPU with Tensor Cores). Accuracy loss on vision tasks is typically 0.1-1 percentage points with post-training static quantisation.

Types:

  • Dynamic: quantise weights to INT8, activations computed in FP. Easy, small speedup.
  • Static (post-training): quantise weights + calibrate activation ranges on a small calibration set. Much faster than dynamic.
  • Quantisation-aware training (QAT): simulate quantisation during training so the model learns around it. Best accuracy, needs labelled data.

For vision, post-training static quantisation gives 95% of the benefit with 5% of the effort. Use QAT only when accuracy loss from PTQ is unacceptable.

Pruning and distillation

  • Pruning: remove unimportant weights (magnitude-based) or channels (structured). Works well on overparameterised models; less useful on already-compact architectures.
  • Distillation: train a small student to mimic a large teacher's logits. Often recovers most of the accuracy lost by shrinking the model. Standard for production edge models.

The inference runtimes

  • PyTorch eager: slow, not for deployment. Use for development only.
  • TorchScript: legacy. Superseded by torch.compile and ONNX export.
  • ONNX Runtime: the neutral runtime. CPU, CUDA, CoreML, TensorRT, OpenVINO all have ONNX providers. Start here.
  • TensorRT: NVIDIA's compiler. Best latency on NVIDIA GPUs (workstation and Jetson). Integrates with ONNX Runtime or standalone.
  • Core ML: Apple's runtime for iOS/macOS. Needs .mlmodel or .mlpackage.
  • TFLite: Google's runtime for Android/ARM. Needs .tflite.
  • OpenVINO: Intel's runtime for CPU/VPU. Needs .xml + .bin.

In practice: export PyTorch -> ONNX -> pick the runtime for the target. ONNX is the lingua franca.

Edge architecture picker

BudgetModelWhy
< 3M paramsMobileNetV3-SmallCompiles everywhere, good baseline
3-10MEfficientNet-Lite-B0Best accuracy per param on TFLite
10-20MConvNeXt-TinyBest accuracy-per-param, CPU-friendly
20-30MMobileViT-S or EfficientViTTransformer with ImageNet accuracy
30-80MSwin-V2-TinyIf stack supports window attention

Quantise all of these to INT8 unless you have a specific reason not to.

Check: The Concept0/28
1. What is latency for an inference system?Define
An input, model and output chain with a dashed span beneath it marked only by a question mark, and below that 18 dots for repeated runs of the same measurement, sorted low to high.
2. What does the FLOPs figure of a model measure?Define
A box labelled model(x), one forward pass, with an arrow to a dashed question mark box standing for a single figure reported for that pass.
3. What does INT8 quantisation do to a vision model?Define
A trained vision model flowing through a dashed box named INT8 quantisation on its way to the device, with a note that the target hardware has INT8 kernels.
4. What is QAT (quantisation-aware training)?Define
A dashed training-loop frame containing a forward pass box, an arrow into a dashed question mark box, and an arrow into an update weights box, noted as repeating on every iteration.
5. What is ONNX used for when deploying a vision model?Define
The lesson's torch.onnx.export call at opset 17 producing a model.onnx file, with a dashed question mark box asking what a file like this is for at deployment time.
6. What does TensorRT do?Define
A box labelled TensorRT between two dashed question mark boxes, one for what it takes in and one for what it produces.
7. What is distillation?Define
A card headed distillation with three blanked rows: what it starts from, what it produces, and what it costs.
8. Which quantisation approach calibrates activation ranges on a small calibration set, without retraining, and gives 95% of the benefit for 5% of the effort on vision models?Define
A trained FP32 model flowing through a dashed question mark box into an INT8 model, with a small calibration set feeding into that step from below.
9. Why does an edge profile run 5-10 dummy forward passes before timing anything?Explain
A code box showing a warmup loop of model(x) calls, highlighted, before a comment marking where timing starts, with a question asking why the loop runs first.
10. Why does the lesson tell you to track peak memory instead of the steady-state average?Explain
A memory-over-time curve that stays flat for most of the run with one brief spike far above the rest.
Illustrative shape, not a captured trace.
11. FLOPs and on-device latency do not correlate perfectly. Why?Explain
A table of two candidate models with their FLOPs given, baseline and ten percent more, and their on-device p50 cells left as question marks.
12. You've measured p50 latency for a model at batch size 1. A teammate asks for throughput instead. How do you get it without re-benchmarking?Explain
A badge reading measured, p50 latency at batch 1, with an arrow to a dashed question mark box asking for throughput without re-benchmarking.
13. How does dynamic quantisation differ from post-training static quantisation?Explain
A dynamic quantisation box and a static post-training quantisation box, each above its own dashed question mark box standing for what that one does.
14. Post-training static INT8 quantisation typically loses how much accuracy on vision models?Explain
An FP32 model with known accuracy flowing through a PTQ box into a dashed question mark box labelled accuracy change.
15. Why does the lesson recommend QAT only when PTQ's accuracy loss is unacceptable?Explain
A run PTQ box leading to a loss acceptable decision, the yes branch going to a ship it box and the no branch going to a dashed question mark box.
16. Pruning and distillation are both used to shrink a model. What's the actual difference between them?Explain
A box labelled pruning and a box labelled distillation, each with a dashed question mark box underneath asking what it acts on.
17. Why is pruning described as less useful on an already-compact architecture such as MobileNetV3-Small than on a large model?Explain
A large box labelled large model, many weights, next to a much smaller box labelled MobileNetV3-Small, with a dashed question mark box asking why pruning pays off more on the large one.
18. Which inference runtime is called 'the neutral runtime' because CPU, CUDA, CoreML, TensorRT, and OpenVINO all provide execution providers for it?Explain
A dashed question mark hub with five spokes to named boxes, CPU, CUDA, CoreML, TensorRT and OpenVINO, asking what the hub is.
19. Why does the lesson say to export PyTorch to ONNX first and then pick a runtime for the target?Explain
A PyTorch model box flowing into a model.onnx box, then into a dashed question mark box for the runtime that gets chosen after the export.
20. Why does the lesson's picker list MobileNetV3-Small for the smallest parameter budget?Explain
A card stating budget under 3M parameters, model MobileNetV3-Small, with a dashed question mark box asking why this one specifically.
21. A model has a mean latency of 5 ms at batch size 1. About how many images per second can it process at batch size 1?Apply
A badge reading mean latency, 5 ms, with an arrow to a dashed question mark box asking for images per second at batch 1.
22. Your device budget is about 15M parameters and you need a CPU-friendly model with the best accuracy per parameter. Which model does the lesson's picker give for that range?Apply
A card stating a budget of about 15M parameters, needing a CPU-friendly model with the best accuracy per parameter, with a dashed question mark box asking which model.
23. You benchmark a model and report 'average 12ms per image on a 4090'. What is missing?Analyze
A card quoting the report line, average 12ms per image on a 4090, with a dashed question mark box asking what is missing from it.
24. After static PTQ, a vision model has lost 3 points of accuracy, which your product cannot accept, and you have labelled training data. What does the lesson's guidance point to next?Analyze
A card stating 3 percentage points of accuracy were lost, which the product can't accept, and labelled training data is available, with a dashed question mark box asking for the next move.
25. A team quantised a vision model and moved it to TensorRT, then found three days later that the bottleneck was a resize op running on CPU between two GPU calls. Which step did they skip?Analyze
A pipeline of FP32 model, quantise, TensorRT, deployed, with a note that a resize op on the CPU between two GPU calls was found three days later, and a dashed question mark box asking which step was skipped.
26. A team picks the candidate with the lower FLOPs count for deployment without running it on the device. What assumption is wrong?Analyze
A card listing candidate A with a lower FLOPs count and candidate B with a higher one, with A picked without any run on the target device, and a dashed question mark box asking what assumption is doing the work.
27. Your mobile app needs a vision model under 10MB with a tight latency budget. Which backbone do you pick?Design
A card stating model size under 10MB and a tight latency budget, with a dashed question mark box asking which backbone to pick.
28. A camera product has a real-time latency SLA. Which measurement plan best decides whether a candidate model meets it?Design
A card stating a camera product has a hard real-time latency SLA and a shortlist of candidate backbones, with a dashed question mark box asking which measurement plan decides it.

Build It

Step 1: Measure latency correctly

python
import time
import torch
 
def measure_latency(model, input_shape, device="cpu", warmup=10, iters=50):
    model = model.to(device).eval()
    x = torch.randn(input_shape, device=device)
    with torch.no_grad():
        for _ in range(warmup):
            model(x)
        if device == "cuda":
            torch.cuda.synchronize()
        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[int(len(times) * 0.99)],
        "mean_ms": sum(times) / len(times),
    }

Warm up, synchronise, use time.perf_counter(). Report percentiles, not just mean.

Step 2: Parameter and FLOP counts

python
def parameter_count(model):
    return sum(p.numel() for p in model.parameters())
 
def flops_estimate(model, input_shape):
    """
    Rough FLOP count for a conv/linear-only model. For production use `fvcore` or `ptflops`.
    """
    total = 0
    def conv_hook(m, inp, out):
        nonlocal total
        c_out, c_in, kh, kw = m.weight.shape
        h, w = out.shape[-2:]
        total += 2 * c_in * c_out * kh * kw * h * w
    def linear_hook(m, inp, out):
        nonlocal total
        total += 2 * m.in_features * m.out_features
    hooks = []
    for m in model.modules():
        if isinstance(m, torch.nn.Conv2d):
            hooks.append(m.register_forward_hook(conv_hook))
        elif isinstance(m, torch.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

For real projects use fvcore.nn.FlopCountAnalysis or ptflops; they handle every module type correctly.

Step 3: Post-training static quantisation

python
def quantise_ptq(model, calibration_loader, backend="x86"):
    import torch.ao.quantization as tq
    model = model.eval().cpu()
    model.qconfig = tq.get_default_qconfig(backend)
    tq.prepare(model, inplace=True)
    with torch.no_grad():
        for x, _ in calibration_loader:
            model(x)
    tq.convert(model, inplace=True)
    return model

Three steps: configure, prepare (insert observers), calibrate with real data, convert (fuse + quantise). Requires the model to be fused (Conv -> BN -> ReLU -> ConvBnReLU), which torch.ao.quantization.fuse_modules handles.

Step 4: Export to ONNX

python
def export_onnx(model, sample_input, path="model.onnx"):
    model = model.eval()
    torch.onnx.export(
        model,
        sample_input,
        path,
        input_names=["input"],
        output_names=["output"],
        dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}},
        opset_version=17,
    )
    return path

opset_version=17 is the safe default in 2026. dynamic_axes lets you run the ONNX model with arbitrary batch size.

When export fails with an "Unsupported operator" error, it is almost always one of three things: a custom op with no ONNX mapping, a control-flow path (an if or a data-dependent loop) that tracing cannot follow because it is not deterministic, or a Python-level tensor-scalar interaction that has no ONNX equivalent. Fix the first by swapping in a supported op, the second by switching from tracing to torch.jit.script, and the third by bumping the opset version if a later one added support.

Step 5: Benchmark and compare regimes

python
import torch.nn as nn
from torchvision.models import mobilenet_v3_small
 
def compare_regimes():
    model = mobilenet_v3_small(weights=None, num_classes=10)
    params = parameter_count(model)
    flops = flops_estimate(model, (1, 3, 224, 224))
    lat_fp32 = measure_latency(model, (1, 3, 224, 224), device="cpu")
    print(f"FP32 MobileNetV3-Small: {params:,} params  {flops/1e9:.2f} GFLOPs  "
          f"p50={lat_fp32['p50_ms']:.2f}ms  p95={lat_fp32['p95_ms']:.2f}ms")

Run the same function for resnet50, efficientnet_v2_s, and convnext_tiny and you have the comparison table you need for a deployment decision.

Check: Build It0/9
1. You export a PyTorch model to ONNX with opset 17 and it fails with 'Unsupported operator'. What are the most likely causes?Explain
A Python session showing a torch.onnx.export call at opset 17 followed by RuntimeError: Unsupported operator, with a dashed question mark box asking what family of causes produces this error.
Typed, not captured from a run.
2. Inside measure_latency with iters=50, the 50 timings are sorted. Which element does the 'p50_ms' value use?Apply
A code box with times.sort() and the p50_ms formula times[len(times)//2], above a row of ten small boxes standing in for a 50-element sorted list, asking which index p50_ms reads.
3. measure_latency is called with iters=100. Which timing does 'p99_ms' return?Apply
A code box with the p99_ms formula times[int(len(times) * 0.99)], with a dashed question mark box asking which index it reads when there are 100 sorted timings.
4. flops_estimate's linear hook adds 2 * in_features * out_features. How many FLOPs does a Linear(1280, 10) layer add?Apply
A code box with the linear-hook formula total += 2 times in_features times out_features, and a card stating this layer is nn.Linear(1280, 10).
5. flops_estimate's conv hook adds 2 * c_in * c_out * kh * kw * h * w. A Conv2d with 3 input channels, 16 output channels and 3x3 kernels produces an 8x8 output. How many FLOPs does it add?Apply
A code box with the conv-hook formula total += 2 times c_in times c_out times kh times kw times h times w, and a card giving c_in 3, c_out 16, a 3x3 kernel and an 8x8 output.
6. In quantise_ptq, what happens in the loop that runs the calibration_loader through the model between prepare and convert?Apply
A code box showing tq.prepare, then a highlighted loop forwarding calibration_loader batches through the model, then tq.convert, asking what the highlighted loop does.
7. A model was exported with export_onnx using a sample input of batch size 1. You run the resulting file on a batch of 8 images. What happens?Apply
A code box with the lesson's full torch.onnx.export call, and a card stating the sample input had batch size 1 at export but the exported file is then run with batch size 8.
8. A teammate's GPU benchmark reports 0.4 ms per forward pass, but the same model takes about 15 ms in the app. Their timing loop calls model(x) between two perf_counter reads and never calls torch.cuda.synchronize(). What is the most likely explanation?Analyze
A timing loop with no cuda synchronize highlighted, between two badges, this loop reports 0.4 ms and the app actually sees about 15 ms.
9. Your ONNX export fails at an `if` whose condition depends on the tensor values in the input. Which fix matches the cause?Analyze
A code box with an if statement whose condition depends on a tensor's values, highlighted, under a line asking which fix matches this cause.
Illustrative code; the lesson describes this failure in prose, not this exact snippet.

Use It

Production stacks converge on one of three paths:

  • Web / serverless: PyTorch -> ONNX -> ONNX Runtime (CPU or CUDA provider). Easiest, good enough for most.
  • NVIDIA edge (Jetson, GPU server): PyTorch -> ONNX -> TensorRT. Best latency, biggest engineering effort.
  • Mobile: PyTorch -> ONNX -> Core ML (iOS) or TFLite (Android). Quantise before export.

For measurement, torch-tb-profiler, nvprof / nsys, and Instruments on macOS give layer-by-layer breakdowns. benchmark_app (OpenVINO) and trtexec (TensorRT) give standalone CLI numbers.

Check: Use It0/4
1. You're shipping a vision model to an Android phone. Which export path does the lesson recommend?Apply
A trained PyTorch model box, a dashed question mark box for the export path, and an Android phone box as the target.
2. You're deploying a vision model to an NVIDIA Jetson device where latency matters most. Which export path does the lesson recommend, and how does it differ from the web/serverless path?Apply
A trained PyTorch model box, a dashed question mark box for the export path, and an NVIDIA Jetson box as the target.
3. You have a compiled TensorRT engine and want standalone command-line latency numbers without writing a Python script. Which tool fits?Apply
A box labelled TensorRT engine, already compiled, next to a dashed question mark box, under a note asking for standalone command-line latency numbers with no Python script.
4. One PyTorch vision model must ship to iPhones, Android phones and a Jetson box. Which plan keeps the pipeline simplest?Design
One trained PyTorch model above a dashed question mark box for the plan, with an iPhone box, an Android phone box and a Jetson box as the three targets below it.

Ship It

This lesson produces:

  • outputs/prompt-edge-deployment-planner.md, a prompt that picks backbone, quantisation strategy, and runtime given target device and latency SLA.
  • outputs/skill-latency-profiler.md, a skill that writes a complete latency-benchmarking script with warmup, synchronisation, percentiles, and memory tracking.
Author's Note

The mistake I see most often isn't a bad architecture choice. It's skipping the measurement step and jumping straight to quantisation or TensorRT because that feels like the real work. Three days later someone finally profiles the FP32 baseline and finds out the bottleneck was never the model. It was a resize op running on CPU between two GPU calls. Measure first, every time, before you touch a single weight.

FLOPs is the other trap. It's tempting to pick whichever model has the smaller number on paper and call it done, but a depthwise-heavy network with more FLOPs can beat a dense one with fewer, purely because of how the two shapes map onto the hardware you're actually running on. On-device latency is the only number that gets you paid — treat everything else as a hint, not an answer.

Exercises

  1. (Easy) Measure p50 latency for resnet18, mobilenet_v3_small, efficientnet_v2_s, and convnext_tiny at 224x224 on CPU. Report the table and identify which architecture has the best accuracy-per-ms.
  2. (Medium) Apply post-training static quantisation to mobilenet_v3_small. Report FP32 vs INT8 latency and accuracy loss on a held-out subset of CIFAR-10 or similar.
  3. (Hard) Export convnext_tiny to ONNX, run it through onnxruntime with the CPUExecutionProvider, and compare latency to the PyTorch eager baseline. Identify the first layer where ONNX Runtime is faster and explain why.

Key Terms

TermWhat people sayWhat it actually means
Latency"How fast"Time from input to output; p50/p95/p99 percentiles, not mean
FLOPs"Model size"Floating-point ops per forward pass; rough proxy for compute cost
INT8 quantisation"8-bit"Replace FP32 weights/activations with 8-bit integers; ~4x smaller, 2-4x faster
PTQ"Post-training quantisation"Quantise a trained model without retraining; easy, usually enough
QAT"Quantisation-aware training"Simulate quantisation during training; best accuracy, requires labelled data
ONNX"The neutral format"Model exchange format supported by every mainstream inference runtime
TensorRT"NVIDIA compiler"Compiles ONNX into an optimised engine for NVIDIA GPUs
Distillation"Teacher -> student"Train a small model to mimic a big model's logits; recovers most lost accuracy

Further Reading