Skip to content

gfx1250 opus gemm splitk fuse - #4246

Merged
junhaha666 merged 25 commits into
mainfrom
binglue/gfx1250-opus-gemm-splitk-fuse
Aug 14, 2026
Merged

junhaha666 merged 25 commits into
mainfrom
binglue/gfx1250-opus-gemm-splitk-fuse

Conversation

@demonsan

@demonsan demonsan commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Motivation

Add a fused split-K opus GEMM pipeline for gfx1250 (a16w16 / bf16) and wire up the
corresponding DSv4 / Kimi-K3 bf16 GEMM tuned configs. The existing split-K path relies on a
separate reduce kernel; for small-M / large-K DSv4 shapes this incurs an extra
kernel launch and an HBM round-trip for the partial sums. This PR introduces a
single-kernel ("splitk_fuse") variant that keeps the partials resident in cache
and reduces them in-cluster, and merges freshly tuned gfx1250 configs so
these shapes pick the fastest available kernel (with a triton fallback for the untuned ones).

Technical Details

  • New fused split-K pipeline opus_gemm_pipeline_a16w16_clusterlaunch_tdm_splitk_fuse_gfx1250.cuh:
    performs the split-K partial reduction inside a single kernel using a cluster
    barrier (instead of a separate reduce kernel + semaphore). Non-last split WGs
    cast their fp32 partial to the workspace dtype and store it dirty-resident in
    GL2 (CPOL TH_WB|SCOPE_DEV); the last WG TDM-stages the published partials into
    LDS and folds in bias before writing the bf16/fp32 C tile. Workspace defaults to
    bf16, with an fp32 option when bf16 reduce precision is insufficient.
  • New reduce launch helper splitk_reduce_launch_gfx1250.cuh.
  • Split-K reduce extension (splitk_reduce_gfx1250.cuh, opus_gemm.cu,
    opus_gemm_common.py): support the bf16-workspace + fp32-bias path (fp32 bias
    folded in the reduce before the cast to bf16), same kernel name/ABI.
  • Codegen (codegen/common.py, codegen/gen_instances_gfx1250.py,
    gen_instances.py, opus_gemm_tune.py): emit the fused instances and the
    additional bf16-out + fp32-bias reduce instantiations across all compile-time
    split_k values and HAS_OOB.
  • Clusterlaunch grid round-up (opus_gemm_pipeline_a16w16_clusterlaunch_tdm_splitk_ws_gfx1250.cuh,
    codegen/gen_instances_gfx1250.py, opus_gemm_tune.py): a clusterlaunch kid used to
    require the tile grid to be a whole number of clusters, which left most shapes with no
    cluster candidate at all. The launcher now rounds the grid up and the surplus workgroups
    leave at tile_oob -- after paying their one cluster-barrier arrival, which -3 counts
    per workgroup, and before issuing any TDM -- so any (CWGM, CWGN) is legal for any (M, N).
    This also retires the 2D-cluster lockout: what those hung on was the tile-less workgroup
    streaming the whole K loop as zero-extent multicast loads at peers whose extents were
    real. The tuner consequently sweeps (cwm, cwn) over [1,4]x[1,4] minus (1,1), bounded by a
    round-up budget instead of divisibility.
  • Tuned configs (aiter/configs/model_configs/dsv4_bf16_tuned_gemm.csv,
    aiter/configs/model_configs/kimik3_bf16_tuned_gemm.csv): retune the full gfx1250
    block in both files (180 + 345 = 525 shapes) against opus and triton in the same
    session and keep the faster backend per shape. dsv4 is then retuned a second time
    against the round-up (opus only) -- see the performance section.

Performance

Setup

  • gfx1250, 256 CU, bf16 in / bf16 out, no bias, no scaleAB, no bpreshuffle.
  • 525 gfx1250 shapes extracted from the dsv4 and kimik3 configs (180 + 345, no overlap);
    524 measured (see limitation below).
  • Both backends measured back-to-back in the same session, same build, same clocks, via
    csrc/gemm_a16w16/gemm_a16w16_tune.py. opus swept 496 AOT-compiled non-fuse kids
    (28 plain cluster_tdm_ws + 468 clusterlaunch_tdm_ws) with split-K search; triton used
    its own autotune.
  • opus was swept on the 299 shapes with M<=512 & N<=10000 plus the nine large-M K=7168
    shapes already tuned on this branch. The remaining 225 shapes (M>512 or N>10000) are
    triton-only and are unchanged by this comparison.

opus vs triton, head-to-head (299 shapes)

opus is faster on 212 / 299 shapes (71%), median speedup 1.37x, mean 1.83x.

M shapes triton us opus us triton TFLOPS opus TFLOPS speedup
1 29 14.08 5.58 0.8 1.4 1.47x
2 29 14.13 5.65 1.6 2.8 1.55x
4 29 14.17 5.69 3.3 5.7 1.42x
8 29 14.04 5.86 6.6 11.7 1.34x
16 29 13.90 5.77 13.1 22.9 1.37x
32 29 13.92 5.82 26.0 43.4 1.43x
64 29 15.24 6.93 51.0 76.3 1.36x
128 29 18.04 7.86 96.1 136.6 1.40x
256 29 18.49 10.11 174.9 208.1 1.36x
512 29 19.72 14.17 295.9 353.0 1.26x
1024 3 21.09 19.74 264.3 281.6 1.07x
2048 3 28.74 25.50 392.3 336.6 0.85x
4096 3 41.84 52.79 539.0 433.3 0.80x

The win is largest in the decode regime (M<=32), where the shapes are launch- and
memory-bound and opus lands at a ~5.6-5.9 us floor against triton's 14 us. The gap narrows
as M grows and both backends become compute-bound; past M
2048 triton is ahead, which is why
the merged config keeps triton there.

Where triton still wins

triton wins on 87 of the 299 shapes. These are not scattered: they cluster into whole
(N, K) families, hitting nearly every M in the family, so this is a systematic tile/split-K
selection gap rather than measurement noise.

N K shapes median speedup
3072 128 10 0.82x
7168 768 9 0.66x
1536 128 9 0.87x
6144 512 9 0.74x
7168 4224 8 0.70x
7168 3072 8 0.80x
7168 1536 8 0.79x

Since the merged config picks the faster backend per shape, these shapes keep the triton
kernel and no shape regresses.

dsv4 retune on the clusterlaunch round-up (180 shapes)

The round-up removes the exact-cluster-fill requirement, so the tuner can offer any
(cwm, cwn) in [1,4]x[1,4] on any shape. dsv4's gfx1250 block was retuned against it,
opus only, over all 180 shapes (179 measured, see limitations) -- including the
N=32320 / N=129280 families that the sweep above had left to triton. Same 496-kid
non-fuse compile set, split-K search, max err_ratio 0.018.

Against the previous config -- which already picked the faster of opus/triton per
shape -- the tuned latencies over those 179 rows sum 1.52x lower (51.6 ms ->
33.9 ms). 112 rows are replaced and 67 keep their old entry; the merge takes the new
measurement only when it is faster, so no row regresses.

M shapes median opus rows before after
1-32 72 1.00x 60 62
64-512 48 1.11x 37 48
1024-16384 59 1.39x 4 47

The large end is where it pays, because those shapes previously had no usable cluster
candidate and fell back to triton: M=16384 N=32320 K=7168 goes 11.82 ms -> 6.94 ms
(1.70x), M=4096 N=129280 K=4096 5.99 ms -> 3.79 ms (1.58x), M=1024 N=2048 K=7168
55.7 us -> 30.9 us (1.80x). Below M=64 at N>=32320 triton still wins (0.80-0.93x) and
keeps those rows.

Where both configs already chose opus (101 rows) the result is a wash: median 1.016x,
47 faster and 36 slower, all within 10% and all sitting on the 4-5 us launch-latency
floor. The gain comes from the 78 rows that were triton's, where opus now wins 56
(median 1.30x).

152 of the 157 opus rows are clusterlaunch kids, and 99 of them use a (tile, cluster)
pair the previous filter could not select
-- a 2D cluster, cwm>2, or (24 rows) a grid
that does not divide the cluster at all. Those 99 rows are a median 1.26x faster than
what the previous config had for them. The selected dims skew wide: 4x4 on 55 rows, 1x4
on 42, 4x2 on 14, 2x4 on 12, the remaining 29 spread over 1x2 / 1x3 / 4x3 / 2x2 / 4x1 / 2x1.

That width is also why the candidate ranking prefers the widest cluster over a product
near 8/16. Measured on grids the cluster fills exactly, which takes the round-up out of
the picture (tile 128x128x128):

tile grid best 1x2
12x12 (M=N=1536, K=1024) 3x3, 10.7 us 13.5 us
16x16 (M=N=2048, K=2048) 4x4, 18.2 us 27.6 us

At equal size the cluster wants the grid's aspect: on a 4x32 grid 2x4 runs 11.2 us
against 13.3 us for 4x2, and 1x4 (11.4 us) beats 4x1 (20.1 us).

Resulting config

file gfx1250 shapes opus triton
dsv4_bf16_tuned_gemm.csv 180 157 23
kimik3_bf16_tuned_gemm.csv 345 111 234

dsv4 reflects the round-up retune; kimik3 is from the earlier opus-vs-triton sweep and
has not been retuned against the round-up yet.

All 59 gfx1250 shapes that already carried a tuned entry on this branch improved
(median 1.83x, no regressions) and kept their backend choice. That gain is mostly a
machine-configuration difference between the two tuning sessions rather than a code change:
the five triton-selected rows among them sped up by a comparable amount, and triton is
untouched by this PR.

Limitations

  • The fuse kids are excluded from this sweep. The tuning above compiled and searched only
    the 496 plain + clusterlaunch kids; the 1378 splitk_fuse kids this PR adds were skipped to
    keep the build tractable. The numbers therefore validate the retuned configs, not the fused
    pipeline itself, which is covered by the functional test below. A fuse-inclusive sweep is
    follow-up work.
  • Per-shape latencies carry roughly +/-5% noise. Re-running triton on the same 290 shapes
    with an identical build selected the same kernel for 290/290 shapes but shifted latencies by
    a median of 4-6%, with 32% of shapes moving more than 10%. Kernel selection is reproducible;
    individual speedup figures should be read with that error bar. The aggregate results are well
    outside it.
  • One shape can be measured by neither backend: M=16384, N=129280, K=4096. triton fails
    the correctness check (err_ratio 0.49) -- its a16w16 kernel computes output byte offsets in
    int32 and this output is 4.2 GB, so addressing wraps past 2^31 bytes, with corruption
    starting exactly at row 8305 = 2^30 / 129280. opus has no candidate either: the output is
    3.95 GiB and every kid is rejected by the 4 GiB buffer-resource filter, since these kernels
    build 32-bit AMDGPU buffer resources. The row is left as an untuned auto fallback.
  • The dsv4 retune ran opus alone, so its speedups are measured against the previous
    config's entry rather than against a same-session triton run. The 78 rows that were
    triton's carry that caveat; the triton numbers they are compared with come from the sweep
    above, same machine and build.

