An end-to-end benchmark you can defend
Run both engines on identical hardware and workload, with warmup, saturation, and open-loop arrivals; report goodput.
This is the lab the rest of the book's numbers are checked against, so it is pedantic on purpose. Two engines, one harness, one arrival schedule, one seed, a warmup sized from queueing theory rather than from habit, and a headline number that is goodput — completed requests that met an SLO you stated in advance — because throughput without a latency bound is a coordinate with one axis missing.
One 80 GB card minimum with an 8B model, and the result is honest only about that
configuration. It is more honest at 8 × 80 GB with a 70B, because that is
the shape most people actually deploy and because the collectives, the scheduler pressure and the
KV budget all behave differently there
(§5.1). Run the client on a
separate host if you can: a Python asyncio client tokenizing and parsing SSE for hundreds of
streams becomes the slow component, and a saturated client looks exactly like a saturated server.
With no GPU, run.py plan sizes the whole protocol for you — warmup length, run
length, runs per arm — and needs nothing but a target load.
No benchmark on this page was run. run.py was written against the flags and result
keys cited below and its argument handling and arithmetic were exercised, but it has not been
pointed at a live server — no GPU was available while writing. The result table in
the Reporting section is an empty template, not results: every
cell is a dash you fill in. There
are no measured numbers anywhere in this lab, and any table here that carries numbers says
derived in its caption and is arithmetic you can redo.
What you measure
One curve per engine, and one number off each curve.
The curve is goodput against offered load. For each offered rate $\lambda$ you get a point: requests per second that completed within your SLO. The number is the curve's maximum — the highest load at which the replica still keeps its promise. That is the capacity figure a capacity plan is made of, and it is not the same as the throughput number either harness prints by default.
Goodput is not a synonym for throughput with a footnote. It is defined per request, against SLOs you name, and vLLM's harness implements exactly that:
parser.add_argument(
"--goodput",
nargs="+",
required=False,
help='Specify service level objectives for goodput as "KEY:VALUE" '
"pairs, where the key is a metric name, and the value is in "
'milliseconds. Multiple "KEY:VALUE" pairs can be provided, '
"separated by spaces. Allowed request level metric names are "
'"ttft", "tpot", "e2el". For more context on the definition of '
"goodput, refer to DistServe paper: https://arxiv.org/pdf/2401.09670 "
"and the blog: https://hao-ai-lab.github.io/blogs/distserve",
)
if goodput_config_dict:
valid_metrics = []
slo_values = []
if "ttft" in goodput_config_dict:
valid_metrics.append(ttfts)
slo_values.append(
goodput_config_dict["ttft"] / MILLISECONDS_TO_SECONDS_CONVERSION
)
if "tpot" in goodput_config_dict:
valid_metrics.append(all_tpots)
slo_values.append(
goodput_config_dict["tpot"] / MILLISECONDS_TO_SECONDS_CONVERSION
)
if "e2el" in goodput_config_dict:
valid_metrics.append(e2els)
slo_values.append(
goodput_config_dict["e2el"] / MILLISECONDS_TO_SECONDS_CONVERSION
)
for req_metric in zip(*valid_metrics):
is_good_req = all([s >= r for s, r in zip(slo_values, req_metric)])
if is_good_req:
Read the predicate: all([s >= r for s, r in zip(slo_values, req_metric)]). A
request counts only if it met every stated SLO, and the counter is divided by the same
dur_s that throughput uses (vllm/benchmarks/serve.py:L730-L732). So goodput
and throughput are directly comparable, and their ratio is the fraction of your traffic that
was served acceptably — which is the number that decides whether a config ships.
Only three metric names are accepted, and the harness refuses anything else rather than silently ignoring it:
def check_goodput_args(args):
# Check and parse goodput arguments
goodput_config_dict = {}
VALID_NAMES = ["ttft", "tpot", "e2el"]
if args.goodput:
goodput_config_dict = parse_goodput(args.goodput)
for slo_name, slo_val in goodput_config_dict.items():
if slo_name not in VALID_NAMES:
raise ValueError(
f"Invalid metric name found, {slo_name}: {slo_val}. "
"The service level objective name should be one of "
f"{str(VALID_NAMES)}. "
)
if slo_val < 0:
raise ValueError(
f"Invalid value found, {slo_name}: {slo_val}. "
"The service level objective value should be "
"non-negative."
)
return goodput_config_dict
SGLang's serving harness has no goodput at all at 7d89325 — there is no --goodput flag and no SLO-conditioned counter anywhere under python/sglang/benchmark/. That is not a criticism of the engine; it is the reason this lab's one-harness rule is forced rather than chosen. See the next section.
One harness, two servers
§10.3 establishes that cross-harness comparison is invalid — the two clients do not compute the same ITL and do not count output tokens the same way, so their numbers cannot appear in one table. That leaves one choice: pick a client and point it at both servers. Which client is settled by the asymmetry above rather than by preference, and the choice costs one flag.
vLLM's harness dispatches on a backend name, and
openai is the generic OpenAI-Completions path:
ASYNC_REQUEST_FUNCS: dict[str, RequestFunc] = {
"vllm": async_request_openai_completions,
"openai": async_request_openai_completions,
"openai-chat": async_request_openai_chat_completions,
"openai-audio": async_request_openai_audio,
and SGLang serves that route, with the same default path the harness asks for
(--endpoint defaults to /v1/completions,
vllm/benchmarks/serve.py:L1574-L1578):
@app.post("/v1/completions", dependencies=[Depends(validate_json_request)])
async def openai_v1_completions(request: CompletionRequest, raw_request: Request):
"""OpenAI-compatible text completion endpoint."""
return await raw_request.app.state.openai_serving_completion.handle_request(
request, raw_request
)
The extra fields the payload carries — ignore_eos,
repetition_penalty, stream_options — are all declared on SGLang's
CompletionRequest
(python/sglang/srt/entrypoints/openai/protocol.py:L340-L375), so nothing is dropped and
nothing 422s. The readiness probe uses /v1/models, which SGLang also serves
(python/sglang/srt/entrypoints/http_server.py:L1843).
Driving SGLang from vLLM's client means you lose SGLang's own reporting extras — most
usefully the block where it GETs /server_info and embeds the entire server configuration
in its result file (python/sglang/benchmark/serving.py:L1779-L1780), which is a better
provenance record than vLLM's free-form --metadata. Fetch it yourself and store it
alongside; run.py does this automatically for any SGLang arm.
The load must be identical, not merely equal in rate
Both arms must be offered the same requests at the same instants. vLLM's generator precomputes the
whole arrival schedule before the run and seeds it globally
(random.seed(args.seed) / np.random.seed(args.seed),
vllm/benchmarks/serve.py:L1968-L1969), so the same --seed,
--num-prompts and --request-rate produce a byte-identical workload for both
engines. It then rescales the schedule so the achieved rate hits the target exactly:
delay_ts[i] += delay_ts[i - 1]
if ramp_up_strategy is None and delay_ts[-1] != 0:
# When ramp_up_strategy is not set, we assume the request rate is fixed
# and all requests should be sent in target_total_delay_s, the following
# logic would re-scale delay time to ensure the final delay_ts
# align with target_total_delay_s.
#
# NOTE: If we simply accumulate the random delta values
# from the gamma distribution, their sum would have 1-2% gap
# from target_total_delay_s. The purpose of the following logic is to
# close the gap for stabilizing the throughput data
# from different random seeds.
target_total_delay_s = total_requests / request_rate
normalize_factor = target_total_delay_s / delay_ts[-1]
That rescaling matters more than it looks. Without it the realised rate wanders 1–2% with the seed, which is the same order as the differences people publish.
The arrival process itself is a knob, and its default is the one you want:
parser.add_argument(
"--burstiness",
type=float,
default=1.0,
help="Burstiness factor of the request generation. "
"Only take effect when request_rate is not inf. "
"Default value is 1, which follows Poisson process. "
"Otherwise, the request intervals follow a gamma distribution. "
"A lower burstiness value (0 < burstiness < 1) results in more "
"bursty requests. A higher burstiness value (burstiness > 1) "
"results in a more uniform arrival of requests.",
)
--burstiness 1.0 is Poisson. Below 1 is burstier, above 1 is smoother, and if your
production traffic is neither, measure its burstiness first
(§10.1) rather than
accepting Poisson because it is the default.
--max-concurrency converts the run into a hybrid: arrivals stay open-loop but the
reported clock starts after a client-side semaphore, so client queueing vanishes from every
latency percentile and therefore from goodput. A closed loop also cannot produce
$\rho > 1$, so it cannot observe overload at all — §10.3 works the arithmetic through
and shows the same 20% capacity regression reading as "17% slower" closed-loop and "collapses under
load" open-loop. This lab is open-loop. If you set --max-concurrency, you are running a
different experiment and must report the limit next to every number.
The protocol
Six steps, in this order. Steps 2 and 3 conflict — warming the queue also warms the prefix cache — which is why the flush comes between them and why step 4 exists.
Pin every server flag
Same --max-num-seqs, same batched-token budget, same attention backend class, same
quantization, prefix caching on or off on both. Where a flag has no counterpart, say so. Start vLLM
with VLLM_SERVER_DEV_MODE=1 or its flush endpoint will not exist.
At the load you will measure
Not at some other load. An M/M/1 planning model gives the relaxation time
$t_{\mathrm{rel}} = S/(1-\sqrt{\rho})^2$ and blows up as $\rho \to 1$; three time constants at
$\rho = 0.9$ is roughly 1,000 requests (§10.3, derived).
run.py plan computes this hypothetical value for your $\mu$; a state-dependent batched server can converge differently.
And check the status code
SGLang returns HTTP 400 and refuses while requests are in flight; vLLM returns 200 with
{"success": false}. Both look like success to a script that only checks that the call
returned. Drain first.
Re-establish the chosen steady state
Draining and flushing destroys the warmed queue. Resume the measured arrival process, then exclude a declared convergence interval until queue depth, active batch, throughput and latency stabilize across successive windows. Little's Law gives a mean occupancy relation, not a guaranteed warmup duration. For a cold-cache experiment, use fresh nonrepeating prefixes or disable reuse rather than warming the same measurement prefixes again.
At least 1,000 completions
At 1,000 observations only about ten lie beyond the population p99; that is a useful warning, not a universal precision guarantee. Report quantile uncertainty and dependence, plus the window in requests and seconds. Retain failed, rejected, cancelled and timed-out arrivals.
$n \ge 3$, order randomised
One run gives you no $\sigma$ and therefore no claim. Randomise the order of sweep points so thermal drift does not alias onto the swept variable.
Sizing it, before you burn an afternoon
$ python3 run.py plan --mu 25 --rho 0.9 --slo-ttft 500 --slo-tpot 50
$ python3 run.py plan --mu 25 --cv 0.03 --mde 0.04 # how many runs per arm?
$ python3 run.py --help
The two questions plan answers are the two that decide whether the experiment can
work at all. How long might warmup take? — an M/M/1 planning estimate that must be checked empirically. How small a
difference can I defend? — from
at $\alpha = 0.05$ two-sided with 80% power (§10.3). With three runs and a 5% coefficient of variation you cannot defend anything smaller than 11%, and most published deltas are smaller than that. Measure $\sigma$ first, then choose $n$; if $n$ comes out large, drive the CV down with the flush and the randomised order instead of buying more runs.
The commands
# vLLM. Dev mode so /reset_prefix_cache exists at all.
VLLM_SERVER_DEV_MODE=1 vllm serve meta-llama/Meta-Llama-3-8B-Instruct \
--port 8000 --max-num-seqs 256 --max-num-batched-tokens 8192 --enable-prefix-caching
# SGLang. Match the semantics, not the spelling. --schedule-policy is stated
# explicitly because its default is fcfs, not the cache-aware policy people
# assume they are measuring (10.3).
python3 -m sglang.launch_server --model-path meta-llama/Meta-Llama-3-8B-Instruct \
--port 30000 --max-running-requests 256 --chunked-prefill-size 8192 \
--schedule-policy fcfs
URL=http://127.0.0.1:8000 # or :30000 for SGLang, with --backend openai
# 2. warm AT the load you will measure
vllm bench serve --model meta-llama/Meta-Llama-3-8B-Instruct --base-url $URL \
--dataset-name random --random-input-len 2048 --random-output-len 256 \
--request-rate 22 --burstiness 1.0 --num-prompts 1200 --seed 1
# 3. flush, and CHECK THE BODY, not just that curl exited 0
curl -sS -X POST $URL/reset_prefix_cache # vLLM -> {"success":true}
curl -sS -X POST $URL/flush_cache # SGLang -> 200, or 400 if busy
# 4+5. measure. Goodput needs an SLO stated up front.
vllm bench serve --model meta-llama/Meta-Llama-3-8B-Instruct --base-url $URL \
--dataset-name random --random-input-len 2048 --random-output-len 256 \
--request-rate 22 --burstiness 1.0 --num-prompts 2000 --seed 2 \
--goodput ttft:500 tpot:50 \
--percentile-metrics ttft,tpot,itl,e2el --metric-percentiles 50,90,99 \
--metadata gpu=H100-SXM engine=vllm sha=a556f3f \
--save-result --result-filename vllm-rate22-run1.json
Then sweep $\lambda$ and repeat, three times per point, in randomised order.
run.py sweep generates exactly those invocations — use --dry-run and
read them before you let it run anything.
Both harnesses' own warmup flags are nearly no-ops and you should leave them alone; step 2 above replaces them. vLLM's defaults to zero, SGLang's to one:
parser.add_argument(
"--num-warmups",
type=int,
default=0,
help="Number of warmup requests.",
)
parser.add_argument(
"--warmup-requests",
type=int,
default=1,
help="Number of warmup requests to run before the benchmark",
)
SGLang's harness at least knows the warmup polluted the cache and offers to fix it —
--flush-cache, off by default
(python/sglang/benchmark/serving.py:L2587-L2591). vLLM's harness has no flush at all, which
is why step 3 is a separate curl.
What to expect
Goodput will collapse before throughput does
That is the whole reason to measure it. Throughput rises with offered load until capacity $\mu$ and is then flat forever; goodput rises, peaks before $\mu$, and falls, because past the knee the queue is adding latency to requests that still complete. A throughput curve tells you the machine is fine right up to the point where every user has left. Plot both on one axis and the gap between the two peaks is your operating headroom.
The two engines will swap places along the 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 load point picks a winner by picking a load, which is why the deliverable is a curve and why a comparison that reports one number is not wrong so much as unfalsifiable.
Things that make the comparison invalid, in the order they bite
| What makes it invalid | Why | What this lab does |
|---|---|---|
| Two different harnesses | They do not compute the same ITL and do not count output tokens the same way, so the numbers are not the same quantity. | One client, both servers, via --backend openai. |
Closed loop, or --max-concurrency |
The slower engine is offered less work, so the comparison is circular; and client queueing is excluded from every latency percentile, hence from goodput. | Open loop only. run.py refuses to set a concurrency cap unless you pass
--allow-closed-loop, and stamps it into the result if you do. |
| Different arrival instants | Two engines offered different request schedules are running different experiments. | Same --seed, same --num-prompts, same
--request-rate, same --burstiness; the schedule is precomputed and
rescaled to hit the target rate exactly. |
| Prefix cache carried over between runs | With a fixed seed the second run replays the first run's prompts and skips their prefills. The delta is larger than most published improvements. | Flush between every run, and verify the response body. |
| Warmup measured in requests instead of in $t_{\mathrm{rel}}$ | Queue relaxation at $\rho = 0.9$ is ~1,000 requests, not 10. Short runs systematically understate tail latency, hence overstate goodput. | run.py plan sizes it from your $\mu$ and the load you will offer. |
| Mismatched server semantics | SGLang's --schedule-policy defaults to fcfs, so most SGLang
numbers never exercise cache-aware scheduling; and it silently downgrades to FCFS above 128
queued requests, which a load sweep crosses mid-run. |
State the policy explicitly on both sides and keep the queue below 128, or say that you did not. |
| Overload numbers without a run duration | Past saturation the reported p99 is $0.198 \times T$ — a property of your patience. Two overload numbers from runs of different length are not comparable. | Record the measured window in seconds and requests, and never report a point past $\mu$ without both. |
| A saturated client | The slow component is your laptop, and it looks identical to a slow server. | Client on a separate host; verify that doubling client processes does not raise throughput. |
Two diagnostics worth running once
First, run the same configuration twice back to back without flushing, then once with. The delta is your cache-carryover confounder measured in your own units, and it is the single most useful number in this lab because it bounds how much of any difference you later measure could be an artefact.
Second, add --probe-request-rate 1 to a saturated run. It sends single-token probes that
bypass the concurrency limit and reports their latency separately
(vllm/benchmarks/serve.py:L1678-L1687) — a direct measurement of how much your main
workload stalls an unrelated user, which is the thing an SLO is actually protecting.
Reporting
The deliverable is a table with one row per offered load per engine, and a provenance block above it.
Below is the shape, with every cell left as a dash. It is a template, not results —
nothing in this book was measured. The good/completed fraction uses matched completed-request counts, weighted by run duration; it excludes failed arrivals. Report failures and good/offered separately when evaluating offered-load SLO attainment. run.py report emits this table filled in from
your result JSON.
| Engine | λ offered | completed | throughput | goodput | good/completed | TTFT p99 | TPOT p99 | window | runs |
|---|---|---|---|---|---|---|---|---|---|
vllm a556f3f | — | — | — | — | — | — | — | — | — |
sglang 7d89325 | — | — | — | — | — | — | — | — | — |
Above it, the provenance block from §10.3's reporting template, in full. Neither harness records
all of it: vLLM's result JSON stores the client-side arguments plus a free-form
--metadata KEY=VALUE and nothing about the server; SGLang's embeds the whole
/server_info payload but is not the harness you are running. The fields are your
responsibility either way, and a result table without them is a screenshot.
I could not find any mechanism in either repository that records GPU clock, temperature or power
into the benchmark result. I looked in vllm/benchmarks/serve.py,
vllm/benchmarks/sweep/ and python/sglang/benchmark/serving.py. Sustained
decode holds a card near its power limit and clocks fall over minutes, so a ten-minute run's last
third is a different machine from its first. Until something upstream records it, log
nvidia-smi yourself at 1 Hz for the run duration and report the range;
run.py will start that logger for you with --log-clocks.
Exercises
- Before running anything: your replica saturates at $\mu = 40$ req/s and you want to characterise it at $\rho = 0.95$. How many seconds and how many requests of warmup must precede the measured window for three time constants? Then answer the practical version: is that experiment worth running, or should you characterise at $\rho = 0.9$ instead and say so?
- Run the same configuration twice back to back without flushing, then once with a flush in between. Report the delta as a percentage of throughput. Then answer: is any comparison you are planning to make larger than that delta?
- Predict, then verify. Take your measured throughput curve and your measured goodput curve under
--goodput ttft:500 tpot:50. Predict, before you plot, whether the goodput peak sits left of, on, or right of the throughput plateau's knee. Then explain the answer in terms of what happens to a request that completes but misses its SLO. - Read the file. Open
vllm/benchmarks/serve.pyand find whererequest_goodputis computed. What is the denominator, and what does that imply about comparing a goodput number from a 60-second run against one from a 600-second run at the same offered load, when both are past saturation? - Invalidate your own result. Re-run one load point with
--max-concurrencyset to half your measured $\mu \bar{W}$, and compare the reported TTFT p99 against the open-loop run at the same offered rate. By what factor did the number improve, and where did the missing time go? Name the line of code that hides it.
Answers
- $S = 1/\mu = 25$ ms; $t_{\mathrm{rel}} = S/(1-\sqrt{0.95})^2 = 39.0$ s, so $3\tau = 117$ s, and at $\lambda = 38$ req/s that is 4,446 requests of warmup before the measured window, which then needs its own ~1,000 completions for a p99 (derived, §10.3). Practically: at $\rho = 0.95$ every point costs you three minutes of pure warmup and the variance is worst exactly there. Characterise at $\rho = 0.9$ (1,025 requests, 46 s) and report the utilisation you used — a number at a stated $\rho$ beats a noisier number at a more impressive one.
- Whatever you measure, the operational rule is the same: any difference you later report that is smaller than this delta is indistinguishable from having forgotten to flush. Most published engine comparisons are.
- Left of the knee. Throughput counts every completion regardless of latency, so it keeps rising until $\mu$; goodput drops a request the moment its TTFT or TPOT crosses the SLO, and queueing delay starts climbing well before saturation — the M/M/1 wait is $S\rho/(1-\rho)$, which is already $4S$ at $\rho = 0.8$. So goodput peaks somewhere below the plateau and then falls while throughput is still flat. The gap between the two peaks is the load range where the machine is busy and the users are unhappy.
request_goodput = good_completed / dur_s, anddur_sis the makespan of the measured window. Past saturation the backlog grows as $(\lambda-\mu)T$ and a request arriving at time $t$ waits $(\lambda-\mu)t/\mu$, so later requests miss the SLO that earlier ones met: the fraction of good requests falls with run length. A 60-second overload run and a 600-second one at the same offered load will report different goodput, and neither is wrong — they are answers to different questions. Overload points are only interpretable alongside $T$.- TTFT p99 will improve, potentially by a large factor, and the missing time is client-side queue
wait. The semaphore is acquired outside the per-request clock:
async with semaphore:wraps the call torequest_func, and the clock starts inside (vllm/benchmarks/serve.py:L963-L973). Real user wait rose; reported wait fell. This is why the concurrency limit must appear next to every latency number or the number means nothing.
Key takeaways
- Goodput is completions that met every SLO you named, divided by the same duration throughput uses
— so their ratio is directly the fraction of your traffic that was served acceptably. Only three
SLO names are accepted (
ttft,tpot,e2el) and the harness rejects anything else rather than ignoring it. - Only one of the two projects' harnesses computes goodput at all, which settles the one-harness
question on mechanical grounds rather than preference. vLLM's client drives an SGLang server through
--backend openai, because SGLang serves/v1/completionsand accepts every field the payload carries. - The same
--seed,--num-prompts,--request-rateand--burstinessgive both engines a byte-identical arrival schedule, and the harness rescales it so the achieved rate hits the target exactly. Without that rescaling the realised rate wanders by 1–2% with the seed — the same order as most published deltas. - Warmup is a queueing problem. Size it from $t_{\mathrm{rel}} = S/(1-\sqrt{\rho})^2$, not from a round number of requests: at $\rho = 0.9$ that is roughly 1,000 requests, and both harnesses default to 0 or 1.
- The deliverable is a curve with a provenance block, not a number. Two engines swap places along the load curve routinely, so a single point picks a winner by picking a load. And with three runs at a 5% coefficient of variation you cannot defend any claim smaller than 11%.