DeepInfer Explained

Batching

BeginnerLearn the basicsabout 10 min read + 7 min deep dives

Code version: DeepInfer @ 9bd9cf2 · 2026-10-21

In one sentence

Batching runs the tokens of many sequences through the model in one step, so that one read of the weights serves all of them.

The idea#

Why run many sequences together?#

In each step, the model reads all its weights from device memory. For one sequence, a decode step computes very little: one new token (or a few). So the device spends most of the step on memory reads, and its math units wait.

A batch puts the tokens of many sequences into the same step. The weights come from memory once and serve every token in the batch. Up to a limit, 64 tokens take about the same time as 1 token. The page GPUs, kernels, and speed gives the numbers.

An engine must answer three questions in every step:

  1. Which sequences run? This is the job of the scheduler.
  2. How are their tokens stored? With padding, or packed end to end.
  3. How many tokens fit? A token budget limits the size of one step. A long prompt must then be split.

Static and continuous batching#

With static batching, the engine takes a group of requests and runs them until the last one ends. A sequence that ends early keeps its place in the batch, but it does no work. New requests wait for the next group.

With continuous batching, the engine makes a new batch in every step. A finished sequence leaves at once. A waiting request can join at the next step. This is also called iteration-level batching.

A worked example. The batch has 3 slots. Five requests want 3, 7, 4, 5 and 2 output tokens (A to E). One step makes one token per sequence.

Static batchingContinuous batching
Batch 1A, B, C run steps 1-7 (B is the longest)A, B, C start at step 1
D startsstep 8step 4 (A ended at step 3)
E startsstep 8step 5 (C ended at step 4)
Last tokenstep 12step 8
Slot-steps used21 of 3 × 12 = 36 (58 %)21 of 3 × 8 = 24 (88 %)

The useful work is the same: 3 + 7 + 4 + 5 + 2 = 21 tokens. Continuous batching finishes in 8 steps instead of 12, and D and E get their tokens much earlier.

An everyday comparison#

Think of a tour bus and a city tram. The tour bus leaves with a full group and comes back only when the last person is ready. Empty seats stay empty for the whole tour. The tram stops at every station. People get off, and new people get on at once. Continuous batching is the tram.

The comparison stops here: a tram seat costs the same for every person, but a batch slot does not. A new sequence must first read its whole prompt (prefill). That step costs much more than one decode token.

Padded and packed batches#

The sequences of a batch have different lengths. A simple tensor has rectangular rows, so the engine has two choices:

  • Padded. Make a tensor [batch, max_len]. Fill the short rows with pad tokens. The device computes the pad tokens too, and then drops the results.
  • Packed (varlen). Put all tokens end to end in one tensor [total_tokens]. A second tensor, cu_seqlens, marks where each sequence starts. This is a packed batch.

A worked example. Three prompts have 5, 2 and 3 tokens.

padded:  [3, 5] = 15 slots, 5 of them padding (33 % waste)
packed:  [10]   = 10 slots, no padding
         cu_seqlens = [0, 5, 7, 10]
         sequence 0 = tokens 0..4, sequence 1 = tokens 5..6, sequence 2 = tokens 7..9

Matrix multiplies do not care about sequence boundaries: each token row is independent. Only attention needs the boundaries, because a token must look only at tokens of its own sequence. Varlen attention kernels read cu_seqlens to find them.

Chunked prefill#

One step has a token budget. A prompt can be longer than the budget. With chunked prefill, the engine splits the prompt into chunks and runs one chunk per step. The KV cache keeps the chunks that are done, so the next chunk can attend to them.

Example: the budget is 4,096 tokens and a prompt has 10,000 tokens. The chunks are 4,096, 4,096 and 1,808 tokens, in three prefill steps.

Chunking also keeps each step short. Without chunks, one very long prompt makes one very long step, and all decoding sequences wait for it.

Why decode is memory-bound#

A prefill step has many tokens per sequence. A decode step has only one token per sequence (or a few, with speculative decoding). So in decode, the batch size is the number of running sequences.

Take one weight matrix of 4,096 × 4,096 BF16 values: 32 MiB. The table uses illustrative device numbers: 8 TB/s memory bandwidth and 2,250 TFLOP/s of BF16 math.

Tokens in the batchBytes read (about)Time for the bytesFLOPsTime for the math
133.6 MB4.2 µs33.6 M0.015 µs
6434.6 MB4.3 µs2.1 G0.95 µs
51242.0 MB5.2 µs17.2 G7.6 µs

The step takes about as long as the larger of the two times. With 1 or 64 tokens, the memory read sets the time: the multiply is memory-bound. So 64 tokens cost almost the same as 1 token. With 512 tokens, the math sets the time. This is why decode needs large batches. It is also why the KV cache size matters so much: the cache limits how many sequences can run at the same time.

Mixed steps or separate steps#

Many engines mix prefill chunks and decode tokens in one step. Others keep the two kinds apart: a step is a prefill step or a decode step. With separate kinds, every decode step has the same simple form, one or a few tokens per sequence. The cost is that a waiting prompt must wait for a prefill step, and the decoding sequences pause during that step.

DeepInfer keeps the two kinds apart. It also has engine types that do only one kind: a PROMPT engine does only prefill, and a COMPLETION engine does only decode (csrc/engine/scheduler.hpp:37-43). The deployment notes describe separate prefill and decode machines in production (doc/inference_deployment_qa.md:31).

The picture#

Figure: panel 1 shows that continuous batching leaves fewer idle slots. Panel 2 shows that a packed batch has no padding. Panel 3 shows the two step kinds of DeepInfer. Click the diagram to zoom.
Open full size ↗

Read panel 1 from left to right: each column is one step. A dashed cell is a slot with no work. Panel 2 shows the same three prompts in the two layouts. Panel 3 shows what one DeepInfer step holds; the budget of 4,096 tokens is only an example.

In DeepInfer#

This is a basics page, so this part only gives pointers. The pages The scheduler and Input metadata (varlen batching) explain the full code.

  1. The C++ scheduler makes one batch per step, and the batch has one kind. In a NORMAL engine, _schedule() returns a prefill batch or a decode batch, never a mix (csrc/engine/scheduler.hpp:288-341). All data-parallel ranks vote, so all of them do the same kind of step. See Data parallelism and step votes.
  2. Prefill fills a token budget, decode takes all running sequences. A prefill step takes waiting prompts in order until max_num_batched_tokens is full. It cuts the last prompt to the rest of the budget (csrc/engine/scheduler.hpp:467-475). A decode step takes every running sequence, up to max_num_seqs (csrc/engine/scheduler.hpp:622-643).
  3. The batch is packed. The model gets the tokens end to end, with cu_seqlens_q and cu_seqlens_k of shape [num_seqs + 1]:
    cu_seqlens_q: torch.Tensor | None = None
    """cumsum(seqlen_q). Shape: [num_seqs + 1]."""
    cu_seqlens_k: torch.Tensor | None = None
    """cumsum(seqlen_k). Shape: [num_seqs + 1]."""
cu_seqlens_q marks the new tokens of each sequence in the packed batch. cu_seqlens_k also counts the tokens that are already in the KV cache.

Deep dive#

The sections below give the full detail. Open them one at a time, or use Open all deep dives at the top of the page.

Deep dive

Static and continuous batching: the general formula

Let a batch have S slots, and let request i need ni decode steps.

  • Static. A group of S requests takes max(ni) steps. The useful slot-steps are Σ ni. Use = Σ ni / (S × max ni). Use is low when the lengths differ much. Output lengths of a chat model differ very much.
  • Continuous. A slot is idle only when no request waits. With a long queue, use is near 100 % for decode. The cost moves to prefill: each joining request needs one or more prefill steps.

Worked example with prefill. Use the five requests of "The idea" (3 slots; A to E want 3, 7, 4, 5 and 2 tokens). Now a request first needs a prefill step, and the prefill step makes its first output token. So request i needs 1 prefill and ni − 1 decode steps. A new request joins as soon as a slot is free.

EngineStepsTotal
Staticprefill A, B, C · 6 decode steps (until B has 7) · prefill D, E · 4 decode steps (until D has 5)12
Continuous, separate step kinds (like DeepInfer)1: prefill A, B, C · 2-3: decode · 4: prefill D (B, C pause) · 5: decode (C ends) · 6: prefill E · 7-9: decode (E ends at 7, B and D at 9)9
Continuous, mixed steps1: prefill A, B, C · 2-3: decode · 4: prefill D + decode B, C · 5: prefill E + decode B, D · 6-8: decode (E ends at 6, B at 7, D at 8)8