Test Plan

  • Local bring-up test op_tests/opus/test_splitk_fuse.py (kid 21000) against a
    CPU fp32 reference (torch GPU bf16 matmul is unreliable on the current gfx1250
    ROCm build), covering bf16/fp32 output and bias/no-bias:
    • run(64, 512, 2048, bf16, bias=False/True)
    • run(64, 512, 2048, fp32, bias=False)
    • run(128, 1024, 4096, bf16, bias=False)
  • Verified generated instances build for gfx1250.
  • Full gfx1250 retune of the dsv4 + kimik3 blocks (524 shapes) against both backends;
    every tuned row passes the tuner's correctness check (max err_ratio 0.042 for opus).
  • Round-up bring-up: a standalone HIP driver ran all 15 cluster dims over 12 case
    scenarios each (full / M-partial / N-partial / 1-of-16 alive / ragged / split-K),
    plus 20 consecutive sweeps at 4x4 -- no hang, no mismatch, no writes outside the
    tile from workgroups that leave at tile_oob.
  • dsv4 retune against the round-up (179 shapes, opus only): every row passes the
    tuner's correctness check, max err_ratio 0.018.

Test Result

  • All fused split-K cases PASS (bad=0) against the CPU reference for both
    bf16/fp32 output and with/without bias.
  • Retuned configs: 268 shapes select opus (dsv4 157, kimik3 111), no shape regresses
    against the previous triton-fallback baseline.

@demonsan
demonsan requested a review from a team July 15, 2026 06:32
@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:triton-300x Run an additional Triton test job on MI300X in PRs; main branch always runs both MI35X and MI300X
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 4246 --add-label <label>

@demonsan
demonsan requested a review from valarLip July 15, 2026 06:42
@demonsan demonsan changed the title Binglue/gfx1250 opus gemm splitk fuse gfx1250 opus gemm splitk fuse Jul 15, 2026
@demonsan
demonsan force-pushed the binglue/gfx1250-opus-gemm-splitk-fuse branch 7 times, most recently from 8b15272 to b7a7bac Compare July 27, 2026 02:26
demonsan and others added 17 commits August 10, 2026 06:16
Append the gfx1250 K=7168 shape block to dsv4_bf16_tuned_gemm.csv: tuned
shapes use the opus results from dsv4_gfx1250_tuned_gemm.csv, and the
remaining untuned shapes fall back to triton, matching the baseline scheme.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add the fused split-k pipeline and split-k reduce launch for gfx1250 opus
gemm, extend split-k reduce to support the bf16 workspace + fp32-bias path,
and update codegen/gen_instances to emit the fused instances.

Co-authored-by: Cursor <cursoragent@cursor.com>
The aiter imports must run after _patch_flaky_hip_device_count(); mark them
with noqa: E402 to satisfy the ruff code-style CI.

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

Overwrite the 59 tuned gfx1250 K=7168 shapes with the latest tuning run;
some large-M shapes now select triton over opus.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add a golden-checked CUDA-graph opus_gemm sweep (default entry) with split-K
workspace pre-warm on the capture stream, and print a latency-vs-tuned-CSV
table for gfx1250.

Co-authored-by: Cursor <cursoragent@cursor.com>
…1250 dispatch

- Add opus_gemm_workspace_release() (current stream) and
  opus_gemm_workspace_release_all() (all streams) to free the per-stream
  split-K workspace buffer, host/device handles and registry entry, with
  eager-mode guards and pre-free synchronization; wire up pybind + Python.
- Make the gfx1250 dispatch self-contained: move the shared flat-array
  dispatch POD types into opus_gemm_heuristic_dispatch_gfx1250.cuh
  (opus_gfx1250_detail) and drop the gfx950 header include so a gfx1250-only
  build compiles without OPUS_BUILD_HAS_GFX950.
- Allocate the split-K workspace handle from plain host memory on the
  gfx942/gfx1250 (device-mirror) path instead of hipHostMallocCoherent, so
  those builds use no pinned/coherent host memory (reclaimed by the OS at
  process exit); gfx950 keeps the coherent handle it dereferences on device.

Co-authored-by: Cursor <cursoragent@cursor.com>
…try)

Replace the SplitkWsRegistry/hipMalloc-based workspace management for gfx1250
split-K kernels with direct pointer passing from Python-allocated torch.empty
buffers (bf16 by default, matching OPUS_WS_BF16=1).

