Metrics, and How to Measure Them Without Lying to Yourself

On this page12

Part 0 set an objective: maximise the number of tenants served within their SLA, per dollar, on fixed hardware. Every post after this one claims to move that number.

Which means this post has to come first, because a claim like “prefix caching bought us four more tenants” is worth exactly as much as the measurement behind it. Get the measurement wrong and the rest of the series is a sequence of confident assertions about nothing.

So: the scoreboard. What each number means, what it hides, and the specific ways a benchmark on one contended machine will mislead you if you let it.


The two latencies

“Latency” is not one number for a streaming model. A request has three phases and two distinct waits, and collapsing them loses the thing you care about.

The two latencies of one request

TTFTqueueprefilldecodeinter-tokenrequest arrivestime →One average latency number describes neitherof these, and hides the queue entirely.
One request, three phases. A single average latency number describes none of them — and hides the queue entirely.

Time to first token (TTFT) is how long the user stares at nothing. It covers queueing, admission, and prefill — the whole path from arrival to the first visible character.

Inter-token latency (ITL) is the gap between tokens after that. It’s what the reader experiences as the speed the text appears, and it’s governed by decode: one token per step, each step reading every weight out of HBM.

They move independently, and often in opposite directions. Batching more requests together raises throughput and generally improves nobody’s TTFT; a larger batch makes each decode step do more work, so ITL widens even as the machine gets more efficient. A change that improves one and degrades the other is the normal case, not the exception, which is why a single latency number can’t govern any decision in this series.


Throughput, and the question it hides

Throughput comes in two units and neither means anything alone.

Tokens per second is the honest measure of what the chip is doing. Requests per second is what capacity planning wants. Both need a qualifier that is almost always omitted: at what concurrency, and within what latency bound?

Throughput without a latency bound is trivially gamed. Queue a thousand requests, batch them as wide as HBM allows, and tokens/sec goes up while every individual tenant waits far longer than their SLA permits. That configuration is “faster” by the headline number and useless by the objective in Part 0.

So throughput is only ever reported here as a pair:

N tokens/sec at C concurrent requests with p99 TTFT under T.

Drop any of the three and the number stops being a measurement and becomes a boast.


Percentiles, and why they have to be per tenant

Averages are useless here, for the ordinary reason and one specific to multi-tenancy.

The ordinary reason: latency distributions in a batching server are not remotely normal. A mean sits between the fast path and the slow path and describes neither.

The specific reason is the one this series exists for. Imagine ten tenants; nine are served well and one is being starved by an eviction policy that keeps dropping its blocks. Aggregate p99 covers all requests across all tenants — so if the starved tenant sends 5% of traffic, its catastrophic experience is a rounding error inside a healthy-looking global percentile. The number stays green while a tenant leaves.

This costs nothing to implement and changes what you can see. It is the difference between a fairness claim and a fairness measurement.


HBM headroom: the one that only binds here

This is the metric most serving write-ups don’t carry, because on a cluster it never binds — when the pool gets tight you add a node and the question dissolves.

On one chip it is the leading indicator for everything else. Part 0 put the number at roughly 3.8 GiB of KV pool after weights. Track, continuously:

  • Free KV blocks as a fraction of the pool
  • Preemptions and evictions per minute — requests whose blocks were reclaimed mid-flight
  • Admission rejections — requests refused rather than queued

The relationship worth internalizing: headroom is a leading indicator and latency is a lagging one. By the time p99 TTFT moves, the pool has been thrashing for a while. A benchmark that only watches latency finds out last.


MFU, and why a low number is the expected answer

Model FLOPs utilization — how much of the chip’s arithmetic capacity you actually used — is the standard efficiency metric, and it needs recalibrating before it’s useful here.

In training, high MFU is the goal and 40–60% is respectable. In decode, MFU is structurally low and there is no configuration that fixes it, because decode is memory-bound: the chip spends its time reading weights out of HBM, not multiplying. Post 2’s section 13 works through why, and Part 3 will do the TPU-specific version.

So a decode MFU of a few percent is not a bug report. Used correctly, MFU here answers a narrower question: did this change move the chip closer to being compute-bound? Batching should raise it. Quantization should raise it. If a change that was supposed to improve batching leaves MFU flat, the batching didn’t happen — which is a far more useful signal than the absolute value ever is.


Measuring without lying to yourself

Four ways a benchmark on this machine will mislead you.

Warm up, or you’re benchmarking the compiler

XLA compiles per input shape, and the first request of any new shape pays for a compilation. Measure from a cold engine and you’ve measured compilation, then averaged it into everything else. Discard warmup explicitly rather than trusting it to wash out — with percentiles it does not wash out, it becomes your tail.

Open loop, or you’ll measure a lie

This is the one that quietly ruins load tests. A closed-loop generator — N workers that each send a request, wait for the response, then send the next — cannot generate load faster than the server responds. When the server slows down, the generator slows down with it, so the requests that would have arrived during the stall never arrive and never get counted. The result is a latency distribution that looks fine precisely when the system is failing.

This is coordinated omission, and it makes an overloaded server look healthy. Generate load open-loop instead: requests are issued on a schedule that does not care whether previous ones finished, and latency is measured from intended send time. On a box run near saturation — which is every configuration in this series — the difference between these two methodologies is larger than most of the optimizations the series will test.

Measure at the client

Server-side timers exclude queueing, transport, and detokenisation. The tenant experiences all three.

Reach steady state, and hold it

A batching server’s behavior at 30 seconds and at 10 minutes are different systems — the KV pool fills, prefix caches populate, the batch composition settles. Part 0 committed to sub-10-minute runs so results survive a --max-run-duration cutoff; those runs still need to reach steady state before the measurement window opens.


Reading a trace

The TPU profiler is how you tell why a number moved rather than just that it did — where the gaps in a step are, whether the host starved the device, whether a recompile crept in.


The scoreboard

MetricAnswersReported asWatch for
TTFTHow long until the tenant sees anything?per-tenant p50 / p95 / p99measured from prefill instead of arrival, hiding the queue
Inter-token latencyHow fast does the text appear?per-tenant p50 / p95 / p99widening as batch size grows, even as throughput improves
ThroughputWhat is the box actually producing?tokens/sec at concurrency Cquoted without a concurrency or a latency bound
HBM headroomHow close is the pool to binding?free KV blocks, evictions/minnothing — this one moves first, which is the point
MFUDid this change move us toward compute-bound?percent, compared against itselfbeing read as an absolute grade rather than a delta
Admission rejectionsWho never got served at all?count, per tenantabsent from the dashboard entirely, which is common

The last row is the one most easily forgotten. A rejected request has no latency, so it appears in no latency metric — and a system that stays fast by refusing half its traffic will look excellent on every other line of this table.


Part 2 is next: the architecture these numbers describe. The full request lifecycle on one machine — ingress, admission, engine, chip, and back out — and why the host CPU is a more common bottleneck than the accelerator everyone is watching.