The step count is not the full story: a prefill step with long prompts takes much longer than a decode step. In the separate-kind engine, the running sequences pause during each prefill step. So DeepInfer does not try a prefill step at every step. It tries only at fixed decode-step intervals (deep dive "When does DeepInfer do a prefill step?").

Deep dive

Packed batches: the tensors, with shapes

A packed batch in DeepInfer is an InputMetadata object. These fields describe the token layout (deepinfer/model_executor/input_metadata/input_metadata.py:32-38, deepinfer/model_executor/input_metadata/input_metadata.py:261-264):

FieldShapeMeaning
is_promptboolTrue for a prefill step, False for a decode step.
input_ids[total_q]The token ids of all new tokens, end to end.
positions[total_q]The position of each token in its own sequence.
cu_seqlens_q[num_seqs + 1]cumsum(seqlen_q), with a leading 0.
cu_seqlens_k[num_seqs + 1]cumsum(seqlen_k): cached tokens plus new tokens.

Worked example: a prefill step with a cached prefix. Sequence 0 is a new prompt of 5 tokens. Sequence 1 has 100 tokens in the cache and sends a chunk of 2 tokens. Sequence 2 is a new prompt of 3 tokens.

seqlen_q     = [5, 2, 3]            total_q = 10
seqlen_k     = [5, 102, 3]
cu_seqlens_q = [0, 5, 7, 10]
cu_seqlens_k = [0, 5, 107, 110]
positions    = [0 1 2 3 4 | 100 101 | 0 1 2]

The two new tokens of sequence 1 have positions 100 and 101. They attend to 102 keys: 100 from the cache and 2 new ones. Rule: seqlen_k = cached tokens + in-flight tokens + seqlen_q (csrc/engine/scheduler.hpp:697).

Padding still exists in one place. A decode step that replays a CUDA graph must match a captured size. The model runner pads the list of sequences with placeholder entries up to the next captured size (deepinfer/worker/model_runner.py:182-191). The padding is at the end of the packed tensor, not inside each row. The field valid_token_mask marks the padding tokens (deepinfer/model_executor/input_metadata/input_metadata.py:44-46).

Deep dive

The prefill step in DeepInfer, step by step

_schedule_prefill takes sequences from the front of the waiting queue, in order (first in, first out). For each sequence it checks these rules. The first rule that fails stops the loop or skips the sequence (csrc/engine/scheduler.hpp:351-546):

  1. A sequence that is already finished (aborted, or a failed image or grammar) goes to ignored_seqs (lines 363-368).
  2. A sequence that is not yet schedulable stops the loop (lines 369-371). For example, the multimodal worker has not finished it.
  3. A prompt longer than prompt_limit is ignored with a warning (lines 377-384). prompt_limit is max_model_len with chunked prefill, else min(max_model_len, max_num_batched_tokens) (csrc/engine/scheduler.hpp:112).
  4. The KV blocks and a state slot must be free, else the loop stops (LATER) or the prompt is ignored (NEVER) (lines 386-397).
  5. The number of running sequences must stay at or below max_num_seqs (lines 440-442).
  6. The sum of the sequence lengths (seqlen_k) must stay at or below prefill_max_sum_seqlen_k (lines 444-449). The default is 230, so this limit is normally off (deepinfer/environ.py:9).
  7. If the prompt does not fit the rest of the budget, cut it to the rest (lines 470-475). With context parallelism, the rest is rounded down to a multiple of cp_align = 2 × cp_size (csrc/engine/scheduler.hpp:90).
  8. A cut prompt needs chunked_prefill; without it the loop stops (lines 504-507).
  9. The scheduler commits: it adds the tokens to num_batched_tokens and allocates the blocks (lines 515-520).
  10. In a NORMAL engine, a chunked prompt is the last one in the step (lines 543-545).