Changes:
- kargs struct: ws_handle* -> void* ptr_ws (direct pointer, no indirection)
- main kernels: kargs.ws_handle->ptr -> kargs.ptr_ws
- reduce kernel: const opus_splitk_ws_handle* -> const void* ws_ptr
- codegen launcher: remove registry/grow logic, add workspace tensor param
- C++ dispatch: opus_gemm_a16w16_tune gets optional workspace param;
  gfx1250 split-K kids pass workspace, others unchanged
- Python layer: allocate workspace via torch.empty(dtype=bf16) for kid [20000,30000)
- OpusA16W16NoscaleKernel for gfx1250 moved to opus_gfx1250_detail namespace
  (6-arg signature with workspace, separate from gfx950/gfx942 5-arg type)
- gen_instances.py manifest: emit 6-arg forward decl for gfx1250 split-K launchers
- gfx950/gfx942 paths unchanged (still use SplitkWsRegistry)

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: binglue.lyu <binglue.lyu@amd.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…rewarm

The gfx1250 split-K partial-sum workspace was allocated as a bf16 buffer sized
from an over-estimated raw element count. Kids whose kernel writes an fp32
workspace then overran the buffer (fp32 is 2x the bytes), corrupting adjacent
device memory and hanging the machine.

Size the workspace from the selected kid's OWN kernel definition instead:
  * allocate a [batch, split_k, padded_M, padded_N] tensor by element count in
    the kid's workspace dtype (fuse kids use compile-time fuse_split_k /
    fuse_ws_dtype; ws-variant kids use fp32 with runtime split_k), never a raw
    byte size;
  * fall back to a safe fp32 over-estimate when the kid table can't be loaded.

Also drop the eager graph-capture prewarm in tuned_gemm: the workspace is a
plain torch.empty passed to the launcher, and torch's HIP graph-capture-aware
caching allocator makes a single torch.empty path valid for both eager and
capture (replay-stable address, no pre-warm needed). The gfx942/gfx950 hipMalloc
registry path is unaffected and still fails loudly if used under cudagraphs
without a warm.

Co-authored-by: Cursor <cursoragent@cursor.com>
Replaces the positional tdm_desc constructor with tdm_traits::resolve<> over an
unordered pack of policy tags, so a call site names only the concerns it varies
and every default and validation lives in one place.

Adds the dim1..dim3 stride programming that 3D-5D windows were missing, so a
tile past 2D walks planes by the D#'s own pitch instead of landing on top of
itself, and rejects gather with ndim>2 at compile time since both claim D#
groups 2/3.

Moves the LDS write point out of window state into a make_descriptor()
parameter, leaving slot cycling to the pipeline that already owns the
s_wait_tensorcnt depth.

Makes every offset the public API takes an element count, matching the extents,
strides and origins it already took. The LDS byte scaling folds into the
address add, so the 4wave GEMM comes out 10 instructions shorter.
…g 24

The two gather bits in D# group 0 dword 0 were transposed. The reference
lowering in llvm-project (AMDGPUMakeDmaBaseLowering in AMDGPUToROCDL.cpp, with
the offsets pinned by name in the gfx1250 conversion test) puts gather_mode at
bit 30 and index_size at bit 31; opus had them the other way round. gather<32>
sets both bits either way, which is why the encoding was accidentally correct
everywhere it had been exercised, but gather<16> emitted 0x80000001 where the
reference emits 0x40000001 -- index_size of 32 bits with gather disabled, so the
window silently degraded to a plain contiguous copy instead of failing. Since
async_store shares make_descriptor(), scatter carried the same bug and is fixed
with it. The rest of the descriptor was checked field by field against the same
lowering and agrees, as does the 16-bit index packing and the reuse of tile_dim1
as the valid-index count.

Also drops the runtime rewrite of those bits in make_descriptor(). Both are
compile-time and already sit in group0_dword0_const, which nothing else patches,
so it was dead -- and holding one encoding in two places is what let the
transposition survive.

Three clang-24 builtin changes broke the header outright. aac296b87e8c moved the
AMDGPU builtins from GNU vectors to ExtVector, so the lax conversions that used
to paper over spelling differences are gone, and 7318bc734a5b retyped D# group 0
as unsigned while leaving groups 1-3 signed. fp16_t is therefore registered as
_Float16 from clang 24 on, which is what the fp16 wmma/mfma builtins now demand
and what opus itself used before clang 20; the older compilers keep __fp16
rather than shifting their numerics, since respelling turns arithmetic on fp16_t
from the fp32-intermediate v_fma_mixlo_f16 into a native v_fma_f16. Group 0 gets
a bit_cast behind OPUS_HAS_UNSIGNED_TENSOR_D0, generating nothing since the
lanes are unchanged, and the b64/b128 buffer store vdata goes unconditionally
unsigned because it was declared that way in the .def era too. Verified to
produce identical instructions under clang 23 and clang 24.

Tightens the sub-byte element check. Only array<fp4_t, 2> and its vector form
are accepted now: the bare type reports sizeof 1 for a 4-bit value so the
descriptor strides twice as far as the data, and an array or vector of any other
width makes the D# element several values at once, rescaling every extent,
stride and pad. The old sizeof test caught only the bare type.

