Skip to content

[HIP] Perf/gfx1250 per group quant - #5273

Open
yzhou103 wants to merge 9 commits into
ROCm:mainfrom
yzhou103:perf/gfx1250-per-group-quant
Open

[HIP] Perf/gfx1250 per group quant#5273
yzhou103 wants to merge 9 commits into
ROCm:mainfrom
yzhou103:perf/gfx1250-per-group-quant

Conversation

@yzhou103

@yzhou103 yzhou103 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Motivation

Technical Details

Test Plan

Test Result

gfx1250,[T, 7168] bf16,group_size=128,e8m0 + transpose_scale=True

T before (us) after (us) speedup before (TB/s) after (TB/s)
1 2.314 2.256 1.03x 0.01 0.01
2 2.544 2.564 0.99x 0.02 0.02
4 2.597 2.340 1.11x 0.03 0.04
8 2.514 2.380 1.06x 0.07 0.07
16 2.598 2.481 1.05x 0.13 0.14
32 2.643 2.583 1.02x 0.26 0.27
64 2.773 2.606 1.06x 0.50 0.53
128 2.923 2.652 1.10x 0.94 1.04
256 3.072 2.912 1.05x 1.80 1.90
512 3.726 3.185 1.17x 2.96 3.47
1024 4.942 3.781 1.31x 4.47 5.84
2048 7.660 5.982 1.28x 5.76 7.38
4096 12.957 7.938 1.63x 6.82 11.13
8192 21.460 14.031 1.53x 8.23 12.59
16384 37.549 24.795 1.51x 9.41 14.25

e8m0 scale, transpose_scale=False

T before (us) after (us) speedup before (TB/s) after (TB/s)
1 2.401 2.356 1.02x 0.01 0.01
2 2.667 2.428 1.10x 0.02 0.02
4 2.757 2.241 1.23x 0.03 0.04
8 2.535 2.234 1.13x 0.07 0.08
16 2.565 2.319 1.11x 0.13 0.15
32 2.656 2.382 1.12x 0.26 0.29
64 2.774 2.490 1.11x 0.50 0.55
128 2.948 2.664 1.11x 0.94 1.04
256 3.275 3.011 1.09x 1.69 1.83
512 3.575 3.354 1.07x 3.09 3.29
1024 4.897 4.660 1.05x 4.51 4.74
2048 7.624 5.432 1.40x 5.79 8.13
4096 12.334 9.352 1.32x 7.16 9.44
8192 20.395 15.538 1.31x 8.66 11.37
16384 34.281 26.257 1.31x 10.30 13.45

fp32 scale

T before (us) after (us) speedup before (TB/s) after (TB/s)
1 2.344 2.298 1.02x 0.01 0.01
2 2.647 2.451 1.08x 0.02 0.02
4 2.600 2.584 1.01x 0.03 0.03
8 2.499 2.389 1.05x 0.07 0.07
16 2.557 2.489 1.03x 0.14 0.14
32 2.597 2.519 1.03x 0.27 0.28
64 2.716 2.628 1.03x 0.51 0.53
128 2.852 2.756 1.03x 0.98 1.01
256 3.142 2.911 1.08x 1.77 1.91
512 3.511 3.356 1.05x 3.17 3.31
1024 4.633 4.094 1.13x 4.80 5.44
2048 7.481 5.909 1.27x 5.95 7.53
4096 12.160 9.998 1.22x 7.32 8.90
8192 20.181 16.883 1.20x 8.82 10.54
16384 33.928 28.661 1.18x 10.49 12.42

Submission Checklist

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

🏷️ CI Guide

Runs automatically on every PR:

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

Extended tests (opt-in via labels):

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

Only add ci:atom_full for FlyDSL or Triton upgrades.
Add labels via the sidebar or gh pr edit 5273 --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.

