Skip to content

feat(ck-tile): rowcolquant GEMM TE to dispatcher bridge - #9979

Closed
ozturkosu wants to merge 10 commits into
developfrom
users/muozturk/ck/rowcolquant_bridge_block_scale
Closed

ozturkosu wants to merge 10 commits into
developfrom
users/muozturk/ck/rowcolquant_bridge_block_scale

Conversation

@ozturkosu

Copy link
Copy Markdown
Contributor

Motivation

Adds the gemm_rowcolquant block-scale op (per-row scale on A, per-column scale on B) to
the TileEngine -> Dispatcher bridge (codegen -> C ABI -> Python), so callers can generate,
build, and launch row/col-quant GEMM at parity with Old-TE without writing C++. Uses the
direct-launch, registry-bypass pattern (launch takes ck_tile::QuantGemmHostArgs with
scale pointers the registry backend can't express).

Test Plan

  • CPU unit tests: python3 -m pytest dispatcher/tests/test_rowcolquant_bridge.py -v
  • On-GPU numeric verify vs fp32 reference (tester, serialized on MI300X).
  • clang-format-18 on the ctypes lib.

Test Result

  • CPU unit tests: 9 passed (name prefix, byte-exact codegen<->utils name contract,
    codegen-JSON projection, fp8/bf8-rcr scope, problem defaults).
  • Build (gfx950, hipcc 7.2): fp8 and bf8 compile; fp8 links to a loadable .so
    exporting all 6 dispatcher_* symbols. Name parity byte-exact
    (gemm_rowcolquant_{fp8,bf8}_rcr_compv3_cshuffle_intrawave_16x64x256_1x4x1_16x16x128).
  • clang-format-18: clean on gemm_rowcolquant_ctypes_lib.cpp.
  • On-GPU numeric + perf parity: pending (draft) — posted as a comment after the GPU run.

Scope / known limitations (exact Old-TE match)

  • dtype fp8/bf8, layout rcr only — the exact set gemm_quant_rowcol.cpp registers.
  • Per-row float scale [M,1] on A, per-col float scale [1,N] on B (QK_A=QK_B=1), applied in
    the CShuffle epilogue; QuantType::RowColQuant. Accumulator slot = AccDataType (float),
    narrowed to half only in the epilogue.
  • k_batch == 1 (split-K not registered for rowcol in Old-TE). Non-tile-multiple M/N/K
    rejected by IsSupportedArguments.

Performance parity

Pending — serialized A/B vs Old-TE gemm_rowcolquant, added as a comment after the GPU run.


Related PRs / references (TileEngine -> Dispatcher GEMM bridge series): #8997 (regular), #9000 (grouped), #9028 (stream-K), #8887 (fp8/bf8/int8), #9305 (multi-ABD), #9306 (batched), #9307 (preshuffle), #9308 (multi-D), #9328 (batched-contraction), #9329 (mx_gemm), #9978 (tensor_quant). Sibling block-scale quant bridges (this series): abquant, aquant, bquant, rowcolquant, tensor_quant.

Registry-bypass, single-kernel-per-.so, direct-launch ctypes bridge for the
RowColQuant block-scale GEMM (per-row scale of A + per-column scale of B),
mirroring the grouped_gemm_bquant bridge pattern.

Scope matches Old-TE gemm_quant_rowcol.cpp exactly: fp8/bf8, rcr layout
(RowMajor A / ColMajor B / RowMajor C), compv3 pipeline, intrawave scheduler.
Uses QuantGemmHostArgs with AQ/BQ float scale vectors (QK_A=QK_B=1, no quant
groups). Arch is derived at runtime and throws on unknown (gfx942+gfx950).

Files:
  codegen/unified_gemm_rowcolquant_codegen.py
  bindings/ctypes/gemm_rowcolquant_ctypes_lib.cpp
  python/gemm_rowcolquant_utils.py
  python/rowcolquant_selftest.py
  codegen/codegen_common.py  (make_rowcolquant_kernel_name helper)
  bindings/ctypes/CMakeLists.txt  (dispatcher_gemm_rowcolquant_lib target)
@ozturkosu

Copy link
Copy Markdown
Contributor Author

Reviewer pass (rowcolquant bridge)

Reviewed against Old-TE source of truth example/ck_tile/38_block_scale_gemm/gemm_quant_rowcol.cpp + run_gemm_quant_example.inc on branch users/muozturk/ck/rowcolquant_bridge_block_scale (HEAD 6810179). CPU unit test passes 9/9; clang-format-18 -style=file clean; all 7 files are additive and the shared codegen_common.py edit is append-only (bquant helpers untouched).

Overall this is in good shape and closely mirrors Old-TE. The core correctness traps are handled right. Findings below.

Blocking

None. Parity, correctness, arch handling, registry-bypass, name contract, and CI test are all met (see notes).

Should-fix

  • dispatcher/python/rowcolquant_selftest.py:57-67 + gemm_rowcolquant_utils.py:312-346 — the numeric self-test does not actually validate. RowColQuantGpuGemmRunner.run passes float32 A/B straight through, but the ctypes lib reinterprets the host pointer as const ADataType* (fp8/bf8 = 1 byte) and copies only elements_to_bytes<fp8>(M*K) = M*K bytes from a 4*M*K-byte float32 buffer. The kernel therefore reads the first quarter of the buffer as raw fp8 bytes -> garbage. The _reference also compares against a full-precision float32 matmul with no fp8 rounding. Net: the "max relative error" line is meaningless. Either narrow A/B to real fp8/bf8 bytes before the call (and round the reference the same way) or clearly mark the run path as a smoke test only.
  • **gemm_rowcolquant_utils.py:362-375 (_detect_gpu_arch) + 60 (_DEFAULT_GFX_ARCH="gfx950").** On any detection failure this silently falls back to gfx950, which is a soft "default to a specific arch." The C++ runtime guard (ctypes lib:140-155) correctly rejects unknown archs, so this won't produce wrong results, but a gfx942box with a flaky enumerator would silently build gfx950 objects. Prefer raising, or at leastlog.warning` the fallback, to stay consistent with the "never default an arch" policy.

Nit

  • codegen/unified_gemm_rowcolquant_codegen.py:300 — ComputeDataType passed as ADataType, whereas Old-TE passes void (AComputeDataType = void since IS_FP8BLOCKSCALE is false for rowcol). These are functionally identical for the registered scope because A==B (fp8/fp8, bf8/bf8) and mixed_prec_compute_type_t<void,A,B> == mixed_prec_compute_type_t<A,A,B> when A==B. No behavior change, but passing void (or a comment) would track Old-TE exactly and avoid a surprise if A!=B variants are ever added.
  • gemm_rowcolquant_ctypes_lib.cpp:161-168 — non-packed strides are rejected. Fine and clearly documented, but note this means stride_B==K is required for the ColMajor B (leading dim K), which is correct for rcr; just calling out that any caller supplying padded leading dims will hit the hard reject.
  • CMake CK_TILE_ROWCOLQUANT_GFX_ARCH default gfx942 (CMakeLists:296-304) mirrors the merged bquant lib and is only a -DGFX_ARCH passthrough string (the source does not static_assert on it, unlike mx_gemm), so it's harmless — consistent with the bquant precedent.

Confirmed correct

  • Parity: exactly {fp8,rcr} + {bf8,rcr}, A=fp8/bf8, B=fp8/bf8, C=half_t, Acc=float; rcr = RowMajor A / ColMajor B / RowMajor C, AQ RowMajor [M,1], BQ ColMajor [1,N] — matches the a_layout=="R" && b_layout=="C" branch. No missing/extra dtype or layout.
  • Correctness: QuantGemmHostArgs with QK_A=QK_B=1, stride_AQ=stride_BQ=1, both scale ptrs set; QuantType::RowColQuant. The accumulator-slot trap is handled: GemmRowColTensorQuantPipelineProblem<ADataType, BDataType, AccDataType, AccDataType, ...> — the CDataType_ slot is float and the real narrowing to half happens in the CShuffleEpilogue, matching run_gemm_quant_example.inc lines 126-133 / 236-252.
  • Arch: runtime hipGetDeviceProperties guard throws on non-gfx942/gfx950; no compile-time gfx942 default in the launch path (matches merged grouped_gemm_bquant).
  • Registry-bypass: direct SelectedKernel::launch(QuantGemmHostArgs, stream_config), dispatcher/include intentionally excluded — matches the bquant template.
  • Name contract: Python .name and codegen KERNEL_NAME both delegate to make_rowcolquant_kernel_name; test locks the contract byte-exact.
  • CI: tests/test_rowcolquant_bridge.py present and meaningful (name prefix, tile encoding, byte-exact contract, codegen-JSON projection, fp8/bf8 rcr scope).
  • split-K: k_batch>1 relies on IsSupportedArgument to reject (Old-TE registers only k_batch=1 for rowcol) — correct.

@therock-pr-bot

therock-pr-bot Bot commented Jul 24, 2026 •

Copy link
Copy Markdown

✅ All Checks Passed — Ready for Review

Check Status Details
📝 PR Title/Description ✅ Pass —
⛔ Forbidden Files ✅ Pass —
🧪 Unit Test ✅ Pass —
🔎 pre-commit ✅ Pass —
🚫 Draft PR 🔜 To Be Enabled —
🚩 Feature Flag 🔜 To Be Enabled —
📊 Code Coverage 🔜 To Be Enabled —
🤖 therock-pr-bot ✅ Pass —

🎉 All checks passed! This PR is ready for review.

📖 Need help? See the Policy FAQ for details on every check and how to fix failures.

@ozturkosu
ozturkosu requested a review from Copilot July 24, 2026 23:08
@therock-pr-bot

Copy link
Copy Markdown

🎉 All checks passed! This PR is ready for review.

Round-2 review fixes for the gemm_rowcolquant dispatcher bridge:

- Self-test now feeds REAL 1-byte-per-element fp8/bf8 bytes to the kernel
  (via ml_dtypes e4m3/e5m2 encode) and rounds the numpy reference through
  the same quantize->dequantize, so "max relative error" is a genuine
  numeric check with a pass/fail tolerance. When ml_dtypes is unavailable
  the run is clearly marked SMOKE-ONLY (no correctness claim).
- _detect_gpu_arch now raises instead of silently falling back to gfx950,
  so a flaky enumerator on a gfx942 box can never build gfx950 objects.
  Explicit gfx_arch= still short-circuits detection.
- Add encode_fp8_bytes / quantize_dequantize_fp8 / fp8_encoding_available
  helpers plus CPU unit tests locking their byte-width and rounding.
@ozturkosu

Copy link
Copy Markdown
Contributor Author

Round-2 fixes

Addressed both should-fix review items on the gemm_rowcolquant bridge (commit 88dae56c43):

#1 — Self-test now actually validates.
The runner previously passed float32 A/B straight through while the ctypes lib reinterprets the pointer as const fp8_t* (1 byte/elem), reading a quarter of the buffer as garbage, and compared against a full-precision numpy reference. Fixed:

  • Added encode_fp8_bytes() (float32 → real 1-byte-per-element OCP fp8 e4m3 / bf8 e5m2 via ml_dtypes) and quantize_dequantize_fp8().
  • rowcolquant_selftest.py now feeds real fp8/bf8 bytes to the kernel and rounds the numpy reference through the same quantize→dequantize, so max relative error is a genuine numeric check with a pass/fail tolerance (0.15).
  • If ml_dtypes is unavailable, the --run path is clearly marked SMOKE-ONLY (kernel launches, timing reported, no correctness claim) and returns without a false pass.

#2 — No more silent arch fallback.
_detect_gpu_arch() now raises RuntimeError when rocm_agent_enumerator fails or returns no usable arch, instead of silently defaulting to gfx950 (which would let a flaky enumerator on a gfx942 box build gfx950 objects). Callers that know their target still pass gfx_arch= to short-circuit detection entirely.

Also added 3 CPU unit tests locking the encoder byte-width (1 byte/elem) and fp8-vs-bf8 rounding.

Tests: pytest dispatcher/tests/test_rowcolquant_bridge.py -v → 12 passed (9 original + 3 new).

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.

Pull request overview

This PR adds a RowColQuant (per-row A scale + per-col B scale) GEMM path to the CK TileEngine → Dispatcher bridge, enabling codegen → per-kernel .so build → Python ctypes launch for the fp8/bf8 rcr scope using the direct-launch (registry-bypass) pattern.

Changes:

  • Adds a dedicated RowColQuant codegen script and shared kernel-name constructor to keep codegen↔Python naming byte-exact.
  • Introduces a per-kernel ctypes .so (direct SelectedKernel::launch(QuantGemmHostArgs)) plus Python utilities/runner for building and launching.
  • Adds CPU-only unit tests and a Python self-test script for build/run validation.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
projects/composablekernel/dispatcher/tests/test_rowcolquant_bridge.py New CPU-only unit tests that lock naming contract and config projection for RowColQuant.
projects/composablekernel/dispatcher/python/rowcolquant_selftest.py New self-test CLI to build and optionally run/verify default fp8/bf8 RowColQuant kernels.
projects/composablekernel/dispatcher/python/gemm_rowcolquant_utils.py New Python build utilities + ctypes wrapper + high-level runner for RowColQuant.
projects/composablekernel/dispatcher/codegen/unified_gemm_rowcolquant_codegen.py New code generator producing per-kernel headers defining SelectedKernel + KERNEL_NAME.
projects/composablekernel/dispatcher/codegen/codegen_common.py Adds make_rowcolquant_kernel_name() shared helper to enforce byte-exact naming across layers.
projects/composablekernel/dispatcher/bindings/ctypes/gemm_rowcolquant_ctypes_lib.cpp New ctypes C ABI implementation doing host-pointer allocation/copy and direct kernel launch.
projects/composablekernel/dispatcher/bindings/ctypes/CMakeLists.txt Adds a CMake target for the RowColQuant ctypes library when a generated kernel header is present.

Round-2 fix: default_fp8_config/default_bf8_config now derive warp_tile_k
from the target arch via _warp_tile_k_for(), mirroring ck_tile
get_k_warp_tile<PrecType,16>(): fp8/bf8 -> 128 on gfx950, 32 on gfx942.

The previous hardcoded warp_tile_k=128 is valid only on gfx950; on gfx942
there is no 16x16x128 fp8/bf8 warp-gemm, so the kernel compiles but
silently outputs all-zeros. This exact bug was confirmed on the sibling
tensor_quant bridge GPU tester, where warp_tile_k=32 (Old-TE's gfx942
value) is bit-exact.

Also updates the byte-exact kernel .name, the codegen default-config note,
and adds CPU tests asserting 32 on gfx942 / 128 on gfx950.
@ozturkosu

Copy link
Copy Markdown
Contributor Author

Round-2 fix: arch-aware warp_tile_k

default_fp8_config / default_bf8_config previously hardcoded warp_tile_k=128 (the ..._16x16x128 name). That is valid only on gfx950: on gfx942 there is no valid 16x16x128 fp8/bf8 warp-gemm, so the kernel compiles but silently outputs all-zeros.

This exact bug was just confirmed on the sibling tensor_quant bridge GPU tester: with warp_tile_k=32 (the correct gfx942 value; Old-TE uses 16x16x32 on gfx942) the output is bit-exact, and with 128 it is all-zeros.

Fix: warp_tile_k is now arch-derived via a new _warp_tile_k_for(variant, gfx_arch) helper that mirrors ck_tile::get_k_warp_tile<PrecType, 16>():

arch fp8/bf8 warp_tile_k
gfx950 128
gfx942 32

Updated: codegen default-config note, the byte-exact .name (now 16x16x32 on gfx942), and the CPU tests (assert 32 on gfx942 / 128 on gfx950).

Verification:

  • python3 -m pytest dispatcher/tests/test_rowcolquant_bridge.py -v -> 17 passed.
  • Smoke-recompiled the gfx942 fp8 kernel with warp_tile_k=32 via hipcc -> .so builds & links (..._16x16x32_gfx942.so).

The rest of the op (fp8/bf8 rcr, per-row A + per-col B scale, AccDataType accumulator, round-2 fp8-encode self-test + arch-raise) is unchanged.

@ozturkosu

Copy link
Copy Markdown
Contributor Author

GPU validation (rowcolquant, MI300X/gfx942)

Node ctr-cx66-mi300x-13 (gfx942, MI300X), HEAD 16ec7c4043, ROCm 7.2. fp8/bf8, rcr, per-row scale A + per-col scale B, k_batch=1.

Arch trap confirmed & round-2 fix works. The gfx942 default correctly resolves to warp_tile_k=32 (...16x16x32). I also built the wrong ...16x16x128 on gfx942 as a control: it produces all-zeros (max_rel≈1.0) — exactly the documented trap. The Python default_{fp8,bf8}_config("gfx942") path avoids it.

Functional correctness (quant→dequant numpy ref, gate 2e-2)

Encoded with FNUZ fp8/bf8 (gfx942 has no CK_USE_OCP_FP8).

dtype shapes tested (M,N,K) max_rel PASS/FAIL
fp8 256³, 512³, 1024³, 2048³, 1024×2048×4096 4.88e-4 PASS
bf8 256³, 512³, 1024³, 2048³, 1024×2048×4096 4.88e-4 PASS

Bridge output is bit-identical to Old-TE (gemm_quant_rowcol, same 16x16x32 instance compiled against the same ctypes lib): max_rel bridge-vs-OldTE = 0.0 on the finite set. Old-TE's own CPU -verify=1 also passes. 100% function parity.

Performance A/B parity vs Old-TE

Identical external timing BOTH sides: same standalone harness, same CK stream_config{warmup=50, repeat=100, gpu_timer, flush_cache=true, rotating_count=1000}, interleaved per (kernel,shape), min of 2 reps.

shape (M,N,K) dtype bridge ms OldTE ms gap%
256,256,512 fp8 0.00380 0.00387 -1.67
512,512,512 fp8 0.00444 0.00433 +2.40
1024,1024,1024 fp8 0.01257 0.01260 -0.26
2048,2048,2048 fp8 0.06461 0.06462 -0.03
4096,4096,4096 fp8 0.41702 0.41704 -0.00
1024,2048,4096 fp8 0.06271 0.06274 -0.04
3840,4096,2048 fp8 0.21365 0.21293 +0.34
256,256,512 bf8 0.00387 0.00380 +2.01
512,512,512 bf8 0.00432 0.00448 -3.40
1024,1024,1024 bf8 0.01254 0.01255 -0.04
2048,2048,2048 bf8 0.06505 0.06501 +0.06
4096,4096,4096 bf8 0.41734 0.41723 +0.03
1024,2048,4096 bf8 0.06286 0.06332 -0.73
3840,4096,2048 bf8 0.21289 0.21257 +0.15

median gap = -0.02%, 100% within ±15% (14/14), max |gap| = 3.40% (small shapes only — measurement noise). No >15% rows.

Verdict

PASS — functional + performance at 100% Old-TE parity on gfx942 for fp8 and bf8. Ready from a validation standpoint.

Non-blocking note for a coder

  1. rowcolquant_selftest.py main() calls default_{fp8,bf8}_config() without the arch arg, so it builds the gfx950 16x16x128 tile even under --arch gfx942 (produces all-zeros). It should thread the resolved arch into the config (default_fp8_config(args.arch)).
  2. gemm_rowcolquant_utils.encode_fp8_bytes/quantize_dequantize_fp8 hardcode OCP float8_e4m3/e5m2. On gfx942 (ck_tile::fp8_t=e4m3fnuz) this silently yields NaN/garbage in the self-test verify. It should pick FNUZ vs OCP by arch (mirror CK_USE_OCP_FP8). Kernel itself is correct with FNUZ.

Round-3 GPU-tester findings (self-test/reference path only; shipping
kernel is confirmed Old-TE bit-exact + perf parity):

1. Arch not threaded: rowcolquant_selftest main() called
   default_fp8_config()/default_bf8_config() with no arch, so --arch gfx942
   still built the gfx950 16x16x128 tile -> all-zeros. Resolve arch once
   (arg or autodetect) and pass it to both configs and the encoders.

2. FP8 encoding was OCP-only: encode_fp8_bytes/quantize_dequantize_fp8
   hardcoded OCP float8_e4m3/e5m2, so the numpy reference silently NaN'd on
   gfx942 (which uses FNUZ fp8_t). Select FNUZ vs OCP by arch, mirroring
   CK_USE_OCP_FP8 (gfx942->FNUZ, gfx950/gfx12->OCP), via ml_dtypes
   e4m3fnuz/e5m2fnuz. SMOKE-ONLY fallback when ml_dtypes is absent kept.

Add tests asserting the arch threads into the default config and that the
encoder picks FNUZ on gfx942 / OCP on gfx950. 23/23 pass.
@ozturkosu

Copy link
Copy Markdown
Contributor Author

Round-3: self-test arch + FNUZ fixes

Addresses the two non-blocking self-test/reference bugs the GPU tester flagged (the shipping kernel itself is confirmed Old-TE bit-exact + perf within ±15% — these were reference-path-only issues). Fixed in 5d8ad1930a.

  1. Arch not threaded into the default config. rowcolquant_selftest.py main() called default_fp8_config() / default_bf8_config() with no arch, so --arch gfx942 still built the gfx950 16x16x128 tile → all-zeros. Now the arch is resolved once (explicit --arch or autodetect) and threaded into both configs and the encoders.

  2. FP8 encoding was OCP-only. encode_fp8_bytes / quantize_dequantize_fp8 hardcoded OCP float8_e4m3 / e5m2, so on gfx942 (where ck_tile::fp8_t is FNUZ) the numpy reference silently NaN'd. The encoder now selects FNUZ vs OCP by arch, mirroring CK_USE_OCP_FP8 (gfx942 → e4m3fnuz/e5m2fnuz, gfx950/gfx12 → OCP), via ml_dtypes. The SMOKE-ONLY fallback when ml_dtypes is absent is preserved.

Tests: added TestArchThreadedIntoConfig (arch reaches warp_tile_k + kernel name) and TestFp8EncodingFlavourByArch (encoder picks FNUZ on gfx942 / OCP on gfx950; FNUZ round-trip stays finite). python3 -m pytest dispatcher/tests/test_rowcolquant_bridge.py -v → 23/23 pass.

@ozturkosu
ozturkosu marked this pull request as ready for review July 25, 2026 06:02
@ozturkosu
ozturkosu requested a review from a team as a code owner July 25, 2026 06:02
@ozturkosu

Copy link
Copy Markdown
Contributor Author

Parity results — rowcolquant bridge vs Old-TE (gfx942 / MI300X)

Full default_config apples-to-apples A/B sweep, bridge (Dispatcher) vs Old Tile-Engine, 28-shape suite.

Verdict: AT PARITY.

comparable median mean ≤5% ≤10% ≤15% >15% survivors
48 +0.28% −0.02% 46/48 48/48 48/48 0

(negative = bridge faster). Coverage: 56 total = 48 comparable + 8 N/A (M=1 unsupported on both sides).

Fairness (all enforced): warmup=50/repeat=100 both sides; Old-TE via develop CMake (real TE -mllvm flags); fresh bridge .so from PR tip + stale-guard; A/B interleaved per (stem,shape) same GPU; matched [−5,5] fp8 input data both sides; every |gap|>15% re-measured standalone median-of-3.

Note: Old-TE's own CPU reference-verify reports FAIL on all 48 rows here — a pre-existing Old-TE reference/tolerance quirk for rowcol (identical device kernel runs on both sides; bridge output is non-zero). It is not a bridge issue and does not affect timing parity.

Correctness gate: arch-aware warp_tile_k 16x16x32 on gfx942, all bridge outputs non-zero.

@ozturkosu

Copy link
Copy Markdown
Contributor Author

Fresh MI350X / gfx950 fair parity re-verify — 2026-07-27

Verdict: AT PARITY. Full apples-to-apples bridge-vs-Old-TE sweep on 1× MI350X (gfx950), ROCm 7.2.0 / HIP 7.2.26015, 28-shape suite × all dtype×layout.

bridge (PR) #cases median gap % mean gap % % within ±15% verdict
abquant (#9981) 294 +0.04 +0.59 100.0% AT PARITY
bquant (#9982) 96 −0.69 −1.00 100.0% AT PARITY
aquant (#9980) 384 −1.65 −2.03 100.0% AT PARITY
tensor_quant (#9978) 48 +1.67 +2.68 100.0% AT PARITY
rowcolquant (#9979) 48 +2.09 +2.61 100.0% AT PARITY

gap% = (bridge − oldTE)/oldTE × 100 (+ = bridge slower). 870 comparable timing rows across all 5 bridges, 0 gaps >15% (nothing required standalone re-measure), 0 all-zeros/silent-fail rows.

This PR (rowcolquant): 48 comparable cases, median +2.09%, 100% within ±15%. Arch-aware warp_tile_k (gfx950→128) confirmed correct; 0 all-zeros/silent-fail rows.

Fairness enforced: warmup/repeat 50/100 both sides; Old-TE built via develop CMake real TE -mllvm flags; -amdgpu-coerce-illegal-types=1 dropped on the bridge to stay matched (roc-7.2 clang rejects it and develop's check_cxx_compiler_flag fails it → Old-TE omits it too); stale-.so guard; fresh /tmp build from branch tip; A/B interleaved per (stem,shape); every |gap|>15% row re-measured standalone median-3.

Coverage: 8 N/A rows (small-M M≤64 unsupported — Old-TE also rejects). N/A = genuine coverage limits identical on both sides, not perf gaps.

Per-bridge CSVs + consolidated report: https://gist.github.com/ozturkosu/f33d8f9bb5b503395334c5051d3c39c9 (rowcolquant_parity_July27.csv).

ThruptiRajLakshmanaGowda and others added 2 commits July 29, 2026 13:30
Replace verbose inline if(!= hipSuccess){cleanup();return -1;} guards
with HIP_CHECK(), which was defined but never invoked. Also update the
HIP_CHECK definition to call cleanup() on error (matching the grouped
variant), and expand the elements_to_bytes comment to the canonical
3-line form explaining packed types.

Co-Authored-By: Claude <noreply@anthropic.com>
…ypes_lib

Remove trailing alignment spaces before backslash continuations in
HIP_CHECK macro and remove manual column-alignment spaces in hipMalloc
and hipMemcpy call sites.

Co-Authored-By: Claude <noreply@anthropic.com>
ozturkosu and others added 3 commits July 29, 2026 15:59
- run(): reject A/B whose dtype is not 1 byte (fp8/bf8) with a clear
  TypeError, and coerce AQ/BQ to float32. Prevents float32/float16 A/B
  from being silently reinterpreted as fp8 on the device.
- self-test: the smoke-only path (no ml_dtypes) now feeds correctly
  sized 1-byte buffers instead of float32, so it satisfies the new
  dtype guard and no longer implies a bogus fp8 encoding. The verify
  path already used real fp8 bytes via encode_fp8_bytes().
- codegen: skip unsupported layouts (only rcr) with a warning in
  _build_specs; downstream indexes layout[0/1/2] + ROWCOLQUANT_LAYOUT_TO_CK
  and would otherwise raise IndexError/KeyError.
- CMake: skip dispatcher_gemm_rowcolquant_lib when no generated kernel
  header exists instead of building it kernel-less (the ctypes .cpp
  requires a force-included header under CK_TILE_SINGLE_KERNEL_INCLUDE).
  Matches the sibling bridge targets.
@ThruptiRajLakshmanaGowda

Copy link
Copy Markdown
Contributor

LGTM!

@ozturkosu

Copy link
Copy Markdown
Contributor Author

Bridge vs Old-TE parity results

Full data (secret gist): https://gist.github.com/ozturkosu/0387019a2f18cdb43c494286bc755d1f

AT PARITY. gfx950 (MI355X), 48 cases, median +2.09%, 100% within +/-15%.

Contains: consolidated parity summary + per-PR CSV result file(s).

Fair A/B methodology: warmup=50/repeat=100 both sides, interleaved per (stem,shape), Old-TE built via CK CMake with real TE -mllvm flags, stale-.so guard, every |gap|>15% row re-measured standalone median-3. ROCm 7.2.

@ozturkosu

Copy link
Copy Markdown
Contributor Author

Superseded by #10439, which combines all 5 block-scale quant GEMM TE→dispatcher bridge ops (tensor_quant, rowcolquant, aquant, abquant, bquant) into a single PR onto develop. Closing in favor of #10439.

@ozturkosu ozturkosu closed this Aug 10, 2026
ozturkosu added a commit that referenced this pull request Sep 1, 2026
#10439)

JIRA ID : AICK-2090

## Summary

This PR lands the five block-scale quantized GEMM TileEngine→Dispatcher
bridges as a single
branch on top of `develop`, so the dispatcher can run every block-scale
quant GEMM that the
Tile-Engine supports. Each op differs in *how* the scales are laid out
on the A and B operands:

- **tensor_quant** (#9978) — **per-tensor** quantization: a single
scalar scale for the whole A
matrix and a single scalar scale for the whole B matrix. The simplest
scheme; the kernel
  dequantizes with two scalars in the epilogue.
- **rowcolquant** (#9979) — **per-row / per-column** quantization: one
scale per row of A and one
scale per column of B (a length-M vector for A, a length-N vector for
B). Common for
activation×weight GEMMs where each output row/column has its own dynamic
range.
- **aquant** (#9980) — **A block-scale** quantization: A is split into
blocks along K
(`quant_group_k`) and each block carries its own scale; B is the plain
low-precision operand.
Supports fp8/bf8 and the packed i4 variants, and both the decode (`mem`)
and preshuffle
  (`compv3`) pipelines across the rcr/rrr/crr/ccr layouts.
- **abquant** (#9981) — **A+B block-scale** quantization: A is
block-scaled along K
(`aquant_group_k`) and B is block-scaled along N and K
(`bquant_group_n`, `bquant_group_k`).
  Includes the gfx950 **EightWaves** path and the B-preshuffle path.
- **bquant** (#9982) — **B block-scale** quantization: B is split into
blocks and each block
carries its own scale (A is the plain operand). Includes the **MX /
microscaling** variants
(e8m0 block scales: bf16×bf16, bf16×bf8, bf16×fp4) and the
**grouped**-GEMM form.

Each op is a registry-bypass, single-kernel-per-`.so` ctypes bridge that
builds its kernel at
runtime through the Python codegen path. Supersedes / combines
#9978–#9982.

## Motivation

The five bridges started as sibling draft PRs that all touch the same
three shared files
(`bindings/ctypes/CMakeLists.txt`, `tests/CMakeLists.txt`,
`codegen/codegen_common.py`). Landing
them together resolves the cross-PR merge conflicts once and gives a
single, fully-tested branch
instead of five that keep colliding.

## Design

- **Registry-bypass ctypes bridges** — each op appends its own
target/test/helper to the shared
  files, so the merges are purely additive.
- **Arch safety** — get-arch-or-`FATAL_ERROR` (no silent gfx942
default).
- **abquant clang guard** — `gemm_abquant_utils.py` gates
`-amdgpu-coerce-illegal-types=1` on the
local clang accepting it. clang≥22 (ROCm 7.2) removed the flag and
aborts the abquant `.so`
compile; it only tightens EightWaves register allocation on older
toolchains and the kernel is
bit-accurate without it. This lets abquant actually build+run on ROCm
7.2 instead of self-skipping.
- **aquant pipeline/preshuffle decoupling** (latest commit) — the aquant
codegen used to hard-couple
the pipeline to the preshuffle flag (`compv3 ⇔ preshuffle`), so it could
not emit the
`compv3 + APreshuffleQuant=false` family: 616 valid kernels the
Tile-Engine builds. I added an
independent `pipeline` field so `compv3` is selectable without
preshuffle, giving the bridge full
coverage of the Tile-Engine's aquant kernel space. This is a
Python-codegen-only change (the C++
template already exposes `APreshuffleQuant` as a separate compile-time
Traits flag).

## How this was tested

**1. CPU unit / bridge tests** (host-only, run in CI):
`test_{tensor_quant,rowcolquant,aquant,abquant,bquant}_bridge.py`,
`test_quant_bridge_shared.py`, `test_codegen_common.py`, and the per-op
self-tests. These cover
config construction, kernel-name generation, codegen output, and the
dtype/layout guards.

**2. On-device GPU correctness** (MI350X / gfx950, ROCm 7.2) — bridge
output checked against an
independent CPU reference, all passing:

- [x] tensor_quant — 3/3 (fp8, bf8, not-all-zeros)
- [x] rowcolquant — 3/3
- [x] aquant — 3/3 (incl. i4)
- [x] abquant — 3/3 (fp8, bf8, not-all-zeros) after the clang guard
above
- [x] bquant — 6/6 (fp8/bf8, i4, MX bf16bf16 / bf16bf8 / bf16fp4)

**3. CI regression** — build + tests green across gfx942, gfx950,
gfx90a, gfx11, gfx1201, gfx1250,
plus the static-analysis / clang-tidy / pre-commit gates.

**4. Comprehensive same-kernel performance parity vs the Tile-Engine.**
This is the main validation.
For every kernel the Tile-Engine builds from its **full
`default_config`** (the whole tuning space,
not the small CI subset), I construct the **byte-identical** kernel on
the dispatcher bridge and run
the two interleaved A/B with the **same warmup=50 / repeat=100 and the
same TE `-mllvm` flags**, on
**both MI300 (gfx942) and MI350 (gfx950)**. Every `>15%` outlier is
re-measured standalone
(median-of-3 on an isolated GPU) before it is trusted, to rule out
contention artifacts. Result:
the bridges are **at performance parity with the Tile-Engine** — per-op
medians land within a few
percent and the large majority of kernels are within ±15%; the handful
of survivors are narrow,
mostly bridge-*faster* differences at awkward shapes, not regressions.

The aquant coverage fix above was validated at full scale on gfx942:
**1232/1232 build, 1200/1232
GPU-correct, performance median +0.47% (95.8% within ±15%).** The 32
correctness failures are a
**pre-existing shared CK kernel bug** — a `192-tile + 2x2x1 + 32x32x32 +
row-major-A` cluster that
the **Tile-Engine also fails its own verification on** (byte-identical
kernel) — so it is not
introduced by this PR; I'll track it as a separate upstream issue.

**5. bquant correctness.** The Tile-Engine's bquant benchmark baseline
does not compile at this head
(a pre-existing upstream codegen defect, unrelated to the bridge), so
there is no Tile-Engine
performance baseline for bquant — it is validated by **correctness
only**: every buildable bquant
kernel passes on-device against a CPU reference (MX variants are
correctly gfx950-only).

---

Supersedes / combines #9978, #9979, #9980, #9981, #9982.


---
> **Note — abquant layout coverage:** the abquant bridge in this PR
currently supports the `rcr` layout. Full `ccr`/`crr`/`rrr` layout
support is added in the follow-up PR #11477 (stacked on this branch, to
merge after this one).

---------

Co-authored-by: Muhammed Emin Ozturk <3836908+ozturkosu@users.noreply.github.com>
Co-authored-by: ThruptiRajLakshmanaGowda <Thruptiraj.LakshmanaGowda@amd.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants