Running a benchmark that is not a lie
vllm/benchmarks/serve.pyvllm/benchmarks/sweep/server.pyvllm/v1/worker/gpu_worker.pypython/sglang/benchmark/serving.pypython/sglang/srt/managers/schedule_policy.py
a556f3f · sglang 7d89325Most published inference numbers are not reproducible and not comparable. Almost none of them are lies in the sense of anyone deciding to deceive — they are artefacts of defaults, of a warmup that was too short, of a load generator that quietly throttled itself, and of a scheduler that changed policy halfway through the sweep. This chapter is the checklist you hold a benchmark to.
The problem
Run this twice against the same server, same model, same GPU, back to back:
vllm bench serve --model meta-llama/Meta-Llama-3-8B-Instruct \
--dataset-name random --random-input-len 2048 --random-output-len 256 \
--num-prompts 500
Three of the defaults in that invocation decide the answer before the server does any work.
parser.add_argument(
"--request-rate",
type=float,
default=float("inf"),
help="Number of requests per second. If this is inf, "
"then all the requests are sent at time 0. "
"Otherwise, we use Poisson process or gamma distribution "
"to synthesize the request arrival times.",
)
parser.add_argument(
"--num-warmups",
type=int,
default=0,
help="Number of warmup requests.",
)
So: all 500 requests are dispatched at $t=0$, there is no warmup, and — because vLLM's prefix-cache flush lives behind a development-mode flag we will get to in §5 — whatever the previous run left in the KV cache is still there. The first run pays cold Triton autotuning and a cold prefix cache; the second run finds a warm cache holding the tail of the first run's prompts. On a fixed-seed random dataset the two runs use the same prompts, so the second run's prefills are largely free.
Neither number is wrong. They measure two different systems. And the delta between them is comfortably larger than most of the improvements people publish.
SGLang's harness defaults differently but not better: --warmup-requests defaults to 1, and the flush that would undo that one request's cache pollution only fires in CI or when you ask for it explicitly.
# Flush cache after warmup so the measured run does not benefit from
# request-local prefix reuse. vLLM exposes a different, development-mode
# endpoint for the same purpose.
should_flush_cache = (
"sglang" in backend and _get_bool_env_var("SGLANG_IS_IN_CI")
) or flush_cache
if should_flush_cache:
flush_server_cache(base_url, backend, flush_cache_timeout)
time.sleep(1.0)
The thesis of this chapter: the reasons benchmarks are wrong are systematic, mechanical, and enumerable. Every one of them has a mechanism you can read in the source and a control you can apply. What follows is the enumeration.
Mental model: the output is a curve
A throughput number without the latency it was achieved at is not a measurement, it is a coordinate with one axis missing. The object a benchmark actually produces is a curve: for each offered load, one point of (throughput, latency-percentile). The shape of that curve is fixed by queueing theory and does not depend on which engine you run. What the engine changes is where the curve sits.
Figure 1 — the load curve. Completed throughput rises with offered load until capacity $\mu$, then is flat forever. Latency rises slowly, then diverges at $\mu$. Derived from the M/M/1 wait formula of §1.2 with $S = 70.6$ ms, $\mu = 14.2$ req/s (the Llama-3-8B arithmetic of §3). Shape, not measurement.
Three consequences drop straight out of the picture and they organise the rest of the chapter. First, below saturation the harness's reported request throughput is not an output at all — it is your --request-rate echoed back, because request_throughput=completed / dur_s (vllm/benchmarks/serve.py:L725-L734) and dur_s spans the arrival schedule you specified. Second, above saturation the throughput number is the same for every engine that can saturate, and the latency number is a function of how long you ran. Third, there is exactly one interesting point per configuration — the load at which the SLO binds — and finding it requires sweeping.
First principles: warmup, run length, and saturation
How long is warmup, really
"We discarded the first 10 requests" is not a method, because at least four separate things converge on separate timescales and none of them is 10 requests.
Compile and capture
torch.compile and CUDA-graph capture happen at server startup, before the socket binds — see §5. On a cold Inductor cache that is minutes, and §8.2 owns it. It is not paid by your first request, which is a common and wrong assumption.
Shapes nobody warmed
Triton autotuning, FlashInfer and CuTeDSL JIT for a shape the startup sweep missed. These are paid by a real request, at an unpredictable moment. vLLM ships a monitor whose whole purpose is to catch them (§6).
The batch reaching equilibrium
Continuous batching means the running batch grows from empty. Until it reaches its steady-state size you are measuring a smaller batch than the one you configured — and decode step time is a function of batch size.
The slow one
The distribution of queue depth relaxes far slower than the batch fills, and its time constant blows up as you approach saturation. This is the term that makes warmup expensive, and it is derivable.
Take the last two quantitatively. Model the replica as a single server with mean service time $S$ and utilisation $\rho = \lambda S$, exactly as §1.2 does. Started empty, an M/M/1 queue approaches its stationary distribution with relaxation time
where $N_{\mathrm{rel}}$ is the expected number of arrivals during one spectral-relaxation timescale. This assumes stationary Poisson arrivals, exponential service and $\rho<1$; it is a planning scale, not a finite-time convergence guarantee for a batched inference engine. Pipeline fill has a different scale: one mean residence time corresponds to $\lambda\bar{W}=\bar{L}$ expected arrivals by Little's Law. That identity does not prove that discarding exactly that many requests removes a transient.
Anchor $S$ with real arithmetic. Llama-3-8B bf16 on an H100 SXM: 15.01 GB of weights, 3.35 TB/s of HBM, so the batch-1 decode floor is 4.48 ms (Formula sheet). At batch 64 with 2,048-token prompts and 256 output tokens, the mid-run context is 2,176 tokens and KV is 128 KiB per token (§2.1):
Batch 64 is well under the H100 ridge $I^{*} = 295$, so the bandwidth floor is the right bound. Now charge each request the GPU time it actually consumes, keeping the two kinds of work separate. The 255 decode steps are shared — one step advances all 64 requests by one token — so a request's share of them is $255 \times 9.93/64 = 39.6$ ms. Its prefill is not shared: 2,048 tokens of prefill is serial GPU work no other request in the batch benefits from, and at the 989.4 TFLOP/s peak it costs $2 \times 7.505{\times}10^{9} \times 2048 / 9.894{\times}10^{14} = 31$ ms.
Little's Law now reads out the residence time instead of manufacturing capacity: $\bar{W} = N/\mu = 64/14.2 = 4.5$ s at 64 in flight. The shortcut is worth naming because it is the natural mistake — "a request lives for its own 2.53 s of decode plus its own 31 ms of prefill, so $\bar{W} = 2.56$ s and $\mu = 64/2.56 = 25$ req/s". That charges the batch one prefill where it owes 64, and 64 prefills are 1.98 s of GPU time the decode steps must make room for. It is also circular: in a closed loop at $N = 64$, $\bar{W}$ is set by capacity ($\bar{W} = N/\mu$), so you cannot recover $\mu$ from a $\bar{W}$ computed as though the system were otherwise empty. Chunked prefill does not rescue it — interleaving prefill chunks into decode steps redistributes that 1.98 s, it does not delete it, and the steps it lands in get slower.
Both constants are hardware floors — 100% of HBM bandwidth for decode, 100% of peak FLOP/s for prefill — so 14.2 req/s is an upper bound on capacity, not a prediction. Substituting §1.1's realistic 86 ms for the same 2,048-token prefill puts $S$ at 126 ms and $\mu$ near 8 req/s. All derived arithmetic, no measurement.
| Utilisation $\rho$ | $t_{\mathrm{rel}}$ in units of $S$ | $t_{\mathrm{rel}}$ (s) | Requests in $1\tau$ | Requests in $3\tau$ |
|---|---|---|---|---|
| 0.50 | 11.7 | 0.82 | 6 | 18 |
| 0.80 | 89.7 | 6.3 | 72 | 215 |
| 0.90 | 379.7 | 26.8 | 342 | 1,025 |
| 0.95 | 1,559.6 | 110.2 | 1,482 | 4,445 |
Near $\rho\approx0.9$, the illustrative three-timescale warmup is about 1,000 arrivals and 80 seconds, around one hundred times ten requests. It grows rapidly near saturation. An initially empty, short run can understate stationary tail latency, but actual convergence depends on the workload and system. Check rolling occupancy, throughput and latency rather than treating this estimate as certification.
A warmup plan separates (1) the CUDA-graph buckets and compiled ranges the workload will touch; (2) a queue-stabilization window at the measured load, with $3\,t_{\mathrm{rel}}$ as an illustrative starting heuristic; and (3) the intended cache state. A cache flush may require draining or rejection handling, and subsequent discarded requests immediately warm the cache again. For a cold-cache experiment, specify whether the initial cold transient itself is measured or whether a controlled unique-prefix workload maintains misses. For a warm-cache steady-state experiment, verify rolling stability after any flush or restart. Discarding $\bar{L}$ requests alone guarantees neither queue convergence nor a cold cache.
Why an overloaded run measures your patience
Past saturation the queue is not in steady state and never will be. Offered $\lambda > \mu$, the backlog after $T$ seconds of running is $(\lambda - \mu)T$, and a request arriving at time $t$ waits $(\lambda-\mu)t/\mu$. Offer 20% over capacity — with the $\mu = 14.2$ req/s above, $\lambda = 17$ req/s:
Notice what cancelled: $\mu$ itself. At a fixed fraction over capacity the wait depends only on the overshoot and on how long you ran, so the three numbers below are properties of your experiment, not of the engine, and hold for any model and any GPU.
Same server, same engine, same offered load — three different p99s, differing by a factor of ten, entirely because of run duration. An overload number is only interpretable alongside $T$, and even then it tells you about your load generator rather than about the engine. This is why run duration is a mandatory field in the reporting template of §8, and why comparing two published overload numbers is meaningless unless both ran for the same wall time.
Open loop versus closed loop
This is the most consequential methodological choice in the chapter, and it is usually made by accident.
Closed loop fixes the number of requests in flight: a client holds $N$ slots and issues a new request only when one returns. Open loop fixes the arrival process: requests are emitted on a schedule that does not know or care what the server is doing. Production is open loop — your users do not wait for the previous user's response before typing.
Figure 2 — request generation under each loop. Top: arrivals land on a schedule fixed before the run starts; a slow server accumulates backlog. Bottom: an arrival fires only on a completion, so a slow server is sent fewer requests — the workload adapts to the system under test, which is exactly what an A/B comparison must not allow.
Why closed loop flatters
Under a closed loop with $N$ slots, the offered rate is $\lambda = N/\bar{W}(\lambda)$ — the system settles at the intersection of the Little's Law line $L = N$ with the latency curve. As $N$ grows, $\lambda \to \mu$ and $\bar{W} \to N/\mu$: latency grows linearly in $N$ and never diverges. There is no configuration of a closed-loop client that produces a queue growing without bound, because the client is the admission control.
Work the difference on a 20% decode regression. Engine A: $\mu_A = 14.2$ req/s from §3. Only decode slows 20%, so $S_B=31.1+1.2(39.6)=78.62$ ms and $\mu_B\approx12.72$ req/s, versus $S_A=70.7$ ms and $\mu_A\approx14.14$ req/s.
| Method | Engine A | Engine B | Reported verdict |
|---|---|---|---|
| Closed, $N=64$ | 14.14 req/s, W 4.52 s | 12.72 req/s, W 5.03 s | "B serves about 10.1% fewer requests/s" |
| Open, $\lambda = 13.6$ | $\rho = 0.962$, wait about 1.77 s | $\rho = 1.069$, end-of-run fluid backlog delay about 20.8 s | "B collapses at this load" |
A roughly 10.1% capacity regression can turn a stable open-loop load into a growing queue. The closed loop cannot see the second because it never offers a load the server cannot absorb; it converts every capacity deficit into a proportional latency increase, which is the single most flattering transform available.
There is a worse hybrid, and both harnesses ship it: an open arrival schedule plus a client-side semaphore. vLLM creates a task per request at its scheduled arrival time and the semaphore blocks inside the task:
semaphore = (
asyncio.Semaphore(max_concurrency)
if max_concurrency
else contextlib.nullcontext()
)
async def limited_request_func(request_func_input, session, pbar):
async with semaphore:
return await request_func(
request_func_input=request_func_input, session=session, pbar=pbar
)
The per-request clock starts inside request_func, after the semaphore is acquired. §1.2 works this through: time spent in the client-side queue is invisible to every reported latency. Setting --max-concurrency below the server's capacity therefore drives reported TTFT p99 down while real user wait rises without bound. If you report a latency number, you must report the concurrency limit next to it or the number means nothing.
What each harness can express
vLLM precomputes the whole arrival schedule as absolute offsets from one start_ts and sleeps to each in turn, so dispatch overhead cannot accumulate:
start_ts = time.time()
for request_index, request in enumerate(input_requests):
if delay_ts[request_index] > 0:
current_ts = time.time()
sleep_interval_s = start_ts + delay_ts[request_index] - current_ts
if sleep_interval_s > 0:
await asyncio.sleep(sleep_interval_s)
yield request, request_rates[request_index]
SGLang sleeps a fresh exponential sample after each yield, so every interval is measured from the moment the previous request was dispatched:
input_requests_iter = iter(input_requests)
for request in input_requests_iter:
yield request
if request_rate == float("inf"):
# If the request rate is infinity, then we don't need to wait.
continue
# Sample the request interval from the exponential distribution.
interval = np.random.exponential(1.0 / request_rate)
# The next request will be sent after the interval.
await asyncio.sleep(interval)
The achieved rate is therefore slightly below the target by the per-request dispatch cost, and the shortfall grows with client load — a bias that goes the wrong way exactly when the client is busiest. Structurally, vLLM's generator supports a tunable burstiness (Gamma shape; 1.0 is Poisson) and linear or exponential ramp-up; SGLang's is exponential only, plus timestamped trace replay. §10.2 owns the full comparison.
What the engines actually warm, and how to flush them
vLLM does the heavy warmup at startup, before the server is reachable. compile_or_warm_up_model runs a dummy forward at every size the compiler needs, warms JIT kernels, then captures the graphs:
# We skip EPLB here since we don't want to record dummy metrics
for size in sorted(warmup_sizes, reverse=True):
logger.info("Compile and warming up model for size %d", size)
self.model_runner._dummy_run(size, skip_eplb=True, remove_lora=False)
self.model_runner.maybe_remove_all_loras(self.model_runner.lora_config)
# Warmup and tune the kernels used during model execution before
# cuda graph capture.
kernel_warmup(self)
cuda_graph_memory_bytes = 0
if not self.model_config.enforce_eager:
cuda_graph_memory_bytes = self.model_runner.capture_model()
kernel_warmup is a long list of model-specific JIT pre-compilations, and the comments say exactly why:
# DSv4 mHC TileLang kernels (hc_pre/hc_post/hc_head_op) run every decoder
# layer per token; warm them across token sizes first so the first real
# request doesn't pay JIT cost. No-op for non-DSv4 models (gated inside).
deepseek_v4_mhc_warmup(
worker.get_model(),
max_tokens=worker.scheduler_config.max_num_batched_tokens,
cudagraph_capture_sizes=cudagraph_capture_sizes,
)
At the end of that function the worker draws a line and starts policing it:
# All warmup is done — start monitoring for unexpected JIT
# compilations that would cause latency spikes during inference.
from vllm.utils.jit_monitor import activate as activate_jit_monitor
activate_jit_monitor(
mode=self.observability_config.jit_monitor_mode,
verbose=self.observability_config.jit_monitor_verbose,
)
# Freeze the worker heap so the GC won't scan static objects
# (model weights, KV caches, CUDA graphs) during inference.
freeze_gc_heap()
Figure 3 — vLLM's startup warmup path, and the boundary it draws. Everything left of the boundary is paid before the socket binds; anything that compiles right of it is a latency spike the monitor reports.
Note what is not on the left of that boundary: the prefix cache, the running batch, and the queue-depth distribution. Startup warmup solves the compilation problem completely and the benchmarking problem not at all.
Flushing the prefix cache
You almost always want the measured run to start with a cold prefix cache, because otherwise the result depends on what the previous run did — an unwritten, unreported input. Both engines expose a flush, asymmetrically.
SGLang's is a first-class route, and it refuses unless the engine is idle:
@app.api_route("/flush_cache", methods=["GET", "POST"])
@auth_level(AuthLevel.ADMIN_OPTIONAL)
async def flush_cache(timeout: float = Query(0.0, ge=0.0)):
"""Flush the radix cache."""
ret = await _global_state.tokenizer_manager.flush_cache(timeout_s=timeout)
if ret.success:
content = (
"Cache flushed.\nPlease check backend logs for more details. "
"(When there are running or waiting requests, the operation will not be performed.)\n"
)
else:
content = ret.message or "Flush cache failed.\n"
return Response(
content=content,
status_code=200 if ret.success else HTTPStatus.BAD_REQUEST,
)
def flush_cache(self, empty_cache: bool = True):
"""Flush memory pools (e.g., KV cache, Mamba cache) and optionally empty device allocator cache."""
if self.is_fully_idle():
self.cur_batch_for_debug = None
self.last_batch = None
self.tree_cache.reset()
self.req_to_token_pool.clear()
self.token_to_kv_pool_allocator.clear()
self.grammar_manager.clear()
self.metrics_reporter.reset_metrics()
# ...
else:
logging.warning(
f"Cache not flushed because there are pending requests. "
f"#queue-req: {len(self.waiting_queue)}, "
f"#running-req: {len(self.running_batch.reqs)}"
)
success = False
return success
vLLM's equivalent exists only in development mode. The route is registered behind VLLM_SERVER_DEV_MODE (vllm/envs.py:L1419-L1421, vllm/entrypoints/launchers/api_server/routers.py:L34-L37):
@router.post("/reset_prefix_cache")
async def reset_prefix_cache(
raw_request: Request,
reset_running_requests: bool = Query(default=False),
reset_external: bool = Query(default=False),
):
"""
Reset the local prefix cache.
# ...
Returns `{"success": bool}`. The reset fails (`success=false`) while
blocks are still held, e.g. by running requests or in-flight async KV
offload transfers; callers may retry.
"""
SGLang's harness knows about both and calls whichever matches the backend, which is the one place in either repo where the cross-engine methodology is written down:
def flush_server_cache(
base_url: str,
backend: str,
flush_cache_timeout: float = _DEFAULT_SGLANG_FLUSH_CACHE_TIMEOUT,
) -> None:
"""Flush an engine's prefix cache after benchmark warmup."""
if backend.startswith("vllm"):
response = requests.post(
base_url + "/reset_prefix_cache", headers=get_auth_headers()
)
elif backend.startswith("sglang"):
response = requests.post(
base_url + "/flush_cache",
headers=get_auth_headers(),
params={"timeout": flush_cache_timeout},
)
else:
response = requests.post(base_url + "/flush_cache", headers=get_auth_headers())
response.raise_for_status()
The sweep driver
vLLM ships the load-curve machinery as vllm bench sweep: it restarts the server per server-side configuration, runs --num-runs repetitions per point, and resets caches between them.
VLLM_RESET_CACHE_ENDPOINTS = [
"/reset_prefix_cache",
"/reset_mm_cache",
"/reset_encoder_cache",
]
# ...
for endpoint in self.VLLM_RESET_CACHE_ENDPOINTS:
res = requests.post(server_address + endpoint)
res.raise_for_status()
# ...
else:
raise NotImplementedError(
f"No implementation of `reset_caches` for `{server_cmd[0]}` server. "
"Please specify a custom command via `--after-bench-cmd`."
)
Those three POSTs 404 unless the server was started with VLLM_SERVER_DEV_MODE=1, and raise_for_status() turns that into a hard failure mid-sweep. That is the correct behaviour — a sweep that silently skipped the flush would produce a curve whose points are not comparable to each other — but it is a trap the first time.
The confounders checklist
Figure 4 — the confounders mapped onto a benchmark timeline. Each band is a state variable that changes during a run and is not reported by either harness. A benchmark is defensible when every band is either constant across the measured window or explicitly recorded.
| Confounder | Mechanism | Control |
|---|---|---|
| Prefix-cache carryover | The previous run's prompts are still resident. With a fixed seed the next run replays the same prompts and skips their prefills. | Flush between runs: POST /flush_cache, or POST /reset_prefix_cache with VLLM_SERVER_DEV_MODE=1. Both refuse while requests are in flight — drain first. |
| Warmup pollution | When either harness warms at all — vLLM only under an explicit --num-warmups N, SGLang by default with 1 — it repeats input_requests[0], so that one measured request is a guaranteed cache hit and no other shape is covered. | Flush after warmup, then discard $\bar{L}$ more requests to refill the batch. |
| Hit rate cannot reach 1.0 | §2.3: max_cache_hit_length = num_tokens - 1, so a fully cached $S$-token prompt reports $B\lfloor (S{-}1)/B \rfloor$ hits. | Compute the ceiling before reading the dashboard. $B=16$, $S=2048 \Rightarrow$ 2,032/2,048 = 99.22% is a perfect score, not a 0.78% miss. |
| CUDA-graph bucket plateau | §8.1: vLLM's ladder is $[1,2,4,8,16,24,\dots]$. Batches 9 through 16 all replay the batch-16 graph. | Sweep batch sizes on the ladder rungs, or report the padded size. A flat region between 9 and 16 is bucketing, not scaling. |
| Scheduler policy switch | §1.4: SGLang downgrades LPM to FCFS above 128 queued requests, unlogged. A concurrency sweep crosses that mid-run. | Pin with --schedule-policy fcfs for comparisons, or keep the queue below 128 and say so. |
| The default is not the design | §2.4: schedule_policy defaults to "fcfs". Most published SGLang numbers do not exercise cache-aware scheduling at all. | Pass --schedule-policy lpm explicitly if that is what you mean to measure, and report it. |
| Router threshold | §9.4: cache_threshold is matched characters over total input characters, so a long unique suffix suppresses cache-aware routing. | If benchmarking a fleet, report the threshold and the prefix-to-suffix ratio of the dataset. |
| Client-side bottleneck | A Python asyncio client tokenizing and parsing SSE for thousands of streams becomes the slow component. vLLM ships an escape hatch: VLLM_USE_RUST_BENCH=1 re-execs vllm bench serve as a Rust binary (vllm/entrypoints/cli/benchmark/main.py:L23-L35). | Run the client on a separate host; verify by checking that client CPU is not saturated, and that doubling client processes does not raise throughput. |
| Tokenizer mismatch | vLLM re-tokenizes generated text to get output_len and warns "this may inflate the output token count slightly" (vllm/benchmarks/serve.py:L592-L603); SGLang uses the server-reported count and reports the retokenized one separately (python/sglang/benchmark/serving.py:L1122-L1129). | Both denominators feed output-token throughput. Use one harness for both engines — see §10.2. |
| Thermal and clock drift | Sustained decode holds an H100 near its power limit; clocks drop over minutes. A 10-minute run's last third is a different machine from its first. | Log nvidia-smi --query-gpu=clocks.sm,temperature.gpu,power.draw at 1 Hz for the run duration and report the range. Randomise the order of sweep points so drift does not alias onto the swept variable. |
Worked trace: what the first thirty seconds measure
Follow one default invocation, --num-prompts 500 --request-rate inf, against a freshly started vLLM server, in code order.
- The readiness probe does not fire.
--ready-check-timeout-secdefaults to 0 (vllm/benchmarks/serve.py:L1899-L1905) andwait_for_endpointsits behindif ready_check_timeout_sec > 0, so the default path prints "Skipping endpoint ready check." and sends nothing (:L858-L874). Pass a positive timeout and it does send one full-lengthtest_input— built frominput_requests[0](:L826-L832) — whose blocks then sit in the prefix cache for the entire measured run. num_warmupsis 0, so the warmup block atL876-L899is skipped too. On--dataset-name randomthe only pre-run traffic is_align_prompts_to_server_tokenizerPOSTing to/tokenize(:L2125-L2128), which allocates no KV. State at $t=0$ on a freshly started server: prefix cache empty, running batch empty, queue empty — so request 1 pays anything the startup sweep missed.benchmark_start_time = time.perf_counter()atL999, then theasync foratL1013drainsget_request. Withrequest_rate == inf, everydelay_tsentry is 0, so all 500asyncio.create_taskcalls fire in one event-loop pass.- The server receives a 500-request thundering herd. The scheduler admits what fits, the rest queue. Queue depth goes from 0 to ~440 in one step — which, on SGLang, would cross the 128-request LPM boundary immediately and for the entire run.
- Requests complete. As they do, the batch shrinks; the last ~64 requests run at a batch size that falls monotonically toward 1, so their decode steps are the fastest in the run (fewer KV bytes per step) and their TPOTs pull the mean down.
benchmark_duration = time.perf_counter() - benchmark_start_timeatL1077. Throughput is 500 divided by that makespan.
So the reported number is the makespan of a 500-request burst through a transient that starts at batch 0, peaks at whatever max_num_seqs allows, and decays back to batch 0. The only regime it never visits is steady state. It is a perfectly good capacity probe — it answers "what is $\mu$?" — and it is useless as a latency measurement, because every latency percentile it reports is dominated by the queueing of the initial burst, whose magnitude is set by --num-prompts.
With --request-rate inf, doubling --num-prompts roughly doubles reported mean and p99 latency while leaving throughput unchanged. Two teams running the same command with different --num-prompts will publish latency numbers that differ by 2× and both will be reproducible.
What a defensible result looks like
A number is checkable when a stranger with the same hardware can reproduce it. That requires the following, and no harness records all of it for you — vLLM's result JSON stores client-side arguments and a free-form --metadata KEY=VALUE (vllm/benchmarks/serve.py:L2214-L2240), and nothing about the server. SGLang does better: it GETs /server_info and embeds the whole server configuration in the result (python/sglang/benchmark/serving.py:L1779-L1790). Either way, the fields below are your responsibility.
IDENTITY
engine + commit SHA vllm a556f3f (not "vLLM 0.x")
model + revision meta-llama/Meta-Llama-3-8B-Instruct @ <hf sha>
harness + commit SHA which repo's serve.py, at what SHA
HARDWARE
GPU / SKU / count 1x H100 SXM 80GB
driver + CUDA + torch e.g. 550.x / 12.4 / 2.x
host CPU, RAM, NUMA pinning
client host same box or separate? NIC speed?
SERVER CONFIGURATION
full `vllm serve` / `python -m sglang.launch_server` command line
every non-default flag, especially: --max-num-seqs, --max-num-batched-tokens,
--enable-prefix-caching / --schedule-policy, --enforce-eager, quantization,
attention backend, --gpu-memory-utilization
attention backend selection: --attention-backend (the old VLLM_ATTENTION_BACKEND
env var no longer exists at a556f3f -- see 03-04)
environment: VLLM_SERVER_DEV_MODE, TORCH_CUDA_ARCH_LIST
WORKLOAD
dataset name + version, or the input/output length DISTRIBUTIONS (not means)
prefix-sharing rate of the dataset (see 10-01)
number of distinct prompts, and the seed
LOAD
arrival process open loop, Poisson, lambda = 12 req/s
(or: closed loop, N = 64 -- and say which)
burstiness parameter vLLM only; 1.0 = Poisson
--max-concurrency if set, the reported latency EXCLUDES client queueing
ramp-up strategy if any
PROTOCOL
warmup: how many requests, at what load, for how long
cache flushed after warmup? which endpoint, did it return success
measured window: start and end, in requests AND seconds
number of independent runs, and whether the server was restarted between them
order of sweep points (randomised?)
RESULTS -- a curve, not a number
for each offered load: throughput, TTFT p50/p90/p99, ITL p50/p99/max,
E2EL p99, goodput under the stated SLO
the SLO used to define goodput
run-to-run standard deviation for at least the headline metric
GPU clock/temperature range over the run
When is a difference real
Let $\sigma$ be the run-to-run standard deviation of the metric and $\mu$ its mean, with $n$ independent runs per arm. The minimum difference detectable at $\alpha = 0.05$ two-sided with 80% power is
| Runs per arm $n$ | MDE at CV = 1% | MDE at CV = 2% | MDE at CV = 5% |
|---|---|---|---|
| 3 | 2.3% | 4.6% | 11.4% |
| 5 | 1.8% | 3.5% | 8.9% |
| 10 | 1.3% | 2.5% | 6.3% |
| 20 | 0.9% | 1.8% | 4.4% |
Read the CV = 5% column, which is what you get when the prefix cache is not flushed between runs or the GPU is thermally drifting: three runs give an approximate 11.4% planning MDE at the stated significance and power. That is not the observed significance of a particular experiment. Lower run variance and more independent runs both improve sensitivity; paired or blocked designs require the variance of their differences. Measure $\sigma$ first, then decide $n$: $n = (3.96\,\mathrm{CV}/\mathrm{MDE})^{2}$. To resolve 5% at CV = 5% needs $n = 16$; at CV = 2% it needs $n = 3$.
Percentiles need their own sample count. §1.2 works this through: p99 from 1,800 completed requests sits on the 18th-worst, which is noisy but usable; p999 from the same sample is an anecdote. Do not quote a p99 computed from fewer than ~1,000 completed requests, and never quote a p999 from fewer than ~10,000.
Comparing two engines
Cross-harness comparison is invalid
The two harnesses do not compute the same ITL or the same output-token count — §10.2 has the incomparability table. Numbers from vllm bench serve and sglang.benchmark.serving cannot be placed in the same table, ever. Pick one client and point it at both servers.
Match the semantics, not the spelling
Same max_num_seqs, same batched-token budget, same attention backend class, same quantization, same chunked-prefill setting, prefix caching on or off on both. Where a flag has no counterpart, say so rather than leaving it at a default that differs.
Same arrival process, same seed
Open loop with an identical schedule, so both engines are offered the same requests at the same instants. Under a closed loop the slower engine receives a different workload, and the comparison is circular.
And report the curve. Two engines swap places along a load curve routinely: one wins at low load because its per-step overhead is lower, the other wins near saturation because its scheduler packs better. A single point picks a winner by picking a load.
Pitfalls and war stories
A latency outlier with no queue behind it
A p999 ITL of 800 ms with queue depth 3. Look in the server log for "%s %s during inference: %s%s. This causes a latency spike; consider extending warmup to cover this shape/config." (vllm/utils/jit_monitor.py:L126-L130). Run with --jit-monitor-mode=error to fail the run instead of hiding it in a warning.
Cache not flushed, benchmark proceeds anyway
Cache not flushed because there are pending requests. #queue-req: N, #running-req: M — SGLang can return HTTP 400; the displayed client uses raise_for_status, so it raises unless an outer handler catches the error. Do not assume the run continues. Check the status code, not just that the call returned. vLLM's returns {"success": false} with a 200.
NotImplementedError mid-sweep
vllm bench sweep aborts on the first cache reset unless the server has VLLM_SERVER_DEV_MODE=1. The rescue hatch is --after-bench-cmd, which replaces the reset entirely — convenient, and an easy way to accidentally stop flushing.
A discontinuity at concurrency ~130
An SGLang sweep with --schedule-policy lpm shows throughput dropping sharply somewhere past 128 queued requests. Nothing in the engine degraded: _determine_active_policy turned cache-aware scheduling off. It logs nothing. §1.4 has the code.
I could not find any mechanism in either repo that records GPU clock, temperature, or power in the benchmark result JSON, nor any warning about thermal drift on long runs. I looked in vllm/benchmarks/serve.py, vllm/benchmarks/sweep/, and python/sglang/benchmark/serving.py. SGLang's /server_info payload includes memory usage but I did not find clock telemetry in build_memory_usage. If it exists, the likely location is python/sglang/srt/observability/. Until then, collecting clocks is on you.
Hands-on
Produce one defensible point, then one curve. Start the server in a mode that lets you flush:
# server: dev mode so /reset_prefix_cache exists, everything else pinned
VLLM_SERVER_DEV_MODE=1 vllm serve meta-llama/Meta-Llama-3-8B-Instruct \
--max-num-seqs 256 --max-num-batched-tokens 8192 --enable-prefix-caching
# warm at the load you will measure, then flush, then measure
vllm bench serve --model meta-llama/Meta-Llama-3-8B-Instruct \
--dataset-name random --random-input-len 2048 --random-output-len 256 \
--request-rate 12 --num-prompts 1200 --num-warmups 0 --seed 1
curl -X POST localhost:8000/reset_prefix_cache # expect {"success":true}
vllm bench serve --model meta-llama/Meta-Llama-3-8B-Instruct \
--dataset-name random --random-input-len 2048 --random-output-len 256 \
--request-rate 12 --num-prompts 2000 --burstiness 1.0 --seed 2 \
--percentile-metrics ttft,tpot,itl,e2el --metric-percentiles 50,90,99 \
--metadata gpu=H100-SXM driver=550 sha=a556f3f \
--save-result --result-filename rate12-run1.json
Then sweep. vllm bench sweep restarts the server per configuration and resets caches between runs, which is the whole protocol above in one command:
VLLM_SERVER_DEV_MODE=1 vllm bench sweep \
--serve-cmd "vllm serve meta-llama/Meta-Llama-3-8B-Instruct --max-num-seqs 256" \
--bench-cmd "vllm bench serve --dataset-name random --random-input-len 2048 --random-output-len 256 --num-prompts 2000" \
--bench-params bench_params.json \
--num-runs 5 --output-dir sweep-results
with bench_params.json listing {"request-rate": [4, 6, 8, 10, 12, 13, 14, 16]}. Five runs per point gives you $\sigma$; the table in §8 turns $\sigma$ into what you are allowed to claim. Two things to verify before trusting the output: that the reset POSTs returned 200 (they will crash the sweep if not), and that client CPU stayed below saturation.
Three cheap diagnostics worth running once. (1) Run the same configuration twice back to back without flushing, and once with. The delta is your cache-carryover confounder, in your units. (2) Add --probe-request-rate 1 to a saturated run; it sends single-token probes that bypass --max-concurrency and reports their latency separately (vllm/benchmarks/serve.py:L1678-L1686) — a direct measurement of how much your main workload stalls an unrelated user. (3) Sweep concurrency across 8, 9, 12, 13, 16 and confirm the plateau predicted by §8.1. The lab 11-end-to-end-benchmark in Labs walks the full protocol.
Exercises
- Read the code. Open
vllm/benchmarks/serve.pyatL876-L899. After--num-warmups 50completes, what is in the prefix cache, and what does that do to the first measured request? - Predict, then verify. You sweep SGLang concurrency from 32 to 512 with
--schedule-policy lpmand see throughput rise, then fall sharply somewhere past 128. Predict the cause, then readpython/sglang/srt/managers/schedule_policy.py:L290-L294and say what you would log to confirm it. - Derive. Your replica saturates at $\mu = 40$ req/s. You want to characterise it at $\rho = 0.95$. Using the relaxation formula of §3, how many seconds and how many requests of warmup must precede the measured window for three time constants?
- Arithmetic. You run each engine three times and measure output-token throughput with a coefficient of variation of 3%. Engine B reads 4% higher. Can you claim it is faster? What $n$ would you need?
- Predict. What happens if you run the
vllm bench sweepcommand in §9 against a server started withoutVLLM_SERVER_DEV_MODE=1? Name the exact call that fails and the exception type.
Answers
1. All 50 warmup requests use the same test_input — one prompt, sent 50 times concurrently. The cache ends holding exactly that one prompt's blocks. Because test_prompt is drawn from the same dataset the measured run uses, the measured run's matching request gets a full prefix hit and an almost-zero prefill. It also warms only the CUDA-graph buckets reachable at concurrency 50 and only one prompt length, so shape coverage is narrow. There is no flush afterwards in vLLM's harness at all.
2. _determine_active_policy returns CacheAgnosticPolicy.FCFS whenever the policy is LPM and len(waiting_queue) > 128. Crossing that boundary silently disables cache-aware ordering, so prefix hits collapse and prefill work rises. To confirm, log len(waiting_queue) and the returned policy per scheduling round, or compare the run against one pinned with --schedule-policy fcfs — if the "cliff" disappears (because the whole run is now FCFS), the cliff was the policy switch.
3. $S = 1/\mu = 25$ ms. At $\rho = 0.95$, $t_{\mathrm{rel}} = S/(1-\sqrt{0.95})^{2} = 25\,\mathrm{ms} \times 1559.6 = 39.0$ s, so $3\tau = 117$ s. Arrivals during that: $\lambda = 0.95 \times 40 = 38$ req/s, so $38 \times 117 = 4{,}446$ requests. Note this is warmup before the measured window, and the measured window then needs its own $\ge 1{,}000$ completions for a p99.
4. The planning MDE is $3.96\times3\%/\sqrt{3}=6.9\%$ at the stated power. A 4% effect is below that design target, but this does not establish the observed p-value or interval; compute those from the actual independent or paired run results. For a 4% MDE at CV = 3%: $n = (3.96 \times 3/4)^{2} = 8.8$, so 9 runs per arm. Cheaper: drive the CV down by flushing between runs and randomising sweep order, then 3 runs may suffice.
5. ServerProcess.reset_caches POSTs to /reset_prefix_cache, which is not registered without dev mode, so the server returns 404 and res.raise_for_status() raises requests.exceptions.HTTPError. The sweep aborts after the first benchmark run of the first parameter combination. Restart with the env var, or supply --after-bench-cmd — but note that the latter replaces the reset rather than fixing it.
Key takeaways
- Below saturation, a harness's reported request throughput is your
--request-ratedivided by one, becausedur_sspans the arrival schedule you chose. Above saturation, reported latency is $0.198 \times T$ — a property of your patience, not the engine. The only informative output is the pair (throughput, latency percentile) at each offered load, i.e. a curve. - Warmup is a queueing problem, not a compilation problem. Compilation and graph capture are paid at startup before the socket binds; what a benchmark must wait for is queue relaxation, which at $\rho = 0.9$ takes 1,025 requests and 80 seconds for the worked Llama-3-8B configuration (derived). Both harnesses default to 0 or 1 warmup requests.
- A closed loop cannot measure overload: its offered rate is $N/\bar{W}$, so latency grows linearly in $N$ and never diverges. Here a 20% decode-only slowdown is about a 10.1% total capacity loss and can overload the fixed open-loop rate. Production is open loop; measure open loop.
--max-concurrencyis the worst of both: arrivals stay open-loop while the reported clock starts after the client-side semaphore, so client queueing vanishes from every latency percentile. Report the concurrency limit next to every latency number or discard the number.- Four of the confounders are engine features masquerading as scaling behaviour: the CUDA-graph bucket ladder makes batch sweeps plateau, SGLang's 128-request LPM downgrade makes concurrency sweeps cliff,
schedule_policydefaulting tofcfsmeans most SGLang benchmarks never exercise cache-aware scheduling, andmax_cache_hit_length = num_tokens - 1caps a hit-rate dashboard at 99.22% for 2,048-token prompts on 16-token blocks. - Cross-harness comparison is invalid — the two clients do not compute the same ITL or the same output-token count. One client, both servers, matched flags, matched arrival schedule, and $n \ge 3$ runs with a reported $\sigma$. With three runs and a 5% CV you cannot defend any claim smaller than 11%.
Further reading
- §10.1 for the workload distributions this chapter assumes you already measured, and the bias baked into each shipped dataset; §10.2 for what each harness computes and why the two cannot be mixed; §10.4 for why identical inputs give different tokens across runs; §10.5 for turning a defensible curve into cost per million tokens.
- §1.2 — the metric definitions, the M/M/1 and Kingman results this chapter builds on, Little's Law, and goodput. Do not re-derive them; cite them.
- Mor Harchol-Balter, Performance Modeling and Design of Computer Systems — chapters on open vs closed systems are the canonical treatment of why the two answer different questions, and where the relaxation-time result comes from.
- Schroeder, Wierman and Harchol-Balter, "Open versus Closed: A Cautionary Tale" (NSDI 2006) — the paper that put this distinction on the map for systems benchmarking; the conclusions transfer to LLM serving unchanged.
vllm/benchmarks/sweep/in the vLLM tree —param_sweep.py,server.py,plot_pareto.py. The Pareto plotting is the closest thing either project has to an opinion about how results should be presented.- Gray, The Benchmark Handbook (1993), chapter 1 — relevance, portability, scalability, simplicity. Thirty years old and every criterion still fails on a typical LLM serving benchmark.