Skip to content

[Qwen3.5] gfx1250 GDN prefill kernels (conv + norm, WY, recurrence, output + norm), no runtime autotune; DP points on unified attention - #2830

Merged
azaidy merged 10 commits into
ROCm:alizaidy/gfx1250-qwen-supportfrom
vgokhale:vgokhale/qwen35-gdn-gfx1250-kernels
Oct 29, 2026
Merged

azaidy merged 10 commits into
ROCm:alizaidy/gfx1250-qwen-supportfrom
vgokhale:vgokhale/qwen35-gdn-gfx1250-kernels

Conversation

@vgokhale

Copy link
Copy Markdown

Qwen3.5 GDN layers on gfx1250: new prefill kernels for the conv, the chunk-local WY step, the recurrence and the output (no runtime autotune left in the GDN prefill path), and the recipe's DP points on unified attention with 64-token KV blocks.

Needs aiter alizaidy/gfx1250-qwen-support from 3d3228c1 (pushed): the chunk-form fused_gdn_verify decode kernel, ROCm/aiter#7100's current configs and routing, and ROCm/aiter#6999 (the 2D unified-attention kernel takes the block-table stride at runtime; before it, unified-attention prefill recompiled for every prefill batch).

Commits

  1. conv1d rows kernel with the q / k L2 norm in its epilogue (causal_conv1d.py). The GDN prefill conv used 8-token x 256-channel tiles; the new kernel unrolls the chunk's tokens so each row is one independent load. qk_l2norm_head_dim normalizes each head after the bf16 rounding of the conv output, so chunk_gated_delta_rule skips its two l2norm_fwd launches (use_qk_l2norm_in_kernel=not qk_normed_by_conv).
  2. chunk_h: a Gluon gfx1250 recurrence kernel, no autotuning (chunk_delta_h_gfx1250.py). The state stays fp32 in registers for a sequence's whole chunk walk, and the next chunks' w / k / u / gate tiles are prefetched into LDS. The launch config is a fixed rule on sequences x heads. The @triton.autotune (12 configs, timed on the first launch in the serving loop, and not even the same pick across processes) and the scratch-copy pre-tune are gone; the Triton kernel stays for other archs with one fixed config.
  3. chunk_o: a Gluon gfx1250 output kernel with the gated RMSNorm in its epilogue (chunk_o_gfx1250.py). chunk_gated_delta_rule(..., output_gate=(z, weight, eps)) writes norm(o) * weight * silu(z) straight into the out_proj input; the pure-prefill call sites in attention_gdn.py pass it and skip the separate norm.
  4. Chunk-local prefill kernels: kkt + inverse / WY in two kernels (fused_cumsum_kkt.py, new fused_inverse_recompute.py). k kᵀ once a key head on bf16 WMMA, coalesced gate / beta loads, and the 16x16 inverses, their merge and w / u in one kernel. This replaces solve_tril_16x16 + fused_merge_recompute, whose single configs were tuned on MI308. The short-prefill (< 64 tokens) kernels (cumsum, chunk_scaled_dot_kkt, solve_tril, wy_fast) take fixed configs instead of runtime autotune.
  5. conv1d: fp32 FMA taps, hardware exp / reciprocal SiLU. The reference kernel rounds each tap product to bf16. Against an fp64 conv of the same bf16 inputs (the checkpoint's conv weights, layers 0 / 1 / 12 / 28), that puts its outputs at 1.43-1.48x the bf16 rounding error, with 66-70% correctly rounded. fp32 taps give the correctly rounded value for every output, as the decode verify kernel's conv already does. The fast SiLU moves about 1 output in 10⁵ by one bf16 step.
  6. Recipe: DP points set ATOM_USE_UNIFIED_ATTN=1 and --block-size 64; the recipe doc gains the c2048 results and the exact server command.
  7. Lint.

Results

c2048 DP4 + EP4, MTP3, 512 sequences an engine. Generation tok/s is the mean over the 2 minutes before a 15 s profiler window at minute 3; step times are DP rank 0's:

branch tip 6cdc02f + aiter a0df93d2 + GDN kernels (1-4) + 5, unified attention, 64-token blocks (aiter 3d3228c1)
generation tok/s 27.4k 28.9k 33.1k
prefill step 169 ms 159 ms 147 ms
decode step (median) 18.5 ms 17.5 ms 15.5 ms
MTP tokens a forward 2.73 2.72 2.72

Per GDN layer at 16k prefill tokens, 32 value heads (cold-cache microbenchmarks; the trace agrees):

Stage Before After
conv1d + q / k L2 norm 134 us 62-66 us
cumsum + k kᵀ, solve_tril, merge / recompute 241-246 us 147-154 us
chunk_h 208-396 us (by sequence mix) 98-202 us
chunk_o + gated RMSNorm 135-141 us 99-106 us

TP4 heads (8 value heads) gain about the same factors.

GSM8K, DEP4, 3-shot, 256 concurrent, on the last column's stack: 0.8635 / 0.8499 (0.861 / 0.851 before).

Testing

  • New: tests/test_causal_conv1d_rows_gpu.py and tests/test_gdn_chunk_gfx1250.py.
    • The conv test compares q / k / v with an fp64 conv: at most one bf16 step, on 4e-6 to 2e-5 of values. The conv-state pool matches the reference kernel bit for bit.
    • The chunk test runs the eager slot path, the token-graph plan path and fixed-length batches against the Triton kernels.
  • With them, the GDN tests (test_gdn_midstep_state_gpu, test_gdn_midstep_checkpoints, test_gdn_page_checkpoint, test_state_checkpoint, test_gdn_fused_decode, test_gdn_fused_verify, test_fused_sigmoid_gate) give 401 passed on gfx1250.
  • Bitwise status:
    • chunk_h and chunk_o + norm are bitwise identical to the base tree on every path.
    • The w / u stage is at the fp64 floor (relative L2 1.66e-3, as before), with 99.8-100% of values bit-identical.
    • The end-to-end chunk output differs from the base tree by about 1e-3 relative.
  • test_aiter_flydsl_gdn.py is MI308-only and fails identically on the base tree.
  • ruff / black are clean for new code; existing files have no more findings than before.
  • The tables above ran on 6cdc02f (a DSv4.1-flow harness, server command in the recipe doc). This branch is rebased on 9c6a7e7 ([Recipe] Qwen3.5 MLPerf Endpoints: DP sampling defaults (c128 +11%) #2824, [Qwen3.5] Shared expert side stream off by default (ATOM_QWEN_SHARED_EXPERT_STREAM) #2825, [Qwen3.5] TP1 mono decode: fused MTP verify layers on ReplaySSM (ATOM_QWEN35_MONO=1, -tp 1) #2827, Alizaidy/qwen35 dp prefill graph #2828, no file overlap). A c2048 point on this branch with the recipe's current DP defaults (which add the prefill token graphs and the fast launch paths) is running next; I'll add it here.

…m in its epilogue

`causal_conv1d_fn` runs `_causal_conv1d_fwd_rows_kernel` for a width-4 conv
on channel-last input without prefix-cache block states (the GDN prefill), on
the same (sequence, 8-token chunk) launch tables as before. The chunk's tokens
are an unrolled loop, so every row is one independent load and the window
shifts in registers, and one wave covers 256 channels (16 B a lane) where the
reference kernel spread them over four waves with a dynamic token loop.

`qk_l2norm_head_dim` L2-normalizes each head of q and k after the bf16
rounding of the conv output, which is what `l2norm_fwd` computes on the stored
q and k; the reference path applies `l2norm_fwd` after its kernel. The GDN
eager and planned prefill pass it and call the chunk kernels with
use_qk_l2norm_in_kernel=False, unless the FlyDSL prefill may take q and k.

The arithmetic per element is the reference kernel's, so q, k, v and the conv
states are bit-identical to conv -> l2norm_fwd (checked at Hk/Hv 16/32 and
4/8, T up to 16384, forked and in-place slots, cold and warm states, sequences
shorter than the state). Cold-cache CUDA-graph timing, T=16384:
conv + both l2norms 140-143 us -> 87-90 us (DEP4 heads), 44 us -> 27 us (TP4).
The chunked Gated DeltaNet state recurrence walks one (sequence, value head,
V block) over its chunks: two dependent GEMMs a chunk. On gfx1250 it now runs
as a Gluon kernel: the state stays fp32 in WMMA accumulator registers, the
tensor data mover loads the next chunks' w, k, u and gate tiles into LDS while
the current one computes, and stores h and v_new from LDS. The launch
configuration (V block, operand buffers) is a function of the host-known
sequence and head counts.

The Triton kernel keeps one fixed configuration for the other architectures.
Its autotuner timed 12 configurations on the first launch of each process, in
the serving loop and on the launch's own in-place arguments, which is why the
slot-indexed launch needed a scratch-copy pre-tune; both are gone.

v_new is bitwise the same; h and the final state differ by fp32 summation
order.
As Triton folds `h += dot(k, v)` into the WMMA accumulator. h, v_new and the
final state are now bitwise those of the Triton kernel.
…n its epilogue, no autotuning

The chunked GDN prefill wrote its output, and a second kernel read it back for
the layer's gated RMSNorm norm(o) * weight * silu(z). On gfx1250 the output
kernel is now a Gluon kernel: the tensor data mover brings q, k, h, v_new, the
gates and z into LDS, three WMMA GEMMs form one chunk of one head, and with
`output_gate` the norm runs in the epilogue, its sum of squares in
layer_norm_fwd's register layout. The eager and planned prefill paths of the
GDN layer pass it whenever the kernel writes the layer's output in place, and
skip the separate norm.

The Triton kernel keeps one fixed configuration for the other architectures,
where `output_gate` runs layer_norm_fwd after it; its autotuner timed up to
36 configurations on the first launch of each process.

Output and final states are bitwise those of chunk_o + layer_norm_fwd.
… a test against the Triton kernels

A fixed-length batch (no cu_seqlens) has B * NT chunks of h; the store
descriptor was bounded at NT, so the stores of rows past the first were
dropped. tests/test_gdn_chunk_gfx1250.py compares the gfx1250 kernels with
the Triton ones they replace: varlen by slot and by plan, a fixed-length
batch, the output with and without the gate, and the gate against
chunk_o + layer_norm_fwd bit for bit.
…o kernels, no runtime autotune

The chunked Gated DeltaNet prefill (AMD T >= 64 path) ran fused_cumsum_kkt ->
solve_tril_16x16 -> fused_merge_recompute, the configs of the first and last
"swept-best on MI308" and solve_tril_16x16 autotuned at run time over 16
configs.

fused_cumsum_kkt: one program per (chunk, key head), so k and k.k^T once for
the key head's value heads; k.k^T on bf16 WMMA (k is bf16: its products are
exact in fp32) where k widened to fp32 ran fp32 WMMA at 8x the instructions;
the gates load as [64, H] tiles instead of one word per token and head, and key
head 0 stores g_cumsum the same way; A stores from the transpose of the
(symmetric) k.k^T, whose WMMA result holds each lane's values along a row, so
the stores are contiguous instead of one word per 8 KB row. It also stores beta
and beta * exp(g_cumsum) head-major for the next kernel.

fused_inverse_recompute (new, replaces solve_tril_16x16 + fused_merge_recompute):
the 16 x 16 diagonal inverses as (I + N)(I + N^2)(I + N^4)(I + N^8) on fp32
WMMA in-kernel instead of a second kernel through HBM, the block merge as
before, and w / u with the per-token scale folded into Ai's columns so k and v
enter bf16 WMMA exactly, the scaled Ai as a bf16 high + low pair (~16 bits).
w and u stay at the bf16 rounding of the fp64 result; 99.8-99.9% of w and
100% of u are bitwise equal to the old kernels.

Short-prefill (T < 64) path: chunk_local_cumsum, chunk_scaled_dot_kkt,
solve_tril and recompute_w_u launch at fixed configs (the autotuner's gfx1250
picks) instead of autotuning on first use.

gfx1250, cold caches, CUDA-graph replay, w/u stage (kkt + inverse + w/u):
  T=16384, 32 value heads (DEP4): 241-246 -> 147-154 us
  T=16384, 8 value heads (TP4):    71-79 -> 57-59 us
  T=4096: 74-78 -> 55 us (DEP4), 27 -> 23 us (TP4)
chunk_gated_delta_rule output / final state against an fp64 token-by-token
recurrence: 3.47e-3 / 2.52e-3 relative, as before (3.46e-3 / 2.52e-3).
… / reciprocal SiLU

The reference kernel rounds each tap product to bf16 before the fp32 sum. Against an
fp64 conv of the same bf16 inputs (Qwen3.5-35B-A3B conv weights, layers 0/1/12/28)
that puts its outputs at 1.43-1.48x the bf16 rounding error, with 66-70% of them
correctly rounded and a p99 of 4-8 bf16 steps. fp32 FMA taps give the correctly
rounded value for every output, as the fused GDN verify kernel's conv already does in
decode. The IEEE divide and the range-reduced exp in SiLU were about half of the
kernel's 27 VALU instructions per element; the hardware reciprocal and exp move about
1 output in 10^5 by one bf16 step.

conv + q/k L2 norm, 16384 tokens, cold caches: 16/32 heads 87-90 -> 62-66 us (base
tree conv + 2x l2norm_fwd: 140-143 us); 4/8 heads 27-28 -> 20-22 us.

The test compares q, k, v with an fp64 conv instead of the reference kernel bit for
bit: at most one bf16 step (two for a normalized q / k head), on fewer than 1e-3 of
the values (measured 4e-6 to 2e-5); the conv-state pool still matches the reference
kernel bit for bit.
…h 64-token KV blocks; the c2048 GDN results

serve_ep.sh sets ATOM_USE_UNIFIED_ATTN=1 and --block-size 64 (BLOCK_SIZE overrides) on DP points: no KV
gather before the prefill FMHA and the 64-token decode / verify attention configs. It needs aiter
alizaidy/gfx1250-qwen-support from 3d3228c1, whose 2D kernel takes the block-table stride at runtime.

The recipe gains the c2048 DP4 + EP4 points of this branch's GDN kernels and the unified attention
(27.4k -> 33.1k generation tok/s, GSM8K 0.8635 / 0.8499) with the exact server command they ran.
@azaidy
azaidy merged commit bb47e39 into ROCm:alizaidy/gfx1250-qwen-support Oct 29, 2026
1 check passed
zufayu added a commit that referenced this pull request Oct 30, 2026
near_duplicates() only catches a duplicate that arrives as a whole new file. Most
of "we already have it" / "duplicated with the one in aiter" / "use new util func"
in the lead reviewer's comments is a function or class added into a file that
already exists -- a shape the file-level check cannot see, since it never looks
inside a diff hunk for a modified file.

Adds symbol_duplicates(): for each newly added top-level def/class in a modified
file's diff (whole new files stay out of scope -- near_duplicates already covers
them, and double-reporting the same evidence under two headings was not the goal),
compare its body (>=15 substantial lines) against every top-level def/class already
in the tree (same kind, whole tree -- measured at 0.16s / ~14k symbols over ATOM's
1504 .py files, cheap enough that a name-similarity prefilter would suppress exactly
the "unrelated names, near-identical bodies" case this exists to find). Same
threshold as the file-level check, 60%, and same posture: a pair and a ratio, not a
verdict.

Measured over 197 recently merged ATOM PRs (#2388-#2830) at threshold 0.60: 2 fired
(1.0%). Both spot-checked as real hits, not coincidental shape: PR#2560 modifies
atom/plugin/vllm/paged_state_image.py and its tests in place, 10 pairs against the
pre-existing paged_state_checkpoint.py sibling it mirrors (the "image vs checkpoint"
variant pair near_duplicates' own docstring describes, but file-level could not see
here because neither file is new). PR#2553 adds a test 95% identical to an existing
one, down to the mocked runner setup, with one of three asserted values changed.
No threshold raise needed -- 1.0% sits well inside the file-level check's own ~3%
territory, with no false alarms in the spot check.

Wired into the same `twins` CLI mode as a second section, after the file-level
pairs; fetch.sh's existing `triage.py twins ... | tee twins.txt` call is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants