The benchmark harnesses in both repos
vllm/benchmarks/serve.pyvllm/benchmarks/throughput.pypython/sglang/benchmark/serving.pypython/sglang/benchmark/one_batch.py
a556f3f · sglang 7d89325A per-frame ITL and an amortised per-token ITL can describe the same engine with different numbers. A 7.5 ms frame interval divided by a mean 3.5 tokens per frame gives 2.14 ms as a fluid rate calculation, not automatically a reported median. This chapter reads both harnesses line by line so you can tell which comparisons are legitimate.
The problem
Start with the concrete symptom. Run Llama-3-8B on an H100 with EAGLE-style speculative decoding at an acceptance length of 3.5. The engine completes one verify iteration roughly every 7.5 ms and emits integer token counts whose long-run mean is 3.5 per frame. The 7.5/3.5 calculation below is a fluid average, not the median of an actual frame sequence. Now ask the two client harnesses what the inter-token latency was.
Those are derived, not measured: 7.5 ms is an illustrative Llama-3-8B verify step against this book's 4.48 ms batch-1 decode floor plus draft cost, and 2.14 = 7.5 ÷ 3.5 is arithmetic. The point is the ratio, and it comes entirely from a one-line difference in two Python files.
This is not an isolated quirk. The two harnesses also disagree about where the end-to-end clock stops, what counts as a token, whether arrivals are Gamma or exponential, whether failed requests are reported at all, whether the connection is reused, what --random-range-ratio 0.5 means, and which dataset random even is. Every one changes a published number. §1.2 established the metric definitions and the two most damaging divergences; this chapter audits the machinery around them, plus the offline harnesses, the loaders, and the exact commands.
At the pinned SHAs the root-level scripts are shims. benchmarks/benchmark_serving.py, benchmarks/benchmark_latency.py and benchmarks/benchmark_throughput.py are 17 lines each and exit(1) with a deprecation message. python/sglang/bench_serving.py and python/sglang/bench_one_batch.py are 22 lines each and re-export with a FutureWarning. The real code is vllm/benchmarks/serve.py (2,368 lines), vllm/benchmarks/latency.py (178), vllm/benchmarks/throughput.py (1,145), python/sglang/benchmark/serving.py (2,752) and python/sglang/benchmark/one_batch.py (1,063). The asymmetry matters: vLLM's shims refuse, so a post citing benchmarks/benchmark_serving.py is describing a command that now exits 1; SGLang's still run the new implementation behind the warning, so a post citing python -m sglang.bench_serving is describing the current code under an old name.
import warnings
from sglang.benchmark.serving import * # noqa: F401,F403
from sglang.benchmark.serving import cli_main
warnings.warn(
"`sglang.bench_serving` is deprecated and will be removed in a future "
"release; use `sglang.benchmark.serving` instead "
"(e.g. `python -m sglang.benchmark.serving`).",
FutureWarning,
stacklevel=1,
)
if __name__ == "__main__":
Mental model
Every serving benchmark harness is the same four-stage pipeline, and every disagreement between two harnesses is a disagreement inside one of the four stages. A loader turns a dataset plus flags into a list of (prompt, prompt_len, expected_output_len). A load generator decides when each of those is released. A request driver issues the HTTP call, consumes the SSE stream, and stamps timestamps as bytes arrive. An aggregator folds the per-request records into scalars. Nothing else happens.
Figure 1 — the four stages, with the file and function that owns each in both repos. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit
Stage 1 decides the workload. Stage 2 decides whether you are measuring a queueing system or a batch job. Stage 3 decides how much of the queue is on your laptop instead of on the GPU. Stage 4 decides what a "token" is. Two harnesses that agree on the model, the hardware and the prompts can still disagree on all four, and that is exactly what these two do.
First principles: open loop, closed loop, and what a client can see
The single most consequential choice a harness makes is whether request $n+1$'s send time depends on request $n$'s completion. It does not, in an open-loop harness: arrivals come from an exogenous process and the queue is allowed to grow without bound. It does, in a closed-loop harness: a fixed population of $C$ virtual clients each sends, waits, and sends again, so offered load is throttled by the system itself.
Figure 2 — open loop versus closed loop, drawn on the same time axis. Open loop keeps sending into an overloaded server and the queue diverges; closed loop cannot overload the server because a slow response delays the next send. Both harnesses default to open loop; --max-concurrency turns each into a hybrid.
Neither harness implements a true closed loop. Both are open loop with an optional semaphore, which produces a third thing: an open-loop arrival process feeding a client-side queue. The semaphore is acquired before the request function runs and therefore before st = time.perf_counter(), so the wait is invisible to every reported metric — the failure mode §1.2 spells out. §10.3 owns the methodology argument for which regime to report.
vLLM: an absolute Gamma schedule, normalised
vLLM precomputes the entire arrival schedule before sending anything, samples from a Gamma distribution whose shape parameter is --burstiness, accumulates it into absolute offsets, and then rescales so the last arrival lands exactly at $N/\lambda$:
else:
theta = 1.0 / (current_request_rate * burstiness)
# Sample the request interval from the gamma distribution.
# If burstiness is 1, it follows exponential distribution.
delay_ts.append(np.random.gamma(shape=burstiness, scale=theta))
# Calculate the cumulative delay time from the first sent out requests.
for i in range(1, len(delay_ts)):
delay_ts[i] += delay_ts[i - 1]
if ramp_up_strategy is None and delay_ts[-1] != 0:
# ...
# 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]
delay_ts = [delay * normalize_factor for delay in delay_ts]
The generator then sleeps to an absolute deadline measured from a single start_ts:
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: yield, then sleep a fresh exponential
else:
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)
Three differences fall out of those two blocks. Shape: SGLang is Poisson only; vLLM's $C_a^2 = 1/\text{burstiness}$ is tunable, and Kingman's formula says queueing scales with $C_a^2$, so --burstiness 0.3 triples the arrival-variability term at an unchanged mean rate. Drift: vLLM sleeps to an absolute deadline, so a slow event-loop iteration does not push every later arrival back; SGLang's sleep starts when the consumer resumes, so scheduling jitter and the cost of building each RequestFuncInput accumulate into the realised rate. At 6 req/s over 1,000 requests the target duration is 166.7 s; a systematic 1 ms per-iteration overhead adds 1 s to SGLang's wall clock and nothing to vLLM's. Total duration: vLLM's normalisation removes the 1–2% seed-to-seed variation in $N/\lambda$ the source comment describes; SGLang does not normalise, so its request_throughput — a division by wall-clock duration — carries that variance.
How each harness drives a request, and why the numbers do not line up
The transport layer: one session versus one session per request
vLLM builds exactly one aiohttp.ClientSession for the whole run and sizes the connection pool to --max-concurrency:
# Reuses connections across requests to reduce TLS handshake overhead.
# Use ssl_context if provided, otherwise default to True for https URLs
ssl_setting = ssl_context if ssl_context is not None else ("https://" in api_url)
connector = aiohttp.TCPConnector(
limit=max_concurrency or 0,
limit_per_host=max_concurrency or 0,
ttl_dns_cache=300,
use_dns_cache=True,
keepalive_timeout=60,
enable_cleanup_closed=True,
force_close=False,
ssl=ssl_setting,
)
session = aiohttp.ClientSession(
connector=connector,
trust_env=True,
timeout=aiohttp.ClientTimeout(total=6 * 60 * 60),
SGLang's request functions each open their own session, inside the coroutine, for the lifetime of one request:
def _create_bench_client_session():
# When the pressure is big, the read buffer could be full before aio thread read
# the content. We increase the read_bufsize from 64K to 10M.
# Define constants for timeout and buffer size for clarity and maintainability
BENCH_AIOHTTP_TIMEOUT_SECONDS = 6 * 60 * 60 # 6 hours
BENCH_AIOHTTP_READ_BUFSIZE_BYTES = 10 * 1024**2 # 10 MB
aiohttp_timeout = aiohttp.ClientTimeout(total=BENCH_AIOHTTP_TIMEOUT_SECONDS)
return aiohttp.ClientSession(
timeout=aiohttp_timeout, read_bufsize=BENCH_AIOHTTP_READ_BUFSIZE_BYTES
)
That helper is invoked per request — async with _create_bench_client_session() as session: opens each of async_request_openai_completions (python/sglang/benchmark/serving.py:L269) and async_request_sglang_generate (:L664), immediately after the argument unpacking and URL assertion that precede it. The consequence is a fresh TCP connect (and over HTTPS a fresh TLS handshake) inside every request's TTFT window, because st = time.perf_counter() is taken before the connect happens on session.post. On loopback that is tens of microseconds. Across a datacentre network at 0.5 ms RTT it is a floor of roughly 0.5 ms on TTFT that vLLM does not pay, and about 1.0 ms over TLS 1.3, whose handshake adds a second round trip (1.5 ms on TLS 1.2, which needs three). It also burns an ephemeral port per request: at 1,000 req/s with a 60-second TIME_WAIT, 60,000 sockets against a default Linux ip_local_port_range of about 28,000 — outright exhaustion, not merely pressure.
vLLM passes limit=max_concurrency or 0; in aiohttp 0 means unlimited, so leaving --max-concurrency unset gives an unbounded pool and setting it makes the pool match the semaphore exactly. SGLang's per-request sessions each get aiohttp's default connector, whose limit is 100, but each serves one request so the ceiling is never reached. The pitfall bites in the other direction: any harness that shares one session with a default connector silently caps concurrency at 100. Neither of these two does, but a homegrown wrapper around either easily will, and the symptom is excellent TTFT with concurrency stuck at 100 no matter what --request-rate says.
What counts as a token: three different answers in two repos
vLLM stamps one ITL sample per SSE frame that carries a choices field — including frames whose text is empty:
# NOTE: Some completion API might have a last
# usage summary response without a token so we
# want to check a token was generated
if choices := data.get("choices"):
# Note that text could be empty here
# e.g. for special tokens
text = choices[0].get("text")
timestamp = time.perf_counter()
# First token
if not first_chunk_received:
first_chunk_received = True
ttft = time.perf_counter() - st
output.ttft = ttft
# Decoding phase
else:
output.itl.append(timestamp - most_recent_timestamp)
most_recent_timestamp = timestamp
generated_text += text or ""
elif usage := data.get("usage"):
output.output_tokens = usage.get("completion_tokens")
if (pt := usage.get("prompt_tokens")) is not None:
output.prompt_len = pt
SGLang's OpenAI-compatible backend gates on the text being truthy, so an empty-text frame produces no sample and does not even start the TTFT clock:
# NOTE: Some completion API might have a last
# usage summary response without a token so we
# want to check a token was generated
if data["choices"][0]["text"]:
timestamp = time.perf_counter()
# First token
if ttft == 0.0:
ttft = time.perf_counter() - st
output.ttft = ttft
# Decoding phase
else:
output.text_chunks.append(
data["choices"][0]["text"]
)
output.itl.append(timestamp - most_recent_timestamp)
SGLang's native backend, reached with --backend sglang or sglang-native, does something different again: it reads the server's cumulative meta_info["completion_tokens"] and spreads the chunk gap over however many tokens arrived.
if ttft == 0.0:
ttft = time.perf_counter() - st
output.ttft = ttft
# Decoding phase
else:
num_new_tokens = output_len - last_output_len
if num_new_tokens == 0:
continue
chunk_gap = timestamp - most_recent_timestamp
adjust_itl = chunk_gap / num_new_tokens
output.itl.extend([adjust_itl] * num_new_tokens)
So SGLang's own ITL semantics depend on which backend flag you pass: --backend sglang gives per-token amortised ITL, --backend sglang-oai gives per-chunk ITL like vLLM. The aggregator then applies a third correction, but only in a narrow case — when the server reported a speculative acceptance length and the backend is one of the two OpenAI ones:
use_retokenized_itl = (
accept_length is not None
and accept_length > 0
and backend in ("sglang-oai", "sglang-oai-chat")
)
# ...
if use_retokenized_itl:
for k, itl in enumerate(outputs[i].itl):
num_tokens = len(
tokenizer.encode(
outputs[i].text_chunks[k], add_special_tokens=False
)
)
adjusted_itl = itl / num_tokens
retokenized_itls.extend([adjusted_itl] * num_tokens)
else:
itls += outputs[i].itl
accept_length comes from a GET on /server_info, reading internal_states[0]["avg_spec_accept_length"], and is only attempted when "sglang" in backend (python/sglang/benchmark/serving.py:L1581-L1600). Benchmark a vLLM server through SGLang's harness with --backend vllm and the correction never fires: you get raw per-chunk ITL, matching vLLM's harness. So the flag combination that makes SGLang's numbers comparable to vLLM's is the one that makes them incomparable to SGLang's own defaults.
Figure 3 — one identical engine behaviour, two ITL distributions. The illustrative fluid model has a 7.5 ms frame interval and mean payload 3.5 accepted tokens (illustrative, derived from this book's 4.48 ms Llama-3-8B batch-1 decode floor plus draft cost). Top: the byte stream both harnesses see. Bottom: what each records. The pictured 3.5-token payload is a fluid approximation. For alternating 3- and 4-token frames, per-token recorded values are 2.5 ms and 1.875 ms with weights 3 and 4; their pooled median is 1.875 ms, not 2.14 ms.
The incomparability table
This is the chapter's deliverable. Every row is something one harness computes differently from the other, read from source this session. A reader who has this table can look at two published numbers and say precisely why they do not compare.
vllm/benchmarks/serve.py, vllm/benchmarks/lib/endpoint_request_func.py, python/sglang/benchmark/serving.py and the dataset loaders at the pinned SHAs. No numbers, therefore nothing here is measured.| What | vLLM vllm bench serve | SGLang sglang.benchmark.serving | Comparable? |
|---|---|---|---|
| ITL sample | one per SSE frame carrying choices, empty text included (endpoint_request_func.py:L238-L240) | sglang: one per token, gap ÷ tokens (L755-L760). sglang-oai: one per frame, non-empty text only (L336-L348) | no frame size and aggregation weights differ |
| ITL re-correction | none | re-tokenizes each chunk, but only if accept_length > 0 and backend is sglang-oai(-chat) (L1116-L1120) | no |
| First token | first frame with a choices key, even if text is "" | first frame with truthy text (OAI) or truthy data["text"] (native) | nearly differs when the first token is a special token |
| E2EL stop | most_recent_timestamp - st — last content frame (endpoint_request_func.py:L256-L257) | latency recomputed at the top of the loop, so the [DONE] frame is included (L323-L326, L356-L358) | no transport-dependent terminal-event offset |
| TPOT | (latency - ttft) / (output_len - 1), output_len from usage.completion_tokens | same formula, output_len from meta_info (native) but, on the OAI backends, the requested length — the harness never sends stream_options.include_usage and its usage read sits behind a non-empty-text gate (:L333, :L348-L351) | no inherits the E2EL offset |
| E2EL reported by default | no — default --percentile-metrics is ttft,tpot,itl (serve.py:L2165) | yes — always, and printed first | — |
| Percentiles | user-chosen, --metric-percentiles, default 99 | hard-coded p90/p95/p99, no flag (L1244-L1268) | only at p99 |
| max ITL | absent | max_itl_ms, printed (L1261, L1724) | — |
| Goodput | --goodput ttft:900 e2el:14100; conjunction over per-request scalars; VALID_NAMES = ["ttft","tpot","e2el"] | absent — grep -i goodput returns nothing | — |
| Concurrency (Little's Law) | absent; reports Peak concurrent requests instead | concurrency = sum(e2e) / dur_s (L1268) and peak | — |
| Failed requests | counted, printed as a line, first 10 errors dumped (serve.py:L661-L666, L1170) | never printed; infer from num_prompts - completed | dangerous |
| Arrival process | Gamma, shape --burstiness, absolute schedule, total normalised to $N/\lambda$ | exponential only, relative sleep after yield, no normalisation | at burstiness=1 |
| Warmup | --num-warmups default 0; the ready-check request is skipped too (--ready-check-timeout-sec default 0), so there is no pre-run generation at all | --warmup-requests default 1, capped at output_len=32 | no |
| Cache flush before measuring | none | flushes in CI or with --flush-cache, then time.sleep(1.0) (L1457-L1466) | no |
| Input token count | server-authoritative when usage.prompt_tokens is returned (endpoint_request_func.py:L245-L247) | always the client tokenizer's prompt_len (L1131) | no on tokenizer mismatch |
| Output token count | usage.completion_tokens; falls back to re-tokenizing, which a source comment notes may inflate the count — nothing is printed at runtime | server count and a second retokenized total, both printed | yes SGLang shows the gap |
| Connection | one pooled session, keepalive 60 s | a new ClientSession per request | TTFT offset |
--dataset-name default | random — synthetic token ids | sharegpt — real conversations | no |
random means | (offset + index + arange) % vocab_size, specials excluded | ShareGPT text truncated or repeated to length; random-ids is the synthetic variant | no |
--random-range-ratio r | uniform on $[\lfloor L(1-r)\rfloor, \lceil L(1+r)\rceil]$, mean $L$ | uniform on $[\max(\lfloor Lr\rfloor,1), L]$, mean $L(1+r)/2$ | no opposite semantics |
Set --random-input-len 1024 --random-range-ratio 0.0 in both. vLLM sends 1,000 prompts of exactly 1,024 tokens less the tokenizer's special-token budget: about 1.02 M input tokens. SGLang sends prompts uniform on [1, 1024]: about 512 K input tokens, half as many. At --random-range-ratio 0.5 vLLM's mean stays 1,024 while SGLang's is 768. Identical flags: SGLang sends 25–50% fewer input tokens, equivalently vLLM sends 33–100% more. The arithmetic is vllm/benchmarks/datasets/utils.py:L72-L75 against python/sglang/benchmark/datasets/common.py:L56-L64. §10.1 owns why the resulting distributions are unrepresentative; this is just what the loaders do.
num_special_tokens = int(tokenizer.num_special_tokens_to_add())
real_input_len = max(0, int(input_len) - num_special_tokens)
input_low = math.floor(real_input_len * (1 - input_range_ratio))
input_high = math.ceil(real_input_len * (1 + input_range_ratio))
output_low = math.floor(output_len * (1 - output_range_ratio))
output_high = math.ceil(output_len * (1 + output_range_ratio))
# Ensure the lower bound for output length is at least 1 to
# prevent sampling 0 tokens.
output_low = max(output_low, 1)
output_high = max(output_high, 1)
def compute_random_lens(full_len: int, range_ratio: float, num: int) -> List[int]:
# full_len=0 is valid for embedding benchmarks where no output tokens are generated
if full_len <= 0:
return [0] * num
return np.random.randint(
max(int(full_len * range_ratio), 1),
full_len + 1,
size=num,
).tolist()
The loaders also differ in what they put in the prompt. vLLM's RandomDataset generates (offset + index + arange(input_len)) % vocab_size with special ids removed, then decodes, re-encodes, and warns if the round trip changed the length (vllm/benchmarks/datasets/datasets.py:L559-L572, L680-L689). SGLang's random downloads ShareGPT, takes real first-turn prompts, and repeats a short prompt to reach the target length: ratio = (input_lens[i] + prompt_len - 1) // prompt_len; input_ids = (prompt_token_ids * ratio)[: input_lens[i]] (python/sglang/benchmark/datasets/random.py:L128-L134). That is emphatically not vLLM's uniform-noise distribution, and the source flags the synthetic alternative as risky: the random-ids branch is commented "Sample token ids from random integers. This can cause some NaN issues."
The offline harnesses: a different question entirely
The serving harnesses answer "what does a user see under this offered load". They cannot isolate a kernel, because every number they produce is contaminated by scheduling, admission, HTTP, and the arrival process. For a kernel-level change you want a harness with no server, no network, and a fixed batch. Both projects ship one, and they are structured differently.
vllm bench latency
178 lines. Builds an in-process LLM, generates --batch-size dummy prompts of --input-len random token ids, and times llm.generate() end to end. 10 warmup iterations, 30 measured, percentiles across iterations. Reports one scalar per iteration: whole-batch wall time. You divide to get a step time.
vllm bench throughput
1,145 lines. Submits every request at once and divides token counts by wall time. No latency percentiles at all. This is a saturation number: the engine always has a full backlog, so it measures peak sustainable tokens/s and nothing about tails.
python -m sglang.benchmark.one_batch
1,063 lines. Bypasses the server entirely, drives ModelRunner.extend and .decode directly, and synchronises the device around every single decode step. Reports prefill latency, per-step decode latency, and the median across steps. This is the number you place on a roofline.
tic = time.perf_counter()
next_token_ids, _ = model_runner.decode(next_token_ids, batch)
model_runner.synchronize()
latency = time.perf_counter() - tic
# ...
# Record decode timing from 2nd output
if output_len > 1:
med_decode_latency = np.median(decode_latencies)
med_decode_throughput = batch_size / med_decode_latency
rank_print(
f"Decode. median latency: {med_decode_latency:6.5f} s, median throughput: {med_decode_throughput:9.2f} token/s"
)
That model_runner.synchronize() on both sides of the timed region is the whole reason this harness exists: without it CUDA's asynchrony lets the CPU race ahead and every per-step number is a launch time, not an execution time. It is also why one_batch is a worse throughput benchmark than the serving harness — syncing every step destroys the CPU/GPU overlap a real server relies on. Use it to answer "did my kernel get faster", never "how fast is this engine".
vLLM's latency.py makes one configuration decision worth copying:
parser = EngineArgs.add_cli_args(parser)
# V1 enables prefix caching by default which skews the latency
# numbers. We need to disable prefix caching by default.
parser.set_defaults(enable_prefix_caching=False)
Thirty iterations over the same dummy_prompts with prefix caching on would make iterations 2–30 nearly free of prefill, and the reported average would be a decode-only number wearing a prefill-plus-decode label. Neither serving harness makes this correction — which is why SGLang's post-warmup cache flush matters, and why vLLM's lack of one matters.
Worked trace: one request through each harness
Follow request #417 of a 1,000-prompt run at --request-rate 6.0.
vLLM. (1) main_async loads the dataset with get_samples(args, tokenizer), then — for random and prefix_repetition only — calls _align_prompts_to_server_tokenizer, which POSTs prompt 0 to the server's /tokenize and compares the length (vllm/benchmarks/serve.py:L2125-L2128). (2) benchmark() builds the shared session; the wait_for_endpoint ready check is gated on --ready-check-timeout-sec, which defaults to 0 (:L1899-L1905, :L858-L874), and --num-warmups also defaults to 0 — so at stock defaults no request is sent before the clock starts. (3) get_request has already precomputed delay_ts[417]; it sleeps until start_ts + delay_ts[417] and yields. (4) The main loop builds a RequestFuncInput and wraps limited_request_func in an asyncio.Task; the task is created immediately, so the semaphore wait happens inside it. (5) async_request_openai_completions takes st, POSTs with stream_options: {include_usage: True}, and feeds every socket read into StreamedResponseHandler.add_chunk, which splits on \n\n and emits only complete SSE messages (vllm/benchmarks/lib/endpoint_request_func.py:L31-L61). (6) The first message with choices sets ttft; each later one appends to output.itl; the trailing usage-only message sets output_tokens and overwrites prompt_len. (7) output.latency = most_recent_timestamp - st. (8) calculate_metrics pools every request's itl list into one flat array and takes np.percentile over the pool.
SGLang. (1) run_benchmark loads the dataset and builds the tokenizer. (2) benchmark() fires --warmup-requests tasks (default 1) with output_len=min(test_request.output_len, 32), checks at least one succeeded, conditionally flushes the server cache, and sleeps 1 s. (3) get_request yields request 417 and only then sleeps np.random.exponential(1/6.0). (4) limited_request_func acquires the semaphore if one exists. (5) async_request_openai_completions opens a new ClientSession, takes st, POSTs, and iterates response.content line by line — aiohttp's StreamReader.__aiter__, so each iteration is one SSE line. (6) latency is recomputed on every non-empty line including [DONE]. (7) A frame with truthy text sets ttft or appends an ITL sample and a text chunk. (8) output.latency = latency — the [DONE] timestamp. (9) The session closes and the TCP connection is torn down. (10) After gather, the harness GETs /server_info for avg_spec_accept_length, then calculate_metrics optionally re-tokenizes every chunk.
Step 8 in vLLM and step 8 in SGLang are the same line of intent and different lines of code, and that is the entire E2EL divergence. Step 5 in vLLM and step 5 in SGLang are the same intent and produce a TTFT that differs by one TCP handshake.
Pitfalls and war stories
The headline: never compare across harnesses
ITL differs with frame sizes and aggregation weights, E2EL by the terminal-event timing, TPOT by that offset divided by $N-1$, TTFT by one TCP handshake, input-token totals by the tokenizer, and the workload by the loader semantics. If you must compare two engines, run one harness against both. SGLang's has --backend vllm and --backend vllm-chat; vLLM's has --backend openai and openai-chat, which work against any OpenAI-compatible server including SGLang. Pick one, say which in the report, and note that whichever you picked has a home-field advantage in defaults.
The client is a GPU-starving CPU workload
Both harnesses run a single-threaded asyncio loop that JSON-decodes every SSE frame. SGLang's native backend sends the cumulative generated text in every frame, which the source notes makes parsing O(n²) per request — the reason it switched to orjson on raw bytes (python/sglang/benchmark/serving.py:L714-L720). On the same host as the server, that loop competes with the engine's frontend for cores, inflates ITL, and gets worse as output length grows. Run the client elsewhere, or at minimum re-run at half the request rate and confirm the latencies do not move.
A tokenizer mismatch inflates input-length statistics
vLLM detects and repairs this, but only for the random and prefix_repetition datasets. The real strings are WARNING: /tokenize unavailable, skipping alignment. and WARNING: tokenizer mismatch (server=…, expected=…), re-aligning prompts. (vllm/benchmarks/serve.py:L118-L131). SGLang has no equivalent: its total_input is whatever its local tokenizer counted, so if the server adds a BOS the harness never learns. The tell is SGLang's Total generated tokens versus … (retokenized) — if those disagree on the output side, assume the input side does too.
SGLang never prints a failure count
The harness prints Successful requests: N and nothing else. If 200 of your 1,000 requests died mid-stream, the percentiles are computed over the 800 that survived — the fast ones — and the output looks great. Always compute --num-prompts minus completed by hand. vLLM prints Failed requests: and dumps up to ten error strings (vllm/benchmarks/serve.py:L661-L666).
vLLM's default warmup is zero requests
--num-warmups defaults to 0 (vllm/benchmarks/serve.py:L1693-L1697). And the ready-check request that would at least have touched one shape is skipped as well: --ready-check-timeout-sec defaults to 0 (:L1899-L1905). At stock defaults there is no pre-run generation, so prompt 0's prefix is cold and any CUDA graph capture or JIT triggered by an unseen shape lands inside your first measured request. Ask for a ready check and the opposite problem appears: it uses the full requested output_len and is not followed by a cache flush, so prompt 0's prefix is warm instead. SGLang runs one 32-token warmup and can flush. Neither default is adequate; set warmups explicitly and say what you set.
Numbers that are easy to misread
Peak output token throughput in both harnesses is the max over 1-second buckets reconstructed from start_time + ttft plus the cumulative ITL list — not an engine counter, and biased upward by bucket alignment. Peak concurrent requests marks a request active in every whole second between its start and end, over-counting short requests. SGLang's Concurrency is $\sum \text{E2EL} / T$, a Little's-Law mean, not a peak — the two SGLang lines look alike and mean different things. And vLLM's Total input tokens sums successful requests only, so it shrinks when requests fail.
I could not find a warmup-request exclusion mechanism in either harness beyond running warmups before benchmark_start_time is taken — neither drops early measured requests from the statistics, so ramp-up effects inside the measured window are included in the percentiles. I looked in vllm/benchmarks/serve.py (benchmark and calculate_metrics) and python/sglang/benchmark/serving.py (benchmark and calculate_metrics). If a steady-state-only mode exists it is somewhere I did not look; check before claiming either harness reports steady state.
Hands-on: the real commands at these SHAs
The vLLM entry point is the CLI subcommand registered by BenchmarkServingSubcommand in vllm/entrypoints/cli/benchmark/serve.py:L11-L22, which simply forwards to vllm.benchmarks.serve.add_cli_args and main — unless VLLM_USE_RUST_BENCH=1, in which case maybe_exec_rust_bench() os.execvs a Rust binary instead and none of this Python runs at all (vllm/entrypoints/cli/main.py:L57, vllm/entrypoints/cli/benchmark/main.py:L23-L35; the variable defaults to False). The sibling subcommands are latency, throughput, startup, sweep and mm-processor — the names come from each class's name attribute, not from the module names imported at vllm/entrypoints/cli/benchmark/main.py:L38-L46.
vllm bench serve \
--backend vllm --model meta-llama/Meta-Llama-3-8B-Instruct \
--dataset-name random --random-input-len 1024 --random-output-len 256 \
--random-range-ratio 0.0 \
--num-prompts 1000 --request-rate 6.0 --burstiness 1.0 \
--num-warmups 50 \
--percentile-metrics ttft,tpot,itl,e2el --metric-percentiles 50,90,99 \
--goodput ttft:900 e2el:14100 \
--ignore-eos \
--save-result --result-filename run-vllm.json
# offline, for a kernel change:
vllm bench latency --model meta-llama/Meta-Llama-3-8B-Instruct \
--input-len 1024 --output-len 128 --batch-size 32 \
--num-iters-warmup 10 --num-iters 30 --output-json lat.json
# offline, for peak sustainable throughput:
vllm bench throughput --model meta-llama/Meta-Llama-3-8B-Instruct \
--dataset-name random --input-len 1024 --output-len 256 --num-prompts 1000
python -m sglang.benchmark.serving \
--backend sglang \
--model meta-llama/Meta-Llama-3-8B-Instruct \
--dataset-name random --random-input-len 1024 --random-output-len 256 \
--random-range-ratio 1.0 \
--num-prompts 1000 --request-rate 6.0 \
--warmup-requests 50 --flush-cache \
--output-file run-sgl.jsonl --output-details
# offline, per-decode-step latency:
python -m sglang.benchmark.one_batch \
--model-path meta-llama/Meta-Llama-3-8B-Instruct \
--batch-size 1 8 32 --input-len 1024 --output-len 128 \
--run-name roofline --result-filename one_batch.jsonl
--random-range-ratio 1.0 on the SGLang side is what reproduces vLLM's 0.0: SGLang's lower bound is $\lfloor L \cdot r \rfloor$, so $r = 1$ pins every length to $L$. That single substitution is the difference between comparing the same workload and comparing workloads whose input token counts differ 2×.
Both print a fixed-width block to stdout. The labels below are the literal format strings from the print statements, with values elided — I have no GPU, so there are no numbers here and you should be suspicious of anyone who publishes a table without saying which harness and which flags produced it.
============ Serving Benchmark Result ============
Successful requests: ...
Failed requests: ...
Maximum request concurrency: ... # only if --max-concurrency
Request rate configured (RPS): ... # only if not inf
Benchmark duration (s): ...
Total input tokens: ...
Total generated tokens: ...
Request throughput (req/s): ...
Request goodput (req/s): ... # only if --goodput
Output token throughput (tok/s): ...
Peak output token throughput (tok/s): ...
Peak concurrent requests: ...
Total token throughput (tok/s): ...
--------------- Time to First Token --------------
Mean TTFT (ms): / Median / P50 / P90 / P99 ... # percentiles from --metric-percentiles
------ Time per Output Token (excl. 1st token) ----
Mean TPOT (ms): / Median / P... ...
--------------- Inter-token Latency --------------
Mean ITL (ms): / Median / P... ...
----------- Speculative Decoding ----------------- # only if the server exposes the counters
Acceptance rate (%): / Acceptance length: / Drafts: / Draft tokens: / Accepted tokens:
==================================================
============ Serving Benchmark Result ============
Backend: / Traffic request rate: / Max request concurrency:
Successful requests: ... # NO failure line
Benchmark duration (s): ...
Total input tokens: / Total input text tokens:
Total generated tokens: / Total generated tokens (retokenized):
Request throughput (req/s): / Input token throughput (tok/s):
Output token throughput (tok/s): / Peak output token throughput (tok/s):
Peak concurrent requests: / Total token throughput (tok/s):
Concurrency: ... # sum(E2EL)/duration, Little's Law
Accept length: ... # from /server_info, sglang backends only
----------------- End-to-End Latency -------------
Mean / Median / P90 / P95 / P99 E2E Latency (ms) # percentiles are HARD-CODED
--------------- Time to First Token --------------
Mean / Median / P90 / P95 / P99 TTFT (ms)
------ Time per Output Token (excl. 1st token) ----
Mean / Median / P90 / P95 / P99 TPOT (ms)
--------------- Inter-Token Latency --------------
Mean / Median / P90 / P95 / P99 / Max ITL (ms) # Max ITL exists only here
==================================================
Three checks to run on any output before you believe it. (1) Divide mean_itl_ms by mean_tpot_ms. Do not interpret that ratio alone as acceptance: pooled ITL weights intervals while mean TPOT may weight requests equally. Different output lengths and latencies can make the ratio differ from one even with one token per frame and no speculation. Inspect per-request event counts and matching denominators. (2) In SGLang, compare Total generated tokens with Total generated tokens (retokenized). On the native backend a gap means the harness's tokenizer disagrees with the server's, and every token-throughput number is off by that ratio; on an OAI backend the first figure is the requested length, so the gap measures how often generation stopped early instead. (3) Compute --num-prompts minus Successful requests. If it is not zero, the percentiles are conditioned on survival.
Exercises
- Read the file. Open
python/sglang/benchmark/serving.pyand find bothasync_request_openai_completionsandasync_request_sglang_generate. Write down, for each, the exact line that appends tooutput.itl. Then state what--backend sglang-oaiversus--backend sglangdoes to the reported median ITL for a non-speculative run where every frame carries exactly one token. - Arithmetic. Both harnesses are launched with
--random-input-len 2048 --random-output-len 256 --random-range-ratio 0.25 --num-prompts 1000. Compute the expected total input tokens for each. Which flag value would you give SGLang to match vLLM's distribution exactly, and is an exact match even possible? - Predict, then verify. You run vLLM's harness against a server over HTTPS with a 0.4 ms RTT, then SGLang's harness against the same server with the same workload. Predict the sign and rough size of the TTFT difference and the E2EL difference, and say which lines of code produce each. Then check against
vllm/benchmarks/serve.py:L805-L822andpython/sglang/benchmark/serving.py:L71-L82. - Predict, then verify. A colleague benchmarks a kernel change with
vllm bench latency --batch-size 1 --input-len 8192 --output-len 128and reports "3% faster". Predict what fraction of the measured wall time is the change's target if the change only affects decode attention, using this book's 4.48 ms Llama-3-8B batch-1 decode floor. Then explain whypython -m sglang.benchmark.one_batchwould give a cleaner answer, and what it would cost you. - Design. You must publish one table comparing vLLM and SGLang on a shared workload. Write the exact two command lines and the exact list of caveats the table's caption must carry. At least five caveats should come from the incomparability table above.
Answers
1. OAI backend: output.itl.append(timestamp - most_recent_timestamp) at python/sglang/benchmark/serving.py:L348. Native backend: output.itl.extend([adjust_itl] * num_new_tokens) at L760. When every frame carries exactly one token, num_new_tokens is 1 and adjust_itl == chunk_gap, so the two are identical — the divergence is entirely a multi-token-per-frame phenomenon (speculative decoding, or any proxy that coalesces frames). This is why the difference is invisible in most published comparisons and catastrophic in the ones that involve spec decode.
2. vLLM: uniform on $[\lfloor 2048 \cdot 0.75 \rfloor, \lceil 2048 \cdot 1.25 \rceil] = [1536, 2560]$ less special tokens, mean about 2,048, so roughly 2.05 M input tokens. SGLang: uniform on $[512, 2048]$, mean 1,280, so roughly 1.28 M — 38% fewer. No SGLang value matches vLLM's distribution, because SGLang's interval is right-anchored at $L$ and vLLM's is centred on $L$; --random-range-ratio 1.0 against vLLM's 0.0 matches only the degenerate fixed-length case. For anything else, generate the prompts once and feed both harnesses the same file.
3. SGLang's TTFT is higher by one TCP connect plus one TLS handshake — roughly 0.8 ms at 0.4 ms RTT, two round trips, since TLS 1.3 adds one after the TCP handshake — because _create_bench_client_session is called inside the request coroutine and st is taken before session.post. vLLM pays it once and reuses the connection for the remaining 999. Separately, SGLang's E2EL is measured through the terminal event instead of the final content event, with no fixed RTT increment; both may arrive in one transport read, because latency is recomputed on the [DONE] line. Both offsets favour vLLM's reported numbers.
4. 128 decode steps at roughly 4.48 ms is about 573 ms of decode; an 8,192-token prefill at batch 1 is compute-bound and of the same order. Decode attention is therefore well under half the measured wall time and is only one of several decode kernels, so a "3% faster" whole-batch number could be a 15–30% kernel win or noise across 30 iterations. one_batch reports the median per-step decode latency directly (python/sglang/benchmark/one_batch.py:L856-L862), separating prefill from decode and giving a distribution over hundreds of steps rather than 30 batch totals. The cost: its per-step synchronize() destroys CPU/GPU overlap, so its absolute numbers are pessimistic and must never be quoted as engine throughput, and it runs one trial per sweep point, so run-to-run variance is unmeasured.
5. One harness against both servers — e.g. vllm bench serve --backend openai --base-url http://vllm-host:8000 … and the identical command with --base-url http://sglang-host:30000 — both with --num-warmups 50, explicit --metric-percentiles and --goodput, prompts from a shared file rather than generated per-harness, and the client on a third machine. Caveats: (i) ITL is per-frame, so it looks worse for whichever engine emits more tokens per frame; (ii) E2EL stops at the last content token; (iii) no cache flush between runs, so run order matters; (iv) input token counts depend on each server's tokenizer; (v) failed requests are excluded from all percentiles; (vi) arrivals are Gamma with the stated burstiness, not production traffic; (vii) neither engine's defaults were tuned, so this compares defaults, not ceilings.
Key takeaways
- Under speculative decoding the same engine produces ITL distributions that differ with frame sizes and sample weighting between the two harnesses, and by which SGLang backend flag you passed —
--backend sglangamortises per token,--backend sglang-oaidoes not unless the server also reportsavg_spec_accept_length. --random-range-ratiohas opposite meanings: vLLM centres the length interval on $L$, SGLang right-anchors it at $L$ with $\lfloor Lr \rfloor$ as the floor. At the default0.0the same flags give vLLM a fixed 1,024-token prompt and SGLang a mean of 512.--dataset-name randomalso means synthetic token ids in one repo and repeated ShareGPT text in the other.- vLLM opens one pooled connection for the run; SGLang opens one per request. The handshake lands inside TTFT, which biases SGLang's TTFT upward off-loopback and burns an ephemeral port per request at high rates.
- SGLang never prints a failure count, so percentiles computed over survivors look excellent under overload. vLLM defaults to zero warmup requests and never flushes the prefix cache, so its first measured requests carry graph-capture and cache-warming costs. Neither default is publishable; set both explicitly.
- The serving harnesses measure a scheduling change;
vllm bench latencyandsglang.benchmark.one_batchmeasure a kernel change.one_batchsynchronises the device around every decode step, which is exactly what you want for a roofline placement and exactly what makes its throughput numbers pessimistic. - Within one run,
mean_itl_ms / mean_tpot_msis a free consistency check: it should be 1.0 unless frames carry multiple tokens, in which case it estimates the tokens per frame.
Further reading
- The provenance header.
python/sglang/benchmark/serving.py:L1-L4records that the file was adapted from vLLM'sbenchmark_serving.pyandbackend_request_func.pyat vLLM commit6366efc. Everything in the incomparability table is drift since that fork. Reading the two files side by side is the fastest way to see which project changed which decision. - vLLM PR #9390, vllm-project/vllm#9390 — the
--max-concurrencysemaphore. SGLang's copy still carries the comment# From https://github.com/vllm-project/vllm/pull/9390atpython/sglang/benchmark/serving.py:L1378-L1379. - DistServe, Zhong et al., arXiv:2401.09670 — the goodput definition vLLM's
--goodputhelp text points at, and the reason ITL is excluded fromVALID_NAMES. - vLLM issue #9778, vllm-project/vllm#9778 — multi-modal token counting in
throughput.py; the harness prints a redWARNINGpointing at it whenever a multi-modal request runs on a non-chat backend (vllm/benchmarks/throughput.py:L1116-L1124). - Open versus closed loop. Schroeder, Wierman and Harchol-Balter, Open Versus Closed: A Cautionary Tale, NSDI '06 — the paper that explains why the two regimes give different answers and why closed-loop harnesses cannot find the tail. §10.3 applies it.
- Neighbours: §1.2 for the metric definitions and the Prometheus surface, §10.1 for what the shipped datasets are biased toward, §10.3 for warmup and saturation methodology, §10.4 for why two identical runs do not produce identical tokens, and §10.5 for turning a decode step into a roofline point and then into money.