Repository navigation
perf(attention): keep a head's Q-blocks on one XCD in the dualwave mapping - #919
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates the dualwave flash-attention workgroup-to-(head, q_block) mapping on gfx950 so that a single head’s query blocks are assigned contiguously in linear workgroup-id space (making head the slow axis). This is intended to improve K/V cache residency per XCD by avoiding scattering one head’s workgroups across all XCDs.
Changes:
- Add a gfx950-specific constant (
NUM_XCD_GFX950 = 8) with explanatory comment. - In
_init_dualwave_thread_mapping, conditionally remap(head, q_block)from the linearized 2D workgroup id whennot SPLITKandNUM_HEADS_Q % 8 == 0; otherwise keep the original direct(block_idx.x, block_idx.y)mapping.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # The map is a bijection over [0, NUM_HEADS_Q * num_q_blocks), so every | ||
| # (head, q_block) pair is still computed exactly once and the result is | ||
| # bit-identical. Split-K adds a third grid axis whose blocks share the | ||
| # same K/V range, so it keeps the direct mapping. |
There was a problem hiding this comment.
Done — cut from 13 lines to 4, and the constant's from 2 to 1.
You were right that it was out of place: this file has 64 comment blocks, and before this change 43 were 1 line and 18 were 2 (median 1, previous max 4). Mine was the only one over 4. It now reads:
# Swizzled Head-first Mapping (arXiv:2511.02132): the grid is head-fast, so one
# head's q-blocks scatter across all XCDs and each re-streams its K/V. Re-derive
# (head, q_block) with head as the slow axis to keep them on one XCD. Bijective,
# so output is bit-identical; split-K's third grid axis would not survive it.Kept only what isn't inferable from the code — the citation, and why split-K is excluded. The mechanism narrative, the L2 counters and the perf tables live in the commit message and PR description instead.
Only comment lines changed: the AST of the file is byte-identical to the revision I tested (ast.dump hash 98d4320861bbd0bd before and after), so the test and benchmark results in the PR description still apply unchanged.
460f351 to
177caf5
Compare
…pping Implements Swizzled Head-first Mapping (Choudhary et al., "Optimizing Attention on GPUs by Exploiting GPU Architectural NUMA Effects", arXiv:2511.02132) for the gfx950 dualwave kernels. The dualwave launch grid is (NUM_HEADS_Q, num_q_blocks, ...) -- head-fast -- and `_init_dualwave_thread_mapping` took block_idx.x/y directly as (head, q_block). gfx950 has 8 XCDs each with a private ~4 MB L2, and the hardware assigns contiguous workgroup ids to an XCD, so one head's query blocks were spread across all 8 XCDs and every XCD re-streamed that head's K/V from HBM. Re-derive (head, q_block) from the linear workgroup id with the head as the slow axis, so a head's blocks stay on one XCD and its K/V stays resident in that XCD's L2 across the concurrent wavefronts. The reuse is the concurrent-wavefront mechanism, not "loaded once" -- a head's K/V is well over the 4 MB L2 at these sequence lengths. Mechanism confirmed by hardware counters (rocprofv3, TCC_HIT_sum / TCC_MISS_sum, one dispatch at S=65536 H=64 D=128 bf16 non-causal): before: L2 hit 2.36% (hits 2.073e+08, misses 8.593e+09) after : L2 hit 93.62% (hits 8.239e+09, misses 5.611e+08) a 15.3x reduction in L2 misses, consistent with the 80-97% L2 hit rates reported in the paper on MI300X. Measured on this branch vs its merge-base, MI355X (gfx950), H=64 D=128 bf16 non-causal batch=1, median of 30 timed iterations, each build in its own JIT cache dir, on the part at PCI bus 0x85: seqlen | before | after | delta | aiter v3 ASM 16384 | 1200.9 | 1318.0 | +9.8% | 1252.1 65536 | 1101.7 | 1329.2 | +20.7% | 1255.6 131072 | 1081.9 | 1326.9 | +22.6% | 1223.7 This moves the dense non-causal path from losing to aiter's closed ASM at every size to beating it by 5-8%. Note on reproducing the absolute numbers: this workload runs into the 1400 W package cap, so sustained clocks -- and throughput -- vary by part. Across all 8 parts in one node the same measurement spans 1204-1328 TFLOP/s after the change (10-11% spread), while the *delta* is stable at +16.3% to +22.6% (median +20%) at 65536-131072. Compare before/after on one part; do not compare absolutes across parts. Correctness: * tests/kernels/test_flash_attn_fwd.py passes on 9 configurations before and after -- bf16/fp16/fp8 dense, causal, D=64, GQA kv=8, batch=4, and both fallback paths (num_kv_splits=2 and num_heads=20). fp8 matters because DualwaveFp8KernelContext shares this mapping function. * The map is a bijection over [0, NUM_HEADS_Q * num_q_blocks), so every (head, q_block) pair is computed exactly once and output is bit-identical: 0 differing elements across 7 shapes (D=64/128, causal, batch=2, and both fallback cases), each also checked against an fp32 SDPA reference (worst 4.6e-3 relative to peak, the expected bf16 error, unchanged here). Guarded to non-split-K with NUM_HEADS_Q % 8 == 0, falling back to the direct mapping otherwise; split-K adds a third grid axis whose blocks share a K/V range that this 2-D linearization would not preserve. The condition is compile-time from traits rather than an env var, so the selected mapping is a function of the build key and cannot alias in the JIT disk cache.
177caf5 to
2149ef8
Compare
|
Re-validated against current
Two notes for reviewers:
🤖 Generated with Claude Code |
Measured on MI355X, B=1 S=16384 H=64 D=128 bf16 causal: the swizzle costs 7.4% there (1067 -> 988 TFLOP/s), turning a win into a regression. Cause: under a causal mask, q-block i does work proportional to i+1. The stock head-fast mapping gives each consecutive run of workgroups a uniform amount of work, one q-block index across all heads. Making q_block the fast axis instead clusters steadily-increasing work into consecutive workgroup ids, so the XCDs finish at different times. Guarded to `not traits.CAUSAL`. Causal returns to the stock mapping and its stock performance (1066 vs 1067 TFLOP/s), while non-causal keeps the full gain (1328 vs 1331). Output stays bit-identical across all 7 validation shapes.
|
Pushed Measured on MI355X, B=1 S=16384 H=64 D=128 bf16, PCI bus
Cause. Under a causal mask, q-block The guard is Output remains bit-identical across all 7 validation shapes (D=64/128, causal, batch=2, and both fallback paths), re-run after the change. A balanced causal mapping — pairing q-block 🤖 Generated with Claude Code |
jhinpan
left a comment
There was a problem hiding this comment.
Blocking: this is not the cited Swizzled Head-first mapping under AMD's documented SPX round-robin workgroup scheduling.
For this launch grid, the PR defines wid = block_idx.x + block_idx.y * H, then maps head = wid // Q and q_block = wid % Q. A fixed logical head therefore consumes consecutive physical WIDs (head * Q + q_block). Since SPX assigns WIDs round-robin across the 8 XCDs (XCD = wid % 8), any head with Q >= 8 is spread across all 8 XCDs. This is Naive Head-first ordering: it can improve temporal/concurrent-wave locality, so the reported counters and speedup may be real, but it does not keep a head on one XCD as the title, comment, and PR description claim.
The cited paper's actual swizzle preserves the physical WID's XCD residue, e.g. for MHA when H % 8 == 0:
heads_per_xcd = H // 8
head = (wid % 8) * heads_per_xcd + wid // (8 * Q)
q_block = (wid % (8 * Q)) // 8
Then all WIDs for one head have the same wid % 8.
There is also a concrete GQA concern. For Hq=64/Hkv=8, the existing direct mapping already gives wid % 8 == h_idx % 8 == h_kv_idx, keeping all Q heads that share K/V on one XCD. The proposed mapping spreads every logical Q head—and consequently every KV group—across all 8 XCDs. Correctness/bit-exactness tests cannot detect this locality regression, and the PR provides no GQA performance or L2 comparison.
Please implement the modulo/stride spatial swizzle for MHA, and either preserve the current GQA mapping or add a KV-head-aware GQA swizzle. Please also add an enumerative mapping test asserting the number of XCDs used per head/KV group, plus before/after GQA counters or performance results.
References:
- Cited mapping and formula: https://arxiv.org/html/2511.02132v1
- ROCm/AITER spatial mapping description (
wid % NUM_XCDS): ROCm/aiter#3936 - AMD SPX scheduling behavior: https://rocm.blogs.amd.com/software-tools-optimization/compute-memory-modes/README.html
#919 made `_init_dualwave_thread_mapping` re-derive (head, q_block) from the linear workgroup id with the head as the slow axis, so a head's blocks stay on one XCD and its K/V stays resident in that XCD's L2 across the concurrent wavefronts. It applied to every qualifying dualwave dense shape, bf16 included, gated only on `not SPLITK and not CAUSAL and NUM_HEADS_Q % NUM_XCD_GFX950 == 0`. #950 put a trait in front of that condition: - if const_expr(not traits.SPLITK and not traits.CAUSAL and ...): + if const_expr(traits.XCD_SWIZZLE and not traits.SPLITK and ...): `XCD_SWIZZLE` defaults to False and only the fp8 builder passes it. That PR measured and described fp8 only, and never touched the bf16 builder -- the string `xcd` does not appear in flash_attn_gfx950.py at all -- so the remap silently stopped applying to bf16. No test covered the mapping, so nothing caught it. Bisecting upstream on MI355X, B=2 H=32 D=128 bf16 non-causal, TFLOP/s at S = 61,380 / 180,180 / 239,580, aiter measured in the same process as control: cf1db2f 7/31 #919 1273.2 1287.0 1287.4 772f070 8/6 #974 1253.7 1074.5 1053.4 494b2cb 8/9 #945 1251.9 1073.3 1052.6 f6d0a41 8/14 #991 1247.2 1076.2 1061.2 47009a3 8/14 #1004 1248.2 1076.2 1061.3 daee90e 8/17 #930 1249.0 1076.4 1060.6 96af91b 8/17 #1014 1247.2 1076.6 1064.5 4521c8c 8/18 #960 1247.8 1074.8 1061.2 aiter ~1250 ~1253 ~1252 One step, between 7/31 and 8/6, and #950 (8/3) is the only commit in that window that touches the mapping. Everything after it is flat to within noise. This plumbs `xcd_swizzle` through the bf16 builder and reuses the existing dispatch condition (non-causal, `H % NUM_XCD_GFX950 == 0`, `num_q_blocks >= MIN_Q_BLOCKS_XCD_SWIZZLE`). Split-K is excluded structurally: it takes the `_build_splitk` branch above this dispatch site. The remap is bijective, so output is bit-identical. Two tests assert that over H=8/16/32/64 and cover the `H % 8 != 0` fallback both forced and auto -- the guard #919 shipped without. One loose end: the restored figure is still ~1.4% below the 7/31 measurement (1268.8 against 1287.4 at S=239,580). The candidates are the `MIN_Q_BLOCKS_XCD_SWIZZLE` threshold #950 introduced for fp8, which #919 did not have, and the LLVM bump in #945. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#919 made `_init_dualwave_thread_mapping` re-derive (head, q_block) from the linear workgroup id with the head as the slow axis, so a head's blocks stay on one XCD and its K/V stays resident in that XCD's L2 across the concurrent wavefronts. It applied to every qualifying dualwave dense shape, bf16 included, gated only on `not SPLITK and not CAUSAL and NUM_HEADS_Q % NUM_XCD_GFX950 == 0`. #950 put a trait in front of that condition: - if const_expr(not traits.SPLITK and not traits.CAUSAL and ...): + if const_expr(traits.XCD_SWIZZLE and not traits.SPLITK and ...): `XCD_SWIZZLE` defaults to False and only the fp8 builder passes it. That PR measured and described fp8 only, and never touched the bf16 builder -- the string `xcd` does not appear in flash_attn_gfx950.py at all -- so the remap silently stopped applying to bf16. No test covered the mapping, so nothing caught it. Bisecting upstream on MI355X, B=2 H=32 D=128 bf16 non-causal, cache cold, TFLOP/s at S = 61,380 / 180,180 / 239,580, aiter in the same process as control: cf1db2f 7/31 #919 1284.6 1288.2 1286.8 772f070 8/6 #974 1256.7 1188.1 1184.3 494b2cb 8/9 #945 1255.6 1187.9 1187.2 f6d0a41 8/14 #991 1256.0 1182.8 1183.6 47009a3 8/14 #1004 1260.5 1188.6 1183.4 daee90e 8/17 #930 1254.1 1077.0 1060.9 96af91b 8/17 #1014 1251.8 1074.9 1060.6 4521c8c 8/18 #960 1249.3 1073.9 1059.0 aiter ~1252 ~1255 ~1255 Two independent steps. The first, between 7/31 and 8/6 (-8.0%), is #950 and is what this commit fixes. The second, between 47009a3 and daee90e (-10.4%), is #930 dropping the fastmath flag when it moved these kernels onto fx.maxnumf, which reaches the raw MLIR builder without passing through resolve_fastmath; that is a separate fix, filed separately. This plumbs `xcd_swizzle` through the bf16 builder and reuses the existing dispatch condition (non-causal, `H % NUM_XCD_GFX950 == 0`, `num_q_blocks >= MIN_Q_BLOCKS_XCD_SWIZZLE`). Split-K is excluded structurally: it takes the `_build_splitk` branch above this dispatch site. The remap is bijective, so output is bit-identical. Two tests assert that over H=8/16/32/64 and cover the `H % 8 != 0` fallback both forced and auto -- the guard #919 shipped without. One loose end: the restored figure is 1268.3 against cf1db2f's 1286.8, a 1.4% gap. That is #930's dropped fastmath, which costs 10.4% with the remap off but only 1.4% with it on -- the two interact. Fixing #930 separately closes it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#919 made `_init_dualwave_thread_mapping` re-derive (head, q_block) from the linear workgroup id with the head as the slow axis, so a head's blocks stay on one XCD and its K/V stays resident in that XCD's L2 across the concurrent wavefronts. It applied to every qualifying dualwave dense shape, bf16 included, gated only on `not SPLITK and not CAUSAL and NUM_HEADS_Q % NUM_XCD_GFX950 == 0`. #950 put a trait in front of that condition: - if const_expr(not traits.SPLITK and not traits.CAUSAL and ...): + if const_expr(traits.XCD_SWIZZLE and not traits.SPLITK and ...): `XCD_SWIZZLE` defaults to False and only the fp8 builder passes it. That PR measured and described fp8 only, and never touched the bf16 builder -- the string `xcd` does not appear in flash_attn_gfx950.py at all -- so the remap silently stopped applying to bf16. No test covered the mapping, so nothing caught it. Bisecting upstream on MI355X, B=2 H=32 D=128 bf16 non-causal, cache cold, TFLOP/s at S = 61,380 / 180,180 / 239,580, aiter in the same process as control: cf1db2f 7/31 #919 1284.6 1288.2 1286.8 772f070 8/6 #974 1256.7 1188.1 1184.3 494b2cb 8/9 #945 1255.6 1187.9 1187.2 f6d0a41 8/14 #991 1256.0 1182.8 1183.6 47009a3 8/14 #1004 1260.5 1188.6 1183.4 daee90e 8/17 #930 1254.1 1077.0 1060.9 96af91b 8/17 #1014 1251.8 1074.9 1060.6 4521c8c 8/18 #960 1249.3 1073.9 1059.0 aiter ~1252 ~1255 ~1255 Two independent steps. The first, between 7/31 and 8/6 (-8.0%), is #950 and is what this commit fixes. The second, between 47009a3 and daee90e (-10.4%), is #930 dropping the fastmath flag when it moved these kernels onto fx.maxnumf, which reaches the raw MLIR builder without passing through resolve_fastmath; that is a separate fix, filed separately. This plumbs `xcd_swizzle` through the bf16 builder and reuses the existing dispatch condition (non-causal, `H % NUM_XCD_GFX950 == 0`, `num_q_blocks >= MIN_Q_BLOCKS_XCD_SWIZZLE`). Split-K is excluded structurally: it takes the `_build_splitk` branch above this dispatch site. The remap is bijective, so output is bit-identical. Two tests assert that over H=8/16/32/64 and cover the `H % 8 != 0` fallback both forced and auto -- the guard #919 shipped without. One loose end: the restored figure is 1268.3 against cf1db2f's 1286.8, a 1.4% gap. That is #930's dropped fastmath, which costs 10.4% with the remap off but only 1.4% with it on -- the two interact. Fixing #930 separately closes it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#919 made `_init_dualwave_thread_mapping` re-derive (head, q_block) from the linear workgroup id with the head as the slow axis, so a head's blocks stay on one XCD and its K/V stays resident in that XCD's L2 across the concurrent wavefronts. It applied to every qualifying dualwave dense shape, bf16 included, gated only on `not SPLITK and not CAUSAL and NUM_HEADS_Q % NUM_XCD_GFX950 == 0`. #950 put a trait in front of that condition: - if const_expr(not traits.SPLITK and not traits.CAUSAL and ...): + if const_expr(traits.XCD_SWIZZLE and not traits.SPLITK and ...): `XCD_SWIZZLE` defaults to False and only the fp8 builder passes it. That PR measured and described fp8 only, and never touched the bf16 builder -- the string `xcd` does not appear in flash_attn_gfx950.py at all -- so the remap silently stopped applying to bf16. No test covered the mapping, so nothing caught it. Bisecting upstream on MI355X, B=2 H=32 D=128 bf16 non-causal, cache cold, TFLOP/s at S = 61,380 / 180,180 / 239,580, aiter in the same process as control: cf1db2f 7/31 #919 1284.6 1288.2 1286.8 772f070 8/6 #974 1256.7 1188.1 1184.3 494b2cb 8/9 #945 1255.6 1187.9 1187.2 f6d0a41 8/14 #991 1256.0 1182.8 1183.6 47009a3 8/14 #1004 1260.5 1188.6 1183.4 daee90e 8/17 #930 1254.1 1077.0 1060.9 96af91b 8/17 #1014 1251.8 1074.9 1060.6 4521c8c 8/18 #960 1249.3 1073.9 1059.0 aiter ~1252 ~1255 ~1255 Two independent steps. The first, between 7/31 and 8/6 (-8.0%), is #950 and is what this commit fixes. The second, between 47009a3 and daee90e (-10.4%), is #930 dropping the fastmath flag when it moved these kernels onto fx.maxnumf, which reaches the raw MLIR builder without passing through resolve_fastmath; that is a separate fix, filed separately. This plumbs `xcd_swizzle` through the bf16 builder and reuses the existing dispatch condition (non-causal, `H % NUM_XCD_GFX950 == 0`, `num_q_blocks >= MIN_Q_BLOCKS_XCD_SWIZZLE`). Split-K is excluded structurally: it takes the `_build_splitk` branch above this dispatch site. The remap is bijective, so output is bit-identical. Two tests assert that over H=8/16/32/64 and cover the `H % 8 != 0` fallback both forced and auto -- the guard #919 shipped without. One loose end: the restored figure is 1268.3 against cf1db2f's 1286.8, a 1.4% gap. That is #930's dropped fastmath, which costs 10.4% with the remap off but only 1.4% with it on -- the two interact. Fixing #930 separately closes it. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
What
_init_dualwave_thread_mappingtookblock_idx.x/ydirectly as(head, q_block). The dualwave launch grid is(NUM_HEADS_Q, num_q_blocks, ...)— head-fast — and gfx950 assigns contiguous workgroup ids to an XCD, each with a private ~4 MB L2. So one head's query blocks were spread across all 8 XCDs, and every XCD re-streamed that head's K/V from HBM.This re-derives
(head, q_block)from the linear workgroup id with the head as the slow axis, keeping a head's blocks on one XCD so its K/V stays resident in that XCD's L2 across the concurrent wavefronts. One file, +24/−2.This is Swizzled Head-first Mapping from arXiv:2511.02132, applied to the gfx950 dualwave kernels.
Mechanism — measured, not assumed
rocprofv3 --pmc TCC_HIT_sum TCC_MISS_sum, one dispatch at S=65536, H=64, D=128, bf16 non-causal:15.3× fewer L2 misses, consistent with the 80–97% L2 hit rates 1 reports on MI300X. The reuse is the concurrent-wavefront mechanism described in 2, not "loaded once" — a head's K/V is well over 4 MB at these sequence lengths.
Performance
MI355X gfx950, H=64 D=128 bf16 non-causal batch=1, median of 30 timed iterations, each build in its own JIT cache dir, part at PCI bus
0x85, this branch vs its merge-base:Moves the dense non-causal path from losing to aiter's closed ASM at every size to beating it by 5–8%.
Reproducing absolutes: this workload runs into the 1400 W package cap, so sustained clock — and throughput — varies by part. Across all 8 parts in one node the same measurement spans 1204–1328 TFLOP/s after the change (10–11% spread), while the delta is stable at +16.3% … +22.6% (median +20%) at 65536–131072. Compare before/after on one part; don't compare absolutes across parts.
Test steps and results
1. Repo test suite — 9 configs, before and after
DualwaveFp8KernelContext)% 8 != 0, must take the fallback)2. Bit-exactness
The map is a bijection over
[0, NUM_HEADS_Q * num_q_blocks), so every(head, q_block)pair is computed exactly once and output is bit-identical. Verified over 7 shapes — D=64/128, causal, batch=2, and both fallback cases:0 differing elements in every case. Each was independently checked against an fp32 SDPA reference (worst 4.6e-3 relative to peak — the expected bf16 error, unchanged by this commit), so a fault identical in both trees would still have been caught.
3. Reproducing the perf numbers
Pin the part by PCI bus (
torch.cuda.get_device_properties(0).pci_bus_id) — noteROCR_VISIBLE_DEVICESdoes not enumerate in the same order asrocm-smi, and given the 10–11% part spread that mismatch is easy to misread as a regression.Guards
Applied only when
not SPLITK and NUM_HEADS_Q % 8 == 0, falling back to the direct mapping otherwise. Split-K adds a third grid axis whose blocks share a K/V range this 2-D linearization would not preserve.The condition is compile-time from traits rather than an env var, deliberately: the mapping is chosen at trace time, so an env-gated version would not participate in the JIT cache key and two builds with identical traits but different mappings could alias in the disk cache.
References
🤖 Generated with Claude Code