The budget. If you do not set max_num_batched_tokens, the default is max(max_model_len, 4096, max_num_seqs × (1 + nextn)). If you set it, it must be at least max_num_seqs × (1 + nextn), the largest decode batch (deepinfer/config.py:219-228).

def _get_max_num_batched_tokens(
    max_model_len: int,
    max_num_batched_tokens: int | None,
    max_num_seqs: int,
) -> int:
    max_num_decoding_tokens = max_num_seqs * (1 + envs.DEEPINFER_USE_NEXTN_DECODE)
    if max_num_batched_tokens is not None:
        assert max_num_batched_tokens >= max_num_decoding_tokens
        return max_num_batched_tokens
    return max(max_model_len, 4096, max_num_decoding_tokens)

Worked example. max_model_len = 65,536, max_num_seqs = 256, nextn = 5 (the default, deepinfer/environ.py:129). The default budget is max(65,536, 4,096, 1,536) = 65,536 tokens. Prompts of 30,000, 20,000 and 40,000 tokens wait. Step 1 takes 30,000 + 20,000 + the first 15,536 tokens of the third prompt. The third prompt then waits for the next prefill step with 24,464 tokens left. This needs chunked_prefill=True. The EngineArgs default is False (deepinfer/engine/arg_utils.py:42); the example scripts set True (examples/_config.py:120).

Deep dive

The decode step in DeepInfer, step by step

_schedule_decode builds the decode batch (csrc/engine/scheduler.hpp:559-660):

  1. Free the finished sequences, and clean finished entries out of waiting (lines 562-577).
  2. Move sequences from the prompted queue (prefill done) into running, up to max_num_seqs (lines 595-620). This is the moment when a new sequence joins the decode batch.
  3. Sort running by output length and give each sequence a block if it needs one. If no block is free, preempt (lines 622-643). The page The KV cache explains this part.
  4. Sort the scheduled sequences by total length, longest first (lines 648-649).
  5. Get seqlen_q for each sequence and add them up (lines 651-657).

Without the variable-length draft feature, seqlen_q is 1 + the number of draft tokens (csrc/engine/mtp_varlen.hpp:153-157). After a prefill the draft list has 5 entries, so each sequence sends 6 tokens: the last real token and 5 drafts. See Speculative decoding.

Sizes. With max_num_seqs = 256 (the default, deepinfer/engine/arg_utils.py:36) and nextn = 5, one decode step has at most 256 × 6 = 1,536 tokens. The CUDA graph runner captures graphs up to the same size (deepinfer/worker/model_runner.py:76-79). So for the matrix multiplies, the decode batch has up to 1,536 rows, not 256. Draft tokens make decode less memory-bound.

Check. schedule() stops with an error if a batch has more than max_num_batched_tokens tokens (csrc/engine/scheduler.hpp:671-673).

Deep dive

When does DeepInfer do a prefill step?

In a NORMAL engine, the scheduler counts decode steps in decode_step. A prefill step sets it back to 0 (csrc/engine/scheduler.hpp:676-680). The scheduler tries a prefill step only at two kinds of points:

            bool is_any_point = (decode_step != 0) && (decode_step % prefill_any_attempt_interval == 0);
            bool is_all_point = (decode_step % prefill_attempt_interval == 0);
            if (!is_any_point && !is_all_point) {
                return _schedule_decode(acquire);
            }
            bool want = (get_gpu_cache_usage() <= GPU_CACHE_PREFILL_WATERMARK) && _has_prefill_waiting();
            bool do_prefill;
            if (is_any_point) {
                do_prefill = (_dp_allreduce(want ? 0 : 1, c10d::ReduceOp::MIN) == 0); // ANY:任一 rank 想做即触发
            } else {
                do_prefill = (_dp_allreduce(want ? 1 : 0, c10d::ReduceOp::MIN) == 1); // ALL:全员想做才触发
            }
            if (!do_prefill) {
                return _schedule_decode(acquire);
            }

Translated comments (Chinese → English)

  • 316 ANY: one rank that wants a prefill is enough.
  • 318 ALL: all ranks must want a prefill.
  • ALL points: every DEEPINFER_PREFILL_ATTEMPT_INTERVAL decode steps, default 70. All data-parallel ranks must have prefill work.
  • ANY points: every DEEPINFER_PREFILL_ANY_ATTEMPT_INTERVAL decode steps, default 100. One rank with work is enough (deepinfer/environ.py:174-178).
  • A rank wants a prefill only if cache usage is 90 % or less and the first waiting sequence is schedulable (line 313).

