Repository navigation
[GDN, Attention] Make a mixed decode + prompt step cheaper - #965
Conversation
`GDNMixedWrapper` runs its decode half on every row it is sized for, and a captured wrapper was sized to the largest capture (`_cg_max_bs`), the floor the other wrappers take for their buffers. So every mixed bucket ran the GDN decode kernel on 128 rows: on Qwen3.5-4B at c48, ~2.6 ms of a mixed step was decode work on rows addressing the null slot. The split wrapper now takes the bucket's rows; the plain decode and prefill wrappers keep the floor, since their kernels run on the step's rows whatever their buffers hold. The dropped rows address only the null slot: outputs and every real slot's state are bit-identical to the 128-row wrapper (`test_the_decode_halfs_width_changes_no_real_rows_result`). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A resource that runs a combined walk on split kernels (CombinedSplit) reads the step's one-token rows on a decode kernel and its prompt rows on a prefill kernel. The engine records each split's prefill walks at load, refusing two resources that disagree, and the worker's one batch builder puts those walks' rows last, keeping each side's order. The kernels can then read the decode rows as the step's first tokens in place. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GDNMixedWrapper and FlashInferMixedWrapper gathered every token into decode-then-prompt order and scattered the outputs back, per layer (index_select / index_copy_ over the whole step). With the engine putting the decode rows first, the decode half reads the leading run of one-token rows as the step's first tokens and a bs-row torch.where merges its outputs in place. Under capture the prefill half takes every token, the run as one sink row ahead of the prompt rows; an eager step's prefill half takes the tokens after the run, so it writes prefix-cache checkpoints only for the prompt rows its checkpoint buffer is sized to. Row order only bounds capacity: a decode row behind a prompt row runs on the prefill half, which is still correct while the step holds no more prompt rows than the bucket was captured for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…budget FlashInfer's tensor-core decode splits long KV rows across CTAs within a budget of 2 CTAs per SM over the KV heads (66 rows on an H200 with 4 KV heads), and every row it plans costs at least one, padding rows included. A 64-row mixed bucket holding ~46 decode rows left no room to split, so its long-KV rows ran unsplit: 328 us a layer against 135 us for the same rows in a 48-row decode bucket. The split's decode half now runs the same FA2 paged kernel planned through the prefill wrapper, where a row with no query costs nothing: the run's rows take one token each and one row carries the rest. Measured on 46 rows with 15% at 4k-16k KV: 159 us at 64 rows. The CUDA-core option (decode_tensor_cores: false) keeps the decode wrapper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Automated review🟡 No findings on Description
What the review checked
Advisory, not a merge gate. Rules: |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Several comments and docstrings still described the split before rows were ordered decode first and read in place: the planner-cost rationale (unmeasured, dropped), what rows outside the run read, who stages the tensor-core half's host arrays, what the GDN decode half takes eagerly, and why the lead sequence leaves run rows alone with the sink off. The two capacity errors now also name a broken row order or a split that leaves out a multi-token walk, which this change made possible causes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
order_split_rows puts every walk outside prefill_walks first, so a multi-token row there ends the decode run and can push a step past the captured prompt-row cap. Nothing checks it at load yet; say it where the split is declared. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The engine orders a split walk's rows from this hook alone, but Step 2a did not list it, and the combined-walks paragraph read as if setting a config field were enough. A spec whose config carries splits but does not return them here gets no ordering. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two comments still called the order a step runs in the scheduler's batch order; since _make_executing_batch puts a split walk's prompt rows last, the TP wire carries the leader's step order instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The staging note left out the prefill wrapper, which stages its own pinned arrays. The GDN docstring now groups by capture vs eager, the run() comment says the run sequence exists only under capture, and the refusal messages list their causes as alternatives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The spec list now holds methods too, so it is "members", not "three fields". Only FlashInfer attention and GDN read `splits`. The engine reorders every step of a split walk, not only captured ones. CombinedSplit now says a multi-token row counts as a prompt row itself and that plan raises past the cap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tensor-core decode half's last row takes the rest of its batch_size tokens, not every token past the run. The refusal messages name the cap the scheduler overran. Only a captured step raises past max_prefill_rows, FlashInfer splits captured steps only, and other backends ignore splits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Padding rows carry NO_SLOT and slot 0 is a request's. The mixed-split test now also runs sink off, over the existing layouts (bucket only: eagerly they differ from the sink-on cases only in padding rows the step drops) and three more that put slot 0 on a decode row of the run, on a prompt row, and on a step with no live prompt row. A guard row in front of each pool catches a kernel that writes slot -1. Skipped without CUDA. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dghosef
left a comment
There was a problem hiding this comment.
LGTM - nice speedup. Tested on H200s: +7.6-8.3% on 0.8B/4B and +3.9-5.8% on 9B TP2 with mixing on, mixing off unchanged, TP2 outputs match main. The moved planner costs ~0.055ms per mixed step but the GDN plan gets cheaper by about the same. Pushed some comment/doc fixes (docstrings that still described the pre-reorder version, combined_splits() in adding_models.rst) and a sink-off GPU test. Non-blocking: worth validating CombinedSplit against get_combined_graph_walks() at load since a missed multi-token walk now fails the step, and the "mixing off: unchanged" reason in the description isn't quite right - steps still mix with it off
dghosef
left a comment
There was a problem hiding this comment.
ok yea this is good, just merge and then gtg
main moved Worker._make_executing_batch to executing_batch.make_executing_batch and speculation assembly to SpeculationBuilder (#850). The split-row ordering moves with them: make_executing_batch takes the step's engine and orders a mixed step's rows there, which both the worker's batches and speculative ones (SpeculationContext.engine_manager) still go through. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The merge passed every step's engine to make_executing_batch, which reads Worker.engine_manager on each step and broke the worker fakes in test/rust/test_spec_rows.py and test_speculation_end_to_end.py, which have no engine. Only a step with a per-row walk map can need reordering, so the engine is looked up for those alone, and the fakes get a stand-in that keeps order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflict in test/modular/test_ragged_attention_cpu.py: main's mixed-wrapper assertions (a sink row on each side of the prompt rows, the tensor-core decode half on the FA2 paged prefill kernel) replace this branch's copy of the old ones; this branch's op-registry test is kept beside them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's #850 moved speculation, executing-batch building and stream buffers out of worker.py; this branch's additions to them move with them: chunk_ranges to make_executing_batch and SpeculationBuilder, join_dim to StreamBufferManager. A speculation carrying cut chunks takes the per-row assembly rather than assemble_rows. The attention and GDN split files and their tests take main's #965 as merged, which this branch carried a draft of. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A mixed step, one that holds decode rows beside prompt rows, runs a split walk on two kernels per layer: decode for the one-token rows and prefill for the rest (
CombinedSplit). On Qwen3.5 such a step cost more than a decode step followed by a prefill step, so running decode rows in prompt steps (speculation_mixes_walks) lost throughput. This PR removes three costs in that path. #916 (chunked prefill) will stack on it.What changed
The GDN decode half is sized to its bucket (
GDNManager._get_wrapper,mstar/engine/resources/linear_attn/gdn.py). A captured split wrapper took the largest capture's row count, the floor the other wrappers use for their buffers. So every mixed bucket ran the GDN decode kernel on 128 rows, most of them on the null slot. The split's decode half now takes the bucket's rows. The dropped rows only ever addressed the null slot, so every real row's output and state is bit-identical (test_the_decode_halfs_width_changes_no_real_rows_result).The engine orders a split walk's rows decode first, and the wrappers read them in place.
GDNMixedWrapperandFlashInferMixedWrapperused to gather every token into prompt-first order and scatter the outputs back, per layer:index_select/index_copy_over the whole step.NodeResourceSpec.combined_splits(). The engine records each split's prefill walks at load, and refuses two resources that disagree.Engine.order_split_rowsputs those walks' rows last. It is called once, inWorker._make_executing_batch, which every path to a step goes through, including the speculative one and TP followers.bs-rowtorch.wherewrites its outputs over the prefill half's.Padding rows no longer use up FlashInfer's decode split budget (
FlashInferMixedWrapper,mstar/engine/resources/attn/wrappers.py).scheduler.cuh). The run's rows take one token each and the last row carries the rest.decode_tensor_cores: falseoption keeps the CUDA-core decode wrapper.No new config options or environment variables.
speculation_mixes_walksstays off by default: with these changes, mixing ties on Qwen3.5-4B and still loses on 0.8B (below).Performance
H200, one GPU per server,
MSTAR_SHM_ARENA=1, each tree's default Qwen3.5 config (prefix cache on). Closed loop with--ignore-eos. Two rounds per arm, interleaved, one server per arm and round. "mixed" is 400 prompts of mixed length with 128–256 output tokens; "long" is 200 long prompts with 64–128 output tokens. Cells are tok/s, mean of the two rounds with the range in brackets.Tried and dropped: a denser mixed capture ladder (32/48/96 rows) measured +0.2% for 31 s more startup; CUDA-core decode for the split's decode half was slower than the FA2 path; FA3 for the decode half took about 100 µs per layer against 34 µs on FA2 without long rows.
Correctness
Tolerance tests, run on CUDA:
window_left-1 and 7.Prefix-cache snapshots in a mixed step:
test_a_mixed_steps_prompt_rows_save_the_snapshots_the_chunked_path_saves(eager and bucket) checks that a prompt row's snapshot is the conv window and state the chunked path saves, with more decode rows than a checkpoint interval.Ordering:
test/modular/test_split_row_order.pycovers prompt rows going last in order, other walks and nodes keeping their order, disagreeing resources being refused, and the worker making a mixed step decode first.Live greedy check: Qwen3.5-4B, 32 prompts, temperature 0, prefix cache on, staggered so prompt rows join running decode steps.
The same prompt diverges at its first token between mixing on and off on both trees. No errors in any server log.
Tests
ruff check .: clean.python -m pytest test/modular test/distributed test/chatterbox test/kokoro test/omnivoice test/sampling_test mstar/model/*/tests benchmark/worker_phases,OMP_NUM_THREADS=2): 49 failed, 8511 passed, 946 skipped. On main: 50 failed, 8504 passed, 927 skipped. The branch's failures are the same set as main's, and the one failure only on main istest_eval_compare_multigpu.py::test_a_head_that_hangs_is_cut_at_the_cap_with_stacks_and_fails.test_gdn_mixed_split,test_attn_mixed_split,test_attn_decode_tensor_cores,test_combined_split,test_split_row_order,test_qwen3_5_mixed,test_gdn_prefix_snapshots,test_recurrent_prefix_snapshots,test_gdn_cuda_graph,test_micro_scheduler,test_admit_failure_handling,test_accelerator_graph_capture,test_resource_runner,test_ragged_attention_cpu,test_ragged_attention: 365 passed.Follow-ups
Developed with AI assistance (Claude).
🤖 Generated with Claude Code