Edge and On-device AI

Latency and throughput

Latency is how long one answer takes and throughput is how many answers per second — they trade against each other, and on the edge latency almost always wins.

Read these first

On this page 8
  1. The short answer
  2. The analogy you have already lived
  3. Which one matters on a device
  4. The number people report is the wrong one
  5. What makes runs slow sometimes
  6. How to measure without fooling yourself
  7. Remember this
  8. What to learn next

One lesson, three depths. Pick the one that fits you today — you can switch any time.

Beginner — No maths. Plain English.

The short answer

Latency is how long you wait. Throughput is how many people get served each hour. They are not the same, and improving one often hurts the other.

The analogy you have already lived

You are standing at the roadside. An auto-rickshaw is right there, and a bus is due.

The auto leaves the moment you sit down. That is low latency — your own wait is short.

The bus carries fifty people at once. Over an hour it moves far more people than the auto ever could. That is high throughput.

The bus is worse for you and better for the city. It has to wait until it is full, and that waiting is exactly what buys the throughput.

Serving a model is the same choice. Answer each request the moment it arrives, or collect a group and answer them together.

Which one matters on a device

On a server, throughput usually wins. A thousand people are waiting, and serving more of them per second is what you are paid for.

On a phone there is only one person. There is nobody to group with. Latency is the only number the user can feel.

This is the deepest difference between cloud AI and edge AI, and it changes almost every decision that follows.

The number people report is the wrong one

Most benchmarks report an average. An average hides the problem.

Imagine a camera that takes 10 milliseconds for most frames, and once every hundred frames takes 200 milliseconds. The average is about 12 milliseconds, which sounds fine. The user sees a stutter every three seconds, which is not fine.

So measure it this way instead:

  • p50 — the middle. Half of runs are faster than this.
  • p90 — nine out of ten runs are faster.
  • p99 — ninety-nine out of a hundred are faster. This is the one users complain about.

The gap between p50 and p99 is the honest description of how smooth something feels.

What makes runs slow sometimes

  • Another app woke up and took the processor.
  • The chip got hot and slowed itself down.
  • The first run had to load things into memory.
  • The phone dropped its speed to save battery.

None of this appears in an average. All of it appears in a p99.

How to measure without fooling yourself

  1. run it many times and throw the first ones away    <- warm-up
  2. keep every timing, not the average
  3. sort them and read p50, p90, p99
  4. do it on the real device, on battery, warm

Step four is the one people skip. A benchmark on a plugged-in laptop with a cold chip is the best case. Nobody uses your product in the best case.

Remember this

  • Latency is one person's wait. Throughput is people served per hour.
  • On a device, latency wins, because there is only one person.
  • Report p50, p90 and p99, never an average.

What to learn next

Developer — Code and libraries.

Setup

bash
pip install torch onnxruntime numpy

The script builds two models with random weights. Latency depends on a model's shape rather than on its trained values, so random weights measure exactly the same as trained ones and take no time to produce.

Measuring properly

benchmark.py
import time, statistics as st, numpy as np, torch, torch.nn as nn, onnxruntime as ort

torch.manual_seed(0)
mlp = nn.Sequential(nn.Linear(64, 256), nn.ReLU(), nn.Linear(256, 256), nn.ReLU(), nn.Linear(256, 10)).eval()
torch.onnx.export(mlp, torch.zeros(1, 64), "mlp.onnx", input_names=["x"], output_names=["y"],
                  dynamic_axes={"x": {0: "batch"}, "y": {0: "batch"}}, opset_version=13)

cnn = nn.Sequential(
    nn.Conv2d(3, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
    nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
    nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.AdaptiveAvgPool2d(1),
    nn.Flatten(), nn.Linear(64, 10)).eval()
torch.onnx.export(cnn, torch.zeros(1, 3, 64, 64), "cnn.onnx", input_names=["x"], output_names=["y"],
                  dynamic_axes={"x": {0: "batch"}, "y": {0: "batch"}}, opset_version=13)

rng = np.random.default_rng(0)

def session(path):
    so = ort.SessionOptions()
    so.intra_op_num_threads = 1        # pin the threads, or the numbers move with system load
    so.inter_op_num_threads = 1
    return ort.InferenceSession(path, so, providers=["CPUExecutionProvider"])

def run_times(sess, x, n):
    for _ in range(30):                # warm-up: allocation, thread start, cache fill
        sess.run(None, {"x": x})
    out = []
    for _ in range(n):                 # keep EVERY timing, never an average
        t = time.perf_counter(); sess.run(None, {"x": x}); out.append((time.perf_counter() - t) * 1000)
    return out

s_mlp, s_cnn = session("mlp.onnx"), session("cnn.onnx")

one = rng.standard_normal((1, 64), dtype=np.float32)
ts = sorted(run_times(s_mlp, one, 1000))
print("one input, 1000 runs, one CPU thread")
print("  fastest %.4f ms   p50 %.4f   p90 %.4f   p99 %.4f   slowest %.4f"
      % (ts[0], ts[499], ts[899], ts[989], ts[-1]))
print("  the slowest run is %.0fx the median" % (ts[-1] / ts[499]))
print()

print("small dense model")
print("  batch   ms per call   inputs/second   ms per input")
for b in [1, 2, 4, 8, 16, 32, 64, 128, 256]:
    x = rng.standard_normal((b, 64), dtype=np.float32)
    m = st.median(run_times(s_mlp, x, 200))
    print("  %5d   %10.3f    %12.0f    %10.4f" % (b, m, b / (m / 1000), m / b))
print()

print("convolutional model")
print("  batch   ms per call   images/second   ms per image")
for b in [1, 2, 4, 8, 16, 32]:
    x = rng.standard_normal((b, 3, 64, 64), dtype=np.float32)
    m = st.median(run_times(s_cnn, x, 60))
    print("  %5d   %10.3f    %12.0f    %10.4f" % (b, m, b / (m / 1000), m / b))
Output
one input, 1000 runs, one CPU thread
  fastest 0.0108 ms   p50 0.0111   p90 0.0115   p99 0.0201   slowest 0.0486
  the slowest run is 4x the median

small dense model
  batch   ms per call   inputs/second   ms per input
      1        0.011           90090        0.0111
      2        0.012          172414        0.0058
      4        0.013          310078        0.0032
      8        0.019          427808        0.0023
     16        0.027          592593        0.0017
     32        0.047          686695        0.0015
     64        0.083          772480        0.0013
    128        0.159          807062        0.0012
    256        0.314          814897        0.0012

convolutional model
  batch   ms per call   images/second   ms per image
      1        0.153            6532        0.1531
      2        0.301            6647        0.1504
      4        0.590            6774        0.1476
      8        1.190            6725        0.1487
     16        2.403            6658        0.1502
     32        4.899            6532        0.1531

Every number here is a measurement on one machine, so yours will differ. The shapes of the two tables are what generalise, and they generalise strongly.

The tail is real, and it is not small

Look at the first block. The median is 0.0111 ms and the slowest of a thousand runs is 0.0486 ms — four and a half times slower, on an idle desktop, with a model doing almost nothing. Rerun it and that ratio moves: eight times is common, and so is three. The tail is the least stable thing you can measure, which is exactly why one run of it is not a result.

p99 is already 1.8 times the median. On a phone under real conditions, with other apps and a warm chip, that ratio grows. Quote a median as your latency and you have described the case that never worries anyone.

The dense model: batching is enormously effective

90,090 inputs per second at batch 1. 814,897 at batch 256. Nine times more throughput, from the same model on the same core.

Where did that come from? Look at the last column. One input costs 0.0111 ms; when batched 256-wide it costs 0.0012 ms. Almost all of that 0.0111 ms was never arithmetic. It was fixed cost — the Python call, ONNX Runtime's input binding, the memory allocation. Batching amortises it over 256 inputs.

Now look at the second column, and notice what it cost. A single call went from 0.011 ms to 0.314 ms. Any individual input now waits 28 times longer. That is the auto-rickshaw and the bus, in a table.

The convolutional model: batching does nothing at all

6532 images per second at batch 1. 6532 at batch 32. Identical.

The per-image time never moves from about 0.15 ms, because for this model the time genuinely is arithmetic. There was no fixed cost to amortise. Batching bought nothing and made each individual answer 32 times slower to arrive.

This is the result to remember. Batching is not a general speed-up. It converts fixed per-call cost into throughput. When there is no fixed cost worth converting, batching is pure loss.

And a device usually has one input at a time anyway. Which is why the edge answer is nearly always batch size 1.

How to find out which case you are in

Run the two-point test. Measure at batch 1 and batch 8:

  • Throughput rose a lot? You are overhead-bound. Reduce per-call cost — fewer sessions, reuse buffers, drop a layer of framework.
  • Throughput barely moved? You are compute-bound or memory-bound. Only a smaller model, a lower resolution, or better hardware will help.

Two measurements, and you know which half of the optimisation space to work in.

Common mistakes

Reporting an average. Now that you have seen a 4.5x tail on an idle machine, you know what the average hid.

Not warming up. The first call includes memory arena allocation and thread pool creation. Including it in a 10-run benchmark can double your reported latency.

Leaving the thread count to chance. Without intra_op_num_threads, ONNX Runtime picks based on visible cores, and a container may show 64 cores while the cgroup allows two. Pin it.

Measuring only inference. The user waits for the whole path: camera capture, resize, normalise, inference, draw. Preprocessing is frequently larger than inference for small models. Time the whole loop.

Ignoring model load time. A model that loads in 400 ms and infers in 5 ms is a 405 ms experience for the first user action. Measure cold start separately and report it.

Benchmarking on mains power. Phones and laptops run slower on battery. Measure on battery, warm, with the screen on.

Try it yourself

Change intra_op_num_threads to 4 and rerun. Compare the batch-1 row for both models. Then increase the convolutional model's input from 64x64 to 128x128 and watch the per-image time roughly quadruple while the batching behaviour stays exactly the same. That is the difference between the two axes of this lesson.

What to learn next

Researcher — Mathematics and papers.

Definitions, stated so they cannot be confused

For a system processing requests with per-request service time $s$ and batch size $B$:

$$ L_{\text{batch}} = t_{\text{fixed}} + B \cdot t_{\text{per-item}}, \qquad X = \frac{B}{L_{\text{batch}}} = \frac{B}{t_{\text{fixed}} + B\, t_{\text{per-item}}} $$

  • $L_{\text{batch}}$ — wall-clock time for one call.
  • $X$ — throughput in items per second.
  • $t_{\text{fixed}}$ — per-call cost independent of batch size: dispatch, binding, allocation, kernel launch.
  • $t_{\text{per-item}}$ — marginal cost of one more item.

As $B \to \infty$, $X \to 1/t_{\text{per-item}}$. The achievable throughput gain from batching is therefore exactly

$$ \frac{X_{\infty}}{X_1} = \frac{t_{\text{fixed}} + t_{\text{per-item}}}{t_{\text{per-item}}} = 1 + \frac{t_{\text{fixed}}}{t_{\text{per-item}}} $$

The two tables in the developer block are this ratio measured. The dense model has $t_{\text{fixed}}/t_{\text{per-item}} \approx 8$, giving the 9x observed. The convolutional model has $t_{\text{fixed}} \approx 0$ relative to its compute, giving 1x. Nothing else needs to be said about when batching helps.

Little's Law

For any stable queueing system,

$$ L = \lambda W $$

where $L$ is the mean number of items in the system, $\lambda$ the arrival rate, and $W$ the mean time an item spends in it. The law is distribution-free — it assumes only stability.

The consequence people misuse: throughput and latency are linked through concurrency, so quoting one without the other is meaningless. A system reporting 10,000 requests per second at 5 ms latency is running about 50 concurrent requests. If it reports 10,000 per second at 500 ms, it is running 5,000, and something is deeply queued.

On a device $\lambda$ is bounded by the sensor — 30 frames per second from a camera — and concurrency is 1. Little's Law then says $W$ must be under 33 ms or the queue grows without bound. Drop frames deliberately rather than letting a queue build, because a queue converts a throughput deficit into unbounded latency.

Why the tail exists

Sources of variance, roughly in order of magnitude on a device:

  1. DVFS transitions. The governor raises and lowers clock frequency on demand. Ramp-up takes milliseconds, so a burst after idle runs at a low clock.
  2. Scheduler preemption. Another process takes the core. On big.LITTLE architectures the scheduler may also migrate the thread to a small core mid-run.
  3. Thermal throttling. Covered in power and thermal limits.
  4. Memory system. Page faults, TLB misses, cache eviction by another process.
  5. Garbage collection or allocator behaviour in the host language.

Dean and Barroso's The Tail at Scale (2013) is about fan-out in distributed systems, but the mechanisms transfer directly: shared resources with occasional long occupancy produce heavy-tailed service times, and the tail dominates perceived quality.

Note that a tail measurement is itself noisy. The p99 of 1000 samples is estimated from about 10 observations, and its own confidence interval is wide. Report the sample count alongside the percentile, or the percentile is not a claim.

Roofline, applied to the two tables

The convolutional model's flat throughput is a roofline result. Arithmetic intensity for a convolution with $C_{\text{in}}$ input channels, $C_{\text{out}}$ output channels and a $k \times k$ kernel over an $H \times W$ output is

$$ I = \frac{2 H W C_{\text{in}} C_{\text{out}} k^2}{4\left(H W C_{\text{in}} + H W C_{\text{out}} + C_{\text{in}} C_{\text{out}} k^2\right)} $$

with the numerator counting multiply-adds and the denominator counting float32 bytes touched. For typical values this is in the tens to hundreds — well past the machine balance point — so the layer is compute-bound. A compute-bound kernel gains nothing from batching, because batching only reduces memory traffic per item.

A dense layer at batch $B$ has $I \approx B/2$: two operations per weight per item, four bytes per weight, amortised over $B$ items. At $B = 1$ this is 0.5 and firmly memory-bound; the reason batching helps a dense layer at all is that it moves $I$ up the roofline. That the effect saturates by $B \approx 128$ in the measurement is the point where the model returns to being compute-bound.

Measurement methodology

  • Pin the thread count. Do not let the runtime infer it, and never benchmark under a container whose cgroup limit differs from nproc.
  • Fix the frequency governor where possible (performance on Linux), or report that you did not.
  • Report cold and warm separately. Model load, graph optimisation and first-allocation costs are real user-visible costs and are not latency.
  • Use a steady arrival rate, not a closed loop, when the deployment has one. A closed-loop benchmark that issues the next request only after the previous returns cannot produce queueing, and therefore cannot reproduce the tail that queueing causes. This is the coordinated omission problem, named by Gil Tene.
  • Report the sample count and the percentile together.

MLPerf Inference (Reddi et al., 2020) codifies this into four scenarios — SingleStream, MultiStream, Server, Offline — precisely because a single number cannot describe both a phone and a datacentre. SingleStream reports 90th-percentile latency; Offline reports throughput. They are different benchmarks of the same model, and neither can be derived from the other.

Reading

  • Dean and Barroso, The Tail at Scale, CACM 2013.
  • Reddi et al., MLPerf Inference Benchmark, ISCA 2020 — arxiv.org/abs/1911.02549
  • Williams, Waterman and Patterson, Roofline, CACM 2009.
  • Gil Tene, How NOT to Measure Latency — the standard talk on coordinated omission.
  • Little, A Proof for the Queuing Formula L = λW, Operations Research 1961.

What to learn next