Step 0 is an ALL point. So right after a prefill step, the next step is a prefill step again if all ranks still have prompts. The comment of the variable gives the reason for the interval. Translated: "Each rank collects pending prompts before a collective prefill." (deepinfer/environ.py:175-176).

Worked example (one rank). A new request arrives while 200 sequences decode, at decode_step = 12. The next ALL point is 70. So the request waits 58 decode steps before its prefill. If each decode step takes 20 ms (an illustrative number), it waits about 1.2 s before its first token.

The vote, the second vote and the empty "follower" prefill step are on Data parallelism and step votes.

What can go wrong#

  • A prompt that is too long. Without chunked prefill, a prompt longer than max_num_batched_tokens is ignored, with the warning "Input prompt ... is too long" (csrc/engine/scheduler.hpp:377-384). Turn on chunked_prefill, or increase the budget.
  • A budget that is too small. If you set max_num_batched_tokens below max_num_seqs × (1 + DEEPINFER_USE_NEXTN_DECODE), start-up fails on an assert (deepinfer/config.py:224-226). It must also be at least one block (128 tokens) (csrc/engine/scheduler.hpp:116).
  • Slow first token while many sequences decode. A new prompt can wait up to 70 decode steps for a prefill point (csrc/engine/scheduler.hpp:308-312). With more than one DP rank, the ALL vote can fail; then the prompt waits for the next ANY point, up to 100 decode steps. Lower DEEPINFER_PREFILL_ATTEMPT_INTERVAL for a lower first-token latency, at some cost in decode speed.
  • A full cache blocks new prompts. Above 90 % cache usage, a rank does not want a prefill (csrc/engine/scheduler.hpp:313). The running sequences must finish first.

Why it matters. Batch size is the main control of the speed of an inference engine. Large decode batches make the device do useful math instead of waiting for memory. Packed batches avoid wasted work. The scheduler decides which tokens go into each step, so most speed settings are batching settings.

Check yourself#

  1. 1
    Why does a decode step for 64 sequences take about the same time as a decode step for 1 sequence?
    easy
    Show answer

    Both steps must read all weights from device memory once. With few tokens, this read takes much longer than the math. So the read sets the time, and the extra 63 tokens add almost nothing. The operation is memory-bound.

  2. 2
    Three prompts have 6, 1 and 4 tokens. Give cu_seqlens for the packed batch, and the number of padding slots in a padded batch.
    easy
    Show answer

    Packed: cu_seqlens = [0, 6, 7, 11], 11 tokens, no padding. Padded: a [3, 6] tensor = 18 slots, so 18 − 11 = 7 padding slots.

  3. 3
    A DeepInfer NORMAL engine has 50 running sequences and 3 waiting prompts. Can one step hold 50 decode tokens and one prompt chunk?
    medium
    Show answer

    No. In a NORMAL engine each step is a prefill step or a decode step. _schedule() returns the result of _schedule_prefill or of _schedule_decode, never both (csrc/engine/scheduler.hpp:295-341).

  4. 4
    You do not set max_num_batched_tokens. max_model_len is 8,192, max_num_seqs is 1,024, and nextn is 5. What is the token budget, and why?
    hard
    Show answer

    The default is max(max_model_len, 4096, max_num_seqs × (1 + nextn)) = max(8,192, 4,096, 6,144) = 8,192 (deepinfer/config.py:224-228). The third term makes sure that the largest decode batch (1,024 sequences × 6 tokens) always fits in one step.

  5. 5
    With one rank and default settings, a new request arrives at decode_step = 71. At which decode step does DeepInfer next try a prefill step?
    hard
    Show answer

    ALL points are at multiples of 70, ANY points at multiples of 100 (csrc/engine/scheduler.hpp:308-309, deepinfer/environ.py:174-177). After 71, the next multiple of 70 is 140, and the next multiple of 100 is 100. So the scheduler tries at decode_step = 100 (an ANY point). With one rank, ANY and ALL give the same result.