Skip to content

perf(rocke/attention): recover fp16 gfx942 D128 prefill via depth-2 sliced-K ring - #9662

Merged
AnaghaRaoAMD merged 4 commits into
developfrom
users/anarao/s34-ring-perf-perf
Jul 28, 2026
Merged

AnaghaRaoAMD merged 4 commits into
developfrom
users/anarao/s34-ring-perf-perf

Conversation

@AnaghaRaoAMD

@AnaghaRaoAMD AnaghaRaoAMD commented Jul 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The correctness fix (#9647) put fp16 D128 prefill on the non-ring flash path — correct but ~0.55–0.99× AOTriton flash. This restores the sliced-K ring for fp16 D128 using a depth-2 pipeline that is both correct and faster, recovering (and exceeding) the ring's performance without the bug.

How it fixes the ring

The original bug: depth-3 ring, kg % 3 slot map, k_groups=4 (D128) → slice 3 reuses slot 0, and the reusing DMA was unfenced → corrupted QK accumulation. Two changes:

  1. Drain-on-reuse fence (now unconditional). Before a prefetch DMA that reuses the slot slice(kg-1) was just read from, drain LDS reads + barrier. Previously only fired under K_SLICED_LDSSEQ.
  2. ring_depth spec field. Depth-2 (k%2, 2 slots) keeps the live set {kg, kg+1} with no slot reuse in the k_groups=4 case and uses less LDS → lower occupancy pressure. Measured best for fp16 D128.

Risk assessment

  • Python-only, consistent with how the ring already ships: the sliced-K ring schedule lives only in the Python engine (C++ has ring primitives but no schedule loop), and attention is not in the byte-identity gate. feat(rocke/attention): enable gfx942 bf16 D128 prefill ring for fp16 parity #9198 enabled the D128 ring with no C++ change for the same reason — no C++ mirror or gate re-bless required.
  • bf16 D128 and D64 paths are unchanged; the fence refactor is byte-equivalent for k_groups=2 (D64 verified).

Perf (fp16 D128, magnitude randn, MI300X, vs AOTriton flash)

shape non-ring (#9647) depth-2 ring (this PR) speedup
32/8 S1024 0.68× 0.88× 1.29×
32/8 S4096 0.92× 1.42× 1.54×
128/8 S1024 0.70× 1.06× 1.51×
128/8 S4096 0.99× 1.55× 1.56×
16/16 S4096 0.80× 1.17× 1.46×

Testing summary

  • Numeric: fp16 D128 depth-2 ring PASS vs fp32 oracle at magnitude (max_abs ~0.002) — GQA 32/8, 128/8 + MHA 16/16, S1024–4096.
  • No regression: bf16 D128 (non-ring) and D64 depth-3 ring (both dtypes) still PASS at magnitude.
  • Suite: 177 attention tests pass. Guardrail test updated for per-dtype routing; on-GPU numeric check gates both the bf16 non-ring and fp16 depth-2 ring paths.

Testing checklist

  • fp16 D128 depth-2 ring numeric vs fp32 oracle at magnitude
  • fp16 D128 perf recovered (1.18–1.56× vs non-ring; beats flash at S4096)
  • bf16 D128 non-ring unchanged; D64 depth-3 ring unregressed
  • launch-meta matches depth-2 spec (nw=4)
  • 177 attention tests pass
  • Reviewer: confirm Python-only ring authoring is acceptable (matches feat(rocke/attention): enable gfx942 bf16 D128 prefill ring for fp16 parity #9198 precedent); C++ ring + gate coverage tracked separately

Technical changes

  • attention_tiled_2d.py — ring_depth spec field (+ kernel_name tag); unconditional drain-on-reuse fence; depth-2 (k%2) schedule in the ring loop.
  • attention_unified.py — _select_gfx942_flash_ring_depth; ring selector re-enables fp16 D128 (depth-2), keeps bf16 D128 non-ring.
  • attention_spec_builder.py — wire ring_depth into both flash spec builders.
  • test_attn_bf16_d128_ring.py — per-dtype routing assertions + on-GPU numeric gate for both paths.

Note: pre-existing gap (not introduced here)

The sliced-K ring (shipping since #9057/#9198) has no C++ engine implementation and no byte-identity gate coverage for attention. This PR follows that existing Python-only pattern. Closing that gap (C++ ring + attention in the gate) is separate follow-up work.

🤖 Generated with Claude Code

@AnaghaRaoAMD
AnaghaRaoAMD marked this pull request as ready for review July 21, 2026 21:27
@AnaghaRaoAMD
AnaghaRaoAMD requested review from a team as code owners July 21, 2026 21:27
@AnaghaRaoAMD
AnaghaRaoAMD force-pushed the users/anarao/s34-ring-perf-perf branch from 1cffdfe to 2ef1678 Compare July 21, 2026 21:32
Base automatically changed from users/anarao/s34-fix-bf16-d128-ring-correctness to develop July 22, 2026 21:32
@therock-pr-bot

therock-pr-bot Bot commented Jul 22, 2026 •

Copy link
Copy Markdown

✅ All Checks Passed — Ready for Review

Check Status Details
🌿 Branch Name ✅ Pass —
📝 PR Title/Description ✅ Pass —
⛔ Forbidden Files ✅ Pass —
🧪 Unit Test ✅ Pass —
🔎 pre-commit ✅ Pass —
🚫 Draft PR 🔜 To Be Enabled —
🚩 Feature Flag 🔜 To Be Enabled —
📊 Code Coverage 🔜 To Be Enabled —
🤖 therock-pr-bot ✅ Pass —

🎉 All checks passed! This PR is ready for review.

📖 Need help? See the Policy FAQ for details on every check and how to fix failures.

…liced-K ring

Stacked on the D128 ring correctness fix. That fix put fp16 D128 on the non-ring
flash path (correct but ~0.55-0.99x AOTriton flash). This restores the ring for
fp16 D128 using a depth-2 pipeline that is both correct and faster.

Root cause of the original bug was the depth-3 ring's kg%3 slot map: at k_groups=4
(D128) slice 3 reused slot 0 and the reusing DMA was unfenced, corrupting the QK
accumulation. Two fixes here:

  1. Drain-on-reuse fence (now unconditional in the ring schedule): before a
     prefetch DMA that reuses the LDS slot slice(kg-1) was just read from, drain
     the LDS reads + barrier. Previously this drain only fired under LDSSEQ.
  2. ring_depth spec field. Depth-2 (k%2, 2 slots) keeps the live set {kg, kg+1}
     with no slot reuse in the k_groups=4 case, and uses less LDS -> lower
     occupancy pressure. It is the measured best fp16 D128 prefill geometry.

Routing (attention_spec_builder / _select_gfx942_flash_ring_depth):
  * fp16 D128 -> depth-2 ring. Correct at magnitude (max_abs ~0.002 vs fp32
    oracle) and 1.18-1.56x faster than the non-ring path (0.65-1.55x flash;
    beats flash at S4096). Verified on MI300X, GQA 32/8,128/8 + MHA 16/16,
    S1024-4096.
  * bf16 D128 -> stays non-ring. At the production block_size=64 the correct
    rings are all slower than non-ring T=64 for bf16 (the faster T=32 ring needs
    block_size=32, which production never sends for D128 -- latent).
  * D64 (both dtypes) -> depth-3 ring, unchanged.

Python-only, consistent with how the sliced-K ring already ships: the ring
schedule lives only in the Python engine (the C++ engine has ring primitives but
no schedule), and attention is not in the byte-identity gate. #9198 enabled the
D128 ring with no C++ change for the same reason.

Guardrail test updated for per-dtype routing (bf16 non-ring, fp16 depth-2 ring)
and the on-GPU numeric check now gates both paths. 177 attention tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>
@AnaghaRaoAMD
AnaghaRaoAMD force-pushed the users/anarao/s34-ring-perf-perf branch from 2ef1678 to d0cdf1c Compare July 22, 2026 21:41

@arai713 arai713 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@AviralGoelAMD AviralGoelAMD left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM with minor suggestion.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR restores the gfx942 sliced-K ring path for fp16 D128 prefill by introducing a depth-2 ring schedule and routing logic, while keeping bf16 D128 on the non-ring flash path. It also updates the D128 routing guardrail tests to validate the per-dtype policy and numerics on-GPU.

Changes:

  • Add ring_depth to the gfx942 2D tiled attention spec and tag it into the kernel name for cache separation.
  • Implement a depth-2 sliced-K ring schedule (and unconditional drain-on-reuse fence) in the gfx942 tiled kernel builder.
  • Update gfx942 ring enablement/selection to route fp16 D128 to depth-2 ring and keep bf16 D128 non-ring; update regression tests accordingly.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
dnn-providers/hip-kernel-provider/rocke/library/kernels/gfx942/attention_tiled_2d.py Adds ring_depth, tags kernel name, and adjusts sliced-K ring scheduling to support depth-2 plus unconditional drain-on-reuse fencing.
dnn-providers/hip-kernel-provider/rocke/library/kernels/common/attention_unified.py Updates gfx942 ring routing: bf16 D128 stays non-ring; fp16 D128 can use ring with depth selected via new helper.
dnn-providers/hip-kernel-provider/rocke/library/builders/common/attention_spec_builder.py Wires ring_depth from selector into the spec construction paths.
dnn-providers/hip-kernel-provider/rocke/library/tests/test_attn_bf16_d128_ring.py Updates regression coverage to assert per-dtype routing and gate bf16 non-ring + fp16 depth-2 ring numerics on-GPU.

…lback

Addresses review: ring_depth was silently sanitized to 3 when not in (2, 3),
masking misconfigurations (e.g. a typo ring_depth=23) and letting the compiled
kernel diverge from the spec. Validate ring_depth in {2, 3} in __post_init__ when
use_k_sliced_ring is set (raise early), and use spec.ring_depth directly in the
builder. Non-ring specs are unaffected (ring_depth unused when the ring is off).

Co-Authored-By: Claude <noreply@anthropic.com>
@therock-pr-bot

Copy link
Copy Markdown

🎉 All checks passed! This PR is ready for review.

Addresses review: depth-2 (fp16 D128) and depth-3 rings are distinct schedules
(different slot map + LDS footprint), so _tiled_cache_key must distinguish them or
two shapes sharing the geometry but differing on depth would collide on the same
cached launcher. Add _select_gfx942_flash_ring_depth to the key (only when the
ring is active; None otherwise). Matches the rd2 kernel_name tag. Verified the key
differs for fp16 D128 (depth-2) vs D64 (depth-3).

Co-Authored-By: Claude <noreply@anthropic.com>
@AnaghaRaoAMD
AnaghaRaoAMD requested a review from a team as a code owner July 28, 2026 17:10
@github-actions github-actions Bot added the rocke-lib: attention Touches rocKE library attention/FMHA kernels label Jul 28, 2026
Addresses review (Copilot): the CK LdsSeq maps ((1,2,0,1) / (1,2)) are 3-slot
layouts -- they reference slot 2. With ring_depth=2 (fp16 D128) K_lds is allocated
with only 2 slots, so returning slot 2 indexes past the allocation -> out-of-bounds
LDS -> corruption. Reachable via HIPDNN_GFX942_K_LDSSEQ=1 on fp16 D128.

Three layers:
  * _kslot: gate the LdsSeq maps on RING_DEPTH == 3; depth-2 uses the modulo map
    (respects K_SLICE_SLOTS).
  * __post_init__: raise if use_k_sliced_ldsseq is set with ring_depth != 3.
  * selector: _enable_gfx942_flash_k_sliced_ldsseq returns False at depth-2 even
    when the env asks, so the env can't build a raising spec.

Verified: ldsseq+depth2 raises; fp16 D128 + K_LDSSEQ=1 now falls back to ldsseq=off
and passes on-GPU (max_abs 0.002); D64 depth-3 ldsseq still enables. 63 tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>
@AnaghaRaoAMD
AnaghaRaoAMD merged commit bd9d615 into develop Jul 28, 2026
25 checks passed
@AnaghaRaoAMD
AnaghaRaoAMD deleted the users/anarao/s34-ring-perf-perf branch July 28, 2026 19:28
AviralGoelAMD added a commit that referenced this pull request Jul 29, 2026
…st) across gfx950/gfx1250 (#10126)

JIRA ID: AICK-1492

## Summary

Restores **`supports_tiled_2d` signature parity across arches** and
renames the flag per review.

The shared feasibility check `supports_native_unified_attention_tiled`
(in `kernels/common/attention_unified.py`) builds one kwarg list and
passes the D256-fast flag to whichever arch's `supports_tiled_2d`
applies. That flag was declared only on **gfx942** (added with the D256
4-warp fast path), so **gfx950 and gfx1250** — keyword-only signatures
with no such param and no `**kwargs` — raised:
```
TypeError: supports_tiled_2d() got an unexpected keyword argument ...
```
for any problem routed through the check. It stayed hidden because
production (`run_unified_attention_torch`) skips this check; only the
bench/parity `path="auto"` path hits it.

## Changes

- **Rename** the kwarg `use_d256_gfx942_fast` → **`use_d256_fast`**
everywhere it is declared/passed (per review): the shared caller and the
gfx942/gfx950 signatures. The predicate `_d256_gfx942_fast(problem)`
that supplies the value is unchanged.
- **gfx950**: add `use_d256_fast: bool = False` (accept-and-ignore) —
the original parity fix.
- **gfx1250**: add `use_d256_fast: bool = False` (accept-and-ignore,
plus the `del` discard) — it had the **same latent gap** as gfx950, so
this completes the parity rather than leaving gfx1250 to hit the
identical error on its next bench/parity run.


## Scope / impact

- **Production is unaffected**: `run_unified_attention_torch` does not
call this feasibility check; it is bench/parity-only. No kernel/emitter
change; no C++ references (rename is Python-only, no byte-identity
impact).
- Surfaced while GPU-verifying gfx950 D128 sliding-window prefill via
the parity harness (the harness could not run any gfx950 shape until
this was fixed).
- Note: an unrelated, pre-existing test
`test_dense_pipe_wiring.py::test_bf16_ring_enabled_for_d128` fails on
develop (it still asserts the bf16-D128 ring that #9647/#9662 moved to
non-ring); it is not touched by this PR.

Changes are behind the feature flag HIPKERNELPROVIDER_ENABLE_ROCKE which
is defaulted to OFF.
dpeabody-amd added a commit that referenced this pull request Aug 6, 2026
…ion_dense (#10102)

## Motivation

`attention_dense` selects its LDS pitch from the DMA row-packing factor:

```python
ROWS_PER_INSTR = 128 // D          # 1 at D=128, 2 at D=64
LDROW = D + PAD          if ROWS_PER_INSTR == 1 else D
VROW  = D + _LDS_PAD_V   if ROWS_PER_INSTR == 1 else D
```

At D=64 both pads collapse to zero, so neither LDS bank-conflict lever
is active
on that head size. The module docstring already recorded this as known —
"its
reads take some LDS bank conflict (V-pad is a future D=64 perf lever)" —
because
D=64 was added for coverage under the constraint that it cost the D=128
schedule
nothing.

Why it matters, in bank terms. On gfx950 bank = `(byte_addr / 4) % 64`
over 64
banks. A wave64 `ds_read_b128` moves 1024 B while LDS delivers 64 banks
x 4 B =
256 B/cycle, so **4 lanes per bank is the conflict-free floor**, not
merely
"better". Aggregating all 64 lanes of one QK read (`n=8` bf16 = 4 dwords
each,
`krow = nsub*32 + lane%32`, `col = ks*16 + (lane//32)*8`):

| K LDS layout | banks touched | lanes per bank |
|---|---|---|
| D=128 unpadded `[BN, 128]` | 8 / 64 | 32 |
| D=128 shipped `[BN, 136]` | 64 / 64 | 4 (floor) |
| **D=64 shipped `[BN, 64]`** | **16 / 64** | **16** |
| D=64 group-padded `[BN/2, 128+8]` | 64 / 64 | 4 (floor) |

So D=64 today sits far closer to the unpadded D=128 case than to the
shipped one.

## Technical Details

A per-row pad is not possible at D=64: one `async_buffer_load_lds` moves
64 lanes
x 2 bf16 = 128 contiguous elements, which is exactly one D=128 row but
*two* D=64
rows, so a padded row pitch would place the second row at the wrong
offset. The
pad therefore goes **between DMA row-groups** — the group stays
contiguous for the
DMA, while the group pitch restores the bank spread.

- New `lds_k_group_pad` spec field (default 8; 0 reproduces the old
unpadded
layout for A/B). `__post_init__` rejects a non-multiple-of-8 pad,
because
`smem_load_vN` stamps `align 16` on the `n=8` read unconditionally, so
an
  8-byte-aligned pitch would keep the `ds_read_b128` while breaking its
alignment contract silently. It also joins `kernel_name()` on the packed
path
— it changes the emitted layout, so it has to be part of the kernel
identity
  or two layouts share a symbol name and a launcher-cache entry. The
  `head_size=128` name is unchanged.
- K LDS becomes
`[NBUF, BN // ROWS_PER_INSTR, ROWS_PER_INSTR * D +
spec.lds_k_group_pad]`
  when `ROWS_PER_INSTR > 1`; unchanged `[NBUF, BN, D + PAD]` otherwise.
- `_async_load` strides its destination by *group* rather than by row.
The sixth
parameter is renamed `ldrow_bytes` -> `group_bytes`; the two are equal
when
`ROWS_PER_INSTR == 1`. V passes the plain unpadded `ROWS_PER_INSTR * D`
pitch,
so its layout is bit-identically addressed (`V_GROUP_BYTES * grp`
reduces to the
  previous `VROW * 2 * row0`).
- `do_qk` maps `krow` to `(krow // K_GROUP, (krow % K_GROUP) * D +
col)`. This is
exact because `nsub*32` is a multiple of `K_GROUP`, so the group/sub-row
split
commutes with the `nsub` offset. Both terms are hoisted above the KV
loop.
- Mirrored in the default and the persistent builder, with an assert
that the
  group split divides evenly so a future head size fails loudly.
- **Why 8 and not 16.** The whole-wave model above cannot separate 8, 16
and 24 —
all three reach the 4-deep floor. A 16-lane phase (16 x 4 dwords = one
full
64-bank sweep) does separate them: pad 8 gives 16 distinct start banks,
pad 16
repeats each twice. A sweep over {0, 8, 16, 24, 32} confirms 8 and 16
are
indistinguishable in practice while 0 and 32 are clearly worse (32 falls
back to
32 banks at 8 deep). 8 is therefore chosen as the model-optimal and
cheaper of
  the two equivalents.
- New `library/tests/test_attention_dense_d64_lds.py` (CPU-only, 63
cases): the
pad is inert at D=128 (identical IR *and* identical LDS, both grid
variants),
live at D=64, grows LDS by exactly one pad per K row-group across
`block_n`
32/64/128 on both builders, and a misaligned pad is rejected. Every
invariant
runs on both allowed dtypes: fp16 takes the same packed group-pad path
as
bf16, and fp16 at D=64 is already a recorded golden case, so bf16 alone
would
  not establish it.
- Also fixed, in the same file: `_DENSE_LAUNCHER_CACHE` is keyed on
`kernel_name()`, which omits `batch` — but `batch` is baked into the
kernel
(buffer extents, persistent work-item count). Two specs differing only
in batch
collided, so the second silently reused the first binary: B=1 then B=2
produced
wrong output with only `1/B` of the tensor written, and B=2 then B=1
raised
`hipError(700) illegal memory access`. The key is now `(kernel_name(),
batch)`;
  keying here rather than widening `kernel_name()` leaves the emitted IR
  untouched. Pre-existing, reproduces on unpatched develop.
- Exposed on the dense prefill benchmark

(`library/benchmarks/gfx950/attention/prefill/benchmark_dense_prefill_live.py`)
as `--lds-k-group-pad`, forwarded at both spec sites, so the pad can be
swept
directly rather than being reachable only from a unit test. Each sweep
row
now records the tuning knobs it was measured at, so the JSON/CSV report
is
  self-describing instead of relying on `kernel_name()` to carry them.
- The same cache-key bug exists in that script, and is fixed here too.
Its
private `_LAUNCHER_CACHE` keyed on `kernel_name()` alone, so it collided
on
`batch` *and* `waves_per_eu`. That one fires on the shipped default run
rather than only in principle: the varlen sweep drives `[2048] * 4`
(B=4) and
`[256, 512, 768, 1024, 1536, 2048]` (B=6) at the same `max_s`, which
produce
an identical `kernel_name()` but different lowered IR, so on `--mode
all` the
  B=6 config silently reused the B=4 binary. The key is now
`(kernel_name(), batch, waves_per_eu)`. Pre-existing, reproduces on
unpatched
  develop.

V is deliberately left alone. The same 128-element DMA granularity
blocks a
per-row V pad, and a group-padded V would need a pad-aware
`pv32_v_load_paired`
— SSA-emitting `platform/` helper code, which pulls in the C++ mirror
and a
byte-identity re-bless. `ROCKE_DENSE_VPAD` is currently inert at D=64
(`VROW` is
64 at both 0 and 32), so conflict-free transposed-V remains an untapped
D=64
lever, tracked as follow-up rather than bundled here.

## Risk Assessment

- **D=128 codegen does not change, by construction.** Every new
operation sits
behind `ROWS_PER_INSTR > 1`, and the builder is side-effecting, so a
guarded
  branch emits nothing. At `ROWS_PER_INSTR == 1` the derived values
  (`K_GROUP=1, K_ROWS_LDS=BN, LDROW=D+PAD, K_GROUP_BYTES=LDROW*2,
V_GROUP_BYTES=1*VROW*2`) are each identical to the prior expression.
Verified
empirically: every D=128 shape the kernel serves in the tracked cohort
lowers
to identical LLVM IR before and after, under **both** `llvm20` and
`llvm22`;
  only the D=64 cases move.
- **Numerically neutral at D=64.** A 19-variant matrix (causal /
non-causal,
bf16 / fp16, MHA and GQA 4/8/16, persistent and default grids, `block_n`
32/64/128, sliding window, ragged, lazy-rescale off, `waves_per_eu=1`)
gives
`max_abs` against an fp32 oracle identical to the pre-change baseline to
every
digit. `varlen` is not in that matrix: it forbids `persistent` and
shares the
default builder's K path verbatim, so it is covered structurally rather
than
  numerically.
- **Static resources at D=64, `block_n=64`** (`llvm-readelf` on the
HSACO, no
  launch — throttle-independent):

| builder | pad | VGPR | AGPR | SGPR | spill | LDS | waves/CU | limiter
|
  |---|---|---|---|---|---|---|---|---|
  | default | 0 | 141 | 0 | 46 | 0 | 32768 B | 12 | VGPR |
  | default | 8 | 145 | 0 | 46 | 0 | 33792 B | 12 | VGPR |
  | persistent | 0 | 197 | 0 | 46 | 0 | 32768 B | 8 | VGPR |
  | persistent | 8 | 197 | 0 | 46 | 0 | 33792 B | 8 | VGPR |

Zero spill throughout. The persistent builder's register count is
unchanged
(the group/sub-row terms are loop-invariant and hoisted); the default
builder
takes +4 VGPR with no occupancy change. LDS grows 1 KB per workgroup
against
160 KB/CU, and the kernel is VGPR-limited rather than LDS-limited at
D=64, so
  the extra kilobyte does not move `waves/CU`.
- **Python-only; no C++ mirror required.** This is a `library/kernels/`
layout
  change built from existing `IRBuilder` primitives — no new IR op, no
`platform/` helper or emitter touched, so the C++ *lowerer* needs no
change.
The #9662 / #9198 precedent that these attention kernels are not
registered
in the platform byte-identity gate no longer holds: develop registered
them
after this branch was cut. At this PR's base the representative golden
held
zero `attention_dense` entries across two llvm flavors; it now holds 165
across three, having gained `llvm23`. The D=64 cases are re-blessed here
  accordingly (see Test Result); the C++ lowerer still needs no change.
- **Blast radius is bounded to an opt-in path.** `dispatch/attention.py`
matches
  the `attention_dense` candidate only on an explicit
  `algorithm="attention_dense"`, never under `auto`.
- **No interaction with D=256.** `__post_init__` admits only head sizes
64 and
  128; D=256 is a different kernel family.

## ASIC Coverage

gfx950 only. `attention_dense` is gfx950-only by construction
(`supports_attention_dense` rejects every other arch), and the change is
further
gated behind `ROWS_PER_INSTR > 1`, i.e. head size 64. Validated on a
physical
gfx950 node.

## Test Plan

- Byte-identity A/B over every shape the dense kernel serves in the
tracked
  cohort, pristine vs patched, for `llvm20` and `llvm22`.
- D=64 numeric vs an fp32 oracle across the 19-variant matrix, baseline
vs
  patched, compared digit-for-digit.
- K group-pad sweep {0, 8, 16, 24, 32}, forward and reverse (ABBA) to
cancel
  ordering drift, plus a higher-rep 8-vs-16 tiebreak.
- Geometry A/B at D=64: `block_n` {32, 64, 128} x `waves_per_eu` {1, 2,
3} x
  persistent {on, off}.
- LDS bank-conflict counters (`SQ_LDS_BANK_CONFLICT`,
`SQ_LDS_IDX_ACTIVE`) at the
  shipped vs padded layout.
- Static occupancy probe (`llvm-readelf`) at D=64 for both builders, per
pad.
- D=128 null control: the same binary timed with the pad knob at 0 and
8, to
  bound same-session A/B repeatability.
- `test_attention_dense_d64_lds.py`, on bf16 and fp16; `black --check`;
  `ruff check` (no `--fix` —
the emitter-code autofix hazard `platform/AGENTS.md` describes applies
equally
to library kernels, and this file has pre-existing `F841`s whose
assignments
  have op-emission side effects).

## Test Result

- **Byte-identity: PASS.** All D=128 cohort shapes identical on both
llvm
  flavors; only the D=64 cases changed; no build errors.
- **Numeric: PASS, 19/19**, every `max_abs` identical to the pre-change
baseline.
- **LDS bank conflicts drop substantially** at the padded layout. Note
the
whole-wave model predicts a 4x reduction and the measured drop is
larger; the
counters are whole-dispatch and cover both the K `ds_read_b128` and the
still-unpadded transposed-V reads, so the residual conflict is
consistent with
  V — the lever tracked as follow-up.
- **D=64 throughput improves materially** at the tracked configurations,
monotonically with sequence length. Quantitative results are in the
protected
  internal page (AICK-1828).
- **Same-session repeatability**, from the D=128 null control where the
pad knob
is inert, is a few tenths of a percent. Absolute throughput on this part
swings
  far more across sessions, so only same-session ratios were used.
- **Static resources:** zero spill, occupancy unchanged (table above).
- **Geometry: no change warranted.** The shipped `bn=64, waves_per_eu=2,
persistent` stays optimal. `block_n=128` is now feasible at D=64 (it is
  VGPR-blocked at D=128) but does not pay for its doubled LDS.
- **New test: 63/63 pass.** The 30 dtype-dependent cases now run on both
bf16
and fp16; the 3 spec-validation cases stay bf16, since that path never
reads
  the dtype. `black --check` clean. `ruff check` reports the same
  pre-existing findings as an unpatched build and none from this change.
- **Benchmark wiring: verified as far as a CPU-only environment
allows.** The
script is GPU-only, so neither CI nor this environment can run a sweep.
Checked
by importing it against a stubbed torch: `--lds-k-group-pad` is present
in
`--help`, and both the success and failure record paths carry the knob
fields.
The varlen cache collision was confirmed directly rather than inferred,
by
building the B=4 and B=6 specs and comparing them: identical
`kernel_name()`,
  different lowered-IR SHA.
- **Platform golden re-blessed for D=64, and validated before writing.**
The
CI-wired `platform/tests/golden/rocke_representative_ir_sha256.json` now
covers `attention_dense`, so the intended D=64 IR change trips it.
Exactly
  three cases move -- `fp16_h64_sq512`, `persist_h64_sq512`,
`ragged_h64_sq500` -- on each of `llvm20`, `llvm22` and `llvm23`, for 27
changed fields and nothing else. To confirm the blessing environment was
trustworthy rather than merely convenient, the harness was first run
against
unpatched develop, where it reproduced the committed golden with zero
drift
  on all three flavors; each re-blessed hash then matched the value CI
  reported independently.
- **Every D=128 `attention_dense` entry in that gate is unchanged**, on
all
three flavors. That is an independent confirmation of the D=128
byte-identity
claim above, and it extends it to `llvm23`, which was not available when
the
  original A/B was run.
- **Not run:**
`test_attention_dense_golden.py::test_attention_dense_cpp_python_byte_identity`
requires a built `rocke_engine`, which was unavailable in this
environment. No
new IR op is introduced, so no C++ change is expected; a reviewer with
the
  engine built should confirm.

## Notes: pre-existing, not introduced here

`library/tests/golden/attention_dense_ir_sha256.json` does not match a
pristine
build of `develop` for either llvm flavor (0 of 18 cases), which is
consistent
with the fixture being deliberately not CI-wired — its own docstring
says so, and
drift is therefore unpoliced. **This PR does not re-bless it**: doing so
would
mix an unrelated whole-fixture refresh into a D=64 change, and would
land on an
already-red baseline. The byte-identity evidence above is the
pristine-vs-patched
diff rather than the committed fixture. Refreshing the fixture (and
deciding
whether to CI-wire it) is better handled on its own.

## Submission Checklist

- [ ] Look over the contributing guidelines at
https://github.com/ROCm/ROCm/blob/develop/CONTRIBUTING.md#pull-requests.

Changes are behind the feature flag HIPKERNELPROVIDER_ENABLE_ROCKE which
is defaulted to OFF.

JIRA ID : AICK-1828

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
AnaghaRaoAMD pushed a commit that referenced this pull request Aug 14, 2026
…ll kernel (#10337)

ISSUE ID : AICK-1664

Ports the gfx950 dense flash-attention prefill kernel
(`attention_dense`, #9480 / AICK-1663) to
**gfx942 (MI300, CDNA3)** and productionizes it there: kernel, builder,
live benchmark, dispatch
arm, torch entry point, and the golden-IR + on-GPU numeric CI lanes.

Opt-in only — `auto` routing and the gfx950 arm are untouched.

## Why a separate kernel, not an arch branch in the gfx950 file

The gfx950 body bakes in CDNA4-only primitives, so the algorithm
genuinely diverges on CDNA3.
`dsl_docs/architecture/multi_arch_data_layout.md` puts that case in a
per-gfx module, and it keeps
the gfx950 golden IR byte-identical by construction.

| Concern | gfx950 (CDNA4) | gfx942 (CDNA3) |
|---|---|---|
| MFMA atom | `mfma_f32_32x32x16` (K=16) | `mfma_f32_32x32x8` (K=8) → 2×
per K=16 tile, A/B repack; **C-layout identical** |
| Conflict-free V | `ds_read_b64_tr_b16` (transpose read) | **no
`ds_read_tr16`** → `perm_b32` store-path transpose |
| Cross-half exchange | `permlane32_swap` | **absent** → `perm_b32` /
`ds_bpermute` |
| LDS / CUs | 160 KB, one CU count | 64 KB, two CU counts → retune
occupancy, `num_persistent` |
| Tile barrier | bare `s_barrier` is safe (NBUF=2) | single buffer → the
tile barrier **must** drain `lgkmcnt` (`sync_lds_only`) |

## Two CDNA3 hazards worth reviewer attention

- **`sync_lds_only()`, not a bare `s_barrier`.** gfx90a+ set
`FeatureBackOffBarrier`, so
SIInsertWaitcnts skips the conservative pre-barrier drain and places the
lgkm wait at the
`ds_read`'s consumer, which the scheduler may sink past the barrier.
Disassembly before the fix
showed `ds_read_u16 x4 -> s_barrier -> s_waitcnt lgkmcnt(0)`; with
NBUF=1 the next iteration's
DMA can then overwrite `V_lds` under an in-flight read. There is an
IR-level regression test for
this, covering both the conflict-free-V and naive tile-start structures.
- **`supports_attention_dense` is the single gate.** `supports(spec)[0]
is True` implies
`build_attention_dense(spec)` succeeds; a parametrized test asserts that
contract in both
directions, with a guard against the grid collapsing to one branch and
making it vacuous.
Divisibility invariants that guard a *silent wrong answer* raise
`ValueError` rather than
  `assert`, because `python -O` strips asserts.

## Commit series

One revertable lever per commit, each with its rationale in the body:

| | |
|---|---|
| `refactor` | share the attention LDS-capacity accessor |
| `feat` | the gfx942 dense prefill kernel + arch alias export |
| `feat` | opt-in dispatch candidate |
| `feat` | builder + live benchmark |
| `test` | CPU surface + dispatch-wiring tests |
| `perf` | lift conflict-free-V (D128 fp16) |
| `perf` | `exp2_fast` in the softmax |
| `perf` | fuse the softmax rescale; widen `exp2_fast` to bf16 D64 |
| `perf` | tune `waves-per-eu` → 2 WG/CU at bf16 D64 |
| `perf` | persistent grid-stride variant |
| `perf` | adopt the D64 K-bank-conflict pad |
| `feat` | `run_attention_dense_torch` entry point |
| `perf` | default-off `_P0_IGLP` scheduler-hint knob |
| `test` | golden-IR lane |
| `test` | wire the library tests into a collectable CI lane |
| `docs` | re-baseline the kernel + README to the measured bound |
| `test` | correct the bf16-D128 sliced-K ring gate test *(separable —
see below)* |

## Notes for the reviewer

- **`kernels/gfx950/attention_dense.py` is touched (+12) only for the
shared `d64_kpad` spec
field** (default `False`). gfx950 codegen is unchanged and its golden is
never re-blessed. This
  is not cross-arch creep.
- **Per-config tuning.** Levers are gated per `(head_size, dtype)`, not
globally; the builder
`README.md` carries the full record of every lever evaluated — adopted
*and* rejected — with the
mechanism and the reason each won or lost. Module-level knobs
(`_P0_D64_KPAD`, `_P0_IGLP`)
  default off, so non-dispatch builds are byte-identical.
- **The last commit is deliberately separable.**
`test_bf16_ring_enabled_for_d128` asserts that
  bf16 D128 takes the sliced-K ring; the code it tests says the opposite
(`# bf16 D128 stays off the ring` → `return False`) and has since
#9647/#9662. The test is red on
`develop` today, independently of this branch. It is corrected here as
the final commit touching
one test file, so it can be dropped or split into its own PR without
disturbing the series.
- Per `AGENTS.md`, no measured performance data appears in the code, the
commit messages, or this
description. Lever effects are described qualitatively; magnitudes live
in the protected results
  page.

## Test plan

- [x] **CPU lane** — `python rocke/library/tests/run_all.py`. All gfx942
dense surface, dispatch
      wiring, and golden-IR tests pass.
- [x] **Golden-IR (gfx942)** — new separate file + fixture; the gfx950
fixture is never written.
Covers D64/D128 × bf16/fp16 × default/persistent × GQA/MHA × both
persistent decodes, at both
llvm20 and llvm22. Two cases are built through the dispatch factory so
they guard the
      *shipped* tuning rather than hard-coded levers.
- [x] **On-GPU numeric** — `python rocke/library/tests/run_all.py
--gpu-only` → **5/5 pass** on
gfx942 (ROCm 7.2, torch 2.12.1+rocm7.2), driving the public
`run_attention_dense_torch`
against an fp32 SDPA oracle for both grid variants. Verified on the
rebased tree.
- [x] **Resource guard** — 0 VGPR/SGPR spill and within the VGPR/LDS
budget across all shipped
      configs (`builders/gfx942/attention/prefill/_p0_occupancy.py`).
- [x] **Cohort parity** — validated against an fp32 SDPA reference
across the in-scope cohort
(bf16+fp16 × D64+D128, MHA + GQA including non-power-of-2, causal and
full, batch > 1) on
      **both** gfx942 CU counts.
- [x] `pre-commit run --from-ref origin/develop --to-ref HEAD` passes.

### Deferred / not covered by this PR

- **hipDNN reachability** (epic DoD) is still open and needs
coordination with the hipDNN team.
- **The on-GPU numeric lane is `gpu`-marked and self-skips on CPU CI.**
It gates on `gcnArchName`
containing `gfx942` rather than the marketing name, so it correctly
covers MI308X. A CPU-only
  runner will report it skipped, not passed — please read it that way.
- **CI registration of `rocke/library/tests/run_all.py` is left to the
CI owners** (CPU lane on
every runner, `--gpu` on a gfx942 runner), consistent with the gfx950
precedent.
- **No C++ engine mirror**: `library/kernels/` has none, so the
dual-engine byte-identity gate does
  not apply here — the Python lowering is the ground truth.
- D256 is out of scope (AICK-1495 / AICK-1496). Varlen, ragged and
sliding-window are rejected with
  a structured reason by `supports_attention_dense`.

### Known-red on `develop`, not caused by this PR

Running the full library suite **on a GPU runner** shows 8 failures. All
8 reproduce on a clean
`develop` checkout, which shows 9 — this branch has one *fewer*, because
the final commit fixes the
ring-gate test.

- `tests/test_attention_builds.py` (3) — fail identically on clean
`develop`; untouched here.
- `tests/test_lds_budget_resolver.py` (5) — a pre-existing
**test-order** bug, not a code bug. The
file passes 9/9 in isolation. `tests/test_attn_bf16_d128_ring.py` writes
the module-level cache
`_RESOLVED_ATTENTION_ARCH = "gfx942"` without restoring it on every
path, while the resolver
tests patch only the `_resolve_attention_arch` *function* — so they read
the leaked value and
assert a gfx950 LDS cap against a gfx942 one. It is invisible on CPU
because the polluting tests
skip without a device. Verified by pairing: this PR's tests followed by
the resolver pass
(14 and 133 passed); the pre-existing ring test followed by the resolver
fails (5).

Worth a separate `develop`-side fix; deliberately not bundled here, and
deliberately **not**
excluded from the new runner, since silencing them would hide `develop`
breakage behind this gate.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Yaswanth Raparti <113389104+yraparti@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants