ML Interview Notes
30 min read11 sections
Part 10 · Benchmarking and performance engineering · 10-01

Workload characterization

Status
SOURCE PINNED
Primary sources
  • benchmarks/
  • python/sglang/benchmark/serving.py
Edition pins
vllm a556f3f · sglang 7d89325

One H100. One Llama-3-8B. One engine, one set of flags. Change nothing but the shape of the requests and output throughput moves by 29×. Every number in the rest of this book is conditional on a workload description, and a benchmark result published without one is not a result — it is a rumour with three significant figures.

§1

The problem

Two teams run the same command against the same server. Team A summarises support tickets: 8,000 input tokens, 200 output tokens. Team B runs a chat assistant: 500 in, 500 out. Both saturate the GPU. Both report "Llama-3-8B on an H100."

Team A would see 447 output tokens/s. Team B, 12,796. Neither is wrong. Neither is comparable to the other. And neither number tells you anything about what the third team — the one running a coding agent at 1,000 in and 8,000 out — is going to see.

The derivation uses only constants this book has already established. Prefill of $S$ tokens costs $t_{\text{pre}}(S) = 40.6\,\mu\text{s} \cdot S + 6.63{\times}10^{-10}\,\text{s} \cdot S^2$ — reproducing §1.1's 86 ms at 2,048 tokens and 2,041 ms at 32,768 to within 0.2%. The KV pool holds 428,569 resident tokens at 128 KiB/token (§2.1), and filling it makes a decode step cost $4.48\,\text{ms} + 56.2\,\text{GB}/3.35\,\text{TB/s} = 21.25\,\text{ms}$, regardless of workload.

Derived — three workloads on one H100 running Llama-3-8B bf16, TP=1, KV pool saturated. Arithmetic only; nothing here was measured.
Workloadinoutmean resident $\bar s$$B_{\max}$prefill ms/reqdecode ms/reqprefill shareout tok/s
Summarisation8,0002008,10052367.580.382.1%447
Chat50050075057120.518.652.4%12,796
Agentic / reasoning1,0008,0005,0008541.31,9832.0%3,952
28.7×
output tok/s, chat vs. summarisation
82% → 2%
share of engine time in prefill
52 → 571
concurrent requests the pool allows

Same silicon, same weights, same scheduler. The spread comes entirely from two numbers per request. That is the thesis of this chapter and of Part 10: workload shape is not a footnote on a benchmark, it is the dominant term. Before you tune a flag, before you compare two engines, before you believe a blog post, you characterise the workload.

§2

Mental model

A workload is not "chat" or "RAG". Those are product categories. A workload, for the purpose of predicting what a GPU will do, is four distributions:

AXIS 1

Input length distribution

Sets prefill cost (super-linear), KV footprint per request, and therefore how many requests fit — which sets your decode arithmetic intensity. The distribution, not the mean: a heavy tail is head-of-line blocking.

AXIS 2

Output length distribution

Sets the number of decode steps and how long a sequence holds its KV. Unknown at admission time, which is why schedulers must reserve pessimistically.

AXIS 3

Arrival process

Rate and variability. Variability, not rate, is what inflates queueing — and queueing is 90–99% of p99 TTFT.

AXIS 4

Prefix-sharing structure

How much of each input is shared and where the sharing sits. A shared prefix deletes prefill work. A shared suffix deletes nothing.

Axes 1 and 2 place you on the roofline. Axis 3 decides whether the queue in front of the roofline is empty or 300 deep. Axis 4 decides how much of axis 1's cost you actually pay.

Figure 1 — the three canonical workloads on the H100 roofline, with their prefill:decode time splits. Derived. Prefill sits far right of the ridge in all three; the decode step's intensity is set by how many requests the KV pool allows, which is set by input length.

Roofline placement of three workloads Log-log roofline for H100 SXM bf16 with ridge point at 295 FLOP per byte. Decode steps for summarisation, agentic and chat workloads sit at 14.8, 22.3 and 132 FLOP per byte respectively. Prefill for all three sits between 400 and 1630 FLOP per byte, above the ridge. Bars at the bottom show prefill to decode time splits of 82:18, 2:98 and 52:48. 1 10 100 1k 10k arithmetic intensity I (FLOP/byte, log) 3.35 33.5 335 989 TFLOP/s (log) ridge I* = 295 decode summarisation — I=14.8, 50 TFLOP/s decode agentic — I=22.3, 75 TFLOP/s decode chat — I=132, 442 TFLOP/s prefill, all three (I = 402 … 1631) prefill : decode share of engine time summarisation 82 : 18 agentic 2 : 98 chat 52 : 48
§3

First principles: two constants and one ratio

The table in §1 came out of a two-constant model. Deriving it is worth five minutes because the model then predicts any workload you care to name.

Prefill constant. From FORMULAS, $F_{\text{prefill}}(S) = 2PS + 2Lh d_h S^2$. Calibrating against the book's 86 ms for a 2,048-token Llama-3-8B prefill gives an effective throughput of 395 TFLOP/s — 39.9% MFU — and splits into a per-token term and a quadratic term:

$$t_{\text{pre}}(S) \;=\; \underbrace{c_p\,S}_{\text{weight GEMMs}} + \underbrace{c_q\,S^{2}}_{\text{attention}}, \qquad c_p = 40.6\,\mu\text{s},\quad c_q = 6.63{\times}10^{-10}\,\text{s}$$

The quadratic term is 3.2% of prefill at $S = 2{,}048$, 11.6% at 8,000, and 34.9% at 32,768. Below about 4k tokens you can pretend prefill is linear; above it you cannot.

Decode constant. A saturated engine runs at $B = N_{\max}/\bar{s}$ concurrent requests, where $N_{\max} = 428{,}569$ is the pool in tokens and $\bar{s}$ is the mean resident sequence length. Each step costs $t_{\text{step}} = 4.48\,\text{ms} + kN_{\max}/\beta = 21.25$ ms — a machine constant, not a workload one, because the pool is full either way. The engine time one request's decode phase consumes is its share of those steps:

$$t_{\text{dec}} \;=\; S_{\text{out}} \cdot \frac{t_{\text{step}}}{B} \;=\; S_{\text{out}}\,\bar{s}\;\underbrace{\frac{t_{\text{step}}}{N_{\max}}}_{c_d} , \qquad c_d = 4.96{\times}10^{-8}\,\text{s}$$

Read that carefully. Decode engine time is bilinear in output length and resident length. Doubling the input length doubles the cost of every decode step the request will ever take, because it halves the batch that shares the weight stream. This is the mechanism by which axis 1 controls decode performance, and it is invisible if you only look at prefill.

The ratio that predicts everything

$$\frac{t_{\text{pre}}}{t_{\text{dec}}} \;\approx\; \frac{c_p}{c_d}\cdot\frac{S_{\text{in}}}{S_{\text{out}}\,\bar{s}}, \qquad \frac{c_p}{c_d} = 820$$

For a symmetric workload with $S_{\text{in}} = S_{\text{out}} = x$ and $\bar{s} = 1.5x$, prefill and decode cost exactly the same engine time at:

$$x^{*} = \frac{c_p}{1.5\,c_d - c_q} \;=\; 551 \text{ tokens.}$$

That is the balance point for Llama-3-8B on an H100, and it is why "chat" feels like the neutral workload: a 500-in/500-out request lands within 10% of a 50/50 split, by coincidence of model shape and hardware. Move either number and the split collapses fast — $t_{\text{pre}}/t_{\text{dec}}$ is 4.58 for summarisation and 0.021 for agentic, a factor of 220 between them.

Consequence

Speculative decoding helps the agentic workload (98% decode) and is nearly worthless on summarisation (18% decode) — before you even consider that §6.2's speedup condition turns negative at high batch. Chunked prefill (§1.5) is load-bearing for summarisation and a rounding error for agentic. Neither statement is a property of the engine. Both are properties of the ratio.

Where each lands on the roofline

Prefill intensity is $I(T) = 2Td/(b(2T+d))$, and every prefill in this chapter — 402 at $T=500$, 1,631 at $T=8{,}000$ — sits far right of the 295 ridge (§0.4). Prefill is compute-bound in all three workloads. The interesting variation is in decode, where the whole-step intensity is $B(2P + 4Lhd_hs)$ FLOPs over 71.2 GB of traffic — 15.01 GB of streamed weights (the 7.505 B non-embedding parameters; the embedding table is resident but never read, §0.4) plus the 56.17 GB pool:

Derived — decode-step arithmetic intensity at KV saturation. Same 71.2 GB and the same step duration (21.25 ms) in every row; the FLOPs differ by 8.9×.
Workload$B$step FLOPs$I$ (FLOP/byte)$\beta I$% of bf16 peak
Summarisation521.06 × 101214.850 TFLOP/s5.0%
Agentic851.59 × 101222.375 TFLOP/s7.6%
Chat5719.40 × 1012132442 TFLOP/s44.7%

A chat decode step does 8.9× the arithmetic of a summarisation decode step in the same 21.25 ms, because 571 rows amortise the weight stream instead of 52. If someone shows you a "5% MFU" decode measurement and calls it an engine problem, ask what the input lengths were.

§4

The four axes, mechanically

Axis 1 — input length: the mean is the wrong statistic

Take a mixed production workload: 80% short chat turns at 600 tokens, 15% short documents at 4,000, 5% long documents at 16,000. Mean input length: 1,880 tokens. Now take a synthetic workload where every request is exactly 1,880 tokens. Identical means, identical token volume, identical arrival rate.

Derived — two input-length distributions with the same mean. $C^2$ is the squared coefficient of variation, $\mathrm{Var}/\mu^2$.
StatisticFixed 1,88080/15/5 mix
Mean input tokens1,8801,880
$C^2$ of input length0.003.38
Mean prefill time78.7 ms86.7 ms
Worst-case prefill (one request)78.7 ms819.9 ms
Share of prefill time in the top 5%5.0%47.3%
Kingman $\mathbb{E}[W]$ at $\rho = 0.8$, Poisson arrivals2.00 $S$8.76 $S$

The mean prefill time differs by 10%. The mean queueing delay differs by 4.4×, because §1.2's Kingman correction scales the wait by $(C_a^2 + C_s^2)/2$, and service-time variability comes straight off the input-length distribution. The table sets $C_s^2$ equal to the length $C^2$, which is exact only if prefill were linear in $S$; carrying the $S^2$ term through raises $C_s^2$ to 4.14 and the gap to 5.1×, so 4.4× is the conservative reading, not the generous one. Five percent of requests carry 47% of the prefill work and set the tail. Without chunked prefill, each of those 16k prefills stalls every decoding request in the batch for 820 ms — the head-of-line blocking derived in §1.1.

Figure 2 — two input-length distributions with identical means. Derived. The mean (dashed) sits in a bin that almost nobody occupies. The 5% tail bar is 27× wider than the mode and owns 47% of all prefill time.

Input length distributions, fixed versus heavy-tailed Left panel: all mass at 1880 tokens. Right panel: 80 percent at 600 tokens, 15 percent at 4000, 5 percent at 16000, with the mean of 1880 marked between the first two bars and the 5 percent tail highlighted. A — fixed length, C² = 0 B — 80/15/5 mix, C² = 3.38 1,880 100% 600 80% 4,000 15% 16,000 5% mean 1,880 — 0% of requests 47% of all prefill time input tokens (bars not to horizontal scale)

Axis 2 — output length: unknown at admission

Input length is known when the request arrives. Output length is not. The scheduler has, at best, max_tokens — an upper bound the client picked, often the model's context limit. This asymmetry is why admission control has to guess, and both engines guess differently.

SGLang guesses explicitly, with a scalar it tunes online. new_token_ratio scales the KV reservation for each running request (§1.4), and retraction snaps it up towards the observed generation fraction before decaying back — additive increase, additive decrease, on KV admission. Read that as a feedback loop that learns your output-length distribution at runtime. If your workload's real generation fraction is 5% of max_tokens, the controller settles low and admits aggressively. If it is 90%, it settles high and your effective batch shrinks. Two workloads with identical input lengths and identical arrival rates will therefore run at different concurrency, purely from axis 2.

Characterise this

Log both max_tokens and the realised output length. Their ratio distribution is the input to SGLang's controller and the thing that decides whether --schedule-conservativeness needs touching. vLLM exports both: vllm:request_params_max_tokens and vllm:request_generation_tokens.

Axis 3 — the arrival process changes the physics

§1.2 owns the queueing result: at $\rho \in [0.5, 0.95]$, 89–99% of p99 TTFT is queueing. It states Kingman's correction and leaves the arrival-variability treatment here. So:

$$\mathbb{E}[W] \;\approx\; \frac{\rho}{1-\rho}\cdot\frac{C_a^{2}+C_s^{2}}{2}\cdot S$$

with $C_a^2$ the squared CV of inter-arrival times and $C_s^2$ that of service times. Both harnesses let you set $C_a^2$, and in vLLM's case the mapping is exact. vLLM draws inter-arrival gaps from a Gamma distribution:

vllm/benchmarks/serve.py:L456-L467 vLLM
            if current_request_rate == float("inf"):
                delay_ts.append(0)
            elif burstiness == float("inf"):
                # when burstiness tends to infinity, the delay time becomes constant
                # and tends to the inverse of the request rate
                delay_ts.append(1.0 / current_request_rate)
            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))

Gamma with shape $\kappa = \texttt{burstiness}$ and scale $\theta = 1/(\lambda\kappa)$ has mean $\kappa\theta = 1/\lambda$ and variance $\kappa\theta^2 = 1/(\lambda^2\kappa)$. So:

$$C_a^{2} \;=\; \frac{\mathrm{Var}}{\mu^{2}} \;=\; \frac{1}{\texttt{burstiness}}$$

The flag is the queueing parameter, inverted. At $\rho = 0.8$ with the mixed workload above ($C_s^2 = 3.38$), the same offered rate gives $\mathbb{E}[W] = 7.26\,S$ at --burstiness 4, $8.76\,S$ at the Poisson default, and $14.76\,S$ at --burstiness 0.25. Two engineers benchmarking "at 10 req/s" can differ by 2× in mean queueing delay and 0% in throughput. SGLang has no equivalent knob — it samples exponential only, i.e. $C_a^2 = 1$, permanently:

python/sglang/benchmark/serving.py:L1083-L1092 SGLang
            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)

Open loop versus closed loop is not a methodology preference; it is a different experiment. Both harnesses implement an open-loop/semaphore hybrid, not a true closed loop, around the request coroutine:

vllm/benchmarks/serve.py:L961-L972 vLLM
    pbar = None if disable_tqdm else tqdm(total=len(input_requests))

    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

With --max-concurrency N and no rate limit, Little's Law runs backwards: you have fixed $\bar{L} = N$, so $\lambda = N/\bar{W}$ is an output. If the engine slows, arrivals slow with it. The queue never grows, TTFT never diverges, and you cannot observe overload — the experiment has no $\rho$ to exceed 1. Resident tokens are bounded by $N\bar{s}$, so preemption may never fire and that entire code path goes unmeasured. In open loop you fix $\lambda$, $\rho = \lambda/\mu$ is yours to choose, and at $\rho \to 1$ the waiting queue grows without bound — which is precisely when admission control, preemption, and starvation handling become load-bearing. The two modes execute different code. §10.3 owns which to run and how; the physics is that they measure different systems.

Axis 4 — prefix sharing: position matters more than volume

Both engines match prefixes and only prefixes: vLLM by chaining block hashes from token 0 (§2.3), SGLang by walking a radix tree from the root (§2.4). A 2,000-token preamble at the front of every request is worth $2000 \times 40.6\,\mu\text{s} = 81$ ms of prefill per request. The same 2,000 tokens appended after per-request content is worth exactly zero. And §2.3's alignment result makes this brittle in one direction: prepending a single token — a user id, a timestamp — changes $h_0$, and the chain makes every subsequent hash miss. You do not lose a block; you lose all of them.

Figure 3 — where the sharing sits decides whether it is worth anything. Derived, Llama-3-8B on H100 at 40% MFU. Shaded spans are KV that a second request can reuse.

Shared prefix versus shared suffix versus misaligned prefix Three cases. Case A: two requests share a 2000 token prefix, all of it reusable, saving 81 milliseconds. Case B: two requests share a 2000 token suffix after unique content, none reusable, saving zero. Case C: one request has a 12 token id prepended before the shared 2000 tokens, none reusable. A — shared PREFIX system prompt, 2,000 tok question 800 HIT — 2,000 tok reused saves 81 ms of prefill on request 2 B — shared SUFFIX doc A 1,000 instructions, 2,000 tok doc B 1,300 instructions, 2,000 tok MISS at token 0 — saves 0 ms C — misaligned prefix system prompt, 2,000 tok id same 2,000 tok, shifted by 12 MISS — chained hash breaks at block 0 Characterise sharing in absolute matched tokens, not as a fraction of the input. See §9.4 for why the distinction has teeth.

That last line is not decorative. SGLang's cache-aware router gates on match_rate = matched_char_count / input_char_count and falls back to load balancing below cache_threshold (§9.4). A ratio penalises exactly the workload where the absolute shared prefix is largest: long-context RAG, where a valuable 2,048-token preamble is diluted by an 8,000-token retrieved document to a match rate of 0.20 — under both defaults. Characterise your sharing in tokens matched per request and separately as a ratio, because the router only sees the ratio and the GPU only feels the tokens.

§5

What the shipped datasets actually produce

§10.2 covers the harnesses; what matters here is the distribution each loader emits, because that is the workload you are silently choosing. In vLLM the real code is vllm/benchmarks/datasets/datasets.py — 4,814 lines — not the deprecated root-level shim.

Dataset inventory at a556f3f / 7d89325. Registries: vllm/benchmarks/datasets/datasets.py:L1615-L1635 and python/sglang/benchmark/datasets/__init__.py:L18-L32.
NameEngineWhat it actually producesBias
sharegptbothFirst user turn / first assistant reply of each conversationMulti-turn structure discarded → zero prefix sharing. vLLM additionally truncates the tail (below)
randombothSynthetic lengths; vLLM emits arithmetic token ids, SGLang tiles a ShareGPT prompt to lengthLength variance is a flag, not a property; the two engines disagree on what the flag means
sonnetvLLM200-token fixed poem prefix + sampled lines, fixed 150-token outputPrefix sharing is 100% and output variance is exactly 0
prefix_repetitionvLLM10 random prefixes × N random suffixes, 256/256/128 by defaultIdeal cache-affinity workload; sharing rate is a dial, not a measurement
burstgptvLLMReal GPT-4 trace lengths, synthetic token ids, rows shuffledLength distribution real; arrival times and prefix structure destroyed
timed_tracevLLMJSONL trace with timestamps and hash_ids expanded into deterministic 16-token blocksThe only vLLM loader that reproduces all three of lengths, arrivals, and sharing
generated-shared-prefixSGLangGroups sharing a system prompt; uniform or Zipf group popularity, optional multi-turnSynthetic content, but the sharing structure is parameterised properly
mooncakeSGLangReplays the Mooncake FAST'25 traces by timestamp, multi-roundFour distinct workloads under one flag: mooncake, conversation, synthetic, toolagent
agentic-traceSGLangMulti-turn OpenHands-style traces; real assistant replies fed into the next roundThe one loader whose growing-context shape matches agentic serving
longbench_v2SGLangLong context + multiple choice, default output 10 tokensThe extreme long-in/short-out corner

The same flag, two different distributions

--random-range-ratio exists in both harnesses and means different things. vLLM samples symmetrically around the target length:

vllm/benchmarks/datasets/utils.py:L74-L99, L98-L101 vLLM
    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)
# ...
    input_lens = rng.integers(input_low, input_high + 1, size=num_requests)
    output_lens = rng.integers(output_low, output_high + 1, size=num_requests)

SGLang samples downward from the target, treating the ratio as a floor:

python/sglang/benchmark/datasets/common.py:L56-L64 SGLang
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()
Derived from the two loaders above, --random-input-len 1024. Both defaults are range_ratio = 0.0.
range_ratiovLLM meanvLLM $C^2$SGLang meanSGLang $C^2$
0.0 (default)1,0240.0005120.333
0.51,0240.0837680.037
1.0rejected1,0240.000

At the shipped default, the "same" synthetic workload has a 2× different mean input length and a variance that is zero in one engine and $C^2 = 1/3$ in the other. The ratio's meaning is inverted: 1.0 is maximum variance in vLLM and zero variance in SGLang, where vLLM rejects it outright (input_range_ratio must be in [0, 1)). Any cross-engine comparison run on random with a non-zero range ratio is comparing two workloads.

ShareGPT is not one dataset either

Both loaders keep only the first two turns — SGLang's comment says so in as many words at python/sglang/benchmark/datasets/sharegpt.py:L88, and vLLM indexes entry["conversations"][0] and [1] directly at vllm/benchmarks/datasets/datasets.py:L1393-L1396. That alone deletes the multi-turn prefix growth that makes real chat cacheable. But vLLM also prunes:

vllm/benchmarks/datasets/datasets.py:L341-L356 vLLM
def is_valid_sequence(
    prompt_len: int,
    output_len: int,
    min_len: int = 4,
    max_prompt_len: int = 1024,
    max_total_len: int = 2048,
    skip_min_output_len_check: bool = False,
) -> bool:
    """
    Validate a sequence based on prompt and output lengths.

    Default pruning criteria are copied from the original `sample_hf_requests`
    and `sample_sharegpt_requests` functions in benchmark_serving.py, as well as
    from `sample_requests` in benchmark_throughput.py.
    """

Every ShareGPT conversation with a prompt over 1,024 tokens, or a prompt-plus-completion over 2,048, is dropped. SGLang applies no such bound by default — --sharegpt-context-len defaults to None. vLLM's ShareGPT has had its tail amputated; SGLang's has not. The two harnesses, pointed at the same JSON file, produce different length distributions, and the difference is exactly the part of the distribution that dominates queueing.

Read the tree

SGLang's benchmark/ directory is itself workload evidence: benchmark/hicache/bench_multiturn.py (256 clients, 5 rounds, 512-token turns, Poisson or uniform intervals), bench_long_context.py, benchmark/prefill_only/. When a project ships a bespoke harness for a workload, that workload broke the general one.

§6

Worked trace: characterising your own traffic

You cannot tune against a dataset. Here is the procedure, using only what the engines already export.

Figure 4 — the characterisation pipeline. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…

vLLM. Every finished request carries a full timing and token breakdown:

vllm/v1/metrics/stats.py:L239-L255 vLLM
@dataclass
class FinishedRequestStats:
    """Stats associated with a finished request."""

    finish_reason: "FinishReason"
    request_id: str | None = None
    e2e_latency: float = 0.0
    num_prompt_tokens: int = 0
    num_generation_tokens: int = 0
    max_tokens_param: int | None = None
    queued_time: float = 0.0
    prefill_time: float = 0.0
    inference_time: float = 0.0
    decode_time: float = 0.0
    mean_time_per_output_token: float = 0.0
    is_corrupted: bool = False
    num_cached_tokens: int = 0

That single dataclass gives you axes 1, 2 and 4 directly, plus queued_time, which is Kingman's $W$ measured rather than predicted. PrefillStats splits the cache term further — local prefix cache versus external KV transfer versus tokens newly written to the cache (vllm/v1/metrics/stats.py:L258-L276) — which is what you need to tell "my workload shares prefixes" from "my workload re-reads its own recent prefixes".

Without touching the code, the Prometheus endpoint carries the distributions as histograms with 1–2–5 decade buckets up to max_model_len:

vllm/v1/metrics/loggers.py:L730-L747 vLLM
        histogram_num_prompt_tokens_request = self._histogram_cls(
            name="vllm:request_prompt_tokens",
            documentation="Number of prefill tokens processed.",
            buckets=build_1_2_5_buckets(max_model_len),
            labelnames=labelnames,
        )
        self.histogram_num_prompt_tokens_request = create_metric_per_engine(
            histogram_num_prompt_tokens_request, per_engine_labelvalues
        )

        histogram_num_generation_tokens_request = self._histogram_cls(
            name="vllm:request_generation_tokens",
            documentation="Number of generation tokens processed.",
            buckets=build_1_2_5_buckets(max_model_len),
            labelnames=labelnames,
        )

Twelve buckets is coarse — [1, 2, 5, 10, … 5000] at max_model_len = 8192 — so a p99 read off this histogram lands somewhere in [2000, 5000). Good enough to classify the workload, not good enough to size a KV pool. For that, log per-request.

SGLang. Start the server with --export-metrics-to-file plus the mandatory --export-metrics-to-file-dir, and FileRequestMetricsExporter writes one JSON record per request combining the request parameters with meta_info (python/sglang/srt/observability/request_metrics_exporter.py:L33-L62). The cache term lives there:

python/sglang/srt/managers/tokenizer_manager.py:L2238-L2245 SGLang
            if not isinstance(recv_obj, BatchEmbeddingOutput):
                meta_info.update(
                    {
                        "reasoning_tokens": recv_obj.reasoning_tokens[i],
                        "completion_tokens": recv_obj.completion_tokens[i],
                        "cached_tokens": recv_obj.cached_tokens[i],
                    }
                )

The six statistics. From that log, compute — and publish alongside every benchmark number you ever quote:

The minimum workload description. Percentiles, never means.
StatisticHowWhat it predicts
Input length p50 / p90 / p99quantiles of num_prompt_tokensPrefill cost, KV footprint, $B_{\max}$, head-of-line risk
Output length p50 / p90 / p99quantiles of num_generation_tokensDecode step count, KV residency time
Realised / max_tokensratio distributionHow pessimistic admission control has to be
Ratio at p50 and p99$S_{\text{in}}/S_{\text{out}}$ per request, then quantilesPrefill:decode split. Never the ratio of the means — Jensen
$C_a^2$ of arrivals$\mathrm{Var}(\Delta t)/\overline{\Delta t}^2$ over gapsQueueing inflation via Kingman; the --burstiness to set
Sharing rate$\sum_i \texttt{cached\_tokens}_i \,/\, \sum_i \texttt{prompt\_tokens}_i$, and the absolute p50 of matched tokensEffective prefill cost; whether cache-aware routing will engage

Note the cap: §2.3 established that a fully cached prompt reports a hit of at most $B\lfloor(S-1)/B\rfloor$ tokens, so a sharing rate of 1.0 is unreachable by construction. Do not chase the last percent.

§7

Pitfalls

SYMPTOM

Benchmarked on ShareGPT, deployed on RAG

vLLM's ShareGPT caps prompts at 1,024 tokens and totals at 2,048, so $\bar{s} \approx 1{,}500$ and $B_{\max} \approx 285$. Your RAG traffic runs $\bar{s} = 8{,}100$ and $B_{\max} = 52$. Decode intensity falls from ~68 to 14.8 FLOP/byte and output throughput falls by an order of magnitude. Nothing regressed; you measured a different workload.

SYMPTOM

Mean-only characterisation, p99 blows up in prod

Two distributions with identical 1,880-token means differ by 4.4× in mean queueing delay at $\rho=0.8$, and the top 5% of requests own 47% of prefill time. If your workload doc says "average prompt ~2k tokens" and stops there, it has told you nothing about the tail that sets your SLO.

SYMPTOM

Prefix sharing ignored, then production is fast

Run --dataset-name random and prefix sharing is zero by construction. Production shares a system prompt and does 3× better than your capacity model. You over-provisioned. Run sonnet or prefix_repetition instead and sharing is near-100% by construction, and you under-provision. Both are dials, not measurements.

SYMPTOM

Concurrency sweep silently changes scheduler policy

SGLang's _determine_active_policy downgrades lpm to fcfs above 128 queued requests (§1.4, python/sglang/srt/managers/schedule_policy.py:L290-L294). A sweep from 16 to 512 concurrency crosses that line mid-run and measures two different schedulers, with no log line. Pin it with --schedule-policy fcfs.

The compound trap

These interact. A concurrency sweep on --dataset-name random with default range ratio has zero length variance in vLLM and $C^2 = 1/3$ in SGLang, zero prefix sharing in both, and crosses SGLang's policy threshold somewhere around concurrency 130. Three independent confounds in a single command that looks like a controlled experiment.

§8

Hands-on

Isolate one axis at a time. Fix everything else, including the seed.

Arrival variability only — vLLM. Same rate, same dataset, same seed. shell
for B in 0.25 1.0 4.0; do
  vllm bench serve --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 --seed 0 \
    --request-rate 12 --burstiness $B \
    --percentile-metrics ttft,tpot,itl,e2el --metric-percentiles 50,99 \
    --result-filename burst-$B.json --save-result
done

Throughput should be flat across the three (you fixed $\lambda$); TTFT p99 should rise as burstiness falls, by roughly the $(C_a^2+C_s^2)/2$ factor with $C_a^2 = 1/B$. That is Kingman, visible, on one GPU.

Prefix-sharing structure only — vLLM prefix_repetition vs. random. shell
vllm bench serve --model meta-llama/Meta-Llama-3-8B-Instruct \
  --dataset-name prefix_repetition --prefix-repetition-prefix-len 2048 \
  --prefix-repetition-suffix-len 256 --prefix-repetition-num-prefixes 8 \
  --num-prompts 512 --request-rate 8 --seed 0
# then scrape the hit rate the engine itself reports:
curl -s localhost:8000/metrics | grep -E 'vllm:(prefix_cache|request_prompt_tokens)'
Sharing structure with popularity skew — SGLang. Zipf group distribution. Pin the policy on the server, not the client. shell
# server: pin the policy so a concurrency change cannot silently switch it,
# and dump one JSON record per request
python3 -m sglang.launch_server --model-path meta-llama/Meta-Llama-3-8B-Instruct \
  --schedule-policy fcfs \
  --export-metrics-to-file --export-metrics-to-file-dir ./reqlog

# client
python3 -m sglang.benchmark.serving --backend sglang \
  --dataset-name generated-shared-prefix --gsp-num-groups 64 \
  --gsp-prompts-per-group 32 --gsp-system-prompt-len 2048 --gsp-question-len 256 \
  --gsp-output-len 256 --gsp-group-distribution zipf --gsp-zipf-alpha 1.1 \
  --request-rate 8 --num-prompts 2048

Then re-run with --gsp-group-distribution uniform. Identical sharing rate, different sharing concentration: the Zipf run keeps its hot prefixes resident and the uniform run thrashes them out. This is the experiment that tells you whether your cache is big enough for your workload's popularity distribution rather than for its average.

§9

Exercises

  1. Using $c_p = 40.6\,\mu\text{s}$, $c_d = 4.96{\times}10^{-8}$ and $c_q = 6.63{\times}10^{-10}$, find the input length at which a 128-token-output workload spends 90% of engine time in prefill. Then say which of chunked prefill, speculative decoding, and KV quantization is worth tuning.
  2. Read vllm/benchmarks/datasets/utils.py:L64-L101 and python/sglang/benchmark/datasets/common.py:L56-L64. For --random-input-len 2048 --random-range-ratio 0.25, give the exact support, mean and $C^2$ of the input-length distribution each harness produces.
  3. Predict, then verify: you run a concurrency sweep on SGLang at 16, 64, 256, 1024 with --schedule-policy lpm on a workload with a shared 2,048-token prefix. Where in the sweep does the measured prefix-cache hit rate change discontinuously, and why? Find the responsible line with grep -n "_determine_active_policy" -A 5.
  4. A workload has $S_{\text{in}}$ = 500 at p50 and 24,000 at p99; $S_{\text{out}}$ = 400 at both. Compute the p50 and p99 prefill:decode ratios. Then compute the ratio using the means (assume the p99 tail is 1% of traffic and everything else is at p50). Explain the gap.
  5. Read vllm/benchmarks/datasets/datasets.py:L1502-L1533. Explain how timed_trace turns a list of hash_ids into token sequences that actually hit vLLM's prefix cache, and why timed_trace_chunk_hash_size defaulting to 16 is not a coincidence.
Answer — 1

Set $t_{\text{pre}} = 9\,t_{\text{dec}}$ with $S_{\text{out}} = 128$ and $\bar{s} = S_{\text{in}} + 64$. Ignoring $c_q$: $40.6{\times}10^{-6}S = 9 \cdot 128 \cdot 4.96{\times}10^{-8}(S+64)$, i.e. $40.6{\times}10^{-6}S = 5.71{\times}10^{-5}(S+64)$ — the linear coefficients are already close, so include $c_q$: solving $c_pS + c_qS^2 = 9c_d\cdot128\cdot(S+64)$ gives $S \approx 25{,}165$ tokens. Substitution gives $t_{\rm pre}\approx1.4416$ s and $t_{\rm dec}\approx0.1602$ s, hence a 90% prefill share. Above that, tune prefill: chunked prefill (to protect decoders from head-of-line blocking) and anything that raises prefill MFU. Speculative decoding is nearly worthless — it accelerates the 10%. KV quantization helps capacity but, per §2.5's crossover $N^{*} = 1.23{\times}10^5$ tokens, it also changes decode step bytes here since the pool holds 428,569 tokens — so it is a genuine latency lever too, unlike at batch 1.

Answer — 2

vLLM: input_low = floor(2048 × 0.75) = 1536, input_high = ceil(2048 × 1.25) = 2560, sampled with rng.integers(low, high+1) so the support is the integers $[1536, 2560]$ — minus the tokenizer's special-token count, which is subtracted from input_len first. Mean 2,048, $C^2 = r^2/3 = 0.0208$. SGLang: np.random.randint(max(int(2048×0.25),1), 2049), support $[512, 2048]$, mean 1,280, $C^2 = (1-r)^2/(3(1+r)^2) = 0.12$. Same flag, same value: a 60% difference in mean and a 5.8× difference in $C^2$.

Answer — 3

Between the 64 and 256 points, when the waiting queue first exceeds 128 — if self.policy == CacheAwarePolicy.LPM and len(waiting_queue) > 128 returns CacheAgnosticPolicy.FCFS (python/sglang/srt/managers/schedule_policy.py:L290-L294). LPM sorts the waiting queue by matched prefix length so requests sharing a prefix are admitted together; FCFS does not, so the shared prefix is more likely to be evicted between users of it. The hit rate drops with no log line, and the cliff looks like a capacity effect. Note the strict inequality: the downgrade fires at 129 queued requests, not 128.

Answer — 4

