A language model writes one piece of text, or token, at a time. How do you keep a whole machine busy?
Thecatsat?
Each choice depends on the text before it. DeepInfer coordinates many such requests on GPUs: processors built to do many numerical operations together.
You ask a model: “Why is the sky blue?” Before it can write anything, it must process that prompt. Then it must generate its answer. These are different workloads.
A token can be a word, part of a word, or punctuation. The model assigns probabilities to the possible next tokens. Together, those probabilities form a distribution and sum to 1, or 100%. Sampling means choosing one token according to those probabilities.
The prompt’s tokens are already known, so the model can process many prompt positions together. Each position can use the text up to that position, including itself. This phase is prefill. It prepares reusable state and the probabilities for the first answer token.
After that, decode extends the answer. The model processes the token just chosen to calculate probabilities for the next one. Ordinary decoding cannot process a hundred future answer positions together: their input tokens are not known yet.
Another person’s request has a separate chain of dependencies. An engine groups positions from separate requests into a batch. The GPU can process them together because those requests are independent. An engine step runs a batch through the model, then samples tokens.
The model applies a stack of calculation stages, called layers. Each uses weights: numbers learned during training. Reading those weights takes time. Using the same loaded weights for several requests can produce more tokens per memory read. Eventually, the extra arithmetic and communication make a larger batch slower.
Could three independent requests each advance by one token in the same decode step? Could one request advance by three ordinary decode tokens?
Step through a small engine
This model processes three prompts in chunks. When a prompt finishes, the model samples its first answer token. Once all are ready, each decode step advances every unfinished answer by one token. Prompt work and decode work never share a step.
The budget limits prompt positions, while a decode step advances several separate answers. A longer prompt needs more chunks. Its answer tokens still depend on one another.
DeepInfer also keeps each batch entirely in one phase. Its GPUs coordinate the choice through periodic votes, rather than finishing the whole prompt queue first as this toy does. See the prefill vote and csrc/engine/prepare_sample.cpp:61.
Why not put every request in one enormous batch?
More requests need more cache memory. They also increase work per step, communication and time between tokens. Throughput counts total useful tokens per second. Latency is how long one reader waits. A choice that improves one may hurt the other. Step counts in this toy are not timings.
Remember the past without keeping everything
The prompt and the tokens generated so far form the context. To use that context, attention scores positions in the context for relevance and combines information from them.
The model computes arrays of numbers for this: keys for matching positions, and values for the information to retrieve. A KV cache saves them so each new token can reuse earlier work. The cache holds numerical state, rather than stored sentences.
DeepInfer keeps both long-range history and a recent sliding window. History grows with context. The window keeps only the latest positions, overwriting older entries in a ring.
Not every layer needs to create its own long-range history. Some reuse an earlier layer’s history. The layers that create it are its owners. In this toy, eight layers share two owners instead of keeping eight separate histories. Each layer still has an eight-position window. The engine allocates history in blocks of four records.
Grow the context from 8 to 32 tokens. Which memory keeps growing? Turn sharing off. What changes?
Two memory shapes, one context
Toy: eight layers, an eight-position window per layer and four-position history blocks. Sharing uses two long-range owners instead of eight. The window capacity stays at 64 records. Reserved history grows in blocks.
Blocks make allocation manageable. At a block boundary, one extra token can require a whole new block. Most of that block starts empty. Sharing reduces copies of history. A window bounds recent state. These solve different problems.
No. If memory runs short, the engine may remove a running request and release its state. This is preemption. It may later recompute that state. The released engine manages local KV. The shared cache store described in the Q&A is outside this release.
The slowest chip sets the pace
Each layer also has experts: separate calculation blocks specialised during training. A router chooses a few to run for each token. This is a mixture of experts, or MoE. All expert weights must be stored, but a token uses only the selected experts and a shared expert that always runs.
DeepInfer spreads experts across a group of GPUs. A GPU handles attention for its own requests, then sends the resulting numbers to the GPUs holding the chosen experts. Results return before computation continues. Even a GPU with no local requests must join the group’s exchange.
Here, one job means one expert’s work for one token. Every job takes the same time. E0–E3 are routed experts. This example omits shared-expert work, communication and moving weights. Suppose E2 gets unusually busy. A copy can split its jobs, but the destination already has work.
At six jobs for E2, will copying E2 to GPU 0 make the layer finish sooner?
Move the bottleneck
Each job takes one equal unit of work. Original loads are 5 and 7. Copying E2 and sending half its jobs to GPU 0 makes the loads 8 and 4. Moving E1 as well makes them 6 and 6. The slowest GPU sets the idealised step duration.
With six jobs for E2, copying it alone makes the peak load worse. Moving E1 as well relieves the new bottleneck. Change E2’s demand to see why the benefit depends on the whole placement, not just the hottest expert.
DeepInfer’s expert load balancing can replicate hot experts and rearrange their placement. The real system also pays for weight movement, dispatch and communication, and cannot know future demand perfectly. These bars show equal-cost work, not a measured speedup. See deepinfer/model_executor/eplb/policy.py:73 and expert placement and its limits.
Guess ahead, then earn the tokens
A smaller draft model proposes future tokens. Those guesses give the main model known inputs to check together in one pass through its layers. This is speculative decoding. An accepted guess becomes a committed token: part of the answer the model keeps.
The catch is correctness. Keeping every draft token would make you sample from the draft model. Keeping only guesses that are the main model’s favourite would also distort its probabilities.
Suppose the draft proposes “on” in 50% of rounds, but the main model wants it in only 20%. Keeping 40% of those proposals contributes 50% × 40% = 20% of all rounds. Always rejecting “on” would lose a valid outcome. Always accepting it would keep too much.
For a proposed token, call its draft probability q and its main-model probability p. Accept with probability min(1, p/q): use p divided by q, capped at 1. A random draw between 0 and 1 decides whether it passes. With a 40% acceptance chance, a draw below 0.4 accepts it.
At the first rejection, the model needs a replacement. For each possible token, subtract its draft probability from its main-model probability and replace negative results with zero. Divide the remaining values by their total so they sum to 1. This is renormalisation. Sample the replacement from those corrected probabilities.
Later guesses assumed the rejected token was part of the text, so discard them. If all guesses pass, sample one extra token from the main model.
Change the draft’s probability of “on”. Should the final answer probabilities change? What happens to “the” when “on” is rejected?
Keep the main model’s probabilities
Target probabilities for “on”, “in” and “under” are 20%, 60% and 20%. Draft probabilities start at 50%, 30% and 20%. Acceptance keeps 20%, 30% and 20% of all rounds. The rejected 30% becomes “in”. The final distribution is 20%, 60%, 20%.
The middle table rows count shares of all rounds: kept proposals plus replacements. At the initial setting, 30% of proposals need replacing, and every replacement is “in”. The final probabilities remain 20%, 60%, 20%, even when the draft changes.
This browser model implements the ideal rule. DeepInfer multiplies q by about 0.99995 when calculating replacements. This avoids a numerical corner case but allows a small, documented deviation from the main model’s probabilities. See deepinfer/model_executor/layers/tilelang_ops/common.py:3-19, :53.
When do more guesses stop helping?
A later guess is useful only if all earlier guesses pass. Let K be the number of guesses. Let a be each guess’s acceptance chance, given that earlier guesses passed. Average progress over many rounds is 1 + a + a² + … + aᴷ tokens. The 1 is the replacement or extra token. The second guess contributes a² because the first two must pass.
For this cost model, a round takes 10 milliseconds, or ms, plus 1.5 ms per guess. These costs are invented. More guesses help only when their extra tokens repay that cost.
Useful progress per unit of work
Analytical toy: a main-model round costs 10 ms plus 1.5 ms per proposed token. With zero acceptance, extra guesses add cost and no progress. With perfect acceptance, K guesses commit K+1 tokens. These invented costs are not DeepInfer measurements.
DeepInfer estimates acceptance chances to choose how many guesses to check. Its real costs depend on batch size and recorded GPU execution shapes. See confidence-scheduled verification.
The same odds are not the same answer
Imagine two fair coin tosses. Both obey the same distribution. They need not produce the same outcome. Preserving a model’s token probabilities and reproducing an identical token sequence are different promises.
A seed is a number that lets the program reproduce its random draws. But a draw selects a token using calculated probabilities. If those probabilities change, the same draw can select a different token.
Computers store numbers with limited precision. Grouping additions differently can change the last digit. Suppose token A gets draws below a boundary, and B gets the rest. The boundary should be 0.1 + 0.2 + 0.3. What happens to a draw of exactly 0.6?
One draw, two addition orders
(0.1 + 0.2) + 0.3 is 0.6000000000000001. Adding 0.1 + (0.2 + 0.3) gives 0.6. With a draw of exactly 0.6 and the rule draw < boundary, the first selects A and the second B.
This is a deliberately sensitive counterexample, not an estimate of how often real tokens flip. GPU kernels add much larger arrays of numbers. Batch shapes can change how the kernels group those additions. Batch-invariant inference prevents other requests in the batch from changing a request’s numerical result.
DeepInfer’s documented recipe for NVIDIA GPUs combines DEEPINFER_BATCH_INVARIANT=1, a fixed seq_seed and identical input tokens and sampling settings. It also requires DEEPINFER_ENABLE_MTP_VARLEN=0. The last setting fixes how many drafts the model checks. This removes another way the sampling path can differ. This contract does not guarantee repeats across hardware or versions. See README.md:27-28 and the correctness walkthrough.
Put the pieces together
The common quantity is expensive work per committed token. An idle gap, a repeated memory read, an overloaded GPU or discarded guesses can all increase it. None of the techniques fixes every bottleneck.
Five conceptual checks with explanations. With JavaScript disabled, use the questions and worked answers at the end of Pass 0 and Pass 1.
A GPU has no local requests, but holds the busiest expert. Why can it still determine everyone’s delay? Why might adding a copy fail to fix it?
Compare your explanation with the mechanism
Other GPUs send work to that expert, so an empty local request queue does not make this GPU idle. The group waits for its expert work before continuing. A copy moves some of that work to another GPU. If the destination already has enough work, it can become even slower. The result depends on every GPU’s load, because the group waits for the slowest.
Where the small models end
The release supports offline inference. A complete service also needs a shared cache, routing across engine copies, and a way to move cached state between prefill and decode machines. We did not run the engine on GPUs. The deeper passes name the authors and configurations behind each benchmark figure.
Mechanism: a real 16,389-token request through the code.
Internals: preemption, rejected drafts, hot experts and grammar dead ends.
DeepInfer is DeepSeek’s offline inference engine for DeepSeek-V4.1-Flash. Five walkthroughs explain six topics in increasing depth. This explanation uses commit 774ce244b87cc0eb8d4b94ab9f5de2ab22049f01.
Engine claims distinguish three kinds of evidence. Reading the source and running the later CPU checks does not reproduce a GPU deployment.
code means I read it in the code. A path:line reference from the DeepInfer repo root sits next to it.
doc means the Q&A doc, doc/inference_deployment_qa.md, reports it. This is documentary evidence, not an independently reproduced result.
inference means I think it is true but haven't confirmed it.
The release manages the cache and computation inside an engine. Shared prefix storage, separate prefill/decode services, and routing between engine replicas belong to the surrounding service codeREADME.md:38-42. Those parts are described in the Q&A, not implemented in this release.
What you need before you start
The code walkthroughs assume Python, some C++, and basic transformer knowledge. The introduction establishes tokens, sampling, attention, caching and experts without code. These references supply further background:
Topic
Why you need it
Where to learn it
How a decoder-only transformer generates text
Every pass assumes you know what one forward pass computes
Andrej Karpathy, "Let's build GPT: from scratch, in code, spelled out" (video)
What a KV cache is and why it exists
Thread 2 builds on it
The vLLM paper, "Efficient Memory Management for Large Language Model Serving with PagedAttention" (Kwon et al., 2023), sections 1 to 4
Mixture of experts
Threads 3 and 4
The DeepSeek-V3 technical report, the MoE section
Why GPUs are often limited by memory traffic, not maths
Threads 4 and 5 depend on it
Horace He, "Making Deep Learning Go Brrrr From First Principles" (blog post)
Leviathan, Kalman and Matias, "Fast Inference from Transformers via Speculative Decoding" (2023)
If you only have time for one, read the GPU memory-traffic post. Most of the "why" answers in Pass 4 come back to it.
The mental model
DeepInfer turns prompt tokens into new tokens through repeated model steps. A step batches work from independent requests. Speculative decoding can check several proposed tokens per request together.
The engine batches work and moves less memory per token. Its CPU, the general-purpose processor, plans the next step while the GPU runs. CPU work sets the pace if it takes longer than the GPU work it can overlap.
GPUs that exchange expert work must join compatible communication steps, even when one has no local requests. Supported repeatability settings also keep a request’s answer independent of its batch companions.
One picture of the whole system
The release contains only the box labelled "one engine". Everything outside it is described, not in the code doc.
A separate component, DeepSeek Recipe, turns requests into tokens doc. Load balancers choose a prefill engine and later a decode engine doc. Inside each engine, the scheduler chooses a batch, the worker runs the model, and the sampler chooses output tokens codecsrc/engine/scheduler.hpp:275, deepinfer/worker/worker.py:89. The KV cache saves reusable attention state.
DP, or data parallelism, gives different requests to different GPUs. EP, or expert parallelism, spreads experts across GPUs, so tokens travel to the experts they need. A rank identifies one process in a communication group. The expert group must join matching exchanges, a form of lockstep. Separate groups can progress independently.
The whole system
Only the engine at the bottom is in the release. The parts above it are described in the Q&A doc but not in the code.
The six threads
The life of a request. From prompt in, to tokens out.
Memory and the KV cache. How the engine stores earlier results, and when it discards or recomputes them.
The model. What DeepSeek-V4.1-Flash computes per token, and where it differs from a plain transformer.
Spreading work across chips. How attention and experts are split over many GPUs, and how hot experts get balanced.
Making decode faster. Batching, recorded GPU work, overlapping the CPU with the GPU, and guessing tokens ahead.
Getting the right answer every time. Sampling, and making the output the same whatever else is in the batch.
Number formats such as FP8 and FP4 get no thread of their own. Thread 2 covers cache formats. Thread 3 covers model-weight formats.
One token through layers 2, 20 and 37, odd positions
Truncated prefill, a token seen twice?
760B total vs 15B active
Choose which layers own KV
4. Chips
GP practices and clinics
One MoE layer at EP8
A hot expert, a failed GPU
EP8 vs EP64, TP vs EP
One expert gets 3x traffic
5. Decode speed
Waiter and junior chef
Async steps, graph sizes
A rejected draft, padding
When drafts stop paying
Choose verify lengths
6. Right answer
Raffle tickets, adding floats
The sampler, step by step
Two batch shapes, grammar dead ends
What determinism costs
Deterministic drafting
You know vLLM well. Skim Pass 1. In Pass 2, read the scheduler vote and async steps closely, because they differ most from vLLM. Then go to Pass 3.
You know transformers but not serving. Read every pass in order.
You only care about the model. Read thread 3 in each pass. You will still need the MoE parts of thread 4.
You want design practice. Read Pass 0 and Pass 4, then try Pass 5 before you read its answers.
How each pass works
Passes 2–5 begin with recall questions. Mixed exercises ask you to choose which mechanism applies, with answers folded below. Glossary links let you revisit definitions.
Exercises
Name two parts of the production system that the Q&A doc describes but the code does not contain.
One sentence of the mental model says an expert-parallel group must coordinate its steps. From the picture alone, which part of the model forces that?
Answer
The MoE layer. Tokens travel to experts on other ranks in the same group. Each rank must join the corresponding exchanges, even with no local requests. Separate engine groups can progress independently.
Prefill runs on 2 to 8 GPUs per group and decode on 64 to 128. Guess one reason they differ.
Answer
Any reasoned guess counts. Pass 4 gives the full answer. Decode handles few tokens per request per step, so spreading expert weights across many GPUs reduces each GPU’s reads. Prefill already has thousands of tokens per step, so it gains little and pays for the extra traffic.
Which pass covers what happens when the GPU runs out of memory for a new request?
Answer
Pass 3, thread 2. Passes 1 and 2 only mention it.
DeepSeek wrote the scheduler in C++ and most of the rest of the engine in Python. Guess why before Pass 2 tells you.
Answer
The scheduler runs once per step for every request in the batch. With hundreds of requests and steps of about 13 ms, Python would spend enough time managing requests to leave the GPU waiting. Pass 2 shows the evidence.
Intuition
These worked examples use invented numbers. Claims about DeepInfer keep the evidence tags from Pass 0.
The toy used in every thread
Treat “the cat sat on the mat” as six tokens at positions 0–5. Each example introduces the parts of its toy model when they are needed. These settings differ from the introduction’s browser models.
The life of a request
A request waits while the engine chooses its next batch. The engine processes the prompt in large steps, then generates tokens in smaller steps.
A restaurant kitchen with an order rail
Before each round of cooking, the head chef picks orders from a rail. A new order needs prep first, then gets one course per round.
One decodestep: one new token, or a few with drafts
The head chef's choice each round
The scheduler runs once per step
An order that leaves
The request stops at an end token or a length limit
waiting: A = "the cat sat on the mat" 6 tokens
B = a 5-token prompt
C = a 4-token prompt
budget: 8 tokens per prefill step
Take A. It needs 6 tokens and the budget has 8. A fits whole, leaving 2.
Take B. It needs 5, but only 2 remain. Cut B and process its first 2 tokens now. This is chunked prefill.
Stop. A cut prompt ends the step, so C waits.
In this example, "a cut prompt ends the step" is the rule for the engine type this series follows. Pass 2, Thread 1 shows the code.
The next prefill step takes B's last 3 tokens and all 4 of C, 7 in all. Each prompt now has its first output token.
Say A's output is "and", "slept", ".", then the end token. Prefill gave "and", so A needs 3 decode steps at one token per step.
step 1 prefill A[0..5] B[0..1] -> A gets "and"
step 2 prefill B[2..4] C[0..3] -> B and C get first tokens
step 3 decode A B C -> A gets "slept"
step 4 decode A B C -> A gets "."
step 5 decode A B C -> A gets the end token, A leaves
step 6 decode B C (and a new request, once it has had its prefill)
The real engine does not start prefill whenever a prompt waits. It holds a vote every 70 decode steps by default, which Pass 2, Thread 1 covers.
A kitchen can cook a main course on one stove while another does prep. DeepInfer can't, because a step is all prefill or all decode codecsrc/engine/prepare_sample.cpp:61.
The real engine also has many kitchens that cook in lockstep. When one does a round of prep, every kitchen does a round, even with nothing to prep. An idle kitchen cooks an empty plate, which the engine calls a placeholder step. Thread 4 explains why.
Continuous batching reforms the batch between steps: new requests join and finished ones leave. DeepInfer still keeps prefill and decode in separate steps.
Why would an engine refuse to mix prefill and decode in one step, when mixing would keep the GPU busier?
Answer
In production, prefill and decode run on different machines anyway doc, so mixing would only help one machine. The decode path also depends on pre-recorded GPU work with fixed shapes, which Thread 5 covers. A mixed batch would break those shapes. Parts of this answer are my reading inference.
Memory and the KV cache
The engine reuses stored keys and values while they remain in the cache. Long-range records live in fixed-size blocks. A fixed ring holds recent state. Eviction, preemption or a missing window can require recomputation.
A filing cabinet and a sticky-note pad
Picture the KV cache as a cabinet of numbered drawers, all one size. A drawer holds the cards for 4 tokens in the toy, or 128 in the real engine. A clerk keeps a list of free drawers.
A new customer gets enough drawers for their whole file, and the clerk writes the drawer numbers on a slip. Each drawer is a block. The slip is the block table, and the clerk is the block manager.
Each customer also gets a sticky-note pad with a fixed number of slots, where a new note covers the oldest. It is the ring for sliding-window attention.
A has 6 tokens and a block holds 4. So A needs 6 / 4 = 1.5, rounded up to 2 blocks.
The clerk's free list says drawers 7 and 2 are free. A's block table becomes [7, 2].
Positions 0 to 3 go in drawer 7. Positions 4 and 5 go in drawer 2, which still has room for positions 6 and 7.
A's block table is [7, 2].
Drawer
Slot 1
Slot 2
Slot 3
Slot 4
7
pos 0 "the"
pos 1 "cat"
pos 2 "sat"
pos 3 "on"
2
pos 4 "the"
pos 5 "mat"
empty: "and" goes here at decode step 1
empty
Position 8 needs a new drawer, which the clerk hands out then.
The pad has 4 slots: a 3-token window plus 1 spare. Position p goes in slot p mod 4, the remainder after dividing p by 4.
After the prompt, positions 0 to 5:
Slot
0
1
2
3
Holds
pos 4 "the"
pos 5 "mat"
pos 2 "sat"
pos 3 "on"
Positions 4 and 5 covered positions 0 and 1. The window for position 5 needs positions 3, 4 and 5, and all three are there.
The spare supports speculative decoding in Thread 5. A step can write a guess, then reject it. The spare stops that guess overwriting a note the window still needs. The real engine has 6 spares on a 128-token window, 134 slots in all codedeepinfer/worker/cache_engine.py:88-93.
When the drawers run out
Say only 1 drawer is free and prompt B needs 2. B waits. In the real engine too, a new prompt gets every block for its whole length before it starts codecsrc/engine/scheduler.hpp:386-388. So a half-done prompt never stalls waiting for its second half.
Decode is harder, because any running request may need one more drawer at any step. If none is free, the clerk takes drawers back from someone, which is preemption. Pass 3 covers who loses them.
Unlike an office drawer, a block can belong to two requests with the same prompt start. The clerk counts the slips that name each drawer and frees it when the count reaches zero.
Text version
Seven drawers are free, in the order 7, 2, 5, 3, 6, 1, 4. Drawer 0 is never handed out.
A's 6-token prompt takes 7 and 2 from the head. A's block table is [7, 2].
A reaches position 8 and takes 5. A's table is [7, 2, 5].
A is forked into B. B shares 7 and 2, so each has a count of 2. B copies A's half-full drawer 5 into 3. B's table is [7, 2, 3].
A finishes. 7 and 2 drop to a count of 1. Drawer 5 reaches 0 and goes on the tail.
B finishes. 7, 2 and 3 reach 0 and go on the tail.
In a plain transformer each layer would need its own cabinet. In V4.1 only 4 of the 40 layers write long-range cards, and each number takes about 4 bits. So one real block holds 128 tokens for the whole model in about 114 KB inference from the formats in deepinfer/worker/cache_engine.py:424-483.
You might think the KV cache size is "layers times tokens times the size of one key and value". It is much smaller here, for three reasons. Only 4 layers own long-range KV codedeepinfer/hugging_face/config.py:62-75. The window is a fixed ring per request. The stored numbers are 4 or 8 bits. The size estimates are my calculations inference. Pass 2 calculates the real layout.
Case
Prompt length
Blocks needed
Ring slots needed
Toy prompt
6
2
4
A toy prompt twice as long
12
3
4
A toy prompt ten times as long
60
15
4
Long-range memory lives in blocks that grow with the request, and the window lives in a fixed slot. That is why the real engine keeps two pools, one for blocks and one for state slots codedeepinfer/engine/llm_engine.py:265-266.
A forked request looks new, but it shares the parent's drawers and copies only the last, half-full one.
The model
The first two layers read only a 128-token window. Later layers also read up to 512 selected long-range entries. Each layer uses 6 of 384 routed expert networks, plus a shared expert.
A newsroom writer
For each word, a writer does three things.
They reread the last page closely. That is the window.
They ask a librarian, who scans a quick index, for the most useful old clippings. That is the indexer.
They send their sentence to a few specialist desks, such as sport, finance and science, and blend what comes back. That is the mixture of experts.
The writer keeps four versions of their working notes and blends them before each task. In the model, those notes are arrays of numbers called the hidden state. Blending four parallel versions is hyper-connections. A phrasebook of common 2-, 3- and 4-token sequences is Engram.
Follow "mat" at position 5 through the toy's two layers.
position: 0 1 2 3 4 5
token: the cat sat on the mat <- the query
|------ window -----|
layer 1: window only sees pos 3, 4, 5
layer 2: window + 1 older pick sees pos 3, 4, 5 + the librarian's pick
the librarian scores pos 0, 1, 2 -> picks pos 1 "cat"
The toy’s second layer has four experts, E0–E3, and chooses two for “mat”. Say the router gives them these scores.
Expert
Router score
E0
0.05
E1
0.25
E2
0.60
E3
0.10
The top 2 are E2 and E1. Their scores total 0.85. After rescaling to a total of 1, E2 gets 0.60 / 0.85 = 0.71 and E1 gets 0.25 / 0.85 = 0.29. The output is 0.71 times E2's answer plus 0.29 times E1's. The real model also has one shared expert for every token. It scales the routed part by 1.5, a config value passed to the gate kernel codedeepinfer/model_executor/models/v4_modules/mlp.py:378-381.
One token through a later layer
From layer 2 onward, simplified. Attention and MoE each read a blend of the 4 copies of the hidden state, then write back into all 4.
A writer has one archive. Here only layers 2, 8, 14 and 20 write long-range memory. Each layer that uses long-range memory reads one of those four codedeepinfer/hugging_face/config.py:62-75. In layers 2 to 19, each entry also merges two neighbouring tokens.
The librarian uses rough 4-bit summaries to select entries, so it can miss relevant ones. The window keeps every recent position without selection or merging. It still stores numbers with limited precision.
The code calls the YOCO halves “encoder” and “decoder”, but all 40 layers are causal: a position sees only itself and earlier positions. The split describes cache ownership: the first 20 layers build their own long-range memory. The last 20 share one memory that layer 20 builds codedeepinfer/model_executor/models/v4_modules/yoco.py:19-21.
Why would a model with 384 experts per layer send each token to only 6?
Answer
An expert computes only the tokens sent to it. With 6 of 384, each token touches about 1.6% of a layer's expert weights. The model holds about 760 billion parameters with Engram. Each token uses about 15 billion parameters. These are my estimates from the config inference. The catch, in Thread 4, is that the experts must live on many GPUs and tokens must travel to them.
Spreading work across chips
Each GPU runs attention for its own requests, but experts are spread across an expert-parallel group. Every layer needs an exchange within that group.
GP practices and specialist clinics
Each GP practice keeps its own patients and files. That is data parallel attention: each GPU has its own requests and KV cache.
Each town hosts only some specialist clinics, so GPs refer patients across towns. That is expert parallel MoE.
The practices exchange referrals, and every result returns before the next round. That is the all-to-all. A long queue at one clinic can delay the group. The GPUs must join matching exchanges. They need not reach them at precisely the same time.
The toy has two GPUs. GPU0 holds E0 and E1. GPU1 holds E2 and E3. Each token in layer 2 goes to two experts.
Token
Experts
pos 0 "the"
E2, E0
pos 1 "cat"
E2, E1
pos 2 "sat"
E2, E3
pos 3 "on"
E2, E0
pos 4 "the"
E2, E0
pos 5 "mat"
E2, E1
So E0 gets 3 jobs, E1 gets 2, E2 gets 6 and E3 gets 1, 12 in all.
GPU0 does 3 + 2 = 5 jobs and GPU1 does 6 + 1 = 7. The mean is 6, but the layer takes as long as the slowest GPU. The real engine measures this with moe_lb, the busiest GPU divided by the mean. Here it is 7 / 6 = 1.17.
Fixing the hot expert
E2 is hot. Give each GPU one spare slot, so there are 6 slots for 4 experts.
Fix 1: copy E2 onto GPU0, change nothing else. E2's 6 jobs split 3 and 3.
The copy alone made it worse, and Fix 2 works because it also moves an expert. The real balancer, EPLB, does both. It decides which experts get copies, then places all the copies on GPUs codedeepinfer/model_executor/eplb/policy.py:96-123 and :12-70. Pass 3 runs the real algorithm by hand.
You cannot copy a clinic overnight. Expert weights are numbers the engine can copy. The real engine re-plans and moves them every 1,000 steps for prefill and every 10,000 for decode doc.
A practice with no patients could close. A GPU with no requests can't, because the others wait for it in the all-to-all. It runs one fake token, the lockstep rule from Thread 1.
You might think more GPUs per expert group always means faster decode. More GPUs also mean more traffic and more chances for one slow GPU to delay the group. Each GPU reads fewer expert weights per step, which helps decode. So prefill uses the smallest group that fits in memory and decode uses the largestdoc. Pass 4 gives the numbers.
Case
What each GPU does in attention
What each GPU does in MoE
Toy, 2 GPUs
Its own requests
Its own experts, for everyone's tokens
Real decode, 64 GPUs
Its own requests
7 expert slots, for everyone's tokens doc
A GPU with no requests this step
One fake token
Still joins the exchange
Each GPU runs attention locally, but the expert exchange makes it wait for the slowest GPU in its group.
Making decode faster
A decode step has costs that can be shared across its rows, such as reading model weights. Small batches can add rows cheaply, but row computation and memory traffic grow with the batch. The engine tries to gain more useful tokens than those rows cost.
A waiter and a junior chef
A waiter prepares the next round’s orders while the kitchen cooks this one. The waiter represents the CPU, which prepares work for the GPU. Overlapping those tasks is zero-overhead scheduling.
A junior chef plates five dishes ahead, guessing the next orders. The head chef tastes all five at once and keeps them up to the first wrong one, which they replace. That is speculative decoding.
The waiter
Say the CPU needs 3 milliseconds, or ms, per step to pick the batch and read the results. The GPU needs 10 ms to run the model.
Text version
WITHOUT the waiter (the CPU waits for the GPU, then the GPU waits for the CPU)
CPU |sched| |read+sched| |read+sched|
GPU |=====10 ms=====| |=====10 ms=====|
one step = 3 + 10 = 13 ms -> about 77 steps per second
WITH the waiter (the CPU plans step N+1 while the GPU runs step N)
CPU |sched 1|sched 2|read 1| |sched 3|read 2| |sched 4|read 3|
GPU |=====step 1=====|=====step 2=====|=====step 3=====|
one step = 10 ms -> 100 steps per second, 30% more
The catch is that the CPU plans step N+1 before it knows step N's token. The real engine has the GPU fill in that token itself codedeepinfer/worker/worker.py:191-197. Pass 2 shows how.
The junior chef
Request A has "the cat sat on the mat and". The draft model guesses "slept", "on", "the", and the main model checks all three in one step.
The check is a weighted coin flip, not a test of whether the guess is the main model's top choice. Ideal rejection sampling preserves the main model's distribution. DeepInfer implements it with small numerical approximations, described below and in Pass 2.
Position
Draft guess
Draft's probability q
Main model's probability p
Accept chance min(1, p / q)
Coin
Result
1st
"slept"
0.6
0.9
1.0
any
accept
2nd
"on"
0.5
0.2
0.4
0.7
reject
3rd
"the"
thrown away, an earlier guess failed
At the rejected position, the main model samples a replacement. It subtracts the draft’s probabilities from its own, then sets negative results to zero. Say the main model’s probabilities at the 2nd position are "on" 0.2, "in" 0.6 and "under" 0.2. The draft's were "on" 0.5, "in" 0.3 and "under" 0.2. The remaining weights are "on" 0, "in" 0.3 and "under" 0, so the replacement is "in". The real kernel scales the draft’s probabilities by a tiny factor before subtracting. Pass 2, Thread 6 shows why.
The step gave 2 tokens, "slept" and "in", where plain decode gives 1. The real engine guesses 5 at a time doc.
A head chef tastes one dish at a time. The model scores all proposed positions in one pass. At small batch sizes, sharing weight reads and launch costs can make six rows cheaper than six separate steps. It does not make the extra rows free.
The junior chef guesses for free, but the real draft model costs time every step. Each checked guess also adds a row of work. In a big batch, that growing cost becomes harder to hide.
You might think checking more guesses is always better, because a correct guess is a free token. An unlikely guess can waste the work needed to check it. The real engine can predict how likely each guess is to pass and skip the weak ones. The Q&A doc reports that this cuts guesses checked per step from 5.00 to between 3.2 and 4.4. At about 60 requests per GPU that gives 4 to 7% more throughputdoc. Pass 4 shows when it loses.
Why must the GPU fill in tokens under async scheduling, rather than wait for the CPU to read them?
Answer
If the CPU waited for step N's token before launching step N+1, the GPU would sit idle again. The CPU therefore launches step N+1 before reading step N’s result. A small GPU kernel copies step N's token into place just before step N+1 needs it.
Getting the right answer every time
Seed and position fix each random draw. Repeatable tokens also need numerical results that do not change with the batch.
A raffle with pre-numbered tickets
Picture a raffle where your ticket numbers depend only on your name and the round, not on who else entered. Enter again and you get the same tickets.
The model produces a score, or logit, for each possible token. Higher scores mean higher probabilities. Say the vocabulary has four tokens, A, B, C and D, with logits 2.0, 1.0, 0.5 and −1.0.
Temperature 0.5. Divide each logit by 0.5. That gives 4, 2, 1, -2.
Softmax turns scores into probabilities. Exponentiate each score: e⁴, e², e¹, e⁻², where e is about 2.718. The resulting positive values, 54.6, 7.39, 2.72 and 0.14, total 64.8. Divide each by that total to get probabilities 0.842, 0.114, 0.042 and 0.002.
Top-p 0.9. Keep the most likely tokens until they add up to at least 0.9. A alone is 0.842, not enough. A and B make 0.956, so keep both.
Renormalise. A becomes 0.842 / 0.956 = 0.881. B becomes 0.114 / 0.956 = 0.119.
Draw. The seed and position give a number between 0 and 1. Say it is 0.93. A covers 0 to 0.881, B covers 0.881 to 1. So 0.93 picks B.
Run the same request again with the same seed, and step 5 gets 0.93 and picks B again.
Why the batch can change the numbers
Step 5 repeats only if steps 1 to 4 get exactly the same logits. Floating-point addition rounds its results. Changing the addition order can change the answer.
I checked those calculations with NumPy. A GPU adds long rows in chunks, and the chunking can depend on the batch size. So the same request, alone or in a batch of 64, can get logits that differ in the last bit. Near a boundary like 0.881, one bit can flip the token, and every later token differs too.
Batch invariance keeps a request's numerical results independent of batch composition, including through stable reduction choices. DeepInfer offers it as a switch, on NVIDIA only codedeepinfer/worker/runtime_config.py:62-69.
A raffle drum doesn't care what else is in the room. A GPU does, because of the chunked sums, so the logits must also repeat.
Speculative decoding adds a second break. If the engine checks 3 guesses in one run and 5 in another, the draws line up with different verification roles. Both runs can target the same distribution, subject to the numerical approximations above, while producing different tokens. Pass 3 shows why one setting must be off for exact repeats.
You might think the same seed and temperature always give the same output. Repeatability also needs identical input tokens and sampling parameters, stable numerical results and a fixed verification schedule. The README's NVIDIA recipe enables batch invariance, disables variable-length verification and supplies a fixed seq_seedcodeREADME.md:28.
All four runs below use the same model, input tokens and sampling parameters on the supported NVIDIA runtime. Variable-length verification is off, so the verification schedule stays fixed.
Case
Seed
Batch
Batch invariance
Same output as run 1?
Run 1: request alone
42
1 request
On
(the baseline)
Run 2: same request alone
42
1 request
On
Yes, the same draws and the same sums
Run 3: same request among 63 others
42
64 requests
Off
Not guaranteed, the sums may differ
Run 4: same request among 63 others
42
64 requests
On
Yes, within the conditions above
A repeatable output needs repeatable draws and repeatable numbers. A fixed seed and verification schedule give the first. Batch invariance supplies the second within the supported configuration.
Temperature 0 looks like a separate "always pick the top token" mode. In DeepInfer it becomes 0.00001, and the engine still draws codedeepinfer/sampling_params.py:76-79. So two near-equal tokens can each win.
Mixed exercises
For each exercise, first decide which thread applies.
Using the toy budget of 8 tokens per prefill step, three prompts of 3, 7 and 2 tokens arrive in that order. Which tokens does the first prefill step process?
Answer
Thread 1. The 3-token prompt fits whole, leaving 5. The 7-token prompt gets its first 5 tokens. A cut prompt ends the step, so the 2-token prompt waits.
A toy request has a 6-token prompt and will produce 7 tokens. With 4-token blocks, how many blocks does it hold when it finishes? How many ring slots?
Answer
Thread 2. It ends with 6 + 7 = 13 tokens, and 13 / 4 = 3.25, so 4 blocks. The ring still has 4 slots. When it sizes the block list, the real engine also counts tokens still in flight and draft tokens. Pass 2 shows this.
In the toy routing table, suppose token "sat" moved from E2 and E3 to E0 and E1. Work out moe_lb with the original placement, GPU0 holding E0 and E1.
Answer
Thread 4. Now E0 gets 4, E1 gets 3, E2 gets 5, E3 gets 0. GPU0 does 4 + 3 = 7 jobs, GPU1 does 5 + 0 = 5. The mean is 6. moe_lb = 7 / 6 = 1.17. The imbalance flipped sides but did not shrink.
The CPU needs 6 ms per step and the GPU 10 ms. Calculate steps per second with and without overlapping their work. Then do it again with 12 ms of CPU work.
Answer
Thread 5. Without it, a step takes 16 ms, about 62 steps per second. With it, 10 ms, so 100 per second. With 12 ms of CPU work, the overlap hides only 10 ms, so each step takes 12 ms, about 83 per second. Overlap hides all CPU time only while CPU work is shorter than GPU work. That is one reason the scheduler is C++.
A draft guess has q = 0.8 and the main model gives it p = 0.2. What is the chance the main model accepts it? Is a guess with q = 0.1 and p = 0.2 more or less likely to pass?
Answer
Threads 5 and 6. The first passes with chance min(1, 0.2 / 0.8) = 0.25. The second passes with min(1, 0.2 / 0.1) = 1, so always. An unsure draft is fine. What hurts is a draft that is sure of something the main model thinks unlikely.
In the sampling example, the draw is 0.88 instead of 0.93. Which token wins? Now say batch invariance is off, and in a bigger batch A's renormalised share comes out at 0.8799 instead of 0.881. Which token wins?
Answer
Thread 6. With A covering 0 to 0.881, a draw of 0.88 picks A. If A's share drops to 0.8799, the same draw lands in B's range, so B wins. A tiny change in the numbers changed the token, so repeatable draws are not enough on their own.
Two requests share the first 4 tokens of their prompts. In the filing-cabinet picture, what can they share, and what can't they?
Answer
Thread 2. They can share the drawer for positions 0 to 3. Each needs its own drawers from position 4 on, and its own sticky-note pad, because the latest notes differ. When a forked request starts, the real engine shares full blocks and copies the last half-full block and the state slot.
A GPU in the toy has no requests at all this step. Name two things it still does, and say which threads they come from.
Answer
It runs a step with one fake token, because the other GPU waits for it in the expert exchange. That is Thread 4, the lockstep rule. It also takes part in the vote on whether this step is prefill or decode, which is Thread 1. Pass 2 shows both in the code.
Mechanism
Each path:line starts at the root of the DeepInfer repo. I read the code but did not run it. This machine has no GPU.
Recall first
A step in DeepInfer is one of two kinds. Name them, and say whether one step can hold both.
Answer
Prefill and decode. A step is all prefill or all decode, never both. Thread 1 shows the line that enforces it.
In the toy, a 6-token prompt with 4-token blocks needed 2 blocks and a ring of 4 slots. Which of those two numbers grows if the prompt grows?
Answer
Only the block count. The ring is a fixed slot per request.
Why does a GPU with no requests still run a step?
Answer
Every MoE layer needs every GPU to join the token exchange. A GPU that skipped a step would leave the others waiting. So it runs a placeholder step with one fake token.
Under async scheduling, what does the CPU not know when it plans step N+1?
Answer
Which token step N produced, and how many draft tokens step N accepted. The GPU fills those in itself.
What two things must stay the same for output to repeat?
Answer
The random draws and the logits. Seed and position fix the draws. Batch invariance keeps the logits stable across batches.
The request we follow
This trace follows the offline example’s long-context test. It comes from reading the source, rather than observing a run. examples/offline_inference.py --long_context stretches each sample prompt to 16,389 tokens and asks for 4,096 output tokens codeexamples/_prompts.py:26-27, examples/offline_inference.py:63-69. The test tests/models/test_offline_inference_long.py runs that configuration upstream.
I follow sample 1, the English one: "Question: What is the largest planet in the solar system? Answer: Jupiter ..." codeexamples/_prompts.py:72-76.
DP rank r gets the five sample prompts repeated r + 1 times codeexamples/_prompts.py:100. So rank 0 gets five prompts, r0 to r4 after their request ids. Ours is r1. Samples 2 to 4 carry images, and samples 0 and 1 are text only.
Rank 0 has one more prompt than it has running slots. A prefill step holds at most 32,768 tokens, two prompts minus 10. For the trace, assume sufficient free cache and that every rank's first prompts are ready by the vote at decode step 70. Preprocessing runs asynchronously. Reading the source cannot tell us when it finishes.
The life of a request
The request enters a C++ queue, waits for a group vote, gets prefilled in two chunks, then decodes 4,096 tokens.
Processes and who owns what
Each DP rank is its own operating-system process, with its own LLM, LLMEngine and C++ scheduler. examples/_launch.py starts one process per DP rank codeexamples/_launch.py:36-39.
Each step the eight ranks run small all-reduces: communication operations that combine a value from each rank and return the result to every rank. One votes on the step type. Another checks whether any rank has work left codecsrc/engine/scheduler.hpp:316-332, deepinfer/engine/llm_engine.py:665-679.
Eight independent schedulers serve the eight GPUs. Each step, they agree on its type and whether to continue. Requests are split across ranks before the engine sees them. Here the script splits them. In production the load balancers do, and they are described, not in the code doc.
It checks every token id is inside the vocabulary. For a bad id, it logs the error and returns None. The request gets no output codedeepinfer/engine/llm_engine.py:580-589.
The scheduler's add_sequence stamps the arrival time and reserves room for the prompt plus 4,096 output tokens. Then it pushes the sequence onto the back of the waiting queue codecsrc/engine/scheduler.hpp:135-142.
Every prompt carries the empty image dict from point 4 above. None is schedulable on any rank at the first schedule(). As I read the code, the first step then goes like this.
A rank "wants" prefill only if its cache is at most 90% full and the head of waiting is schedulable codecsrc/engine/scheduler.hpp:313. No head is schedulable, so no rank wants it.
The vote fails and the scheduler builds a decode step codecsrc/engine/scheduler.hpp:318-322. running is empty, so the step is empty.
The next ALL point comes when decode_step reaches 70, the default interval codedeepinfer/environ.py:174. So the earliest next ALL opportunity is after 69 more decode steps inference. Whether it starts prefill depends on readiness and available resources. Each empty step still makes a full trip through the model with one fake token on every GPU in this engine group.
NORMAL engine: decide whether the whole group does prompt or decode this step. Within DP the step type must match, and a vote unifies it. ALL points, every prefill_attempt_interval decode steps: only do it if every rank has prefill work. This avoids one rank working while the rest run empty prompt steps alongside. ANY points, every prefill_any_attempt_interval decode steps: do it together if any rank has work, as a backstop for an isolated rank.
Text version
Offline start. No prompt is ready at step 0, so every rank runs empty decode steps. At step 70, an ALL point, every rank has work, so all four prefill. The next step is an ALL point again, and they prefill again. At step 72 rank 2 has nothing left, so the ALL vote fails. Ranks 0, 1 and 3 wait 100 decode steps for the ANY point at step 172.
One rank gets a prompt. Rank 2's prompt arrives at step 10. The ALL vote at step 70 fails, because the other ranks have no work. The ANY vote at step 100 passes. Rank 2 prefills and the other three run empty prompt steps.
Every rank gets one. All four prompts arrive at step 10. The ALL vote at step 70 passes, and all four prefill together.
Why is the vote an all-reduce on a CPU group, not on the GPUs?
Answer
With async scheduling on, the engine passes the CPU "gloo" group to the scheduler codedeepinfer/engine/llm_engine.py:313. The scheduler puts its vote tensor on the CPU codecsrc/engine/scheduler.hpp:170-181. A GPU all-reduce would queue behind the forward pass still running on the GPU. Reading its result would make the CPU wait for that forward pass. Async scheduling exists to remove that wait inference.
The metadata calls the number of new positions processed in this step seqlen_q. seqlen_k includes the earlier cached positions too. In the arithmetic below, ceil means round up to the next integer.
r0, 16,389 tokens. Its block table is empty, so the scheduler checks the length limit and asks for all its blocks at once codecsrc/engine/scheduler.hpp:374-397. It needs ceil(16,389 / 128) = 129 blocks, and they are free codecsrc/engine/sequence.hpp:291-292. r0 fits within the 32,768-token budget. Its seqlen_q = 16,389, leaving 16,379 tokens. r0 becomes RUNNING and gets an MTP info slotcodecsrc/engine/scheduler.hpp:522-526. That slot is a row in a GPU pool where the draft model leaves its guesses for this request. MTP is the code's older name for the draft model.
No. In a NORMAL engine, a cut prompt ends the step: if (is_chunked && engine_type == EngineType::NORMAL) break;. The step holds r0 whole and the first 16,379 tokens of r1, so 32,768 tokens in all.
Pass 1 flagged "a cut prompt ends the step" as the rule for one engine type, NORMAL. A prefill-only PROMPT engine keeps looking, though its budget is usually spent by then.
The scheduler reserves every block of a prompt on its first chunk codecsrc/engine/block_manager.hpp:206-214. So a half-done prompt never waits for blocks for its second half.
Post-process this step at once if it is a prompt step or async scheduling is off. Otherwise keep it for next time codedeepinfer/worker/worker.py:182-187.
This is a prompt step, so step 4 happens at once. Thread 5 explains why decode steps wait.
After the forward pass over all 32,768 tokens, the sampler picks one row per prefill sequence, the last token of its chunk codecsrc/engine/prepare_sample.cpp:197-198. Even a non-final chunk gets sampled. A translated comment: "every prefill chunk must push its last token through sampling. The results of non-final chunks are thrown away afterwards" codecsrc/engine/prepare_sample.cpp:173.
r0 now has num_tokens >= len, so its prefill is done. It joins running, gets its first token appended and has the stop rules checked codecsrc/engine/scheduler.hpp:937-990.
The prompt step reset decode_step to 0, so the next step is an ALL point again. I expect the vote to pass again, because the other ranks have 10 to 40 prompts each still waiting inference.
r1 already has a block table, so the scheduler skips the allocation checks. Its work is 16,389 − 16,379 = 10 tokens, and it fits whole. r1 becomes RUNNING and gets an MTP info slot codecsrc/engine/scheduler.hpp:522-526. seqlen_k = 16,379 + 10 = 16,389.
r2, the one-image prompt, fits whole once its image is ready. It needs 16,389 of the 32,758 tokens left.
r3, the three-image prompt, gets cut to the rest of the budget. The scheduler moves the cut back if it would split an image codecsrc/engine/scheduler.hpp:488-493, :217-262.
One more prefill step finishes r3. Rank 0 then has four running requests, the max_num_seqs=4 limit. When the scheduler reaches r4, num_curr_seqs + 1 > max_num_prompts, so it stops codecsrc/engine/scheduler.hpp:440-442.
With ignore_eos and 4,096 output tokens for everyone, I expect r4 to wait until one of the first four finishes inference. This is continuous batching: a slot opens only when a request leaves.
Rank 0 still "wants" prefill at every ALL point, because r4 sits schedulable at the head of waiting. But _schedule_prefill returns an empty batch, because the slots are full. A second vote catches this codecsrc/engine/scheduler.hpp:328-335. If no rank scheduled anything, the group goes back to decode. If another rank did, rank 0 runs an empty prompt step alongside codecsrc/engine/scheduler.hpp:339-340.
Decode steps until the end
How Sequence r1 changes up to its first decode step, which Thread 5 covers:
Moment
num_tokens
tokens in input.tokens
block_table size
total_inflight_tokens
status
after add_request
0
16,389
0
0
WAITING
prefill step 1 scheduled
0
16,389
129
6
WAITING
prefill step 1 processed
16,379
16,389
129
0
WAITING, front of queue
prefill step 2 scheduled
16,379
16,389
129
6
RUNNING
prefill step 2 processed
16,389
16,390
129
0
RUNNING, in running
first decode step scheduled
16,389
16,390
129
6
RUNNING
The block count is ceil((len + inflight + drafts) / 128)codecsrc/engine/sequence.hpp:291-292. It reaches 130 once that sum passes 16,512. With 6 in flight and 5 drafts, that is at about len = 16,502, roughly 112 output tokens in inference. _append_slot then adds one block codecsrc/engine/scheduler.hpp:817-821.
Each step() returns raw pointers to pooled RequestOutput objects. _run_engine clones each into a Python-owned copy, because the pool reuses the original next step codedeepinfer/entrypoints/llm.py:179-183. Outputs are grouped by request id. When no rank has work left, the loop calls flush_inflight_reqs() once to collect the step still in flight codedeepinfer/entrypoints/llm.py:178.
For r1, generate returns a list of 4,096 RequestOutput objects. The example sets decode_sequence=False, so the engine never turns tokens into text codeexamples/_config.py:121. The script decodes them itself codeexamples/offline_inference.py:78.
Each RequestOutput holds one token id with its logprobs codecsrc/engine/outputs.hpp:18-40. Detokenising and streaming belong to DeepSeek Recipe, a separate repo doc.
The state machine
Request states
A request waits, runs, and ends in a finished state. Running out of blocks in recompute mode sends it back to the waiting queue.
A chunked request goes back to the front of the queue. When the head of the queue can't be scheduled, the whole queue waits behind it.
Prefilled, then handed off through a hook that is empty in the release
COMPLETION
Always decode
Arrives through the prompted queue with its KV already present
One scheduler serves all three roles, and P/D disaggregation is the same code with the vote switched off. The handoff itself is described, not in the code. The hooks send_processed_prompts and pe_do_save_kvcache are empty methods codedeepinfer/engine/llm_engine.py:800-858.
A request made by fork_seq enters waiting with cached state. It shares its parent's blocks and starts with the parent's KV already counted codecsrc/engine/scheduler.hpp:1061-1122.
Why is the scheduler written in C++ when the rest of the engine is Python?
Answer
_schedule releases Python’s interpreter lock (GIL) so other Python threads can run while it works codecsrc/engine/scheduler.hpp:276.
A comment identifies the remaining Python work: "TODO(CPU): Eliminate O(num_reqs) python operations in decoding step" codedeepinfer/engine/llm_engine.py:931.
My reading: async scheduling only hides CPU work that is shorter than GPU work. At Wide-EP decode a rank may run 60 or more requests with 6 rows each, in steps of about 13 ms doc. Python loops over every request, every step, would use too much of that time inference.
Memory and the KV cache
r1 holds 129 paged blocks of 114,048 bytes for long-range KV, plus one fixed state slot for all its sliding-window rings.
One block holds 128 token positions of long-range KV and indexer keys for owner layers 2, 8, 14 and 20. C2 merges pairs of positions into one entry. C1 keeps one entry per position. B means bytes. The cache engine lays them out like this codedeepinfer/worker/cache_engine.py:424-483. The byte counts are my arithmetic from the formats in the code inference.
One block covers 128 token positions in 114,048 bytes.
Offset
Contents
Bytes
0
Layer 2 indexer K (C2: 64 entries × 68 B)
4,352
4,352
Layer 2 main KV (C2: 64 entries × 288 B)
18,432
22,784
Layer 8 indexer K and main KV (C2)
22,784
45,568
Layer 14 indexer K and main KV (C2)
22,784
68,352
Layer 20 indexer K (C1: 128 entries × 68 B)
8,704
77,056
Layer 20 main KV (C1: 128 entries × 288 B)
36,864
113,920
Padding to a multiple of both 128 and 288: 1,152 bytes
Your turn. How many bytes of paged KV does r1's prompt hold, and how many bytes is that per token?
Answer
129 × 114,048 = 14,712,192 bytes, about 14.7 MB. Per token of capacity that is 114,048 / 128 = 891 bytes. The last block is only 5/128 full, so per prompt token it comes out at 14,712,192 / 16,389 = 898 bytes.
Each sequence's active state slot has a fixed size, about 3.1 MB. This is separate from its paged blocks. Memory profiling also reserves copy capacity by budgeting the state pool twice codedeepinfer/worker/base.py:241-244.
Region
Contents
Bytes
Stored
Window rings for layers 0 to 19
20 × 134 × 528 = 1,415,040
Dropped
Window rings for layers 20 to 39 and the 3 draft layers
23 × 134 × 528 = 1,627,296
Dropped
C2 compression state for layers 2, 8 and 14
3 × 7 × 2 × 512 × 4 = 86,016
The code names the two regions codedeepinfer/worker/cache_engine.py:229-234. The production store would keep the stored region across requests. The Q&A doc gives the same 1,415,040 bytes for it doc. The dropped region is never stored, because prefill can rebuild it. The byte totals are my arithmetic inference.
Window rings of layers 20 to 39 and drafts, C2 partial sums
No
No, rebuilt by prefill
Blocks hold the growing history. Rings hold the fixed recent window. The production store saves the region that would cost more to rebuild.
A near-miss and a misconception
The prefill code sets a flag called prefix_caching_hit = truecodecsrc/engine/scheduler.hpp:373-375. It looks like a prefix-cache hit, but it isn't. The scheduler sets it for every brand-new sequence, and its only use is to skip one assert.
The block manager has a free list with reference counts. It has no vLLM-style prefix cache where new requests can find hashed blocks. csrc/engine/block_manager.hpp has no hash, no lookup and no eviction. The only sharing in the release is fork_seq, where a caller names the parent codecsrc/engine/scheduler.hpp:1061. Production prefix hits come from the external store, which the Entrypoint matches by a hash chain. That is described, not in the code doc.
The model
In r1’s first decode step, layers 0 and 1 read only their windows. Later layers also read up to 512 long-range entries selected from one of four owner layers.
What goes in
r1's first decode step has 6 rows. Row 0 is the first output token, at position 16,389. Rows 1 to 5 are draft rows, but there are no real drafts yet. After prefill the engine pads the draft list with a placeholder token codedeepinfer/engine/llm_engine.py:706-711. Thread 5 explains what happens to those rows.
Layer 2 merges each pair of tokens into one long-range entry. The prompt's last token, at even position 16,388, had no partner yet. So during prefill it waited in the compression state, ca_statecodedeepinfer/worker/cache_engine.py:463-465.
Row 0 now brings position 16,389. The decode metadata builder writes a new compressed entry only when (orig_pos + 1) % 2 == 0, which holds for 16,389 codecsrc/engine/input_metadata/prepare_tensor_input_v4.cpp:80-90. The new entry is number 16,389 / 2 = 8,194, rounded down. A C2 layer packs 64 entries per block. So entry 8,194 goes in block-table entry 8,194 / 64 = 128, the 129th block, at offset 8,194 − 8,192 = 2.
In C1 layer 20, position 16,389 becomes entry 16,389, in block-table entry 16,389 / 128 = 128, at offset 16,389 − 16,384 = 5.
Layer 20 is the only layer from 20 to 39 that writes long-range KV. That KV comes from its own input, which is the first half's output after the residual blend and the norm codedeepinfer/model_executor/models/v4_modules/attn.py:465-479. Layers 21 to 39 read it but keep their own window rings.
Layer 20 also picks 2,048 candidate blocks of 8 entries each, so 16,384 entries. Layers 24, 28, 32 and 36 then score only inside those candidates codedeepinfer/model_executor/models/v4_modules/index_attn.py:204-226, :308-325. For r1, at 16,390 entries, that removes almost nothing. At a million tokens it would skip about 98% inference from the numbers.
Pass 1 did this with a toy. These are the real owner lists from the config. KV owners are layers 2, 8, 14 and 20. Indexer owners are layers 2, 8, 14, 20, 24, 28, 32 and 36. Each layer uses the nearest owner at or below it.
Layer 5 reads KV from layer 2, and top-k from layer 2.
Layer 22 reads KV from layer 20, and top-k from layer 20.
Your turn. Layer 27 reads KV from which layer, and top-k from which?
Answer
KV from layer 20, because 20 is the nearest KV owner at or below 27. Top-k from layer 24, because 24 is the nearest indexer owner at or below 27. Layer 24 itself scores only inside layer 20's 2,048 candidate blocks.
Text version
Layers 0 and 1 read only their window.
Layers 2, 8, 14 and 20 write long-range KV. Each other layer reads the nearest owner at or below it. Layers 3 to 7 read layer 2. Layers 21 to 39 read layer 20.
Layers 2, 8, 14, 20, 24, 28, 32 and 36 run an indexer. Layers 24 to 36 score only inside layer 20's candidate blocks.
Layers 0 to 19 are the first half, and their window rings sit in the stored region of the state slot. Layers 20 to 39 are the second half, and their rings sit in the dropped region.
Layers 37, 38 and 39 save their attention input for the draft model.
What attention reads, for row 0
In layer 2, row 0 attends over two sets in one softmax, plus a learned sink.
Layer 20's C1 KV looks like "no compression, so the same as the window". It isn't. It comes from a different projection, compressor.wkv, not attn.wkv, and the indexer picks from it codedeepinfer/model_executor/models/v4_modules/mla_attn.py:63-97.
Why does the second half share one long-range KV instead of building its own in each layer?
Answer
Memory and transfer cost. One shared KV for 20 layers keeps the block at 114,048 bytes. If every C2 and C1 layer owned its KV, a block would be about 11.6 times bigger inference from the layout above. The Q&A doc ties the small KV to two goals: keeping KV for days on SSD, and sending it between data centres doc. Pass 4 weighs the cost to quality, which the release does not measure.
Spreading work across chips
In each MoE layer, rank 0 sends each of its 24 rows to 6 experts. One fused kernel exchanges work with 7 GPUs.
One MoE layer at EP8
Rank 0's decode step has 4 requests × 6 rows = 24 rows. Each row goes to 6 routed experts plus the shared one.
One MoE layer at EP8
All 8 ranks join matching expert exchanges over NVLink. Their local row counts can differ.
Per rank per layer, 24 rows × 6 experts = 144 (row, expert) pairs. If all eight ranks have 24 valid rows, that is 1,152 pairs over 416 slots, about 2.8 pairs per slot inference. Partially filled ranks and placeholder drafts reduce the routed work. Thread 4 in Pass 4 shows why this is the main argument for Wide-EP.
A CUDA graph is a recorded run with fixed shapes. At start-up the engine records one per row count. The counts are powers of two up to an interval, then steps of that interval, up to the maximum codedeepinfer/worker/cuda_graph_runner.py:41-51.
select(total_q) picks the smallest graph at least as big as total_qcodedeepinfer/worker/cuda_graph_runner.py:143-148. 24 rows fit the 24 graph exactly, with no padding. The graph counts rows, not requests, because with varlen on, two batches of 4 requests can have very different row counts.
What the placeholder drafts do
Rows 1 to 5 carry the placeholder token, and the sampler checks them as drafts. Their draft probabilities come from slot 0 of the draft pool. That slot is "pre-filled so its draft log-prob puts all mass on the pad id" codedeepinfer/model_executor/layers/mtp_info_pool.py:47-52. The main model gives the pad token almost no chance. With force top-k at 4,096, the pad token is almost surely outside the kept set inference. Its target logprob is then minus infinity, and the sampler rejects it codedeepinfer/model_executor/layers/tilelang_ops/cuda/fused_sample.py:383-391.
The worker queues the next forward pass before the CPU waits for sample(N). GPU work can then overlap CPU post-processing. If preparing and launching the next step takes too long, the GPU can still wait inference from this ordering.
What the CPU doesn't know, and how the GPU fixes it
schedule(N+1) runs before process_outputs(N). So the scheduler doesn't know N's sampled token, N's new drafts, or how many drafts N accepted. It counts step N as fully accepted, by adding 6 to total_inflight_tokenscodecsrc/engine/scheduler.hpp:697, :726. The block count also reserves room for that codecsrc/engine/sequence.hpp:291-292.
A translated docstring: "under ZOS the CPU-side seqlens_q and seqlens_k lag one step. When the batch overlaps the previous step's batch, they must be fixed on the GPU from the previous step's sampling results" codedeepinfer/worker/worker.py:197.
After the step, process_outputs appends the accepted drafts and the one sampled token. Then it reduces num_tokens to the accepted count. A translated comment: "decode: backfill cache.num_tokens from the optimistic value to the number actually accepted, so unaccepted drafts don't occupy KV" codecsrc/engine/scheduler.hpp:997-1000.
Three ways to run the same decode step
Setting
When the CPU learns step N's tokens
How many drafts r1 checks
Who picks that number
ZOS off
Before it schedules N+1
5
Fixed
ZOS on, varlen off (our run)
After it launches forward(N+1)
5
Fixed
ZOS on, varlen on
After it launches forward(N+1)
0 to 5
CPU fixes the total, GPU splits it
The CPU must fix every shape before launch. The GPU supplies any input values the CPU does not know yet.
A placeholder step and graph padding both add fake rows, but they have different jobs. A placeholder step lets an idle rank join the collectives. Padding makes a batch fit a recorded graph size codedeepinfer/worker/model_runner.py:187.
Why does sampling for N+1 wait until after post-processing N, when the forward pass didn't?
Answer
Sampling needs the grammar state for structured output and the draft pool slot. It also needs the last output token and the row count the GPU chose. CPU post-processing of N supplies those values. The GPU supplies the forward pass’s inputs, so it can run before the CPU reads the previous results. The code refreshes those facts just before it launches sampling codecsrc/engine/sequence.hpp:408-418, deepinfer/worker/worker.py:150-152.
Getting the right answer every time
For r1, the sampler applies a fixed sequence of operations to the logits. Seed 1 and the position determine each draw.
For a prefill row, the position is seqlen_k, the index of the token being made codecsrc/engine/prepare_sample.cpp:198. For r1's first token, seqlen_k = 16,389. So the stream is 3 × 16,389 + 0 = 49,167 with seed 1.
For a decode row, the first row's position is seqlen_k − seqlen_q + 1codecsrc/engine/prepare_sample.cpp:206. In r1's first decode step that is 16,395 − 6 + 1 = 16,390, the index of the token row 0 makes.
The batch row is not in the key, so r1 gets the same draw first or fourth in the batch. The code's contract: "Deterministic in (seq_seeds, sample_positions, is_nextn_predict): independent of batch size and row order" codedeepinfer/model_executor/layers/tilelang_ops/random_sampling.py:19-20.
Take the target logprob t of the draft token and the draft's own logprob d.
The accept logprob is min(t − d, 0). A draft outside the kept top-k set has t = −∞, so it always fails.
Draw a uniform number from stream 3 × position + 2. Accept if it is below exp(min(t − d, 0)).
Your turn. Say t = log 0.3 and d = log 0.6, and the draw is 0.42. Does the draft pass? If not, what does the kernel sample from next?
Answer
t − d = log(0.3 / 0.6) = log 0.5, so the accept chance is 0.5. The draw 0.42 is below 0.5, so the draft passes. With a draw of 0.6, it would fail. The kernel would then draw from the leftover weights max(target − draft × e^(−0.00005), 0), using stream 3 × position + 1. The tiny factor stops the leftover from rounding to all zeros. The code's comment: it "prevents reject sampling from failing to sample anything" codedeepinfer/model_executor/layers/tilelang_ops/common.py:53.
The matrix-multiply libraries switch to deterministic algorithms.
FlashMLA's decode attention runs in batch-invariant mode.
The engine switches off a fused prefill attention kernel on some layers, so prefill takes the same path as decode.
With EPLB on, the MoE combine sums in one fixed order.
The kernel libraries were empty when I wrote this pass, so I could not inspect how those switches worked. I guessed they fix how GPU blocks divide a long sum. Pass 4 opens the kernels and confirms it, with one twist: for V4.1's attention shape the split was already off.
DeepInfer returns logprobs after steps 3 to 8 above, then renormalises them codedeepinfer/model_executor/layers/tilelang_ops/cuda/fused_sample.py:369, :424-434. For r1's first token the end token's logprob is minus infinity, because step 4 banned it. Perplexity measures the probabilities assigned to a reference sequence. Here it measures the processed distribution. It is not raw-model perplexity unless temperature, filtering and masks leave that distribution unchanged. Matching settings makes regression comparisons meaningful, as in the offline test.
Mixed exercises
Name the thread before you work each one out.
Rank 3 in our run gets the five prompts four times, so 20 prompts. With the same limits, how many prompts can be running at once, and how many 16,389-token prompts fit whole in one prefill step?
Answer
Threads 1 and 4. At most 4 running, because max_num_seqs=4 applies per engine. One prefill step fits one whole prompt of 16,389 plus a 16,379-token chunk of the next, so only one whole prompt. Rank 3 waits much longer than rank 0 for its last prompts. In production the load balancer would avoid that, but here the script fixes the split.
A request has a 1,000-token prompt and produced 23 tokens so far. It is in flight with 6 tokens and has 5 drafts. How many blocks does it hold?
Answer
Thread 2. ceil((1,023 + 6 + 5) / 128) = ceil(1,034 / 128) = ceil(8.08) = 9 blocks, from csrc/engine/sequence.hpp:291-292. Without the in-flight and draft room it would need 8.
In layer 2, row 3 of r1's decode step sits at position 16,392. Does it write a new compressed entry? What about position 16,393?
Answer
Thread 3. Layer 2 writes when (pos + 1) % 2 == 0. 16,393 % 2 = 1, so 16,392 does not write. It waits in the compression state. 16,394 % 2 = 0, so 16,393 does write, merging 16,392 and 16,393 into entry 8,196. Rows that turn out to be rejected drafts still wrote. The next step's writes start after the accepted prefix and replace them.
With max_num_seqs=4 and nextn 5, what CUDA graph sizes exist with the default interval? If one request finishes and the batch drops to 3 requests, which graph runs, and how much padding does it carry?
Answer
Thread 5. Sizes 1, 2, 4, 8, 16 and 24. Three requests × 6 rows = 18 rows. The smallest graph at least that big is 24, so it carries 6 padding rows, which the gate masks out of MoE routing.
r1's fifth decode row of some step sits at position P + 4, where P is its first row's position. Write its random stream number for the accept coin. Then say why it doesn't clash with the fresh-sample stream of position P + 4 in another step.
Answer
Thread 6. The row's key position is P + 4 × 10,000,019, so the coin stream is 3 × (P + 40,000,076) + 2. A fresh sample at the plain position P + 4 uses stream 3 × (P + 4). The large multiplier places verification rows beyond any real token position, so the streams do not overlap.
During a decode step, rank 2 finishes all its requests. In the next step it has nothing to decode. Name what rank 2 still does, and the line of code that makes it.
Answer
Threads 1 and 4. It runs a placeholder step, one fake row with block 0. The engine passes scheduler_outputs.is_empty() as is_placeholdercodedeepinfer/engine/llm_engine.py:972, and the worker builds the fake sequence codedeepinfer/worker/worker.py:125-127. The fake row joins the MoE exchange but routes to no expert. It also keeps taking part in the step-type vote.
Async scheduling is on. The CPU schedules N+1 and counts N as fully accepted. Step N actually accepted 2 of 5 drafts. By how much is the CPU's seqlen_k for N+1 too big, and which kernel fixes it?
Pass 2 followed one request through the engine. This pass covers aborts, memory shortages, rejected drafts and overloaded experts. It also checks what happens when a prompt runs in different batches. For each case, I trace the code and identify its test, if one exists.
In two places I ran one pure-Python function from the repo on my CPU. I ran nothing else.
Recall first
In Pass 2’s worked trace, why was decode step 70 the earliest next ALL opportunity for prefill?
Answer
Every prompt carried an empty image dict. The multimodal processor marks requests schedulable only in fetch(), which runs after schedule() in each step(). So the first step was an empty decode step, decode_step became 1, and the next ALL vote point was 70.
r1's prompt had 16,389 tokens. How many blocks did it get, and when?
Answer
129 blocks, all on its first chunk. The scheduler reserves every block of a prompt before it processes any of it.
Under async scheduling, how far ahead of the GPU can the CPU get?
Answer
One step. The worker keeps a single prev_step_state, and every prompt step is post-processed at once, which drains it.
What does the sampler use as the random key for a draw, and what does it leave out?
Answer
The request's seed and a stream number, 3 × position + case. It leaves out the request's row in the batch, so the batch layout can't change the draw.
Which four layers own long-range KV, and which layer's KV does layer 31 read?
Answer
Layers 2, 8, 14 and 20. Layer 31 reads layer 20, the nearest owner at or below it.
When the request doesn't go to plan
An abort only changes a request’s status. Its queue determines which resources the engine frees later.
A 50,000-token prompt aborted between its chunks
A NORMAL engine has max_num_batched_tokens=32768, chunked prefill on, and one 50,000-token prompt called P. Nothing else waits.
After step k.num_tokens = 32,768 < 50,000, so P goes back to the front of waiting. It is still WAITING and still holds 391 blocks codecsrc/engine/scheduler.hpp:1011-1019.
The abort.abort_request("P") calls Scheduler::abort_sequence. It scans running and waiting, finds P, sets FINISHED_ABORTED and returns codecsrc/engine/scheduler.hpp:144-163.
So I think an abort between chunks leaks the blocks and the state slot until the engine restarts inference. A comment suggests why this path may be uncommon: "We do NOT abort in PE" codecsrc/engine/scheduler.hpp:150-151. The release calls abort_request only for failed images, and those requests never got blocks codedeepinfer/engine/llm_engine.py:942-945. No test covers abort.
Where the request sat when the abort came
Where P was
What abort_sequence does
What gets freed, and when
Output
waiting, never scheduled
Marks it
Nothing to free
One output, reason "abort", token = last prompt token
waiting, between chunks
Marks it
Nothing, as far as I can see. Blocks leak inference
Same as above
running
Marks it
Blocks freed at the next decode schedule, through finished_seqs
None
prompted, a handed-over decode request
Not scanned
Picked up at admission as finished
None
process_outputs skips an aborted running request codecsrc/engine/scheduler.hpp:905-915, and free_finished_seqs moves it to finished_seqscodecsrc/engine/scheduler.hpp:562. build_request_outputs walks only scheduled_seqs and ignored_seqs, so no output says "abort" codecsrc/engine/outputs.hpp:94-303. An online server has to report the abort itself. Pass 5 asks you to design that.
In the first two rows the output token is get_last_token_id(). If nothing was generated, that is the last prompt token codecsrc/engine/outputs.hpp:297-298. A client that ignores the finish reason would show it as model output.
An abort is only a mark, so each queue that can hold the request needs its own cleanup path.
abort_request only changes the request’s status codecsrc/engine/scheduler.hpp:153. A later schedule frees memory only if the request’s queue has a cleanup path.
A request finishes while async scheduling has it in flight
Request Q is in a decode batch with ZOS on. At step N, Q samples the end token, but the CPU doesn't know that when it schedules N+1. The timeline is my reading of the code.
A request that finishes while in flight
Q drew the end token at step N, but the CPU had already planned N+1 with Q in it. Q runs one extra step, and its blocks come back a step later.
sample(N+1) keeps the slot it captured. The comment: "Keep the slot captured for an in-flight sample after ZOS releases the request" codecsrc/engine/sequence.hpp:411-414.
When the sampler emits a stop token, it zeroes Q's next draft confidences, so the wasted step takes no verify budget. Translated: "the request hit EOS, set the confidence of all its next-round draft tokens to 0" codedeepinfer/model_executor/layers/sampler.py:252-254.
Q's blocks come back in pre_sampler_sync of step N+2, after forward(N+2) is queued codedeepinfer/engine/llm_engine.py:844-846. A new owner writes them only in stream order after N+1 inference.
DeferredRelease holds a step's pooled objects one round longer. Translated: "release a step's resources one round late, so the caller has finished reading them" codedeepinfer/engine/llm_engine.py:67-71.
A finishing request can remain in one extra forward step. Fixed-5 verification uses six rows. Variable-length verification can reduce its draft allocation inference.
Why does the scheduler reserve all 391 blocks of P on the first chunk, when only 256 hold data after it?
Answer
This makes admission all or nothing. If P held only 256 blocks, its second chunk could find the cache full and wait for 135 more. Prompts that hold blocks while they wait for more can deadlock. Reserving every block at admission means a partially processed prompt never waits for more memory inference. The comment on allocate_seq only says "the caller must itself guarantee that enough resources exist" codecsrc/engine/block_manager.hpp:206-214.
When memory runs out
Prefill waits for free blocks. Decode can't wait, so it takes blocks from the running request with the longest output.
Four running requests and an empty free list
A rank has four running requests and no free blocks. Outputs so far: A 3,000 tokens, B 1,200, C 400, D 50. _schedule_decode does this codecsrc/engine/scheduler.hpp:622-646.
l == r, so the loop ends. D, C and B run. A is out.
Step 3 preempts whenever the free list is empty, even if D needs no new block codecsrc/engine/scheduler.hpp:630. I think this happens in practice inference. The victim has generated the most output so far. It may still be far from finishing.
The function's header comment says the opposite. Translated: "pick this round's decode seqs by LCFS, and when blocks are short, preempt the seq with the shortest output" codecsrc/engine/scheduler.hpp:556-557. The sort is descending and victims come from the front, so the code takes the longest. The finish-reason comment agrees with the code. Translated: "if the KV cache is about to overflow, we forcibly stop the task that occupies the most KV cache" codecsrc/engine/sequence.hpp:55-56. This explanation follows the code.
Recompute has two hidden costs. A's re-prefill waits for the next vote point and for the cache to drop to 90% codecsrc/engine/scheduler.hpp:313. With YOCO truncation on, the rerun can also give a different result. A comment allows it: "with yoco truncation enabled, result changes caused by preemption are allowed" codedeepinfer/engine/llm_engine.py:888.
SWAPPED is in the status enum but nothing sets it codecsrc/engine/sequence.hpp:33. There is no swap to host memory, and the Q&A doc confirms decode doesn't offload KV doc.
The engine preempts the request with the most output, regardless of the header comment. I think the aim is to free the most memory per victim inference, at the cost of the most lost work.
Why stop the victim by default, instead of recomputing it?
Answer
Production decode engines have no prefill channel, so they must stop codecsrc/engine/scheduler.hpp:101-104. Recompute is also unfair to the victim. It waits behind new arrivals for a vote point, then pays for a long prefill of prompt plus output. A client can retry and get its own cache hit. This reasoning is mine, not the Q&A doc's inference.
A prefix hit with a missing window
This case is described, not in the code. The production Entrypoint matches a new prompt against the KV store before routing doc. These are the Q&A doc's hit rules for a prompt of L = 10,000 tokens.
The cap. The hit is at most L − 134 = 9,866 tokens. The 134 is the 128-token window plus 6 for draft rollback doc.
Block KV. The store holds whole 128-token blocks, so the block hit is 77 × 128 = 9,856 doc.
The window. Window state exists only at multiples of 128. If one exists between 9,728 and 9,856, the hit is 9,856. If not, back off one block to 9,728 doc.
The replay. On a miss, prefill starts at 9,728 and reuses the block KV for 9,728 to 9,855. Those 128 tokens rerun only to rebuild the window, with queries limited to keys from 9,728 on doc.
A missing window costs prefill on 272 tokens instead of 144. It adds 128 tokens, regardless of prompt length doc.
Also described, not in the code. The production store keeps window state only at multiples of 128 doc. A decode step adds up to 6 tokens, so it can cross a multiple of 128 mid-step.
Say the boundary is b. The window ending at b needs positions b − 128 to b − 1. The step might reach b + 5 before the engine copies the ring. The ring then holds the latest 134 positions, b − 128 to b + 5, so the oldest needed position just survives. That fits the Q&A doc's claim that an overshoot of up to 5 tokens fits in the 6 extra slots doc. The arithmetic is mine inference.
A new prompt can wait for memory. Decode frees memory from another request so the remaining requests can continue.
When prefill takes a shortcut
With YOCO truncation, layers 20 to 39 run only the last prompt tokens. That saves work but changes the answer slightly.
A 1,000-token prompt with compact truncation
Set DEEPINFER_YOCO_PREFILL_TRUNCATION_MODE=compact and DEEPINFER_YOCO_PREFILL_KEEP_TOKENS=140, the README's settings codeREADME.md:25. A 1,000-token prompt arrives in one chunk.
The code requires KEEP_TOKENS >= window_size + max_backtracking_tokens, which is 134 codecsrc/engine/input_metadata/prepare_tensor_input_v4.cpp:354-357. The translated comment: "after rollback there must still be window-size tokens". So the Q&A doc's "the last 128 tokens" is the idea, and 134 is the real minimum.
The first decode query, at position 1,000, needs keys 873 to 1,000 in each second-half window. Those entries exist, but they came from rows that saw clipped windows. So the window KV is approximate, and so is the output. The Q&A doc calls it "an approximate SWA KV cache" doc.
Rows in layers 20 to 39 for the 1,000-token prompt
Window limit in layers 20 to 39
Same output as off?
off, the default
1,000
Normal
Yes
mask
1,000
Clipped at 860
No, but the same as compact
compact
140
Clipped at 860
No
The modes are documented at deepinfer/environ.py:188-194. mask computes every row so a test can compare it with compact. examples/yoco/yoco_equiv.py does that, but never compares with off codeexamples/yoco/README.md. No test in the release measures how far compact drifts from off, which needs a GPU.
If a cheaper prefill changes what the second half sees, it changes the answer. Comparing compact and mask modes checks whether they agree. It does not check whether they match truncation off.
YOCO truncation is approximate. Prompt logprobs are therefore off, and preemption may change results. The Q&A doc says it "nearly halves total prefill computation" doc. Pass 4 asks what that is worth.
Can one token appear twice in a softmax?
This is an open question, not a known edge case. In layers 20 to 39, the long-range KV has an entry for every token, including the last 128. The window covers those 128 too. So a query could see token t twice in one softmax. It sees it through the window, and through the C1 entry if the indexer picks it.
The two vectors come from different projections, attn.wkv and compressor.wkv, so they differ codedeepinfer/model_executor/models/v4_modules/mla_attn.py:63-97. I found no step that removes the overlap. The arithmetic-work estimator counts window and top-k keys separately codetests/engine/test_flops_estimator.py:82-89. My guess is that it's by design and the model learned to use both inference. Logging the indices on a real run would settle it.
Why must KEEP_TOKENS be at least 134 and not just 128?
Answer
Decode can roll back up to 6 positions after rejected drafts. The next query still needs a full 128-token window, built from rows the second half ran. So the kept rows must cover 128 plus 6 codecsrc/engine/input_metadata/prepare_tensor_input_v4.cpp:354-357. The same 134 sets the Q&A doc's hit cap of L − 134 doc.
When one expert gets too much
EPLB gives hot experts extra copies, then places the copies to reduce the busiest GPU’s load. Both stages are greedy: they choose the best next move without searching every complete layout.
Setup: 5 logical experts, 2 GPUs, 3 slots per GPU, so 6 physical slots and 1 redundant copy. The window has 2 steps.
Tokens
e0
e1
e2
e3
e4
Total
Step 0
90
20
20
40
30
200
Step 1
90
40
40
10
20
200
The starting layout. Slots 0 to 4 hold experts 0 to 4. The spare slot copies expert 0, because start-up copies go to experts in index order codedeepinfer/model_executor/eplb/__init__.py:31-39. GPU0 = {e0, e1, e2}, GPU1 = {e3, e4, e0}. e0's tokens split in half.
GPU0
GPU1
Step 0
45 + 20 + 20 = 85
40 + 30 + 45 = 115
Step 1
45 + 40 + 40 = 125
10 + 20 + 45 = 75
moe_lb = (115 + 125) / (100 + 100) = 1.20
Stage A: who gets the copy. For each candidate expert, calculate the result of adding one copy. Find the hottest single copy of any expert at each step, and sum over the steps. The candidate with the smallest sum wins codedeepinfer/model_executor/eplb/policy.py:105-123.
Copy e0: each e0 copy carries 45, so the hottest copy is 45 at both steps. Sum 90.
Copy anything else: e0 still carries 90 at both steps. Sum 180.
So e0 gets the copy, which the starting layout already gave it.
Stage B: where each copy goes. Sort the copies by load per copy, heaviest first: e0, e0, e1, e2, e3, e4. Ties keep slot order codedeepinfer/model_executor/eplb/policy.py:31-35. A copy's load per step is its expert's tokens divided by its copy count.
For example, e2 on GPU1 leaves both GPUs at (65, 85). The peaks are 65 and 85, so the score is 150.
GPU0 = {e0, e1, e3}
GPU1 = {e0, e2, e4}
Step 0
105
95
Step 1
95
105
moe_lb = (105 + 105) / 200 = 1.05
e1 and e2 are both busy at step 1, so the policy split them. It paired e1 with e3, which is busy at step 0. Scoring each step's peak pairs experts whose busy steps don't overlap. The test file calls this "load complementarity across batches" codetests/models/test_eplb_policy.py:112.
When greedy makes it worse
Same setup, different tokens.
Tokens
e0
e1
e2
e3
e4
Step 0
80
30
40
40
10
Step 1
10
30
40
30
40
The starting layout {e0, e1, e2} and {e3, e4, e0} gives moe_lb 1.057. The real policy, run on my CPU, returns {e2, e4, e0} and {e3, e1, e0}, which gives 1.114. It made the balance worse on the very window it planned from.
The tests check that the maps are consistent, that the policy is deterministic, and some hand-built cases codetests/models/test_eplb_policy.py:14-117. None checks that the result beats the input.
EPLB’s greedy heuristic can produce a worse balance than the layout it replaces. The tests do not establish how often this happens with production loads. Nothing in the code guards against it.
Copies only help if the gate splits the hot expert's tokens between them. The policy assumes an even split, since it divides by the copy count codedeepinfer/model_executor/eplb/policy.py:31, :37. The gate kernel does the split, using the logical-to-physical map codedeepinfer/model_executor/models/v4_modules/mlp.py:392-393. Pass 4, Thread 4 shows it splits by a fixed stride over the token's row in the batch. This spreads rows across copies without measuring their current loads. The split still depends on which rows choose the expert.
A GPU fails
The Q&A doc says a decode instance restarts on however many healthy GPUs it gets, and uses that as its EP size doc. The release isn't elastic, but the redundant-expert rounding handles odd sizes codedeepinfer/model_executor/eplb/__init__.py:21-28. Take EP63 with a lower bound of 63 copies.
384 + 63 = 447.
447 / 63 = 7 remainder 6, so the padding is 63 − 6 = 57.
Extra copies = 63 + 57 = 120. Total slots = 504 = 8 × 63.
Each surviving GPU holds 8 slots, up from 7. It needs about 14% more expert memory inference from the formula.
Why does stage B score the busiest GPU at each step, instead of balancing the total over the window?
Answer
Every MoE layer waits for its slowest GPU at every step. A layout with balanced totals can still have GPU0 hot at step 0 and GPU1 hot at step 1. Each step then runs at the hot GPU's speed. Scoring per-step peaks rewards layouts whose busy experts take turns. The translated comment states the goal: make "the sum over steps of the max over ranks" as small as possible codedeepinfer/model_executor/eplb/policy.py:43-45.
When the verifier says no
Rejected drafts have already written their KV. The engine moves its count back and lets later steps overwrite that KV.
The verifier rejects draft 3 of 5
Request R has 2,000 tokens, with KV for the first 1,999. Its newest token, at position 1,999, has no KV yet. The next decode step has 6 rows.
KV now counts as ready through position 2,001, including t1999, d1 and d2. The KV at 2,002 to 2,004 belongs to rejected drafts d3, d4 and d5. The engine leaves it in place. The next step's row 0 is t at position 2,002, which overwrites it, and later steps overwrite 2,003 and 2,004.
ring of 134: position 2004 writes slot 2004 mod 134 = 128, and covers position 2004 - 134 = 1870
next query: t at 2002 needs window 1875 .. 2002 -> 1870 is not needed, safe
ring of 128: position 2004 would write slot 2004 mod 128 = 84, and cover position 2004 - 128 = 1876
next query: t at 2002 needs window 1875 .. 2002 -> 1876 is needed, but now holds d5's KV
With a ring of 128, the rejected d5 would corrupt the very next query's window. With 134, a step can write 6 positions ahead without touching a slot a later window needs. The ring size is window_size + DEEPINFER_MAX_BACKTRACKING_TOKENS, and the second is at least nextn + 1 codedeepinfer/worker/cache_engine.py:88-93, deepinfer/environ.py:132. C2 compression state gets compress_ratio + (6 − 1) slots for the same reason codedeepinfer/worker/cache_engine.py:130-133.
The draft model's window cache uses the same rule. Translated: "the positions of rejected drafts are exactly where later draft writes go. They are overwritten before any read and need no masking" codedeepinfer/model_executor/models/mtp/dflash.py:204-209.
examples/benchmark.py sets DEEPINFER_MAX_BACKTRACKING_TOKENS=1codeexamples/benchmark.py:164. The engine clamps it to at least 6, so the setting has no effect with nextn 5 inference from deepinfer/environ.py:132.
A rejected draft’s KV can remain in place without affecting the next step. The count moves back, and the next writes cover it before anything reads it. The 6 spare ring slots make sure of that.
37 rows and a 40-row graph
With max_num_seqs=64 and nextn 5, the largest graph is 64 × 6 = 384 rows. With interval 8 the sizes are 1, 2, 4, 8, 16, 24, then every 8 up to 384 codedeepinfer/worker/cuda_graph_runner.py:41-51.
Three of the 40 rows, 7.5%, are padding. That is a share of rows, not a measured share of elapsed time. With interval 8, at most 7 rows are ever padded.
Choosing how many drafts to check, worked in full
This is the full example of varlen verification. Pass 4 leaves out a step, and Pass 5 leaves it all to you. I chose the numbers for this example. The algorithm is MTPVarlen::schedulecodecsrc/engine/mtp_varlen.hpp:150-267.
Four requests W, X, Y and Z, with nextn 3. The confidence head supplies these acceptance chances. Each value already includes the chance that all earlier drafts pass:
Request
Draft 1
Draft 2
Draft 3
W
0.95
0.90
0.80
X
0.85
0.60
0.30
Y
0.50
0.20
0.10
Z
0.30
0.10
0.05
The engine measures step time per graph size at start-up. Say it gives 8.1 ms for 4 rows, 8.6 ms for 8 and 12.0 ms for 16. Add 0.4 ms for the sampler. Sizes 1 and 2 don't matter here.
W .95, W .90, X .85, W .80, X .60, Y .50, X .30, Z .30, Y .20, Y .10, Z .10, Z .05
Start with 4 rows, one per request, and 4 expected tokens. A request's own row always yields a token codecsrc/engine/mtp_varlen.hpp:228-231.
Add drafts in order. After each, work out expected tokens per millisecond on the smallest graph that fits codecsrc/engine/mtp_varlen.hpp:235-258.
Rows
Expected tokens
Graph
Time, ms
Tokens per ms
4
4.00
4
8.5
0.471
5
4.95
8
9.0
0.550
6
5.85
8
9.0
0.650
7
6.70
8
9.0
0.744
8
7.50
8
9.0
0.833
9
8.10
16
12.4
0.653
12
9.20
16
12.4
0.742
16
9.65
16
12.4
0.778
Pick the best: 8 rows, so 4 extra rows.
Hand them out to the top 4 drafts: W, W, X, W. W checks 3 drafts, X checks 1, Y and Z check none codecsrc/engine/mtp_varlen.hpp:262-266.
Now change one number. Say the 16-row graph took 10.4 ms instead of 12.0. Then 16 rows give 9.65 / 10.8 = 0.894 tokens per ms, the best in the table. Every request would check all 3 drafts. I checked both versions with a short script.
Both the estimated cost curve and confidence determine the chosen length. Within that estimate, rows fitting the same graph share one time estimate, so the scan tends to fill graph sizes. Actual routed work can still change the runtime.
Why does the CPU use confidences from two rounds back to pick the total?
Answer
The translated comment gives the rule: "we must make sure confidence_{i+1..nextn} does not affect whether draft_token_i is verified". So the total uses round N−2's rates codecsrc/engine/mtp_varlen.hpp:160-168. Under async scheduling, round N−1's rates aren't on the CPU yet when it schedules round N. Using N−2 in both modes makes async and sync scheduling pick the same total inference. Those older confidences may no longer fit the next step. Pass 4 considers this as one possible cause of varlen’s loss at low concurrency.
When the same prompt must give the same answer
The batch-invariance test runs a prompt alone and in a batch of 64. It forces the same continuation and compares exposed tokens and logprobs exactly.
Assert that token ids, logprobs, top token ids and top logprobs are equal with Python ==codetests/models/test_batch_invariant.py:103-105. This checks those exposed values exactly, not every intermediate tensor or unforced stochastic generation.
Shape A, batch of 64
Shape B, alone
Prefill
About 6 prompts packed per 16,384-token step, with chunks cut at different places
Forcing the tokens removes the random choice, so the test compares numerical results. My reading of why: without forcing, one flipped token changes every later token. The test could not identify where the numerical results first differed inference.
The other 7 DP ranks run their own prompts at the same time. So even "alone" shares every MoE layer with other ranks' tokens, and the test covers that mixing too inference.
What makes it pass
DEEPINFER_BATCH_INVARIANT=1 flips the four switches Pass 2 listed. Two more pieces are invariant without the switch.
tests/models/test_kl.py compares step-by-step decode with a full prefill along the same tokens. Prompt logprob lists must be equal. For generated positions it checks a mean discrepancy with max_kl = 0.0codetests/models/test_kl.py:190-206. The helper evaluates mean(exp(d) - 1 - d) in double precision, where d is the logprob difference codedeepinfer/testing.py:4-11. Floating-point cancellation can make that expression zero for unequal inputs. This is strong regression evidence for the tested configuration, but it does not prove bitwise equality of all decode and prefill values. Batch-invariant mode also disables a fused attention path used by prefill on some layers codedeepinfer/model_executor/models/v4_modules/attn.py:87-92.
The case that breaks it: varlen on
With DEEPINFER_ENABLE_MTP_VARLEN on, I expect the test’s logits to match but unforced tokens to differ inference.
Varlen can target the same distribution, within the sampler's numerical approximations, without producing the same tokens. The README's recipe for repeatable output turns it off codeREADME.md:28. The evaluation script turns it on, so that recipe does not guarantee exact repeats for its runs codeeval/run_eval.py:120-121.
The grammar breaks. If the matcher can't fill a mask, the sampler marks only that request as failed. Translated: "raising would take down every request plus an NCCL barrier timeout. An SO bug kills only this request" codedeepinfer/model_executor/layers/sampler.py:377-386. The scheduler appends token 0 and sets FINISHED_SO_FAILEDcodecsrc/engine/scheduler.hpp:974-980. A client that ignores the finish reason sees a stray token 0.
Every token is blocked. If the grammar mask, the token blacklist and the first-token end ban together block every token, the row is all minus infinity. The sampling kernel then asserts "Invalid logits: contains NaN or +inf, or all entries are -inf" codedeepinfer/model_executor/layers/tilelang_ops/cuda/fused_sample.py:218. A device assert usually kills the whole process inference. I found no earlier check for this mix.
The draft model ignores the grammar. The verifier rejects forbidden drafts, because their masked target probability is 0 codedeepinfer/model_executor/layers/sampler.py:433-437. So I expect fewer drafts to pass under a strict grammar inference.
Sampling and verification
Pass 1 worked this in full and Pass 2 left a gap. Do this one alone.
Vocabulary of 5 tokens, logits 3.0, 2.0, 2.0, 0.0, −1.0. Temperature 1.0, top-p 0.8, top-k −1. The draw is 0.55. Which token does the sampler pick? Then: a draft token is token 2, with draft probability 0.5, and the accept coin draws 0.75. Does the draft pass?
Answer
e to each logit: 20.09, 7.39, 7.39, 1.00, 0.37. Sum 36.24. Probabilities: 0.554, 0.204, 0.204, 0.028, 0.010.
Top-p 0.8. Token 0 alone is 0.554. Adding one 0.204 gives 0.758, still under 0.8. Adding the second gives 0.962, enough. So the kept set is tokens 0, 1 and 2. The tie between tokens 1 and 2 doesn't matter, because the mass needs both. When part of a tied group would be enough, the kernel keeps the lower token ids first codedeepinfer/model_executor/layers/tilelang_ops/cuda/fused_sample.py:270-366.
Renormalise over 0.962: 0.576, 0.212, 0.212. Token 0 covers 0 to 0.576.
The draw 0.55 is below 0.576, so the sampler picks token 0.
The draft: token 2's target probability is 0.212, the draft's is 0.5. The accept chance is 0.212 / 0.5 = 0.424. The coin drew 0.75, above 0.424, so the draft fails.
DeepInfer turns temperature 0 into 0.00001. The sampler still draws, rather than always picking the top token codedeepinfer/sampling_params.py:76-79. Two tokens whose logits differ by 0.00001 get odds of about 73% and 27%. Without a seed, each request gets a random one codedeepinfer/sampling_params.py:86-90. That seed is fixed when the SamplingParams object is built, then deep-copied per request codedeepinfer/entrypoints/llm.py:151. So one shared object gives every request in a generate call the same seed. Ten identical prompts then give ten identical answers inference from those lines.
Why does the sampler turn probabilities into integers before it adds them up?
Answer
So the sum doesn't depend on the order threads add in. Pass 1 showed that float addition depends on order. The top-p and top-k kernels add probability mass with atomic adds from many threads at once codedeepinfer/model_executor/layers/tilelang_ops/cuda/fused_sample.py:119-125. With floats, two runs could cut the top-p set at different tokens. With integers, floor(p × 2³¹), the sum is exact and the cut is the same every time codedeepinfer/model_executor/layers/tilelang_ops/cuda/random_sampling.py:38-50. The cost is that a token with probability below 2⁻³¹ can never be drawn inference.
Mixed exercises
Each one mixes threads. Decide which idea applies first.
A NORMAL engine has max_num_batched_tokens=32768, max_num_seqs=4 and one request already running. Waiting are prompts of 20,000, 9,000, 6,000 and 3,000 tokens. Which tokens does the next prefill step process?
Answer
Thread 1. 20,000 fits whole, 12,768 left. 9,000 fits whole, 3,768 left. 6,000 doesn't fit, so its first 3,768 tokens go in, and the cut ends the step. The 3,000-token prompt waits. Running requests now count 3, because the cut one stays WAITING. The step holds 32,768 tokens.
A prompt of L = 20,000 tokens hits the production store. The window state is missing near the end. What hit length does the Q&A policy give, and how many tokens does prefill compute?
Answer
Thread 2. The cap is 20,000 − 134 = 19,866. The largest multiple of 128 at or below it is 155 × 128 = 19,840. The window is missing, so back off to 19,712. Prefill computes 20,000 − 19,712 = 288 tokens: 128 replayed for the window, and 160 new.
Which layers do layers 13, 33 and 1 read their long-range KV and their top-k from?
Answer
Thread 3. Layer 13: KV from 8, top-k from 8. Layer 33: KV from 20, top-k from 32, which scores inside layer 20's candidate blocks. Layer 1: none, it is a window-only layer.
Under async scheduling, a request is preempted for recompute while its decode step is in flight. What does process_outputs do with that step's tokens, and why?
Answer
Threads 1, 2 and 5. It throws them away. _preempt already reset the request's counters and put it back in waiting. Counting the tokens would push it into the queue twice and advance num_tokens wrongly. The translated comment at csrc/engine/scheduler.hpp:917-919 says so.
EPLB re-plans every 10,000 steps from a 1,000-step window in production decode. Name two ways the plan can be wrong for the next 10,000 steps.
Answer
Thread 4. First, traffic drifts, so the window may not look like the future. Second, the greedy policy can worsen balance even on its own window. The counterexample showed this, and the engine does not check for it. Third, the gate may not split a hot expert's tokens evenly between copies, as the policy assumes.
A decode step with 4 requests uses the 24-row graph. Request X gets rejected at its first draft. How many of X's 6 rows produced a token, and what happens to the KV the other rows wrote?
Answer
Threads 5 and 2. One. Row 0 carries X’s last token. The first draft fails, so the step samples one replacement from the corrected probabilities. The KV at the 5 draft positions stays in place. num_tokens moves back to 1 past its old value, and later steps overwrite those positions. The ring's 6 spare slots keep the window safe.
You run the batch invariance test with varlen on and without golden tokens. What fails first, the tokens or the logits?
Answer
Thread 6. I expect the tokens to differ first. The logits at a given position would still match if the tokens before it matched inference. Varlen changes how many drafts each request checks, which changes which random stream makes each token. Once one token differs, every later position sees different input, so later logits differ too.
A structured-output request with a JSON schema gets preempted in a recompute-mode engine. What happens to it?
Answer
Threads 2 and 6. It stops with FINISHED_PREEMPTED, whatever the mode, because the grammar's state can't be replayed codecsrc/engine/scheduler.hpp:769-780. The client sees "insufficient_system_resource".
Performance and trade-offs
This pass compares each choice with its alternatives and estimates what it costs. Most numbers come from the Q&A doc and the benchmark charts in doc/images/. I read chart values by eye, so allow plus or minus 2%. My notes are in research/00-benchmarks-and-doc.md.
I checked out FlashMLA, DeepGEMM and DeepEP with git submodule update --init --depth 1. Paths into them start with vendor/.
Here doc also covers the charts, and inference covers my own arithmetic.
Recall first
In Pass 3, why did a rejected draft's KV not need erasing?
Answer
The scheduler moves num_tokens back to the accepted count, so the stale positions stop counting as ready. Later steps overwrite them before anything reads them. The window ring has 6 spare slots, so a write up to 6 positions ahead never covers a slot a later window needs.
What did EPLB's stage B score, and why that and not the total load?
Answer
The sum over recent steps of each step's busiest GPU. Every MoE layer waits for its slowest GPU at every step. So a layout with balanced totals still runs slowly if a different GPU is hot each step.
Which setting breaks repeatable sampling even with batch invariance on, and why?
Answer
Varlen verification. It picks each request's draft count from measured step times and a ranking across the batch. That can change which random stream makes a token. Both runs can target the same distribution, within the sampler's numerical approximations, without producing identical tokens.
A prompt is aborted between two prefill chunks. What did Pass 3 find about its blocks?
Answer
It goes into ignored_seqs, and no path frees the blocks of an ignored, aborted sequence. So I think the blocks leak. No test covers it.
One scheduler per rank, and a vote
A combined engine chooses one phase for its expert-parallel group. During prefill, requests already decoding in that group wait.
Consider a NORMAL engine running both phases. The decode chart gives about 13 ms at EP64 with 64 requests per rank doc. The prefill chart gives 376 ms for a 32,768-token step at EP4 doc. These are different configurations. Combining them below illustrates the scheduling cost. It does not measure a combined engine. The decode estimate is 246 tokens/s/user divided by 3.25 tokens/step, about 76 steps/s inference.
Waiting for the vote. A ready request that misses an ALL point can wait another 70 decode steps for the next one. That is about 70 × 13 ms ≈ 0.9 s codedeepinfer/environ.py:174. An ANY point every 100 decode steps offers a backstop, about 1.3 s at this step time codedeepinfer/environ.py:177.
The prefill step. A 32,768-token prefill step at EP4 takes about 376 ms doc. If a combined engine had that prefill duration, its running requests would see a gap of hundreds of milliseconds inference.
Vote opportunities do not guarantee admission: readiness, cache usage and available slots still matter. These intervals are not upper bounds on a request’s total wait.
Separating prefill and decode
Mixed prefill/decode batches let running requests continue during prompt processing, although the extra work can lengthen each step. DeepSeek's described deployment separates the phases: prefill runs on 8-GPU machines and decode on 64- to 128-GPU supernodes, with KV sent between them doc. Both roles use the same scheduler code but skip the vote codecsrc/engine/scheduler.hpp:288-293.
That requires a small KV cache. Thread 2 shows a 16K-token prompt's KV is about 15 MB. The Q&A doc budgets 400 Gbps per 8 GPUs when P and D sit in different data centres doc.
I think separating P/D suits DeepSeek’s scale. Decode is the expensive phase, and separate machines keep prefill from interrupting it. On one small machine, like Pass 2’s offline example, I think mixing phases would reduce the delays caused by voting inference.
Why the scheduler is C++
Async scheduling hides CPU work only when it is shorter than the GPU step. At 13 ms steps and 60 requests per rank, the CPU has about 200 microseconds per request per step inference. That budget also covers the sampling launch, grammar masks and output building. An engine comment names the work left: "TODO(CPU): Eliminate O(num_reqs) python operations in decoding step" codedeepinfer/engine/llm_engine.py:931.
Async scheduling still depends on CPU speed. Once CPU and GPU work overlap, a step takes at least as long as its CPU work.
A tiny KV cache, kept for days
At about 891 bytes per token, KV is cheap to keep on SSD for days and to send between data centres.
A 128-token block holds 114,048 bytes, exactly 891 bytes per token of paged capacity for the pinned configuration, as Pass 2 found inference from deepinfer/worker/cache_engine.py:424-483. This is the growing paged-cache component, not total GPU memory per token.
Model
What each token stores
Bytes per token
Llama-3-70B, BF16
80 layers × 8 KV heads × 128 numbers × 2 for K and V × 2 bytes
The Llama estimate uses Meta’s architecture table. The V3 estimate uses its official configuration and assumes a one-byte latent-cache format. These are layout estimates, not measured allocations inference. DeepInfer's active state slot adds about 3.1 MB per request, whatever its length. Memory profiling also reserves copy capacity by budgeting the state pool twice codedeepinfer/worker/base.py:241-244. Weights, workspaces and unused capacity are separate costs.
What the small KV makes possible
One of the Q&A doc's deployment goals is to "retain KV cache for more than 72 hours" doc. At 891 bytes per token that is plausible.
The 3FS store has about 100 PiB of SSD for KV doc. That is 1.13 × 10¹⁷ bytes, or about 1.26 × 10¹⁴ tokens, 126 trillion inference.
One cluster serves 155 trillion tokens per day doc, so the store holds most of a day's tokens even with no reuse. Longer retention therefore depends on prefix sharing and the mix of new tokens. The Q&A describes retention periods, or time-to-live (TTL), of 3–7 days doc.
At Llama-3-70B's 320 KB per token, it would hold about 340 billion tokens, about 3 minutes of traffic inference.
A storage server reads at least 40 GB/s doc, about 45 million tokens of KV per second inference. A prefill GPU computes about 87,000 tokens per second at a 32K chunk doc. So one storage server serves cached KV about 500 times faster than one GPU recomputes it inference.
Compared with 7 days, a 3-day TTL reduces the chatbot cache hit rate by 0.5 to 1% doc.
What it costs
Three choices make the KV small, and each may cost quality.
Shared KV. Only 4 of 40 layers write long-range KV. Pass 2 showed a block would be about 11.6 times bigger without the sharing inference.
Compression. Layers 2 to 19 merge token pairs, so their long-range memory is half resolution.
The release does not isolate these choices in controlled comparisons, or ablations. It therefore does not establish their separate effects on quality.
Bounded replay
Without bounded replay, a prefix hit with a missing window must rebuild the window from scratch. In the second half of the layers that is 2,560 tokens, 20 layers × 128 doc. With replay it costs 128 tokens at any context length doc.
The decoder version, YOCO truncation, saves more. In compact mode, layers 20 to 39 run only the last 140 rows of each prompt, which nearly halves prefill compute doc. The prefill chart's 87,000 tokens per second already includes it, labelled "Decoder Bounded Replay (140 tokens)" doc.
Pass 3 showed the cost: a slightly different answer. The release doesn't measure how different, and I'd measure that first with a GPU.
The alternative: vLLM's prefix cache
vLLM keeps a hashed prefix cache in each engine's GPU memory. DeepInfer's block manager has none codecsrc/engine/block_manager.hpp:105-136. Its prefix reuse lives in an external store shared by every prefill engine doc.
With a shared store, "every prefill machine can hit any prefix" doc. The router can choose an engine by load without sending requests to whichever engine holds the prefix. Compute nodes also fail "more than an order of magnitude" more often than storage nodes, so the cache outlives them doc.
This needs small KV. A 100K-token prefix is 32 GB at 320 KB per token, but 89 MB at 891 bytes inference.
A big model with a small active part
V4.1 holds about 760 billion parameters but uses about 15 billion per token. Storing the complete model and computing the active part are different costs.
These are estimates from the model config, compared with the checkpoint’s stated 510 GB inference from research/03-model.md. B means billion parameters. GB measures storage in billions of bytes.
Per token, the model uses 6 routed experts and 1 shared expert in each of 40 layers, about 9.9B parameters. Attention adds about 5B, so about 15B in all, 2% of the total inference.
What that means for memory
One routed expert in one layer is 3 × 5,120 × 2,304 ≈ 35.4 million parameters. That is about 18.8 MB at FP4 with scales, or about 0.75 GB across 40 layers inference.
By default the Engram tables stay in host memory, and the engine fetches rows across ranks codedeepinfer/model_executor/models/deepseek.py:473-477. The full 203 GB tables are not resident in GPU HBM. Per-token lookups still transfer selected data. I did not measure their latency here inference.
How FP4 reduces weight reads
Decode reads every local expert that receives a token, every step. At EP64 that is up to 5.3 GB per step, against about 20 GB in BF16 inference.
The alternatives
Dense, like Llama-3-70B. Every token touches all 70B weights. V4.1 stores about 11 times more parameters and uses about a fifth as many per token inference.
DeepSeek-V3. 256 routed experts, top-8, and its first 3 layers dense. V4.1 has 384 FP4 experts, top-6, and MoE in every layer. These V3 settings come from its official configuration.
What the kernels confirm about the model
The kernel sources confirm some model formulas that Pass 3 left as guesses.
The "Flash" checkpoint is 510 GB doc. It uses few active parameters per token, which supports fast decode per GPU.
Why decode goes wide and prefill stays narrow
Decode can spread expert weights across more GPUs to reduce each GPU’s reads. The prefill chart’s large chunks already provide many tokens per expert, so wider groups offer less benefit and add communication.
EP8 against EP64
The decode chart at 131,072 tokens of context, nextn 3 and 75% acceptance docdoc/images/wide_ep_decode_pareto.png:
Requests per GPU
EP8 tokens/s/GPU
EP64 tokens/s/GPU
EP64 gain
EP8 tokens/s/user
EP64 tokens/s/user
8
~1,900
~2,600
+37%
~238
~325
64
~12,650
~15,700
+24%
~198
~246
256
~24,900
~26,300
+6%
~98
~103
The weight-read argument
Per decode step, each GPU reads these weights. All figures are my estimates inference.
Attention weights. Every GPU runs attention for its own requests, so it reads all of them: about 5B parameters, about 5 GB in FP8.
Expert weights. Each GPU reads its own expert slots, if they receive tokens. That is about 39 GB at EP8 and 5.3 GB at EP64.
Total. About 44 GB at EP8 against about 10 GB at EP64.
Assume a peak bandwidth of 8 TB/s, comparable to NVIDIA’s published Blackwell specification. Those reads alone would require at least about 5.5 ms at EP8 and 1.3 ms at EP64 inference. Sustained bandwidth can be lower.
At 64 requests the chart has EP8 at about 61 steps per second, or 16.4 ms per step. EP64 runs about 76, or 13.2 milliseconds per step. That 3.2 ms gap is close to my predicted 4.2 ms. The HBM estimate is rough, and it assumes every slot gets a token. I think reduced weight reads explain most of the gain inference.
At 256 requests the step is longer, so the fixed weight read matters less and the gain shrinks to 6%.
Tokens per expert
At 64 requests and nextn 3, a rank has 64 × 4 = 256 rows, so 1,536 (row, expert) pairs per step inference.
At EP8 those spread over 52 slots per GPU, about 30 tokens per slot.
At EP64 they spread over 7 slots per GPU, about 219 tokens per slot.
A 30-row multiply does little arithmetic per byte of weight. At 219 rows it does about 7 times more work per byte read. This is how EP "increases batch size and GPU matrix-multiplication efficiency", in the Q&A doc's words doc.
A hot expert's tokens are split between its copies by a fixed stride, (rank + row × 23333) % copiescodevendor/DeepGEMM/deep_gemm/include/deep_gemm/epilogue/sm100_mega_gate.cuh:204-206. Pass 3 guessed the split was even. It cycles through copies as row indices change, with a different starting offset per rank. Tokens selecting an expert need not occupy uniformly spaced rows, so a particular batch can still split unevenly. The policy assumes an even split codedeepinfer/model_executor/eplb/policy.py:31.
The DeepGEMM README gives no Mega MoE numbers, only a link to an external pull request codevendor/DeepGEMM/README.md:18. Its test demands bit-for-bit equality with the separate DeepEP dispatch, multiplies and combine codevendor/DeepGEMM/tests/test_mega_moe.py:345-346. This checks numerical agreement on the tested inputs. The release has no benchmark that isolates the speedup from fusion.
Why prefill stays narrow
At EP4, a 32,768-token chunk makes 32,768 × 6 = 196,608 pairs per rank. Over 104 slots per GPU that is about 1,890 tokens per slot inference. The larger token count makes arithmetic more important relative to weight reads. Going wider then adds communication for less weight-read benefit.
Hence the Q&A doc's rule: prefill uses "the smallest EP/DP that HBM allows", decode "the largest EP/DP that the available GPUs allow" doc. Balance also worsens as EP grows. For prefill, moe_lb is 1.045 at EP2 and 1.074 at EP4 doc. At EP4, 78 GB of experts per GPU is about what fits beside the KV cache. So I think EP4 is the low end in practice inference.
Why EP and not tensor parallel
Tensor parallel splits every weight matrix across GPUs. I see three reasons to avoid it here. All three are inference.
The experts are small. At 5,120 × 2,304, an 8-way split leaves very thin multiplies.
Attention has one KV head. The model stores one 512-number vector per token codedeepinfer/hugging_face/config.py:55. Splitting heads would leave every GPU holding a full copy of that KV.
Every layer would need an all-reduce of the hidden state. EP sends only routed tokens to their experts.
The Q&A doc gives the outcome without reasons: "TP and CP are not used for now" doc.
What load balancing is worth
Production decode measures moe_lb at 1.090 with EPLB on, against 1.0 for perfect balance doc. So the busiest GPU does 9% more than average, and every rank waits for it.
The Q&A doc also reports roughly 80% of a perfectly balanced offline benchmark when assessing overall load balancing docdoc/inference_deployment_qa.md:125-127. The charts above use random expert routing to remove much of the imbalance codeexamples/benchmark.py:165. The 80% report does not establish a conversion factor for every chart: production context lengths, concurrency and acceptance rates must also match before comparing throughput.
Complete the expert placement
Setup: 4 logical experts, 2 GPUs, 3 slots per GPU, so 2 redundant copies. Two steps.
Tokens
e0
e1
e2
e3
Step 0
20
20
40
120
Step 1
20
40
20
100
Start-up copies go to e0 and e1, so GPU0 = {e0, e1, e2}, GPU1 = {e3, e0, e1}. That gives GPU1 140 and 130, GPU0 60 and 50, so moe_lb = 270 / 190 = 1.42.
Stage A. Round 1: copying e3 cuts the hottest copy to 60 and 50, sum 110. Anything else leaves 220. So e3. Round 2: copying e3 again cuts its copies to 40 and 33.3. The hottest copy is now 40 at step 0, from e2 or an e3 copy, and 40 at step 1, from e1. Sum 80, against 110 for anything else. So e3 has 3 copies.
Stage B. Copies in order of load per copy: e3, e3, e3, e1, e2, e0.
Copy
Load (step 0, step 1)
Goes to
GPU0 after
GPU1 after
e3
(40, 33.3)
GPU0, tie
(40, 33.3)
(0, 0)
e3
(40, 33.3)
GPU1
(40, 33.3)
(40, 33.3)
e3
(40, 33.3)
GPU0, tie
(80, 66.7)
(40, 33.3)
e1
(20, 40)
GPU1
(80, 66.7)
(60, 73.3)
e2
(40, 20)
GPU1, now full
(80, 66.7)
(100, 93.3)
e0
(20, 20)
?
?
Your turn. Where does e0 go, and what is the final moe_lb?
Answer
GPU1 is full, so e0 goes to GPU0, which becomes (100, 86.7). The busiest GPU is 100 at step 0 and 93.3 at step 1. The means are 100 and 90. So moe_lb = 193.3 / 190 = 1.018, down from 1.42. I ran the real policy on my CPU and got the same layout: GPU0 = {e3, e3, e0}, GPU1 = {e3, e1, e2}.
EPLB also considers which steps make each expert busy, as Pass 3 showed. It can only plan from past load. Production re-plans every 10,000 decode steps from a 1,000-step window doc. A burst lasting a few seconds may finish before the next layout change inference.
What each speed trick is worth
Drafts help when their extra committed tokens outweigh the time spent proposing and checking them. The balance changes with batch size.
The cost of a decode step
The Q&A doc's table compares fixed-5 and dynamic verification at EP64 doc. For each row I worked out the step time as concurrency × accepted length / throughput. The row count is concurrency × (1 + verified length). A line fitted to all 12 points gives this inference.
Text version
step time ≈ 8.8 ms + 0.0168 ms per row
The fit groups costs into a roughly fixed term and a term that grows with rows. Weight reads, communication, launches and arithmetic contribute, but this fit does not measure their separate shares.
Concurrency
Rows, fixed-5
Fixed part as a share of the fitted step
16
96
about 85%
48
288
about 65%
96
576
about 48%
Why dynamic verification loses at 16 and wins at 96
Dynamic verification drops the drafts least likely to pass. That saves rows but loses their tokens.
At concurrency 16 the step is mostly fixed cost. Dropping 9 rows saves about 0.15 ms of a 10.4 ms step, about 1.5%. Accepted length falls about 2.4%, from 3.32 to 3.24 doc. The Q&A doc reports a net −1.5% doc.
At concurrency 96, the fitted per-row term is more than half the step time. Dropping 169 rows saves about 2.8 ms of an 18.5 ms step and loses about 4.5% of accepted tokens. The net is the reported +11% doc.
For small batches, the scheduler’s own estimate should favour checking every draft. I see three possible reasons it does not. All are inference, listed from strongest to weakest code evidence.
The start-up cost curve is measured at full load with random routing and short context codedeepinfer/worker/model_runner.py:581-584. So it may be wrong for small batches.
Choose how many drafts to check
Three requests U, V and W check up to 2 drafts each. Each confidence already includes the chance that all earlier drafts pass.
Request
Draft 1
Draft 2
U
0.9
0.7
V
0.6
0.3
W
0.2
0.1
The cost curve gives 7.0 ms for the 4-row graph, 7.6 ms for 8 rows and 9.5 ms for 9 rows. Add 0.4 ms for the sampler.
Rows
Expected tokens
Graph
Time, ms
Tokens per ms
3
3.0
4
7.4
0.405
4
3.9
4
7.4
0.527
5
4.6
8
8.0
0.575
6
5.2
8
8.0
0.650
7
5.5
8
8.0
0.688
8
?
?
?
?
9
?
?
?
?
Your turn. Fill in rows 8 and 9, pick the best, and say how many drafts each request checks.
Answer
At 8 rows, adding W’s 0.2 gives 5.7 expected tokens. Graph 8 takes 8.0 ms, giving 0.713 tokens per ms. At 9 rows, adding W’s 0.1 gives 5.8 expected tokens. Graph 9 takes 9.9 ms, giving 0.586 tokens per ms. The best is 8 rows. The 5 extra rows go to the top 5 drafts: U .9, U .7, V .6, V .3, W .2. So U and V check 2 drafts each and W checks 1. I checked this with a short script.
Async scheduling
Pass 1’s toy overlapped 3 ms of CPU work with a 10 ms GPU step. That gave 30% more steps per second. The real gain depends on the CPU time, which the release doesn't publish. The engine's cpu_timer does time the schedule, the post-process wait and a "bubble" segment codedeepinfer/engine/llm_engine.py:881-887, :991-992. A GPU run with async scheduling on and off would settle it. The benchmark already puts the setting in its trace names codeexamples/benchmark.py:567.
Wasted rows. A finishing request can remain in the next forward step. Fixed-5 verification gives it six rows. Variable verification can reduce its draft allocation.
No tests. I found no engine-level test of the async edge cases. Safety rests on assertions, such as the varlen scheduler's counter check codecsrc/engine/mtp_varlen.hpp:195-196.
CUDA graphs and padding
Graphs remove launch cost, which matters when a 13 ms step is a few hundred kernel launches. They cost padding. At the default interval of 8, a step wastes at most 7 rows codedeepinfer/environ.py:155. At 0.0168 ms per row that is at most about 0.12 ms, under 1% of a step inference from my fit. The benchmark uses an interval of 32 codeexamples/benchmark.py:169. That means fewer graphs, shorter start-up and less graph memory, but up to 31 padded rows, about 0.5 ms.
Speculative decoding helps only when the extra accepted tokens outweigh drafting and verification costs. Per-user speed and aggregate tokens per GPU can respond differently. At high concurrency each checked draft takes a row another request could have used. That trade changes with load, which is why the varlen scheduler exists.
What determinism costs
Batch invariance makes numerical choices stable across batch compositions. The tests enforce exact equality for some exposed results and aggregate regression checks for others.
What the switch changes inside the kernels
Pass 2 guessed that the switch fixes how kernels divide long sums. The kernel sources confirm it.
V4.1's decode attention reads a 128-token window codedeepinfer/model_executor/models/v4_modules/attn.py:857 plus 512 top-k entries, exactly 640. So split-KV is off for V4.1 decode either way, and I read batch invariance as free in this model's decode attention inference. No README or comment gives a cost figure.
Point 3 shows that determinism doesn't need split-K turned off. It needs the split count fixed, so the summation tree is the same for any batch.
DeepEP and Mega MoE, the expert exchange. Mega MoE sums each token's expert outputs in 32-bit floats in a fixed order. The kernel says: "Shared slot goes first, then routed slots in top-k order" codevendor/DeepGEMM/deep_gemm/include/deep_gemm/impls/sm100_fp8_fp4_mega_moe.cuh:1449-1450. That order doesn't depend on the batch, so it needs no switch.
One gap remains. The gate picks a hot expert's copy with (rank + row × 23333) % copiescodevendor/DeepGEMM/deep_gemm/include/deep_gemm/epilogue/sm100_mega_gate.cuh:204-206. The row is the token's position in this batch, so the batch picks the token's GPU. With Mega MoE that doesn't matter, because copies share weights and the sum order is fixed. With DeepEP and redundant experts but EPLB off, the row could change the early-sum grouping, and the switch wouldn't stop it. I think that gap is real but narrow inference.
I therefore expect little batch-invariance overhead with one NVLink domain, Mega MoE and V4.1’s attention shape. Other configurations can lose faster cuBLASLt paths or small-batch split-K.
Why the tests compare floats exactly
The offline PPL test compares each sample's decode perplexity with a stored value using ==codetests/models/test_offline_inference.py:152-158. This checks the final scalar exactly. Perplexity aggregates token logprobs, so matching it does not prove that every token, logprob or intermediate value matched.
The golden file also tests across batches. DP rank 0 runs the five sample prompts with seeds 0 to 4. Rank 1 runs the same five with the same seeds, then five more, in a batch twice the size codeexamples/_prompts.py:100, :113. Rank 1's first five stored perplexities equal rank 0's exactly, on all four platform and workload pairs codetests/models/offline_decode_ppl.json:1-90. Our Pass 2 request, sample 1 in the long workload on NVIDIA, stores 1.000645962988749.
The two ranks share every MoE layer but run different batch sizes. Numerical changes that alter a stored perplexity fail the test. Aggregation or rounding can hide other changes. The golden file can be refreshed with examples/refresh_offline_decode_ppl.py, so a failure needs investigation before accepting a new baseline inference about intent.
The Ascend perplexities match across ranks too, although Ascend does not support the batch-invariance switch codedeepinfer/worker/runtime_config.py:62-63. The engine always enables deterministic kernel mode there codedeepinfer/worker/runtime_config.py:67. Matching these aggregates does not establish batch invariance for every exposed value or configuration.
What it buys
Debugging. Stable numerical results make a forced continuation easier to compare alone and in a batch. Scheduling or lifetime bugs can still depend on batch composition.
Prefill/decode regression checks.test_kl.py compares exposed logprobs along the same tokens, with an aggregate discrepancy limit of zero codetests/models/test_kl.py:190-206. As Pass 3 explains, this does not prove whole-pipeline bitwise equality or validate every P/D handoff and recomputation path.
Repeatable evaluation, with the supported NVIDIA recipe, identical inputs and parameters, a fixed seed and varlen off. The evaluation script enables varlen, so that recipe does not guarantee its runs repeat codeeval/run_eval.py:120-121.
The alternative
Allowing batch-dependent numerical choices gives kernels more freedom to optimise each shape. The cost of fixing those choices needs measurement for each workload.
DeepInfer combines position-keyed random draws, a sampler designed to be invariant without the switch, and regression checks across batching and execution paths. I think these checks help a team shipping a model and engine together. Each check still has limits.
Determinism depends on every kernel, as Pass 1 showed. A fixed seed cannot prevent one changed bit in the logits from changing the sampled token.
Mixed exercises
Assume a combined NORMAL engine takes 13 ms per decode step and 376 ms per prefill step. With the default vote intervals, how far apart are ALL and ANY opportunities? Does that bound admission time? What gap do running requests see during prefill?
Answer
Threads 1 and 5. ALL opportunities are 70 decode steps apart, about 0.9 s. ANY opportunities are 100 apart, about 1.3 s. These do not bound admission time when readiness or capacity prevents scheduling. Under the stated timing assumptions, running requests in the group wait through the 376 ms prefill step.
You want 7-day retention for all traffic. The store has 100 PiB. At 891 bytes per token, how many days of 155 trillion tokens per day fit, before counting prefix sharing?
Answer
Thread 2. 1.26 × 10¹⁴ / 1.55 × 10¹⁴ ≈ 0.8 days. So 7 days works only because most tokens are shared prefixes, and only the unique ones need storing. That is why the Q&A doc mixes 3 and 7 days by traffic type.
At EP16 with the offline example's 32-copy lower bound, how many expert slots does each GPU hold, and about how many GB of expert weights?
Answer
Threads 3 and 4. 384 + 32 = 416, and 416 / 16 = 26, so no rounding is needed. 26 slots × 0.75 GB ≈ 19.5 GB of expert weights per GPU.
Using the fit of 8.8 ms plus 0.0168 ms per row, what is the step time for 60 requests with fixed-5 verification? How many rows would dynamic verification need to drop to save 1 ms?
Answer
Thread 5. 60 × 6 = 360 rows. The fit gives 8.8 + 0.0168 × 360 ≈ 14.85 ms. The table gives 14.42 milliseconds. To save 1 ms it would drop about 1 / 0.0168 ≈ 60 rows, one per request.
Why would turning on varlen verification in the offline PPL test break it, even with batch invariance on?
Answer
Threads 5 and 6. Varlen picks each request's draft count from measured GPU step times and the other requests' confidences. A different count can assign a token to a different random stream. The sampled continuation and its processed logprobs can then change, so the stored perplexity is no longer guaranteed to match.
A team proposes running decode at EP8 instead of EP64 to save on network gear. Using the chart, what do they lose at 64 requests per GPU, in throughput and in per-user speed?
Answer
Thread 4. Throughput drops from about 15,700 to about 12,650 tokens per second per GPU, about 19%. Per-user speed drops from about 246 to about 198 tokens per second. To serve the same load they would need about 24% more decode GPUs.
Why can DeepInfer's prefix cache live outside the engine when vLLM's lives inside it?
Answer
Threads 2 and 3. At about 891 bytes per token, fetching a long prefix moves tens of megabytes, not tens of gigabytes. That makes a shared store fast enough. It also lets any prefill machine hit any prefix, so routing doesn't have to follow the cache.
Design it yourself
Use the source constraints to design each change before opening the worked answer. Traffic numbers are invented. Model and engine settings come from the pinned release. Most answers are design proposals inference.
Recall first
What does FlashMLA's batch-invariant switch change for V4.1 decode attention, and why?
Answer
It changes nothing for this attention shape. FlashMLA only splits a request's keys when the window width plus the top-k width is over 640. V4.1 uses 128 + 512 = 640, so it never splits, with or without the switch.
At EP64 with 64 requests per GPU, about how many tokens does each expert slot see per decode step, and why does that matter?
Answer
About 219, with nextn 3. At EP8 it is about 30. Decode is limited by weight reads. A 219-row matrix multiply does about 7 times more maths per byte of weight than a 30-row one.
Why did dynamic verification lose 1.5% at concurrency 16 in the Q&A table?
Answer
At 16 requests the fixed part of a step, about 8.8 ms, is about 85% of it. Dropping drafts saved little time but cost more in tokens. The scheduler’s own estimate should have favoured keeping the drafts. Stale confidences, a badly calibrated confidence head, or a start-up cost curve that doesn't fit small batches could explain why it didn't.
Where does a cache hit in production come from, the block manager or somewhere else?
Answer
Somewhere else. The block manager is a free list with reference counts and no hashing. Production hits come from an external store on 3FS that the Entrypoint matches by a hash chain. The Q&A doc describes that store. It is not in the code.
The online server and its aborts
You must put an online server on top of LLMEngine. Clients stream tokens and can cancel at any time. The release has no online wrapper codedeepinfer/engine/llm_engine.py:141. From Pass 3:
Design the abort path. Say what the server tracks, what it sends the client, and what you change in the engine.
Worked answer
Calling abort_request and closing the stream leaves two problems. The client never learns whether the engine produced tokens after cancellation. Leaked blocks shrink the cache until a restart.
What the server tracks. For each request id: its state as the server sees it, the tokens sent so far, and a "cancel requested" flag. The server sends the final event itself.
The abort path.
The server sets the flag and calls abort_request between two step() calls, never during one. During a prompt step, a scheduled prompt has left waiting but not yet joined running, so abort_sequence would miss it codecsrc/engine/scheduler.hpp:144-160inference.
The server stops forwarding tokens for that id at once. Async scheduling may emit one more step of tokens. The server drops them.
The server sends the client a final "abort" event itself, because the engine won't for a running request.
If abort_request returned 0 sequences, the request had already finished or was in flight in a prompt step. The server retries after the next step().
What to change in the engine. Free the blocks of an aborted sequence in waiting. Change the two places that move aborted sequences into ignored_seqs. If the sequence has a non-empty block table or a state slot, call free_seq_nolock first. The over-length fork path already does this codecsrc/engine/scheduler.hpp:407. Then add the test Pass 3 asked for. Abort a prompt between two chunks, and check that the free block count returns to its start value.
A vLLM-style server gets a final output for every request from the engine. Here the server must track completion and send that event itself.
One 8-GPU box, prefill and decode together
A customer has one 8-GPU SM100 machine and no budget for P/D disaggregation. They run DeepInfer as a NORMAL engine at EP8. Their users complain that answers stall for a third of a second every few seconds.
A decode step at EP8 with 64 requests per GPU takes about 16 ms, from the EP8 curve in the decode chart doc. The prefill chart, at EP4, shows about 68 ms for a 4,096-token step and about 376 ms for 32,768 tokens doc. Assume EP8 prefill is similar per GPU.
Explain the stalls. Then decide: change the settings, or make the engine mix prefill and decode in one step?
Worked answer
The stalls. Every 70 decode steps, about 1.1 s at 16 ms, the ranks vote. If they all have prompts waiting, they run a prefill step of up to max_num_batched_tokens. At 32,768 tokens that takes about 376 ms. Every running request on every GPU waits that long for its next token. That explains the roughly one-third-second stall inference from the vote interval at deepinfer/environ.py:174 and the chart.
Mixing prefill and decode, as vLLM does, could reduce the gaps. It requires substantial changes to this engine.
Start with smaller prefill steps. Set max_num_batched_tokens to about 4,096. Each prefill step then takes about 68 ms, not 376 ms. The chart shows throughput at about 60,000 tokens per second per GPU doc. The larger chunk gives about 87,000 doc. You lose about 30% of prefill throughput, but the stall becomes about one fifth as long. You can also lower DEEPINFER_PREFILL_ATTEMPT_INTERVAL so prompts wait less. The stalls then come more often but are smaller.
Larger change: mixed steps. Four things stop a step from holding both.
Decode uses CUDA graphs sized by row count. A mixed step's shape changes every time, so it would run without a graph and lose the launch savings for the decode rows inference.
Async scheduling's GPU-side corrections assume decode rows. Prefill rows would need to bypass them.
For one machine, I would try smaller prefill steps first. If stalls still matter, build mixed steps as a separate engine mode. Run mixed steps without a CUDA graph, with a prefill budget of a few hundred tokens per step. At 0.0168 ms per row, 512 prefill rows add about 9 ms to a 16 ms step inference from my fit in Pass 4. That is a steady cost instead of a spike. That rate comes from decode rows, so the prefill rate would need measuring.
At DeepSeek’s scale, P/D disaggregation removes this interruption because decode engines never prefill. It needs a second machine group and a KV transfer path, which only pays at scale.
Size the KV store
You run a cluster like DeepSeek’s. Use these invented traffic assumptions.
155 trillion tokens per day, 90% input and 10% output.
80% of input tokens hit the cache, so 20% are new.
Decode KV is also written to the store, as the Q&A doc says production does doc.
60% of stored tokens need 3-day retention, 40% need 7 days.
Each storage server has 20 × 30.73 TB SSDs and reads at 40 GB/s doc. It uses 10 data units plus 2 redundant units, called 10+2 erasure coding doc.
Requests average 20,000 tokens.
How many bytes does the store hold, how many servers do you need, and is capacity or read bandwidth the limit? Can you keep each request’s window state as long as its blocks?
Worked answer
Bytes stored per day. New input tokens: 155T × 0.9 × 0.2 = 27.9T. Output tokens: 155T × 0.1 = 15.5T. Total 43.4T tokens per day. At 891 bytes each, 38.7 PB per day.
Bytes held. Weighted retention is 0.6 × 3 + 0.4 × 7 = 4.6 days. So the store holds about 200T tokens, about 178 PB, about 158 PiB.
Capacity. One server has 614.6 TB raw. With 10+2 coding, 10 of 12 parts hold data, so about 512 TB usable. 178 PB / 512 TB ≈ 347 servers, before headroom.
Bandwidth. Hit tokens per day: 155T × 0.9 × 0.8 = 111.6T. At 891 bytes, 99.4 PB per day, about 1.15 TB/s on average. At 40 GB/s per server that is about 29 servers, perhaps 90 at a 3-times peak.
Capacity requires about 4–12 times as many servers as read bandwidth, under these assumptions. The small KV size makes capacity the limit. Shorter retention reduces that capacity requirement, as the Q&A doc discusses. The doc measured the cost of a shorter TTL: 0.5 to 1% of chat hit rate for 3 days instead of 7 doc.
These are my calculations using invented traffic inference. The real store is about 100 PiB doc, so the real hit rate, mix or prefix sharing must differ from mine.
The window state. Each stored request also needs its window state, 1,415,040 bytes doc. At 20,000 tokens per request that is 7.75 billion requests per day, about 11 PB of window state per day. Production keeps this state in host memory on the machine that made it, as a single copy with no 3FS backup doc. Keeping all of it for 4.6 days would require about 50 PB of host memory, separate from the SSD store.
So window state must expire much sooner than the blocks. This is why bounded replay exists. When the blocks hit but the window state is gone, back off one block and recompute 128 tokens doc. The cost is bounded, so a short window-state lifetime is affordable.
Window state is fixed per request. Blocks grow with token count. They cost different amounts to rebuild, so they need different storage tiers.
Put a prefix cache inside the engine
You want vLLM-style prefix caching inside one engine, for a deployment without a shared store. What do you add to the block manager and the scheduler? What is hard about this model in particular?
Worked answer
What to add.
A hash per full block. Hash the block's 128 tokens with the previous block's hash, so a hash names a whole prefix. Keep a map from hash to block id.
Keep freed blocks findable. Today free() puts a block back on the free list at once codecsrc/engine/block_manager.hpp:95-102. Instead, a block with reference count 0 stays in the hash map, at the tail of an LRU list. allocate() takes from the LRU head and removes the old hash.
Match at admission. In _schedule_prefill, walk the new prompt's block hashes, pin every match, and set cache.num_tokens to the matched length. The setter already exists for an external store codecsrc/engine/sequence.hpp:210-214.
Reuse fork_seq's copy rule. A partial last block can't be shared. Copy it, as fork admission does codecsrc/engine/scheduler.hpp:425-437.
The window needs separate handling. The blocks hold only long-range KV. A hit also needs the window ring for the 128 positions before the hit point, for the 20 layers in the first half. The ring lives in a per-request state slot that a new request doesn't own codedeepinfer/worker/cache_engine.py:229-234.
You have two choices. Snapshot the stored region of the state slot at each 128-token boundary, as production does, 1.4 MB per snapshot doc. Or use bounded replay: reduce the hit length by 128 tokens and recompute them with swa_valid_start set codecsrc/engine/input_metadata/prepare_tensor_input_v4.cpp:150-155. For an in-engine cache I'd use replay. Snapshots cost GPU memory, which is scarce, while replaying 128 tokens costs little compute.
The second half needs no snapshot, as long as YOCO truncation is on. Its window is rebuilt from the prompt's tail on every prefill codedeepinfer/worker/cache_engine.py:836-837.
Hashing the blocks is enough for a model with only full attention. A model with a sliding window keeps per-request state outside the blocks, and a prefix cache must rebuild or restore it too.
A KV budget of 1 KB per token
You are designing the next model's cache layout. Your budget is 1,024 bytes per token for long-range KV and indexer keys, in V4.1's formats. Per raw token, a C2 owner layer costs 178 bytes and a C1 owner layer 356 bytes, from Pass 2's block layout. V4.1 uses 3 C2 owners and 1 C1 owner, 890 bytes.
Options inside the budget, my arithmetic inference.
Layout
Bytes per token
What changes
V4.1: 3 C2 owners in layers 0 to 19, 1 C1 owner for 20 to 39
890
The baseline
5 C2 owners, no C1
890
The second half loses full resolution
1 C2 owner, 2 C1 owners
712
The first half shares one memory across 18 layers
2 C2 owners, 2 C1 owners
1,068
Over budget
4 C2 owners, 1 C1 owner
1,068
Over budget
I would keep V4.1’s layout and use the spare 134 bytes elsewhere.
The second half must have exactly one owner to keep YOCO truncation. One shared KV built at the boundary lets layers 20 to 39 skip most prompt rows. A second owner in the second half would need KV for every prompt token. That layer would then run every row, which undoes the saving.
That owner should be C1, because 20 layers read it and it is their only long-range memory.
Add owners in the first half if quality needs it, because they are cheaper there. Each C2 owner costs half a C1 owner. But 4 C2 owners plus the C1 owner is 1,068 bytes, over budget.
The spare 134 bytes could go to indexer keys with more numbers, for better top-k picks. The indexer decides what 38 layers see.
How each layout affects quality is a training question this repo can't answer. This is an argument about structure, not a measured choice.
More owner layers might improve quality. A second owner in the second half also prevents truncation from skipping most prompt rows.
64 GPUs, and one expert gets 3 times the traffic
A decode instance runs EP64 with 384 experts and 1 redundant copy per GPU, so 7 slots per GPU. Every expert gets L tokens per step, except one, which gets 3L. The redundant copies sit where start-up put them, on experts 0 to 63.
What does this do to step time? What do you do about it? What if the hot expert is hot only for a few seconds at a time?
Worked answer
What it does. The total load is 384L + 2L = 386L, so the mean per GPU is 386L / 64 ≈ 6.03L. Say the hot expert's GPU also holds 5 normal experts and one start-up copy that halves a normal expert. That GPU carries 3L + 5L + 0.5L = 8.5L. So moe_lb ≈ 8.5 / 6.03 ≈ 1.41. Every MoE layer of every step waits for that GPU inference.
Pass 4’s fit gives a fixed term of about 8.8 milliseconds. The MoE exchange contributes to this term. If MoE were half the step, a 41% slower MoE would make the step about 20% slower inference. This assumes that guessed share of the step.
What EPLB does. Given a window that saw the hot expert, stage A gives it copies until no copy of it is hotter than the rest codedeepinfer/model_executor/eplb/policy.py:110-123. Two extra copies split 3L into three parts of L. Stage B then spreads those copies over GPUs. The gate assigns the hot expert’s tokens to copies using a stride over batch rows codevendor/DeepGEMM/deep_gemm/include/deep_gemm/epilogue/sm100_mega_gate.cuh:204-206. The final peak still depends on the complete placement and the actual split between copies. Each extra copy costs about 0.75 GB of HBM per GPU that holds it inference.
Moving the hot expert to its own GPU leaves 3L on one slot. Copying can split that load.
If it is hot only for seconds. Production re-plans every 10,000 decode steps, about 2 minutes at 13 ms, from a 1,000-step window doc. EPLB misses a shorter burst, or reacts after it ends. Three options:
Check memory after a GPU failure. If one GPU fails and the instance restarts at EP63, the rounding gives 120 extra copies, 8 slots per GPU codedeepinfer/model_executor/eplb/__init__.py:27-28. That is about 0.75 GB more per GPU. Check it still fits beside the KV cache before you plan for failover.
Find a better expert placement
Pass 3 did a full run, and Pass 4 left out the last step. Here you do every step: 6 experts, 2 GPUs, 4 slots per GPU, so 2 copies. Both steps have the same tokens.
Tokens
e0
e1
e2
e3
e4
e5
Step 0
30
30
30
30
30
150
Step 1
30
30
30
30
30
150
Work out stage A and stage B by hand. Then ask whether a better layout exists.
Stage A. Round 1: copying e5 makes its copies 75, the hottest. Anything else leaves 150. So e5. Round 2: copying e5 again makes its copies 50, while every other expert is 30. Anything else leaves 75. So e5 again. e5 has 3 copies of 50.
Stage B. Order: e5, e5, e5, then e0 to e4 at 30 each.
e5 to GPU0, e5 to GPU1, then the third e5 ties and goes to GPU0. GPU0 = 100, GPU1 = 50.
e0 to GPU1, 80. e1 to GPU1, 110. e2 to GPU0, 130, since GPU1 would reach 140.
e3: GPU0 would reach 160, GPU1 140. So GPU1, which is now full at 4 slots.
e4 goes to GPU0, 160.
Final: GPU0 = {e5, e5, e2, e4} = 160, GPU1 = {e5, e0, e1, e3} = 140. moe_lb = 160 / 150 = 1.067. The real policy, run on my CPU, gave the same layout.
Is there better? Yes. Give e5 one copy, so 75 each, and spend the second copy on e0, so 15 each. GPU0 = {e5, e0, e1, e2} = 75 + 15 + 30 + 30 = 150. GPU1 = {e5, e0, e3, e4} = 150. moe_lb = 1.00.
Stage A over-copied e5, because it asks "which copy is hottest right now", not "which copies pack best". A packing step that could trade a copy back would find the perfect layout.
Pick the verify lengths, and the graph sizes
Two requests, A and B, each with 3 drafts. Their confidences, already multiplied along each request:
Request
Draft 1
Draft 2
Draft 3
A
0.90
0.85
0.60
B
0.70
0.30
0.10
The cost curve is 6.0 ms for 2 rows, 6.1 ms for 4 rows and 9.0 ms for 8 rows. Add 0.2 ms for the sampler. Run the varlen scan alone, without a worked step.
Second question. You own the CUDA graph sizes. The default is powers of two up to 8, then steps of 8. Would you change that for an engine with max_num_seqs=128 and nextn 5?
Worked answer
The scan. Sorted chances: A .90, A .85, B .70, A .60, B .30, B .10.
Rows
Expected tokens
Graph
Time, ms
Tokens per ms
2
2.00
2
6.2
0.323
3
2.90
4
6.3
0.460
4
3.75
4
6.3
0.595
5
4.45
8
9.2
0.484
6
5.05
8
9.2
0.549
7
5.35
8
9.2
0.582
8
5.45
8
9.2
0.592
The best is 4 rows, so 2 extra rows, both to A. A checks 2 drafts and B checks none. 8 rows comes close, at 0.592. If the 8-row graph cost 7.0 ms instead of 9.0, 8 rows would win at 0.757 and both requests would check all 3. The jump in the cost curve determines the answer, as Pass 3 found.
Checking every draft above 50% would check A’s three and B’s first, giving 6 rows. That is worse than 4, because 6 rows needs the 8-row graph.
The graph sizes. The maximum is 128 × 6 = 768 rows. With steps of 8 that is about 98 graphs. Each graph costs capture time and some memory. Padding wastes at most 7 rows, about 0.12 ms at my 0.0168 ms per row.
The varlen scan tends to fill a graph size because every row in that size shares one cost estimate. More sizes give it more choices, but require more graphs. I'd keep steps of 8 up to about 256 rows, where a step is still mostly fixed cost, then go to steps of 32. Above 256 rows, a 31-row waste is under 0.5 ms of a step near 13 to 20 ms inference. The benchmark uses steps of 32 everywhere codeexamples/benchmark.py:169.
Make speculative decoding deterministic, with varlen on
Pass 3 showed why varlen breaks repeatable output. The number of drafts checked per request depends on timing and on other requests, and that changes which random stream makes each token. Can you keep varlen's speed and still get the same tokens every run, for the same prompt and seed?
Worked answer
What must not change. For a given request, the token at absolute position P must be a fixed function of the model's distribution at P, the seed and P. It must not depend on whether P was reached as a checked draft or as a fresh sample. For this design, assume the same accepted prefix gives identical target probabilities across verification layouts. The existing prefill/decode test supplies regression evidence, but its zero aggregate discrepancy does not prove per-value equality codetests/models/test_kl.py:190-206, deepinfer/testing.py:4-11. An implementation must check that numerical assumption directly as well as fix the random draws.
For each position P, draw one uniform number from a stream keyed only by the seed and P.
Use it to choose the main model’s token from the cumulative probability intervals, as in Pass 1.
Accept the draft at P only if it equals that token.
The token at P is then the main model's sample, whether or not a draft was checked there.
Under that numerical assumption, the scheme follows the main model's distribution in ideal arithmetic and makes its samples independent of the verification length. The implementation must still check finite-precision sampling. The acceptance-rate comparison below also assumes the draft and target draws are independent.
The cost. Fewer drafts pass. Rejection sampling accepts a draft with chance equal to the sum over tokens of min(p, q). Sample-then-compare accepts with chance equal to the sum of p × q, which is never larger and usually much smaller inference. At low temperature, when one token’s probability is near 1, the two are nearly equal. At temperature 1 the loss could be large. I would measure it before choosing.
A smaller change. Keep rejection sampling, but key its coins by absolute position and draft identity, not by row. That removes the row dependence, but whether position P was reached as a draft at all still depends on the verify length. So this change alone does not establish invariance. The sample-then-compare proposal gives a direct way to make the token at P independent of the verify length, under the assumptions above inference.
Using step counts instead of measured times would make varlen’s choice independent of timing. But the choice still depends on the other requests' confidences, so a request's tokens would still change with its batch.
Mixed exercises
These mix all six threads. Decide which idea applies before you answer.
Your server sends a client's 30,000-token prompt to a NORMAL engine with max_num_batched_tokens=8192 and chunked prefill on. The client cancels after 10 seconds. Name every resource you must check came back.
Answer
Threads 1 and 2. The 235 blocks reserved at the first chunk, its state slot, its MTP info slot if it had one, and any multimodal pool handles. If it was between chunks, the release leaks the blocks and the state slot. Your server should free them, or you should fix the engine.
Suppose the draft model and engine could support 10 guesses instead of 5. Name two memory costs that grow with it.
Answer
Threads 2 and 5. The window ring grows from 134 to 139 slots per layer per request, because the rollback room is at least nextn + 1. The block count reserves room for in-flight and draft tokens, so each request may need an extra block sooner. The draft pool, the CUDA graph sizes and the maximum rows per step grow too.
A colleague says: "batch invariance is free for V4.1, so turn it on everywhere." When would you push back?
Answer
Threads 4 and 6. When EP spans several NVLink domains. Then the engine uses DeepEP's combine, and with EPLB on, invariance turns off its early summing, which costs bandwidth. Also when cuBLASLt would have been faster for a dense multiply. The "free" finding in Pass 4 covers decode attention and the one-domain Mega MoE setup, not everything.
A request finishes at step N under async scheduling. How many forward rows does it waste, and what stops it from stealing verify budget?
Answer
Threads 1 and 5. One step's rows, since the CPU scheduled it for N+1 before it learned of the finish. With varlen on, the sampler zeroes its next draft confidences when it emits a stop token. The GPU's redistribution then gives its draft rows to others.
How do YOCO compact truncation and batch invariance differ in the changes they make? Can either affect a stored perplexity baseline?
Answer
Threads 3 and 6. Truncation changes what the second half of the model sees, so the numbers themselves change. Batch invariance fixes numerical choices so results stop depending on batch composition. Switching those choices can still change rounded values and a stored perplexity. Investigate the difference before accepting a new baseline.
Your EP64 decode instance shows moe_lb of 1.25 just after start-up, then 1.09 an hour later. Give one plausible explanation. What would you inspect to check it?
Answer
Thread 4. At start-up the redundant copies sit on experts 0 to 63 in index order, not on the hot ones. EPLB's first re-plan comes at a quarter of the interval to fix that quickly. Re-planning could explain the improvement. Check placement and load logs. The two readings alone cannot establish the cause. The reported production result of 1.09 is not a target the policy guarantees.
Take a 64-request decode step at EP64. Compare a 3-row CUDA graph pad, one rank in a placeholder step, and one expert at 3 times its share. Which is the likely bottleneck under the earlier equal-work assumptions, and what would you measure?
Answer
Threads 4 and 5. The hot expert is the likely bottleneck under the equal-work assumptions, because the group waits for its GPU. The fitted row cost estimates about 0.05 ms for three padded rows. This is not a measured penalty. A placeholder keeps an idle rank in the exchanges and does not by itself establish a delay to others. Measure per-rank expert time, padding cost and communication before deciding which causes the largest delay.
Glossary
Each pass links a term here on its first use in that pass. Code names are in backticks, and paths start at the DeepInfer repo root.
0-9
3FS
DeepSeek's distributed file system on SSDs. The production KV cache store sits on it, outside this release.
A
Abort
Cancelling a request before it finishes. The scheduler only marks it FINISHED_ABORTED. The next decode schedule frees a running request’s blocks. Pass 3 shows another case where the engine may never free them. Code: Scheduler::abort_sequence in csrc/engine/scheduler.hpp:144.
Accepted length
The number of tokens a request gains in one decode step with speculative decoding: the accepted drafts plus one sampled token.
All-to-all
A coordinated exchange within a GPU group. Each GPU can send different data to the others. Some transfers may be empty. MoE layers use it twice: dispatch sends tokens to the GPUs that hold their experts. Combine sends the results back.
Attention sink
One extra learned score per attention head that sits in the softmax as a "nothing" entry. A head can put attention there instead of on irrelevant keys.
B
Batch invariance
A request's numerical model results do not depend on which or how many other requests share its batch. DeepInfer enables it with DEEPINFER_BATCH_INVARIANT=1, on NVIDIA only. Identical sampled text also needs the documented sampling conditions: identical inputs and parameters, a fixed seed and variable-length verification off.
Block
A group of 128 consecutive token positions of one request, in GPU memory. It holds their long-range KV for every layer that owns KV. Also called a paged block.
Block manager
The C++ object that allocates and frees blocks. It tracks them with a free list and a reference count per block. Code: csrc/engine/block_manager.hpp.
Block table
The ordered list of block numbers that holds one request's tokens. Entry 0 holds positions 0 to 127, entry 1 holds 128 to 255, and so on.
Bounded replay
Rebuilding the sliding-window cache by recomputing only the last few tokens, with attention limited to those tokens. The result is approximate, but the cost does not grow with context length.
C
C1, C2
How much a layer compresses its long-range KV. C2 merges each pair of tokens into one entry. C1 keeps one entry per token. A layer with ratio 0 has no long-range KV, only the window.
Chunked prefill
Splitting a long prompt across several prefill steps, so one step never processes more than the token budget.
A small classifier on the draft model that predicts, for each draft token, how likely the main model is to accept it.
Context parallel (CP)
Splitting one request's prompt tokens across several GPUs during prefill. DeepInfer gives each GPU one early and one late slice to even out the attention work. The Q&A doc says production does not use it.
Continuous batching
Re-forming the batch at every step. New requests join and finished ones leave between steps, without waiting for the whole batch.
CUDA graph
A recorded sequence of GPU kernel launches with fixed shapes and fixed memory addresses. Replaying it costs one launch instead of hundreds.
D
Data parallel (DP)
Each GPU, or each CP group, runs attention for its own requests with its own KV cache. In DeepInfer each DP rank is its own engine process.
Decode
The phase that produces new tokens, one step at a time. Each step processes one token per request, plus any draft tokens.
Deterministic sampling
The same input tokens and sampling parameters always give the same output tokens. In DeepInfer it needs batch invariance on, varlen verification off, and a fixed seed.
DFlash, DSpark
DSpark is DeepSeek's speculative decoding scheme. DFlash is its draft model: 3 extra layers that read features from the main model and propose 5 tokens in one parallel pass.
A proposed future token from the draft model. The main model checks it in the next step.
E
Engine
One LLMEngine with its C++ scheduler and its workers. In DeepInfer one engine runs per DP rank.
Engram
A large table of learned vectors. The engine hashes the last 2, 3 or 4 tokens to find a vector. The model adds the result into its hidden state at layers 1 and 14. It holds about 197 billion parameters but needs almost no arithmetic.
EPLB
Expert-parallel load balancer. It counts the tokens each expert receives. At intervals, it decides which experts get extra copies and where those copies go.
Expert
One small feed-forward network inside a MoE layer. V4.1 has 384 routed experts and 1 shared expert per layer.
Expert parallel (EP)
Splitting the experts of each MoE layer across GPUs. Each token travels to the GPUs that hold the experts the router picked for it.
F
FP8, FP4, MXFP4
Floating-point number formats with 8 or 4 bits per value. Quantised arrays in this engine also use scale factors. The group size depends on the array’s format. MXFP4 is a 4-bit format with one power-of-two scale per 32 values.
H
HBM
High-bandwidth memory, the main memory on a GPU. Model weights and the KV cache live here.
Hyper-connections (mHC)
Instead of one residual stream, the model keeps 4 parallel copies of the hidden state per token. Each sub-layer reads a learned blend of the 4 and writes back into all 4. The code calls it hc.
I
Indexer
A cheap scoring network that ranks every long-range KV entry for a query and keeps the best 512. The main attention then reads only those 512 plus the window.
K
KV cache
Reusable numerical state from attention: keys for matching positions, and values for retrieving their information. Saving earlier state avoids recomputing it while it remains cached. In V4.1 one stored vector serves as both key and value.
L
Lockstep
Ranks in one expert-parallel group take compatible steps and join matching communication operations. They need not arrive at the same clock time. Separate groups can progress independently.
Logprob
A probability p stored as its natural logarithm, ln(p). A probability of 1 has logprob 0. A probability of 0 corresponds to minus infinity. DeepInfer reports logprobs after temperature, top-p, top-k and any grammar mask.
M
Mega MoE
One fused kernel that does dispatch, both expert matrix multiplications and combine, using shared memory across the GPUs of one NVLink domain.
MLA
Multi-head latent attention. Each token stores one small vector instead of separate keys and values per head. In V4.1, 64 query heads use one 512-wide vector as both key and value.
Mixture of experts (MoE)
A layer where a router sends each token to a few of many small expert networks, and adds their outputs with the router's weights. V4.1 sends each token to 6 of 384 routed experts plus 1 shared expert.
moe_lb
DeepInfer’s balance measure: sum the busiest GPU’s load for each step and layer, then divide by the sum of the corresponding mean loads. For nonzero work, 1.0 is perfect balance.
MTP
Short for multi-token prediction, the code’s older name for the draft model and its supporting code. Names such as mtp.draft_tokens and "MTP info slot" come from it. An MTP info slot is the row in a GPU pool where the draft model leaves its guesses for one request.
N
nextn
The number of draft tokens proposed per request per step, 5 by default. Code: DEEPINFER_USE_NEXTN_DECODE.
NVLink domain
A set of GPUs joined by NVLink, so each can read the others' memory directly. The Q&A doc also calls it a scale-up domain. In production it means an 8-GPU machine for prefill and a 64- to 128-GPU supernode for decode.
P
P/D disaggregation
Running prefill and decode on separate machines, then sending the KV cache from the prefill machine to the decode machine. DeepSeek also splits off the vision encoder, so the Q&A doc says E/P/D.
Placeholder step
A step with one fake token. A GPU with no local requests runs it to join the other GPUs’ communication operations.
Preemption
Taking a running request out of the batch because memory ran out. DeepInfer picks the request with the longest output and either stops it or requeues it to recompute.
Prefill
The phase that processes the prompt. It computes the KV cache for every prompt token and the first output token.
R
Redundant expert
An extra copy of a hot expert on another GPU, so its tokens can be split between copies. A logical expert is one the router can pick. A physical expert is a slot on a GPU that holds one logical expert's weights.
Rejection sampling
The rule speculative decoding uses to accept or reject a draft token. In ideal arithmetic, it preserves the main model’s distribution, rather than always selecting its top choice. DeepInfer's version allows a tiny, documented bias, to prevent replacement weights rounding to all zeros.
RoPE
Rotary position embedding. It rotates parts of the query and key vectors by an angle that depends on the token's position.
Router
The small layer in MoE that scores every expert for a token and picks the top few. The code calls it the gate.
S
Scheduler
The C++ object that decides, each step, which requests run and how many tokens each processes. Code: csrc/engine/scheduler.hpp.
Seed
A 64-bit number per request that, with the token position, fixes every random draw for that request. Code: seq_seed in SamplingParams.
Sequence
The scheduler's record of one request: its tokens, its block table, its state slot and its status.
Sliding-window attention (SWA)
The model’s recent attention reads a window of 128 positions, including the current position. Later V4.1 layers also read selected long-range entries. A request then needs a fixed-size ring of recent keys and values, not its whole history.
State slot
One fixed-size area of GPU memory per request that holds the sliding-window rings for all layers and some compression state. It does not grow with context length.
Step
One forward pass of the whole model over a batch, plus sampling. In DeepInfer a step is either all prefill or all decode.
Structured output
Forcing the output to follow a format such as a regex, a JSON schema or a grammar. The sampler blocks every token the format does not allow.
T
Temperature
A number that divides the logits before softmax. Low values sharpen the distribution, high values flatten it. DeepInfer turns 0 into 0.00001 and still samples.
Tensor parallel (TP)
Splitting each weight matrix across GPUs, so every GPU does part of every multiplication. DeepInfer does not use it.
Throughput
Tokens produced per second, usually per GPU. In decode, it trades against per-user speed.
Top-k, top-p
Filters before sampling. Top-k keeps the k most likely tokens. Top-p keeps the smallest set whose probabilities add up to at least p.
TTFT, TBT
Time to first token and time between tokens. TTFT is mostly prefill. TBT is the decode step time.
V
Varlen verification
Choosing how many draft tokens to check per request each step, from the confidence head's predictions, instead of always checking all 5. Code: DEEPINFER_ENABLE_MTP_VARLEN, off by default.
Vision encoder (VE)
The network that turns an image into embeddings the language model can read.
W
Wide-EP
Expert parallel across 64 to 128 GPUs, as DeepSeek runs decode. Each GPU holds only a few expert slots, so it reads few expert weights per step.
Y
YOCO
The split of V4.1's 40 layers into two halves, layers 0 to 19 and 20 to 39. The second half reuses one long-range KV cache built at layer 20 instead of building its own. All 40 layers are causal.
Z
Zero-overhead scheduling (ZOS)
DeepInfer’s asynchronous scheduling, on by default. The CPU prepares and queues step N+1 before reading step N’s results. This hides CPU work only while it fits within the overlapping GPU work. Slower CPU preparation can still leave the GPU waiting.
What's next
What the code showed that the doc didn't
There is no quadratic chunk-size solver in the release. The Q&A's cost(q, c) and compute_quota_limit live in the unreleased load balancer. Chunk size is set only by max_num_batched_tokens (csrc/engine/scheduler.hpp:470). flops_estimator.hpp only feeds arithmetic-work statistics.
Prefill and decode never share a step. A NORMAL engine votes across DP ranks every 70 or 100 decode steps.
There is no prefix cache in the block manager. The only in-release sharing is fork_seq.
CP is in the code and README, but the Q&A says production does not use it.
Aborting a prompt between prefill chunks seems to leak its blocks and state slot (csrc/engine/scheduler.hpp:363-367, :569-578). Nothing frees an aborted sequence in ignored_seqs.
FlashMLA never splits V4.1's decode attention, because 128 window + 512 top-k = 640 is at its threshold (vendor/FlashMLA/csrc/api/sparse_decode.cpp:223). So batch invariance is close to free there.
These questions come from all seven research notes. Reading the code could not answer them.
Does the abort-between-chunks leak really happen? Abort a 50,000-token prompt after its first chunk, then check that get_num_free_gpu_blocks() returns to its starting value (csrc/engine/pybind_scheduler.cpp:73).
How long does the offline engine run empty steps at start-up? I expect about 70 placeholder decode steps before the first prefill, because nothing is schedulable at the first schedule() (Pass 2, Thread 1).
What are the measured decode costs and chosen draft counts? Do they reproduce the Q&A table's 5.00 to 3.2-4.4 drafts per step? Why does varlen lose at concurrency 16: stale confidences, a badly calibrated confidence head, or a cost curve measured at full load?
How far does YOCO compact truncation drift from off? No test compares them. examples/yoco/yoco_equiv.py only compares mask with compact.
What does batch invariance cost in throughput across EP sizes, and does it hold across different CP or EP sizes at all?
Is there a batch-invariance gap on the DeepEP path with redundant experts and EPLB off, because the gate's replica choice depends on the batch row?
How often does an EPLB re-plan make balance worse on real traffic, and how long does a re-plan stall the ranks at EP64?
Can one token appear twice in a C1-layer softmax, once through the window and once through the indexer's pick? Logging the indices would settle it.
How long does the Engram fetch take from host memory across ranks, and is it hidden behind layers 0 and 1?
How often do the sampling rules block every token in a row? That is a row where the grammar mask, the token blacklist and the first-token end ban together block every token. Does the device assert then take the whole engine down?
What does the internal compute_quota_limit limiter do, and what are its fitted cost(q, c) numbers? That code is not in the release.
What to run first if you get a GPU
The offline example needs 8 SM100 GPUs for EP8. The decode benchmark curves need 64. Start small, and in this order.
Build the engine and check its stored results.export DEEPINFER_ACCELERATOR=nvidia && ./build.sh, then python -m pytest -v -s tests/models/test_offline_inference.py. This runs the 16,389-token request family from Pass 2 and compares stored perplexity scalars exactly. A pass gives a regression baseline. It does not prove that every intermediate value or execution path matches.
Read the start-up logs. Check the block size against 114,048 bytes and count the empty decode steps before the first prefill. The logs let you check two of my main inferences.
Write and run the abort test from question 1. Use LLMEngine to check whether the suspected leak occurs.
Measure async scheduling. Run python examples/benchmark.py with DEEPINFER_ZERO_OVERHEAD_SCHEDULING=0 and =1 and compare decode avg_SPS and the bubble timer. The benchmark already puts the setting in its trace names (examples/benchmark.py:567).
Measure varlen at small and large batches.DEEPINFER_ENABLE_MTP_VARLEN=1 python examples/benchmark.py --decode_num_requests 16, then 64. Log batch_confidence_error to check whether inaccurate confidence predictions explain the loss at 16.
Measure batch invariance's cost. The same benchmark with DEEPINFER_BATCH_INVARIANT=0 and =1. Pass 4 predicts almost no change in decode attention.
Then check the remaining cases: YOCO compact against off on long prompts, EPLB re-plans with --enable_eplb, and the C1 overlap logging.
Revisions
One line per change: what changed and why. Newest at the bottom of each section.
Research and plan
research/05-decode-speed.md: corrected the claim that ZOS defaults to off. EngineArgs.__post_init__ turns it on for NORMAL and COMPLETION engines (deepinfer/engine/arg_utils.py:51-55). The reader had looked only at the EnvVar.bool() default.
After Pass 2
Pass 1, Thread 1: "a cut prompt always ends the step" became "ends the step", for NORMAL engines (csrc/engine/scheduler.hpp:543). A PROMPT engine keeps looking.
Pass 1, Thread 1: added a "simplified here" line before the toy step table. The real engine only starts prefill at vote points, every 70 decode steps by default, not whenever a prompt waits. Pass 2 showed the vote.
Pass 1, Thread 4: "every few thousand steps" became the Q&A doc's real numbers, 1,000 steps for prefill and 10,000 for decode. The vague figure hid that prefill and decode differ by 10x.
Pass 2: fixed six line references that were one or two lines off. For example, the force top-k flag is examples/offline_inference.py:17, not :18. I built tools/check_refs.py to catch these.
Pass 2: every offline prompt carries an empty image dict. This prevents scheduling on the first step. I now expect about 70 empty decode steps before the first prefill. Pass 1 had no way to show this, because it comes from the order of two calls inside step().
After Pass 3
Plan, Thread 3: moved the pending C2 token in ca_state from Pass 3 to Pass 2. The request at position 16,389 provides a concrete example. Pass 3 uses a chunk wholly before the YOCO keep window instead, so no example repeats.
Pass 1, Thread 5: added "simplified here" after the leftover-odds example. The real kernel scales the draft's odds by e^(-0.00005) before subtracting, so the leftover never rounds to all zeros (deepinfer/model_executor/layers/tilelang_ops/common.py:53).
Glossary, Abort: the old text said the blocks come back on the next free pass. Pass 3 found that an abort between prefill chunks seems to leak them, so the entry now says it depends on the queue.
Glossary, Rejection sampling: added the documented bias. "Keeps the output on the main model's distribution" was true only to within (1 + 5e-5).
Glossary, Preemption: added that the victim is the longest-output request. Pass 3 showed the scheduler's own comment says the opposite.
Pass 3, top-p exercise: my first draft credited the tie rule for keeping two tied tokens. The mass alone needed both. I rewrote the answer to say when the tie rule matters.
Understanding that grew in Pass 3: I ran the real rebalance_experts_global on CPU. It matched my calculations and found layouts where the greedy plan worsens the starting balance. Before that, I had only the reader's port to go on.
After Pass 4
Checked out vendor/FlashMLA, vendor/DeepGEMM and vendor/DeepEP with git submodule update --init --depth 1, as the prompt allowed for Pass 4. A reader wrote research/07-kernels.md from them.
Pass 2, Thread 6: my guess that batch-invariant mode "pins how a long sum is split" is now confirmed. I kept the guess, marked it as a guess at the time, and pointed to Pass 4. FlashMLA already skips split-KV when the window plus top-k is at most 640. V4.1’s 128 + 512 is exactly 640. The switch therefore changes nothing in this model’s decode attention.
Pass 3, Thread 4, Case J: the even split of a hot expert's tokens across its copies was a guess. The gate kernel shows a fixed stride over the token's batch row, (rank + row × 23333) % copies. This does not measure current load, and selected rows can split unevenly across copies. I updated the case and pointed to Pass 4.
Pass 4, Thread 2: my first draft said Llama-3-70B-sized KV would fill the 3FS store with "a few hours" of traffic. The sum gives about 3 minutes. Fixed.
Pass 4, Thread 3: added what the kernels confirm. Router scores are sqrt(softplus(logit)), there is no group-limited routing, and the attention sink only adds to the softmax denominator.
Pass 4: choosing replicas by batch row may leave a narrow batch-invariance gap. It affects the DeepEP path with redundant experts and EPLB off. I couldn't confirm it without a GPU, so it is tagged as inference and listed in "What's next".
tools/check_refs.py skipped .cu and .cuh files at first. I added the extensions and rechecked everything.
Final read, Pass 0 to Pass 5
Pass 0: the thread table still listed "odd positions" under Pass 3. That example moved to Pass 2 after Pass 3, so the table now says so.
Pass 0: the system diagram used EP, DP and "drafts" before anything defined them. Added one paragraph after the diagram that names all three and points to Threads 4 and 5.
Pass 1: "they send their draft sentence" in the newsroom analogy clashed with draft tokens. Now "their sentence".
Pass 1: "in a real office each layer would need its own cabinet" mixed the analogy with the model. Now "in a plain transformer".
Pass 2: "MTP info slot" appeared without a definition. Added one sentence where it first appears, and a glossary entry for MTP.
Pass 2: EP, DP and CP were not linked at first use. Linked them in the setup table.
Pass 2: the line quoting Pass 1's "a cut prompt always ends the step" no longer matched Pass 1's revised wording. Rewrote it to match.
Pass 3: four path:line references ended in ] instead of a backtick, so the checker never saw them. All four had a digit in the path, for example prepare_tensor_input_v4.cpp, which my earlier fix pattern missed. Fixed, and rechecked with a digit-aware pattern across all files.
All passes: Passes 3 to 5 had no glossary links. tools/link_glossary.py now links the first use of each term in every pass, 137 links in all.
Glossary: added MTP and Wide-EP, both used in the passes without entries.
Diagrams instead of ASCII
Pass 0, Pass 1, Pass 2 and Pass 3: six box-and-arrow ASCII pictures are now Mermaid diagrams, which Pele draws at build time. They are the whole system, one token through one layer, request states, one MoE layer at EP8, and the two async-step timelines.
Pass 1 to Pass 5: ASCII grids for block contents, the state slot, expert token counts, truncated prefill and draft verdicts are now tables.
Pass 2: the ASCII held nine short code references, such as [worker.py:122], that the checker couldn't see. They are now full references in the prose, and all of them resolve.
Pass 2, request states: the six finished states sit in one box with their causes, not six boxes. Six boxes side by side shrank the text until it was unreadable.
Pass 1, Thread 2: new figure that steps through the drawer toy. Blocks come from the head of the free list. A fork shares full drawers. The clerk returns a drawer to the tail only when its count reaches 0.
Pass 2, Thread 1: new figure of the prefill vote on four ranks. It follows the scheduler's rule: ALL points every 70 decode steps, ANY points every 100, and a prompt step resets the count. The arrivals are made up.
Pass 2, Thread 3: new figure of the 40 layers. It shows which layer owns the long-range KV and the top-k picks each layer uses, which half each layer is in, and the draft-model taps. It sits after the layer-27 exercise, so it doesn't give away the answer.
All figures: titles now say what the figure shows, not just its topic.
Independent factual and learning review, 21 October 2026
Pass 0: pinned the source revision and separated engine-local cache management from the production service. Scoped collective coordination to one expert-parallel group. Qualified CPU/GPU overlap.
Pass 1: replaced “never computes them twice” with cache reuse while state remains available. Named recomputation after preemption and missing windows.
Pass 1: acknowledged the first two window-only layers and the shared expert. Decode rows now have an explicit growing cost instead of a universal “six costs little more than one” claim.
Pass 1: carried fixed verification length, identical inputs and the supported NVIDIA configuration into the repeatability table. Qualified ideal rejection sampling by the implementation's numerical approximations.
Pass 2: labelled the request as a worked trace from upstream configuration. Stated readiness and memory assumptions. Added the scheduler’s second prefill vote. Preserved all interactive-figure build markers.
Passes 2 and 4: separated the 891-byte paged-cache slope from active state, reserved copy capacity and other GPU memory. Made the ring size conditional on the draft limit.
Pass 2: distinguished perplexity of the processed sampling distribution from raw-model perplexity. Matching settings alone does not turn one into the other.
Pass 3: stated that the batch-invariance test forces the continuation and compares exposed values exactly. Removed the claim that zero aggregate discrepancy proves bitwise equality. The helper promotes to double, but cancellation can still hide distinct inputs.
Pass 4: limited the PPL golden test to the aggregate it asserts. Removed claims that any numerical change must fail it or that matching Ascend aggregates prove broader invariance.
Pass 4: kept the 80% load-balancing comparison within its documented scope. Removed an unsupported conversion of every chart into production throughput. Removed an unchecked comparison with other engines.
research/factual-review.md: corrected the numerical counterexample to account for the helper's promotion to double. The revised example uses distinct float32 inputs near zero and still produces a zero double-precision discrepancy.
research/06-correctness.md: aligned the original test-reading notes with the corrected limits of exact exposed-value comparisons and aggregate KL/PPL checks.
Glossary and Pass 5: separated numerical batch invariance from repeated sampled text. Stated the deterministic-drafting proposal’s equal-probability assumption rather than treating the aggregate KL test as proof. Kept its ideal-arithmetic and independent-draw limits.
Guided learning experience and verification
Added an introduction to the next token that needs no specialist background. Kept all six original technical passes for deeper reading.
Added tested batch, cache, expert, verification, draft-cost and arithmetic experiments plus assessment and a free-response check. Prompt completion samples its first answer token. Each scene states its toy limits.
Improved navigation, mobile section access, glossary focus/history, numeric alternatives, local fonts and no-JavaScript reading. Pre-rendered the initial index to prevent a measured layout shift.
Added source/model ledgers, independent reviews, reproducible builds, artifact validation, model/browser checks, local performance/screenshot evidence and a CI workflow. The review states that GPU performance and human learning remain untested.
Sources and model limits
Research checked on 21 October 2026. This explanation describes DeepInfer at commit 774ce244b87cc0eb8d4b94ab9f5de2ab22049f01, dated 20 October. I checked the source without reproducing a deployment or benchmark. During review, upstream had reached revision 9bd9cf2. This explanation keeps the earlier dependency versions. It does not include that revision’s build and DeepEP changes.
What the release contains
The official README describes an offline inference release supporting DeepSeek-V4.1-Flash, NVIDIA SM100 and Huawei Ascend 950. It contains engine-local KV management, model execution, scheduling and sampling. A complete inference service also requires an external KV system, disaggregation and load balancing across replicas. The production Q&A describes these parts. They are outside the engine release.
Central claim ledger
ID / claim
Primary evidence and location
Status and limitation
Where taught
C1: each engine step is entirely prefill or entirely decode. Group votes coordinate the phases
I checked the code and official config, with a separate agent review. The saved config matches official revision 1bae0e1 byte for byte. Its SHA256 is 8be45ce0476004a3f529fd896115a4a2e800a129ad2d3ec05b16050f52e21879. Toy records do not estimate bytes. The deeper passes’ 891 B/token covers paged blocks only. It excludes state, weights and unused capacity.
Memory / Passes 2–4
C3: attention DP and MoE EP create collective participation and load-balancing constraints
The Q&A describes the deployment. The code implements the placement algorithm. Each exchange covers one communication group, rather than every GPU in a service. The toy assumes equal-cost jobs and omits communication and weight movement.
Experts / Passes 3–4
C4: ideal speculative rejection sampling preserves the target distribution
Numerical checks cover the probability-mass identity across draft distributions. The real kernel scales draft probabilities by exp(−0.00005) before calculating replacement weights. This introduces a documented bias. The toy uses the ideal rule. The same distribution need not produce an identical sequence.
Drafts / Passes 1–4
C5: verification length should maximise expected progress per cost
A separate agent checked the source. The toy assumes constant conditional acceptance and invented linear costs. The real scheduler uses confidence estimates and graph cost curves.
Optional draft-cost experiment / Pass 3
C6: fixed seed alone does not guarantee batch-independent output
The NVIDIA README documents these conditions. I did not test them on a GPU. The upstream test forces the continuation to compare numerical results. The browser example uses JavaScript floating point at a deliberately sensitive boundary. It does not estimate failure frequency.
Repeats / Passes 3–4
The notes in research/00 through research/07 give code locations, worked calculations and questions that need accelerator tests. A separate agent checked the main claims and source identity. This is another model’s review. It does not replace reproduced results or human expert review.
What the experiments can establish
The experiments show how separate requests share work and how history differs from fixed window capacity. They also show placement bottlenecks, probability correction and floating-point ordering. They do not estimate deployment speed, GPU utilisation, cache capacity or output quality. The page does not run a language model.
The batch toy’s phases are deliberately simpler than the engine’s policy. The memory toy uses eight layers, two owners, four-position blocks and an eight-position ring. Real state includes more components and spare capacity for rejected drafts. The expert toy assumes perfect splitting and identical work per job. The probability toy has three tokens, ideal arithmetic rules and no grammar or top-p. The draft-cost toy uses constant conditional acceptance and invented milliseconds. The sum-order example is an intentionally sensitive boundary.
All central models share calculation code with their rendered numbers. The repository’s quality review records automated checks and browser results. No human participant study has tested learning.
Open questions
Engine timing and cache allocation during abort still need accelerator tests. So do exact repeats, replay drift and the placement/invariance cases in the advanced passes. These are open questions, not verified bugs or measurements. The open-questions page suggests experiments. The revisions page records corrections to the explanation.
Attribution and access
DeepInfer and the kernel repositories I inspected are DeepSeek projects. Their Apache 2.0 licences apply. I wrote the diagrams and introductory models for this explanation. The speculative-decoding algorithm is attributed above. Font licences accompany bundled font files. Accessibility work targets WCAG 2.2. Automated checks alone do not prove conformance. The page uses no analytics, accounts or remote inference calls. It stores your Day/Night preference locally.