The remainder is presentation. tdm_traits becomes a namespace rather than a
struct of statics, tdm_desc reaches through one Traits alias instead of five,
default_cache_policy comes from make_cache_policy() now that it is not stuck in
an incomplete-class context, and the longer comment blocks are condensed to the
file's single-line style with the measurements kept.

Co-authored-by: Cursor <cursoragent@cursor.com>
The gather mode and index size bits go back where they were. The previous
commit moved gather_mode to bit 30 and index_size to 31 to agree with MLIR's
AMDGPUToROCDL lowering; on gfx1250 the lowering is the one that is wrong.
Issuing one descriptor twice with nothing but that dword changed, 0x80000001
fetches the listed rows and 0x40000001 returns consecutive rows from the
origin. Only gather<16> can tell the two orderings apart -- gather<32> is
0xC0000001 either way -- which is how the transposition passed review and why
no 32-bit workload noticed it.

tdm_groups23 now receives tile_dim3 and tile_dim4 as template arguments.
tdm_desc computed them into group2_dword3_const and group3_dword2_const, but
the groups23 aggregate zeroed both operand groups and neither constant was
ever read, so a rank-4 tile collapsed onto its dim3 = 0 slice and a rank-5
tile onto dim3 = dim4 = 0. Ranks 2 and 3 were unaffected because tile_dim2
rides in group1, which is why nothing before this noticed.

test_tdm_feature_gfx1250.cu is the suite that found both: one self-contained
TU covering element sizes 1/2/4/8 bytes and array<fp4_t,2>, tile ranks 2..5
each with a move along its outermost dimension, gather and scatter at both
index widths, runtime descriptor state, padding, multicast and clamping.
Every check is byte-exact against a position hash, and untouched memory is
checked as hard as copied memory -- clamping, short gather lists and the
tile_dim1 patch are only visible that way. Both halves of the group0 encoding
are asserted, so a future transposition goes red instead of silently costing
gather<16> its rows.

test_tdm_gfx1250.cu is removed: it is written against tdm_window, which the
refactor took out of opus.hpp, so it has not compiled for some time. Its
coverage -- a pipelined multi-wave GEMM with cluster multicast -- is not what
the new suite checks and wants porting rather than deleting.

Around the harness: setup.py gains a positive per-arch list so the suite is
built only for gfx1250, an arch fallback through torch for hosts without
rocminfo, and named entries for two sources that do not currently compile
there, so one stale source no longer takes the whole .so with it. The
wmma_scale tests gain the missing-symbol guard the rest of the harness
already uses, since they look their launchers up by name at call time.

Verified on gfx1250 under both the ROCm 7.x clang and the clang 24 / HIP 7.15
toolchain in the opus_rocm_compiler container: 26 runtime checks pass, the 17
compile-time rejections all fire, opus_tdm_example is unaffected, and the
bf16 GEMM still runs at 147 TFlops.
The "feature" qualifier distinguished nothing -- it is the only TDM suite in
the directory. setup.py's source list and arch-only map follow the new name.
Signed-off-by: binglue.lyu <binglue.lyu@amd.com>
The TDM refactor replaced the positional tdm_window<> constructor with an
unordered pack of policy tags and moved every offset to element units, which
left the three gfx1250 a16w16 split-K pipelines and their traits header on an
API that no longer exists.

Rebuild WindowA/WindowB on opus::tdm<> with a padding_auto tag in place of the
kLdsPadEn / kPadInterval / kPadAmount constants the old descriptor carried, and
retire the byte-denominated slot sizes in favour of kSlotElemsA/B. The producer
loops now take the slot index as a template parameter and hand
make_descriptor() an element offset, so the double-buffer half is chosen at the
issue site instead of living in window state. The clusterlaunch variants read
their multicast masks from tdm_traits::peers_along_y/x<> rather than deriving
them in a loop, and the fuse variant's workspace staging follows the same
element-offset form.

Retune the gfx1250 blocks of the dsv4 and kimik3 bf16 GEMM configs (180 + 345
shapes) against opus and triton measured back to back in one session, keeping
the faster backend per shape: 212 shapes select opus and none regress against
the triton fallback they replace. The sweep searched the 496 plain +
clusterlaunch kids; OPUS_TUNE_NO_FUSE=1 is the switch that held the ~1.4k fuse
kids out of the compile set, so those remain unmeasured.

Co-authored-by: Cursor <cursoragent@cursor.com>
tdm_window<> and its positional make() no longer exist. The 2D x-load becomes
an opus::tdm<> window whose double-buffer half is handed to async_load() as an
element count, rather than being folded into the LDS base address in bytes.

The 3D residual load has no make() overload to move to, so it programs the
descriptor through make_from_layout() with explicit shape, pitch and origin
arrays in D# order. That also retires the placeholder barrier argument the old
eight-parameter call had to thread through.

Co-authored-by: Cursor <cursoragent@cursor.com>
The style CI has been red on this branch. Three separate findings, all on lines
the branch adds:

ruff flags the five `# noqa: E402` directives on the imports that have to run
after _patch_flaky_hip_device_count(). Under the 0.16.0 the workflow pins,
those imports no longer trip E402 at all, so the suppressions are dead and
RUF100 reports them. Drop them; the comment above the block still records why
the imports sit where they do.

ruff also flags two idioms in gemm_op_a16w16.py that reviewdog already
commented on: lru_cache(maxsize=None) becomes functools.cache, and the
splitK>1 ternary becomes max(1, ...).

