Skip to content

Chunked prefill (mixed prefill + decode batches)

ModuleL10.3 · build · Rust · Pass 7 · 5 to 8 h
You buildrust/crates/tl-engine/src/chunk.rs: the Chunked prefill policy, plan (one step’s mixed batch for the runner and the rows to sample), and split
Contractno Rust trait contract file yet: section 4 pins the API; prefill_chunk of [engine] in config/runtime.schema.json
Testscourse/tests/rust/l10_3.rs, 7 tests (what they check: section 4)
NeedsL10.2 the scheduler and its PrefillPolicy (chapter) · L10.1 the runner and ForwardBatch (chapter) · or --ref-deps
Used byL10.5 (the engine loop builds every step with plan; [engine].prefill_chunk turns Chunked on)
MilestoneMS-L10
Optional depthAgrawal et al. 2023, SARATHI: chunked prefills (free); Agrawal et al. 2024, Sarathi-Serve (free); vLLM chunked prefill docs (free)
  • A long prompt prefilled in one step makes that step slow and every running request waits for it; in chunks of at most prefill_chunk tokens it shares each step with the decodes (long_prompt_progresses_beside_decodes).
  • Every step stays within max_batch_tokens: decodes first, then prefill chunks in what is left (token_budget_never_exceeded).
  • A mixed batch is one sequence per decode and one per chunk; only decodes and chunks that end their sequence are sampled (plan_samples_only_sequence_ends).
  • Chunking changes when K and V are computed, never their values: the logits after the last chunk agree with one whole prefill within floating-point tolerance (chunked_prefill_matches_whole_within_tolerance).
Terminal window
ol start L10.3 # stubs tl-engine/src/chunk.rs
ol tests L10.3
ol check L10.3

L10.2 prefills a prompt whole, in one step, and a prompt longer than the budget may only run in a step of its own. Two costs follow. While a 2000-token prompt runs, every decoding request waits for that step: its time per output token (TPOT) spikes. And a long prompt can wait for a step of its own for a long time while others decode (whole_prompt_waits_for_a_step_of_its_own). Chunked prefill splits the prompt across steps and mixes the pieces with decodes, so both waits are bounded.

SymbolMeaningType
β\betatoken budget per step (max_batch_tokens)integer
κ\kappachunk size (prefill_chunk)integer
mmtokens of a prompt still to prefillinteger
dddecodes in the stepinteger
τ(tokens)\tau(\text{tokens})time of one step, roughly linear in its tokensseconds

Time to first token (TTFT) is the time from arrival to the first generated token; for a prompt it needs every chunk done. Time per output token (TPOT) is the time between a running request’s tokens, one step. A step’s time grows with the tokens it processes, so TPOT is about τ(d+prefill tokens in the step)\tau(d + \text{prefill tokens in the step}).

Chunking fixes the step’s size: each step takes the dd decodes first, then gives each prefilling request min⁡(m,κ,budget left)\min(m, \kappa, \text{budget left}) tokens. TPOT is bounded by τ(β)\tau(\beta) whatever prompts arrive. A prompt of mm tokens needs about ⌈m/min⁡(κ,β−d)⌉\lceil m / \min(\kappa, \beta - d) \rceil steps to its first token instead of one big step, so TTFT for long prompts rises a little while TPOT for everyone else stops spiking. The right κ\kappa is measured, not derived: L10.7 exports TTFT and TPOT histograms, and load.01 drives the engine to read them.

Splitting a prompt into chunks is safe only if the K and V of each position, and the final logits, agree with one whole prefill. Absolute positions (pos =ps+i= p_s + i, RoPE at the same angle) and writing each chunk’s K and V to the same blocks preserve cached values. Candle can use different floating-point reduction paths for different tensor shapes, so the test compares logits within a small tolerance.

plan(scheduler, schedule_output) builds what the runner takes:

  • one ForwardSeq per decode: the newest token, at position nr−1n_r - 1;
  • one ForwardSeq per chunk: tokens start..start + len at start;
  • the rows to sample: every decode, and each chunk whose last flag says it reaches the end of the sequence. A middle chunk’s logits predict a token the prompt already has, so they are computed and thrown away.

The policy itself is one line: Chunked { chunk }.chunk_len(m, budget, _) = min(m, chunk, budget).

