Repository navigation
[Chunked Prefill #4b] TP and streaming support - #367
Conversation
30025f2 to
0897a5b
Compare
87e4350 to
8cb4453
Compare
2e444b8 to
6b7fde1
Compare
6b7fde1 to
98d57db
Compare
98d57db to
2de0362
Compare
2de0362 to
01e59ed
Compare
merceod
left a comment
There was a problem hiding this comment.
Ran Qwen3.5-9B TP2 on klone H200s from the top of the stack with a log line per prepared and per settled chunk on both ranks: over the parity set (112 prompts of 20 to 3k tokens at c=1/8/32, greedy and seeded) both ranks produced 3411 chunk preparations and 3411 settlements in the same order with the same ranges, outputs were deterministic at c=1 and inside the usual batch floor at c=8/32, 70 aborts during or right after chunked prefills left the server clean, and late arrivals were served in under 140 ms with 40 and 64 requests decoding. Approving, with three notes.
- The SP refusal is lifted but there is no SP-capable chunkable model and no SP handling in the chunk path. Keeping the refusal for sp_size > 1 until there is one would be safer.
- tp_chunks zips with strict=False, so a length mismatch between the leader's chunk lists and the request ids would turn into a silent token-count mismatch (a hang on the collective) instead of an error. strict=True costs nothing here.
- Nothing checks that the follower's get_input_sequence_len agrees with the leader's ranges or that the prepared length matches the measured one; the ranks agreed in every chunk I logged, but a mismatch would hang the collective or drift the sampler rather than fail loudly. A cheap assert on the follower (range end <= prepared length) would turn that into an error.
The PR body says more thorough testing is in progress. The runs above cover TP2 parity, cancels and scheduling with async scheduling off, and the same with MSTAR_TP_ASYNC_SCHED=1 at a 256-token budget (11,944 chunk preparations per rank in identical order, 70 aborts clean, outputs unchanged afterwards).
| rids = self.tp_rids(message) | ||
| ranges = { | ||
| rid: (start, end) | ||
| for rid, start, end in zip(rids, message.chunk_starts, message.chunk_ends, strict=False) |
There was a problem hiding this comment.
strict=False here hides a length mismatch between chunk_starts/chunk_ends and the rids: it would become a token-count mismatch and a hang on the collective instead of an error. strict=True costs nothing.
There was a problem hiding this comment.
strict=True alone would raise on every step without a chunked row, including every decode-only step, because maybe_send_zmq_to_tp_followers sends both lists empty then. It works if the zip is skipped when the lists are empty.
| submodules: dict, model: Model, parallel_groups: WorkerParallelGroups, | ||
| ) -> None: | ||
| """A chunked walk must report row lengths, and chunking under TP/SP is not supported yet.""" | ||
| def _refuse_unsupported_chunking(submodules: dict, model: Model) -> None: |
There was a problem hiding this comment.
This lifts the SP refusal, but there is no SP-capable chunkable model and the chunk path has no SP handling. Keeping the refusal for sp_size > 1 until there is one seems safer.
Gaurav-Shah05
left a comment
There was a problem hiding this comment.
LGTM. I ran qwen3_5_9b_tp2 and orpheus_tp2 w/ and wo/ MSTAR_TP_ASYNC_SCHED. Qwen3.5 gives the same greedy output on TP2 as on TP1 for prompts from 1.5k to 24k tokens, Orpheus samples the same tokens on TP2 with chunking on and off, and a client that disconnects mid-prefill leaves both ranks clean.
01e59ed to
94787be
Compare
94787be to
a3df090
Compare
a3df090 to
5034c9e
Compare
41588aa to
7e514c1
Compare
The backlog was measured before the rows of another walk were parked, so while prompts waited under the combined walk every decode merge measured them and then estimated each continuing row's length. Parked rows are now left out of the measurement too. Not measured yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er states Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Chunking stays on for a TP node; only SP still turns it off. The leader alone cuts chunks, since a follower's own cut could differ, and sends them on ScheduleTPNode columnar: chunk_starts / chunk_ends / chunk_totals / chunk_strides parallel to request_ids, -1 for a row it did not chunk, all empty when it chunked none. A follower stamps them onto the batch it builds on both the serial and the speculative follow path, so its postprocess holds a row mid-prefill's inputs and moves the chunk Loop by the leader's strides. On a row's first chunk it caps the Loop at the prompt's length, as the leader's scheduler does: the cap decides on which step the Loop ends, so every rank has to set the same one. A first chunk is the one whose stride is its end, since the Loop counts from 0 even past a cached prefix. The rows that need another chunk are derived from the ranges rather than sent. Lists that do not match the head's rows raise before any row is popped. On the speculative follow path a row of N mid-prefill loops back on nothing of N's, so, as on the leader, it is kept out of the loop-back prep, and runs its next chunk from the input held since its first. N's chunks came from the leader, so every rank splits these out alike. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lker keeps pace A consumer that can start on a prefix now takes a chunked prefill's stream a chunk at a time instead of all at once when the prompt ends. - ChunkPolicy.allow_partial_input() (default False), set with FixedChunkPolicy(allow_partial=True): the consumer takes each chunk's piece as an item of its own. - ChunkingPolicy.per_chunk_outputs: streaming edges kept on the chunk Loop's body with their real destination, so every chunk routes its piece as it completes. Disjoint from accumulated_outputs. No runtime change: a Loop body's streaming edge already routes per iteration (thinker_decode's does), in both runtimes. - Startup refusal (mstar/validation/placement.py) for a chunked stream its consumer would misread: a per-chunk edge whose consumer does not allow partial input (join_dim cannot stand in, since it joins the pieces of one pass and a per-chunk stream sends one per pass), a per-chunk name that is no streaming output of the node, and a gathered stream whose connection neither joins (join_dim) nor allows partial input. - Qwen3-Omni: the Thinker sends thinker_states / thinker_mask per chunk into a partial-input Talker, which extends its KV with each. Walks come from #385's consumer_walk as before: every prefill chunk's pass is assigned talker_prefill, and the conductor's talker_prefill handling sends nothing, so it is unchanged. A chunk of only system prompt or earlier turns keeps no rows; the Thinker leaves it unsent rather than run the Talker on an empty prefill. join_dim stays for consumers that want the whole prompt. The Talker's KV label is unkeyed, so its layout (one span per Thinker walk) is used only for its footprint, which per-chunk writes leave unchanged; the span-length check in resolve_cached_prefix applies to keyed labels only. The resource validators read a chunked node's per-chunk edges off its Loop body, beside the Loop's own outputs, so the Talker still counts as a partition the Thinker streams into. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
39715b2 to
e12da48
Compare
|
Force-pushed: this branch is now rebuilt on #916's new head (aaa4f38), where the chunk Loop counts tokens. The old head was 39715b2. Under TP, followers now also apply the leader's per-row strides ( |
ScheduledBatch.get_continuing_rows splits the rows that run again in the step speculated after it by where their inputs come from: rows that loop back on the step's outputs, as decode's do, and rows mid-prefill, which run from the inputs held since their first chunk and whose next chunk the scheduler cuts from where the chunk in flight ends. The chunk Loop's tick makes the node a speculation target but carries nothing, so the two kinds go different ways. _try_speculate_next built both lists inline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… route takes them ScheduledBatch carries its node's chunk-Loop walks, stamped at _submit_gpu_step, and computes RouteInput.loop_strides from its own chunk ranges, so routing, speculation prep and the spec-target choice all read them off the batch rather than through ChunkLoops. Rust's route_and_send_inline takes the same optional loop_strides as complete_and_route_batch, so a device-loopback step in a chunk-Loop walk no longer falls back to the general route. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed walk has a token budget A chunked prompt then rides in decode steps instead of decode sitting out each chunk step. Without a budget a merged prompt runs whole, so mixing stays off there. speculation_mixes_walks still overrides either way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#955 replaced the speculation chain's targeted merge with one pick (FIFOBatchBuilder.pick, pick_chain_step / take_chain_step). Chunking now sits on it: - the token budget cuts the rows the pick took, in build_batch, and only those rows are measured (_build_step); - a chain's rows mid-prefill are waiting rows at the front of its pick, never popped, parked or returned; a step that keeps only them still runs as a chunk-only speculation rather than a yield-away; - take_chain_step charges the chain's own rows' estimated spans to the budget. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in main through #975 (#955's one pick for a capped step) and 4a's continuing rows, step-carried loop strides and default mixing. The TP-follow path replays the leader's chunks and never goes through the chain pick, so it needs no change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…k chunks Both yields the chunk Loop added to the fairness peek (from a prompt chain's first step, and after a step whose chain can't continue) now require the walk to run under a token budget, the same test `speculation_mixes` uses. With `max_batch_tokens: null`, or on a model that never chunks, a prompt still runs inside its chunk Loop but whole, and speculation keeps main's order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gaurav-Shah05
left a comment
There was a problem hiding this comment.
Second pass after the core PR was changed. Still LGTM.
On 2xH100s against #916's head (6f2d85f), Qwen3.5-9B at TP2 with bursts of 16k-30k-token prompts beside 16 decodes, two sessions each:
| Burst | Decode, tok/s per stream | Worst gap, ms | TTFT p50, s | Prefill, k tok/s |
|---|---|---|---|---|
| 8 | 27 against 2 | 46 against 2065-3155 | 2.5 against 4.3-5.4 | 49 against 35-44 |
| 16 | 25-26 against 5 | 47-48 against 1627-1843 | 4.0 against 3.6-3.8 | 50 against 55-56 |
| 32 | 25 against 5 | 49-51 against 1867-1896 | 8.1 against 7.6-7.7 | 50 against 57 |
Neither session logs a Not enough free pages refusal, and the 24-prompt parity set gives identical outputs on both heads in every run. Qwen3-Omni T2S at 1/s with the per-chunk Talker brings long-prompt TTFA p50 to 240 and 241 ms against 273 and 281, and short-prompt TTFA p99 to 293 and 311 ms against 392 and 430, with RTF p50 unchanged.
Through c6fcbe8 (#857). Resolutions beyond keeping both sides: - #857's postprocess: `PostprocessRequest.loop_strides` carries each row's chunk-Loop stride into the route, natively and in `PhasedPostprocess`, in place of the fused route's argument; a dropped row's stride leaves with it. A step with a row mid-prefill keeps check_stop, which drops that row's stops and stream-terminated loops, and `step_finished` keys no prefix for it. - #794's END_PARTITION check lets through a row whose innermost loop is its own chunk Loop: only the last chunk keeps its stop, and that Loop ends by its count. - #950's per-group allocation: a chunked row's walk-ahead is added to the span every group allocates and to the per-group count in `_sized_refusal`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pared The engine keeps a chunked row's whole input from its first chunk to its last. A first chunk whose admit was refused left that entry behind, so the retry skipped the length and cached-prefix checks: if another request's pages landed in between, it was cut past a prefix never applied, and if it then ran whole the entry outlived the prompt and a later chunked walk was cut from the old input. The refusal now drops entries that step created. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#857 moved the call into Engine.step_finished; the guard follows it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ad of unstarted prompts `FIFOBatchBuilder.pick` ranked a prompt mid-prefill by its request's arrival unless it held the step, so older requests' prompts that hadn't started (back from an OOM hold, say) took a mixed step's prompt rows ahead of it, and it never reached the start-order pass or the oldest prompt's floor. Under a token budget the line now puts rows mid-prefill right after the rows holding the step, in the order their prompts started. `chunk_started` is dropped once a row's last chunk is cut, so a finished prompt's decode rows line up as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#916 through b423ddf, which merges main through #857. The scheduler keeps both sides: a row's start stamp is dropped once its last chunk is cut, then its chunk Loop is capped (cap_chunk_loops). The TP prefix test's batch stub gains first_chunk_rids. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Postprocess::new took loop_strides in the merge of main; the unit test now passes them and checks a dropped row's stride leaves with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What does this PR do?
Chunked prefill under TP, and streaming consumers that take a chunked prefill a chunk at a time. Stacked on #916, which expresses a chunk as one iteration of a graph
Loop; this PR replaces its earlier version on #365, and takes over #371 (streaming chunks), whose completion machinery theLoopand producer-determined walks (#385) make unnecessary.TP: followers run the leader's chunks
#916 runs a TP/SP node's prefills whole. Here only SP stays that way (
_unchunkable_nodes,mstar/worker/engine_manager.py); under TP the leader alone cuts chunks and every follower runs the same ones.ScheduleTPNodecarries the step's chunk ranges columnar, parallel torequest_ids:chunk_starts,chunk_ends,chunk_totalsandchunk_strides(-1 for a row that isn't chunked, all empty on a step with no chunked row). The wire codec encodes them typed.chunk_rangesfrom the head (MicroScheduler.tp_chunk_ranges) on both the serial and the speculative follow path, and derive the rows mid-prefill from the ranges, as the leader's_assign_chunksdoes. So a follower's postprocess holds a mid-prefill row's inputs and moves the chunkLoopby the leader's strides. Lists that don't match the head's rows raise before any row is popped.Loopat the prompt's length (GraphRuntime.set_loop_caps), as the leader's scheduler does. The cap decides which step theLoopends on, so every rank has to set the same one. A first chunk is the one whose stride is its end, since theLoopcounts from 0 even past a cached prefix.Engine._prepare_chunk, unchanged), and reserves ahead per row (reserve_whole_prefill) from the same ranges, so the ranks reserve alike. [Preemption 1/7] Slim Admission Control (A1) #869's allotments are off above one rank, so that reservation still matters here.Streaming: a consumer can take a chunk at a time
Under #916 a chunked producer's streamed outputs leave on the final chunk, and a consumer that counts items joins the pieces into one (
Connection.join_dim). A consumer that can use a prefix now takes each chunk's piece as it lands instead.ChunkingPolicy.per_chunk_outputsnames the outputs a chunk sends as soon as it completes._wrap_node_in_loopkeeps those edges on theLoopbody, where a streaming edge already routes every iteration, as a decode loop's does; no runtime change, Rust or Python.ChunkPolicy.allow_partial_input()(default False), set withFixedChunkPolicy(allow_partial=True).mstar/validation/placement.py): a per-chunk edge whose consumer does not allow partial input is refused, as is a per-chunk name that is not a streaming output of the node, and a streaming edge carrying the final chunk's output whose consumer neither joins (join_dim) nor allows partial input.join_dimonly joins pieces that arrive in one pass, so it is not enough for a per-chunk edge.thinker_states/thinker_maskper chunk, and the Talker takes them withallow_partial=Trueinstead ofjoin_dim=0, so it extends its KV as each Thinker chunk lands. Producer-determined walk transition #385'sconsumer_walkalready assigns each pass its Talker walk, so every chunk runstalker_prefilland the conductor's trigger count is unchanged. A chunk with no kept rows (all system prompt or earlier turns) is not sent at all, so the Talker never runs a 0-row prefill.What #371 needed and this does not:
finished_graph_walkon edges, per-rowcompletes_node/completes_walkthrough routing, the completion rids onRouteArg, the Rust frame fields and theStreamBufferhold-back. TheLoopdecides which edges leave on which chunk, and the walk ends when theLoopdoes.How was it tested?
On 4037bd2, which merges #916's 6f2d85f (
mainthrough #975, including #955), unless marked. This head (8d227ad) mergesmainthrough #916 (squashed as 4079d7f), #977 and #980; the diff againstmainis the same as it was against #916's last head (fb12141). On it this PR's own tests and #916's scheduler, postprocess and chunking tests pass (test/modular/test_{micro_scheduler,tp_follow_mid_prefill,kv_prefix_tp,chunked_prefill,chunk_stream_partial,qwen3_omni_talker_walk,qwen3omni_vision_chunks,stream_chunk_schedule,dropped_row_stream_stop,inline_tokens,spec_chain_key,loop_turns,chunk_loop_stop,admit_failure_handling,kv_offload}.py,test/rust/test_{runtime_parity,postprocess}.py: 630 passed, 37 skipped). A TP2 smoke on b16564a, the same code before #977 and #980 (Qwen3.5-9B, 2 x H200 per server, one session, againstmainat c6fcbe8) matches the runs below: parity 286 of 288 on both; c48 25.8 against 24.3 req/s, short-prompt TTFT p50 109 against 419 ms, 229 against 391 gaps over 50 ms; burst of 8 at 45.2 against 18.8 decode chunks/s, 0 against 16 stalls, TTFT p50 2.16 against 3.89 s; no failed requests, no hang, no server errors. The Qwen3-Omni runs below predate it. The port makes no Rust change of its own, and the TP-follow path replays the leader's chunks without going through #955's chain pick, so the merge needed no change here.ruff check .: clean.test/rust,OMP_NUM_THREADS=2, GPUs hidden): 221 failed, 9308 passed, 1084 skipped, 20 errors, all among currentmain's failures (9d161dc) on the same machine;test_eval_compare.py::test_whichever_arm_starts_first_on_cold_caches_still_startsfails onmainand passed here.test/integration, on 3 x H200, torch 2.12.1+cu132): 239 failed, 9570 passed, 530 skipped, 27 errors, every failure among [Chunked Prefill] Chunked prefill as a graph Loop (replaces #365) #916's head's on the same machine. Those fail the same way onmain(see [Chunked Prefill] Chunked prefill as a graph Loop (replaces #365) #916).test/modular/test_micro_scheduler.py: a follower stamps the leader's chunks and strides, a step with none, and lists that don't match the head.test/modular/test_tp_follow_mid_prefill.py: a follower's speculative step with a row mid-prefill, on a realWorker.test/modular/test_kv_prefix_tp.py: a follower's first chunk starts past rank 0's leased prefix.test/modular/test_chunked_prefill.py: chunking stays on under TP and off under SP.test/modular/test_chunk_stream_partial.py: per-chunk edges stay on theLoopbody, the startup check's refusals, andjoin_dimstill joining a final-chunk edge.test/modular/test_qwen3_omni_talker_walk.py: Omni's real graph passes the check, an all-system-prompt chunk sends nothing, and a chunk sends just its kept rows.test/rust/test_runtime_parity.py: on both runtimes, each chunk's piece routes on its own step and the token leaves only on the final chunk.Qwen3.5-9B TP2
2 x H200, two sessions per arm, interleaved, prefix cache off,
MSTAR_SHM_ARENA=1, Rust graph runtime, a fresh Inductor cache per boot. The workloads are #916's: the 200- and 3000-token prompts are mixed 4:1 with 256 tokens out; bursts of 16 decodes, then 8, 16, 32 and 32 prompts of 16k-30k tokens with 16 tokens out; and the 24-prompt parity set. Arms: this head withMSTAR_TP_ASYNC_SCHED0 and 1, and #916's head, whose TP prefills run whole (with 0). Cells are session 1, session 2, in ms unless marked.Against
main, after #1075The same setup and workloads, with async scheduling on by default for both arms: this head (d7738b1, before #916's peek gating, which changes nothing under a token budget) against
main(9d161dc). The parity set is #916's 288 prompts. Cells are session 1, session 2, in ms unless marked.mainBoth arms gave identical answers in both sessions. This head had two failed 5/s requests in session 1, both client disconnects 0.2-0.4 ms after sending with nothing in the server log.
Qwen3-Omni, per-chunk Talker
qwen3omni_2gpuon 2 x H200 per server, the Thinker's budget at 16 tokens, so each prompt runs many Thinker chunks and the Talker extends its KV on each. Two sessions per arm: this head (per-chunk) against #916's head (the pieces joined into one item at the end). Speech out with Omni's system prompt.whisper-large-v3-turboand scored against that answer's own text (benchmark/answer_audio_wer.py), 384 answers per arm (benchmark.runner, thetextset, c8, 192 per session). Mean WER is 0.169 for both, with 10 and 11 answers above 0.5 (Fisher p = 1.0), and every answer had audio.Open questions
KVManager.resolve_cached_prefixanswers 0 until rank 0 has sent the step, so the leader measures a chunked row from 0 the first time it schedules it, and the row loses prefix reuse under TP. The ranks stay symmetric. Rank 0 leasing at measure time would fix it, which is a KV-lease change for its own PR.MSTAR_TP_ASYNC_SCHED=1withspeculation_mixes_walkson: a follower takes the speculation target's walk from the head's first row. With mid-prefill rows of several walks in one head, that could differ from the leader's choice. This predates this PR, andspeculation_mixes_walksis off by default.Developed with AI assistance (Claude).
🤖 Generated with Claude Code