Skip to content

[ASM] [MLA v4 nm] Swap gfx950 asm decode kernel - #5195

Merged
amd-ruitang3 merged 6 commits into
mainfrom
liyjiang/mla_v4_nm_perf27
Sep 24, 2026
Merged

amd-ruitang3 merged 6 commits into
mainfrom
liyjiang/mla_v4_nm_perf27

Conversation

@liyjiang

@liyjiang liyjiang commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Technical Details

New pipeline for Dsv4 mla decode FP8+BF16

@liyjiang
liyjiang requested review from a team and amd-ruitang3 September 2, 2026 01:28
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

🏷️ CI Guide

Runs automatically on every PR:

  • ✅ Pre-checks (submodule verification, code formatting)
  • ✅ Aiter op tests (gfx942 + gfx950)
  • ✅ Triton tests on MI35X (only when aiter/ops/triton/** or related paths are changed)

Extended tests (opt-in via labels):

Label Tests
ci:gfx1250-ffm-triton Run the five-shard gfx1250 FFM Triton test suite
ci:triton-300x Run an additional Triton test job on MI300X in PRs; main branch always runs both MI35X and MI300X
multigpu Aiter multi-GPU tests on the 8-GPU runner
ci:sglang SGLang integration tests: DeepSeek-R1-MXFP4 accuracy, Qwen 3.5 accuracy
ci:atom ATOM benchmark: DeepSeek-R1-0528, GPT-OSS-120B
ci:atom_full ATOM accuracy suite for PR and main models from ATOM models_accuracy.json
ci:vllm vLLM benchmark: GPT-OSS-120B, DeepSeek-R1-0528, Kimi-K2.5
ci:all All standard extended tests (excludes ci:atom_full)

Only add ci:atom_full for FlyDSL or Triton upgrades.
Add labels via the sidebar or gh pr edit 5195 --add-label <label>

PR title tags & labels:
Component tags ([Triton/Gluon], [HIP], [CK], [ASM], ...) are added to the PR title and as PR labels automatically from the changed files and re-synced on every push — change-type tags like [fix]/[Perf], op tags like [MLA], and human labels (ci:*) are left untouched. Add the no-auto-title label to opt this PR out.

@github-actions github-actions Bot changed the title [MLA v4 nm] Swap gfx950 asm decode kernel [ASM] [MLA v4 nm] Swap gfx950 asm decode kernel Sep 2, 2026
@github-actions github-actions Bot added the ASM label Sep 2, 2026
liyjiang and others added 6 commits September 22, 2026 11:40
Co-authored-by: Cursor <cursoragent@cursor.com>
The kernel read 16 e8m0 scale bytes out of the packed Q row, but only 14 are
valid: the row is 448B fp8 NoPE, 14B scale, then padding from byte 462. Byte
462/463 is the e8m0 NaN encoding when it happens to be 0xFF, so a served model
-- which reuses the Q buffer and leaves junk in the padding -- produced NaN
output. gsm8k on DeepSeek-V4-Pro dropped from 0.9522 to 0.0265.

Synthetic tests could not see it: the quantizer builds the packed row from
zeroed storage, so the padding is always 0x00, and `abs(a-b) > tol` is False
for NaN, so an all-NaN output still scored zero bad rows.

The KV pool shares the same row layout but its scale read was already bounded
correctly; only Q was affected.

Verified: op_tests/test_mla_v4_nm.py passes; the padding-poisoned sweep
(Q/K/both x 0x00/0xFF/per-byte/random, 18 cases) is clean, as is an 80-config
accuracy sweep repeated 4x with the padding filled with 0xFF. Kernel timing is
unchanged (24 configs x 5 interleaved rounds, median -0.15%, Wilcoxon p=0.32).

Co-authored-by: Cursor <cursoragent@cursor.com>
The packed Q/KV row is 448B fp8 NoPE, 14B e8m0 scale, then 50B of padding, and
`_native_to_2buff_for_asm` builds it with `torch.zeros` -- so every existing
test hands the kernel padding that is always 0x00. A served model reuses these
buffers and the padding carries junk, which is how a kernel that read 16 scale
bytes instead of 14 shipped: byte 462/463 at 0xFF is the e8m0 NaN encoding and
the row came out NaN.

Poisons the padding of Q, KV and both, with 0xFF and with an arbitrary fill,
across the three shipped (gqa, q_seq_logical) entry points. The reference reads
only the defined bytes, so its result must not move either.

Checks isfinite before the tolerance compare: `nan > tol` is False, so an
all-NaN result otherwise reports zero mismatching rows and passes.

Co-authored-by: Cursor <cursoragent@cursor.com>
…nge and kv-tail regression tests

Co-authored-by: Cursor <cursoragent@cursor.com>
The closure read gqa and the per-gqa tensors from the enclosing loop, so
it would have used the last iteration's values had it outlived one. Taking
them as an explicit argument removes the late binding.

Co-authored-by: Cursor <cursoragent@cursor.com>
Same results as the kernel it replaces — identical cos_diff across an 80-config
gqa x batch x kv sweep, and gsm8k unchanged end to end. Decode at gqa=16 and 32
gains 10-17%; at 64 and 128 no config is measurably slower.

Co-authored-by: Cursor <cursoragent@cursor.com>
@liyjiang
liyjiang force-pushed the liyjiang/mla_v4_nm_perf27 branch from 55eda3f to 0a3ed7f Compare September 22, 2026 16:49
@amd-ruitang3
amd-ruitang3 merged commit 874d253 into main Sep 24, 2026
89 of 101 checks passed
@amd-ruitang3
amd-ruitang3 deleted the liyjiang/mla_v4_nm_perf27 branch September 24, 2026 04:17
yhl-amd added a commit that referenced this pull request Oct 4, 2026
…l with in-kernel split and merge (#6126)

* [MLA v4 nm] Cost-model num_kv_splits auto pick and kv_len_hint

The gfx950 v4 nm auto split pick (ignore_total_kv=1) maximizes CU
occupancy only, so it prefers whole multiples of cu_num: for gqa=128
single-token decode it picks 13-16 splits (several workgroup waves) for
batches whose KV already fits one wave, paying extra partial writes and a
wider stage-2 merge for no added parallelism.

- On gfx950, pick num_kv_splits by minimizing a fitted latency model:
  workgroup waves x per-wave KV time, plus the partial-write/stage-2 cost
  of splitting. Other archs keep the existing pick.
- Add kv_len_hint: callers that pass capacity-sized kv_page_indices (e.g.
  CUDA-graph decode) cannot be planned from total_kv / num_seqs, so they
  pass the per-seq KV length to plan for. The pick depends only on shapes
  and this int, so it stays fixed across graph capture and replay.
- Fix the stage-2 merge for a caller-supplied split_indptr with one split
  per seq on a grid > 1: it took the in-place BF16 copy (keyed on
  split_indptr[-1] == num_seqs) although the kernel wrote FP32 partials,
  returning NaN.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: yihonglie <hyi@amd.com>

* [MLA v4 nm] Expose the split plan to callers instead of kv_len_hint

Planning inside mla_decode_fwd_v4_nm needs a per-seq KV length, and the
only one the wrapper can see, kv_page_indices.shape[0] / num_seqs, is
meaningless for CUDA-graph callers whose index buffers are capacity-sized.

- Add get_mla_v4_nm_split_plan(num_seqs, num_heads, kv_len, split_indptr=):
  returns the split count (cost model on gfx950, occupancy pick elsewhere)
  and fills a caller-owned persistent split_indptr in place. Callers build
  it in their metadata step and pass both to the wrapper.
- Drop kv_len_hint; the wrapper's own auto pick is unchanged from main.
- Tests: plan fill, accuracy through the wrapper, and CUDA-graph replay with
  different KV lengths under a captured plan. _run_varlen_point now returns
  its checkAllclose error so the new tests gate on it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: yihonglie <hyi@amd.com>

* [MLA v4 nm] Return the split plan as a NamedTuple, per-arch cost table

- get_mla_v4_nm_split_plan returns MlaV4NmSplitPlan(num_kv_splits,
  split_indptr). The field names are the wrapper's kwargs, so callers pass
  a plan on as mla_decode_fwd_v4_nm(..., **plan._asdict()).
- Latency-model coefficients live in a per-arch table; archs without an
  entry keep the occupancy-only pick. Only gfx950 is fitted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: yihonglie <hyi@amd.com>

* [MLA v4 nm] Add a persistent decode kernel with in-kernel split and merge

A persistent variant of the v4 nm fp8 decode kernel (gqa=128, one query
token per row). Each call is one launch of grid (2P, 1, 1): the kernel
plans the KV split over P partitions from kv_indptr, runs the attention and
merges the split partials itself, so callers need no host split plan and
no stage-2 merge, and the launch is CUDA-graph safe with varying KV lengths.

- hsa/gfx950/mla_v4: mla_a8w8_qh64_qseqlen1_gqaratio64_nm_ps.co, symbol
  _ZN5aiter39mla_a8w8_qh64_qseqlen1_gqaratio64_nm_psE, registered as the
  ps=1 row of mla_v4_asm.csv.
- csrc: mla_decode_v4_ps_asm in module_mla_v4_asm, with its own documented
  kernarg struct (same 21 x 16 B layout, repurposed slots marked), shape /
  dtype / contiguity / workspace checks and its own kernel cache.
- aiter.mla: MlaV4NmPsWorkspace, get_mla_v4_nm_ps_workspace(device,
  num_partitions=128) and mla_decode_fwd_v4_nm_ps(...). One workspace
  serves calls that are ordered on the GPU; allocate it outside capture.
- Tests: accuracy and LSE against the fp8-dequant reference (uniform,
  ragged, K=0 rows, N up to 3000, K up to 8320), kv_indptr with offset and
  extra entries, workspace reuse across batch sizes, partition counts,
  CUDA-graph replay with changing KV lengths, agreement with
  mla_decode_fwd_v4_nm, argument checks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: yihonglie <hyi@amd.com>

* [MLA v4 nm] Add the generator for the persistent decode code object

mla_a8w8_qh64_qseqlen1_gqaratio64_nm_ps.co is now reproducible from sources
in the repo: nm_ps_src/gen.py patches the disassembly of the #5195 decode
kernel (asm/rt.s) into the persistent kernel, splices in the in-kernel split
planner and last-arriver merge (splice/*.hip, compiled with hipcc -S),
renames the kernel symbol, assembles for gfx950 and can check the sha256
against the shipped .co. README.md documents provenance, rebuild steps and
the kernarg layout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: yihonglie <hyi@amd.com>

* [MLA v4 nm] Drop the unused span buffer from the persistent decode API

The persistent code object never reads kernarg slot 0xb8, yet the span
workspace [P, 2] was a field of MlaV4NmPsWorkspace, a required argument of
mla_decode_v4_ps_asm and size-checked in C++. Remove it from the workspace,
the ctypes signature and the C++ entry point, and pass nullptr at 0xb8.
Document the three regions of the cnt counter buffer next to its size.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: yihonglie <hyi@amd.com>

* [MLA v4 nm] Add a guard-page out-of-bounds test for the persistent kernel

Run mla_decode_fwd_v4_nm_ps in a subprocess at large N (up to the 32768 row
limit) with every input and output placed at the very end of its own
2 MiB-rounded allocation under the non-caching allocator, so an access one
element past q, qrope, the KV pools, kv_indptr (exactly N+1 entries),
kv_page_indices, sink, out or lse faults the GPU. Covers the lse and no-lse
paths and checks the merge counters return to zero.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: yihonglie <hyi@amd.com>

* [MLA v4 nm] Rename research identifiers in the persistent kernel generator

Rename the spliced planner / combine kernels and their labels
(t8pre -> ps_plan, t8fin -> ps_merge, T8_* -> PS_*) and regenerate the code
object. .text, .rodata (kernel descriptor) and .note (metadata) are
byte-identical to the previous build; only local symbol names in .symtab
change. Update the expected sha256.

README fixes: the row limit is N <= 32768 (out / q byte offsets wrap above
it; the cnt / arange sizes come from a 65536-row layout), cnt is int32 with
its three regions spelled out, and slot 0xb8 is passed as 0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: yihonglie <hyi@amd.com>

* [MLA v4 nm] Mark kernarg slot 0x68 of the persistent kernel as unused

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: yihonglie <hyi@amd.com>

* [MLA v4 nm] Ship the persistent decode kernel as a code object only

Drop hsa/gfx950/mla_v4/nm_ps_src (assembly, patch scripts and HIP splice
sources). Nothing at build or run time reads it; the kernel ships as
mla_a8w8_qh64_qseqlen1_gqaratio64_nm_ps.co, unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: yihonglie <hyi@amd.com>

* [MLA v4 nm] Satisfy ruff in the persistent decode changes

dict literals for the workspace kwargs and the test inputs; one with-statement
for the capture-time rejection check. No behaviour change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: yihonglie <hyi@amd.com>

* [MLA v4 nm] Expose the split count as a host-only helper

get_mla_v4_nm_num_kv_splits(num_seqs, num_heads, kv_len) returns the split
count the plan helper picks, with no device work. The uniform split_indptr
for it is [0, s, 2s, ...], and any prefix of a longer one is valid, so a
caller can keep one constant buffer per split count and slice it instead of
writing a split_indptr before every call. get_mla_v4_nm_split_plan now builds
on it; its behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: yihonglie <hyi@amd.com>

* [MLA v4 nm] Drop get_mla_v4_nm_split_plan; the split count is the API

Nothing calls the plan helper or MlaV4NmSplitPlan any more: callers take the
count from get_mla_v4_nm_num_kv_splits and pair it with the uniform
split_indptr [0, s, 2s, ...]. The tests build that pair with a local helper,
and the mla_decode_fwd_v4_nm docstring points at the count helper.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: yihonglie <hyi@amd.com>

---------

Signed-off-by: yihonglie <hyi@amd.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants