Python · cell 1
Serving a model means running it for many users at once, on GPUs you rent by the hour. This section turns the ideas of the whole chapter into a working service and a bill. It shows how one program keeps a GPU busy with hundreds of requests, how many GPUs a product needs, how to split the model over them, and what each million tokens costs. A team must answer these questions before it buys hardware, and they come up in every ML systems interview.
Here is the concrete question the section answers. You must serve a 70B chat model to a product that peaks at 200 requests per second. Prompts average 2,000 tokens and answers 400. The product team wants the first token within a second and every later token within 50 ms. How many GPUs do you need, of which kind, split how, and what will a million tokens cost? And what should the fleet do at night, when traffic falls to a fifth of the peak?
We build the answer in three layers. First comes the engine, the program that runs one copy of the model and serves many requests with it. One copy of the model, on however many GPUs it needs, is called a replica. Then comes the fleet, many replicas behind a router that sends each request somewhere. Last comes the plan, a number of GPUs and a price. Along the way we meet the parts of serving that are neither arithmetic nor memory traffic. These are the CPU work around each step, output that must follow a format, many fine-tuned versions of one model, queues, and machines that take minutes to start.
Every number in this section comes from one model of time, built in §20.1. Here it is again in brief. A request goes through two phases. Prefill runs the whole prompt through the model in one pass and produces the first output token. Decode then produces one token per step. Each decode step reads the KV cache, the keys and values that every earlier token stored so that attention never recomputes them (§6.5). A server decodes many sequences together, and is the number of sequences in one decode step.
Two latencies matter to a user. Time to first token (TTFT) is the time from a request's arrival to its first output token, queueing and prefill included. Time per output token (TPOT) is the mean gap between the later tokens. The opening question asks for a TTFT of at most 1 s and a TPOT of at most 50 ms. Targets like these are called service-level objectives (SLOs).
A decode step does two jobs at once. It does arithmetic, and it reads memory. It lasts as long as the slower of the two. Take Llama 3.1 8B on one H100 GPU, stored in bfloat16, the 2-byte number format in which models are usually served. Reading its 16 GB of weights takes 4.8 ms. The arithmetic for one token, 16 billion floating-point operations (FLOPs), takes 16 µs. So at small batches the step is set by reading, and each extra sequence in the step is almost free. For one replica on GPUs the general model is
Here is a GPU's peak arithmetic rate in FLOP/s and the bandwidth of its main memory, called HBM (high-bandwidth memory), in bytes per second. Other chapters use for a policy and for a KL coefficient, but here both are hardware rates. Their ratio is the ridge point of the roofline, the model that bounds a kernel's time by its arithmetic or by its memory traffic (§11.2). For an H100 in bfloat16, TFLOP/s, TB/s, and FLOPs per byte.
The model being served has parameters, of which multiply each token ( for a dense model). It has layers and attention width . Its weights take bytes at bytes per weight, and its cache takes bytes per token. The sequences have mean context . The last two terms are the time the GPUs spend talking to each other and the fixed overhead of a step.
In plain words, a step lasts as long as the arithmetic of every sequence, or as long as reading the weights once plus every cache once, whichever is longer. Throughput is tokens per second, and every user gets one token per step.
The middle sections attacked the terms of that formula. §20.2 and §20.3 shrank the bytes per number. §20.4 shrank the number of weights. §20.5 made each weight read yield several tokens. §20.6 packed more sequences into each step, with continuous batching, a paged cache, prefix caching, chunked prefill, and separate prefill and decode GPUs. Each of these is recalled in a sentence where we use it.
From the outside an engine is a web endpoint. It accepts a chat request and streams tokens back. Inside it is one loop that runs as fast as the GPU allows. Each pass of the loop is a step. A step decides which requests get how many tokens of work, and runs one forward pass over all of those tokens at once. It then picks one new token for every request that needs one, and cleans up. Everything an engine does well, it does by keeping that loop full and never letting the GPU wait.
In plain words, each step picks the work, does it in one forward pass, picks the tokens, and tidies up. The engine is the scheduler's decisions plus the machinery that carries them out fast.
The scheduler's decision is only a list of numbers , one per request. That list can express every mechanism of the last two sections.
vLLM's 2025 rewrite of its engine, called V1, made exactly this its scheduling interface. Each step's plan is a dictionary from request to number of tokens, with no separate prefill steps and decode steps (vLLM Team 2025). The usual policy is the stall-free schedule of §20.6 (Agrawal et al. 2024). It gives every running decode its token first and fills the rest of the budget with prompt chunks in arrival order.
The cell writes this step as code. A step is a list of (new_tokens, cached_tokens) pairs, one per scheduled request. Its time is the step-time model with the work and bytes of every pair added up. Each new token of a chunk attends to the cache plus, on average, half the chunk before it.
The next two cells turn the step into a whole engine. serve admits waiting requests in arrival order while there is a free sequence slot and room in the cache for the whole prompt and answer. It schedules decodes first and prompt chunks after them. It charges each step its GPU time plus a CPU time, and records when every token is streamed. The CPU time stands for scheduling, building inputs, sampling, and turning tokens into text. We model it as 1 ms plus 20 µs per scheduled request, a plausible figure that you should measure for your own engine. The trace is 120 requests arriving at random, 40 per second on average.
The two runs differ in one line. If the CPU does its work after the GPU finishes, a step takes . If the scheduler prepares step while the GPU runs step , a step takes . Overlapping cuts the median TPOT from 10.3 to 7.9 ms. It also halves the median TTFT, from 86 to 44 ms, because the same work fits into fewer seconds and the queue drains faster. When a step lasts about 5 ms, a millisecond or two of CPU work is a large fraction of it.
This is a real problem in production. The vLLM team wrote that the CPU work of "running the API server, scheduling work, preparing inputs, de-tokenizing outputs, and streaming responses" becomes "increasingly pronounced" for "smaller models like Llama-8B running on NVIDIA H100 GPUs", where a step takes about 5 ms on the GPU. Its V1 engine, rebuilt largely to cut that overhead, reported up to 1.7 times the throughput of the old one (vLLM Team 2025). The SGLang team wrote that an unoptimized engine "can spend as much as half of its time on CPU overhead" (SGLang Team 2024). Engines use five fixes.
In an interview, "where does the time go in a decode step of a small model?" has those two answers. The CPU is the one candidates forget.
Most open serving runs on one of three engines. They share the loop above and differ in emphasis.
vLLM began as the PagedAttention paper, which stored each request's cache in fixed-size blocks like pages of virtual memory (Kwon et al. 2023). Its V1 engine is built around the token-count scheduler. It keeps a pool of 16-token cache blocks in a free queue. It finds shared prefixes by hashing blocks, which it measured as cheap enough to turn on by default, with "less than 1% decrease in throughput even when the cache hit rate is 0%". When memory runs out it evicts a request and later recomputes its cache (vLLM Team 2025; Gordić 2025). It supports the widest range of models and hardware.
SGLang began as a system for programs that call a model many times (Zheng et al. 2024). It keeps every request's cache in a radix tree, a tree of token sequences in which shared openings are stored once, and calls this RadixAttention. It evicts the least recently used (LRU) leaves first and orders requests to maximize reuse (§20.6). It introduced compressed automata that speed up structured output (described below) and the overlap scheduler. Its data-parallel attention was built to serve DeepSeek's latent-attention models at scale.
TensorRT-LLM is NVIDIA's engine. It has in-flight batching, its name for continuous batching, where requests join and leave the batch at every step. It also has a paged cache with block reuse, speculative decoding, and kernels tuned for FP8 and FP4 on NVIDIA's own GPUs. It now runs on a PyTorch-based architecture. The original backend, which compiled each model into a TensorRT engine, has been removed (NVIDIA).
| vLLM | SGLang | TensorRT-LLM | |
|---|---|---|---|
| KV memory | paged blocks of 16 tokens, free-block queue | paged, with a radix tree over token prefixes | paged blocks with reuse |
| prefix reuse | chained block hashes, LRU | radix tree, LRU over leaves, cache-aware scheduling | block reuse |
| scheduler | tokens per request per step, decodes first | overlap scheduler one step ahead | in-flight batching, overlap scheduler |
| structured output | pluggable backends (XGrammar and others) | XGrammar and other backends, and the jump-forward decoding it introduced | guided decoding with grammar backends |
| strongest at | breadth of models and hardware, community | programs with shared prefixes, structured output, large mixture-of-experts models | peak speed on NVIDIA GPUs, low-precision kernels |
The table is a snapshot of late 2026. All three move fast and borrow each other's best ideas within months. Choose by measuring your own traffic, which is what the rest of this section teaches you to model.
Above the engines sits an orchestration layer that treats many replicas as one service. NVIDIA Dynamo adds a planner that adjusts GPU resources to keep meeting the latency targets, and a router that knows which replica holds which cache blocks. It also adds the NIXL library, which moves caches between GPUs, and a manager that spills caches to cheaper memory. It runs SGLang, TensorRT-LLM, or vLLM underneath. llm-d is a similar open stack built on Kubernetes, the standard system for running containers on a cluster, and it is now at version 1.0. It routes by prefix cache, separates prefill from decode, spreads experts over many GPUs, and autoscales, running vLLM or SGLang.
A model that fits on one GPU is served by copying it, one replica per GPU, behind a router. This is data parallelism for inference, and it needs no communication at all. A model that does not fit must be split. Tensor parallelism splits every weight matrix over GPUs, so each GPU holds and reads of the weights (§10.4). The price is two all-reduces per layer. An all-reduce is a collective operation that sums a vector across all the GPUs and gives every GPU the result.
The reading time shrinks like , while the latency of each all-reduce grows with . So adding GPUs lowers the latency of one token only up to about
GPUs, where is the latency of one message between GPUs (§20.1). For Llama 3.1 70B in bfloat16 on H100s, with 2 µs per message, . That answers "how fast can one user go". The operator asks a different question. Which layout gives the most tokens per GPU while every user still gets a token every 50 ms?
A decode replica serving real traffic usually runs out of memory before its steps get too slow for the TPOT target. Each GPU then holds its share of the weights and fills the rest of its memory with caches. A short calculation shows what that buys.
A memory-bound step reads the weights once and every cache once. That is bytes, spread over GPUs at each, so . Then
which is the second formula. ∎
In plain words, a GPU full of cache takes as long to decode one step as it takes to read its own memory once. For an H100 at 90% of 80 GB that is about 21.5 ms, whatever the model. Its throughput is the most that cache reading allows, , times the share of memory that holds caches rather than weights. Spreading the weights over more GPUs raises that share, and the all-reduces take some of it back.
The cell adds the all-reduces of §20.1, with 2 µs per ring step and 450 GB/s links (illustrative values), and the 50 ms target. It finds the best layout on H100s and on B200s, NVIDIA's next generation, with 180 GB of HBM and 8 TB/s each.
On H100s the cheapest layout is TP4, at about 2,890 tokens per second per GPU. TP8 gives the lowest latency at batch 1 (7.2 ms per token) but 9% less throughput per GPU than TP4, because its all-reduces take back more than its extra cache room gives. On B200s TP2 is best. The 50 ms target never binds, because a full GPU takes only 20 to 33 ms per step. For this workload memory, not latency, sizes every replica.
An interviewer who asks "TP8 or two TP4 replicas?" wants exactly this trade.
Two other layouts appear in serving. Pipeline parallelism puts consecutive layers on different GPUs (§10.5). It splits the memory but not the latency of a token, since the token still passes through every stage in turn. Engines use it mainly to span machines when a model does not fit in one. Llama 3.1 405B in bfloat16, for example, needs 810 GB against the 640 GB of an eight-H100 node. A mixture-of-experts model replaces each feed-forward layer by many expert networks, of which each token uses a few. Expert parallelism spreads those experts over many GPUs (§13.4). Running prefill and decode on separate GPUs lets each phase choose its own layout.
Many applications parse the model's answer, as JSON or a function call, and they need a guarantee that it parses. The mechanism is a mask. At each step the engine works out which tokens could still lead to valid output. It sets the logit of every other token to , where the logit is the score the model gives a token before the softmax. A token with logit has probability exactly zero, so it is never sampled.
The format is written as an automaton, a machine that reads the output one character at a time and keeps a state summarizing what it has read. A token is allowed in state if its characters lead from to a state from which the format can still be completed. The allowed set holds those tokens. It also holds the end-of-sequence token when is an accepting state, one in which the output may stop.
The cell checks the four sets by running every token through the automaton. It also packs each set into one 32-bit integer, with bit set when token is allowed, which is how engines send masks to the GPU.
A real format is larger, and its allowed sets must be ready at every step. When a format is described by a regular expression, its automaton has finitely many states and is called a DFA (deterministic finite automaton). Then can be computed for every state once, before serving starts. That precomputed table is the index of the Outlines library (Willard and Louf 2023).
JSON can nest to any depth, so a JSON grammar needs a pushdown automaton, a state machine with a stack that remembers the open brackets. No finite table covers every possible stack. XGrammar's answer is to split the vocabulary in two (Dong et al. 2024). Most tokens can be judged from the current position in the grammar alone, and their verdicts are precomputed. The rest depend on the whole stack and are checked at serving time. For Llama 3.1's 128,256 tokens and a JSON grammar, only 1,134 tokens fall in the second group. A further step cuts them to 120 by rejecting, ahead of time, the tokens that no rule enclosing the current one could ever accept next.
§24.2 builds the index in full, proves that the masked decoder never gets stuck and only emits valid strings, and covers schemas and tool calls. It writes and for what we write and . Here we look at what the engine does with the mask.
For Llama 3.1's 128,256 tokens a row is 4,008 words, about 16 KB. Even a batch of 256 constrained requests sends only 4 MB to the GPU per step. The arithmetic is not the problem. The problem is producing the masks in time. vLLM (Gordić 2025) and XGrammar arrange the work in four steps.
The CPU must finish all of a step's masks within the GPU's step. XGrammar reports under 40 µs per token for JSON schemas. So 64 constrained requests need about 2.6 ms of CPU per step on one core, against a decode step of 5 to 30 ms, which is why engines fill masks on several threads. Speculative decoding complicates step 2. Each drafted position needs its own mask, computed by advancing a copy of the automaton along the draft and discarding the copy on rejection.
Two interview questions follow from this pipeline. Why must top-p come after the mask? Because top-p applied first can keep only tokens the mask then removes, leaving nothing to sample. Why is the mask cheap on the GPU but not on the CPU? Because applying it is one pass over the logits, while computing it walks every constrained request's automaton.
Implement two functions.
pack_bitmask(allowed, V) takes a list of collections of allowed token ids and a vocabulary size V. It returns the bitmask of the box as an np.int32 array.apply_bitmask(logits, bitmask) takes a float array of shape (B, V) and a bitmask. It returns a new array of the same shape and dtype in which every disallowed logit is -np.inf and every allowed one is unchanged. It must use no Python loop over rows or tokens, since engines run it on every step for the whole batch. It must ignore the bits beyond V in the last word.Formats are full of text the model has no choice about. In the JSON object {"name": "..."}, every character before the name is fixed. A masked decoder still spends one forward pass per token of such forced text, and each pass produces a distribution with only one allowed continuation.
SGLang compresses those runs (Zheng et al. 2024). Call a state of the character automaton forced if it is not accepting and has exactly one outgoing character. Start from any state and follow forced states, collecting their characters. The result is the forced string of that state, the text that every valid continuation must start with. The engine appends the forced string's tokens to the sequence in one go and runs them through the model as a small prefill chunk. The chunk's last position gives the logits for the next real choice.
A run of forced tokens then costs one forward pass instead of . While decode is memory-bound, a pass over tokens costs about the same as a pass over one (§20.1). The SGLang paper measured 1.6 times the throughput on a JSON decoding benchmark from this alone. It also found that rebuilding the compressed automaton for every request, instead of once for a batch of requests that share it, made throughput 2.4 times lower.
There is one catch, and it comes from the tokenizer (§2.6). The forced text must be turned into tokens, and cutting it at the boundary with the text already generated can produce a split the model never saw in training. Suppose the model has written " and the forced text is }. The usual tokenization of the whole may be the single token "}. So SGLang retokenizes the previous text together with the forced string. The tokens that change at the boundary are replaced, which means their cache entries are dropped and recomputed in the same chunk.
Jump-forward therefore emits exactly the characters that masked decoding would have been forced to emit, in fewer passes, but not always the same tokens. Masked decoding lets the model spell forced text with any allowed tokens. Retokenization spells it the usual way, which is usually closer to what the model saw in training. Neither one samples the model's own distribution over valid strings, as the next part shows.
The setup describes a character automaton as a dict with three keys. "start" is the start state, "accept" is the set of accepting states, and "delta" maps every state to a dict from characters to next states. Implement two functions.
forced_string(dfa, q) returns (s, q_end), the forced string of state q and the state it ends in. Follow edges while the current state is not accepting and has exactly one outgoing character. Assume every state can reach an accepting state.jump_forward(tokens, forced, vocab) takes the ids generated so far, the forced string, and vocab, which maps ids to strings. Let text be the concatenation of the tokens' strings, and let new = tokenize(text + forced, vocab) with the setup's greedy tokenizer. Return (keep, append). Here keep is the length of the longest common prefix of tokens and new, the tokens whose cache entries survive, and append = new[keep:].A mask guarantees that the output is valid. It does not guarantee that valid outputs come with the probabilities the model gives them. Masking each step and renormalizing samples every token from the model's next-token distribution restricted to . The product of those per-step choices is not, in general, the model's distribution conditioned on producing a valid string. The mask at each step cannot see whether the model was about to wander out of the format a few tokens later.
The cell computes both distributions exactly and checks the masked one by sampling.
§24.2 proves this distortion in general and implements grammar-aligned sampling (Park et al. 2024), which removes it at the cost of extra samples. For a serving team the distortion has two practical consequences. First, a format can lower answer quality. One study found that format restrictions lowered accuracy on reasoning tasks (Tam et al. 2024). A rerun with consistent prompts found no loss (Kurt 2024), so measure it on your own task and prompt. Second, the order of fields in a schema matters. A model forced to write its answer field before its reasoning field has no room to reason first.
Suppose a thousand customers each fine-tuned the same base model with LoRA. LoRA leaves the base weights frozen and learns a small adapter for each weight matrix, a product of two thin matrices (§14.5). Adding the adapter into the base weights, called merging, gives a model with no added latency. But a thousand merged copies of a 70B model in bfloat16 is 141 TB of weights. The alternative is to keep one base in memory with the small adapters beside it, and to batch requests for different adapters together through the shared base.
Here is the adapter in symbols. This chapter writes a weight as , so a row of inputs multiplies it from the left. An adapter of rank is the update , with and . The scale is for a hyperparameter . §14.5 writes weights the other way round, as matrices that multiply a column from the right. Its and are the transposes of ours, so its update is the transpose of our . In this part is the LoRA scale, not the draft length of speculative decoding, and the bold is a matrix, not the batch size .
The two products are kept in the order , so the update is never formed. Punica introduced the segmented kernel. It reported 12 times the throughput of the serving systems of its day on multi-LoRA traffic, "while only adding 2ms latency per token" (Chen et al. 2023).
S-LoRA noticed that adapters behave like caches (Sheng et al. 2024). Both vary in size and come and go with requests. So it stores both in one pool of pages, each page one hidden vector wide. A rank- adapter then occupies pages, and a sequence of tokens occupies pages. S-LoRA keeps all adapters in CPU memory and loads each one when its requests are scheduled, and it added kernels for adapters of different ranks in one batch. It served 2,000 adapters at once with little overhead. Its throughput was up to 4 times that of vLLM with naive LoRA support and up to 30 times that of the Hugging Face PEFT library.
What does an adapter cost the step? Count it the way §20.1 counted everything else, in FLOPs and in bytes read.
Computing costs FLOPs, and multiplying the result by costs . Dividing by gives item 1. Each adapter holds numbers per adapted weight. It is read once per step if any token uses it, however many tokens do. That gives item 2. Item 3 divides the memory-bound step time by the step time without adapters. ∎
The cell puts real numbers in. It takes a rank-16 adapter on all seven linear layers of every block of Llama 3.1 8B (the query, key, value, and output projections, and the three feed-forward matrices), and a decode step of 64 requests at 2,048 tokens of context.
The adapter is 84 MB and adds 0.6% to the arithmetic, so the FLOPs never matter. The bytes can. A batch of 64 requests that all use one adapter decodes in 9.95 ms instead of 9.92. If they use 64 different adapters, the step reads 5.4 GB more and takes 11.52 ms. That is 16% longer for everyone in the step, including requests that use no adapter at all.
This is why engines cap the number of distinct adapters per step (vLLM's max_loras), and why routers send an adapter's traffic to the replicas that already hold it. It is also why one tenant with a rare adapter slows its neighbors. The interview question "is LoRA serving free?" has the answer "free in FLOPs, not in bytes".
Implement multi_lora_forward(X, W, A, B, scale, adapter_idx), the layer of the definition box.
X have shape (n, d_in), and the base weight W has shape (d_in, d_out).A of shape (n_adapters, d_in, r) and B of shape (n_adapters, r, d_out), with one scale per adapter in scale of shape (n_adapters,).adapter_idx has length n, and -1 means the token uses no adapter.Return the (n, d_out) output, without a Python loop over tokens and without forming any d_in × d_out update.
A replica that is 95% busy sounds efficient. Its users disagree. Requests arrive at random, so sometimes several arrive close together. A server that is almost always busy has almost no spare capacity to absorb such a burst, so each burst leaves a queue that drains only slowly. Queueing theory turns this into formulas, and the simplest one already explains why fleets are run with headroom.
Start with numbers. A replica can serve 10 requests per second, so each takes 0.1 s on average, and 8 arrive per second. It is busy 80% of the time. The formulas below say that a request then waits 0.4 s on average before service starts, four times its own service time. At 9.5 arrivals per second the wait is 1.9 s, nineteen service times. The last few percent of load cost more delay than all the rest.
The model behind those numbers is the M/M/1 queue. The name lists the arrival process, the service times, and the number of servers, and M stands for memoryless.
Write for the mean time a request waits before service starts and for its mean time in the system, waiting plus service. In a serving system, the wait is the queueing part of TTFT.
The number in the system moves up by one at rate and, when it is positive, down by one at rate . In steady state the probability flow between and must balance, , so . These probabilities sum to 1 exactly when , which gives . The mean is .
Little's law says that the mean number in a system equals the arrival rate times the mean time spent inside, for any arrival pattern and any order of service (§20.1). It gives . Then .
For the distribution of the time in the system, note first that Poisson arrivals see the system in its stationary distribution. This is the PASTA property, "Poisson arrivals see time averages". So an arriving request finds others with probability . Under first-come first-served it leaves after the remaining service of the request being served, the services of the waiting, and its own. By memorylessness the remaining service is again exponential with rate . So the time in the system is a sum of independent exponentials of rate , where for . Its moment generating function, for , is
which is the generating function of an exponential with rate . So , and setting it to gives the quantile. ∎
In plain words, the mean wait is the service time multiplied by , a factor that explodes as the load nears 1.
A prefill's time is set by its prompt length, so prefill service times vary only as much as prompt lengths do, often far less than an exponential. The M/G/1 queue allows any distribution of service times (G for general), and one formula covers it.
In plain words, the wait grows with the second moment of the service time, so variable service makes queues longer even at the same mean. Making every service the same length halves the wait.
An arriving request waits for the remaining service of whoever is being served, plus the full services of the requests already queued. By PASTA it finds on average requests queued (Little's law applied to the queue alone), each needing , so they contribute .
It finds the server busy with probability . A request that arrives at a random moment during busy time lands in a service interval with probability proportional to that interval's length, so the mean remaining service is . That contributes . So , and solving for gives the formula. ∎
The cell checks both formulas by simulation. A first-come first-served queue obeys Lindley's recursion. The wait of request is , where is the gap between arrivals and . In words, a request waits for the work the previous request left behind, minus the time since that request arrived, and never less than zero. Unrolling the recursion gives with and , a running minimum that NumPy computes without a loop. The last line of the cell compares eight replicas with a queue each against eight replicas sharing one queue.
Simulation and theory agree to within a few percent, the error of a finite run near saturation. The last line makes the case for routing well. Eight replicas, each with its own queue at 80% load, make a request wait four service times on average. One shared queue feeding the same eight replicas cuts the wait to 0.29 service times, 14 times less. A request then never waits behind a busy replica while another sits idle. A router that sends each request to the least loaded replica comes close to the shared queue. A router that assigns requests at random gives up the whole gain. Poisson arrivals split at random give each replica Poisson arrivals of its own, which is exactly the case of one queue per replica.
LLM servers are not M/M/1 queues. A decode replica serves a whole batch at once and slows every user a little as the batch grows (§20.1), and requests queue only when its memory is full. The lesson still carries over. Delay is set by the slack , not by the load, and the safe operating point is well below full. Practitioners often run fleets at 60 to 80% of the throughput at which the latency targets still hold. The formulas above say how to pick the number from a TTFT target, and an interviewer who asks "why not run at 95%?" wants the factor .
Implement three functions.
mm1(lam, mu) returns a dict with "rho", "L" (mean number in the system), "W" (mean time in the system), "Wq" (mean wait before service), and "W_p99" (99th percentile of the time in the system). Every entry except "rho" is np.inf when lam >= mu.mg1_wq(lam, ES, ES2) returns the Pollaczek-Khinchine mean wait for service times with mean ES and second moment ES2, or np.inf at or beyond saturation.max_rate(mu, w_target, q=0.99) returns the largest arrival rate at which the q-quantile of the M/M/1 time in the system is at most w_target. It returns 0 if even an empty server is too slow.A fleet is many replicas behind a router, which picks a replica for each request, and an autoscaler, which decides how many replicas there should be. Each makes a decision that the step-time model cannot make for it.
A router balances three goals that pull apart.
Affinity concentrates load, and a popular system prompt can overload the one replica that holds it. So routers follow affinity only while the target's load stays within some margin of the least loaded replica, and they copy hot prefixes to more replicas. When imbalance appears anyway, a request can be moved. Llumnix migrates running requests together with their caches between replicas, like an operating system moving a process between cores. It reported tail latencies an order of magnitude lower, and up to 36% cost savings at similar tail latency (Sun et al. 2024).
Traffic follows the clock. DeepSeek's service peaked at 278 nodes and averaged 226.75 over its published day, a peak-to-mean ratio of 1.23. It moved nodes from inference to research and training at night (DeepSeek-AI 2025). An autoscaler adds replicas when load rises and removes them when it falls. Two choices decide whether it works.
The signal. The GPU utilization that nvidia-smi reports is the "percent of time over the past sample period during which one or more kernels was executing on the GPU" (NVIDIA). A decode replica serving a single user runs kernels almost all the time. It reads close to 100% at batch 1 and at batch 300 alike, so an autoscaler that targets 70% of it scales on noise. Other signals track how close a replica is to its latency targets. They are the number of waiting requests, the fraction of cache blocks in use, the percentiles of TTFT and of the gaps between tokens, and the tokens in flight against the capacity the plan computes.
The delay. A new replica must get a machine, pull the engine image, load the weights, start the engine, and record its CUDA graphs. This is its cold start. Loading 70.6 GB of FP8 weights from network storage at an assumed 2 GB/s takes 35 s alone, and provisioning adds minutes when no warm machine is free. Small fixes add up. Shipping precompiled Python bytecode in vLLM's container image saved about 18 s per startup in one H100 benchmark (Matteson 2026). So a scale-up decided now helps only one cold start later, while a scale-down takes effect at once.
That asymmetry has an exact consequence. Count time in steps, and say a replica requested at one step is ready steps later. Then a fleet that reacts to the current load is always steps behind on a rising curve.
A replica is ready at exactly if it was requested at or before and not removed since. Picture the replicas as a stack in the order they were requested. A higher target pushes new requests on top, and a lower one removes from the top. So the replicas present at every step of are the bottom of the stack, and all of them were requested by step . Any replica requested later is not ready yet. That is the first formula.
Now take . Every in has , so , and the minimum over is at least . Conversely, any target with for every must have for every . That is, whenever , so is at least the maximum.
For the reactive target, if for some , then . ∎
In plain words, to be ready for the morning you must start the machines one cold start before the morning. That means scaling on a forecast, at least steps ahead, rather than on the current load. Daily traffic is regular enough to forecast. The price is idle capacity on the way down and whenever the forecast is wrong.
The widget simulates one day of traffic in 5-minute steps. The gray line is the demand, and the staircase is the capacity of the replicas that are ready. Pink shading marks the times when demand exceeds that capacity. Demand peaks at 200 requests per second, falls to a fifth of that at night, and carries 5% noise. The controls set the policy, the cold start, and the target utilization, and Play runs the day from midnight.
Implement autoscale(load, per_replica_rps, cold_start, target_util=1.0, policy="reactive", min_replicas=1). The array load holds one request rate per step, and cold_start is the number of steps a new replica takes to become ready.
"reactive" policy targets . The "predictive" policy targets , with the window cut at the end of the array.Return (ready, short). The first is the integer array of ready replicas per step, from the theorem. The second is the array of steps at which load[t] > ready[t] * per_replica_rps.
We can now answer the opening question. A capacity plan sizes the prefill work and the decode work separately, adds headroom to each, and prices the result. Prefill is compute-bound, so its pool is sized by FLOPs. Decode is memory-bound, so its pool is sized by how many sequences a replica can hold. Here is the plan in general.
In plain words, prompts cost FLOPs and are sized by arithmetic. Answers cost memory and bandwidth and are sized by the batch a replica can hold. Both get slack so that queues stay short. The largest batch in step 2 is the smaller of two bounds from §20.1. One is the number of sequences whose caches fit in the free memory, and the other is the largest batch whose step still meets the TPOT target. Little's law gives the same decode pool from the other side. The pool holds sequences at once, and that must fit in the replicas' memory at the target utilization.
The cell plans the opening question on H100s. Its assumptions are FP8 weights and arithmetic, a bfloat16 cache, and TP4 instances for both pools (the best decode layout found above). Prefill runs at 50% MFU, and both pools target . The cell checks TTFT by simulating each prefill instance's queue with wait_times, with requests split at random among the instances. It prices the fleet at an assumed $2 per GPU-hour. It then follows the load through a day that peaks at 200 requests per second at 2 p.m. and bottoms out at 40 around 2 a.m., and repeats the plan on B200s.
Prefill dominates this plan, with 76 of its 112 GPUs. There are five times as many prompt tokens as output tokens, while a prompt token is only about 2.4 times cheaper to process than an output token here (about 6,900 against 2,900 tokens per second per GPU). A prefix cache that served the first half of each prompt would cut prefill FLOPs nearly in half and free 36 GPUs (9 of the 19 instances), which is why system prompts and conversation histories are worth caching. Every number here is a roofline bound with stated efficiencies. Real kernels, real distributions of prompt lengths, and engine overheads must be measured before the order is placed.
Two refinements are worth knowing. First, separating the pools, as this plan does, lets each be sized and laid out on its own, but it costs a cache transfer, 13 ms here. It also wastes the idle arithmetic of memory-bound decode steps. A shared pool with chunked prefill fills that idle arithmetic with prompt work for free, up to the limit of §20.6. Second, headroom for failures matters more than it looks. Losing one of the 9 decode replicas at peak moves the other eight from 77% to 86% of their throughput.
This is the system design question of inference interviews, and the interviewer wants the structure more than the digits. Size prefill by FLOPs and decode by memory, put headroom on both, then check TTFT and state a price.
The planner below runs the same arithmetic at any request rate, for Llama 3.1 8B on single H100s or the 70B model on groups of four. The other assumptions are the example's, from the request shape and the TPOT target to $2 per GPU-hour. It compares the fleet's cost per thousand requests with an API that charges one price per million tokens, input and output alike. The default API prices, $0.10 and $0.60 per million, are inputs to play with, not market data. Lower the request rate and watch the fleet's cost per request climb once even the smallest fleet sits partly idle.
Implement capacity_plan(traffic, model, hw, slo, kappa) following the box. The arguments are dicts.
traffic has "rate", "T_in", and "T_out".model has "n_params", "layers", "heads", "head_dim", and "kv_heads".hw has "flops", "bw", "hbm" (per GPU), "util" (the usable fraction of HBM), "tp", "w_bytes", "kv_bytes", and "mfu" (of prefill).slo has "tpot" and "rho_max".Model the decode step on one replica of = hw["tp"] GPUs, with both terms of the step-time model divided by , no communication, and no overhead. Assume is at most kv_heads. The attention width is = heads × head_dim, and the cache holds bytes per token, with = kv_heads, = head_dim, and = kv_bytes. With , define the following.
Return a dict with "prefill_gpus", "batch", "binding" ("memory" if , else "tpot"), "decode_replicas", "decode_gpus", "total_gpus", "usd_in", and "usd_out". Raise ValueError if no batch of at least 1 is feasible.
A plan is only as good as its check against reality. DeepSeek published a day of its production service (§20.1), and the same arithmetic can be run backward on it. DeepSeek served on H800 GPUs, the export version of the H100, in nodes of eight.
The plan gives a cost per token when the fleet is fully used. You pay for the fleet whether or not it is used, so what matters is how much of it you use.
Proof. Over any period the fleet costs the same as at full use while serving the fraction of the tokens, so the cost per token is multiplied by . Comparing with gives the threshold. ∎
The threshold makes the usual advice exact. A fleet sized for the peak and never scaled down runs at its mean-to-peak ratio times its headroom, which for our day is of its capacity. A small product at a few requests per second may keep a minimum fleet of 8 GPUs mostly idle.
The engineers who run the fleet, the spare capacity, and the evaluation of every model update come on top. That is why many teams self-host only their highest-volume models. In an interview, "self-host or API?" is answered by this utilization and by requirements the price ignores, such as where the data may live.
Nondeterminism. The same request at temperature 0 can give different answers on different runs. GPU kernels choose their order of summation by batch size, and floating-point addition is not associative, so a request's logits depend on what else shares its step (§6.4). Batch-invariant kernels fix the order of summation whatever the batch, at some cost in speed (He 2025). Reinforcement learning now drives that work, because the engine that generates rollouts and the trainer must agree on every log-probability. A 2026 recipe in vLLM made the two bitwise identical for DeepSeek-V4-Flash at 91% of the optimized rollout throughput, and carried the method to a second model (Bai and Shen 2026).
CPU work that grows with the output. Detokenizing, checking stop strings, and parsing reasoning and tool calls run per token on the CPU. In vLLM's frontend with no GPU, streaming a parsed reasoning output replays the token history through a fresh parser for each chunk. On outputs of several thousand tokens that measured about 9 times the CPU of serving in one process (Hickey 2026).
Grammars that are expensive to compile or to mask. The first request with a new schema waits for compilation. Grammars whose tokens mostly depend on the stack defeat the precomputed masks.
Tenants that slow each other. Distinct LoRA adapters in a step, a prefix that every router sends to one replica, and one customer's 100,000-token prompts all slow the steps that other requests share.
Lengths with heavy tails. Reasoning models write outputs whose lengths vary by orders of magnitude (§18.7). Plans built on mean lengths fill memory sooner than expected and need preemption, which costs a recomputed prefill (§20.6). Size memory for the percentiles, not the means.
Scaling on the wrong signal, or too late. Scaling on nvidia-smi utilization, scaling reactively against a cold start of minutes, and scaling down so fast that every dip is followed by a cold start all show up as spikes in TTFT.
The table collects the main serving decisions, what each buys, and what it costs.
| decision | buys | costs | choose it when |
|---|---|---|---|
| larger tensor-parallel degree | lower latency per token, more room for caches | all-reduce time, which can lower throughput per GPU | latency targets are tight, or the model does not fit |
| more replicas of a smaller layout | throughput per GPU, no communication | more copies of the weights | the TPOT target is loose and memory binds |
| separate prefill and decode pools | separate sizing and layouts, no prefill stalls in decode | a cache transfer on every request, a fast network | TTFT and TPOT targets conflict at scale |
| one pool with chunked prefill | prefill uses decode's idle arithmetic | prefill chunks lengthen decode steps | moderate scale, simpler operations |
| grammar-constrained decoding | output that always parses | mask compute, a distribution that is not the model's | a program consumes the output |
| unmerged multi-LoRA | one base serves many variants | adapter bytes per distinct adapter per step | many low-traffic variants |
| merged adapter | zero added latency | one full copy per variant | a few high-traffic variants |
| predictive autoscaling | no shortfall during ramps | idle capacity, a forecast to maintain | cold starts are long and traffic is periodic |
| an API instead of a fleet | no idle capacity, no operations | the provider's margin on every token | utilization would be below |
The block implements the multi-LoRA layer twice, in the gather form used for decode and the segmented form used for prefill, and checks both against a loop. It then applies a packed grammar bitmask to a batch of logits with bit operations, the way engines do it on the GPU.
In production you start an engine and talk to it over HTTP. The commands below are shown, not run. They start vLLM with Llama 3.1 70B in FP8 on four GPUs with adapters enabled. Then they send a request that names an adapter and demands JSON that matches a schema. Flags change between versions, so check them against the documentation of the version you run.
vllm serve meta-llama/Llama-3.1-70B-Instruct \
--quantization fp8 --tensor-parallel-size 4 \
--max-num-batched-tokens 8192 --max-num-seqs 256 --gpu-memory-utilization 0.90 \
--enable-lora --max-loras 8 --max-lora-rank 16 \
--lora-modules support=/adapters/support-v3