@yzhou103
yzhou103 force-pushed the perf/gfx1250-per-group-quant branch from 0c78858 to 5fde45a Compare September 4, 2026 07:15
@yzhou103
yzhou103 marked this pull request as ready for review September 4, 2026 08:03
@yzhou103
yzhou103 requested review from a team and a lite review from Copilot September 4, 2026 08:03
@github-actions github-actions Bot changed the title Perf/gfx1250 per group quant [HIP] Perf/gfx1250 per group quant Sep 4, 2026
@github-actions github-actions Bot added the HIP label Sep 4, 2026

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

The new gfx1250 TDM staging path can issue out-of-bounds loads for extra grid blocks and the LDS staging buffer lacks required alignment for vectorized loads.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Optimizes the HIP dynamic per-group quantization kernels for gfx1250 by introducing gfx1250-gated tuning choices (group traversal order, block sizing, and optional TDM staging) and a gfx1250 fast-path for bf16→fp8 conversion that uses scalef32 divide semantics.

Changes:

  • Add gfx1250-gated kernel tuning: column-major group traversal for transposed-scale layouts, interleaved 32B chunking for improved memory coalescing, and launch-time block-size selection.
  • Introduce an optional gfx1250 TDM (tensor) staging path that pipelines group loads through LDS for the column-major case.
  • Extend store/conversion utilities to support “scale is divisor” conversion (including a gfx1250 bf16x8→fp8x8 scalef32 intrinsic path).
File summaries
File Description
csrc/kernels/quant_kernels.cu Adds gfx1250-tuned group ordering, interleaved chunk gather/store, optional TDM staging, and runtime launch heuristics for block sizing/TDM enablement.
csrc/include/aiter_opus_plus.h Adds gfx1250 scalef32 bf16→fp8 conversion helper, introduces scaled_cast_div, and wires a “scale_is_divisor” option through store helpers.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 2
  • Review effort level: Lite

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

const int64_t wave_g0 = block_g0 + wave_id * kGroupsPerWave;
int64_t x0;
int32_t y0;
resolve(win_g0, x0, y0);
// ori_row_stride. One descriptor moves it, and the hardware picks the transaction
// sizes instead of us paying the 50% per-instruction coverage of a b128 whose
// lanes sit 32B apart.
__shared__ DTYPE_I tdm_lds[kTdmLdsWaves * kTdmRing * kTdmSlotElems];
yzhou103 and others added 5 commits September 4, 2026 13:30
[16384, 7168] bf16, group_size 128, e8m0 + transposed: 36.86 -> 26.9 us.