black wants the _alloc_splitk_workspace call collapsed onto one line, which it
now fits after those edits.

Co-authored-by: Cursor <cursoragent@cursor.com>
@demonsan
demonsan force-pushed the binglue/gfx1250-opus-gemm-splitk-fuse branch from 86d1eba to 25dd628 Compare August 10, 2026 06:18
… an exact cluster fill

A clusterlaunch kid used to require the tile grid to be a whole number of
clusters, enforced by an assert at launch and mirrored by a divisibility
filter in the tuner. That left most shapes with no cluster candidate at all.

The launcher now rounds the tile grid up to whole clusters and the workgroups
the round-up adds leave at `tile_oob` in the pipeline prologue -- after paying
their one cluster-barrier arrival, which -3 counts per workgroup, and before
any TDM is issued. Their peers therefore never wait on them and never see a
request whose extents disagree, so any (CWGM, CWGN) is legal for any (M, N).
The workspace strides stay on the unrounded tile counts, so the reduce kernel
is unaffected, and the epilogue store loses its now-dead OOB guard.

This also retires the 2D-cluster lockout: what those configurations hung on was
the tile-less workgroup streaming the whole K loop as zero-extent multicast
loads at peers whose extents were real, not a deeper strided-A multicast defect.

The tuner consequently sweeps (cwm, cwn) over [1,4]x[1,4] minus (1,1), bounded
by a round-up budget rather than by divisibility: drop a cluster side wider than
the grid it rides on, drop anything leaving more than GFX1250_MAX_CLUSTER_WASTE
of the launched workgroups tile-less, then rank by (waste bucket, widest
multicast group, grid-aspect match). Ranking by group size rather than by
proximity to 8/16 follows the measurement -- a 12x12 grid runs 10.7 us at 3x3
against 13.5 us at 1x2, and on a lopsided 4x32 grid aspect is worth as much as
a size step (2x4 at 11.2 us against 4x2 at 13.3 us).

dsv4 gfx1250 is retuned against this, opus only, on 179 of its 180 shapes
(M=16384 N=129280 has a 3.95 GiB output and every kid is rejected by the 4 GiB
buffer-resource filter). The tuned times over those shapes sum 1.52x lower, and
opus now holds 157 of the 180 rows against 101 before.
demonsan and others added 7 commits August 12, 2026 12:18
…e is fixed

The fused single-kernel split-K pipeline still misbehaves, so keeping its 1378
kids in the table only costs tuning time and risks a bad pick. Gate the whole
sweep on GFX1250_SPLITK_FUSE_ENABLED (False): the kid list comes out empty, so
nothing looks the family up, the tuner has nothing to select and the codegen has
nothing to emit -- and the [21000, ...) band it used to claim is free for another
kernel family. The factory, the codegen emitter and the device pipeline all stay
in place, so re-enabling is a one-line flip.

This replaces the OPUS_TUNE_NO_FUSE escape hatch that every recent tune run had
to remember to set.

Co-authored-by: Cursor <cursoragent@cursor.com>
Conflicts came from main's new a8w8_mxscale BMM family (#4320) landing in the
same two files this branch edits, and both sides were purely additive:

* opus_gemm_common.py OpusGemmInstance: keep this branch's fuse_* axes next to
  main's mxscale BMM axes; both groups are defaulted trailing fields.
* gen_instances.py manifest emit: keep both signature templates and fold the
  gfx1250 6-arg workspace choice into main's A16W16_TUNE_TAGS branch, after the
  BMM branch (the tag sets are disjoint, so the order is free).

Verified after the merge: 496 gfx1250 kids with none in the fuse band, tuner
candidate counts unchanged, and codegen over gfx950+gfx942+gfx1250 emits both
the 44 BMM and the 63 workspace launcher declarations.

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

opus_gemm_lookup.h and opus_gemm_a16w16_tune_lookup.h each defined one
macro per output dtype, listing every kid in the compile subset regardless
of arch. Each arch's dispatch header then expanded those macros into a
table of its own launcher function-pointer type -- but gfx1250's a16w16
launchers take a workspace tensor that gfx950/gfx942's do not, so any build
targeting gfx1250 alongside another arch failed to type-check: gfx950's
table held gfx1250 function pointers and vice versa. GPU_ARCHS filtering
of |S| hid this for single-arch builds only. A gfx950+gfx1250 host compile
produced 439 such errors across all six tables.

Emit one macro per (dtype, arch) instead and have each arch header expand
only its own, which is also what the per-arch host TU split already does
for the split-K reduce ABI. Table contents are unchanged for every
single-arch build -- verified entry-for-entry against the base commit for
gfx950, gfx942 and gfx1250, with the rest of the generated blob byte
identical.

Winners in get_tune_dict are now keyed on arch too. The (M,N,K) tables are
per arch, so without arch in the key a shape tuned on two arches kept only
whichever CSV row was read last and the other arch silently fell back to
its heuristic: an all-arch build dropped 7 of 308 tuned rows (gfx1250
266/268, gfx950 35/40) before this change and drops none after.

Verified by host-compiling opus_gemm.cu for gfx950;gfx1250,
gfx942;gfx950;gfx1250, gfx942;gfx1250 and each arch alone.