p50: $\bar{s} = 700$, ratio $= 820 \times 500/(400 \times 700) = 1.46$. p99: $\bar{s} = 24{,}200$, ratio $= 820 \times 24000/(400 \times 24200) = 2.03$ from the linear term — but at 24k the quadratic term is 28% of prefill, pushing the true ratio to about 2.8. Using means: $\bar{S}_{\text{in}} = 0.99(500) + 0.01(24000) = 735$, $\bar{s} = 935$, ratio $= 820 \times 735/(400 \times 935) = 1.61$. The mean-based ratio says "mildly prefill-heavy"; the p99 requests are twice that and each takes about 1.356 s of exclusive prefill under the stated constants. Do not apply Jensen in the wrong direction: $f(S)=S/(S+200)$ has $f''(S)=-400/(S+200)^3<0$, so $E[f(S)]\le f(E[S])$. For this mixture these are 0.7171 and 0.7861. The quadratic prefill term changes the full cost ratio and requires explicit averaging; a ratio of mean costs also weights requests differently from a mean of per-request ratios.

Answer — 5

_expand_prompt maps each hash id to a cache key f"{h}:{expanded_size}", derives a 32-bit seed from it, and generates a deterministic token block with random.Random(seed).choices(...), memoising in self._expanded_generated_prompts. Two trace entries sharing a hash-id prefix therefore produce byte-identical token prefixes, which is what vLLM's chained block hash needs to hit. Matching the configured block size simplifies interpretation. Other expansion sizes can still yield complete matching blocks: for a common prefix of $L$ tokens and block size $b$, up to $b\lfloor L/b\rfloor$ tokens are reusable under whole-block caching. A partial tail is not a total miss.

§10

Key takeaways

  • The prefill:decode split is $820 \cdot S_{\text{in}}/(S_{\text{out}}\,\bar{s})$ for Llama-3-8B on an H100, and it swings from 82% prefill to 2% prefill across ordinary product workloads. Every optimisation in this book is worth something on one side of that split and nothing on the other.
  • Input length controls decode performance, not just prefill: it sets KV footprint, which sets $B_{\max}$, which sets decode arithmetic intensity. Same 21.25 ms step, 14.8 FLOP/byte at 8k inputs versus 132 at 500. A "low MFU" decode measurement is usually a long-input workload.
  • --burstiness in vLLM is exactly $1/C_a^2$ — the Gamma shape parameter is the reciprocal of Kingman's arrival-variability term. SGLang has no equivalent and is pinned at Poisson. Two "10 req/s" benchmarks can differ 2× in queueing delay and 0% in throughput.
  • Closed loop (--max-concurrency) and open loop (--request-rate) are not two ways to measure the same thing. Closed loop cannot represent overload, bounds resident tokens at $N\bar{s}$, and may never exercise preemption at all.
  • The shipped datasets are dials, not observations. random has zero prefix sharing and a length variance that means opposite things in the two harnesses; sonnet has 100% sharing and zero output variance; vLLM's sharegpt has had everything above 1,024 prompt tokens deleted. Only timed_trace, mooncake and agentic-trace reproduce arrivals and sharing together.
  • Publish six numbers with every benchmark: input p50/p99, output p50/p99, $C_a^2$, and the absolute matched-token p50. Without them the number is not reproducible, and Part 10 is about numbers that are.
§11

Further reading

  • Mooncake: A KVCache-centric Disaggregated Architecture for LLM Serving (FAST'25). The source of the four traces SGLang replays via --dataset-name mooncake; the paper's workload analysis is the best published account of real arrival and sharing structure.
  • BurstGPT: A Real-world Workload Dataset to Optimize LLM Serving Systems. The trace behind vLLM's burstgpt loader — note the loader keeps the lengths and discards the timestamps.
  • LongBench v2, whose SGLang loader defaults to a 10-token output and is therefore the sharpest long-in/short-out probe either repo ships.
  • vLLM PR #10105 — "Add Gamma-Distribution Request Generation Support for Serving Benchmark", which is where --burstiness and therefore a controllable $C_a^2$ entered the harness; #26941 later added the inf (deterministic, $C_a^2 = 0$) case.
  • vLLM PR #39795 — timed trace replay, the only vLLM path that reproduces arrivals and prefix structure together; #20638 for PrefixRepetitionRandomDataset.
  • SGLang PR #29215 — the agentic-trace multi-turn dataset; #3211 for the hierarchical-cache serving benchmarks in benchmark/hicache/, and #19077 for the refactor that split the dataset loaders out of bench_serving.
  • John Kingman, The single server queue in heavy traffic (1961) — the diffusion approximation this chapter leans on. The modern statement is in Hopp & Spearman, Factory Physics, ch. 8.
  • §10.2 for what the harnesses do with these datasets, and §10.3 for warmup, saturation, and the reporting discipline that makes two runs comparable.

The Inference Engineering Course · code read at vllm@a556f3fccb and sglang@7d893255c3, pinned 2026-08-21. See SOURCES for the full provenance record.

Explore the library

Reading preferences

Appearance
18 px