Five independent changes: v_cvt_scalef32_pk8_fp8_bf16 for the convert (90
instructions per thread down to 4, gated on use_e8m0_scale since the
hardware keeps only the scale's exponent); sinking the scale store past the
conversion, which had forced a blocking s_wait_xcnt; column-major group
order when the scale is transposed, making its store contiguous instead of
one byte per cache line; two 32B chunks per thread instead of one 64B run,
halving both lane strides; and block size from the group ordering, 64 when
column-major and 256 otherwise.

Bit-exact against an fp64 reference across 9 shapes; test_quant.py passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wave 0 moves the block's tile global -> LDS with tensor_load_to_lds and a
barrier publishes it. [16384, 7168] e8m0 + transposed: 27.0 -> 25.50 us.

Only column-major order qualifies: there a block's groups are consecutive
rows at one y, a plain 2D region one descriptor can move. Depth 2 from a
sweep -- deeper unrolls the body once per tile and buys nothing. Gated on 6
blocks per SIMD (the switch lands between M=3072 and 4096) because staging
halves the block count and costs up to 20% below that.

The grid is deliberately sized by the UNSTAGED block span, leaving its back
half with nothing in range. Sizing it exactly measured 15-24% slower with
identical device code -- worth a dispatch-level profile before anyone
"fixes" it.

Bit-exact across 15 shapes and all three scale variants; test_quant.py passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The column-major ordering, the 32B chunk split, the 64/256 block size and
TDM staging all came off gfx1250 sweeps and lean on wave32, b128 as the
widest per-lane access, and that part's cache and dispatch behaviour. None
was measured on gfx950, so it now keeps its pre-series shape byte for byte
-- compile-checked only, no such part here.

Device side gates on __gfx1250__, host side on a cached get_gpu_arch().
Block size is a template argument, so the arch picks at launch.

gfx1250 unchanged (8.01/25.46 us at M = 4096/16384); correctness unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 256-thread block divides the block count by four, which on a small
tensor leaves the machine idle: at [8, 7168] that is 7 blocks on 1024 SIMDs.
On the fp32-scale path it is up to 1.23x slower through T=1024 and 4-9%
faster from T=2048, so it now needs 1 block per SIMD -- blocks per SIMD
rather than T, the same shape as the TDM gate.

This was a regression the series introduced: block size went from a constant
64 to layout-dependent 64/256 with no size condition, and the fp32-scale
path measured 0.89-0.95x at T <= 16 against main. It is now 1.01-1.06x.

Bit-exact across 15 shapes and all three scale variants; test_quant.py passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ring never needs more slots than there are steps to stage, so at the
shipped KPT of 2 one of its three slots was LDS nobody writes, 4 KiB per
block. Capping it at kTdmKPT measures -3.6% at [16384, 7168] e8m0 +
transposed (24.40 -> 23.53 us, 3 reps).

The kernel body is byte-identical either way -- 834 instructions, zero
differing lines in the disassembly -- and the output is bit-identical across
108 cases. Only the LDS request changes, 12288 -> 8192 bytes. Why that makes
identical code faster is not established; it is a threshold, not a slope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@yzhou103
yzhou103 force-pushed the perf/gfx1250-per-group-quant branch from 37ded91 to d4be180 Compare September 4, 2026 13:34
Copilot AI review requested due to automatic review settings September 4, 2026 13:34

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

The new gfx1250 TDM path can issue out-of-bounds async loads due to missing early-exit and unsafe host-side TDM enabling when runtime row count differs.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (2)

csrc/kernels/quant_kernels.cu:397

  • In the gfx1250 TDM path, the result of resolve(win_g0, ...) is ignored. Because the host intentionally oversizes the grid even when staging is enabled (see the comment in the launch code), some blocks will have win_g0 out of range; those blocks should early-exit before building a TDM descriptor and issuing async_loads, otherwise they can DMA from out-of-bounds global memory.
        int64_t x0;
        int32_t y0;
        resolve(win_g0, x0, y0);

        auto w = opus::make_tdm<TdmWindow>(

csrc/kernels/quant_kernels.cu:1252

  • Host-side use_tdm is computed using rows derived from input.numel()/cols, but the kernel may override ori_rows from num_rows_ptr (device value). If num_rows_ptr is provided, the divisibility and tiling assumptions required by TDM (e.g., rows % kGroupsPerStagedBlock == 0) can be wrong at runtime, which risks out-of-bounds TDM loads. The simplest safe option is to disable TDM when num_rows_ptr != nullptr.
            const bool use_tdm =
                dyn_gq_tuned_arch() &&
                kColMajor && DYN_GQ_TDM_KPT > 1 && (rows % kGroupsPerStagedBlock) == 0 &&
                (num_group / kGroupsPerStagedBlock) >= simds * kTdmMinBlocksPerSimd;
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread csrc/kernels/quant_kernels.cu Outdated
Comment on lines +329 to +331
static constexpr bool kStoreTakesDivisor =
use_e8m0_scale && std::is_same_v<DTYPE_O, opus::fp8_t> &&
std::is_same_v<DTYPE_I, opus::bf16_t> && (thread_data_size % 8 == 0);
@zufayu
zufayu requested review from junhaha666 and zufayu September 7, 2026 01:16
yzhou103 and others added 2 commits September 7, 2026 08:03
`v_cvt_scalef32_pk8_fp8_bf16` has no saturation. fp8 e4m3's top two steps are
448 (0x7e) and 480, whose encoding 0x7f is taken by NaN, so the convert
round-to-nearests anything past their midpoint 464 straight into a NaN. The
software `v_med3_f32` path clamps, so the two disagreed on out-of-range input.

Two disjoint sources, both now saturated to +-448:

  * An inf element made amax inf, hence an inf e8m0 scale, and turned EVERY
    element of the group into NaN (128 of 128 measured). Capping amax keeps the
    scale finite so the clamp can reach the inf elements. Sign is preserved,
    which the med3 path did not manage -- it returned -448 for +inf.

  * A finite value above 448*scale. Only reachable when the round mode floors
    the scale: RoundDown / Even run up to 2x low, far outside the 3.6% headroom
    up to 464. Measured with RoundDown at [4096, 7168], all-finite input:
    8055 NaN out of 29,360,128 before, 0 after with max |value| 448.

NaN is deliberately left alone -- it must stay NaN, and the convert already maps
a NaN element to a NaN byte without disturbing the rest of its group. The clamp
preserves that by comparing rather than min/max-ing: both tests are false for
NaN, so it passes through untouched.

Free on the shipped default: `kScaleMayClip` is a compile-time constant, so
under RoundUp the clamp folds to `if(degenerate_group)`, a branch never taken on
finite data. Measured at [T, 7168] e8m0+transposed, three interleaved rounds:
0.4-1.9% depending on size and run, at the noise floor. VGPR unchanged
(60/52/51), and the gfx950 device assembly is byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three DYN_GQ_* knobs were scaffolding from the sweep. Nothing in the build
ever defined them, so they were only obfuscating three constants behind the
preprocessor -- with no type, no scope, and no comment at the definition. They
become named constexpr values with a one-line rationale each; the sweeps they
came from already live at the use sites.

The comments this branch added ran to 182 lines against 289 of code. Trimmed to
122 by keeping the conclusion, the decisive number and whatever is
counter-intuitive, and dropping the reasoning-out-loud, the restatements of
rationale given elsewhere, and a "revisit with a profile" TODO that would only
have gone stale.

Pure refactor: the gfx1250 device assembly is byte-identical across both steps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 7, 2026 08:07

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.

🔵 Needs a closer look

The PR introduces substantial arch-specific kernel control-flow and memory/launch-shape changes (including TDM staging and new conversion semantics) that warrant careful human validation beyond automated review.

Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@zufayu

zufayu commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Review result: 🔴 High risk

I found one confirmed correctness issue in the new gfx1250 TDM path.

Blocking correctness issue: runtime num_rows can violate the TDM tiling invariant

The host decides use_tdm from the statically allocated rows in csrc/kernels/quant_kernels.cu (around lines 1211–1214), but the kernel later replaces ori_rows with the runtime num_rows (around lines 72–75). The group-to-coordinate mapping then uses that runtime value (around lines 137–149).

This is incorrect when num_rows is supplied and is not aligned to kGroupsPerStagedBlock.

A concrete reachable case is:

  • allocated input shape: [4096, 7168]
  • group_size=128
  • E8M0 scales
  • transpose_scale=true
  • runtime num_rows=33

The host enables TDM because the allocated row count satisfies the 32-group tiling/occupancy checks. The block beginning at gid 32 builds a TDM window starting at row 32 of scale column 0, so only one row in that descriptor is valid. However, resolve() maps gids 33–47 to rows 0–14 of scale column 1, while process() consumes the corresponding LDS slots as though that data had been staged by the same descriptor. Those slots do not contain the inputs for the resolved groups, producing silently incorrect quantized output.

This is reachable through the public per_group_quant_hip path: MoE callers pass a dynamic num_local_tokens as num_rows (for example in aiter/fused_moe.py).

There is a second edge case: runtime num_rows=0 reaches gid % ori_rows and gid / ori_rows, causing integer modulo/division by zero.

Please either disable TDM whenever num_rows is supplied unless its runtime value is known to satisfy the staging boundary requirements, or redesign the staging loop to handle runtime row boundaries. An early return for ori_rows == 0 is also needed. Regression coverage should include runtime row counts such as 0, 31, 32, and 33 with E8M0 and transpose_scale=true.

Notes from the rest of the review

  • I did not confirm the previously suspected out-of-bounds DMA issue from the deliberately oversized grid. opus::tdm uses saturating subtraction for the descriptor extents, so an out-of-range origin becomes a zero-extent transfer.
  • I also did not report the LDS vector-alignment concern because I could not establish a concrete misalignment failure from the implementation and ABI guarantees.
  • No changed or added test covers the new E8M0 transposed TDM path with dynamic num_rows; the existing quant test defaults to FP32 scale, transpose_scale=false, and num_rows=None.
  • The normal Aiter tests passed on MI35X/MI300X, but the gfx1250 extended test and downstream ATOM/Kimi/SGLang jobs were skipped for this fork PR. I did not have gfx1250 hardware available for an independent runtime or performance validation.
  • The PR includes performance tables, but the exact benchmark command, ROCm/compiler version, clock configuration, warmup count, and sample count are not documented, so the performance claims are not independently reproducible from the PR description alone.

Because the dynamic-row issue can silently corrupt quantized output in a public, reachable path, I consider this blocking until fixed and regression-tested.

…hold

Two dispatch-level changes, mutually exclusive by construction:

  1. The transposed (column-major) path now uses blk=256 instead of blk=64
     when TDM staging is not active and the grid is large enough. The wider
     block covers 4x the groups and cuts the block count by 4x, which wins
     4-8% from T=512 to T=4096. Below T=512 it is 2.7% negative, so the
     gate sits at 1/4 block per SIMD.

  2. TDM staging and the wide block cannot combine: a wider block scales the
     staged tile with it (16 KiB per ring slot vs 4), and the two together
     measured 22% slower at T=8192. The staging threshold is raised from 6 to
     8 blocks/SIMD so staging starts at T=8192 (where it is worth ~15%)
     rather than T=4096 (where it measured 10.18 vs 10.02, slightly harmful).

Measured with run_perftest, three interleaved rounds with continuous
interference monitoring (all clean), before = main merge-base:

    T=512  1.02x   T=1024 1.20x   T=2048 1.39x
    T=4096 1.36x   T=8192 1.62x   T=16384 1.59x

All three scale paths (e8m0+transposed, e8m0 non-transposed, fp32) are
bit-identical to the prior commit across M = 1 / 127 / 128 / 4096 / 16384 /
65536. test_quant.py exit=0 (400 items, max error 9.9e-5).

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 8, 2026 10:14
@yzhou103

yzhou103 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author
image

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

The new gfx1250 TDM path can issue async loads with an out-of-range window due to the intentionally oversized grid, risking out-of-bounds global memory reads.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment on lines +382 to +386
int64_t x0;
int32_t y0;
resolve(win_g0, x0, y0);

auto w = opus::make_tdm<TdmWindow>(
Comment on lines +1186 to +1190
// per ring slot at 256 against 4 at 64) and the two together measured 22%
// slower at T=8192. Where staging does not apply, the wide block is worth
// 4-6% from T=512 to T=4096.
static constexpr int32_t kBlkTuned = 256;
const int simds_bs = static_cast<int>(get_num_cu_func()) * 4;
…oup-quant

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

# Conflicts:
#	csrc/kernels/quant_kernels.cu
Copilot AI review requested due to automatic review settings September 9, 2026 13:53

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.

🔵 Needs a closer look

The gfx1250 TDM staging path can issue out-of-bounds DMA loads in intentionally over-provisioned blocks because the window-origin bounds check is computed but not enforced before creating the TDM descriptor.

Review details

Suppressed comments (1)

csrc/kernels/quant_kernels.cu:406

  • In the gfx1250 TDM staging path, the grid is intentionally over-provisioned (see host-side comment) so some blocks correspond to group IDs beyond the valid range. Those blocks should return before creating the TDM descriptor / issuing async_loads; currently resolve(win_g0, ...) is called but its boolean result is ignored, so out-of-range blocks can still form a window origin and DMA from OOB global memory.
        int64_t x0;
        int32_t y0;
        resolve(win_g0, x0, y0);

  • Files reviewed: 2/2 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants