Skip to content

[Triton/Gluon] Fused Qwen3-Next GDN decode op for gfx950 - #5520

Merged
azaidy merged 15 commits into
ROCm:mainfrom
rbrugaro-amd:dev/gdn-fused-decode-fp8
Oct 6, 2026
Merged

azaidy merged 15 commits into
ROCm:mainfrom
rbrugaro-amd:dev/gdn-fused-decode-fp8

Conversation

@rbrugaro-amd

@rbrugaro-amd rbrugaro-amd commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

[Triton/Gluon] Fused Qwen3-Next GDN decode op for gfx950

Motivation

Qwen3-Next's Gated DeltaNet decode step runs as four separate kernels today:

fused_qkvzba_split_reshape_cat  ->  causal_conv1d_update
  ->  fused_recurrent_gated_delta_rule_packed_decode  ->  gated RMSNorm

aiter has all the pieces for GDN — chunk_gated_delta_rule,
fused_recurrent_gated_delta_rule, causal_conv1d_update_split_qkv,
gated_rmsnorm_fp8_group_quant — but not the single-launch fusion, and not
one that consumes the packed qkvz/ba projections directly.

This adds that op. On 4x MI355X / TP4 with Qwen3-Next-80B-A3B-Instruct-FP8 it is
worth +6.0% to +9.1% end-to-end output throughput when wired into SGLang
(companion PR), with no accuracy regression.

Why a new op rather than a mode on fused_kda_decode

fused_kda_decode is the same shape of fusion — conv, recurrence, gated norm,
one launch — but it implements KDA, and the differences are structural, not
parametric:

fused_kda_decode this op
head topology one H; q/k/v all H heads VH == 2*KH
gate lower_bound * sigmoid(exp(A)*(a+dt)), vector over K exp(-exp(A_log)*softplus(a+dt)), scalar per head
output gate sigmoid(z) z * sigmoid(z) (SiLU)
conv bias none seeds the accumulator
input pre-split mixed_qkv/gate/beta packed qkvz/ba, split internally

The head topology alone settles it: KDA's lp = H*K, q_off = i_h*K,
v_off = 2*lp + i_h*V offsets cannot express VH = 2*KH. A mode flag would
double an existing kernel's branch factor to host a different algorithm, so this
ships as a sibling instead. fused_kda_decode is not exported from
gated_delta_net/__init__.py, so no existing interface is perturbed.

Modifications

New:

  • aiter/ops/triton/_gluon_kernels/gfx950/gated_delta_net/fused_gdn_decode_qkvz.py
    — the Gluon kernels. Two decompositions behind one entry point:

    • batch < 128 — _fused_decode / _fused_decode_tiled. One eight-wave
      workgroup owns a token/key-head group's convolution history and both FP32
      value-head states, with prefetch and tiling to hide memory latency while
      the machine is under-occupied.
    • batch >= 128 — _decode_group, which keeps the whole [V, K] state
      tile in registers across both matrix-vector products. Above that threshold
      the kernel is bandwidth-bound, where the latency-hiding machinery stops
      paying and the simpler traffic pattern reaches a better fraction of peak.

    Selection is a range check, not a shape table, so every batch size is
    covered.

  • aiter/ops/triton/gated_delta_net/fused_gdn_decode_qkvz.py — public wrapper,
    shape/dtype validation and the support predicate.

  • op_tests/triton_tests/test_fused_gdn_decode_qkvz.py — a pure-torch fp32
    reference plus correctness, pad-slot, bf16-only and determinism tests.

  • op_tests/op_benchmarks/triton/bench_fused_gdn_decode_qkvz.py — bandwidth and
    roofline sweep.

Modified:

  • aiter/ops/triton/gated_delta_net/__init__.py — export the op and its
    predicate.

What it fuses

  1. unpack packed projected_qkvz -> Q/K/V/Z and projected_ba -> b/a
  2. depthwise causal conv1d against the rolling window, with bias, + SiLU
  3. delta-rule gating, decay = exp(-exp(A_log) * softplus(a + dt_bias))
  4. FP32 recurrent state update at ssm_state[indices]
  5. gated RMSNorm with a SiLU gate
  6. optional per-head group-128 FP8 quantization

Stage 6 is opt-in via quant_dtype=. With None the op returns bf16 only,
because an unquantized Linear consumer cannot accept an (fp8, scales) tuple.

pad_slot_id

Rows whose state index equals pad_slot_id (default -1) are skipped without
reading or writing state. This matches what the other indexed-state ops in this
directory already do — fused_conv_recurrent_norm returns on state_idx < 0,
causal_conv1d_update_split_qkv takes an explicit pad_slot_id — and it is the
contract SGLang's replay_state_indices_validator documents. Measured cost:
0.2-0.3%, i.e. within noise.

Scope and supported shapes

Deliberately narrow, and reported rather than asserted:
fused_gdn_decode_qkvz_supported() returns (bool, reason) so callers fall
back and can log why.

  • gfx950 only — CDNA buffer addressing, v_exp_f32/v_rcp_f32 inline asm,
    explicit register layouts
  • num_v_heads == 2 * num_k_heads; head_k_dim == head_v_dim == 128
  • conv width 4; contiguous conv and recurrent state pools
  • bf16 packed projections / conv weight / conv bias / dt_bias / norm_weight;
    fp32 A_log and ssm_state; int32 state indices
  • fp8 output, when requested, is e4m3fn or e4m3fnuz

No CUDA path is affected. The op is reachable only through its own module,
and every caller is expected to gate on the predicate.

Accuracy Tests

pytest op_tests/triton_tests/test_fused_gdn_decode_qkvz.py — 22 passed on
MI355X / ROCm 7.0 / Triton 3.8.

  • test_fused_gdn_decode_correctness at batch 1, 2, 4, 8, 16, 32, 64, 128 —
    output, scales, and both mutated state pools against the fp32 reference at
    ATOL/RTOL 0.05, the bar this directory already uses for fused_kda_decode.
  • test_fused_gdn_decode_pad_slot — PAD_SLOT_ID rows leave ssm_state and
    conv_state bit-unchanged.
  • test_fused_gdn_decode_bf16_only — quant_dtype=None returns (out, None, None).
  • test_fused_gdn_decode_determinism — same inputs twice, bit-identical.
  • Eleven reference-only tests pin the packed layout, gate algebra and quant
    round-trip independently of the kernel.

The reference itself was validated before being trusted: run against seven
independent implementations of this kernel across nine batch sizes
, it agrees
on every one, and conv_state matches bit-exactly on all 63 combinations.

Speed Tests and Profiling

Hardware: 4x AMD Instinct MI355X (gfx950), TP4 / EP1.
Image: lmsysorg/sglang-rocm:v0.5.19-rocm700-mi35x-20260910 (ROCm 7.0,
Triton 3.8.0, torch 2.9). Model: Qwen/Qwen3-Next-80B-A3B-Instruct-FP8.

Kernel level

Per-launch time under CUDA graph replay with a fixed 512-slot state pool.
Both matter: timing the Python call instead of the kernel produces a flat ~25 us
floor that is host overhead, and sizing the pool to the batch puts small batches
entirely in cache.

batch 1 2 4 8 16 32 64 128 192 256
this op (us/call) 9.70 9.73 9.80 10.02 9.84 10.40 14.24 26.95 36.81 46.52

Replaced chain, same conditions, per decode step over 36 GDN layers:
830.8 us -> 279.2 us (2.98x) for the four kernels listed in Motivation.

Roofline

python3 op_tests/op_benchmarks/triton/bench_fused_gdn_decode_qkvz.py, MI355X
(8.0 TB/s peak HBM). The op performs no MFMA and no gl.dot; it is bound by the
FP32 recurrent state, read and written once per token per value head, which
dominates every other tensor by an order of magnitude.

batch 1 8 32 64 128 192 256
us/call 9.39 9.69 10.40 14.24 26.95 36.81 46.52
TB/s 0.12 0.89 3.33 4.87 5.14 5.65 5.96
% peak 1.4% 11.2% 41.6% 60.8% 64.3% 70.6% 74.5%

Two regimes, and they are why the op carries two decompositions. Up to batch
~32 it is latency-bound — there is not enough state traffic to fill the
machine, and time is nearly flat at ~9.4-10.4 us regardless of batch, so
prefetch and tiling pay. From batch 64 it becomes bandwidth-bound, where
that machinery stops paying and the register-resident tile reaches a better
fraction of peak: 74.5% at batch 256.

A single decomposition plateaued at 5.20 TB/s (65%) there. Three candidate fixes
were tested and rejected before concluding the gap was structural: extra HBM
passes (identical byte counts, by source-level count of the state load/stores),
launch-order locality (rewriting the grid to the other kernel's order gained
0.75%), and store cache policy (.cs to .wt, slightly worse). What remains is
the register layout and reduction structure — a different algorithm, not a
tuning constant.

End to end (companion SGLang PR)

Un-profiled, random dataset ISL/OSL 1024/1024, num-prompts == max-concurrency.

conc baseline tok/s with this op delta baseline TPOT with op TPOT
1 459.9 487.4 +6.0% 4.27 ms 4.03 ms
8 3072.1 3262.3 +6.2% 5.07 ms 4.76 ms
32 8858.2 9666.3 +9.1% 6.86 ms 6.26 ms
64 13974.7 15109.6 +8.1% 8.60 ms 7.91 ms

GSM8K 1319q: 0.941 baseline vs 0.941 with the op, Invalid: 0.000 on both.

End to end at a raised graph cap

The large-batch band only fires above batch 128, which requires
--cuda-graph-max-bs-decode above its default of 64. At a cap of 256, two arms
on the same base, un-profiled:

conc baseline tok/s with this op delta baseline TPOT with op TPOT
128 21528.0 22596.2 +5.0% 11.00 ms 10.43 ms
256 30926.3 32276.3 +4.4% 14.97 ms 14.29 ms

Reproduction

docker run --rm -it --device /dev/kfd --device /dev/dri --network host \
  --group-add video --ipc host --shm-size 32g \
  -v $HF_HOME:/hf lmsysorg/sglang-rocm:v0.5.19-rocm700-mi35x-20260910

pytest op_tests/triton_tests/test_fused_gdn_decode_qkvz.py -q

Checklist

  • Tests added, mirroring the op tree (op_tests/triton_tests/)
  • black / ruff clean
  • DCO sign-off on every commit
  • gfx950-gated; no CUDA path affected
  • Benchmark (op_tests/op_benchmarks/triton/bench_fused_gdn_decode_qkvz.py)

…decode

Defines the op contract for a fused Qwen3-Next Gated DeltaNet decode kernel
before any kernel lands: packed qkvz/ba split, depthwise causal conv1d with
bias + SiLU, GDN gating, the fp32 recurrent state update, gated RMSNorm with a
SiLU gate, and an optional per-head group FP8 quantization epilogue.

This is a sibling of fused_kda_decode, not a variant of it. KDA uses a
per-channel lower-bounded sigmoid gate, a sigmoid output gate, uniform head
counts and no conv bias; Qwen3-Next GDN uses a per-head
exp(-exp(A_log) * softplus(a + dt_bias)) decay, a SiLU output gate,
num_v_heads == 2 * num_k_heads and a biased conv. The head topology alone
(lp = H*K offsets) cannot express VH == 2*KH, so a mode flag on the existing
kernel is not viable.

The reference has been cross-checked on MI355X against all seven Artemis Gluon
variants across all nine specialized batch sizes (M = 1,2,4,8,16,32,64,128,256).
conv_state matches bit-exactly on every band; the other outputs agree within
the ATOL/RTOL 0.05 bar this directory already uses for fused_kda_decode, with
the residual being a few bf16 ULP from differing fp32 evaluation orders.

The kernel-side tests are written against the pad_slot_id convention that
aiter's other GDN ops already honour (fused_conv_recurrent_norm skips
state_idx < 0; causal_conv1d_update_split_qkv takes an explicit pad_slot_id),
rather than the reserved-slot-0 redirection the out-of-tree Artemis adapter
maintains via a monkeypatched _replay_metadata. They skip until the op lands.

Signed-off-by: Rita Brugarolas Brufau <rita.brugarolasbrufau@amd.com>
One Gluon launch replaces the four-kernel Qwen3-Next decode chain
(fused_qkvzba_split_reshape_cat -> causal_conv1d_update ->
fused_recurrent_gated_delta_rule_packed_decode -> layer_norm_gated) and can
optionally emit the per-head group-128 FP8 activations that a block-FP8
out_proj consumes directly, removing a separate quantization launch.

A sibling of fused_kda_decode, not a variant of it: KDA uses a per-channel
lower-bounded sigmoid gate, a sigmoid output gate, uniform head counts and no
conv bias, and its lp = H*K offsets structurally cannot express VH == 2*KH.

Verified bit-identical to the upstream Artemis kernel on every output
(out, quantized, scales, ssm_state, conv_state) at M = 1, 32 and 256 on MI355X,
and green against the fp32 reference across M = 1..128.

Two changes on top of the upstream source:

* pad_slot_id: rows whose state index is the sentinel are skipped without
  reading or writing state, matching the contract SGLang's
  replay_state_indices_validator documents and the one aiter's
  fused_conv_recurrent_norm already implements. This removes the upstream
  kernel's dependence on a caller-maintained reserved slot 0.
* HAS_FP8: the quantization epilogue is optional, because an unquantized
  out_proj cannot accept an (fp8, scales) tuple.

Support is reported by fused_gdn_decode_qkvz_supported() as (bool, reason)
rather than asserted, so callers fall back and can log why. Coverage is
deliberately narrow and stated: gfx950, VH == 2*KH, head dims 128, conv width 4.

The Gluon body is generated by build_aiter_kernel.py, which pins the source
sha256 and asserts every edit it applies; the source is ~600 lines of dense
Gluon with explicit register layouts, where a transcription slip would surface
only as a numerical mismatch.

Signed-off-by: Rita Brugarolas Brufau <rita.brugarolasbrufau@amd.com>
build_aiter_kernel.py reads the Artemis kernel pack, which is not part of this
repository, so shipping it here would put a script in the tree that cannot run.
Provenance stays where a reviewer needs it -- the generated kernel's header
records the source file and its sha256 -- and the generator itself lives with
the pack it reads.

Signed-off-by: Rita Brugarolas Brufau <rita.brugarolasbrufau@amd.com>
The maintainer guide asks for kernel + wrapper + test + benchmark; this is the
missing fourth.

Reports achieved HBM bandwidth against arch peak. The kernel performs no MFMA
and no gl.dot -- it is bound by the FP32 recurrent state, read and written once
per token per value head, which dominates every other tensor by an order of
magnitude. So bytes moved is the right roofline denominator.

Two measurement choices the script documents inline, because getting either
wrong silently invalidates the result: it times under CUDA graph replay, since
timing the Python call measures host overhead that at small batches is ~2.5x the
kernel itself; and it sizes the state pool independently of the batch, since
sizing it to the batch puts small batches entirely in cache.

Measured on MI355X: the kernel is latency-bound at small batch (1.4% of peak at
batch 1) and becomes bandwidth-bound from batch 64, saturating at 5.0-5.2 TB/s,
62-65% of the 8 TB/s peak.

Signed-off-by: Rita Brugarolas Brufau <rita.brugarolasbrufau@amd.com>
@github-actions

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 5520 --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.

The header said 'us'. The timed region is a CUDA graph enclosing the whole
wrapper, so the number is per call. This op always issues a single launch, so
the two coincide here -- but the label should not depend on that staying true,
and the Artemis sources it derives from do use multi-launch paths at small
batch where per-call and per-launch differ.

Signed-off-by: Rita Brugarolas Brufau <rita.brugarolasbrufau@amd.com>
Above batch 128 the op now uses a second decomposition that keeps the whole
[V, K] state tile in registers across both matrix-vector products, instead of
the latency-hiding path that serves smaller batches.

This is not a tuning of the existing kernel and could not be reached by one. The
two move identical bytes -- one read and one write of the fp32 state tile,
confirmed by source-level count -- and three candidate explanations for the gap
were tested and rejected: launch-order locality (rewriting the grid to the other
kernel's order gained 0.75%), store cache policy (.cs to .wt was slightly
worse), and routing large batches through the kernel that does carry a batch
ladder (about 5% of the gap). What remains is the register layout and reduction
structure, which is a different algorithm.

At batch 256 this takes the op from 53.5 to 46.5 us, and from 65% to 74.5% of
peak HBM bandwidth with the same byte count. At 128 the two are within 2%; below
128 the existing path is better by up to 15%, so the threshold sits at the
measured crossover.

Selection is a range check rather than a shape table, so every batch size above
the threshold is covered -- there are no gaps to fall into. Correctness is
verified against the fp32 reference at 128, 192 and 256, and at the
non-power-of-two sizes 136, 160, 200 and 248.

Signed-off-by: Rita Brugarolas Brufau <rita.brugarolasbrufau@amd.com>
@rbrugaro-amd
rbrugaro-amd marked this pull request as ready for review September 17, 2026 23:06
@rbrugaro-amd
rbrugaro-amd requested a review from a team September 17, 2026 23:06
@zufayu
zufayu requested review from a team and azaidy September 18, 2026 01:17
@Boss2002n
Boss2002n requested a lite review from Copilot September 18, 2026 05:33

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.

🟡 Changes recommended

Unresolved critical operand-layout and state-pool validation issues remain, along with dispatch, predicate, and test-coverage gaps.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds a gfx950-only fused Qwen3-Next GDN decode operation combining convolution, recurrence, normalization, and optional FP8 quantization.

Changes:

  • Adds Gluon kernels, public wrapper, dispatch, and support validation.
  • Exports the operation and adds kernel package initialization.
  • Adds correctness tests and CUDA-graph performance benchmarks.
File summaries
File Summary
op_tests/triton_tests/test_fused_gdn_decode_qkvz.py Reference and kernel correctness tests
op_tests/op_benchmarks/triton/bench_fused_gdn_decode_qkvz.py Performance benchmark
aiter/ops/triton/gated_delta_net/fused_gdn_decode_qkvz.py Public API, validation, and dispatch
aiter/ops/triton/gated_delta_net/__init__.py Public operation exports
aiter/ops/triton/_gluon_kernels/gfx950/gated_delta_net/fused_gdn_decode_qkvz.py Small- and large-batch Gluon kernels
aiter/ops/triton/_gluon_kernels/gfx950/gated_delta_net/__init__.py Kernel package initialization
Review details

Suppressed comments (11)

aiter/ops/triton/_gluon_kernels/gfx950/gated_delta_net/fused_gdn_decode_qkvz.py:479

  • The wrapper launches _fused_decode_tiled[...] directly, making this a launchable entry kernel, but it has no config-aware repr=. Add a make_kernel_repr callback with its meaningful constexpr/tuning keys, as required by the existing pattern in aiter/ops/triton/README.md:269-296, so compiled specializations remain identifiable in traces.
@gluon.jit
def _fused_decode_tiled(X, BA, History, State, Indices, Weight, Bias, ALog, DTBias,

aiter/ops/triton/_gluon_kernels/gfx950/gated_delta_net/fused_gdn_decode_qkvz.py:625

  • The wrapper launches _decode_group[...] directly, making this a launchable entry kernel, but it has no config-aware repr=. Add a make_kernel_repr callback with its meaningful constexpr/tuning keys, as required by the existing pattern in aiter/ops/triton/README.md:269-296, so compiled specializations remain identifiable in traces.
@gluon.jit
def _decode_group(

aiter/ops/triton/gated_delta_net/fused_gdn_decode_qkvz.py:45

  • This 128-token crossover is a measured performance choice rather than a functional constraint, but it is hardcoded in the wrapper. Put the dispatch threshold in the op's tuning configuration so retuning the crossover does not require changing runtime source code.
_LARGE_BATCH = 128

aiter/ops/triton/gated_delta_net/fused_gdn_decode_qkvz.py:276

  • The tiled launch also hardcodes num_warps=8, so this decomposition bypasses config-driven tuning. Route its warp count through the same tuning configuration as the other launch paths.
            num_warps=8,

aiter/ops/triton/gated_delta_net/fused_gdn_decode_qkvz.py:286

  • The small-batch launch repeats a hardcoded num_warps=8, leaving this specialization untunable through configuration as well. Use the configured launch parameters here instead of another literal.
            num_warps=8,

aiter/ops/triton/gated_delta_net/fused_gdn_decode_qkvz.py:40

  • The public wrapper advertises both torch.float8_e4m3fn and torch.float8_e4m3fnuz, but every kernel test uses only FP8_DTYPE = torch.float8_e4m3fn. The alternate format uses a different maximum (240 versus 448), so its scale and quantization path remain unverified.
_FP8_QUANT_MAX = {torch.float8_e4m3fn: 448.0, torch.float8_e4m3fnuz: 240.0}

aiter/ops/triton/gated_delta_net/fused_gdn_decode_qkvz.py:75

  • The support predicate only checks these six tensors for CUDA, while conv_bias, A_log, dt_bias, and norm_weight are also dereferenced by the kernel without any device, shape, or dtype validation; the flat-indexed projections, weights, bias, and indices are likewise not required to be contiguous. A CPU or strided operand can therefore make supported() return true and still be passed under the wrong ABI. Include every kernel operand in the support contract or reject unsupported layouts/devices before launching.
    tensors = (
        projected_qkvz,
        projected_ba,
        conv_state,
        ssm_state,
        ssm_state_indices,
        conv_weight,
    )
    if not all(isinstance(t, torch.Tensor) and t.is_cuda for t in tensors):

aiter/ops/triton/gated_delta_net/fused_gdn_decode_qkvz.py:98

  • For an empty/zero-head state pool, k_heads becomes zero; because the equality check then succeeds for v_heads == 0, the next line divides by k_heads while constructing group_width. The advertised (bool, reason) predicate should reject non-positive head counts before this division instead of raising ZeroDivisionError.
    k_heads = (channels - v_heads * head_v_dim) // (2 * head_k_dim)
    if v_heads != 2 * k_heads:
        return (
            False,
            f"requires num_v_heads == 2 * num_k_heads, got {v_heads}/{k_heads}",
        )

    group_width = 2 * head_k_dim + 2 * (v_heads // k_heads) * head_v_dim

op_tests/triton_tests/test_fused_gdn_decode_qkvz.py:278

  • The reference pad-slot test checks slots 3 and above, but slot 0 is the reserved slot that would be touched by an implementation that incorrectly converted the -1 sentinel into a valid index. Add an assertion for slot 0 so the test actually detects that failure mode.
    assert torch.equal(inp["conv_state"][3:], conv_before[3:])
    assert torch.equal(inp["ssm_state"][3:], ssm_before[3:])

op_tests/triton_tests/test_fused_gdn_decode_qkvz.py:104

  • The reference keeps the SiLU convolution result in fp32, but every kernel path returns this value as BF16 and converts it back to fp32 before Q/K normalization and the recurrence (_convolve/_conv_channels do .to(bfloat16).to(float32)). This oracle therefore compares different computations; round the activation at the same boundary before using it downstream.
        conv_out = acc * torch.sigmoid(acc)

op_tests/triton_tests/test_fused_gdn_decode_qkvz.py:315

  • The correctness parameter list jumps from 64 to 128, but the wrapper routes every batch in 65–127 to _fused_decode_tiled with B32=False; that entry path is never exercised by this test file. Add a representative batch in that interval (and keep the boundary cases) so the second decomposition is actually validated.
@pytest.mark.parametrize("batch", [1, 2, 4, 8, 16, 32, 64, 128, 192, 256])
def test_fused_gdn_decode_correctness(batch):
  • Files reviewed: 5/6 changed files
  • Comments generated: 12
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

…ions

Review findings from ROCm#5520, verified against the code before fixing.

The most serious was a hole in the tests: the correctness case computed
ref_quant and never compared it, so the FP8 output was checked only through its
scales. A broken epilogue could pass every case as long as the bf16 output,
scales and state updates were right. Now asserted, in value space rather than
code space -- a 1-ULP e4m3 code difference near the top of the range is enormous
as a raw integer and meaningless as a value, so both sides are dequantized with
their own scale.

The support predicate claimed more than it checked:
  - conv_state and ssm_state are indexed with the same slot, but their slot
    counts were never compared, so a shorter conv pool passed and was then read
    out of bounds
  - only the two pools were required contiguous, while projected_qkvz,
    projected_ba, conv_weight, conv_bias, A_log, dt_bias and norm_weight are all
    addressed with flat pointer arithmetic -- a same-shaped strided view passed
    and was read from the wrong locations
  - conv_bias was missing from the device check, and A_log, dt_bias and
    norm_weight were not parameters at all, so the predicate could return
    (True, "") for a launch whose operands were on the CPU or wrongly shaped

Also: the bf16-only path is now checked numerically rather than only for dtype
and None, since HAS_FP8=False is a separate compiled specialization; a broken op
module now fails instead of silently skipping every kernel test, with the skip
narrowed to the optional Gluon dependency; the kernel module no longer imports
torch, so it stays importable without PyTorch as the convention requires; and
the three launchable kernels carry config-aware repr= callbacks.

Dropped float8_e4m3fnuz rather than testing it: that is the gfx942 format and
this op is gfx950-only, so advertising it promised a path that can never run.

PAD_SLOT_ID is deliberately excluded from the repr keys. It is a protocol
sentinel rather than a tuning key, and make_kernel_repr does not sanitize the
sign of a negative value -- including it emits an invalid function identifier
and fails at compile.

Signed-off-by: Rita Brugarolas Brufau <rita.brugarolasbrufau@amd.com>
rbrugaro-amd added a commit to rbrugaro-amd/sglang that referenced this pull request Sep 22, 2026
ROCm/aiter#5520 review widened fused_gdn_decode_qkvz_supported() to take A_log,
dt_bias and norm_weight, which it previously never saw -- it could return
covered for a launch whose parameter tensors were on the CPU or wrongly shaped,
after which the kernel dereferenced them at fixed offsets.

Threads those through covered() and the backend call site so the delegation
stays complete, which is the point of delegating: the two contracts cannot drift
if SGLang forwards everything AITER checks.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Rita Brugarolas Brufau <rita.brugarolasbrufau@amd.com>
rbrugaro-amd added a commit to rbrugaro-amd/aiter that referenced this pull request Sep 22, 2026
…ions

Review findings from ROCm#5520, verified against the code before fixing.

The most serious was a hole in the tests: the correctness case computed
ref_quant and never compared it, so the FP8 output was checked only through its
scales. A broken epilogue could pass every case as long as the bf16 output,
scales and state updates were right. Now asserted, in value space rather than
code space -- a 1-ULP e4m3 code difference near the top of the range is enormous
as a raw integer and meaningless as a value, so both sides are dequantized with
their own scale.

The support predicate claimed more than it checked:
  - conv_state and ssm_state are indexed with the same slot, but their slot
    counts were never compared, so a shorter conv pool passed and was then read
    out of bounds
  - only the two pools were required contiguous, while projected_qkvz,
    projected_ba, conv_weight, conv_bias, A_log, dt_bias and norm_weight are all
    addressed with flat pointer arithmetic -- a same-shaped strided view passed
    and was read from the wrong locations
  - conv_bias was missing from the device check, and A_log, dt_bias and
    norm_weight were not parameters at all, so the predicate could return
    (True, "") for a launch whose operands were on the CPU or wrongly shaped

Also: the bf16-only path is now checked numerically rather than only for dtype
and None, since HAS_FP8=False is a separate compiled specialization; a broken op
module now fails instead of silently skipping every kernel test, with the skip
narrowed to the optional Gluon dependency; the kernel module no longer imports
torch, so it stays importable without PyTorch as the convention requires; and
the three launchable kernels carry config-aware repr= callbacks.

Dropped float8_e4m3fnuz rather than testing it: that is the gfx942 format and
this op is gfx950-only, so advertising it promised a path that can never run.

PAD_SLOT_ID is deliberately excluded from the repr keys. It is a protocol
sentinel rather than a tuning key, and make_kernel_repr does not sanitize the
sign of a negative value -- including it emits an invalid function identifier
and fails at compile.

Signed-off-by: Rita Brugarolas Brufau <rita.brugarolasbrufau@amd.com>
@rbrugaro-amd

Copy link
Copy Markdown
Contributor Author

Addressing copilot raised comments. Pushed 8c1e530.

Fixed

Test coverage

  • ref_quant was computed and never compared — the FP8 output was only checked through its scales, so a broken epilogue could pass every case. Now asserted in value space: both sides dequantized with their own scale, since a 1-ULP e4m3 code difference
    near the top of the range is enormous as a raw integer and meaningless as a value.
  • bf16-only path now checked numerically, not just for dtype/None — HAS_FP8=False is a separate compiled specialization.
  • ImportError narrowed to the optional Gluon dependency; a broken op module now fails instead of silently skipping every kernel test.

Support predicate — it claimed more than it checked

  • conv_state/ssm_state slot counts are now compared. They're indexed with the same slot, so a shorter conv pool previously passed and was then read out of bounds.
  • All flat-indexed operands must be contiguous, not just the two pools — projected_qkvz, projected_ba, conv_weight, conv_bias, A_log, dt_bias, norm_weight are all addressed with flat pointer arithmetic, so a same-shaped strided view was being read
    from the wrong locations.
  • conv_bias added to the device check; A_log/dt_bias/norm_weight are now predicate parameters with shape/dtype validation — they weren't parameters at all before.

Conventions

  • torch import removed from the kernel module.
  • repr= added to the three launchable kernels.
  • Distinct, in-range live-slot requirement documented on the public API.

float8_e4m3fnuz: removed rather than tested. It's the gfx942 format and this op is gfx950-only, so advertising it promised a path that can never run. Happy to reinstate with tests if you'd rather keep the dtype surface.

Two open questions for @ROCm/triton_kernels_team :

  1. Tuning parameters in Python (4/8, 16, 4, num_warps) — agreed these belong in a tuned config rather than the source. There's no config JSON for this op yet, so it's config plumbing rather than a move. Do we need to incorporate this for this PR or can we proceed as is for now?
  2. Test file location (op_tests/triton_tests/gated_delta_net/) — holding on this one: test_fused_kda_decode.py sits at the flat root for the same category, so I'd like to confirm whats the reviewer preference on this.

…ations

The style checks were red on files I had not linted the same way CI does. Three
distinct causes:

The generated Gluon module was never formatted. It is copied from sources that
are not black-formatted, so black rejected it. The generator now formats its own
output rather than leaving it to be hand-patched.

My local ruff runs used --select E4,E7,E9,F, which is narrower than the rule set
the repository's pyproject actually enforces, so C408, ISC004, RUF059, UP006,
UP035, UP045 and I001 all went unseen. Fixed at source: dict() calls rewritten
as literals, implicit string concatenation inside return tuples parenthesised,
typing.Optional/Tuple replaced with PEP 604/585 spellings, imports sorted, and
the unread second element of the inline-asm pairs renamed to _scratch.

Also removes build_aiter_kernel.py, which had been copied to the repository root
by mistake and was never meant to ship here.

Verified with the versions CI pins -- black 26.5.1 and ruff 0.16.0 -- run from a
tree carrying this repository's pyproject, over every file the branch touches.
26 op tests still pass.

Signed-off-by: Rita Brugarolas Brufau <rita.brugarolasbrufau@amd.com>
@rbrugaro-amd
rbrugaro-amd force-pushed the dev/gdn-fused-decode-fp8 branch from 8c1e530 to 48ec7cc Compare September 22, 2026 19:19
rbrugaro-amd added a commit to rbrugaro-amd/sglang that referenced this pull request Sep 22, 2026
ROCm/aiter#5520 review widened fused_gdn_decode_qkvz_supported() to take A_log,
dt_bias and norm_weight, which it previously never saw -- it could return
covered for a launch whose parameter tensors were on the CPU or wrongly shaped,
after which the kernel dereferenced them at fixed offsets.

Threads those through covered() and the backend call site so the delegation
stays complete, which is the point of delegating: the two contracts cannot drift
if SGLang forwards everything AITER checks.

Signed-off-by: Rita Brugarolas Brufau <rita.brugarolasbrufau@amd.com>
rbrugaro-amd and others added 2 commits September 22, 2026 13:21
Ruff's I001 fired on three files. My earlier local run missed it because I linted
copies flattened into a scratch directory, where the aiter package path no longer
matched, so isort classified aiter imports as third-party and sorted them into a
different, self-consistent order. Running ruff 0.16.0 against the real paths with
the repository's own pyproject reproduces CI exactly.

Worth noting for anyone reproducing: the ruff step uses reviewdog with
filter-mode=diff_context and -fail-on-error=true, so any finding on a changed
line fails the check -- whole-repo cleanliness is not the bar, the diff is.

Verified with the pinned versions, black 26.5.1 and ruff 0.16.0, on the real
tree. 26 op tests still pass.

Signed-off-by: Rita Brugarolas Brufau <rita.brugarolasbrufau@amd.com>
@azaidy

azaidy commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

@rbrugaro-amd two more changes

  1. The large-batch kernel does not call the shared device helpers. It writes the same operations again, with different primitives:
Operation Small band Large band (_decode_group)
exp _exp = gl.exp2(x * 1.4426950408889634) (L44) gl.exp (L939)
sigmoid _sigmoid, v_rcp_f32 inline asm (L49) 1.0 / (1.0 + gl.exp(-b)) (L940)
softplus _head_dynamics, with a NATIVE_LOG path (L125-L135) inline, no NATIVE_LOG (L938)
Q/K L2 norm _normalize_qk (L337) inline (L931-L932)
FP8 scale _quantization_scale, rcp plus Newton step (L62) inline plain divide (L975)

So the file now holds two implementations. Is this expected? If not, please make _decode_group call _head_dynamics, _normalize_qk and _quantization_scale. If there is some kind of performance implication, please mention it in a comment and you can keep the code as is.

  1. The kernel file defines its own _exp and _sigmoid. The repo already has aiter/ops/triton/_triton_kernels/activation.py with _silu, _sigmoid, _silu_exp2 and _sigmoid_exp2. Can you use those?

@azaidy azaidy self-assigned this Sep 24, 2026
Mirrors the public wrapper's category, as requested in review. Adds __init__.py
to match the sibling test directories.

Signed-off-by: Rita Brugarolas Brufau <rita.brugarolasbrufau@amd.com>
Addresses the review asks to stop duplicating operations inside the kernel file
and to use the repository's activation helpers.

_decode_group now calls _head_dynamics and _quantization_scale instead of
re-implementing the gate, softplus, exp, sigmoid and FP8 scale inline, and
_sigmoid delegates to _triton_kernels/activation._sigmoid_exp2.

Outputs are bit-identical to the previous kernel on all five results -- bf16
output, quantized, scales, and both mutated state pools -- at batch 1, 2, 8, 32,
64, 128, 192 and 256. Kernel time is unchanged: 10.47 -> 10.53 us at batch 32,
27.03 -> 26.76 at 128, 46.58 -> 46.45 at 256.

_normalize_qk is left in place. Unlike the others it is not a call-site swap:
it takes a shared-memory handle and reads through .gather() with a hardcoded
128 offset, where the large band uses .slice().load() with an explicit
gl.SliceLayout tied to its register layout. The arithmetic is the same either
way, so routing through it would change layouts in a kernel that is
bandwidth-bound above batch 128 without changing results.

Signed-off-by: Rita Brugarolas Brufau <rita.brugarolasbrufau@amd.com>
@rbrugaro-amd

Copy link
Copy Markdown
Contributor Author

@rbrugaro-amd two more changes

  1. The large-batch kernel does not call the shared device helpers. It writes the same operations again, with different primitives:

Operation Small band Large band (_decode_group)
exp _exp = gl.exp2(x * 1.4426950408889634) (L44) gl.exp (L939)
sigmoid _sigmoid, v_rcp_f32 inline asm (L49) 1.0 / (1.0 + gl.exp(-b)) (L940)
softplus _head_dynamics, with a NATIVE_LOG path (L125-L135) inline, no NATIVE_LOG (L938)
Q/K L2 norm _normalize_qk (L337) inline (L931-L932)
FP8 scale _quantization_scale, rcp plus Newton step (L62) inline plain divide (L975)
So the file now holds two implementations. Is this expected? If not, please make _decode_group call _head_dynamics, _normalize_qk and _quantization_scale. If there is some kind of performance implication, please mention it in a comment and you can keep the code as is.

  1. The kernel file defines its own _exp and _sigmoid. The repo already has aiter/ops/triton/_triton_kernels/activation.py with _silu, _sigmoid, _silu_exp2 and _sigmoid_exp2. Can you use those?

Thanks @azaidy — addressed in 92470292d

Four of the five operations in your table now route through the shared
helpers. _normalize_qk does not
— it is the one case that is not a
call-site swap, and I have left it alone. Rationale at the bottom; happy to
change it if you disagree.

Summary against your table:

operation status
exp ✅ via _head_dynamics
sigmoid ✅ via _head_dynamics + activation._sigmoid_exp2
softplus ✅ via _head_dynamics
FP8 scale ✅ via _quantization_scale
Q/K L2 norm ❌ kept inline — see below

1. Large band now calls the shared device helpers

_decode_group no longer re-implements the gate, softplus, exp, sigmoid or FP8
scale inline:

  • _head_dynamics replaces the inline ba_offset / softplus / decay / beta
    block. This covers four of the five rows in your table at once (exp, sigmoid,
    softplus, and the gate load). Called with NATIVE_LOG=False, which is exactly
    the gl.log path the band already used. The index arithmetic is provably
    identical for ratio == 2, which the op asserts:
    (head//2)*4 + head%2 == group*2*ratio + v//V.
  • _quantization_scale replaces the plain / fp8_max divide.

2. Using activation.py

_sigmoid now delegates to _triton_kernels/activation._sigmoid_exp2.

Confirming something that may be useful more broadly: a @gluon.jit kernel can
call a @triton.jit helper
— it compiles and runs. (Precedent already existed
in _gluon_kernels/gfx1250/fusions/fused_clamp_act_mul.py.)

Note activation.py has no plain exp, so _exp stays local; it is the
exp2(x·log2e) form and is bit-identical to gl.exp anyway.

Outputs are bit-identical

Refactored vs previous kernel, identical inputs, every returned and mutated
tensor:

batch out (bf16) quantized scales ssm_state conv_state
1 ✅ ✅ ✅ ✅ ✅
2 ✅ ✅ ✅ ✅ ✅
8 ✅ ✅ ✅ ✅ ✅
32 ✅ ✅ ✅ ✅ ✅
64 ✅ ✅ ✅ ✅ ✅
128 ✅ ✅ ✅ ✅ ✅
192 ✅ ✅ ✅ ✅ ✅
256 ✅ ✅ ✅ ✅ ✅

v_rcp_f32 and IEEE divide differ by ≤1 ULP in fp32, but every use site rounds
to bf16 immediately, so nothing propagates. Since no output bit moves, the
accuracy numbers already reported still hold unchanged. 26 op tests pass.

Kernel time is unchanged

Same-session A/B, kernel swapped in place, 3 repeats each (MI355X, 512-slot
state pool, CUDA-graph replay):

batch before after delta
32 10.47 µs 10.53 µs +0.6%
128 27.03 µs 26.76 µs −1.0%
256 46.58 µs 46.45 µs −0.3%

Full sweep after the change, median of 3:

batch 1 2 4 8 16 32 64 128 192 256
µs/call 9.41 9.40 9.48 9.67 9.66 10.85 14.44 26.72 36.57 46.42
TB/s 0.12 0.23 0.46 0.89 1.79 3.21 4.79 5.19 5.68 5.96
% of peak 1.4 2.9 5.7 11.2 22.4 40.1 59.9 64.8 71.0 74.5

_normalize_qk — the exception flagged above

This is the one row I did not unify, and it differs in kind from the others.

It is not a call-site swap. _normalize_qk takes a shared-memory handle and
reads via shared.gather(col, 0) / shared.gather(128 + col, 0). The large band
reads the same values through a different primitive:

q   = conv_shared.slice(0, K).load(gl.SliceLayout(0, layout))
key = conv_shared.slice(K, K).load(gl.SliceLayout(0, layout))

Three differences: .gather() vs .slice().load(); a hardcoded 128 where the
band uses constexpr K; and the band's loads carry an explicit gl.SliceLayout
tied to its register layout. Routing through the helper would change that layout
in a kernel that is bandwidth-bound above batch 128 — 74.5% of peak at 256 —
where register pressure drives occupancy.

The arithmetic is identical either way (rsqrt(sum(x*x) + 1e-6), with q
scaled), so this is a layout and occupancy question rather than a numerics one.
Happy to revisit if you'd prefer it unified regardless.

Resolves the one conflict, in aiter/ops/triton/gated_delta_net/__init__.py:
upstream added fused_gdn_prefill_qkvz at the same location this branch adds
fused_gdn_decode_qkvz. Both are kept, imports and __all__ alphabetically
sorted, matching the repository's isort configuration.

Upstream did not touch this branch's dependencies -- activation.py,
kernel_repr.py and arch_info.py are unchanged -- and the decode and prefill
Gluon kernels share no helper names.

Signed-off-by: Rita Brugarolas Brufau <rita.brugarolasbrufau@amd.com>
@azaidy
azaidy self-requested a review September 29, 2026 14:15

@azaidy azaidy 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! Please wait on final approval from @vgokhale

@azaidy
azaidy requested review from azaidy and vgokhale September 29, 2026 14:16

@vgokhale vgokhale 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.

Review: PR #5520 — Fused Qwen3-Next GDN decode (gfx950 Gluon, fp8 group-quant)

This is the decode counterpart to the prefill op in #5606, and structurally it's in good shape: the canonical four files (kernel in _gluon_kernels/gfx950/gated_delta_net/, wrapper in ops/triton/gated_delta_net/, test in the gated_delta_net/ test category, benchmark), no separate _launch file, repr on every launched kernel, a _supported() probe + ValueError-on-misuse wrapper, and an fp32 reference that's itself pinned. Dtypes are clean (bf16 in, fp8 e4m3fn out, fp32 reference only).

Two blockers, both already solved correctly by the prefill sibling ten files away — the fix for each is "do what fused_gdn_prefill_qkvz.py does."

1. Blocker — top-level Gluon import breaks import-safety and defeats the _supported() probe

The wrapper imports the Gluon tile kernels at module top level:

# aiter/ops/triton/gated_delta_net/fused_gdn_decode_qkvz.py:28
from aiter.ops.triton._gluon_kernels.gfx950.gated_delta_net.fused_gdn_decode_qkvz import (
    _decode_group, _fused_decode, _fused_decode_tiled,
)

and that kernel module does from triton.experimental import gluon at its own top level (kernel L28–29). The whole chain is unconditional — no try/function guard anywhere — and gated_delta_net/__init__.py:22 re-exports the wrapper. So import aiter.ops.triton.gated_delta_net eagerly pulls the Gluon dialect and raises ImportError on any build without triton.experimental.gluon (Triton < 3.8, the ROCm 3.7 backport, CPU / non-gluon platforms).

This is self-defeating: the wrapper docstring promises callers "fall back instead of crashing" via fused_gdn_decode_qkvz_supported() — but if importing the module already crashed, the probe is unreachable on exactly the platforms it exists for. Because the shared __init__ imports both siblings, it also poisons the carefully-deferred prefill path next to it.

The prefill wrapper is the precedent: it has no top-level Gluon import; all five of its gluon-kernel imports are lazy, inside each host launcher (e.g. fused_gdn_prefill_qkvz.py:209/381/555/860/1128), which it documents as "mirroring gemm/basic/gemm_a16w16.py."

Fix: delete the module-level import (wrapper L28–32) and import _decode_group / _fused_decode / _fused_decode_tiled lazily at the launch sites (L289 / L319 / L329), behind the gfx950 gate.

2. Blocker — no Triton ≥ 3.8 version gate

fused_gdn_decode_qkvz_supported() gates only on get_arch() != "gfx950" (L67) plus shape/dtype/contiguity. There is no version check anywhere in the wrapper or kernel.

#5606 added an explicit >= 3.8 gate (_gluon_supported()) for a specific reason: ROCm backported an earlier, incompatible Gluon into some 3.7 builds where import triton.experimental.gluon succeeds but the tile layout ops miscompile — so an import probe isn't enough, you need a numeric version floor.

This is not hypothetical for decode: the decode kernel is built from the same 3.7-fragile primitives — gl.BlockedLayout (×11, same 4-tuple form), gl.convert_layout (×14), gl.amd.slice (×4), plus gl.amd.cdna3.buffer_load/store. On a 3.7-backport environment the import sails past (gluon present), then execution reaches these ops and miscompiles. Combined with #1, that's a crash with no clean fallback.

Fix: add the >= 3.8 check to _supported(), mirroring the sibling's _gluon_supported().

3. Minor — batch sweep is a scaled-duplicate ladder

@pytest.mark.parametrize("batch", [1, 2, 4, 8, 16, 32, 64, 128, 192, 256]) appears twice (test L245, L317) → 20 cases on batch alone. The kernel has two regimes split at _LARGE_BATCH = 128, so 1/2/4/8/16/32/64 are the same small-path regime at different sizes — scaled duplicates. Keep the distinct regimes plus the crossover boundary, e.g. 1, 64, 127, 128, 256, which pins both paths and the boundary with ~half the cases.

Summary

Structure, repr, test anchoring, and dtype discipline all match where #5606 landed. The two blockers are a regression of import-safety work the prefill sibling already got right: move the Gluon imports off the module top level and into the launch sites, and add the Triton ≥ 3.8 gate to _supported(). Both are confirmed (not stylistic): the import chain is provably eager and ImportErrors without gluon, and the decode tiles actually use the layout ops the version gate exists to protect. Trim the batch ladder and it's ready.

The wrapper imported the Gluon tile kernels at module scope, so the module
raised ImportError on any build without triton.experimental.gluon -- before
fused_gdn_decode_qkvz_supported() could report the reason and let the caller
fall back, which is the one case the probe exists for. Import the three
kernels at their launch sites instead, and defer arch_info for the same
reason: detection can touch the driver at import time. Mirrors
fused_gdn_prefill_qkvz.py.

Add _gluon_supported(), an explicit Triton >= 3.8 floor checked before the
import probe. Some ROCm 3.7 builds carry an earlier, incompatible Gluon where
the import succeeds but the layout ops these kernels use miscompile, so
probing the import alone is not enough.

Trim the batch ladder from ten sizes to one per dispatch branch
(1, 32, 64, 127, 128, 256), adding 127 to cover the previously untested
65-127 path. The branch mapping is recorded above the list.

The Gluon kernel is unchanged and outputs stay bit-identical across batches
1-256, so the reported accuracy and end-to-end numbers are unaffected.

Signed-off-by: Rita Brugarolas Brufau <rita.brugarolasbrufau@amd.com>
@rbrugaro-amd

Copy link
Copy Markdown
Contributor Author

@vgokhale Thanks for the review, all 3 feedbacks fixed in [08ad92f](https://github.com/ROCm/aiter/pull/5520/commits/08ad92f5cedfd10c4cb3644c0651d4e226818ba6).

1. Gluon imports are lazy

Module-level import removed; the three kernels are imported at their launch
sites behind the gfx950 gate. I also deferred arch_info for the same reason —
slightly beyond your ask, mirroring the prefill sibling's _arch_supported(),
since driver-touching detection at import time is the same hazard.

Verified no top-level survivors:

import line scope
arch_info 61 deferred
triton.experimental.gluon 93 deferred
kernel imports 344 / 377 / 391 deferred

2. Triton ≥ 3.8 gate

_gluon_supported() added, lru_cache'd, version floor checked before the
import probe — which is the point, since the 3.7 backport imports fine. Wired
into _supported() ahead of the shape/dtype checks.

Behaves as intended with Gluon blocked:

wrapper imports without gluon : OK
_arch_supported()             : (True, '')
_gluon_supported()            : (False, 'triton.experimental.gluon unavailable')

and on the target machine:

triton 3.8.0   _arch_supported: (True,'')   _gluon_supported: (True,'')
FULL PREDICATE: (True, '(covered)')

3. Ladder trimmed — but 32 kept

[1, 2, 4, 8, 16, 32, 64, 128, 192, 256] → [1, 32, 64, 127, 128, 256], defined
once and shared by both tests. 20 → 12 cases.

I kept 32 against your list, because it isn't a scaled duplicate — it's its
own dispatch branch and the only flattened 1-D grid. Traced through the
wrapper:

batch kernel grid
1 _fused_decode (1, 4)
32 _fused_decode_tiled (128,) B32=True, 1-D grid
64 _fused_decode (4, 64) transposed grid
127 _fused_decode_tiled (127, 4) B32=False, also INDEX64=True
128 _decode_group (512,) 4 warps
256 _decode_group (1024,) 8 warps

Validation after changes

Rebuilt the test environment from a pristine checkout and overlaid the merged
tree (upstream/main + this PR) to avoid accumulated state:

check result
op tests 18 passed, 0 skipped
bit-identity vs pre-change kernel, batches 1→256, all 5 tensors identical everywhere
odd / CUDA-graph captured sizes all covered
variant coverage 7 variants × 9 bands, all ok
black + ruff (26.5.1 / 0.16.0) clean

Kernel timing unchanged — median of 3, shipped benchmark, 512-slot pool:

batch 1 32 64 128 192 256
before (µs) 9.41 10.85 14.44 26.72 36.57 46.42
after (µs) 9.41 10.91 14.39 26.70 36.52 46.25
delta 0% +0.6% −0.3% −0.1% −0.1% −0.4%

The Gluon kernel file is byte-unchanged — the diff is two files, host-side only
(imports, the predicate gate, test parametrization). Since no output bit moves,
the accuracy and end-to-end numbers already reported still hold unchanged.

@azaidy azaidy 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!

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.

4 participants