Skip to content

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

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

feat(ck-tile): tensor_quant GEMM TE to dispatcher bridge#9978
ozturkosu wants to merge 4 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
🌿 Branch Name ✅ Pass
📝 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.

@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_dispatchersTensorQuantGpuGemmRunner).
  • 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.

Comment on lines +210 to +214
double_smem_buffer: bool = False
pad_m: bool = False
pad_n: bool = False
pad_k: bool = True
block_size: int = 256
Comment on lines +480 to +482
pipeline = config.get("pipeline", "compv3")
epilogue = config.get("epilogue", "cshuffle")
scheduler = config.get("scheduler", "intrawave")
Comment on lines +314 to +325
else()
message(STATUS "No TensorQuant GEMM kernel found for ctypes lib - building without kernel")
add_library(dispatcher_gemm_tensor_quant_lib SHARED gemm_tensor_quant_ctypes_lib.cpp)
target_include_directories(dispatcher_gemm_tensor_quant_lib PRIVATE
${PROJECT_SOURCE_DIR}/include
${PROJECT_SOURCE_DIR}/dispatcher/include
)
target_link_libraries(dispatcher_gemm_tensor_quant_lib PRIVATE hip::device)
set_target_properties(dispatcher_gemm_tensor_quant_lib PROPERTIES
POSITION_INDEPENDENT_CODE ON
CXX_STANDARD 17
)
@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.

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

2 participants