Token by Token
Part VI · ProductionChapter 26 / 37

Benchmarking

A benchmark should answer one question, how much of your traffic this configuration serves within your SLOs. Most published numbers answer an easier one. This chapter covers workload shapes, load models, sweeps, the tools, and how to spot a misleading result.

The metrics chapter defined what to measure: TTFT, ITL, TPOT, E2E and goodput. This chapter is about producing those numbers so they predict production. A useful benchmark has three parts, and getting any one wrong makes the result meaningless:

  • The workload. Prompt and output length distributions, prefix sharing, and the request mix of your traffic, not a default 1024 / 128.
  • The load model. How requests arrive: a fixed number in flight, or a rate that ignores how fast the server answers.
  • The method. Warm-up, run length, sweep points, and where the client sits.

The output is a single sentence you can plan with: “N req/s at p99 TTFT ≤ X and p99 ITL ≤ Y, for this workload, on this engine version and these flags.” That sentence feeds capacity planning.

Start from the workload shape

Two numbers describe most requests: the input sequence length (ISL) and the output sequence length (OSL). Their ratio decides which phase of the engine does the work. Long inputs stress prefill and TTFT; long outputs stress decode, ITL and KV cache capacity. Their spread matters as much as their median: a p99 prompt ten times the median is what breaks TTFT.

ExploreWorkload shape explorer

Switch between workloads and watch the two histograms and the GPU-time bar. Summarisation and agent steps are prefill-heavy per token, yet decode can still dominate wall time because long contexts make every decode step read more KV. Reasoning is almost all decode. Embeddings have no decode at all. Turn the prefix cache off to see what a benchmark with random prompts measures instead. Distributions and costs are illustrative first-order estimates.

WorkloadTypical ISL : OSLPrefix sharingWhat it stressesBenchmark must include
Chat~3 : 1, wide spreadsystem prompt, historyTTFT and ITL togethermulti-turn history, real length spread
RAG / search10–20 : 1low (chunks vary)prefill, TTFTrealistic retrieved context sizes
Summarisation15–30 : 1almost noneprefill, long-context KVthe long tail of documents
Code completion30–50 : 1, tiny outputsvery high (same file)TTFT at high request raterepeated prefixes, short timeouts
Reasoning1 : 5–10moderatedecode, KV per requestreal output lengths; no ignore_eos
Agents50+ : 1, growing per turnvery high (history)prefill and prefix-cache hit ratewhole sessions, not single turns
Embeddingsprefill onlylowcompute, batch sizeinput tokens/s, not output tokens/s

The best source of a workload is your own traffic: sample request logs (lengths and, where allowed, prompts), keep the session structure for multi-turn and agent traffic, and replay it. Synthetic data is fine for sweeps if you match the distribution and the prefix structure, not only the means.

Closed loop vs open loop

Load generators drive the server in one of two ways, and they produce different curves from the same server:

  • Closed loop (fixed concurrency). N virtual users each send a request, wait for the full response, then send the next. --max-concurrency 32 in vllm bench serve is a closed loop. When the server slows down, the client slows down with it, so the queue can never exceed N.
  • Open loop (fixed request rate). Requests arrive at rate λ, usually as a Poisson process, whether or not earlier requests have finished. --request-rate 8 is an open loop. Real users behave like this: they do not wait for each other.

A closed loop hides queueing. The client stops sending exactly when the server falls behind, so the bursts that build queues in production never happen. This is a version of coordinated omission: the test avoids measuring the moments that would have been worst. Below the knee the two models roughly agree. Near the knee, a Poisson stream at the same average rate has a much worse tail, and past capacity an open-loop queue grows without limit.

SimulationSame replica, two sweeps

Each dot is one sweep point: a full benchmark run at one concurrency (closed loop) or one arrival rate (open loop), plotted as latency against the throughput it achieved. The green ring marks the highest-throughput point that meets both p99 SLOs, which is your capacity. Compare the two curves at about 1,750 tok/s: same throughput, roughly 2× worse p99 TTFT under Poisson arrivals, so the open-loop capacity at the same SLO is about 20% lower. Tighten the ITL SLO to see it, rather than TTFT, set the limit. Illustrative model of one 64-slot replica with chunked prefill.

Sweeping means running the benchmark at a series of load levels and plotting latency against throughput. A good sweep has many points near the knee and a few beyond it. Practical rules:

  • Use concurrency sweeps to find the knee quickly and to compare engines at equal concurrency. Then confirm the capacity with an open-loop run at the rate you plan to serve, ideally with some burstiness.
  • vllm bench serve sends every request at time zero when --request-rate is left at its default of inf. That measures a thundering herd, not a service. Always set a rate or a concurrency.
  • Read the knee off the p99 curves against your SLO, not off the throughput curve. Throughput keeps rising slowly long after users have started to suffer.

Running it properly

Warm-up. The first requests after start-up pay for kernel compilation, CUDA graph capture, lazy allocation and a cold prefix cache. Send a warm-up phase and exclude it (--num-warmups in vllm bench serve, --warmup-requests in SGLang’s benchmark), and benchmark only after readiness passes.

Duration and sample size. A p99 is decided by the slowest 1% of requests. With 200 requests that is two requests. Run each sweep point long enough for at least a few hundred, ideally 1,000+ requests, and for open-loop tests at least a few minutes so queues reach steady state. Repeat the run before believing a 5–10% difference.

SimulationHow many requests does a p99 need?

Eight runs draw from the same latency distribution. Drag the run length down to 100 and the “p99” of each run is just its slowest request; the eight runs disagree by 2× or more. Above ~1,000 requests they converge on the true value. Tick Skip warm-up to see 20 cold requests dominate short runs. Illustrative distribution.

Client-side bottlenecks. The load generator is a program too. A single Python process parsing hundreds of concurrent token streams can run out of CPU, and then it measures its own event loop: ITL rises and throughput plateaus while the GPU is half idle. Watch the client’s CPU, run it on a separate machine close to the server, and spread very high concurrency over several client processes. Check which tokenizer the client uses to count tokens; counting with a different tokenizer than the server shifts every per-token number.

Where to point it. Measure twice, at two places:

TargetWhat it measuresUse it for
Directly at one pod (pod IP or a per-pod Service)the engine alone, one replicaengine flags, model and quantization choices, per-replica capacity
Through the gateway / InferencePooleverything users see: TLS, auth, routing, queueing across replicasend-to-end SLOs, routing policies, autoscaling behaviour

The difference between the two is your platform overhead; it should be a few milliseconds of TTFT, not hundreds. Avoid kubectl port-forward for load tests: it tunnels through the API server and becomes the bottleneck long before the GPU does. Through a gateway, remember that routing changes results: a prefix-aware router turns repeated prompts into cache hits that a direct-to-pod test never sees.

The tools

All of these speak the OpenAI-compatible API, stream responses, and report TTFT, ITL and throughput percentiles. They differ in load models, datasets and reporting.

ToolFromLoad modelsStrengths
vllm bench servevLLMconcurrency, Poisson or gamma rate (--burstiness)quick, many datasets (random, sharegpt, prefix_repetition), built-in --goodput SLO counting
GuideLLMvLLM ecosystemsynchronous, throughput, constant and Poisson rates, automatic sweepsweeps from idle to saturation in one command; rich reports
AIPerfNVIDIA (successor to GenAI-Perf)concurrency and request ratedetailed per-request records, synthetic and trace-based inputs
inference-perfKubernetes Serving working groupmulti-stage rate profiles from a config fileKubernetes-native; built for benchmarking model servers and gateways in-cluster
SGLang benchmarkSGLangconcurrency and request ratesame flags across engines; random, ShareGPT and generated datasets
# vLLM: one closed-loop sweep point, with SLO-based goodput
vllm bench serve --backend openai-chat --endpoint /v1/chat/completions \
  --base-url http://llama-8b.serving.svc:8000 --model meta-llama/Llama-3.1-8B-Instruct \
  --dataset-name random --random-input-len 1024 --random-output-len 256 \
  --max-concurrency 32 --num-prompts 1000 --num-warmups 50 \
  --percentile-metrics ttft,tpot,itl,e2el --metric-percentiles 50,90,99 \
  --goodput ttft:500 tpot:50
# ...the same, open loop: replace --max-concurrency with --request-rate 8

# GuideLLM: automatic sweep from synchronous to max throughput
guidellm run --backend kind=openai_http,target=http://llama-8b.serving.svc:8000 \
  --profile kind=sweep \
  --data kind=synthetic_text,prompt_tokens=1024,output_tokens=256 \
  --constraint kind=max_duration,seconds=120

# AIPerf: fixed concurrency, streaming
aiperf profile --model meta-llama/Llama-3.1-8B-Instruct --url http://llama-8b.serving.svc:8000 \
  --endpoint-type chat --streaming --concurrency 32 \
  --synthetic-input-tokens-mean 1024 --output-tokens-mean 256

# SGLang's benchmark, against an SGLang server
python -m sglang.bench_serving --backend sglang --host 127.0.0.1 --port 30000 \
  --dataset-name random --random-input-len 1024 --random-output-len 256 \
  --num-prompts 1000 --request-rate 8

How benchmarks lie

Most misleading benchmarks contain no false numbers. Each choice below is defensible on its own; together they can make the same server look 10–30× better, or worse, than what users get.

InteractiveSame server, different headline

The blue bars never move: they are what users of this replica actually get. Each toggle changes only the number a benchmark report would print. Try Count input tokens + Report peak throughput + One prompt repeated, then switch to Random tokens and notice that a benchmark can also be unfairly pessimistic. Multipliers are illustrative.

The usual suspects, and the question that exposes each:

  • Aggregate tok/s with no latency. At what p99 TTFT and ITL? Peak throughput is measured where users have already left.
  • Counting input tokens. Output or total tokens? vllm bench serve prints both output and total token throughput; prompt-heavy workloads make “total” several times larger.
  • ignore_eos with fixed lengths. Uniform outputs keep batches neat and hide models that ramble or stop early, which is exactly what a quantized model can do.
  • Random prompts have no shared prefixes, so prefix caching never helps and the benchmark understates production. Identical prompts hit the cache every time and overstate it. Use real prompts or a dataset with your prefix structure (--random-prefix-len, prefix_repetition).
  • Mismatched engine defaults. Comparing engines “out of the box” compares their defaults: prefix caching, chunked-prefill budget, max batched sequences, CUDA graphs, speculative decoding, KV-cache dtype, even the chat template. Pin and list every flag on both sides.
  • Averages. Means hide the tail; report p50, p90 and p99.
  • Ignoring cold start. No warm-up penalises engines with long compilation; a warm-only benchmark hides the cold start users hit after every scale-up. Measure both, separately.

A reporting template

Every benchmark result should travel with its context, or nobody can reproduce or compare it:

FieldExample
Model, revision, precisionLlama-3.1-8B-Instruct @ pinned commit, BF16 weights, FP8 KV
Hardware1 × H100 SXM 80 GB, driver and CUDA version
Engine, version, full flagsvLLM x.y.z, --max-num-seqs 64 --enable-prefix-caching …
WorkloadISL/OSL distributions (p50/p99), prefix structure, dataset source, ignore_eos yes/no
Load modelclosed loop at N, or Poisson at λ req/s (burstiness), duration, warm-up excluded
Client and pathtool and version, client location, direct-to-pod or via gateway
Results per sweep pointoutput tok/s, req/s, TTFT/ITL/E2E p50/p90/p99, error rate, measured OSL
Verdictmax load at SLO (goodput knee), with the SLO stated
Interview checkTry answering before you expand.
coreWhat is the difference between a concurrency sweep and a request-rate sweep, and which predicts production latency better?

A concurrency sweep is a closed loop: N clients each wait for a response before sending again, so the client slows down with the server and queues stay bounded. A request-rate sweep is an open loop: requests arrive (usually Poisson) regardless of responses, as real users do. The open loop predicts production tails better because it creates the bursts and queues that a closed loop avoids. Use concurrency to find the knee quickly and to compare configs, and confirm capacity with an open-loop run.

coreWhy is 'output tokens per second' alone not a useful benchmark result?

It has no latency attached. Throughput keeps rising until saturation while TTFT and ITL get worse, so the highest number is measured at a latency no user would accept. The useful result is throughput at a stated SLO (p99 TTFT and ITL), or the full latency–throughput curve, for a stated workload shape.

midHow many requests do you need per sweep point to report a p99?

Enough that the p99 rests on a meaningful number of slow samples: at least several hundred, ideally 1,000+, so 10 or more requests lie above it. With 100 requests the “p99” is just the slowest request. Exclude warm-up, run open-loop points long enough to reach steady state, and repeat runs to see the noise before calling a small difference real.

midA vendor claims engine A has 2× the throughput of engine B on the same GPU. What do you ask?

At what latency percentile and SLO; output or total tokens; what ISL/OSL distribution, and whether ignore_eos forced lengths; random, real or repeated prompts (prefix caching); which flags and defaults on each side (prefix caching, chunked prefill, max sequences, speculative decoding, KV dtype, quantization); closed or open loop; warm-up; and the exact versions. Then rerun both on your own workload with matched settings.

seniorYour load test showed 12 req/s per replica within SLO, but production replicas miss SLO at 7 req/s. Walk through the likely causes.

Compare the test against production on each axis. Load model: a closed-loop test hides Poisson and bursty arrivals. Workload: production prompts or outputs are longer or more spread out (check p99 ISL/OSL), or reasoning traffic grew. Prefix structure: the test reused one prompt (inflated cache hits) or production routing scatters prefixes. Path: the test hit pods directly; production goes through a gateway with auth and a router. Engine: different flags or version in production. Also check the client was not the bottleneck, and look at preemptions and KV usage in production. Fix the benchmark to replay real traffic, then re-derive capacity.

seniorBenchmarking at 256 concurrency, ITL rises and throughput plateaus while GPU SM activity is only 50%. What's happening?

Likely a client-side bottleneck: one load-generator process cannot parse that many token streams, so it delays reading tokens and inflates ITL. Check the client’s CPU, split the load across processes or machines, and move the client close to the server. If the client is fine, check for a proxy in the path (port-forward, a buffering ingress), the engine’s max-sequences limit (extra requests just queue), and the API server’s own CPU (tokenisation and detokenisation in the frontend).

See it on the concept map →
Token by Token · LLM inference & serving, visualized