An independent performance study by Brian Woods, run on rented H100 SXM hardware. Every number below was measured on that hardware. Vendor datasheet figures appear only where labelled, and only to be contradicted.

NVIDIA H100 SXM · vLLM 0.27.1 · five open models

What is an H100
actually doing?

Inference benchmarks report tokens per second. On its own that number says nothing about whether the machine is working hard, because it has no ceiling attached to it.

The useful question is what fraction of the hardware's real limit you reach, and which limit you are against. This study measures those limits on the card itself, predicts each model from first principles, and reports where the predictions failed.

81 % of the bandwidth ceiling
Qwen3-8B decode at batch 1, against a bandwidth limit measured on the card rather than quoted, and it holds near that level from batch 1 to batch 128.

01

The spec sheet is not the ceiling#

Every roofline needs a denominator. Using the vendor's peak figures is the most common way to make a performance analysis quietly wrong, because those figures assume conditions the card will not hold. So the first measurement is of the machine itself.

HBM bandwidth
3.04 TB/s
91 % of the 3.35 TB/s spec figure
BF16 dense GEMM
786 TFLOP/s
79 % of the 989 TFLOP/s spec figure
FP8 GEMM
1352 TFLOP/s
1.72× the BF16 rate
Ridge point
258
FLOP per byte. Below this, work is memory bound

Rooflining against 989 TFLOP/s overstates compute headroom by more than a fifth. The ridge point matters as much: at 258 FLOP per byte, a decode step needs arithmetic intensity above that to be compute bound, and single sequence decode is nowhere near it. That number sets up everything below.

Conditions. Full 132-SM SXM parts, boost held at 1980 MHz, well inside the 700 W limit, nothing thermally throttled. Clock locking is not permitted inside the container, so figures are best-of-N. Two physical H100s were used across the study and their measured peaks differed by about 1 %, which is the floor on cross-card comparison here.

02

Predict first, then measure. The gap is the finding#

At batch 1 a decode step is a weight streaming problem. The model reads every weight to produce one token, so the ceiling follows from the two numbers above.

Qwen3-8B weights on disk = 15.26 GiB (measured, not a parameter count times an assumed dtype) measured HBM bandwidth = 3.044 TB/s predicted decode ceiling = 3.044e12 / 16.38e9 = 185.8 tok/s predicted floor per token = 5.38 ms

Measured: 149.9 tok/s at 6.63 ms per token, which is 81.1 % of the bandwidth ceiling. The shortfall is about 1.25 ms per step that weight streaming does not explain. KV traffic cannot account for it either, since at batch 1 it is under 1 % of bytes moved.

0%20%40%60%80%100%1864128MBU %
decode isolated (128 in / 1024 out)round one shape (1024 in / 128 out)
Bandwidth utilisation against concurrency for Qwen3-8B. Isolating decode with a short prompt and long output holds near 80 % throughout. The dashed line is the same server measured with a 1024 token prompt and 128 output tokens, where most of the work is prefill and a decode roofline no longer describes what the machine is doing.
concurrencyoutput tok/sMBUTPOT ms
115081.1%6.63
8113579.8%6.97
64674277.6%9.27
1281067077.9%11.69

vLLM holds 78 to 81 % of memory bandwidth from batch 1 to batch 128 while throughput rises 71-fold. Per-token latency degrades gently, 6.63 to 11.69 ms, which is the batching trade working as intended.

Workload shape decides which ceiling you measure. The dashed line is the same model on the same server. A 1024 in / 128 out shape is 89 % prefill by token count, so the decode roofline understates it badly. If a benchmark does not state its input and output lengths, its utilisation numbers cannot be interpreted.

03

The residual is mostly kernel launches#

A residual is only interesting if you can attribute it. CUDA graphs exist to remove per-kernel launch overhead by replaying a captured graph, so disabling them should expose exactly that cost.

batch 1, graphs on
137 tok/s
1.43 ms residual per step
batch 1, eager
70 tok/s
8.61 ms residual per step
attributed to launches
7.18 ms
per decode step, recovered by graph replay

Running eager halves throughput at batch 1. So roughly 7.2 ms per step is launch overhead that graphs already remove, and what remains with graphs on is attention, sampling and scheduling. The penalty shrinks to 34 % at concurrency 64, because fixed per-step costs amortise across more tokens. That is also why small models suffer most from them.

04

A small model is not a fast model, it is an idle GPU#

Comparing all five models at batch 1, where decode dominates elapsed time and the roofline applies cleanly:

modelarchitectureweights GiBactive params KV MiB/1k toktok/sMBU
Qwen3-0.6BGQA1.40.75B11252428%
Qwen3-8BGQA15.38.19B14413775%
Qwen3-14BGQA27.514.77B1608381%
OLMoE-1B-7BGQA/MoE12.90.86B12848343%
DeepSeek-V2-LiteMLA/MoE29.31.47B3024343%

Qwen3-0.6B produces the highest token rate of any model here and the worst utilisation. Its weights are only 1.4 GiB, so streaming them takes less time than the fixed per-step cost measured in section three. The roofline therefore under-predicts it: the model is not bandwidth bound, it is overhead bound.

The practical reading is that a high tokens-per-second figure on a small model is not evidence the hardware is being used. It is often evidence a cheaper card would do the same work.

05

A mixture of experts gets less efficient as you load it#

A mixture of experts reads only the experts its tokens route to. At batch 1, DeepSeek-V2-Lite touches 6 of 64. The subtlety is that at batch B different tokens route to different experts, so the union grows. Under uniform routing the expected number of distinct experts touched is E · (1 - (1 - k/E)B).

batchexperts touchedGiB read per stepof total weights
16.0 / 644.9616.9%
834.9 / 6417.0658.3%
6463.9 / 6429.2199.8%

Effective weight traffic grows roughly sixfold from batch 1 to batch 64. A dense model gets batching amortisation for free, because one weight read serves every sequence in the batch. A mixture of experts does not: past a certain batch size you are reading the whole model again and the sparsity advantage is gone. Any capacity plan that prices a MoE on its active parameter count is describing the small batch case only.

Assumption, stated plainly. The union formula assumes uniform routing. Real routers are skewed and load imbalanced, so the true count at low batch is likely higher. This is a first-order correction, not a measurement of routing behaviour. It is worth getting right: charging every step a full weight read, as a dense model would, produces bandwidth utilisation above 100 %, which is a useful signal that the model is wrong.

06

MLA moves a different ceiling than the one people quote#

DeepSeek-V2-Lite uses multi-head latent attention, storing a compressed latent per token instead of full keys and values per KV head. It uses 4.7× less KV per token than Qwen3-8B, 30.4 MiB per 1k tokens against 144.

It is worth being precise about what that buys, because it is routinely overstated. It does not make decode faster at low concurrency, where weight streaming dominates and KV is around 1 % of bytes moved. It raises how many long sequences fit at once, and delays the point where KV traffic crowds out weight traffic. Those are ceilings on scale, not on latency.

07

The benchmark will measure its own cache if you let it#

vLLM's random dataset is seeded deterministically, so repeated invocations send the same prompts. The usual way to build a latency curve is one server and a ladder of benchmark runs at rising concurrency, and prefix caching is on by default. Every point after the first therefore re-sends prompts whose KV blocks are already resident.

Two arms on the same card, same model, request count fixed at 256 per point, prefix caching enabled in both. The only difference is whether the cache is reset between points:

concurrencyladder hit ratetok/sreset hit rate tok/sinflation
10.4%1380.4%141-2.0%
898.4%10070.4%907+11.1%
3298.4%33030.4%2205+49.8%
6498.4%52880.4%2844+85.9%

From the second point onward the ladder runs at a 98.4 % prefix cache hit rate and reports up to 86 % more throughput than the same server and workload measured cold. A second pass reproduces it. Concurrency 1 is unaffected, which is what the roofline predicts: at batch 1 decode is weight streaming bound, so a KV hit buys almost nothing.

This is not a small correction to apply afterwards. It is larger than most effects anyone would be trying to measure, and it grows with concurrency, so it distorts the shape of a scaling curve and not just its height. Reported upstream as vllm#52884. vLLM's own bench sweep serve already resets caches between runs, so the supported sweep path is not affected; the exposure is hand-rolled ladders built from repeated bench serve calls.