Note that a full mixed-arch device build is still blocked by an unrelated
issue: the gfx1250 clusterlaunch pipeline applies __cluster_dims__ to its
kernel unguarded, which the gfx950 device pass rejects. That needs an
arch-guarded device stub and is not addressed here.

Co-authored-by: Cursor <cursoragent@cursor.com>
…the cluster kernels

Both clusterlaunch kernels already compile their body away to an empty stub
outside gfx1250, but the __cluster_dims__ attribute sat outside that guard.
A mixed-arch build compiles every device TU once per offload arch, and the
gfx950/gfx942 pass rejects the attribute before it ever reaches the body:

  opus_gemm_pipeline_a16w16_clusterlaunch_tdm_splitk_ws_gfx1250.cuh:75:1:
  error: 'cluster_dims' is not supported for this GPU architecture
  1 warning and 1 error generated when compiling for gfx950.

Guard it the same way, keeping the attribute on the host pass, which is
where the launch reads the cluster geometry from. Extended to the fuse
kernel too -- that family is unregistered today, so it has no TU to fail in,
but the bug is identical and would come back with it.

gfx1250 codegen is unchanged by construction (__gfx1250__ is defined on that
pass). Verified: gfx1250 device TUs now compile clean with
--offload-arch=gfx1250 --offload-arch=gfx950, a gfx1250-only build still
produces a correct GEMM (rel_err 2.7e-03 bf16), and four compiled
clusterlaunch kids dispatched by id are numerically clean (rel_err 5.4e-05),
so cluster multicast is intact.

