Skip to content

perf(rocke/attention): restore LDS K padding at D=64 in gfx950 attention_dense - #10102

Merged
dpeabody-amd merged 6 commits into
developfrom
users/dpeabody/rocke-attention-d64-lds-kpad
Aug 6, 2026
Merged

dpeabody-amd merged 6 commits into
developfrom
users/dpeabody/rocke-attention-d64-lds-kpad

Conversation

@dpeabody-amd

@dpeabody-amd dpeabody-amd commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

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

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 perf(rocke/attention): recover fp16 gfx942 D128 prefill via depth-2 sliced-K ring #9662 / feat(rocke/attention): enable gfx942 bf16 D128 prefill ring for fp16 parity #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 F841s 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

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

JIRA ID : AICK-1828

…ion_dense

The LDS pitch selector disabled both bank-conflict pads at D=64, because one
async_buffer_load_lds writes 64 lanes x 2 bf16 = 128 contiguous elements --
exactly one D=128 row, but two D=64 rows, so a per-row pad would place the
second row at the wrong offset. Pad between DMA row-GROUPS instead: the group
stays contiguous for the DMA while the group pitch restores the bank spread.

K LDS becomes [NBUF, BN // ROWS_PER_INSTR, ROWS_PER_INSTR * D + _LDS_PAD_K64]
when ROWS_PER_INSTR > 1, and do_qk maps krow to
(krow // K_GROUP, (krow % K_GROUP) * D + col) -- exact because nsub*32 is a
multiple of K_GROUP. Mirrored in the default and persistent builders. V keeps
the unpadded packed pitch and stays bit-identically addressed.

D=128 emission is unchanged by construction: every new operation sits behind
ROWS_PER_INSTR > 1, and at ROWS_PER_INSTR == 1 each derived value is identical
to the prior expression. Verified against every D=128 shape the kernel serves,
under both llvm20 and llvm22.

_LDS_PAD_K64 is validated at import as a non-negative multiple of 8 elements:
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.

Also key _DENSE_LAUNCHER_CACHE on (kernel_name(), batch). batch is baked into
the kernel via the K/V buffer extents and the persistent work-item count, but
is absent from kernel_name(), so two specs differing only in batch collided and
the second silently reused the first binary -- wrong output, or an illegal
memory access depending on the order. Keying here rather than widening
kernel_name() leaves the emitted IR untouched.

JIRA ID : AICK-1828

Co-authored-by: Cursor <cursoragent@cursor.com>
@dpeabody-amd
dpeabody-amd requested a review from yraparti July 29, 2026 00:13
@dpeabody-amd
dpeabody-amd requested review from a team as code owners July 29, 2026 00:13
@dpeabody-amd dpeabody-amd added the rocke-lib: attention Touches rocKE library attention/FMHA kernels label Jul 29, 2026
@therock-pr-bot

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

Copy link
Copy Markdown

✅ All Checks Passed — Ready for Review

Check Status Details
📝 PR 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.

🙋 Wish to Override Policy?

@therock-pr-bot

Copy link
Copy Markdown

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

Review feedback on #10102: thread the pad through AttentionDenseSpec and the
builder instead of reading an environment variable at import time.

- lds_k_group_pad replaces the _LDS_PAD_K64 module global and the
  ROCKE_DENSE_KPAD64 override; default 8, and 0 reproduces the old unpadded
  layout for A/B.
- The 16-byte-alignment validation moves into __post_init__ alongside every
  other spec check, so supports_attention_dense() now reports a bad pad the
  same way it reports any other invalid field instead of raising at import.
- The pad joins kernel_name() on the packed path only. 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.
- The tests construct specs directly rather than monkeypatching a module
  global, and gain a case asserting the pad is in the kernel identity at
  head_size=64 and absent at 128.

No functional change to the emitted kernel beyond the name: head_size=128 IR
stays byte-identical across every cohort shape under both llvm flavors, and the
D=64 numeric matrix is unchanged.

JIRA ID : AICK-1828
@yraparti

yraparti commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

The D=64 approach looks solid. Padding between DMA row-groups preserves the packed async-LDS load path while restoring K read bank spreading, and the alignment validation is particularly important for the ds_read_b128 path. A few things I'd like to see for confirmation:

Perf data comparing kpad={0,8,16,24,32} to justify the default value of 8.
Coverage demonstrating no regressions in persistent+D64 variants.
Follow-up evaluation of launcher cache uniqueness. I agree with @AviralGoelAMD comment that waves_per_eu appears codegen-affecting and may need to participate in the cache key as well.

Otherwise the design direction looks correct and maintains D=128 codegen stability while addressing the D=64 banking issue

@dpeabody-amd

Copy link
Copy Markdown
Contributor Author

@yraparti thanks, taking your three in order.

dpeabody-amd and others added 2 commits August 5, 2026 16:50
fp16 is an allowed dtype and takes the same packed group-pad path as bf16,
and fp16 at head_size=64 is already a recorded golden case, so bf16 passing
did not by itself establish the invariants for it.

Thread dtype through _spec and parametrize the five IR-dependent tests over
both dtypes; 33 cases become 63. The misaligned-pad test is left alone since
__post_init__ validation never reads dtype.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ll benchmark

The pad was reachable only from a unit test, so the benchmark could select
--d 64 but was pinned to the default pad with no way to A/B against 0. Add
--lds-k-group-pad and forward it at both spec sites.

Also fix the launcher cache key, which keyed on kernel_name() alone. Neither
batch nor waves_per_eu is part of the name, and both are baked into the
kernel -- batch through the K/V buffer extents, waves_per_eu through the
amdgpu-waves-per-eu attribute. This collides today rather than in theory: the
varlen sweep runs B=4 and 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.

Record the tuning knobs on each sweep row so the JSON and CSV reports are
self-describing rather than relying on kernel_name() to carry them.

Co-authored-by: Cursor <cursoragent@cursor.com>
@dpeabody-amd
dpeabody-amd force-pushed the users/dpeabody/rocke-attention-d64-lds-kpad branch from 964cb94 to 5d4300e Compare August 5, 2026 22:50
dpeabody-amd and others added 2 commits August 5, 2026 18:39
…m golden

develop registered attention_dense into the platform byte-identity gate after
this branch was cut: at its base the representative golden held zero
attention_dense entries and two llvm flavors, and it now holds 165 entries and
adds llvm23. The D=64 IR change in this PR therefore trips a gate it did not
previously reach.

Re-blesses exactly the three D=64 cases that the pad is designed to change --
fp16_h64_sq512, persist_h64_sq512 and ragged_h64_sq500 -- across all three
flavors. Every D=128 attention_dense entry is untouched, which is the gate
independently confirming this PR's D=128 byte-identity claim, including on
llvm23 which was not previously available to check against.

The re-bless was validated before writing: on unpatched develop the harness
reproduces the committed golden with zero drift on all three flavors, so the
regenerated values come from an environment that matches the one that blessed
it. With the patch applied the drift is exactly the nine expected entries, and
each resulting hash matches the value CI reported.

Note this is the platform golden, which is CI-wired. The separate
library/tests/golden/attention_dense_ir_sha256.json fixture is still not
CI-wired and is still stale against develop; refreshing it remains out of
scope here.

Co-authored-by: Cursor <cursoragent@cursor.com>
@dpeabody-amd
dpeabody-amd merged commit a2cb8ab into develop Aug 6, 2026
163 of 176 checks passed
@dpeabody-amd
dpeabody-amd deleted the users/dpeabody/rocke-attention-d64-lds-kpad branch August 6, 2026 13:49
aosewski added a commit that referenced this pull request Aug 6, 2026
…nse spec

develop's #10102 ("restore LDS K padding at D=64 in gfx950 attention_dense")
added `lds_k_group_pad: int = 8` to AttentionDenseSpec, immediately next to this
branch's `d64_kpad: bool = False`. The two are the same lever -- pad between DMA
row-groups to break the D64 QK bank pattern -- derived independently per arch,
and they landed on the SAME 8-element pad from opposite directions: gfx950 from
a bank/phase model, gfx942 from a measured ~2x ON/OFF ablation.

Kept both fields. They stay separate because the consumers differ (a bool switch
on a fixed gfx942 layout vs a parameterized gfx950 pitch) and collapsing them
would re-bless both arches' goldens. Unifying them once the gfx942 layout is
parameterized too is noted in the field comment as the follow-up.

Two consequences had to be handled, both caught by the gfx942 golden lane:

1. The shared kernel_name() now emits a `kpad{N}` token whenever
   `128 // head_size > 1`, so every gfx942 D64 symbol picked up `kpad8` -- from a
   field this builder reads exactly zero times. It is not removable without
   touching gfx950's name (and its golden), so gfx942's own suffix is renamed
   `_kpad` -> `_d64kpad`. The tags are now spelled differently on purpose: a
   substring test for one can no longer match the other, and a gfx942 symbol
   cannot be misread as describing the gfx950 pitch. p0_kernel_name carries a
   warning saying so.

2. gfx942 golden re-blessed: 7 D64 cases x 2 flavors. Verified name-only, not
   codegen -- all 8 D128 cases are unchanged (matching upstream's
   `128 // head_size > 1` guard), the byte deltas are exactly the symbol-length
   change, and building the same spec with lds_k_group_pad 8 vs 24 gives a
   byte-identical gfx942 body modulo the symbol.

Test assertions moved from `"_kpad" in name` to `name.endswith("_d64kpad")`,
matching the anchoring convention this file already uses for `_wpe`/`_b`.

572 library tests + 138 dispatch tests pass; the 2 remaining failures in
test_attention_builds.py are pre-existing on develop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dpeabody-amd added a commit that referenced this pull request Aug 6, 2026
…dth a spec field (#10183)

**Branch:** `users/dpeabody-amd/aick-1829-k-slice-hd` off `develop` @
`106da66db1`, one commit.

---

## Motivation

The gfx942 sliced-K ring stages K as `[ring_depth, T, K_SLICE_HD]`
slices, so the slice
width sets both `k_groups = head_size // width` and the K_lds row
stride. `ring_depth`
became a spec field earlier; the width was still a builder-local
constant, hardcoded in
two places that had to agree — the emitter and the ahead-of-time
LDS-budget gate. Editing
one and not the other silently desynchronises the admission estimate
from what the builder
emits.

The width is worth expressing because the row stride is what the QK
read's LDS bank spread
follows. The read has 32 lanes take 32 consecutive K rows at a fixed
column, so lane `i`
lands on bank `(stride_dwords * i) % 32`. On gfx942's 32 banks of 4 B
the number of
distinct banks touched is `32 // gcd(stride_dwords, 32)`, and the
conflict degree is 32
lanes over that many banks: width 32 is a 16-dword stride reaching 2
banks, width 16 an
8-dword stride reaching 4. Narrowing trades conflict degree against
`k_groups`, and so
against the per-tile barrier and partial-wait count, which makes it a
tuning axis rather
than a constant.

**This PR only makes the width expressible.** It selects the shipped 32
for every problem,
so no kernel changes. Selecting a narrower width is separate work — see
below for why it
cannot be bundled here.

## Technical Details

- **New `k_slice_hd` spec field**, default 32, documented with the
bank-stride mechanism and
with the slot-reuse arithmetic that makes it safe at any legal width:
with the plain
modulo map, `K_SLICE_SLOTS == ring_depth` and the prefetch distance is
`ring_depth - 1`,
  so the DMA target `(kg + ring_depth - 1) % ring_depth` is identically
`(kg - 1) % ring_depth` — always the slot `slice(kg-1)` was read from,
for every depth and
every `k_groups`. The existing drain-on-reuse fence therefore already
covers any width.
- **`__post_init__` validation**, active only when the ring is on. The
width must divide
`head_size`, or the last slice reads past the K_lds row. It must be a
multiple of the QK
k-step, which is also why 8 is the floor rather than merely the
narrowest useful value —
`k_steps_per_group` floors to zero below it and the slice would emit no
MFMA. And it must
leave at least two slices: a single group skips both the prologue's
second issue and the
in-loop prefetch, yet still allocates `ring_depth` slots and touches
only one, which is
  dead LDS.
- **Two checks on the ring's partial-`vmcnt` fence count.** That count
is not just a loop
bound: it is the operand `s_waitcnt(vmcnt=...)` uses to leave exactly
one slice's VMEM
stream in flight. Partial waits are FIFO-count fences, so it must count
whole slice
prefetches (mirroring the check the full-tile `kv_calls_per_tile`
already has), and it
must fit the 6-bit `vmcnt` field. Neither fires for any configuration
the *selector*
reaches — the three shipped ring geometries all give a count of 4 — but
`__post_init__`
does accept specs that trip them, because the count depends on
`num_warps` through
`KV_HALVES_PER_CALL` and the validator cannot see it. They are therefore
`ValueError`
rather than `assert`: an `assert` is stripped under `python -O`, which
is precisely when
  the mis-fenced kernel would otherwise be emitted.
- **`kernel_name()` tag `ks{width}`**, emitted only when the ring is on
and the width is not
32, so every shipped ring kernel keeps its symbol name and its cached
HSACO.
- **Launcher cache-key entry**, beside the `ring_depth` entry and for
the same reason: two
widths are different schedules with different LDS footprints and must
not share a cached
launcher. This is the failure mode #10102 hit where
`_DENSE_LAUNCHER_CACHE` was keyed on a
  name that omitted `batch`.
- **`_select_gfx942_flash_k_slice_hd`**, returning 32, with a
`HIPDNN_GFX942_K_SLICE_HD`
diagnostic override. Out-of-range or malformed values fall back to 32.
The selector
mirrors the validator's shape rules — the width must divide `head_size`
and must leave at
least two slices — rather than only the first of them. That matters
because the
feasibility gate answers `supported` before the spec is built, so a rule
enforced only in
`__post_init__` surfaces as a late `ValueError` on the dispatch path
instead of a clean
fall back to the default. The third rule, a multiple of the QK k-step,
holds for every
member of `_GFX942_K_SLICE_HD_CHOICES` and cannot fail. The guard is
`str.isdecimal()`
rather than `isdigit()` deliberately: `isdigit()` accepts characters
such as superscripts
that `int()` then rejects, which would turn a typo'd env var into an
exception on the
  dispatch path.
- **The LDS-budget gate now takes `ring_depth` and the width** instead
of pinning three slots
of 32. The `ring_depth` half is a follow-up to the depth work, which
left the gate sizing
depth-2 as three slots. Note the direction, because it is the opposite
of the reassuring
one: replacing the pinned 3 with `ring_depth` *lowers* the depth-2
estimate, so the gate
admits more rather than less. Swept over the gate's legal parameter
space, 640 tuples move
from reject to admit. None of them is reachable — every one is at a tile
size outside
{64, 128}, and `__post_init__` pins ring `tile_size_eff` to exactly
those two, so no ring
spec can exist there. The new estimate was also checked never to
under-count what the
  builder actually allocates.
- **Both parameters are passed on both gfx942 routes into that gate** —
the general path and
the bf16-wide early return — and on neither of the other arches, since
only gfx942's
`supports_tiled_2d` declares them. Passing a kwarg that only one arch
accepts is the
failure mode #10126 fixed for a different flag; this change deliberately
does not add a
  second instance of it.

## Why no width is selected here

The C++ twin of this builder hardcodes `K_SLICE_HD` (and
`K_SLICE_SLOTS`) in
`platform/cpp/instances/gfx942/attention_tiled_2d_ctx_prologue.cpp`. A
selector returning
anything other than 32 would make the two engines emit different `K_lds`
extents for ring
specs, and the byte-identity gate would not catch it, because the gfx942
tiled-2D parity
corpus enumerates no ring configuration. Mirroring the width into the
C++ engine therefore
belongs with the change that selects one, not with the change that makes
it expressible.

Worth flagging while in the area: `K_SLICE_SLOTS` is hardcoded to 3 on
that side while
`ring_depth` is already a spec field, so depth-2 specs diverge between
the engines today for
the same reason and with the same invisibility. That is pre-existing and
not addressed here.

## Risk Assessment

- **No behaviour change, verified rather than argued.** Emitted LLVM IR
was hashed before and
after across a 16-case gfx942 cohort (bf16 and fp16, D128 and D64,
Hq/Hkv 32/8, 16/16 and
128/8, two sequence lengths) at both `llvm20` and `llvm22`. All 32
hashes are unchanged, as
are all LDS footprints and all kernel names. The only difference is the
new field appearing
  on the spec.
- **The byte-identity parity corpus is unchanged**, checked directly
rather than assumed: the
Python emitter output was hashed for all 7 configs of
`gfx942_attention_tiled_2d_emit` and
none moved. These kernels *are* in the corpus; none of its configs is a
ring config.
- **No C++ change rides here, and that is a deliberate exception to
`platform/AGENTS.md`.**
The rule at `AGENTS.md:88` requires a spec/instance change to be made in
both engines in
the same change, and its trigger is the class of change rather than
behavioural
neutrality. Nothing here changes emitted output, so the two engines
cannot disagree today
— but the C++ twin does not carry the new field, and the mirror is the
first commit of the
stacked follow-up (`refactor: carry the sliced-K ring geometry in the
C++ attention spec`),
which lands with the change that selects a non-default width. Flagging
it rather than
  arguing the rule away.
- **Every new operation is gated.** The validator and both fence checks
only run when the ring
is active, and the name tag only appears away from the default.
Validation and checks emit no
  IR.
- **Inert off the ring**, by construction and by test: a non-ring spec
lowers to identical IR
  at width 32 and width 16.
- **Rebased on top of #10126, which has now landed.** That PR closed a
pre-existing gap
where the shared feasibility caller passed a kwarg only one arch
declared, and renamed it
`use_d256_gfx942_fast` → `use_d256_fast` with matching signatures across
the arch gates.
This branch uses the new name. The two new ring parameters are still
gfx942-only, because
only gfx942's `supports_tiled_2d` declares them, so the `if arch ==
"gfx942"` gating at the
call site remains necessary — and a test pins that they never reach a
non-gfx942 gate.
Worth noting for reviewers that this gating is now observable through
the real dispatch
path: before #10126 the tiled-2D gate raised `TypeError` on every arch
except gfx942, so
the test had to assert through kwarg capture rather than by provoking
the failure.
- **The width is not reachable in production.** Only
`HIPDNN_GFX942_K_SLICE_HD` can move it,
and that knob carries the engine-divergence caveat above, which is why
the default path
  does not use it.

## ASIC Coverage

gfx942 only. The sliced-K ring is gated on the gfx942 transposed-x8
cfvst path and the
selector checks `_resolve_attention_arch() == "gfx942"`. gfx950 has its
own `kv_ring_depth`
in a different kernel and is untouched. Everything here is
CPU-verifiable; no device
required.

## Test Plan

- [x] New `library/tests/test_attn_k_slice_hd.py`, CPU-only, 70 tests:
selection returns the
shipped width for D128 and D64, env-override handling including values
that are numeric
but not decimal, validator rejections for all three rules, `kernel_name`
gating,
cache-key separation, inertness off the ring, the exact LDS delta at
both ring depths
and both tile sizes, admission unchanged for every legal ring
configuration, and the
arch-gating of the two new feasibility parameters in both directions.
- [x] IR-hash A/B over the 16-case gfx942 cohort, pristine `develop` vs
this branch, at
      `llvm20` and `llvm22`.
- [x] Parity-corpus hash check over all 7
`gfx942_attention_tiled_2d_emit` configs.
- [x] `library/tests` attention selection, comparing the failure set
against unpatched
      `develop` line by line.
- [x] Relative-path contract guard.
- [x] `black --check` (pinned 25.12.0); `ruff check` without `--fix`
(per
`platform/AGENTS.md`, the IR builder is side-effecting so F841 autofix
silently changes
      kernels).
- [x] `pre-commit run --from-ref origin/develop --to-ref HEAD`.
- [ ] On-GPU numeric lanes — **not applicable**: this change cannot
alter emitted code, and
IR identity is the stronger statement. Numeric gates belong with the
change that
      selects a width.

## Test Result

- **IR identity: PASS.** 16 cases x 2 llvm flavors, all 32 sha256 values
identical to
  pristine `develop`, with LDS footprints and kernel names identical.
- **Parity corpus: unchanged**, all 7 configs.
- **New test: 70/70 pass.** Includes the exact LDS arithmetic — halving
the width frees
precisely `ring_depth * T * 16 * 2` bytes and nothing else — at both
ring depths and both
  tile sizes.
- **No new suite failures**: the attention selection reports the same
pre-existing failing
  set as unpatched `develop` (`diff` of the sorted lists is empty).
- **Relative-path guard: PASS.** **`black --check` clean.**
**`pre-commit` clean** over the
PR range. **`ruff check`: no findings on the changed source files**; the
new test reports
one `I001` matching its sibling attention tests, which is the tree's
import convention —
the repo declares no ruff config, so ruff classes `kernels` as
third-party, and there is no
  ruff hook in `.pre-commit-config.yaml`.

## Follow-up

Selecting a narrower width per head size, with the C++ mirror, is
tracked under the same
ticket.

## 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-1829
aosewski added a commit that referenced this pull request Aug 6, 2026
… on gfx942

develop's #10102 added `lds_k_group_pad: int = 8` to AttentionDenseSpec for the
gfx950 D64 K-LDS pad. That is the same lever this branch added as
`d64_kpad: bool = False`, not merely a similar one:

  gfx950  LDROW           = K_GROUP        * D + spec.lds_k_group_pad
  gfx942  K_GROUP_STRIDE  = ROWS_PER_INSTR * D + _P0_LDS_PAD   (_P0_LDS_PAD == 8)

Same formula, same group definition, same LDS shape, same constant, and
equivalent gates (`128 // head_size > 1` vs `head_size == 64` over the supported
{64,128}). `d64_kpad` was a strictly less expressive duplicate: pad=0 is
d64_kpad=False, pad=8 is True. Keeping both was unjustified, so gfx942 now reads
the shared field and `d64_kpad` is gone.

What this buys beyond deleting a field:

  * The PR no longer touches kernels/gfx950/attention_dense.py AT ALL -- that
    12-line field was the only reason it did. The do-not-touch-gfx950 rule now
    holds by construction rather than by review.
  * The `_kpad` vs `kpad{N}` tag collision disappears with the field. The shared
    kernel_name() already emits kpad{N} on the packed path, so p0_kernel_name
    adds no tag of its own -- one fact, one spelling. (Before this, a gfx942 D64
    symbol said `kpad8` while emitting an UNPADDED layout whenever d64_kpad was
    False: the name was actively wrong.)
  * gfx942 inherits the parameterization for free. The A/B is now
    `lds_k_group_pad=0` rather than a module-level probe constant, so
    `_P0_D64_KPAD` and the `_p0_d64_kpad()` policy function are both deleted.
  * gfx950's `% 8 != 0` validator now covers gfx942 too.

Shipped codegen is UNCHANGED, verified rather than argued: dispatch used to set
d64_kpad=True (stride 2*64+8=136) and now inherits pad=8 (stride 136). Lowering
the four dispatch specs before and after and hashing the IR with the symbol name
normalised out gives identical bodies on all four --
fp16/bf16 x D64/D128: b7fa1ed9 / 7258cee1 / 1f37baee / 0992a24d. Only the symbol
changed, losing the now-redundant suffix.

Golden re-blessed. The `default_d64_*` cases now pass `lds_k_group_pad=0`
explicitly: they were the unpadded-layout coverage, and with the shared field
defaulting the pad ON nothing else would pin it. Their bodies are unchanged --
only the name moves kpad8 -> kpad0, which also makes it honest.

573 library + 138 dispatch tests pass; the 2 test_attention_builds.py failures
are pre-existing on develop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dlangbe added a commit that referenced this pull request Aug 17, 2026
… launcher cache key (#10754)

JIRA ID : AICK-1894

## Motivation

`waves_per_eu` is baked into the emitted kernel binary via the
`amdgpu-waves-per-eu` LLVM function attribute, which drives AMDGPU
register-file sizing. However, it was absent from the
`_DENSE_LAUNCHER_CACHE` key in `attention_dense.py`, which was keyed
only on `(kernel_name(), batch)`. Two specs differing only in
`waves_per_eu` would collide, causing the second to silently reuse the
first binary. Any in-process sweep over `waves_per_eu` would re-measure
the wrong kernel. Additionally, `__post_init__` validated every other
performance knob but allowed any integer `waves_per_eu` to flow straight
to codegen.

Follow-up to #10102.

## Technical Details

**`rocke/library/kernels/gfx950/attention_dense.py`**

- Widened the launcher cache key from `(kernel_name(), batch)` to
`(kernel_name(), batch, waves_per_eu)`. This is host-side only: emitted
IR, symbol names, and all golden IR hashes in
`attention_dense_ir_sha256.json` are untouched.
- Added a `__post_init__` bounds check: `waves_per_eu` must be in `[1,
8]` (hardware maximum waves/SIMD on CDNA). Values ≥ 3 are accepted but
flagged in the error message as a known performance trap (caps VGPRs at
≤170, forces spills, ~−20% throughput on this kernel).

**`rocke/library/tests/test_attention_builds.py`**

Added `TestAttentionDenseWavesPerEu` with three tests:
- `test_waves_per_eu_validation_rejects_out_of_range` — asserts
`__post_init__` rejects `{0, -1, 9, 100}` and accepts `{1, 2, 8}`.
- `test_waves_per_eu_ir_attribute` — lowers two specs (`wpe=1`, `wpe=2`)
and asserts `"amdgpu-waves-per-eu"="N,N"` appears correctly in each. No
comgr needed.
- `test_waves_per_eu_cache_key_isolation` — asserts the cache keys for
`wpe=1` and `wpe=2` are distinct and that `waves_per_eu` is the
distinguishing third element. No comgr needed.
- `test_waves_per_eu_cache_isolation_binaries` — compiles both specs
through comgr and asserts the resulting HSACOs have distinct SHA-256
hashes. Skips when comgr is unavailable.

## Test Plan

Run the new test class (no GPU required):

```bash
cd rocke/platform
python -m pytest ../library/tests/test_attention_builds.py::TestAttentionDenseWavesPerEu -v

## Test Result

- `test_waves_per_eu_validation_rejects_out_of_range` — PASSED (11 subtests)
- `test_waves_per_eu_ir_attribute` — PASSED
- `test_waves_per_eu_cache_key_isolation` — PASSED
- `test_waves_per_eu_cache_isolation_binaries` — PASSED (comgr available on test machine; `wpe=1` and `wpe=2` produced distinct HSACOs)

## Submission Checklist

- [ ] Look over the contributing guidelines at https://github.com/ROCm/ROCm/blob/develop/CONTRIBUTING.md#pull-requests.
dlangbe added a commit that referenced this pull request Aug 26, 2026
JIRA ID : AICK-1895

## Motivation

`lds_k_group_pad` was added to `AttentionDenseSpec` in PR #10102 with a
fixed
default of 8. That PR committed to a follow-up sweep over the full pad
space to
confirm the default and record whether any shape benefits from a
different value.
This PR closes that follow-up by documenting the sweep methodology,
results, and
decision in-repo, and making the knob visible in the runbook's knob
catalog.

## Technical Details

Three documentation edits:

**`library/builders/gfx950/attention/prefill/README.md`**
- Fixed stale line: "block_n and waves_per_eu are the only performance
knobs"
  — there are now three (`block_n`, `waves_per_eu`, `lds_k_group_pad`).
- Added `## Tuning — lds_k_group_pad` section documenting:
- Sweep axes: pad ∈ {0, 8, 16, 24, 32} × bn ∈ {64, 128} × 3 GQA ratios ×
    causal/swa/varlen/persistent × S ∈ {2048, 4096, 8192} (840 configs,
    all correctness-passed).
- The whole-wave bank-conflict model explaining why pad=8, 16, and 24
tie at
    the 4-lanes-per-bank floor, and what it would take to separate them.
- Decision: default stays at 8. pad=24's plurality is sub-1% and
noise-grade
without an ABBA repeat; pad=16 has an unacceptable regression on a
bn=128
sliding-window shape that makes it unsafe as a global or
shape-conditioned
    default.
- The pad=16 regression recorded as a named constraint for future work.
  - The multiple-of-8 invariant (`smem_load_vN` stamps align 16
    unconditionally; `__post_init__` enforces it).

**`platform/dsl_docs/optimization/optimization_runbook.md` §12.1.F**
- Added `lds_k_group_pad` knob-catalog row, sibling to the existing
`lds_k_pad`
and `lds_layout` rows. Points to the gfx950 README for the full decision
  record. Makes the knob visible to the Step 0 exhaustive lever sweep.

**`library/builders/gfx942/attention/prefill/README.md`**
- Added one row to the "Lever record → Adopted" table noting that gfx942
reuses
the shared `AttentionDenseSpec.lds_k_group_pad` field and inherits
default 8,
  with a cross-reference to the gfx950 sweep.

Per `platform/AGENTS.md` §Compliance, absolute TFLOPS figures and
per-lever
deltas are kept on the protected results page only; the in-repo docs
contain
methodology, axes, decision, and qualitative direction.

## Test Plan

- Documentation-only change; no kernel IR or dispatch logic modified.
- Verified the stale knob-count sentence (L26-28 of the gfx950 README)
is
  corrected to reflect three knobs.
- Confirmed the new runbook row is correctly placed in §12.1.F alongside
sibling
  LDS-layout knobs.
- Confirmed the gfx942 lever table row accurately describes the
inheritance
  relationship and points to the canonical decision record.

## Test Result

No functional changes. All existing parity and byte-identity gates are
unaffected.

## Submission Checklist

- [ ] Look over the contributing guidelines at
https://github.com/ROCm/ROCm/blob/develop/CONTRIBUTING.md#pull-requests.
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