Everything on this page is measured with the cache reset before every run, the request count held fixed, and each point repeated. An earlier pass without those controls produced a throughput knee, an FP8 regression and a prefix caching effect, none of which exist.

08

Three knobs, and what they actually do#

Each of these was predicted before it was run. All three are measured with the prefix cache flushed before every run, a fixed request count, and three repeats per point.

FP8 KV cache buys capacity, and a little speed

shapeconcurrencyBF16 KV tok/sFP8 KV tok/s delta
decode isolated1150148-1.5%
decode isolated811381127-0.9%
decode isolated6467257074+5.2%
decode isolated1281037411013+6.2%
prefill heavy1142140-1.4%
prefill heavy8900903+0.4%
prefill heavy6428542944+3.1%
prefill heavy12833133449+4.1%

FP8 KV is neutral at low concurrency and 4 to 6 % faster at high concurrency, in both a decode-heavy and a prefill-heavy shape, while KV capacity nearly doubles from 407,248 to 790,272 tokens on the same FLASH_ATTN backend. Baseline run-to-run spread was 0.1 to 2.8 %, so the gains at scale are outside the noise and the small negatives at batch 1 and 8 are not.

The shape of that curve follows from where the bytes are. At batch 1 the KV cache is about 1 % of bytes moved, so halving it changes nothing and the conversion work is a small net cost. By concurrency 128 KV is a large share of traffic and the saving shows up. This is also relevant to vLLM #48786, which reports FP8 KV being slower on Hopper and traces it to the FlashAttention 3 FP8 prefill kernel at head_dim=256. Qwen3-8B is head_dim=128 and shows no penalty in either shape, which supports treating that as specific to the 256 path rather than general to FP8 KV on this hardware.

The prefill budget trades latency, it does not add throughput

With chunked prefill, prefill chunks and decode steps compete for one token budget, so a larger max_num_batched_tokens should relieve decode under load. At concurrency 128 it does nothing to throughput, while moving latency substantially in both directions:

max_num_batched_tokensoutput tok/sTTFT msTPOT ms
8192 (default)3356120828.7
163843353161725.6
327683354194623.0

Throughput is flat to within run-to-run noise. The knob buys inter-token latency at the cost of first-token latency. A deployment should set it from which of those it cares about, not in the hope of more tokens per second.

Prefix caching, and what a null result does not mean

Disabling prefix caching on this workload changes throughput by +0.2 %, against 1.2 % run-to-run spread over three repeats. That is a null result, and it is a fact about the workload rather than the feature.

The benchmark's random dataset gives every request its own random vocabulary offset and a shared prefix length of zero, so no two prompts share a prefix and there is nothing for the cache to hit. A cache returning no hits on inputs with no reuse is behaving correctly. On traffic that repeats a prefix, which is most real serving: a shared system prompt, a multi-turn conversation, a few-shot template, a retrieved document reused across questions, the same feature is doing substantial work. This study does not measure that case, and this number is not evidence about it.

Measure the engine, not your own history. A concurrency sweep run against one server will accumulate state between points. Flush the prefix cache before every run, hold the request count fixed across points, and repeat each point. Skipping any of the three is enough to produce double-digit differences that survive a plausibility check and disappear under a controlled rerun.

09

Method, and what would change the conclusions#

H100 80GB HBM3 SXM, driver 580.126.09, vLLM 0.27.1, PyTorch 2.13.0+cu130. Load generated with vllm bench serve on the random dataset, 8 warmup requests per point, EOS ignored so output length is fixed. Decode isolation uses a 128 token prompt and 1024 output tokens. Cross-model comparison is taken at batch 1, where decode dominates elapsed time. Controlled runs flush the prefix cache before every measurement and repeat each point three times.

Little's Law (L = λW) held within 3 % on every run in the study. It is free to compute and it catches mismatched units, uncounted requests and warmup bleeding into the measurement window. When it drifts, stop before interpreting anything else.

Limits worth stating

On every path measured here, vLLM runs close to what the hardware allows: 78 to 81 % of memory bandwidth on decode, 79 % of peak FLOPs on prefill. The interesting question in inference performance is usually not which engine is faster. It is which ceiling a given workload is against, since that determines whether more bandwidth, more compute, or a different model shape is what would actually help.