Repository navigation
[Kernel] Triton packed paged prefill and split-KV decode attention - #128
Conversation
Without FlashAttention, Triton prefill ran the single-query decode kernel once per prompt row: one program per (row, head) streaming its whole prefix. Long prompts therefore read every key once per query row. Add a varlen paged prefill kernel with the FA4 packed interface (q packed by cu_seqlens_q, one block table per sequence, bottom-right causal alignment). It tiles 64 queries x 64 keys per program with online softmax, gathers K/V through the block table, supports GQA and non-power-of-two head sizes, and uses exact IEEE products for FP32 inputs. The Triton backend now declares packed_prefill, so prompt prefill takes the packed path on hardware without FA4. Speculative verification keeps per-row attention: it compares against serial decoding token for token, which requires the packed kernel to match the decode kernel bitwise. A new capability, packed_matches_decode (FA4 only), gates that. Ouro-1.4B prefill on one H100 (BF16), packed vs per-row tokens/s: 1K tokens 11.9K vs 4.7K, 4K 15.7K vs 1.5K, 8K 14.3K vs 0.8K, 16K 11.9K vs 0.41K (2.5x to 29x). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One program per (row, head) walked a row's whole context serially, so the longest row in a decode batch set every attention call's latency (about 0.3 ms per call at 10K tokens; Ouro decode makes 96 calls per token). Each row's context is now split into 16 equal tile-aligned parts computed in parallel and merged with their softmax statistics. The partition depends only on the row's own length, so a row's result is batch-invariant, which speculative verification relies on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6c96eb3 to
43aa99e
Compare
Preserve packed-prefill exactness and upstream device context-length capabilities. Signed-off-by: 0z5a <dezhen.lu@student.uni-tuebingen.de>
hsliuustc0106
left a comment
There was a problem hiding this comment.
Verdict: request changes before #128 merges independently. Reviewed head 8a19a9a1d298cdc35765882c29c0fddf2e17664d, against base/merge base 21f59c9928f73426dfce0bf9a7fe537758b88ed3; the remote head was rechecked before posting.
The tiled prefill and split-KV decode target documented serial attention bottlenecks. Retaining per-row prefill by default, with an explicit packed opt-in, is the simpler compatibility alternative.
[P1] Make packed prefill an explicit opt-in — vllm_rlt/attention/backend.py:50. Setting packed_prefill=True in the shared Triton capabilities changes kernel selection for existing split/mixed execution and Huginn boundary callers even when prefill_uva=False. Those callers consume the capability directly, so this arithmetic change reaches beyond the explicitly requested packed-prefill feature. The identical capability and prefill implementation at #130's f17578b produced four mixed-execution GPU token-oracle failures in the contributor's follow-up validation; all four passed after the capability was restricted to an explicitly selected instance.
Move #130's per-instance opt-in correction into this prerequisite before merging #128 alone. That correction is present in #130's 50e8f7e and current 9874287, but absent from this reviewed #128 head; it should not be counted as already resolved here.
Scope: reviewed the full frozen diff, attention dispatch and metadata callers, causal/page/depth handling, split/merge reductions, speculative routing, tests, descriptions and existing evidence using the repository review skill. No PR code or GPU jobs were run locally for this review, and the reported GPU failures were not independently reproduced here. #128's historical GPU, accuracy and speed results at 43aa99e do not by themselves qualify the merged 8a19a9a tree. No source fixes were made.
The shared Triton capabilities no longer declare packed_prefill, so split prefill, mixed execution and Huginn boundary attention keep per-row prefill. Only a backend selected with required packed_prefill (prefill_uva) gets it, on that instance. Same change as InferMatrixLab#130's 50e8f7e, applied to this branch. Co-authored-by: Codex <noreply@openai.com> Signed-off-by: 0z5a <dezhen.lu@student.uni-tuebingen.de> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: 0z5a <dezhen.lu@student.uni-tuebingen.de>
|
@hsliuustc0106 Thanks. I pushed 6b02350 on top of
The diff is the same as #130's Validation at
I did not rerun GSM8K or any performance measurement at this head. |
|
lgtm |
Picks up InferMatrixLab#128 (now merged) and the pre-capture gc.collect() from InferMatrixLab#141. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: 0z5a <dezhen.lu@student.uni-tuebingen.de>
Required human contributor answers
The human contributor must answer all four questions before PR submission.
An explicit answer already recorded elsewhere may be referenced with a precise,
accessible source; a bare link without an identifiable answer is insufficient.
Agents must not invent or fill in the human's answers. Blank, placeholder, or
unresolved answers do not complete this gate. See the
contributor-intent gate
and contributing guide.
1. What outcome are you trying to achieve?
Prefill/decode disaggregation at large scale, for users with larger demand.
2. What is the highest-leverage variable for that outcome?
The split-KV decode kernel and the router, which this work optimizes.
3. How will this work move that variable rather than create busywork?
High-concurrency end-to-end testing and reuse of existing interfaces.
4. Who is the customer or user, and what do they actually need?
Users of other GPUs, i.e. those without Hopper FA4 support.
PR necessity and bugfix evidence
A PR is needed. The human contributor's reason is answer 1: prefill/decode
disaggregation at large scale, for users with larger demand. They chose to
submit the two kernels as one PR; the router is a separate, later PR.
This is a performance change, not a bugfix, so there is no failure evidence to link.
Purpose
Without FlashAttention 4, both attention paths of the Triton backend were
bounded by one program per (row, head) walking a whole context serially.
Prefill (commit
2ab1ba3). Problem: a prompt ran the single-query kernelonce per prompt row, so every key was read once per query row and prefill
traffic grew quadratically. Change:
cu_seqlens_q, one block table per sequence, bottom-right causal alignment;The Triton backend now declares
packed_prefill. Speculative verificationcompares against serial decoding token for token, so it needs the packed kernel
to match the decode kernel bitwise. Only FA4 guarantees that, so a new
capability
packed_matches_decode(FA4 only) gates it, and speculativeverification keeps per-row attention on Triton.
Decode (commit
43aa99e). Problem: the longest row in a decode batch setevery attention call's latency, about 0.3 ms per call at 10K tokens, and Ouro
makes 96 calls per token. Change:
NUM_SPLITS = 16equal, tile-aligned parts, one program per (row, head, split) producing an FP32 partial softmax state, and merge them in a second kernel;Files changed:
vllm_rlt/kernels/triton_attention.py: both kernels;vllm_rlt/kernels/paged_attention.py: dispatch;vllm_rlt/attention/backend.py: capabilities;vllm_rlt/worker/speculative.py: the gate;tests/test_attention.py,tests/test_backend_selection.py,tests/test_speculative.py.FA4, the torch backend and the decode path selection are unchanged.
Test Plan
vllm-rlt Version / Commit: B =
43aa99e(this PR), A =main9d601ce. GPU runs used a 4× H100 64 GB node, the CUDA Triton backend, BF16 weights/activations/KV, and Ouro-1.4B (HFByteDance/Ouro-1.4B@574fa66c).ruff check .,ruff format --check .(ruff 0.16.8).OMP_NUM_THREADS=1 python -m pytest -q.pytest --run-gpuovertest_attention.py,test_attention_64bit.py,test_speculative.py,test_async_speculative.py,test_kv_cache.py,test_cuda_graph.py,test_pd.py(FA4 cases excluded: not installed). New tests:test_triton_paged_prefill_matches_per_query_reference: out-of-order pages, GQA, head dims 64/96/128.test_split_kv_decode_matches_reference_and_is_batch_invariant, FP32 and BF16. It covers lengths 0/1/31/32/33/511/512/513/4097/10000, the torch reference, zero output for empty rows, and bitwise equality of a row alone vs in a batch.mainand B = this PR, ABBA order (A1, B1, B2, A2) on one GPU:Test Result
Lint/format:
ruff checkpasses.ruff format --checkreports the same 5 files onmainitself under ruff 0.16.8; no new findings.CPU regression:
43aa99e596 passed, 190 skipped (GPU-only), 0 failed.main9d601ceon the same host: 595 passed, 185 skipped.GPU regression, this branch alone: JUnit, H100,
--run-gpu, FA4 cases excluded:43aa99e: 188 passed, 0 failed, 0 errors, 0 skipped;2ab1ba3alone: 186 passed, 0 failed;main9d601ceon the same node: 182 passed, 0 failed.The extra tests on the branch are the new kernel tests. No failure is attributable to either commit.
Speed A/B (main vs this PR, ABBA, async + CUDA graphs, fixed 4 loops, 128 output tokens): median per cell; A1/A2 and B1/B2 are the two repeats of each arm.
gpu_memory_utilization=0.85, the KV pool is 46.03 GiB for A and 45.80 GiB for B. B profiles 0.23 GiB more transient memory: the split-KV workspace and the packed-prefill tiles.Single-request profiling: one warmed request, 2K prompt and 64 tokens, async + CUDA graphs. Each arm ran separately under torch.profiler (Kineto/CUPTI); these runs were not part of the timed A/B.
_paged_prefill_kernelduring prefill and_paged_attention_kernel+_paged_attention_merge_kernelduring decode. A's trace shows only_paged_attention_kernelin both phases. So the measured speedup comes from the new paths.Numerics (Ouro-1.4B BF16, fixed 4 loops):
Earlier stacked A/B (each commit vs its parent on a local tree that also carries the PD routing changes of a separate PR; same node and run per comparison):
GSM8K-87, native A/B (the repository's
benchmarks/gsm8k.py:run --backend nativeon each checkout, thencompare):Eager mode (sync, no CUDA graphs; this is the GSM8K harness configuration). Separate ABBA A/B, same controls as above, 128 output tokens:
Regression: eager decode at small batch is about 9% slower per token, roughly +4 ms/token at 1 row.
Eager profile (512-token request):
Microbenchmark:
paged_attentioncalled directly, host µs per call measured in a host-bound enqueue loop.mainIn this loop the extra ~21 µs per call is about 14 µs of per-call partial-state allocations plus about 7.5 µs for the second (merge) launch. At 96 calls per token that is only about 2 ms of the ~4 ms per-token gap, because a hot loop understates the cost inside the engine (next item).
In-engine attribution (corrected after posting; the first version of this description left about 2 ms per token unattributed). From the eager profile above, the host time of each decode-attention call on the CPU timeline: from the end of the preceding operator to the start of the next operator after the call's own allocations and launches.
mainGPU time per call (CUDA-graph replay),
mainvs this PR:mainAt 64 uniform 2K rows the split adds about 5% GPU time. Real decode batches with mixed lengths are dominated by the long rows.
Follow-up: a fix is being validated for a follow-up PR. In eager mode it merges in the same launch and reuses the partial-state buffers per stream; under graph capture it keeps the separate merge.
main.Where it inverts: with 4 rows at 2K the GPU work dominates and the PR is 8.5% faster.
Under CUDA graphs, the PD decode worker's configuration, launches are replayed and the PR is 1.7–3.6× faster per token (table above).
TTFT is faster in every eager cell (packed prefill).
Memory:
Not done / limitations:
NUM_SPLITS = 16is fixed, not tuned per shape. At 16 rows × 2K the decode gain is small (1.08×), because the GPU is already mostly busy there.🤖 Generated with Claude Code