Skip to content

feat(ck-tile): tensor_quant GEMM TE to dispatcher bridge - #9978

Closed
ozturkosu wants to merge 11 commits into
developfrom
users/muozturk/ck/tensor_quant_bridge_block_scale
Closed

ozturkosu wants to merge 11 commits into
developfrom
users/muozturk/ck/tensor_quant_bridge_block_scale

Conversation

@ozturkosu

Copy link
Copy Markdown
Contributor

Motivation

The TileEngine -> Dispatcher bridge had no path for the gemm_tensor_quant block-scale
op (single per-tensor scale on A and B). This PR adds a complete bridge (codegen -> C ABI ->
Python) so callers can generate, build, and launch tensor-quant GEMM at parity with the
legacy Tile Engine version without writing any C++.

It follows the direct-launch, registry-bypass pattern used by the other block-scale
quant bridges, because the launch takes ck_tile::QuantGemmHostArgs (with quant scale
pointers) that the single-pointer registry backend cannot express.

Test Plan

  • CPU-only unit tests: python3 -m pytest dispatcher/tests/test_tensor_quant_bridge.py -v
  • On-GPU numeric verify over dtype x layout vs an 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 round-trip, fp8/bf8-rcr scope, problem defaults).
  • Build (gfx950, hipcc 7.2): both fp8 and bf8 kernels compile AND link into loadable
    .sos; end-to-end codegen -> hipcc -> .so -> ctypes-load succeeds; .so-reported kernel
    name matches config .name.
  • kernel-name parity: runner name == config name == codegen KERNEL_NAME (byte-exact),
    e.g. gemm_tensor_quant_fp8_rcr_compv3_cshuffle_intrawave_16x64x256_1x4x1_16x16x128.
  • clang-format-18: clean on gemm_tensor_quant_ctypes_lib.cpp.
  • On-GPU numeric + perf parity: pending — will be posted as a comment after the
    serialized GPU run (draft until then).

Scope / known limitations (exact Old-TE match)

  • dtype fp8/bf8 only and layout rcr only (RowMajor A / ColMajor B / RowMajor C)
    -- exactly the two LUT entries gemm_quant_tensor.cpp registers ({fp8,tensor},
    {bf8,tensor}); the only combo run_gemm_quant_example.inc dispatches for these types.
  • Per-tensor scale: one scalar float scale each for A and B (QK_A=QK_B=1), applied in
    the CShuffle epilogue; matches QuantType::TensorQuant.
  • k_batch == 1 launch semantics (split-K not registered for tensor-quant in Old-TE).
  • Non-tile-multiple M/N/K are rejected by the kernel's IsSupportedArguments.

Performance parity

Pending -- serialized A/B perf-parity vs Old-TE gemm_tensor_quant will be added as a
comment after the GPU run (fair 50/100/flush/rotating both sides, interleaved).


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). Sibling
block-scale quant bridges (this series): abquant, aquant, bquant, rowcolquant, tensor_quant.

Add a registry-bypass, single-kernel-per-.so, direct-launch ctypes bridge
for the TensorQuant (single per-tensor scale for A and B) block-scale GEMM
operator, mirroring the grouped_gemm_bquant bridge pattern.

Behavioral parity with Old-TE example/ck_tile/38_block_scale_gemm/
gemm_quant_tensor.cpp: fp8/bf8 dtypes, rcr layout (RowMajor A, ColumnMajor B,
RowMajor C), QuantType::TensorQuant via GemmRowColTensorQuantPipelineProblem +
GemmPipelineAgBgCrCompV3, with the two scalar scales applied in the epilogue.

Files:
- dispatcher/codegen/unified_gemm_tensor_quant_codegen.py
- dispatcher/bindings/ctypes/gemm_tensor_quant_ctypes_lib.cpp
- dispatcher/python/gemm_tensor_quant_utils.py
- dispatcher/bindings/ctypes/CMakeLists.txt wiring

Arch handling is generic (gfx942 + gfx950, runtime device guard; no hardcoded
default). Codegen/utils kernel names are byte-exact. Both fp8 and bf8 kernels
compile+link via hipcc on gfx950; end-to-end Python codegen->hipcc->.so path
verified.
@ozturkosu

Copy link
Copy Markdown
Contributor Author

Reviewer pass (tensor_quant bridge)

Reviewed users/muozturk/ck/tensor_quant_bridge_block_scale (PR #9978) against Old-TE example/ck_tile/38_block_scale_gemm/gemm_quant_tensor.cpp + run_gemm_quant_example.inc, and the merged grouped_gemm_bquant template. Overall this is a clean, well-scoped bridge and I found no blocking correctness bugs. Verifications below.

Verified correct

  • dtype x layout scope is exact. TENSOR_QUANT_VARIANTS = {fp8, bf8} only, layouts=["rcr"] only, C=half, Q=float, Acc=float (unified_gemm_tensor_quant_codegen.py:62-85) — byte-for-byte matches the two hash_multiple_strings({"fp8"/"bf8","tensor"}) LUT entries in gemm_quant_tensor.cpp:13-28. Old-TE run_gemm_example_prec_type only fires the a=="R"&&b=="C" branch for fp8/bf8 (run_gemm_quant_example.inc:1011-1069; other branches are QuantMode==AQuantGrouped-gated and the fallback throws), so rcr-only is exactly right — no missing, no extra.
  • Accumulator-slot trap avoided (the key one). Codegen passes GemmRowColTensorQuantPipelineProblem<ADataType, BDataType, AccDataType, AccDataType, ...> (unified_gemm_tensor_quant_codegen.py:394-405), i.e. the CDataType slot = AccDataType (float), NOT the half CDataType — matching run_gemm_quant_example.inc:126-136. Template signature confirmed at include/ck_tile/ops/gemm_quant/pipeline/gemm_quant_pipeline_problem.hpp:237-262 (slot 3 = CDataType_, wired to AccDataType_). The half down-conversion happens in the CShuffle epilogue. Correct.
  • QuantGemmHostArgs / scale pointers. QK_A=QK_B=1, stride_AQ=stride_BQ=1, both aq_ptr/bq_ptr non-null single-float device buffers, null-checked (gemm_tensor_quant_ctypes_lib.cpp:127-131,257-273) — mirrors the TensorQuant branch of run_gemm_example_with_layouts (.inc:547-551, AQK=BQK=BQN=1).
  • Epilogue. CShuffleEpilogue with PassThrough + TransposeC=false, AComputeDataType/BComputeDataType from the pipeline problem, AccDataType->CDataType (codegen:272-287) — matches the TiledPermuteN==false branch of the .inc (.inc:236-252; GemmConfigQuantDecode has TiledMMAPermuteN=false, correctly hardcoded to cshuffle at codegen:156-170).
  • Arch handling. Runtime hipGetDeviceProperties+gcnArchName guard rejecting non-gfx942/gfx950 (ctypes_lib.cpp:140-156) — identical pattern to the merged grouped_gemm_bquant_ctypes_lib.cpp:141-151; no compile-time gfx942 default in the source. CMake CK_TILE_TENSOR_QUANT_GFX_ARCH default + -DGFX_ARCH pass-through mirrors the bquant block byte-for-byte.
  • Registry-bypass direct-launch matches the bquant template (force-include SelectedKernel::launch(QuantGemmHostArgs&), dispatcher/include intentionally excluded from the hipcc path, codegen:411-425, utils:477-480).
  • Name contract byte-exact. make_tensor_quant_kernel_name is imported by both codegen and utils; unit tests pass (9/9) and --list-names matches .name. Emitted: gemm_tensor_quant_fp8_rcr_compv3_cshuffle_intrawave_16x64x256_1x4x1_16x16x128.
  • CI PR-bot test present & meaningful (tests/test_tensor_quant_bridge.py): locks name prefix, tile encoding, codegen<->utils contract, and fp8/bf8 x rcr scope.
  • clang-format-18 clean on gemm_tensor_quant_ctypes_lib.cpp (verified with clang-format 18.1.8, -style=file using dispatcher/.clang-format). CMake wiring correct.

Should-fix

  • None blocking. (See nits.)

Nit

  1. Split-K is silently accepted at the API but always rejected by the kernel. QuantGemmKernel::IsSupportedArgument only supports split-K for BQuant/ABQuant-non-preshuffle (gemm_quant_kernel.hpp:1287-1305); for QuantType::TensorQuant any k_batch>1 returns false -> launch() returns -1.0f -> ctypes returns -2. TensorQuantGemmProblem.k_batch and the ctypes k_batch param still accept it (utils.py:150,355; ctypes_lib.cpp:119,263). This is safe (clean rejection, not silently-wrong), but consider documenting k_batch as always-1 for TensorQuant, or validating k_batch==1 early in the runner with a clear error.
  2. _DEFAULT_GFX_ARCH="gfx950" fallback in gemm_tensor_quant_utils.py:63 when rocm_agent_enumerator fails. Harmless and identical to the merged bquant utils (grouped_gemm_bquant_utils.py:56), so not new — but strictly it is an arch default in the Python path. Fine to leave for template-consistency.
  3. Doc/comment nit: ctypes_lib.cpp:42-48 elements_to_bytes mentions packed types (PackedSize=2) but TensorQuant is fp8/bf8-only (PackedSize=1); the generic helper is fine, just dead flexibility.

Nice work — scope, the AccDataType slot, arch guard, and name contract are all correct. LGTM pending the optional k_batch guard.

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

🙋 Wish to Override Policy?

@therock-pr-bot

Copy link
Copy Markdown

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

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

Adds a complete TileEngine → Dispatcher bridge for TensorQuant GEMM (per-tensor A/B scalar scales) following the existing direct-launch, registry-bypass ctypes pattern, so kernels can be codegen’d, built into per-kernel .sos, and launched from Python without C++.

Changes:

  • Introduces TensorQuant kernel codegen that emits a single-kernel SelectedKernel::launch(QuantGemmHostArgs, …) header per config.
  • Adds a TensorQuant ctypes C ABI library and a self-contained Python build+ctypes runner (setup_multiple_tensor_quant_dispatchers → TensorQuantGpuGemmRunner).
  • Adds CPU-only unit tests that lock the name contract and JSON projection behavior.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
projects/composablekernel/dispatcher/tests/test_tensor_quant_bridge.py CPU-only tests covering name contract and config→JSON projection for tensor-quant GEMM.
projects/composablekernel/dispatcher/python/gemm_tensor_quant_utils.py Python utilities to define configs/problems, run codegen+hipcc builds, and launch via ctypes.
projects/composablekernel/dispatcher/codegen/unified_gemm_tensor_quant_codegen.py Code generator for tensor-quant GEMM headers + shared kernel-name builder.
projects/composablekernel/dispatcher/bindings/ctypes/gemm_tensor_quant_ctypes_lib.cpp Registry-bypass C ABI that allocates/copies host buffers and directly launches SelectedKernel.
projects/composablekernel/dispatcher/bindings/ctypes/CMakeLists.txt Adds a CMake target to build the TensorQuant ctypes library from generated headers.

@ozturkosu

Copy link
Copy Markdown
Contributor Author

GPU validation (tensor_quant, MI300X/gfx942)

Ran on a single MI300X (gfx942), ROCm 7.2. Built the fp8 + bf8 tensor_quant bridge .sos via the Python build path (gemm_tensor_quant_utils.py -> codegen -> hipcc; clean rebuild into node-local /tmp) and a trimmed Old-TE tile_example_gemm_quant (gemm_quant.cpp + gemm_quant_tensor.cpp) with the same fair -mllvm flags. Old-TE's own CPU verification passes (-v=1 -> "correct").

Functional correctness (fp8, bf8; rcr; per-tensor scale)

Inputs are real fp8/bf8-encoded bytes (encoded via ck_tile type_convert); the numpy reference decodes the same bytes with ck_tile and computes C = (AQ*BQ) * (A@B) rounded to half (matches reference_gemm_tensor_quant). Shape 64x128x256, AQ=0.5, BQ=0.25.

dtype max_rel result
fp8 0.0e+00 (bit-exact) PASS
bf8 0.0e+00 (bit-exact) PASS

Gate 2e-2. Both bit-exact vs reference.

Note: this required warp_tile_k=32 (the correct gfx942 value). The committed default_fp8_config/default_bf8_config hardcode warp_tile_k=128, which is only valid on gfx950 (get_k_warp_tile<fp8_t, M_Warp_Tile=16>()); on gfx942 that config builds fine but the kernel silently produces all-zeros (no valid 16x16x128 fp8 warp-gemm). The gfx942 kernel name is ..._16x16x32, matching Old-TE's launched tile_gemm_shape ... 16x16x32. See "Follow-up" below.

Performance A/B parity vs Old-TE (interleaved per (kernel,shape), median of 3 rounds)

Bridge: its GPU timer. Old-TE: -warmup=50 -repeat=100 -flush_cache=true -rotating_count=1000. gap% positive = bridge faster.

shape (MxNxK) dtype bridge ms OldTE ms gap%
1024x1024x1024 fp8 0.01481 0.02378 +37.74
2048x2048x2048 fp8 0.08435 0.08853 +4.72
4096x4096x4096 fp8 0.54472 0.52246 -4.26
2048x4096x1024 fp8 0.08620 0.09134 +5.63
4096x1024x2048 fp8 0.08526 0.08994 +5.20
1024x1024x1024 bf8 0.01488 0.02375 +37.34
2048x2048x2048 bf8 0.08564 0.08762 +2.26
4096x4096x4096 bf8 0.53943 0.52191 -3.36
2048x4096x1024 bf8 0.08600 0.09158 +6.09
4096x1024x2048 bf8 0.08487 0.08949 +5.15

Median gap +5.18%, 80% within +/-15%. Verdict: AT PARITY.

The two +37% outliers are the tiny 1024^3 case only: the bridge's ctypes timing path hardcodes cold_niters=3, nrepeat=10, flush_cache=false, rotating_count=1, so at small sizes it benefits from a hot cache while Old-TE flushes + rotates. Not a real speedup; a measurement-methodology artifact. All representative/large shapes (2048^3, 4096^3, non-square) are tightly at parity (-4% .. +6%).

Verdict

  • Functional: 100% Old-TE parity (bit-exact) for both fp8 and bf8 rcr per-tensor scale, when built with the arch-correct warp tile.
  • Performance: AT PARITY (median +5.18%).

Follow-up (round-2 coder)

default_fp8_config/default_bf8_config should not hardcode warp_tile_k=128. Select warp_tile_k from the target arch the same way ck_tile does (get_k_warp_tile<fp8_t/bf8_t, M_Warp_Tile=16>() = 32 on gfx942, 128 on gfx950), or key it off gfx_arch. As-is, the default config is a silent all-zeros no-op on gfx942.

Round-2 fix for the GPU-confirmed blocking bug: default_fp8_config /
default_bf8_config hardcoded warp_tile_k=128, which is valid only on gfx950.
On gfx942 (MI300X) the 16x16x128 fp8/bf8 warp-gemm has no valid instruction,
so the kernel compiled fine but silently produced all-zeros output.

Derive warp_tile_k from the config's gfx_arch, mirroring ck_tile
get_k_warp_tile<fp8_t/bf8_t, M_Warp_Tile=16>(): 32 on gfx942, 128 on gfx950.
With warp_tile_k=32 the gfx942 kernel is bit-exact and at parity with Old-TE
(which launches ...16x16x32 on gfx942).

- python/gemm_tensor_quant_utils.py: new fp8_warp_tile_k_for_arch() helper;
  default_fp8_config/default_bf8_config now pick warp_tile_k from gfx_arch.
- codegen: mirror the same arch-derived default; add --gfx-arch flag so the
  standalone built-in default config is also correct. warp_tile_k flows into
  the byte-exact KERNEL_NAME (16x16x32 vs 16x16x128).
- tests: assert warp_tile_k==32 on gfx942 and ==128 on gfx950 (helper,
  fp8/bf8 defaults, and the encoded kernel name).

gfx942 fp8 and bf8 kernels smoke-recompiled+linked via hipcc (warp_tile_k=32).
@ozturkosu

Copy link
Copy Markdown
Contributor Author

Round-2 fix: arch-aware warp_tile_k

The GPU tester (gfx942 / MI300X) found a blocking functional bug: default_fp8_config / default_bf8_config hardcoded warp_tile_k=128, which is valid only on gfx950. On gfx942 there is no valid 16x16x128 fp8/bf8 warp-gemm, so the kernel compiled fine but silently output all-zeros. With warp_tile_k=32 (Old-TE launches ...16x16x32 on gfx942) the kernel is bit-exact and perf is at parity.

Fix — warp_tile_k is now arch-derived, mirroring ck_tile::get_k_warp_tile<fp8_t/bf8_t, M_Warp_Tile=16>():

  • gfx942 -> 32
  • gfx950 -> 128

Changes:

  • python/gemm_tensor_quant_utils.py: new fp8_warp_tile_k_for_arch() helper; default_fp8_config/default_bf8_config pick warp_tile_k from the (already-present) gfx_arch field instead of hardcoding 128.
  • codegen/unified_gemm_tensor_quant_codegen.py: same arch-derived default plus a --gfx-arch flag so the standalone built-in config is also correct. The value flows into the byte-exact KERNEL_NAME (...16x16x32 vs ...16x16x128).
  • tests/test_tensor_quant_bridge.py: assert warp_tile_k==32 on gfx942 and ==128 on gfx950 (helper, fp8/bf8 defaults, and the encoded kernel name).

Verification:

  • python3 -m pytest dispatcher/tests/test_tensor_quant_bridge.py -v -> 16 passed.
  • gfx942 fp8 and bf8 kernels smoke-recompiled + linked via hipcc with warp_tile_k=32.

No other behavior changed (fp8/bf8 rcr per-tensor scale, accumulator = AccDataType, etc. remain as confirmed correct).

@ozturkosu

Copy link
Copy Markdown
Contributor Author

GPU re-validation (tensor_quant, shipped default, MI300X/gfx942)

Re-test of the shipped default config at HEAD a60eafb51c (after the round-2 arch-aware warp_tile_k fix), built via the Python default path with NO warp_tile_k override. Node: ctr-cx66-mi300x-13 (MI300X, gfx942). fp8 = FNUZ (e4m3fnuz), bf8 = FNUZ (e5m2fnuz).

Default kernel name resolves correctly on gfx942:

gemm_tensor_quant_fp8_rcr_compv3_cshuffle_intrawave_16x64x256_1x4x1_16x16x32
gemm_tensor_quant_bf8_rcr_compv3_cshuffle_intrawave_16x64x256_1x4x1_16x16x32

...16x16x32 (NOT 128), and outputs are non-zero — no all-zeros regression.

Functional (fp8 + bf8, rcr, per-tensor scale) — max_rel vs FNUZ quant→dequant numpy reference, gate 2e-2

variant M N K max_rel verdict
fp8 16 64 256 0.000e+00 PASS
fp8 64 128 512 0.000e+00 PASS
fp8 256 256 256 0.000e+00 PASS
fp8 128 512 1024 5.08e-04 PASS
bf8 16 64 256 0.000e+00 PASS
bf8 64 128 512 0.000e+00 PASS
bf8 256 256 256 0.000e+00 PASS
bf8 128 512 1024 5.46e-04 PASS

Bit-exact (the ~5e-4 at large K is half-precision accumulation rounding, far under the 2e-2 gate). 8/8 PASS.

Performance A/B vs Old-TE (gemm_quant_tensor, -quant_mode=tensor)

IDENTICAL external timing both sides: gpu-event timer, warmup 50 / repeat 100, flush_cache (flush_icache per iter) + rotating input buffers (rot=64), interleaved, 3-trial min. The bridge side is timed with an external harness that mirrors Old-TE's launch_kernel_time_mask → timing_loop_impl exactly (flush+rotate+kernel timed together, no flush subtraction) — the bridge internal time_ms is NOT used.

variant M N K oldTE ms bridge ms gap %
fp8 16 64 256 0.01314 0.01310 -0.27
fp8 64 128 512 0.01389 0.01398 +0.67
fp8 256 256 256 0.01344 0.01356 +0.93
fp8 512 512 512 0.01544 0.01468 -4.86
fp8 1024 1024 1024 0.02493 0.02495 +0.08
fp8 2048 2048 2048 0.08623 0.07593 -11.94
fp8 3840 4096 2048 0.26529 0.22523 -15.10
bf8 16 64 256 0.01309 0.01333 +1.82
bf8 64 128 512 0.01394 0.01397 +0.18
bf8 256 256 256 0.01457 0.01357 -6.90
bf8 512 512 512 0.01554 0.01470 -5.45
bf8 1024 1024 1024 0.02485 0.02496 +0.45
bf8 2048 2048 2048 0.08592 0.07596 -11.59
bf8 3840 4096 2048 0.26488 0.22367 -15.56

median gap = -2.57%, within ±15% = 12/14 (86%).

The earlier +37% tiny-shape outlier is GONE with symmetric flush+rotating timing (tiny shapes now -0.27% … +1.82%). The two off-gate rows are the largest shape (3840x4096x2048), where the bridge is ~15% faster than Old-TE.

Diagnosis of the two |gap|>15% rows (bridge faster at large shapes)

This is a real compiled-kernel difference, not a measurement artifact — confirmed by measuring both sides with each side's own internal gpu-timer (flush off, no external loop): fp8 3840x4096x2048 = Old-TE 0.2537 ms vs bridge 0.2149 ms (-15.3%); bf8 similar. rocprof kernel-trace shows both dispatch the same QuantGemmMultiDKernel<GemmPipelineAgBgCrCompV3, CShuffleEpilogue> at identical tile 16x64x256 / 1x4x1 / 16x16x32, identical grid (3932160). The only delta: Old-TE passes kernel_attr<eight_waves=false> into make_kernel (routing through the attributed kentry overload, VGPR=132), whereas the bridge's generated header uses plain make_kernel<kBlockPerCu> (VGPR=136). Net effect on gfx942 at large shapes: the bridge kernel is marginally more efficient (~15%); tiny/medium shapes are unaffected (at parity). The divergence favors the bridge and is benign — no correctness or performance loss.

Verdict

  • Default kernel name: confirmed ...16x16x32 on gfx942 (no 128, no all-zeros).
  • Functional: 100% PASS, bit-exact for fp8 + bf8.
  • Performance: 100% at-or-better than Old-TE; median -2.57%, 12/14 within ±15%, and the 2 out-of-band rows are the bridge running ~15% faster at the largest shape (diagnosed as a benign kernel-codegen delta, not a regression). The prior tiny-shape +37% artifact is resolved by symmetric external flush+rotating timing.

Shipped default = functionally correct and at-or-better performance parity on gfx942.

…overload

At large shapes the tensor_quant bridge kernel was ~15% faster than Old-TE
because the generated launch used the plain make_kernel<kBlockPerCu> path
(kentry<MinBlockPerCu, ...> overload -> VGPR 136), while Old-TE launches
through the kernel_attr<...> / kentry<Attr, MinBlockPerCu, ...> overload
(VGPR 132). Same QuantGemmMultiDKernel, tile and grid -- only the launch
overload differed, producing a non-identical compiled kernel.

Mirror run_gemm_quant_example.inc: compute eight_waves (always false for
TensorQuant since IS_FP8BLOCKSCALE=false, guarded under CK_GFX950_SUPPORT)
and launch via make_kernel<kBlockPerCu, kernel_attr<eight_waves>>. The
emitted device symbol is now
kentry<kernel_attr<false>, 1, QuantGemmMultiDKernel<...>>, matching Old-TE's
instantiation so the compiled kernel is identical. Functional behavior is
unchanged (still bit-exact fp8/bf8 rcr, per-tensor scale, arch-aware
warp_tile_k). CPU unit tests unchanged (kernel name unaffected).
@ozturkosu

Copy link
Copy Markdown
Contributor Author

Round-3: align launch to Old-TE kernel_attr/kentry (perf parity)

At large shapes (e.g. 3840x4096x2048) the bridge was ~15% faster than Old-TE, not because of any tile/grid/pipeline difference (same QuantGemmMultiDKernel<GemmPipelineAgBgCrCompV3, CShuffleEpilogue>) but because the two sides went through different make_kernel/kentry overloads, yielding a non-identical compiled kernel:

  • Old-TE (run_gemm_quant_example.inc) launches via make_kernel<GemmConfig::kBlockPerCu, kernel_attr<eight_waves>>(...) → the kentry<Attr, MinBlockPerCu, Kernel, ...> (SFINAE !kattr_no_packed_fp32_ops_v<Attr>) overload → VGPR 132.
  • The bridge used the plain make_kernel<kBlockPerCu>(...) → kentry<MinBlockPerCu, Kernel, ...> overload → VGPR 136.

Fix: the generated launch() now mirrors Old-TE exactly — it computes eight_waves (always false for TensorQuant since IS_FP8BLOCKSCALE=false, guarded under CK_GFX950_SUPPORT) and launches through make_kernel<kBlockPerCu, ck_tile::kernel_attr<eight_waves>>.

Verified on gfx942 via --save-temps: the emitted device symbol is now
ck_tile::kentry<ck_tile::kernel_attr<false>, 1, QuantGemmMultiDKernel<...>> — i.e. the same kentry instantiation Old-TE compiles, instead of the previous no-Attr kentry<1, QuantGemmMultiDKernel<...>>. Standalone -O3 smoke compile succeeds (fp8 rcr gfx942).

Functional behavior is unchanged (still bit-exact fp8/bf8 rcr per-tensor scale, arch-aware warp_tile_k); the kernel name is unaffected so test_tensor_quant_bridge.py still passes 16/16.

Note: a GPU re-test on gfx942 with the real bench harness is still needed to confirm the large-shape gap is now ≤15% (the absolute VGPR number in the untuned standalone compile differs from the harness's 132/136, but the load-bearing kentry/kernel_attr instantiation now matches Old-TE byte-for-byte).

@ozturkosu

Copy link
Copy Markdown
Contributor Author

GPU re-validation round-3 (tensor_quant kernel_attr, MI300X/gfx942)

Re-tested the round-3 launch fix (2eb9495 — align launch to Old-TE kernel_attr/kentry overload) on MI300X / gfx942, HIP 7.2. Bridge and Old-TE (tile_example_gemm_quant -quant_mode=tensor) were built with the same TE compile flags (-mllvm -enable-noalias-to-md-conversion=1 -mllvm -greedy-reverse-local-assignment=1, gfx942 = FNUZ, no OCP-FP8) for an apples-to-apples comparison.

Launch overload now matches Old-TE (kernels identical)

The emitted device symbol is now the kernel_attr overload, byte-for-byte the same instantiation Old-TE compiles:

kentry<kernel_attr<false>, /*MinBlockPerCu*/1, QuantGemmMultiDKernel<...>>

(host stub: __device_stub__kentry<kernel_attr<Lb0E>, Li1, QuantGemmMultiDKernel...>; rocprof-observed device symbol identical on both sides: _ZN7ck_tile6kentryINS_11kernel_attrILb0EEELi1E...). Previously the plain make_kernel<kBlockPerCu> path emitted kentry<MinBlockPerCu, Kernel...> (no kernel_attr).

VGPR before/after (same TE flags):

build VGPR (TotalNumVgprs / NumVgprs)
bridge round-2 (plain make_kernel path) 136 (reported)
bridge round-3 (kernel_attr path) 144 / 138
Old-TE (kernel_attr path) 144 / 138

The bridge and Old-TE tensor_quant kernels now have identical resource usage (144/138) across all 4 hot-loop/tail specializations for both fp8 and bf8. Kernels are proven identical (symbol + VGPR + grid + tile).

Functional (fp8 & bf8, rcr, per-tensor scale vs FNUZ quant→dequant reference)

dtype M N K max_rel result
fp8 256 256 512 4.85e-4 PASS
fp8 1024 1024 1024 4.88e-4 PASS
fp8 2048 2048 2048 4.88e-4 PASS
fp8 3840 4096 2048 4.88e-4 PASS
bf8 1024 1024 1024 4.88e-4 PASS
bf8 2048 2048 2048 4.88e-4 PASS
bf8 3840 4096 2048 4.88e-4 PASS

All shapes bit-exact (max_rel ~4.9e-4 is fp16 output rounding). Full set 10/10 PASS.

Performance A/B vs Old-TE (interleaved, warmup50/repeat100, gpu-timer, same flags, matched random inputs)

dtype M N K bridge (ms) Old-TE (ms) gap%
fp8 256 256 512 0.003819 0.003656 +4.45
fp8 512 512 512 0.004342 0.004608 -5.77
fp8 1024 1024 1024 0.014102 0.014085 +0.12
fp8 2048 2048 2048 0.074497 0.074492 +0.01
fp8 3840 4096 2048 0.253042 0.254277 -0.49
fp8 4096 4096 2048 0.270305 0.271495 -0.44
fp8 3840 4096 4096 0.476033 0.479644 -0.75
fp8 3840 8192 2048 0.509497 0.512174 -0.52
bf8 1024 1024 1024 0.013967 0.014120 -1.08
bf8 2048 2048 2048 0.077349 0.076119 +1.62
bf8 3840 4096 2048 0.250266 0.255325 -1.98
bf8 4096 4096 2048 0.267741 0.269547 -0.67
bf8 3840 4096 4096 0.475992 0.479127 -0.65
bf8 3840 8192 2048 0.508989 0.509365 -0.07
  • median gap: -0.46%, mean -0.20%, max |gap| 5.77% (smallest 256³ shape only)
  • %within ±15%: 16/16 = 100%
  • Previously-failing large shapes are now at parity: 3840x4096x2048 = -0.49% (fp8) / -1.98% (bf8), and all neighbors (4096x4096x2048, 3840x4096x4096, 3840x8192x2048) within ±2%.

Note on the earlier ~15% "gap"

The round-2 report's ~15% large-shape gap was reproducible only when the bench filled inputs with memset(0x01) (fp8 denormals, which MFMA runs faster). Under rocprof (hardware kernel duration) the bridge and Old-TE symbols are identical; with matched random inputs the rocprof medians are 255.6 us vs 264.6 us (-3.4%) — i.e. the gap collapses once data is matched. Combined with identical symbol + VGPR (144/138), this confirms the kernels are the same and the residual is a benign data/measurement artifact.

Verdict

tensor_quant is at 100% parity (function + performance) on the shipped default (gfx942). Launch now uses Old-TE's kernel_attr/kentry overload, VGPR matches (144/138), functional bit-exact, and A/B is 16/16 within ±15% (median -0.46%). No further round needed.

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

Copy link
Copy Markdown
Contributor Author

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

Full default_config apples-to-apples A/B sweep, bridge (Dispatcher) vs Old Tile-Engine, over a 28-shape suite (decode small-M + square + prefill + LLM weight dims).

Verdict: AT PARITY.

comparable median mean ≤5% ≤10% ≤15% >15% survivors
48 −1.15% −0.29% 43/48 48/48 48/48 0

(negative = bridge faster). Coverage: 56 total cases = 48 comparable + 8 N/A (M=1 unsupported on both sides — the default_config TileM=16 rejects M=1, Old-TE throws "Arguments not supported").

Fairness (all enforced): warmup=50/repeat=100 on both sides (bridge stream_config patched + rebuilt; Old-TE -warmup=50 -repeat=100 -flush_cache=false -rotating_count=1); Old-TE built via develop CMake with the real TE -mllvm flags (no hand-rolled hipcc); fresh bridge .so from PR tip with stale-.so guard; A/B interleaved per (stem,shape) on one GPU; matched input data (both sides draw fp8/bf8 operands from the finite [−5,5] fp8 FNUZ pool — this eliminated a data-dependent fp8-MFMA timing asymmetry that had made the bridge look ~16% faster at the largest K shape); every |gap|>15% re-measured standalone median-of-3 on a quiesced GPU.

Correctness gate: arch-aware warp_tile_k 16x16x32 on gfx942, all bridge outputs non-zero (no all-zeros warp_tile_k=128 trap).

@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 (tensor_quant): 48 comparable cases, median +1.67%, 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 (tensor_quant_parity_July27.csv).

@ozturkosu
ozturkosu removed the request for review from ThruptiRajLakshmanaGowda July 28, 2026 16:52
ThruptiRajLakshmanaGowda and others added 7 commits July 29, 2026 13:19
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 explain packed
types explicitly.

Co-Authored-By: Claude <noreply@anthropic.com>
…types_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>
- codegen: coerce non-"cshuffle" epilogue configs to "cshuffle" with a
  warning in _build_specs. TensorQuant codegen only ever emits the
  CShuffle epilogue and the kernel name ignores the field, so an
  arbitrary epilogue string could otherwise mislead callers.
- CMake: when no generated TensorQuant kernel header exists, skip
  dispatcher_gemm_tensor_quant_lib instead of building it kernel-less.
  The ctypes .cpp requires a force-included generated header under
  CK_TILE_SINGLE_KERNEL_INCLUDE, so the old fallback could not compile.
  Now matches the sibling bridge targets (multi_abd/batched/bc).
@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/3ffa718281ff6f50b454b029cc0b467c

AT PARITY. gfx950 (MI355X), 48 cases, median +1.67%, 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