Repository navigation
Conversation
🏷️ CI GuideRuns automatically on every PR:
Extended tests (opt-in via labels):
|
|
@cagrikymk @azaidy same ask here. This is the split-K long-context variant of #3959 and only adds pa_decode_shuffle_perf.py. The one red is Standard Tests MI35X shard 6, the moe_2stage fp4 allclose test, which this file doesnt touch. I can rebase both if you'd rather have fresh CI. |
|
@azaidy @vgokhale same situation as #3959, this adds pa_decode_shuffle_perf.py (the split-K long-context variant) and has no conflicts. The only red is the June MI35X shard 6 run, the moe_2stage fp4 allclose test, which is outside this file. I can rebase both together once someone has time to review. |
The one-pass sliding-window shuffled-fp8 decode runs one program per (sequence, kv head) and walks the whole windowed key range serially, which underfills the device at long context while the grid is too small to fill it. The asm page-split path can't take a window, so the lever for the windowed shuffled path is the triton kernel. Add paged_attention_decode_shuffle_swa_perf, a flash-decoding (split-K) variant: split the windowed range into partition_size chunks across the grid, then reduce the per-partition softmax state. The split is bounded to [seq_len - window, seq_len), max_seq_len is a host int (no per-step device sync), and fp32 scratch is reused across steps. Gating keeps it one-pass for short context or a full grid, and only splits on arches where it is measured to win end to end (gfx950 by default, overridable via AITER_PA_SHUFFLE_SPLIT_ARCH); force_split overrides the gate for testing both paths on any arch. On gfx950 long context this is ~2.0-2.1x over one-pass, recovering decode to ~84-90% of bf16; at short context it stays one-pass (parity). On gfx942 the isolated kernel is faster (up to ~5x microbench) but decode attention is a small slice of a dense-7B step, so it is parity-to-slightly-behind end to end; the arch gate keeps gfx942 on one-pass. Numerics ALL_PASS on both arches and both fp8 dtypes, one-pass and forced split, within the fp8 quantization floor. Signed-off-by: adlashab <adlashab@amd.com>
a3b19d7 to
1bff7ae
Compare
|
@azaidy @vgokhale same for the split-K variant: rebased onto e8bdaf8, new head 1bff7ae. Main's ruff config flagged two lint points in the file (import order in _arch_allows_split and Numerics on MI355X today: 16 of 16 cases pass for e4m3fn and e4m3fnuz, one-pass and forced split. In git it doesnt depend on #3959, each PR only adds its own file, so they can go in either order. |
There was a problem hiding this comment.
🟡 Changes recommended
Partition coverage and workspace defects can produce incorrect outputs or out-of-bounds writes.
8 open findings
Invalid V-cache rows can corrupt dot products with NaNs · New Empty partitions can propagate NaNs from unwritten accumulators · New Concurrent streams share scratch workspace unsafely · New Validate accumulator shape and device before workspace reuse · New Auto mode processes only part of the key range · New Reuse shared get_num_sms helper with device overrides · New Cache multiple workspace shapes to avoid repeated reallocations · New Add tests and benchmarks for the new decode path · New
What changed in this PR
Adds a Triton split-K decode path for sliding-window attention over shuffled fp8 KV caches, targeting long-context GPU utilization.
Changes:
- Adds partition and softmax-reduction kernels with a one-pass fallback.
- Selects split execution by context length, grid size and architecture.
- Adds reusable fp32 workspace and caller-controlled dispatch overrides.
| File | Description |
|---|---|
aiter/ops/triton/attention/pa_decode_shuffle_perf.py |
Adds decode kernels, dispatch gates and workspace management. |
🧠 Review effort: Balanced
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| p = tl.exp(scores - m_new[:, None]) | ||
| alpha = tl.exp(m_i - m_new) | ||
| l_i = l_i * alpha + tl.sum(p, axis=1) | ||
| acc = acc * alpha[:, None] + tl.dot(p.to(tl.float32), v) |
| ) | ||
| acc_part = tl.load( | ||
| acc_ws_ptr + acc_off, | ||
| mask=gmask[:, None, None] & pmask[None, :, None], |
| need_m = (num_seqs, num_kv_heads, grp, n_part) | ||
| need_a = (num_seqs, num_kv_heads, grp, n_part, head_pow2) | ||
| if workspace is None: | ||
| workspace = _WS_CACHE.setdefault(dev.index, {}) |
| if workspace.get("shape_m") != need_m: | ||
| m_ws = torch.empty(need_m, dtype=torch.float32, device=dev) | ||
| l_ws = torch.empty(need_m, dtype=torch.float32, device=dev) | ||
| acc_ws = torch.empty(need_a, dtype=torch.float32, device=dev) | ||
| workspace.update(shape_m=need_m, m=m_ws, l=l_ws, acc=acc_ws) |
| GRP=grp, | ||
| GRP_POW2=grp_pow2, | ||
| SCALE_SCALAR=scale_scalar, | ||
| PART=partition_size, |
| idx = device.index if device.index is not None else torch.cuda.current_device() | ||
| n = _N_CU.get(idx) | ||
| if n is None: | ||
| n = torch.cuda.get_device_properties(idx).multi_processor_count |
| if workspace.get("shape_m") != need_m: | ||
| m_ws = torch.empty(need_m, dtype=torch.float32, device=dev) | ||
| l_ws = torch.empty(need_m, dtype=torch.float32, device=dev) | ||
| acc_ws = torch.empty(need_a, dtype=torch.float32, device=dev) | ||
| workspace.update(shape_m=need_m, m=m_ws, l=l_ws, acc=acc_ws) |
| return allow | ||
|
|
||
|
|
||
| def paged_attention_decode_shuffle_swa_perf( |
… and NaN paths, add test and bench When the grid underfills the device the auto path caps n_part, but every partition still read partition_size keys, so only n_part * partition_size keys were used (eff 4096, base grid 128, 256 CUs: 4 partitions read 1024 keys). The partition size now grows so n_part partitions cover eff. It is a runtime arg, so a growing context does not compile new kernels. The fp32 scratch is per (device, stream) and only grows. It is checked by element count, so a head 128 call after a head 64 call no longer writes past the acc buffer, and a caller dict that holds tensors of another device gets new ones. The partition dim is rounded up to a power of two and n_part is a runtime arg of the reduce, so changing partition counts reuse the same scratch and the same reduce kernel. A replaced buffer stays referenced because a captured cudagraph can still point at it. NaN paths: V rows of unused slots are zeroed in the edge blocks (0 * NaN in p @ v), the reduce skips partitions with l = 0 because their acc is never written, and rows with seq_len 0 return zeros. V scales use their own strides. CU count comes from get_num_sms() (it honors CU_NUM) and the arch from arch_info.get_arch(). Add op_tests/triton_tests/attention/test_pa_decode_shuffle_perf.py and op_tests/op_benchmarks/triton/bench_pa_decode_shuffle_perf.py. Signed-off-by: adlashab <adlashab@amd.com>
b138774 to
476b9e6
Compare
|
I went through the 8 review bot points on MI355X with a repro against the old head 1bff7ae (KV cache, per-token scales and scratch filled with NaN wherever the kernel shouldnt read). All 8 were real, the fix is in 476b9e6.
V scales also use their own strides now. Perf vs the old head, per call in a cudagraph, MI355X at perf_determinism 1700 MHz, 32 q / 8 kv heads, head 128, 16-slot pages, 2 runs each in ABBA order: where both read the same keys its within -5% to +3% (bs 1 ctx 8192 window 4096: 43.8 -> 45.1 us, bs 32: 327.1 -> 329.9 us, bs 64: 637.9 -> 638.8 us, bs 32 ctx 2048 window 1024: 130.4 -> 124.1 us). Where the old code was capped it read fewer keys, so the new one is slower but correct (bs 8: 59.5 -> 108.0 us, bs 16: 55.3 -> 195.8 us). Thats still 4.5x and 2.5x faster than the one-pass kernel at the same shapes (482.9 and 488.3 us). |
|
No activity for 15 days, so this is now labelled |



Summary
Adds
aiter/ops/triton/attention/pa_decode_shuffle_perf.py, a flash-decoding(split-K) variant of the sliding-window shuffled-fp8 paged decode. The one-pass
kernel (companion PR,
pa_decode_shuffle.py) runs one program per (sequence, kvhead) and walks the whole windowed key range serially, which underfills the device
at long context. This splits that range into
partition_sizechunks across the gridand reduces the per-partition softmax state in a second pass. It is gated to engage
only where it actually helps and never regresses the short-context path.
Motivation
At long context the one-pass per-program key walk gets long while the grid
(
num_seqs * num_kv_heads) is too small to fill the GPU, so most CUs sit idle. Theasm decode path solves this with page-split kernels, but the asm path can't take a
window (the gfx942/950
pa/*.cokernels are prebuilt code objects with no.ssource or PA asm generator in the tree, and the registry has only a causal-mask flag,
no window). So the realistic lever for the windowed shuffled-cache path is the triton
kernel, parallelising context the same way the asm page-split kernels do.
Changes
pa_decode_shuffle_perf.pywithpaged_attention_decode_shuffle_swa_perf(...): a partition kernel + a reducekernel, plus a one-pass fallback that runs the same algorithm as the companion
one-pass kernel.
[seq_len - window, seq_len), so a windowed layer onlypartitions the keys it actually attends.
max_seq_lenis taken as a host int(vLLM already tracks it), so no per-step
.item()device sync.split path doesn't allocate every step.
_pick_n_part+_arch_allows_split): the auto path splits only when thegrid underfills or the serial walk is long (>= a threshold of partitions), AND only
on arches where the split is measured to win end to end (gfx950 by default;
override with
AITER_PA_SHUFFLE_SPLIT_ARCH). Short context, a full grid, or anon-listed arch stays one-pass, so it is identical to the one-pass kernel there.
force_splitoverrides the gating and is used by the tests to exercise both codepaths on any arch.
Performance
Decode tok/s, clock-locked (
perf_determinism), Mistral-family SWA model(
sliding_window=4096),--max-model-len 8192, fp8 shuffled KV,ROCM_AITER_FA.Before = one-pass kernel (companion PR), After = this split-K kernel.
MI355X (gfx950), ROCm 7.13 (where the split is enabled by default):
At long context split-K is ~2.0-2.1x over one-pass and recovers decode to ~84-90% of
the bf16 baseline (909-913 tok/s); at short context it stays one-pass (parity). The
numbers above are from the 0.19.1 image with the 0.19.1 dispatch.
Re-confirmed on vLLM built from source at main d980a3cc
(0.23.1rc1.dev480+gd980a3cc6, same node, clock-locked), routing through the corrected
main dispatch, zephyr-7b-beta: short (batch 32, ctx ~590) bf16 3679 / one-pass 3470 /
split-K 3452 (parity, stays one-pass); long (batch 8, ctx ~6700, n_part ~16) bf16
~743 / one-pass ~387 / split-K 731 and 786 on two warm runs (~1.9-2.0x over one-pass,
recovering to ~parity with bf16). The first windowed split-K call pays a one-time
triton JIT for the partition grid (544 tok/s cold vs ~759 warm); one-pass and bf16
are stable to ~1%.
MI300X (gfx942), ROCm 7.2.1 (kept one-pass by the arch gate; numbers are with the
gate forced off, to show why):
Isolated kernel microbenchmark on gfx942 (clock-locked) is a real win: parity at
short window, ~1.3-1.4x at win1024/ctx2048, ~4.6-6.4x at win4096/ctx8192 and full
causal. But on a dense 7B at these shapes the decode-attention kernel is a small slice
of the step next to the QKV/O projections and the MLP, so the isolated win does not
surface end to end and the extra reduce launch makes it parity-to-slightly-behind.
That is why the auto path keeps gfx942 on one-pass. gfx950's bf16 decode is much
faster (909 vs ~276 tok/s at the same long shape), so the underfilled one-pass kernel
is a far larger fraction of the step there and the split has real headroom.
Testing
Numerics harness against the independent fp8 reference, both fp8 dtypes
(
e4m3fn,e4m3fnuz), both code paths (force_spliton and off), full case matrix(head 128, GQA 32/8 and MQA, block 16/32, windowed and full, per-tensor and per-token)
plus long-context cases that exercise 4-16 partitions. Split-K reproduces the one-pass
result within the fp8 tolerance, so the reduce is not a correctness regression. This
is the same case matrix and ALL_PASS result as the companion one-pass kernel PR.
MI300X (gfx942): ALL_PASS both dtypes, max abs <= 1.81e-2 vs a 0.18-0.35 quant floor.
MI355X (gfx950): ALL_PASS both dtypes, max abs <= 1.55e-2 vs the same floor.
Tested on MI300X (gfx942)
Tested on MI355X (gfx950)
Performance benchmarks run (tables above)
Documentation
Layout, split scheme, and gating are documented in the module docstrings.