Repository navigation
Conversation
… load scheduling, lean FP8 MTP4 gfx950-only tile-kernel changes, each behind a traits flag that requires is_gfx950: - BF16_MTP_FUSED: BF16 multi-query (MTP4 verify) page-128 D128 tiles keep Q in padded LDS rows, give every M-tile a private P/Lsum slot (one publication barrier), carry V across M-tiles and pair K16 packs into gfx950's K32 BF16 MFMA. The resident set fits 256 VGPRs (2 CTAs per CU). The task's last tile is peeled and carries the V tail mask; unowned KV rows stay masked. - BF16_DECODE_NT_KV / DECODE_Q_EARLY / DECODE_LAST_V_AFTER_QK: BF16 single-M decode streams K and V nontemporally, issues Q ahead of the page ids, and loads the last tile's V right after QK. - FP8_SCALES_AFTER_Q / FP8_LOOP_SCALES_LATE: per-token FP8 page-128 tiles stage scales so their vmcnt waits stop draining K and V ahead of the softmax. - FP8_MTP4_LEAN / FP8_MTP4_Q_LDS / FP8_MTP4_PAGES_AHEAD: FP8 MTP4 verify folds the K scale and the V normalization into exp2 and the PV MFMA's P scale, rereads Q from LDS and carries the next tile's page ids, with waves_per_eu=2. - The gfx950 BF16-KV tile launch preloads its leading kernel arguments. gfx942 and gfx1250 code paths are unchanged (gfx942: every compiled PA kernel bit-identical). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
🏷️ CI GuideRuns automatically on every PR:
Extended tests (opt-in via labels):
One backend per PR: PR title tags & labels: Reviews: |
…e-16 tiles gfx950 scalar-scale (per-tensor) FP8 page-16 single-M tiles now take the existing V-prefetch pipe (PAGE16_VPIPE) that per-token FP8 page 16 already uses; before, only page 128 qualified, so page-16 tiles issued V after the softmax barriers. Single-CTA page-16 cost drops from ~2.7 us per 256-token tile. Paired in-process A/B vs the previous head on MI355X (FlyDSL 0.5.0): 32 page-16 review shapes 1.035x geomean (single-CTA 30K context 1.53x, H8 B32 1.18x), outputs bit-exact; other paths unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gfx950 BF16 page-16 single-M decode tiles take the V-prefetch pipe as well (they had no V prefetch: ~3.7 us per 256-token tile in a single CTA). Paired A/B on MI355X: single-CTA 30K context 1.58x, random-length B16 1.04x, B1 short contexts +1-2%, large grids -0.4..-0.8%; outputs bit-exact. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…128 decode gfx950 FP8 single-M page-128 decode loads V with nontemporal flat loads (FP8_DECODE_NT_V), as BF16 decode already streams K and V. K stays cached: streaming K as well slowed the short single-tile tasks, and on page 16 streaming fights the V-prefetch pipe (single-CTA 30K -21%). Paired A/B on MI355X over the FP8 page-128 decode shapes: 1.033x geomean (b64 32K 1.18x, b4 32K 1.13x, b32 8K 1.05x), no shape below 0.998x; outputs bit-exact. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d of rebuilding them pa_decode built a fresh one-element scale tensor on every call for scalar FP8 KV scales (torch.ones for None, torch.tensor for a Python float). Under a CUDA graph each is a captured fill or host copy: a scale-less FP8 call ran 4 dispatches instead of 2. The scale is now one persistent tensor per (device, value), cached only when built outside graph capture (a captured fill has not run yet and lives in that graph's pool), so captured-first calls keep working. Host-only; kernels unchanged on every architecture. Paired A/B on MI355X over the 51-shape review set: 1.245x geomean; short scalar-FP8 calls 9.9 -> 6.6 us; outputs bit-exact. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
READY FOR HUMAN REVIEWPR Self-ReviewRevision Author self-review accepted. Readiness report: Author answers
Show the author's self-review answersBehavior and intentValidation and unverified scopePR-specific questionChanged op testsPR state
Copilot findings
Code and op-test hygiene
Required CI
Shadow mode reports what would block but keeps the status successful. Set |
… from the tile kernel A plan with max_partitions == 1 gives every sequence at most one task (capacity == batch), yet gfx950 FP8/BF16 still wrote packed partials and launched the reducer to copy them out. The tile kernel now stores the normalized rows itself, as gfx1250 and MXFP4 already did: slot s runs packed task s and zero-fills sequence s when it owns no task (reduce_info count 0), so the second dispatch and the partial scratch round trip go away. Sinks and strided outputs keep the reducer. The direct store takes the query's row/head strides for any query length. Paired A/B on MI355X: W6 fp8 page-16 B64 mp1 1k 10.55 -> 8.65 us (1.21x), W5 bf16 B64H8 mp1 1.07x, R7 single-CTA 30k 1.01x; outputs bit-exact; other shapes compile the same kernels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… V; FP8 issues next K after QK gfx950 scalar-scale page-16 tiles issue this tile's V at tile start, then load the next tile's page ids from the block table and stage them in LDS. In-order vmcnt makes that staging wait drain V as well, so V overlapped only QK, and the next K went out only after the P barrier, with PV left to hide it. The next page ids now load ahead of V (they are masked past the task and staged only under the existing next-tile guard), and FP8 issues the next K from them right after QK. BF16 keeps K after the P barrier: its next K needs twice the registers and was 4% slower per tile. Single-CTA cost per 256-token tile on MI355X: FP8 1.693 -> 1.412 us, BF16 2.227 -> 2.152 us. Paired A/B: R7 single-CTA 30k 1.20x, R5b budget=CU 32k 1.16x, W6 B64 mp1 1.10x, R1 128k 1.04x, P2 H8 B32 1.03x; outputs bit-exact; other shapes within +-1% (re-measured candidate-first). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e bound The planner gives every nonempty sequence one task, then spreads the other slots in proportion to tiles. With mixed lengths and about two slots per sequence the long sequences keep the longest tasks: 16 tiles on a P2 H8 B32 random 1k-8k batch whose average is 8.5, so the kernel ends on that tail. On gfx950, single-block plans now give a sequence of t tiles min(ceil(t / T), max_partitions) tasks, with T the smallest bound from ceil(total / capacity) up that fits the capacity. A binary search over [ceil(total / capacity), ceil(total / (capacity - rows))] finds it; one block reduction per halving, int32 math, skipped for full plans. Equal-length batches keep their task size and may use fewer slots, which also shortens the reducer. Paired A/B on MI355X (decode only, plans built outside the graph): H2 D256 H8 B32 1.16x, P2 B32 1.07x, H1 B32 1.05x, MiniMax b3 225k four shapes 1.02-1.05x, P4 B8 1.03x; P1 B128 0.99x; 87-shape geomean 1.006-1.008x. Planner refresh +0.06..0.51 us. test_pa_decode_plan asserts the min-max counts when the planner compiles the balanced variant and the proportional split otherwise. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ssion The one-partition direct output generalized the direct store's address to query lengths above 1. Single-query kernels, including the MXFP4 plan-free ones that already stored directly, now keep the original expression, so their generated code stays as it was; only longer queries take the position/head split. Paired A/B: W3/W4 MXFP4 1.000x/1.001x, W5/W6/R7 within noise, outputs bit-exact. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tition decode A one-partition plan gives each sequence a single CTA that walks all of its tiles; on sparse grids that is a latency chain of four waves, one per SIMD. On gfx950, when the grid has at most one CTA per CU, single-query decode tiles (scalar FP8 or BF16, no window, not the head-256 pipe) now launch eight waves as two four-wave groups. Group g walks tiles start + g, + 2, ... with its own copy of the LDS layout; both run the same iteration count (group 1's last tile may lie past the task and is fully masked), so their CTA barriers pair up. After the loop group 1 publishes its (O, max, sum) per lane and group 0 rescales both to the shared maximum and stores, directly or as the task's partial. The grid condition joins the schedule key ahead of its last two fields, which tests read. Single-CTA cost per 256-token tile on MI355X: FP8 page 16 1.42 -> 1.15 us, BF16 page 16 2.15 -> 1.54 us, FP8 page 128 1.69 -> 1.41 us. Paired A/B: R7 single-CTA 30k 1.19x, W6 B64 mp1 1.04x; all other shapes compile the same kernels. FP8 P is quantized against each group's running maximum, so those outputs move within FP8 rounding (max 3.4e-2 of the output max). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… when both LDS copies fit MXFP4 tiles have no per-tile CTA barrier: each wave owns 64 tokens and the four waves merge once after the loop, so a plan-free sequence is one CTA's latency chain. The dual-group CTA now covers it when two copies of the MXFP4 layout (BF16 V staging and staged page ids) fit gfx950's 160 KiB of LDS. The groups merge lane-wise (O, max, sum) before group 0's four-wave merge, and a barrier keeps that merge's LDS writes behind every lane's read of the merge slots, which share the space. Paired A/B on MI355X: W3 MXFP4 plan-free 64k 316.5 -> 226.2 us (1.40x); W4 (a 512k table stages 4096 page ids, so it keeps one group) unchanged; every other shape compiles the same kernels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… max Every FP8 tile quantizes Q to e4m3 per row, first reducing |q| over each lane's 8-element BF16/FP16 unit. LLVM widened every element to f32 for the max: about 30 VALU per unit on the prologue's critical path, 120 per lane for an MTP4 tile. A 16-bit float's magnitude orders like its sign-cleared bit pattern, so on gfx950 the unit now reduces as a packed 16-bit integer max over sign-cleared words, with one conversion of the winner. The result is the same value. Paired A/B on MI355X over 87 shapes: 1.0045x, all outputs bit-exact; FP8 MTP4 verify b3 32k 1.03x, short page-16 FP8 calls ~1-2%. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…de tiles Single-query decode without a sliding window can only see tokens past a row's bound in a task's last tile, which the pipeline already peels for the V tail mask. The head-256 pipe still compared and selected every score chunk of every tile. Interior tiles now skip that mask (single_scores and pack_single take the tile's mask flag); the peeled tile and every other configuration keep it. On the page-16 V pipe the masks are free and the change measured 1.5% slower, so it is left out there. Paired A/B on MI355X: X2 D256 page-64 per-token B8 mp1 1.12x, X1 B1/B8 1.06x/1.05x, X3 page 16 1.04x; 87-shape geomean 1.0015x. Without the select, the multiply contracts into the exp's FMA, so head-256 outputs move within rounding (max 2.4e-3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Black wrapped the condition, which left the noqa on the closing line; ruff 0.16.0, the version CI pins, then reports SIM102 and an unused noqa. Comment placement only; no behavior change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #7388 (FlyDSL-dtype tile kernel keys, no batch-first plan grid). This branch's traits and kernels now use its names: kv_dtype/query_dtype FlyDSL types, OUT_DTYPE, KV_COMPUTE_BLOCK and MXFP4_KV_DTYPE in place of MXFP4/is_bf16_kv/Q_IN_DTYPE/Q_DTYPE/KV_DTYPE/TILE_TOK; the gfx950 one-partition direct-output allowance moves into the schedule's direct_output check with #7388's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…th the prologue's and preload them for every KV dtype The task prologue on gfx950 waited on three dependent scalar loads: the work_info pointer (byte 0x60, past the preloaded prefix), the task record, then the remaining kernel arguments, which the compiler sinks into the task branch. FP8 and MXFP4 tiles preloaded nothing. gfx950 now orders the tile kernel's arguments as work_info, block table, Q, max_blocks, num_seqs, Q strides, pmax and K, which are exactly the 14 dwords that preload into user SGPRs, and preloads them for every KV dtype. The task record, page-id and Q loads issue without a kernarg wait; K/V and scale pointers are first used after the page ids arrive. The kernel body is shared; gfx942 keeps its argument order and its binaries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… undefined fp8_words packs four f32 values into one word with two cvt_pk_fp8_f32: the first writes the low half and the second the high half. The first conversion's tied old word was a literal 0, so every packed word cost a v_mov_b32 0 ahead of it (22 per FP8 MTP4 verify tile, on the P path). On gfx950 that old word is now undef; the result is bit-identical. gfx942 keeps the zero, so its kernels are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sk slot per CU A multi-query (MTP) tile quantizes and stages all of its query rows and publishes their maxima before any KV work, a cost paid once per task. With the default budget of two slots per CU, MTP4 verify ran twice as many, shorter tasks, and the reducer merged twice as many partials. On gfx950, plans with query_length > 1 and no explicit workgroup_budget now default to one slot per CU. Paired over the 14 MiniMax-M3 MTP4 verify workloads: 12 faster (up to 1.14x), FP8 b3 225K 0.972x and b16 32K 0.993x; all 87 shapes 1.0084x. Explicit budgets, other architectures and single-query plans are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… on gfx950 The previous commit gave gfx950 multi-query plans one task slot per CU by default. With 128-256 sequences that leaves one or two tasks per sequence, so uneven lengths can no longer be split: on MiniMax-M3 MTP4 verify with lengths uniform in 1K-8K, FP8 B256 ran 0.96x of main and BF16 B256 1.25x instead of 1.66x. The default budget is now four slots per sequence and KV head, clamped to one to two slots per CU. Batches up to 64 sequences keep one slot per CU (all 14 fixed-length MTP4 verify workloads plan exactly as before); larger batches return to two. Random-length MTP4 verify at B64-B256: 1.17x-1.61x of main (FP8 B256 1.32x). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measured on MI308X (gfx942, 80 CU) against main's gfx942 kernels, four of this branch's gfx950-only paths also win there, so they now cover gfx942: - one-partition FP8/BF16 plans write output from the tile kernel without the reducer launch (UT 1.034x, one-partition decode up to 1.15x, bit-exact); - the min-max planner (random-length batches 1.108x, up to 1.27x); - two tile groups per CTA on sparse one-partition grids, for head_dim 128 only: wider gfx942 heads already take up to 512 VGPRs per wave, so an eight-wave CTA of them cannot launch (one long sequence 1.40-1.48x); - the packed Q absmax (short FP8 calls 1.034x, bit-exact). The schedule records is_gfx942 next to is_gfx950, outside the cache key. gfx950 and gfx1250 take exactly the previous conditions. The BF16/FP8 nontemporal KV streams, the MTP default budget, the FP8 pack and the reducer changes do not help gfx942 and stay gfx950-only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
zhiding512
left a comment
There was a problem hiding this comment.
Advisory review: no correctness defect found; a few things to fix or clarify. Besides the runs below, the DUAL_GROUP barrier pairing, LDS isolation and merge numerics, and the BF16/FP8 MTP fused paths (K32 pairing, tail masks, scale gating) were read through separately; nothing wrong turned up there.
What I ran (MI355X / gfx950, 256 CUs, FlyDSL 0.5.0; this PR at 58bad83 applied on main cb4ac34): MiniMax-M3 TP4 shapes (Hq16 / Hkv1 / D128, page 128), ATOM call style (block table padded to 256K tokens, no max_context_length, default plan, scratch sized from plan.capacity, FP8 with per-tensor scale tensors), CUDA graph of 10 decodes, main and the PR in separate interleaved processes (4 each, 30 samples per process). Geomean 1.082x over 12 shapes; BF16 MTP4 verify b8/32K 1.372x, BF16 decode b3/225K 1.191x. Two shapes are slower than main; see the inline comments on the default budget and on BF16_DECODE_NT_KV. 59d5230 only changes the default budget above 64 sequences, so every shape here plans the same at the current head.
Per-shape latency (us), main vs this PR
| shape | main | this PR | speedup |
|---|---|---|---|
| decode b1 ctx8K bf16 | 7.69 | 7.37 | 1.043x |
| decode b1 ctx8K fp8 | 7.32 | 7.00 | 1.046x |
| decode b4 ctx8K bf16 | 7.86 | 8.16 | 0.963x |
| decode b8 ctx8K fp8 | 7.86 | 7.52 | 1.045x |
| decode b32 ctx8K bf16 | 23.62 | 22.98 | 1.028x |
| decode b32 ctx8K fp8 | 15.65 | 14.48 | 1.081x |
| decode b4 ctx32K fp8 | 10.40 | 9.64 | 1.079x |
| decode b3 ctx225K bf16 | 65.72 | 55.17 | 1.191x |
| verify b3 q4 ctx8K bf16 | 12.50 | 10.63 | 1.176x |
| verify b3 q4 ctx8K fp8 | 11.46 | 11.07 | 1.035x |
| verify b8 q4 ctx32K bf16 | 38.63 | 28.15 | 1.372x |
| verify b8 q4 ctx32K fp8 | 24.30 | 24.74 | 0.982x |
op_lds.py (3 hunks), pipeline.py (8), traits.py (7) and pa_decode_kernel.py (1). Both PRs insert cache-key fields ahead of D256_PIPE, with the matching positional unpack in create(), and the one conflict in pa_decode_kernel.py is the launch block, where this PR's block=(traits.CTA_THREADS, 1, 1) sits against #7709's block=(traits.BLOCK_THREADS, 1, 1); taking #7709's side there would launch DUAL_GROUP kernels without their second group. Could you agree an order with #7709, and assert the cache-key layout somewhere so a mis-ordered unpack fails loudly?
Advisory review, AI-assisted; the runtime numbers come from the runs described above.
| workgroup_budget = 2 * num_compute_units | ||
| if query_length > 1 and properties.gcnArchName.split(":", 1)[0] == "gfx950": | ||
| workgroup_budget = min( | ||
| max(4 * batch * num_kv_heads, num_compute_units), |
There was a problem hiding this comment.
workgroup_budget=512 passed explicitly it is 1.007x. The same shape in BF16 gains from the smaller budget (1.277x at 512 slots, 1.372x at 256). plan_pa_decode doesn't know the KV dtype, and ATOM passes no budget for head_dim 128 (flydsl_plan_shape), so FP8 MTP deployments get the slower setting. Should FP8 keep two slots per CU (for example ATOM passing the budget by KV dtype), or is this trade-off intended? If intended, could the description say so?
There was a problem hiding this comment.
Thanks for measuring this. The difference is the FP8 scale mode: your run used per-tensor scales, and ATOM's FP8 KV cache is per-token (MhaKvPool.scale_views in atom/model_ops/attentions/mha_kv_pool.py returns "one fp32 per (kv head, token)"). Per-token FP8 MTP4 verify takes the lean fused path, and there the one-slot default helps. Same harness as the description (MI355X, paired ABBA in one process, ATOM call style), this branch before vs after the default, vs main:
| MiniMax-M3 MTP4 verify, per-token FP8 | two slots/CU | one slot/CU (this PR) |
|---|---|---|
| b8 q4 ctx32K | 1.074x (21.65 us) | 1.189x (19.51 us) |
| b16 q4 ctx8K | 1.124x | 1.287x |
| b32 q4 ctx8K | 1.095x | 1.182x |
| b3 q4 ctx225K | 1.187x | 1.164x |
So the trade-off is intended for BF16 and per-token FP8, and per-tensor FP8 MTP pays about 2% at b8 32K as you measured. plan_pa_decode cannot see the KV dtype or scale mode, so I kept the default and documented the per-tensor case in the description (Changes, Performance and the unverified list), with the workaround: callers with per-tensor FP8 scales can pass workgroup_budget=2 * num_CUs. The default now also keeps four slots per sequence (59d5230), so batches above 64 sequences are back to two slots per CU; that fixed a random-length B128-B256 regression the equal-length benchmark had hidden.
| "amdgpu-kernarg-preload": True, | ||
| "amdgpu-kernarg-preload-count": 16, |
There was a problem hiding this comment.
True/16 hard-coded, AITER_FLYDSL_KERNARG_PRELOAD=0 (documented in tensor_shim.py L24-33 as turning the preload hint off for all FlyDSL kernels) no longer reaches the gfx950 tile kernel, while the gfx1250 builder in this file still honors it. Both constants are already imported here:
| "amdgpu-kernarg-preload": True, | |
| "amdgpu-kernarg-preload-count": 16, | |
| "amdgpu-kernarg-preload": AITER_FLYDSL_KERNARG_PRELOAD, | |
| "amdgpu-kernarg-preload-count": min(16, AITER_FLYDSL_KERNARG_PRELOAD_COUNT), |
There was a problem hiding this comment.
Applied in d6fa8700ad: the gfx950 tile launch now takes AITER_FLYDSL_KERNARG_PRELOAD and min(16, AITER_FLYDSL_KERNARG_PRELOAD_COUNT) from tensor_shim, so AITER_FLYDSL_KERNARG_PRELOAD=0 reaches it like the gfx1250 builder. With the default environment the hint is unchanged (on, 16), and the gfx950 binaries are identical to the previous head.
| # gfx950 BF16 single-M-tile decode reads every K and V byte once: | ||
| # stream both nontemporally. Streaming K alone slowed caches that fit | ||
| # the MALL; streaming both did not. | ||
| BF16_DECODE_NT_KV = ( |
There was a problem hiding this comment.
📝 The description says no shape is slower beyond noise, but under hot-cache graph replay decode b4 ctx8K BF16 (MiniMax-M3 TP4) is 7.83 us on main vs 8.14 us here (0.962x; 3 processes x 40 samples per side, spread <= 0.8%). Rotating the graph over 24 KV caches, so the working set exceeds the 256 MB MALL, it becomes 10.98 vs 8.71 us (1.26x). That fits these nontemporal K/V loads giving up MALL reuse (inferred, not profiled). Per-layer KV is normally cold in serving, so this is probably a net win; could you qualify the claim and mention the cache-resident case?
There was a problem hiding this comment.
Agreed, the claim was too broad. I see the same pattern on random-length batches: BF16 decode of 64 sequences with 1K-8K tokens is 0.961x. Turning the streaming off recovers B64 1K-8K (+11%) and B128 1K-8K (+4%) but costs B128/B256 long contexts 8-12%. Your rotation over 24 caches (1.26x) shows the other side. In d6fa8700ad the BF16_DECODE_NT_KV comment now records the cache-resident loss, and the description replaces "no shape is slower beyond noise" with the cases that are slower (your b4 8K hot-replay 0.962x and the 1.26x rotated result included), noting that per-layer KV is normally cold in serving.
| ) | ||
| # gfx950 FP8 MTP4 tiles: scores stay frag * k_scale and the positive | ||
| # per-row scale folds into exp2's FMA; P shares one v_scale * norm per | ||
| # chunk; a power-of-two V normalization rides the PV MFMA's P scale, |
There was a problem hiding this comment.
📝 This comment (and the description) says a power-of-two V normalization rides the PV MFMA's P scale, but the PV MFMA always receives UNIT_E8M0_SCALES (op_gemm.py L15, L61-63), and the normalization is rcp_f32(v_max + eps) (op_softmax.py L206), multiplied into P before the FP8 pack in pack_fused_lean. Could you update the comment and the description so nobody relies on an exactness property the code doesn't have?
There was a problem hiding this comment.
You're right, the comment described an earlier design. The PV MFMA always gets UNIT_E8M0_SCALES; P is multiplied by one v_scale / v_max factor per chunk (shared by the M-tiles) before the FP8 pack, and the PV result is scaled back by v_max. Fixed in d6fa8700ad (the FP8_MTP4_LEAN comment) and in the description's Changes section. No exactness property depends on it.
…nv, comments - The tile specialization key is a NamedTuple: select() builds it by keyword and PaDecodeTraits.create() reads it by name, so a field that one change adds and another misses fails loudly instead of shifting the rest. It is still a tuple (hash, equality, the test's cache_key[-2:]). - The gfx950 tile launch takes AITER_FLYDSL_KERNARG_PRELOAD and the count from tensor_shim (capped at 16), so AITER_FLYDSL_KERNARG_PRELOAD=0 turns the preload off here too. Defaults compile as before. - The FP8 MTP4 lean comment now says what the code does: P is multiplied by one v_scale / v_max factor per chunk before the FP8 pack and PV's output is scaled back by v_max; the PV MFMA takes unit scales. - The BF16 nontemporal K/V comment records that a cache staying MALL-resident across calls loses that reuse (b4 8K replayed hot ~0.96x). Schedules and traits are field-for-field identical to the previous commit over 5184 gfx942 and 5184 gfx950 configurations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…above 256 On gfx942 the packed Q absmax made FP8 per-tensor transposed-V head-1024 decode of 64 sequences at 32K 0.87x of main, while other head-1024 FP8 shapes gained 1-13%. Those tiles already spill, so the changed Q prologue shifts their register allocation. The packed absmax now applies on gfx942 up to head_dim 256, where it measured faster on every shape (FP8 1K contexts 1.034x, bit-exact); gfx950 is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The balanced single-block planner now runs on gfx942 as well; the test already detects it from the kernel arguments, only the comment was stale. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the review and the independent numbers. Merge order with #7709. #7709 is my gfx942 PR. Proposed order: #7700 first, then I rebase #7709 onto it:
Cache-key layout. In The inline points are answered in their threads. |
Summary
gfx950 tile-kernel and planner changes to the FlyDSL
pa_decode, four of which also cover gfx942, plus one host-side change for scalar FP8 scales. MiniMax-M3 TP4 decode and MTP4 verify (Hq16/Hkv1/D128, page 128, ATOM call style) get faster on MI355X: geomean 1.145x over 20 workloads, BF16 MTP4 verify up to 1.55x. Larger page-128 batches gain 1.174x. A 51-shapepa_decodereview set, mostly page 16, D256 and MXFP4, gains 1.359x:Noneor a Python float) no longer launch two scale fills per call (short calls 9.6 -> 6.3 us);Over 24 random-length batches of 64-256 sequences in the MiniMax-M3 configuration the branch is 1.167x; one of them, BF16 decode of 64 sequences with 1K-8K tokens, is 4% slower than main (see Performance).
On gfx942 (measured on MI308X, 80 CU), four of these paths also win and now cover it: one-partition output, the min-max planner, two tile groups (head_dim 128 only) and the packed Q absmax (head_dim up to 256). Over all 786 gfx942 cases of the op test (its 111 cases plus every geometry at batch 1/16/64 and 1K/8K/32K tokens) the branch is 1.024x, one-partition long contexts up to 1.49x and random-length MTP4 batches up to 1.27x; the worst case is 0.969x (see Performance). The other changes stay gfx950-only. gfx1250 kernels are unchanged.
Motivation
On gfx950 four paths left time on the table:
vmcntbehind K, which drained K and V before the softmax.vmcntmade that staging wait drain V, so V overlapped only QK, and the next K went out after the P barrier with only PV to hide it: ~1.7 us per 256-token FP8 tile in one CTA.maxnumf(~22 VALU per M-tile), all on the prologue's critical path.max_partitions == 1) still wrote packed partials and launched the reducer to copy them out, a second dispatch on every call.work_infopointer (byte 0x60, past the BF16-only preloaded prefix), the task record, then the remaining kernel arguments, which LLVM sinks into the task branch. FP8 and MXFP4 tiles preloaded nothing.k_scale/v_scalepassed asNoneor a Python float) were rebuilt on every call withtorch.ones/torch.tensor. Each is a fill or host copy, so such a call ran 4 dispatches instead of 2, including inside captured CUDA graphs. That is ~3.4 us on a ~6.5 us call.Changes
Every kernel change sits behind a
PaDecodeTraitsflag that requiresis_gfx950, except the four paths that also cover gfx942 (listed under "gfx942" below):BF16_MTP_FUSED(BF16, D128, page 128, 2-4 M-tiles, no sliding window, positive softmax scale):mask_unowned_kvsemantics unchanged).BF16_DECODE_NT_KV,DECODE_Q_EARLY,DECODE_LAST_V_AFTER_QK: BF16 single-M decode streams K and V nontemporally, issues Q before the page ids, and loads the last tile's V right after QK. Streaming gives up MALL reuse, so a KV cache that stays resident across calls (one cache replayed hot) can be slower; cold KV, as in serving, gains (see Performance).FP8_SCALES_AFTER_Q,FP8_LOOP_SCALES_LATE: per-token FP8 page-128 tiles stage their scales so the scale waits no longer drain K/V.FP8_MTP4_LEAN,FP8_MTP4_Q_LDS,FP8_MTP4_PAGES_AHEAD:v_scale / v_maxfactor, shared by the M-tiles, before the FP8 pack, and PV's output is scaled back byv_max; the PV MFMA itself takes unit scales.waves_per_eu=2.PAGE16_VPIPE), which per-token FP8 page 16 already used. Before, they issued V after the softmax barriers, at ~2.7 us (FP8) and ~3.7 us (BF16) per 256-token tile in a single CTA.PAGE16_PAGES_FIRST/PAGE16_EARLY_K: scalar-scale page-16 tiles load the next tile's page ids ahead of the current V, so staging them no longer drains V. FP8 also issues the next K from those ids right after QK. BF16 keeps K after the P barrier, because its next K needs twice the registers and was 4% slower per tile.FP8_DECODE_NT_V: V is streamed with nontemporal loads. K stays cached, because streaming it as well slowed short single-tile tasks, and on page 16 streaming fights the V pipe.AITER_FLYDSL_KERNARG_PRELOADandmin(16, AITER_FLYDSL_KERNARG_PRELOAD_COUNT)fromtensor_shim, soAITER_FLYDSL_KERNARG_PRELOAD=0turns it off as for other FlyDSL kernels; the defaults give 16.One-partition output, gfx942 and gfx950, FP8/BF16 (
pa_decode.py,pa_decode_kernel.py,op_epilog.py):max_partitions == 1(capacity equals the batch), sinks are absent and the output has the query's strides, the tile kernel stores normalized rows directly and the reducer is not launched. gfx1250 and MXFP4 already did this.reduce_infocount 0), as the reducer did. The direct store uses the query's row/head strides for any query length.Planner (
pa_decode_plan.py), min-max tasks on gfx942 and gfx950:max_partitions) tasks. T is the smallest bound from ceil(total / capacity) up whose tasks fit the capacity.query_length > 1and no explicitworkgroup_budgetdefault to four slots per sequence and KV head, clamped to one to two per CU: one slot per CU up to 64 sequences, two (as before) from 128. Explicit budgets, single-query plans and other architectures are unchanged, and so isprepare_pa_decode_plan, which always passes a budget (2x CU on a tuning-cache miss). Scratch still followsplan.capacity, as documented, which is smaller for batches of up to 64 sequences.workgroup_budget=2 * num_CUs.test_pa_decode_planasserts the min-max counts when the planner compiles that variant, and the proportional split otherwise.The direct store keeps main's address expression for single-query kernels, so the MXFP4 plan-free kernels compile as before.
Two tile groups per CTA, gfx950 and gfx942 at head_dim 128 (
DUAL_GROUP; traits, context, LDS, pipeline, kernel):On gfx950, and on gfx942 up to head_dim 256, the Q absmax reduces each unit as a packed 16-bit integer max over sign-cleared words, because a 16-bit float's magnitude orders like those bits. The row's 16 lanes then combine the f32 bits with integer max over DPP patterns that fold into the max: rotate by 8 and 4, then the two quad swaps. The result is the same value.
Scalar FP8 page-16, scalar head-256 and BF16 page-16 decode tiles issue Q ahead of the page ids and stage it while K is in flight (
DECODE_Q_EARLY), as FP8 head-128 page-128 decode already did. A partial tile's padded rows load the last real row, and the existing row guard never stages them. Per-token head-256 is excluded: its prologue orders the scales ahead of K, and early Q cost it 10%.Single-query head-256 decode tiles without a window skip the score mask on interior tiles; the peeled last tile keeps it. On the page-16 V pipe the masks cost nothing and the skip measured 1.5% slower, so it is left out there.
Kernel arguments, gfx950 (
pa_decode_kernel.py):work_info, the block table, Q,max_blocks_per_seq,num_seqs, the Q strides,pmax(reduce_infoon one-partition plans) and K: the 14 dwords that preload into user SGPRs. The preload now covers every KV dtype, not only BF16._pa_decode_tile_body); gfx942 keeps main's argument order and preload setting.FP8 packing, gfx950 (
op_gemm.py): the firstcvt_pk_fp8_f32of each packed word takes an undefined old word instead of 0, because the second conversion writes the other half. That drops av_mov_b32 0per word (22 per FP8 MTP4 verify tile); results are bit-identical.Host side, all architectures (
pa_decode.py):normalize_scalereturns one persistent one-element scale per (device, value) forNoneand Python-float scales.Merge with main: the branch merges main after #7388, which keys the tile kernel on FlyDSL dtypes. The gfx950 paths read main's renamed traits (
kv_dtype,query_dtype,OUT_DTYPE) and theKV_COMPUTE_BLOCKconstant. The kernel name and grid follow main, and two-group kernels append_dualto the name and launchCTA_THREADS(512) threads. Main's direct-output check, now in the traits schedule, also admits one-partition gfx942 and gfx950 plans.Tile cache key (
traits.py): the specialization key is aPaDecodeCacheKeyNamedTuple.PaDecodeSchedule.select()builds it by keyword andPaDecodeTraits.create()reads it by name, so a field that one change adds and another misses fails loudly instead of shifting the later fields. It is still a tuple: hashing, equality and the test'scache_key[-2:]are unchanged, and schedules and traits are field-for-field identical before and after over 5184 gfx942 and 5184 gfx950 configurations.gfx942 (MI308X, 80 CU). Each gfx950 path was tried on gfx942 against main's gfx942 kernels. Four win there and now cover gfx942 through an
is_gfx942schedule field (outside the cache key):The rest stays gfx950-only, because on gfx942 it is hardware-specific (K32 BF16 and scaled FP8 MFMA, MXFP4) or measured no better: BF16 nontemporal K/V (0.973x, up to 12% slower at 16-64 sequences and 8K-32K tokens), FP8 nontemporal V (0.987x, random lengths 0.951x), the MTP default budget (+25-32% at one sequence, -22% at 16 sequences and 32K tokens: 80 CUs need another rule), and the FP8 pack (0.998x). gfx950 and gfx1250 take exactly the previous conditions.
What does not change:
is_gfx950/is_gfx942;Performance (if applicable)
Method:
pa_decodeare loaded side by side in one process. They run on identical inputs and plans, each captured as a CUDA graph of 10 calls. Replays alternate ABBA: 20 pairs, 10 replays per sample. The columns are per-version medians; the speedup is the median paired ratio, and every block of 10 pairs agrees on its sign.zhiding512/pa-benchmark(bench_pa_minimax_m3.py, not part of this PR): tables padded to 256K tokens, nomax_context_length, default plan.d02f9d058a. The gfx950 numbers were taken at59d523033c; the later commits leave all 49 gfx950 PA kernels of the 87 shapes (tile, plan and reducer) identical on the MI355X.Geomean over these 20: 1.145x (min 1.016x). Their FP8 scales are per-token tensors (ATOM's FP8 cache layout), so the host-side change does not apply to them.
Two cases measured by the reviewer (MI355X, main and this PR in separate interleaved processes):
workgroup_budget=512. With per-token scales the same shape is 1.074x of main at two slots per CU and 1.189x at the new default (table above).Shapes this PR does not target, same method:
pa_decodereview benchmark (page 16, D256 page 64, MXFP4, padded 128K tables, budget /max_partitions/ hinted plans; FP8 scales passed asNoneor floats): 1.359x.R7_fp8_d128_p16_B1H1_mp1_tbl32k_ctx30k2.29x (310.4 -> 135.9 us, page-16 V pipe and two tile groups),W6_fp8_d128_p16_B64H1_mp1_ctx1k1.99x (16.00 -> 8.03 us, one-partition output),R1_fp8_d128_p16_B1H1_tbl128k_ctx10241.59x (9.57 -> 6.03 us, scale fills and early Q),P3_fp8_d256_p64_H1_B1_ctxU1-8k_tbl32k1.51x.W3_mxfp4_free_B1H1_ctx=tbl64k1.40x (314.2 -> 224.4 us),W4_mxfp4_free_B1H1_tbl512k_ctx2k1.14x. Planner:P2_fp8_d128_p16_H8_B32_ctxU1-8k_tbl32k1.42x,P1_fp8_d128_p16_H1_B128_ctxU1-8k_tbl32k1.24x,H2_fp8_d256_p64_H8_B32_ctxU1-8k_tbl32k_hint1.18x. Head-256 interior masks:X2_fp8_d256_p64_B8H8_mp1_strided_pertok_ctx=tbl12k1.13x.X1B32 1.016x and BF16 page-16R21.022-1.038x.max_partitionsand budget plans, MXFP4 B8/B64): 1.182x, min 0.991x (BF16 page-128 GQA-8 H4 B16 with random 1K-16K lengths, 1.039x in an earlier run), 33 of 40 bit-exact.Over all 87 shapes: 1.272x. Outputs are bit-exact to main on 55 of 87 shapes. The rest are MTP4 verify and FP8 paths whose arithmetic changes, plans whose task boundaries move with the planner (partials are merged in a different grouping), two-group tiles, where FP8 P is quantized against each group's running maximum, and head-256 tiles, where the unmasked score multiply contracts into the exp's FMA. All of them are within the correctness limits below.
The last three commits, each measured on top of the rest of this branch (same method, 87 shapes): the argument preload 1.0073x (scored 1.009x) and the FP8 pack 1.0031x (FP8 MTP4 verify 1.011x), both with every output bit-exact; the MTP default budget 1.0084x, with 12 of the 14 MTP4 verify workloads faster (up to 1.14x) and FP8 b3 225K 0.972x and b16 32K 0.993x. Those two are still faster than main, at 1.16x and 1.14x. Its first form, one slot per CU at any batch size, left 128-256 random-length sequences one or two tasks each: FP8 MTP4 verify of 256 sequences with 1K-8K tokens ran 0.96x of main. Four slots per sequence fix that (1.32x below) and plan all 87 shapes as before.
gfx942 (MI308X)
Method: the same paired in-process ABBA harness, CUDA graph of 10 calls, 10 replays per sample, 10 pairs per case for the full matrix and 12-20 for the probes and re-runs. Cases are the gfx942 cases of
op_tests/test_flydsl_pa_decode.py(FP8-FNUZ with per-tensor and per-token scales, plain and transposed V, and BF16): its 111 cases with their own lengths and plans, plus each of its 75 geometry and KV-type combinations (head_dim 64-1024, page 8-128, GQA 4-16, query length 1-8, windows, sinks) at 1, 16 and 64 sequences of 1K, 8K and 32K tokens with the default plan (675). MI308X (gfx942, 80 CU), ROCm 7.2.0, FlyDSL 0.5.0, main atd02f9d058a, this branch atd6fa8700adwith the head-1024 rows re-measured at8240f4cdd4(the two differ only in the Q absmax of FP8 heads above 256). A foreign-process monitor on the timing GPU stayed clean; one disturbed probe row was re-run.decode: FP8 per-tensor, D128, page 16, GQA 8, one partitiondecode: FP8 per-token, D128, page 16, GQA 8, one partitionmax_partitions=1; 1-64 sequences, 1K-32K tokens): 1.279x, min 1.020x.Testing
op_tests/test_flydsl_pa_decode.py::test_pa_decode_plannow asserts the min-max task counts wherever the planner compiles that variant (gfx942 and gfx950); the rest of the file already covers BF16/FP8 MTP4 page 128, unowned-KV poisoning, one-partition plans, two tile groups and the reducer, and passes unchanged. At the head: gfx950 (MI355X) 355 passed / 37 skipped, gfx942 (MI308X) 285 passed / 107 skipped. MI250X is not targeted. gfx942 was tested on an MI308X (80 CU), not an MI300X; see the self-review.Documentation
These are internal gfx950/gfx942 scheduling and layout changes behind traits flags; the public API and its docstring are unchanged.
Dependencies
Merge order: #7709 (gfx942 schedule tuning) conflicts with this PR in
traits.py,pipeline.py,op_lds.pyand the launch block ofpa_decode_kernel.py. This PR should merge first; #7709 is then rebased onto it, keepsblock=(traits.CTA_THREADS, 1, 1)(takingBLOCK_THREADSwould launch two-group kernels without their second group) and adds its cache-key fields by name, and its gfx942 numbers are re-measured on top of the four paths this PR extends to gfx942.Breaking Changes
None
PR Self-Review
Reviewed revision
bdabd08
Behavior and intent
On gfx950, the PA decode tile kernel takes new specialized paths for BF16 MTP4 verify, BF16/FP8 single-M decode and FP8 MTP4 verify. They remove barriers, use the K32 BF16 MFMA, stream KV, reorder the dependent loads of single-tile tasks and fold FP8 scales into exp2/MFMA. In addition, scalar-FP8 and BF16 page-16 tiles now prefetch V and load the next page ids ahead of it (FP8 also issues the next K after QK), FP8 page-128 decode streams V, and one-partition FP8/BF16 plans write output from the tile kernel without the reducer. The gfx950 and gfx942 planner gives each sequence min(ceil(t / T), max_partitions) tasks with T the smallest feasible tile bound, instead of one task per sequence plus a proportional share, because mixed-length batches ended on the longest sequences' tasks. One-partition and plan-free sequences on sparse grids run two four-wave tile groups that merge once, because a lone four-wave CTA exposed each tile's latency. The Q absmax uses packed and DPP integer maxima, interior head-256 decode tiles skip the score mask, and more single-M decode families issue Q ahead of the page ids. The tile kernel's arguments are reordered so everything the task prologue reads first is preloaded into SGPRs for every KV dtype, and the FP8 pack no longer zeroes an old word it overwrites. Multi-query plans default to fewer task slots (four per sequence, one to two per CU), because each MTP task pays for staging all of its query rows while large uneven batches still need several tasks per sequence. The old gfx950 paths left verify 1.4-2x slower per byte than decode, long BF16 decode issue-bound, and page-16 tiles at ~2.7-3.7 us per 256-token tile. On gfx942, measured on an 80-CU MI308X, four of these paths also win and now apply there too: one-partition output, the min-max planner, two tile groups at head_dim 128 and the packed Q absmax up to head_dim 256; the rest is gfx950-only because it uses gfx950 instructions or measured no better on gfx942. On every architecture,
Noneor float FP8 scales now reuse one persistent device tensor instead of two fills per call, which had doubled the dispatch count of short calls. The tile cache key becomes a named tuple read by field name, so a mis-ordered key fails loudly. gfx1250 kernels, the API, gfx942 kernels outside the four paths, and the reducer keep their previous behavior.Validation and unverified scope
Validation:
pytest op_tests/test_flydsl_pa_decode.pyat the reviewed revision: 355 passed, 37 skipped. main's version of the file fails 3test_pa_decode_plancases on this branch, for example[65-None-None-0-1-100-128], because it asserts the proportional split.pa_decodecall (scalesNone, then 0.75) insidetorch.cuda.graphcapture. It then checks that replay, the next eager call and a second eager call give equal, finite outputs. Passed on gfx950.max_partitions=1cases (lengths 0, 1, 255, 256, 257, 1027, so even and odd tile counts and a past-the-end group-1 tile) and its MXFP4 plan-free, extreme-scale and exact-equality variant cases run through the two-group CTA and pass.max_partitions=1plans for FP8 and BF16, with a context-0 row and a NaN-filled output. A missed zero-fill or a mis-strided direct store would fail them.d02f9d058aand on this branch (8240f4cdd4; the head adds only a test comment). Its defaultmax_partitions=1cases run the gfx942 direct output and two-group CTA, andtest_pa_decode_planchecks the gfx942 min-max counts. The first two-group version also ran head_dim 512/1024 in eight-wave CTAs; the pytest's head-1024 cases failed with a launch error, which is why gfx942 two-group tiles are limited to head_dim 128. The 786-case timing run also compares every output against main's: 441 are bit-exact, and the largest difference, relative to the output's maximum magnitude, is 0.034 for FP8 and 0.0076 for BF16. Correctness against a float reference is the pytest's job.59d523033c(where the gfx950 numbers were measured), the gfx942 extension and review commits leave all 49 gfx950 PA kernels of the 87 shapes (tile, plan, reducer) with identicalgpu.binarycode on the MI355X. The named cache key gives field-for-field identical schedules and traits over 5184 gfx942 and 5184 gfx950 configurations. gfx1250 tile and reducer kernels compile to identical code (compile-only).psf/black@stable) and ruff 0.16.0 (the version CI pins) are clean.Not verified:
is_gfx950,is_gfx942or the architecture string; gfx1250 is checked compile-only.workgroup_budget=2 * num_CUs, but not fixed, because the planner does not see the scale mode.atom/model_ops/base_attention.pykeys its scratch onplan.capacityand takesplan_pa_decode's default budget except at head_dim 256, so its D128 MTP plans pick up the new default safely. A caller that computed the capacity itself would now failpa_decode's workspace shape check for gfx950 MTP plans with the default budget.PR-specific question
The changed test is
test_pa_decode_plan, the canonical planner test. It checks every task record: rows, contiguous tile ranges, counts, padding, refresh inside a CUDA graph, batch buckets and the LDS limit, across windows, partition caps and budgets.Other boundaries: the most important kernel boundary is unowned KV in the new peeled last tile of the BF16 MTP4 fused path. V rows past the context, and padded block-table lanes pointing at page 0, may hold NaN or Inf. They must contribute exactly zero, because 0 * NaN is NaN in PV. The fused path masks V on the task's last tile with the same
mask_v_chunkthe generic path uses, honoringmask_unowned_kv.test_pa_decode_unowned_kv_rows(NaN and Inf fills, pages 16/64/128, query length 4) passes on gfx950. As a check, a copy of this PR with only the fused path's V mask removed failstest_pa_decode_unowned_kv_rows[bf16-128-4-1-16-128-nan]and[...-inf]; this PR passes all 14 of its BF16 page-128 cases. The two-group CTA relies on both groups executing the same CTA barriers. They run the same iteration count, the past-the-end tile is masked, and the merge has a barrier on each side. For MXFP4 a third barrier keeps the four-wave store's LDS writes behind every lane's read of the overlapping merge slots: without it, the MXFP4 plan-free and exact-variant pytest cases produced non-finite outputs. The second boundary is the gfx942/gfx950 one-partition direct output. It relies onplan_pa_decode's invariant thatmax_partitions == 1means capacity equals the batch, so each sequence owns at most one task and no reduction is needed. Slot s runs packed task s, and sequence s is zero-filled when itsreduce_infocount is 0. The NaN-prefilledmax_partitions=1pytest cases with a context-0 row cover both. Plans with sinks or a strided output keep the reducer. The argument reorder is a positional contract between_tile_argsand the gfx950 kernel signature: a mismatch passes wrong pointers or strides, which every pytest case and the 62-workload gate would catch, and gfx942 keeps main's argument order. The third boundary is the host-side scale cache under CUDA graph capture. A scale built while capturing is used for that capture only and never cached, because its fill runs only on replay and its memory is in that graph's pool. The capture-first script above exercises exactly that order. On gfx942 the boundary is register capacity: an eight-wave two-group CTA of head_dim 512 or 1024 needs up to 512 VGPRs per wave and cannot launch, soDUAL_GROUPrequires head_dim 128 there. An earlier version without that limit failed the pytest's head-1024 cases with a launch error; at the reviewed revision they pass.Changed op tests
op_tests/test_flydsl_pa_decode.py, onlytest_pa_decode_plan: its per-row task-count assertion now covers the gfx942/gfx950 min-max planner (bound recomputed on the host) as well as the proportional split. No other test planned and checked task records, so no new test was added; the rest of the file is unchanged.🤖 Generated with Claude Code