Budget β=8\beta = 8, κ=4\kappa = 4, blocks of 4. A has a 2-token prompt and max_tokens 3; B has a 10-token prompt and max_tokens 1; both arrive at step 1.

StepDecodesChunksTokensSampled
1noneA 0..2 (last), B 0..46A
2AB 4..85A
3AB 8..10 (last)3A, B

B’s first token comes at step 3, and no step exceeds 8 tokens. Without chunks (WholePrompt), B (10 > 8) cannot share a step with A’s decodes, so it waits until A finishes and then runs alone in a 10-token step 4 (whole_prompt_waits_for_a_step_of_its_own).

split(10, 4) gives the boundaries a prompt alone in the engine would use: (0,4),(4,4),(8,2)(0, 4), (4, 4), (8, 2), every token once, in order (split_hand_example).

rust/crates/tl-engine/src/chunk.rs
pub struct Chunked { pub chunk: usize }
impl PrefillPolicy for Chunked { fn chunk_len(&self, remaining: usize, budget: usize, alone: bool) -> usize; }
pub struct StepPlan<'a> { pub batch: ForwardBatch<'a>, pub sample: Vec<(usize, RequestId)> } // (logits row, request)
pub fn plan<'a, B: BlockSpace>(s: &'a Scheduler<B>, out: &ScheduleOutput) -> StepPlan<'a>;
pub fn split(n: usize, chunk: usize) -> Vec<(usize, usize)>; // a chunk of 0 means 1

Turn chunking on with Scheduler::new(cfg, blocks).with_prefill_policy(Box::new(Chunked { chunk })); the engine of L10.5 does when prefill_chunk > 0.

TestKINDChecksWhy it matters downstream
hand_example_chunk_planunitsection 3, step by step, through the schedulerthe worked example
whole_prompt_waits_for_a_step_of_its_ownunitthe same workload without chunks: B waits for step 4 and a 10-token stepthe problem chunking solves
split_hand_exampleunit(0,4),(4,4),(8,2)(0,4), (4,4), (8,2) and the edge caseschunk boundaries cover each token once
plan_samples_only_sequence_endsunitdecode at n−1n - 1, chunk tokens and start, only the right rows sampledthe engine samples only real next tokens
token_budget_never_exceededpropertyrandom prompts up to 4x the budget: every step within β\beta, every output exactthe TPOT bound holds
long_prompt_progresses_beside_decodespropertya 40-token prompt starts within 4 steps beside 3 decodes; without chunks it waits about 38why chunking exists
chunked_prefill_matches_whole_within_tolerancedifferentialchunk sizes 1, 3, 7, 16 give logits within tolerance and preserve greedy continuationschunking never changes answers
PitfallSymptomCaught by
A chunk that ignores the budget leftsteps exceed max_batch_tokens; TPOT spikes returntoken_budget_never_exceeded (mutant s01)
A chunk that ignores prefill_chunklong prompts still run wholehand_example_chunk_plan (mutant s02)
Sampling a middle chunk’s logitsextra tokens appear in the answer, or the scheduler must guard against themplan_samples_only_sequence_ends (mutant s03)
Placing a decode token at position nneach decode reads and writes one slot offplan_samples_only_sequence_ends (mutant s04)
A last chunk of full size past the promptthe plan reads past the promptsplit_hand_example (mutant s05)
Chunk-relative positions or q_offset 0chunked output differs from whole outputchunked_prefill_matches_whole_within_tolerance (the faults of L10.1’s s13 and s14)
DirectionModuleHow it uses this
BackL10.2PrefillPolicy is consulted in phases 2 and 3; Chunk.last marks the sampled rows
BackL10.1chunk invariance of the runner makes chunking output-preserving
BackL10.1Candle-backed attention, absolute positions, and the Rust KV cache
ForwardL10.5the engine loop runs plan every step; prefill_chunk in runtime.toml

L10.7 exports TTFT and TPOT, which is how you choose prefill_chunk for your machine.

Your pieceProduction equivalentWhat it addsWhere to look
one fixed chunk sizeSarathi-Serve’s stall-free batchingchunk size from a TPOT targetSarathi-Serve
decodes first, then chunksvLLM v1’s unified schedulerone token budget across prefill and decode, no phasesvllm/v1/core/sched/scheduler.py
mixed batches on one enginedisaggregated prefill (L10.6)prefill and decode on different machinesDistServe, L10.6