A full mixed-arch build now gets past the gfx1250 TUs and hits the mirror
image of this problem, which is not addressed here: gfx950's kid TUs are
also compiled for gfx1250, where get_warp_size() is 32 instead of 64, so
wave-size assertions in the gfx950 traits fail (117 errors, e.g. "splitk
BLOCK_SIZE must cover exactly 4 waves"). Those TUs have no business being
compiled for another arch at all -- the fix belongs in the build flags
(per-source --no-offload-arch), not in more source guards.

Co-authored-by: Cursor <cursoragent@cursor.com>
…s compile

hipcc runs every TU once per --offload-arch, so a GPU_ARCHS=gfx950;gfx1250
build also instantiated the gfx950 kids for gfx1250: the gfx950 traits then
derived their layouts from a 32-wide wave and tripped
`BLOCK_SIZE == 4 * get_warp_size()`, and the reverse direction reached
gfx1250-only kernel attributes.

Wrap the generated device TUs (per-kid instances and the per-arch splitk
reduce) in `#if !defined(__HIP_DEVICE_COMPILE__) || defined(__<arch>__)`,
covering the #include as well, so a foreign device pass is an empty TU that
never parses another arch's headers. The host pass stays inside the guard
since it emits the __device_stub__ symbols the fused host TU links against.

Each object now carries its kernel only in its own arch's code object
(verified with llvm-nm on the unbundled images for gfx942/gfx950/gfx1250).
Builds clean for gfx950, gfx942, gfx1250, gfx950;gfx1250 and all three at
once; op_tests/test_opus_a16w16_gemm.py passes 15/15 on gfx1250 against both
the gfx1250-only and the mixed module.

Co-authored-by: Cursor <cursoragent@cursor.com>
The GLM 5.x tuning commit added a block_m grid divisor but only assigned it
in the gfx950 branch, so gfx1250/gfx942 raised UnboundLocalError at launch.
Those kernels take no BLOCK_M and run one query row per program, so 1
restores the original (seq_len,) grid.

Co-authored-by: Cursor <cursoragent@cursor.com>
@junhaha666
junhaha666 merged commit 1b741c0 into main Aug 14, 2026
60 of 61 checks passed
@junhaha666
junhaha666 deleted the binglue/gfx1250-opus-gemm-splitk-fuse branch August 14, 2026 11:15
Fyzyukk added a commit to Fyzyukk/aiter that referenced this pull request Aug 17, 2026
Reapply upstream commit 1b741c0 on top of the unified public API, canonical exact-kid registry, and caller-owned Torch workspace design.

Adopt the policy-tag N-D TDM API, clusterlaunch grid round-up, compile-time gfx1250 reducer dispatch, mixed-arch device guards, tuned configs, and the final decision to leave the fused family unregistered.
xiaobochen-amd pushed a commit to xiaobochen-amd/aiter that referenced this pull request Aug 28, 2026
… offset, not tensor bytes (#4)

* [TRITON][GLUON] Prefill MQA Logits kernel tuning for GLM 5.x (ROCm#4563)

(cherry picked from commit aded0f8)

Also carries the two-line `block_m = 1` for the non-gfx950 branch from ROCm#4246.
This commit routes the launch grid through block_m but only defines it under
`arch == "gfx950"`, so without those lines every other arch raises NameError at
the kernel launch. Upstream ran with that hole from ROCm#4563 until ROCm#4246.

* [Bugfix][Triton][gfx950] fp8_mqa_logits: gate buffer ops on the int32 offset, not tensor bytes

Prefill shapes whose fp32 logits pass 2 GiB abort the AMDGCN backend at
JIT time (Sequence.h:275 "Begin must be less or equal to End"). BLOCK_M=2
is selected for seq_len > 4096 but only compiles with buffer stores, and
the buffer-store gate switches off at 2 GiB, so the two combine into a
hard crash. For GLM-5.x that is any chunked prefill of 8192 tokens
against a context past 65,536.

The gate had the wrong unit. Buffer ops address through a 32-bit offset,
but the kernel re-bases the pointer per row and per KV tile before each
access, so that offset never has to span the tensor. What must fit in
int32 is the largest element offset the kernel forms, because the row
strides stay 32-bit on the buffer path and are only widened to int64 on
the fallback path. Counting bytes rather than elements made the limit 4x
too tight for an fp32 output.

Measured on MI355X, 32 heads x 128 head_dim, against the plain-store
path. Output is bit-identical in every case (max rel err 0.00e+00):

  s_q     s_k      logits    plain store   buffer store   speedup
  8192    65536    2.00 GiB      3.557 ms       2.941 ms     1.21x
  8192    95457    2.91 GiB      6.105 ms       4.577 ms     1.33x
  8192   131072    4.00 GiB      9.812 ms       6.489 ms     1.51x
  16384  131072    8.00 GiB     21.738 ms      12.879 ms     1.69x
  8192   262144    8.00 GiB     25.710 ms      14.184 ms     1.81x

The new boundary is exact rather than approximate: 8192x262144 and
16384x131072 both place the largest offset at exactly INT32_MAX and are
bit-correct, while 16384x139264 (1.06 x 2^31) and 32768x131072 (2^32)
fault.

End to end, a 21-layer GLM-5.x indexer prefill of 9,695 new tokens
against a 96,960-token context at chunk 8192 drops from 150.2 ms to
118.4 ms.

The existing cases top out at s_q=1024, s_k=1560, four orders of
magnitude below the gate, which is why nothing caught this. Added
8192x65664 and 8192x98304, which crash the process on current main.

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

---------

Co-authored-by: Mehmet Cagri <mehmet.kaymak@amd.com>
Co-authored-by: Jiejing Zhang <jiejizha@smci355-ccs-aus-n02-29.prov.aus.ccs.cpe.ice.amd.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
JohnQinAMD pushed a commit to xiaobochen-amd/aiter that referenced this pull request Aug 28, 2026
… offset, not tensor bytes (#4)

* [TRITON][GLUON] Prefill MQA Logits kernel tuning for GLM 5.x (ROCm#4563)

(cherry picked from commit aded0f8)

Also carries the two-line `block_m = 1` for the non-gfx950 branch from ROCm#4246.
This commit routes the launch grid through block_m but only defines it under
`arch == "gfx950"`, so without those lines every other arch raises NameError at
the kernel launch. Upstream ran with that hole from ROCm#4563 until ROCm#4246.

* [Bugfix][Triton][gfx950] fp8_mqa_logits: gate buffer ops on the int32 offset, not tensor bytes

Prefill shapes whose fp32 logits pass 2 GiB abort the AMDGCN backend at
JIT time (Sequence.h:275 "Begin must be less or equal to End"). BLOCK_M=2
is selected for seq_len > 4096 but only compiles with buffer stores, and
the buffer-store gate switches off at 2 GiB, so the two combine into a
hard crash. For GLM-5.x that is any chunked prefill of 8192 tokens
against a context past 65,536.

The gate had the wrong unit. Buffer ops address through a 32-bit offset,
but the kernel re-bases the pointer per row and per KV tile before each
access, so that offset never has to span the tensor. What must fit in
int32 is the largest element offset the kernel forms, because the row
strides stay 32-bit on the buffer path and are only widened to int64 on
the fallback path. Counting bytes rather than elements made the limit 4x
too tight for an fp32 output.

Measured on MI355X, 32 heads x 128 head_dim, against the plain-store
path. Output is bit-identical in every case (max rel err 0.00e+00):

  s_q     s_k      logits    plain store   buffer store   speedup
  8192    65536    2.00 GiB      3.557 ms       2.941 ms     1.21x
  8192    95457    2.91 GiB      6.105 ms       4.577 ms     1.33x
  8192   131072    4.00 GiB      9.812 ms       6.489 ms     1.51x
  16384  131072    8.00 GiB     21.738 ms      12.879 ms     1.69x
  8192   262144    8.00 GiB     25.710 ms      14.184 ms     1.81x

The new boundary is exact rather than approximate: 8192x262144 and
16384x131072 both place the largest offset at exactly INT32_MAX and are
bit-correct, while 16384x139264 (1.06 x 2^31) and 32768x131072 (2^32)
fault.

End to end, a 21-layer GLM-5.x indexer prefill of 9,695 new tokens
against a 96,960-token context at chunk 8192 drops from 150.2 ms to
118.4 ms.

The existing cases top out at s_q=1024, s_k=1560, four orders of
magnitude below the gate, which is why nothing caught this. Added
8192x65664 and 8192x98304, which crash the process on current main.

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

---------

Co-authored-by: Mehmet Cagri <mehmet.kaymak@amd.com>
Co-authored-by: Jiejing Zhang <jiejizha@smci355-ccs-aus-n02-29.prov.aus.ccs.cpe.ice.amd.com>
Co-authored-by: Cursor <cursoragent@cursor.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.

3 participants