Benchmarking with MLPerf Tiny¶
Every module so far has measured latency ad hoc — a time.perf_counter()
loop around one function call. That's fine for comparing two versions of
your own pipeline, but it doesn't let you compare your device against
someone else's, or your model against a published number, because ad hoc
benchmarks differ in warm-up handling, what's included in the timing
window, and how outliers are treated. MLPerf Tiny is the standardized
benchmark suite for exactly this: a fixed set of reference models (keyword
spotting, visual wake words, image classification, anomaly detection) and
a fixed measurement methodology, so a number from one team's board is
comparable to another team's. This module covers what MLPerf Tiny actually
measures and reimplements its core statistical methodology in tested
Python, since running the real submission harness needs the reference
models and target hardware this environment doesn't have.
What MLPerf Tiny standardizes (and why each part matters)¶
| Standardized element | Why it matters |
|---|---|
| Four reference tasks (keyword spotting, visual wake words, image classification, anomaly detection) | Same task, same dataset, same accuracy target across every submission — no cherry-picking an easy task |
| EnergyRunner harness | Measures both latency and energy per inference, since a model can trade one for the other invisibly if only one is reported |
| Fixed accuracy threshold per task | A submission's latency number is only valid if accuracy stays at or above a published floor — you can't just make a model faster by making it worse |
| Median-of-many-runs latency, not single-run | A single latency measurement is dominated by noise (thermal throttling, other clock cycles, first-run effects); the standard requires many repeated runs |
| Closed vs. open divisions | "Closed" locks the model architecture so only implementation/compiler optimizations are compared; "open" allows model changes, compared separately |
The closed/open split is the detail that matters most for interpreting published results: a closed-division Cortex-M55 number and a closed- division Cortex-M4 number are directly comparable because they ran the identical model — the difference you're seeing is genuinely the hardware and compiler, not a smaller or different network hiding in one submission.
Reimplementing the core measurement methodology¶
The statistical backbone of any MLPerf-style benchmark is straightforward but easy to get subtly wrong: warm-up runs excluded, enough repetitions to get a stable distribution, and reporting percentiles (not just the mean, which hides tail latency that matters for real-time deadlines).
import numpy as np
import time
def benchmark_with_warmup(infer_fn, n_warmup=10, n_measured=200):
"""Runs infer_fn repeatedly, discarding warm-up runs (JIT/cache
effects, first-call overhead) before recording measured latencies.
Returns the full latency distribution in milliseconds."""
for _ in range(n_warmup):
infer_fn()
latencies_ms = []
for _ in range(n_measured):
t0 = time.perf_counter()
infer_fn()
t1 = time.perf_counter()
latencies_ms.append((t1 - t0) * 1000)
return np.array(latencies_ms)
def summarize_latency(latencies_ms):
"""MLPerf-style reporting: median (robust to outliers) plus tail
percentiles, since a real-time system cares about worst-case
behavior, not just typical-case."""
return {
"p50_ms": float(np.percentile(latencies_ms, 50)),
"p90_ms": float(np.percentile(latencies_ms, 90)),
"p99_ms": float(np.percentile(latencies_ms, 99)),
"mean_ms": float(np.mean(latencies_ms)),
"std_ms": float(np.std(latencies_ms)),
}
# Simulate an inference function with realistic jitter: a fast typical
# case with an occasional slow tail (e.g. an OS scheduling hiccup).
rng = np.random.default_rng(42)
call_count = [0]
def simulated_infer():
call_count[0] += 1
base_latency_s = 0.002 # 2ms typical
if rng.random() < 0.05: # 5% of calls hit a slow tail
time.sleep(base_latency_s * 4)
else:
time.sleep(base_latency_s + rng.normal(0, 0.0002))
latencies = benchmark_with_warmup(simulated_infer, n_warmup=10, n_measured=200)
stats = summarize_latency(latencies)
for k, v in stats.items():
print(f"{k}: {v:.3f}")
Running this prints (exact values vary slightly with system scheduling noise, but the shape is stable):
This is the number that a mean-only benchmark hides: p50 (~2.6 ms) looks
nothing like p99 (~10.0 ms) — the simulated 5% slow-tail calls barely
move the median but dominate the 99th percentile. For a real-time keyword
spotter that must respond within a fixed budget, the p99 number is the one
that determines whether the system meets its deadline, not the mean.
The accuracy-latency tradeoff MLPerf Tiny forces you to report honestly¶
A benchmark number without its accuracy is close to meaningless — a model quantized aggressively enough will always be fast. MLPerf Tiny's closed division specifically prevents reporting a latency win that came from silently trading away accuracy, by requiring both numbers together.
def evaluate_tradeoff(latency_ms, accuracy, min_accuracy_threshold):
"""Models MLPerf Tiny's closed-division validity rule: a submission
below the accuracy floor is disqualified regardless of how fast it
is, and can't be compared on latency at all."""
valid = accuracy >= min_accuracy_threshold
return {
"latency_ms": latency_ms, "accuracy": accuracy,
"valid_submission": valid,
"reason": "meets accuracy floor" if valid else "below accuracy floor -- disqualified",
}
candidates = [
{"name": "int8, full model", "latency_ms": 12.0, "accuracy": 0.94},
{"name": "int8, pruned 40%", "latency_ms": 7.5, "accuracy": 0.91},
{"name": "int8, pruned 70%", "latency_ms": 4.0, "accuracy": 0.83}, # too aggressive
]
threshold = 0.90
for c in candidates:
result = evaluate_tradeoff(c["latency_ms"], c["accuracy"], threshold)
print(f"{c['name']}: {result['valid_submission']} ({result['reason']})")
Running this prints:
int8, full model: True (meets accuracy floor)
int8, pruned 40%: True (meets accuracy floor)
int8, pruned 70%: False (below accuracy floor -- disqualified)
The 70%-pruned model is the fastest of the three (4.0 ms) but gets disqualified outright — exactly the discipline that makes MLPerf Tiny numbers trustworthy: you cannot cite a MLPerf Tiny latency figure without it implying the accuracy floor was met.
Edge-AI tradeoffs¶
| Factor | Ad hoc benchmarking (Modules 01-08's time.perf_counter loops) |
MLPerf Tiny methodology |
|---|---|---|
| Comparable across teams/hardware | no — every team's harness differs | yes, by design |
| Reports tail latency | only if you remember to | mandatory (percentiles, not just mean) |
| Accounts for accuracy tradeoff | no, unless you track it separately | mandatory, enforced accuracy floor |
| Setup cost | minutes | real submissions require reference model + harness + review |
| Best fit | day-to-day development iteration | publishable, cross-comparable claims |
How It Actually Works¶
Why median (p50), not mean, is the robust measure of "typical"
latency. The mean is pulled arbitrarily far by outliers — a single 100 ms
stall among 200 measurements of 2 ms each shifts the mean noticeably, even
though 199 of 200 runs behaved identically. The median is the value at
the 50th-percentile rank, so it is completely insensitive to how extreme
outliers are, only to how many there are — a statistical property called
robustness, formally quantified by a statistic's "breakdown point" (the
median's is 50%, the mean's is 0%, meaning even one arbitrarily large
outlier can move the mean without bound). This is precisely why
summarize_latency reports p50 as the headline "typical" number while
still reporting mean_ms/std_ms separately for context — the gap
between the two, as seen in the worked example (p50≈2.6ms vs mean≈2.7ms,
with p99≈10ms), is itself diagnostic: a mean pulled well above the median
signals a real tail problem worth p99 tracking, not measurement noise.
Why percentiles (not "min/max") are the right way to characterize a tail for a real-time deadline. A single observed maximum in a finite sample is itself a random variable with high variance — the exact worst value seen depends heavily on how many runs you happened to measure, so "the max was 12ms" from 200 runs is a poor predictor of what the max would be over 200,000 runs in the field. A percentile like p99 instead makes a probabilistic claim that generalizes: "99% of inferences complete within this time," which is exactly the guarantee a real-time budget needs (e.g. "the keyword spotter must respond within its 500 ms window at least 99% of the time") and is far more stable across sample sizes than a raw observed extremum, because it's estimating a quantile of the underlying distribution rather than reporting one realization of its extreme value.
Why warm-up runs must be discarded before any measurement begins, not
averaged in and corrected for afterward. The first several calls to any
inference pipeline pay one-time costs that never recur: dynamic library
loading, first-touch page faults bringing memory pages into the working
set, branch predictor and cache warming, and — for interpreted or JIT-
compiled runtimes — one-time graph optimization or code generation. These
costs are not drawn from the same statistical distribution as
steady-state inference time (they're a fixed, roughly constant overhead
paid exactly once, not a repeatable random variable), so including them
in a percentile calculation doesn't just add noise, it corrupts the shape
of the distribution being estimated — a handful of warm-up calls could
single-handedly dominate a p99 computed over only 20 total runs. Running
a fixed number of untimed warm-up calls first, as benchmark_with_warmup
does before starting its timing loop, ensures every measured sample is
drawn from the same steady-state distribution the benchmark is actually
trying to characterize.
Exercise¶
Modify simulated_infer to model thermal throttling instead of random
scheduling jitter: have base_latency_s increase gradually (e.g. by 5%)
every 20 calls to simulate a chip warming up and clocking down, reset
after 100 calls to simulate a cooldown period. Re-run benchmark_with_warmup
and summarize_latency and compare how p50 vs. p99 responds to a
systematic drift pattern versus this module's random spike pattern —
they should look different in the resulting distribution shape, and
knowing which pattern you're seeing changes the right fix (cooling vs.
scheduler tuning).