Skip to content

feat(ck-tile): multi-D GEMM TE to dispatcher bridge#9308

Open
ozturkosu wants to merge 14 commits into
developfrom
users/muozturk/ck/bridge_gemm_multi_d
Open

feat(ck-tile): multi-D GEMM TE to dispatcher bridge#9308
ozturkosu wants to merge 14 commits into
developfrom
users/muozturk/ck/bridge_gemm_multi_d

Conversation

@ozturkosu

@ozturkosu ozturkosu commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

ISSUE ID: #8997

Motivation

The TileEngine → Dispatcher bridge had no path for the gemm_multi_d op, which
fuses one or more extra D operands into the GEMM epilogue
(E = elementwise_op(A@B, D0, D1, ...)). This is a real Old-TE capability used for
fused bias/residual-style epilogues with no dispatcher equivalent, so this PR adds a
complete bridge so the dispatcher can generate, build, and launch multi_d at parity
with the legacy Tile Engine version.

The capability set matches the Old-TE gemm_multi_d_instance_builder.py exactly:
fp16, the 4-char layouts {rcrr, rrrr, ccrr, crrr} (A/B vary, C and D row-major),
the element-wise ops {MultiDAdd, MultiDMultiply, PassThrough}, and a swept number of
D tensors (1 and 2). It follows the registry-bypass bridge pattern used by the grouped
(#9000) and stream-K (#9028) bridges.

Test Plan

  • Run the CPU-only unit tests (no GPU required):
    python3 -m pytest dispatcher/tests/test_multi_d_bridge.py -v
  • On-GPU numeric verify over the full capability matrix
    (fp16 × {rcrr, rrrr, ccrr, crrr} × {MultiDAdd, MultiDMultiply} × {num_d 1, 2} = 16
    combos) at M=N=K=1024 against an fp32 reference, gate 2e-2.
  • Confirm the CI config builds real kernels and the sweep covers all ops × D counts.

Test Result

  • CPU-only unit tests pass (10 passed).
  • On-GPU numeric verify: 16/16 combos pass at M=N=K=1024, worst-case
    max_rel = 6.16e-4 (~30x under the 2e-2 gate). Col-major ccrr / crrr have real
    on-GPU numeric evidence.
  • CI config now builds real kernels (was zero); the sweep expands evenly across
    {MultiDAdd, MultiDMultiply} × {num_d 1, 2} per layout.
  • clang-format (18.1.8) clean on multi_d_gemm_ctypes_lib.cpp.
  • Serialized A/B perf-parity vs Old-TE (MI300X / gfx942, fp16, 4 layouts × 2 ops ×
    num_d=1 = 8 stems × 5 shapes = 40 rows, interleaved, fair 50/100/flush/rotating
    both sides): at parity, bridge consistently faster — median gap +9.44%, 100%
    within ±15% (range [+3.76%, +14.99%]; positive = bridge faster, from the
    registry-bypass direct launch avoiding the Old-TE profiler's per-call overhead).
    num_d=1 is the fair slice since the Old-TE gemm_multi_d benchmark is single-D.
    See the parity comment for details.

Related PRs / references (TileEngine → Dispatcher GEMM bridge series): #8997 (regular GEMM fp16/bf16 all-layout), #9000 (grouped GEMM), #9028 (stream-K), #8887 (fp8/bf8/int8). This PR is a sibling in the same bridge effort tracked across those PRs.

Extend the bridge to the gemm_multi_d GEMM op so the Dispatcher generates,
builds, and launches it at full parity with the legacy Tile Engine version,
mirroring gemm_universal (#8997), grouped (#9000), and stream-K (#9028).

Capability set matches Old-TE exactly: fp16; layouts rcrr/rrrr/ccrr/crrr
(A/B vary, C and D row-major); elementwise ops MultiDAdd/MultiDMultiply/
PassThrough; num D tensors swept (default 1,2). E = op(A@B, D0, D1, ...).

- unified_gemm_codegen.py: _multi_d_single_include re-exports NumDTensor/
  DsDataType/DsLayout/DLayout/ElementWiseFn/GemmMultiDArgs and ALayout/BLayout/
  CLayout plus GEMM_KEY_MULTI_D/NUM_D_TENSORS/ELEMENTWISE_OP/D_LAYOUT macros
  under CK_TILE_SINGLE_KERNEL_INCLUDE (leverages the existing MULTI_D codegen
  path: _multi_d_types, _launch_function_multi_d, CShuffle multi-D epilogue).
- multi_d_gemm_ctypes_lib.cpp: dedicated C ABI (dispatcher_run_multi_d_gemm +
  dispatcher_get_num_d_tensors) that force-includes one kernel, allocs A/B/C
  and each D, derives strides from the compile-time layouts, and calls
  SelectedKernel::launch(GemmMultiDArgs, ...) directly (registry-bypass).
- gemm_utils.py: MultiDGemmProblem/Result, GpuMultiDGemmRunner, run_multi_d;
  GemmKernelConfig multi_d fields + 4-char codegen_layout + _multid_ name
  suffix (byte-for-byte name parity); _ctypes_source_name; expand_sweep
  variant="multi_d".
- gemm_multi_d_full_benchmark.py / run_one_gemm_multi_d_kernel.py: 3-phase
  driver + isolated GPU worker (num-D read off the .so, fp32 verify reference).
- MULTI_D_GEMM_BRIDGE.md: bridge documentation.

Smoke (gfx942 MI300X): codegen+compile+link 0 failures across all 4 layouts x
2 ops x 2 num_d; end-to-end driver 6/6 kernels VERIFY (max_rel 4.9e-4).
@ozturkosu ozturkosu changed the title feat(ck/dispatcher): add gemm_multi_d TileEngine->Dispatcher bridge feat(ck-tile): multi-D GEMM TE to dispatcher bridge Jul 11, 2026
@ozturkosu ozturkosu self-assigned this Jul 11, 2026
@therock-pr-bot

therock-pr-bot Bot commented Jul 11, 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

therock-pr-bot Bot commented Jul 11, 2026

Copy link
Copy Markdown

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

Address review findings on the gemm_multi_d TileEngine->Dispatcher bridge:

- Add multi_d_config (elementwise_ops x num_d_tensors) to default_config.json
  and default_ci_config.json so expand_sweep covers the real capability set
  (MultiDAdd + MultiDMultiply, num_d in {1,2}) instead of falling back to
  MultiDAdd/num_d=1 only. Schema matches _expand_values ({"values":[...]}).
- Fix default_ci_config.json tile point: the old 64x64x64 / warp_tile_k=64
  entry was rejected by the dispatcher validator and expanded to ZERO kernels,
  so CI built nothing. Use a validated 128x128x64 / 2x2 / 32x32x16 point that
  yields kernels across all four op x num_d combos.
- Trim gemm_multi_d_full_benchmark.py: correct the false fp16+bf16+fp8+bf8
  dtype claim to fp16-only, fix copy-paste stream-K docstrings
  (streamk_gemm_full_benchmark -> gemm_multi_d_full_benchmark, GemmProblem ->
  MultiDGemmProblem), and note MultiDMultiply error amplification in --verify-tol
  help.

Verified fp16 x {rcrr,rrrr,ccrr,crrr} x {MultiDAdd,MultiDMultiply} x {num_d 1,2}
on gfx942 (MI300X): 16/16 combos pass, worst max_rel 6.16e-4 << 2e-2 gate.
@ozturkosu

Copy link
Copy Markdown
Contributor Author

Posting the round-2 review fixes here to keep the description focused on what the PR does. Here is what I addressed from review:

  • Sweep now covers the real capability set (was falling back to
    MultiDAdd/num_d=1).
    Added multi_d_config
    (elementwise_ops x num_d_tensors) to default_config.json and
    default_ci_config.json, matching the _expand_values schema
    ({"values":[...]}) that expand_sweep consumes. Each layout now expands
    evenly across {MultiDAdd, MultiDMultiply} x {num_d 1, 2}.
  • Fixed a default_ci_config.json that built ZERO kernels. Its old
    64x64x64 / warp_tile_k=64 point was rejected by the dispatcher validator
    and expanded to nothing, so CI compiled no multi_d kernels. Replaced with a
    validated 128x128x64 / 2x2 / 32x32x16 point (64 kernels per layout).
  • Docs/comments corrected in gemm_multi_d_full_benchmark.py: trimmed the
    false fp16+bf16+fp8+bf8 dtype claim to fp16-only; fixed copy-paste
    stream-K docstrings (streamk_gemm_full_benchmark ->
    gemm_multi_d_full_benchmark, GemmProblem -> MultiDGemmProblem); noted
    MultiDMultiply error amplification in the --verify-tol help.
  • Confirmed _reference() fold order matches the ck_tile epilogue
    (MultiDMultiply/MultiDAdd fold left-to-right in fp32, convert to E last);
    no change needed.

@ozturkosu
ozturkosu marked this pull request as ready for review July 12, 2026 22:34
@ozturkosu
ozturkosu requested review from a team as code owners July 12, 2026 22:34
@ozturkosu

Copy link
Copy Markdown
Contributor Author

A/B perf-parity sweep vs Old-TE (MI300X / gfx942)

Ran the serialized bridge-vs-Old-TE performance parity sweep for multi_d GEMM on a single MI300X (gfx942), apples-to-apples:

  • Both sides identical kernels: the bridge codegens the same ck_tile multi_d kernel the Old-TE gemm_multi_d benchmark builds; same TE -mllvm codegen flags on both.
  • Fair timing: warmup=50 / repeat=100 / flush_cache=true / rotating_count=1000 on both sides (the multi_d bridge ctypes stream_config was set to match the Old-TE profiler).
  • Interleaved A/B: per (stem, shape) the two sides are measured back-to-back, 3 reps alternating order, median taken.
  • Coverage: fp16, 4 layouts {rcrr, rrrr, ccrr, crrr} × 2 element-wise ops {MultiDAdd, MultiDMultiply} = 8 stems, num_d = 1 (the Old-TE gemm_multi_d benchmark is single-D, so num_d=1 is the fair, apples-to-apples slice; the bridge's num_d=2 kernels have no Old-TE counterpart in this benchmark). Shapes: 512³, 1024³, 2048³, 1024×512×256, 4096³.

Result (40 rows = 8 stems × 5 shapes)

metric value
median gap +9.44%
mean gap +9.42%
within ±15% 100%
range [+3.76%, +14.99%]

gap% = (bridge_tflops − oldte_tflops) / oldte_tflops × 100 (positive = bridge faster). Every point is within ±15% (the standard parity gate) with the bridge consistently faster. The gap is one-directional (a bridge win, not a regression): the registry-bypass path calls SelectedKernel::launch directly and avoids the Old-TE profiler's per-invocation host overhead, which is proportionally larger for multi_d's epilogue-fused kernels. No case where the bridge is slower.

Full per-(layout, op, shape) CSV is attached to the PR working set (parity_multi_d_MI300X_gfx942_fp16_4layouts_numd1_2026-07-12.csv).

@ozturkosu

Copy link
Copy Markdown
Contributor Author

A/B perf-parity sweep vs Old-TE (MI350X / gfx950)

MI350X (CDNA4 / gfx950) re-run of the multi-D GEMM TE-to-dispatcher bridge perf
parity, mirroring the MI300X methodology. ROCm 7.2.0, single dedicated GPU.

  • Identical kernels: both sides emit the byte-identical ck_tile kernel via the
    identical-kernel trick — the PR's fixed default_ci_config.json was copied into the
    Old-TE tree as parity_ci_config.json and passed with
    -DGEMM_MULTI_D_CONFIG_FILE=parity_ci_config.json, so the Old-TE builder emits the
    same <dtype>_<layout>_<trait>_<tile> stem set the bridge codegens.
  • Fair timing (both sides): warmup=50, repeat=100, flush_cache=true, rotating_count=1000. The bridge multi_d ctypes stream_config was patched to
    {warmup=50, repeat=100, flush_cache=true, rotating_count=1000} and the .so rebuilt;
    Old-TE bins run -warmup=50 -repeat=100 -flush_cache=true -rotating_count=1000 -metric=1 -verify=0 -json_output=true.
  • Same build flags: Old-TE built with the PR's own CMake (-DSUPPORTED_GPU_TARGETS=gfx950,
    TE -mllvm codegen flags on); the bridge codegen applies the identical
    _TILE_ENGINE_CODEGEN_FLAGS. No noalias=0, no hand-rolled hipcc.
  • Interleaved 3-rep median: for each (stem, shape) bridge and Old-TE are measured
    back-to-back, alternating order across reps, median per side (no thermal drift).
    Correctness-gated: every bridge stem is fp32-verified once before timing.
  • Shapes: 512^3, 1024^3, 2048^3, 1024x512x256, 4096^3.
  • Scope: fp16, all 4 layouts (rcrr/rrrr/ccrr/crrr), elementwise op = add and mul,
    16 trait combos per (layout, op) -> 640 measured rows.

Result (num_d=1, as-requested reproduction of the MI300X scope)

metric value
rows 640
median gap +2.46%
mean gap +2.81%
within +/-5% 82.2%
within +/-15% 97.0%
range [-16.3%, +23.4%]

gap_pct = (bridge_tflops - oldte_tflops) / oldte_tflops * 100 (+ = bridge faster).

Important fairness caveat — num_d mismatch. The Old-TE multi_d benchmark bakes
two D tensors (DsDataType = tuple<D0DataType, D1DataType>), i.e. it is a num_d=2
kernel, not single-D. The byte-identical bridge counterpart is therefore num_d=2
(DsDataType = tuple<CDataType, CDataType>), not num_d=1. The num_d=1 bridge kernel does
less epilogue work (one D-add vs two), which biases the num_d=1 gap positive (+2.5%).
The apples-to-apples comparison is num_d=2:

Result (num_d=2, byte-identical to Old-TE — the fair comparison)

metric value
rows 640
median gap -0.14%
mean gap -0.18%
within +/-5% 88.9%
within +/-15% 97.8%
range [-17.4%, +22.4%]

Verdict: at parity. On the byte-identical num_d=2 comparison the bridge is centered on
zero (median -0.14%, 88.9% within +/-5%). All shapes <=2048^3 are 100% within +/-15% and the
compv3/compv4 pipelines are 100% within +/-15%. The only surviving |gap|>15% rows (14/640,
all re-measured standalone median-3 and persisting) are exclusively the mem pipeline at
4096^3 — bridge mostly faster (+16..+22%), two rrrr rows ~17% slower. The same 4096^3
mem-pipeline signature appears in the num_d=1 run, so it is a real large-shape codegen
characteristic of the mem pipeline, not a num_d artifact and not a broad regression;
flagged for rocprof follow-up.

CSV (num_d=1, requested): parity_multi_d_MI350X_gfx950_fp16_4layouts_numd1_2026-07-13.csv
CSV (num_d=2, fair): parity_multi_d_MI350X_gfx950_fp16_4layouts_numd2_2026-07-13.csv

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 end-to-end Tile Engine → Dispatcher bridge support for the multi-D GEMM variant (gemm_multi_d), enabling dispatcher-driven codegen/build/runtime for GEMM kernels that fuse one or more D tensors into the epilogue.

Changes:

  • Introduces a dedicated multi-D ctypes C ABI (dispatcher_run_multi_d_gemm) and corresponding Python-side runner/config expansion support.
  • Adds Tile Engine sweep driver + isolated worker for building/benchmarking multi-D kernels (with optional fp32 reference verification).
  • Adds CPU-only unit tests and extends TE sweep configs to cover {elementwise_op} × {num_d_tensors} for multi-D.

Reviewed changes

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

Show a summary per file
File Description
projects/composablekernel/tile_engine/ops/gemm/run_one_gemm_multi_d_kernel.py New subprocess worker to run/verify one multi-D kernel from a compiled .so.
projects/composablekernel/tile_engine/ops/gemm/gemm_multi_d/configs/default_config.json Adds multi_d_config sweep dimensions (elementwise op × D count).
projects/composablekernel/tile_engine/ops/gemm/gemm_multi_d/configs/default_ci_config.json CI config updated to a validated tile point and adds multi_d_config.
projects/composablekernel/tile_engine/ops/gemm/gemm_multi_d_full_benchmark.py New 3-phase multi-D sweep driver (codegen/build → problems → multi-GPU benchmark).
projects/composablekernel/dispatcher/tests/test_multi_d_bridge.py New CPU-only tests for multi-D naming and codegen JSON projection/contracts.
projects/composablekernel/dispatcher/python/gemm_utils.py Adds multi-D config fields, name/layout encoding, build selection, and a multi-D runner.
projects/composablekernel/dispatcher/codegen/unified_gemm_codegen.py Exports multi-D types/macros under CK_TILE_SINGLE_KERNEL_INCLUDE for single-include builds.
projects/composablekernel/dispatcher/bindings/ctypes/multi_d_gemm_ctypes_lib.cpp New dedicated ctypes library implementing the multi-D ABI and direct-kernel launch path.
projects/composablekernel/dispatcher/bindings/ctypes/MULTI_D_GEMM_BRIDGE.md New documentation describing the bridge design and ABI.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread projects/composablekernel/tile_engine/ops/gemm/run_one_gemm_multi_d_kernel.py Outdated
Comment thread projects/composablekernel/tile_engine/ops/gemm/run_one_gemm_multi_d_kernel.py Outdated
Comment thread projects/composablekernel/tile_engine/ops/gemm/run_one_gemm_multi_d_kernel.py Outdated
Comment thread projects/composablekernel/tile_engine/ops/gemm/gemm_multi_d_full_benchmark.py Outdated
Comment thread projects/composablekernel/tile_engine/ops/gemm/gemm_multi_d_full_benchmark.py Outdated
Comment thread projects/composablekernel/dispatcher/python/gemm_utils.py
Comment thread projects/composablekernel/dispatcher/tests/test_multi_d_bridge.py Outdated
ozturkosu and others added 4 commits July 16, 2026 18:07
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Resolve conflicts in the dispatcher codegen + gemm_utils with the grouped/
stream-K bridge changes that landed on develop:
- unified_gemm_codegen.py: keep the multi_d single-include export block; the
  global ALayout/BLayout/CLayout aliases develop added are already emitted
  once earlier in the same block.
- gemm_utils.py: canonicalize the regular-GEMM flag on _has_single (dropping
  _has_gemm), keep _has_grouped + _has_multi_d and both run_grouped /
  run_multi_d methods, and merge the variant-name / ctypes-source /
  expand_sweep docstrings so grouped and multi_d coexist.

Also address Copilot review feedback:
- gemm_utils.run(): raise a descriptive RuntimeError when the .so lacks
  dispatcher_run_gemm (grouped/multi_d libs) instead of AttributeError.
- run_one_gemm_multi_d_kernel.py: cache host A/B/D per (shape, num_d) so
  batch mode stops regenerating inputs per kernel; fix the input-JSON
  docstring to list only the consumed fields (num_d from the .so, op from
  the kernel name).
- gemm_multi_d_full_benchmark.py: correct the docstring to state multi_d
  uses its dedicated dispatcher_run_multi_d_gemm ABI, not the regular one.
@ozturkosu

Copy link
Copy Markdown
Contributor Author

Merged latest develop (resolved the codegen + gemm_utils conflicts with the grouped/stream-K bridge changes) and addressed the Copilot review. Summary of each point:

1. run() calls dispatcher_run_gemm unconditionally (gemm_utils.py) — Fixed. run() now raises a descriptive RuntimeError when the loaded .so doesn't expose dispatcher_run_gemm (i.e. a grouped/multi_d lib), instead of failing with AttributeError. Also canonicalized the regular-GEMM flag on _has_single (matching develop's grouped bridge) alongside _has_grouped / _has_multi_d.

2. Batch mode regenerates A/B/D per kernel (run_one_gemm_multi_d_kernel.py) — Fixed. Host matrices are now cached on _run_one keyed by (M, N, K, num_d) — A/B per shape, Ds per (shape, num_d) — mirroring the regular GEMM worker's _ab_cache, so batch runs generate inputs once per shape.

3. Worker docstring lists ignored num_d / elementwise_op inputs (run_one_gemm_multi_d_kernel.py) — Fixed. The input-JSON example now lists only the consumed fields (so_path, problem.{M,N,K}, kernel_name) and notes explicitly that D count is read off the .so and the op is parsed from the kernel name, so those keys are ignored.

4. "same C ABI" wording (gemm_multi_d_full_benchmark.py) — Fixed. The docstring now states multi_d keeps the single-problem shape but uses a dedicated C ABI (dispatcher_run_multi_d_gemm in multi_d_gemm_ctypes_lib.cpp, with the extra D-pointer array + count), not the regular dispatcher_run_gemm.

5. Shebang not on line 1 (both TE scripts) — Already on line 1 in the current revision (addressed in an earlier commit before this review batch); no change needed.

6. Test docstring run path (test_multi_d_bridge.py) — Already points at dispatcher/tests/test_multi_d_bridge.py in the current revision; no change needed.

CPU-only unit tests still green (10 passed) and both conflict files compile clean after the merge.

…ault C-ABI timing

Old-TE gemm_multi_d_benchmark_single.cpp bakes DsDataType=tuple<D0,D1>
(DsDataType::size()==2), so num_d=2 is the byte-identical Old-TE comparison.

- MultiDGemmProblem.num_d default 1 -> 2
- expand_sweep multi_d num_d_tensors fallback [1] -> [2]
- multi_d_gemm_ctypes_lib.cpp: default flush_cache=true, rotating_count=1000
  (matching Old-TE) via new env_bool helper; add CK_TILE_BENCH_FLUSH and
  CK_TILE_BENCH_ROTATING knobs so the committed benchmark is fair and
  reproducible without an un-committed patched .so
- MULTI_D_GEMM_BRIDGE.md: document num_d=2 as the headline parity slice
  (correcting the false num_d=1 fair-slice claim), the new timing knobs, and
  a rocprof TODO for the 14/640 4096^3 mem-pipeline outliers
…bridge_gemm_multi_d

# Conflicts:
#	projects/composablekernel/dispatcher/python/gemm_utils.py
The dispatcher multi_d codegen emitted UsePersistentKernel from the swept
'persistent' trait unconditionally, but Old-TE (gemm_instance_builder.py)
wires that flag only for universal/preshuffle/grouped/mx/batched and leaves
gemm_multi_d at the template default (false). For '_True' stems this made the
bridge kernel genuinely persistent, flipping AccumVGPR 224->384 and spilling
32B to scratch -> ~32% slower on small register-bound shapes (e.g. 1024x512x256:
8860ns vs Old-TE 6695ns); large shapes were unaffected.

Gate the flag on the operator so multi_d always emits UsePersistentKernel=false,
matching Old-TE; other variants are unchanged. After the fix the multi_d '_True'
kernel is byte-identical to Old-TE (AccumVGPR 224, Scratch 0) and A/B parity
collapses from ~-34% to -0.13%; a gemm_universal '_True' kernel still emits
UsePersistentKernel=true (no regression).
@ozturkosu

Copy link
Copy Markdown
Contributor Author

Fix: multi_d bridge no longer forces UsePersistentKernel (commit 0b237d6)

Symptom

On small/register-bound shapes, the multi_d bridge kernels whose stem ends in the persistent flag _True were measurably slower than Old-TE (e.g. 1024×512×256: 8860 ns vs Old-TE 6695 ns, ~+32%). Large/compute-bound shapes were already at parity, so this only showed up on tiny GEMMs.

Root cause

The two codegens handle the swept persistent trait differently for gemm_multi_d:

  • Old-TE (tile_engine/ops/gemm/gemm_instance_builder.py) wires UsePersistentKernel from the persistent flag only for gemm_universal / gemm_preshuffle / grouped_gemm / mx_gemm / batched_gemm. gemm_multi_d is intentionally excluded, so it takes the TileGemmUniversalTraits template default UsePersistentKernel = false. The persistent dimension is effectively a no-op for multi_d there.
  • The bridge (dispatcher/codegen/unified_gemm_codegen.py) emitted UsePersistentKernel = {tr.persistent} unconditionally, so multi_d _True stems became genuinely persistent.

A persistent kernel uses a grid-stride loop that needs more accumulator registers: rocprof showed the _True bridge kernel at AccumVGPR 384 + 32 B scratch spill vs Old-TE's 224 / 0, which is what cost ~32% on register-bound small shapes. (_launch_function_multi_d already uses plain GridSize, so the trait bool was the only divergence.)

Fix

Gate the flag on the operator so multi_d always emits false, mirroring Old-TE; all other variants are unchanged:

use_persistent_kernel = tr.persistent and config.variant != GemmVariant.MULTI_D
...
static constexpr bool UsePersistentKernel = {str(use_persistent_kernel).lower()};

Stem names keep the _True/_False suffix (matching Old-TE, which names them the same way), and both now compile to the identical non-persistent kernel.

Verification (MI300X / gfx942, quiet node, warmup=50/repeat=100, A/B interleaved, rocprofv3)

metric (1024×512×256, _True) before after Old-TE
AccumVGPR 384 224 224
Scratch 32 B 0 0
GPU kernel duration 8860 ns 5492 ns 5572 ns
A/B gap (median-15, TFLOPS) ~-34% -0.13%
  • Post-fix mangled kernel name is byte-identical to Old-TE.
  • Regression check: a gemm_universal _True kernel still emits UsePersistentKernel = true (non-multi_d variants unaffected).
  • The large-shape 4096³ _True case that looked like -11.4% in an earlier sweep was compile-time contention; a clean re-measure is -1.0% (parity).

Net: multi_d bridge is now at kernel-level parity with Old-TE across the shape range, including the small _True stems.

@ozturkosu
ozturkosu requested review from yraparti and removed request for yraparti July 21, 2026 03:36
@ozturkosu

Copy link
Copy Markdown
Contributor Author

Architecture-default fix (per review feedback)

Addressed the feedback "don't default to gfx942; use get_arch and throw an error for unsupported arch types", scoped to this PR's own multi_d files.

Changed — bindings/ctypes/multi_d_gemm_ctypes_lib.cpp:
Replaced the silent gfx942 fallback with a hard compile-time failure:

#ifndef GFX_ARCH
#error "GFX_ARCH must be defined at compile time (pass -DGFX_ARCH=<arch>); do not default to a specific GPU architecture."
#endif

The build harness already passes -DGFX_ARCH=<arch> (resolved from get_arch/rocminfo), so a missing arch now surfaces as a loud build error instead of silently baking in gfx942 (which would mis-select MFMA/FNUZ paths on gfx90a/gfx950).

Why this is the whole change here: the requested Python _get_arch() helper / _DEFAULT_ARCH removal targets *multi_d*_utils.py, but no such file exists in this PR. The only Python gfx_arch = "gfx942" default lives in the shared python/gemm_utils.py, which is out of scope for this PR and should be fixed centrally so all bridges (universal, grouped, stream-K, multi_d) benefit consistently.

Verified: clang-format-18 -style=file clean. Commit aad43fd173.

@ozturkosu

Copy link
Copy Markdown
Contributor Author

Whole-config perf parity — multi_d bridge vs Old Tile-Engine (MI300X / gfx942)

Ran the comprehensive default-config config-space (not just a shape sweep):
gemm_multi_d/configs/default_ci_config.json full trait × epilogue × op × num_d space
at the canonical 128×128×64 / 32×32×16 tile, across all 4 layouts
(rcrr/rrrr/crrr/ccrr), fp16, k_batch=1, on the both-engines-buildable intersection.

Verdict: AT PARITY.

metric value
valid timed rows 596 (128 fair stems × 5 shapes, minus non-running kernels)
median gap +0.15 %
mean gap +0.41 %
within ±5 % / ±10 % / ±15 % 92.1 % / 97.8 % / 100 %
surviving gap

gap = (bridge − old)/old × 100 (+ = bridge faster). Shapes: 512³, 1024³, 2048³,
1024×512×256, 4096³.

Per-layout medians: rcrr +0.38 %, rrrr +0.21 %, crrr +0.15 %, ccrr +0.03 % (all 100 %
within ±15 %). Per-op: MultiDAdd +0.30 %, MultiDMultiply +0.05 %. Per-pipeline:
compv3 +0.90 %, compv4 −1.25 %, mem +0.28 %.

Fair comparison: Old-TE multi_d bakes DsDataType=tuple<D0,D1> (num_d=2) and the
elementwise op at CMake time (two Old-TE builds, add + mul). The byte-identical fair
set is num_d=2 on both sides; the bridge's num_d=1 kernels do less epilogue work and
are reported as bridge-only coverage, not timed as parity. Fairness: 50/100
warmup/repeat + flush + identical rotating_count both sides; Old-TE built from its own
CMake with real TE codegen flags; bridge .so stale-guarded and built fresh from PR HEAD
aad43fd1732; A/B interleaved per (stem,shape), median-of-3; Old-TE tflops read from
the full-precision field; per-kernel fp32 correctness gate before timing.

Correctness note (separate from perf): 44/640 rows are non-running kernels
(status −1, dominated by the multi_d mem pipeline across all layouts). They fail
the fp32 gate at every shape on the bridge path, so they are excluded from timing (a
non-running kernel is not a perf gap) — worth a follow-up correctness look at the
multi_d mem pipeline, independent of this bridge comparison.

CSV + full summary attached.

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

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Comment on lines +518 to 533
return f"""// Multi-D symbol exports for the single-include ctypes lib.
using ALayout = {ns_name}::ALayout;
using BLayout = {ns_name}::BLayout;
using CLayout = {ns_name}::CLayout;
using DsDataType = {ns_name}::DsDataType;
using DsLayout = {ns_name}::DsLayout;
using DLayout = {ns_name}::DLayout;
using ElementWiseFn = {ns_name}::ElementWiseFn;
static constexpr ck_tile::index_t NumDTensor = {ns_name}::NumDTensor;
using GemmMultiDArgs = {ns_name}::GemmMultiDArgs;
// Multi-D signature descriptors (consumed by the ctypes lib / KernelKey).
#define GEMM_KEY_MULTI_D 1
#define GEMM_KEY_NUM_D_TENSORS {config.num_d_tensors}
#define GEMM_KEY_ELEMENTWISE_OP "{config.elementwise_op}"
#define GEMM_KEY_D_LAYOUT "{config.d_layout}"
"""
Comment on lines +47 to +51
from gemm_utils import ( # noqa: E402
MultiDGemmProblem,
GpuMultiDGemmRunner,
_multi_d_layout_from_kernel_name,
)
@ozturkosu

Copy link
Copy Markdown
Contributor Author

MI350 / gfx950 build break — diagnosis (blocks the gfx950 parity run)

Building this branch (HEAD aad43fd1) for gfx950 fails to compile ck_tile/core.hpp (20 errors), which blocks both the bridge .so and the Old-TE benchmark_single (both include core.hpp) — so no gfx950/MI350 parity number can be produced from this branch as-is.

Root cause

include/ck_tile/core/arch/mma/wmma/wmma_gfx12.hpp defines the GFX1250 amdgcn_mma specializations, which call gfx1250-only builtins:

__builtin_amdgcn_wmma_f32_16x16x4_f32
__builtin_amdgcn_wmma_f32_16x16x32_bf16
__builtin_amdgcn_wmma_f32_16x16x64_fp8_fp8   (compiler even suggests the gfx9 smfmac_* equivalent)
...

On this branch those specializations are gated only by SFINAE (enable_if_target_gfx1250_t<CompilerTarget>). SFINAE controls template selection, but the compiler still parses the builtin names — and they are undeclared for gfx950, giving use of undeclared identifier errors.

Already fixed on develop

Current develop wraps the entire GFX1250 block in a preprocessor guard #if defined(__gfx125__)#endif, which textually excludes it on non-gfx1250 targets, so core.hpp compiles cleanly for gfx950 on develop. This branch is ~624 commits behind develop and predates that guard.

Fix

Rebase / merge this branch onto current develop to pick up the #if defined(__gfx125__) guard in wmma_gfx12.hpp; the gfx950 build then succeeds and the MI350 parity sweep can run. The gfx942 / MI300X twin already measured at parity, so parity is the expected gfx950 outcome once the header compiles. This is neither a bridge defect nor a develop defect — purely branch staleness.

Control

The sibling mx_gemm branch, which still carries the older wmma_gfx12.hpp, compiled core.hpp for gfx950 with 0 errors on the same node (and finished its full sweep) — confirming the break is this branch's newer-but-unguarded GFX1250 block, not the toolchain or the bridge.

@ozturkosu

ozturkosu commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Arch-agnostic fix — no more silent gfx942 default (commit ec3dc0ed0d)

(no silent GPU-arch default; resolve dynamically or throw):

  • gemm_utils.py (shared): added _get_arch() (rocminfo detect → validate gfx90a/gfx942/gfx950 → raise, never defaults) + _resolve_arch(); GemmKernelConfig.gfx_arch default "gfx942"Optional[str] = None; expand_sweep() + setup_multiple_gemm_dispatchers() resolve the arch so -DGFX_ARCH/--offload-arch never see None.
  • gemm_multi_d_full_benchmark.py: --arch default "gfx942"None.
  • The multi_d ctypes C++ (multi_d_gemm_ctypes_lib.cpp) already used #ifndef GFX_ARCH / #error — left as-is.

Verification: py_compile clean; gemm/multi_d/arch CPU suite 79 passed / 63 skipped (only pre-existing GPU-example failures, unrelated).

Next: fixing the separate gfx950 build break on this branch (staleness vs develop) so it can be verified on MI350, then GPU build+run on gfx942 and gfx950.

ozturkosu added a commit that referenced this pull request Jul 22, 2026
## Summary
Adds a microscaling-GEMM (`mx_gemm`) bridge from the Old Tile-Engine
into the dispatcher's ctypes path for gfx950/MI350. Supports **fp4**
(`pk_fp4_t`, e2m1) and **fp8** (e4m3), **rcr** layout, `comp_async` +
`cshuffle` + `intrawave`, fixed `16x16x128` warp tile, `k_batch == 1`.
Per-32-K `e8m0` block scales are pre-shuffled on-host exactly as the
Old-TE profiler does.

## Motivation
Extend the TE→Dispatcher bridge family to the microscaling GEMM op so
the dispatcher can launch byte-identical `mx_gemm` kernels with block
scaling. Sibling to the other bridge PRs (see below).

## Design note
- **Byte-exact kernels:** the codegen reuses Old-TE
`MxGemmKernelBuilder._generate_kernel_instance` directly rather than
re-implementing header assembly, guaranteeing the emitted kernel matches
Old-TE. It only strips the stale `ck_tile/ops/gemm_mx.hpp` umbrella
include (absent on develop; the mx pipeline is pulled in via
`ck_tile/ops/gemm.hpp`).
- **Registry bypass:** `mx_gemm`'s `launch` takes
`ck_tile::MxGemmHostArgs` with per-32-K `e8m0` scales that the generic
dispatcher backend cannot express, so the ctypes lib builds
`MxGemmHostArgs` from plain C arrays and calls
`SelectedKernel::launch()` directly — the same direct-launch pattern as
the batched/multi-D bridges.
- **Scale pre-shuffle** mirrors `mx_gemm_profiler.hpp`; pack params are
derived from `SelectedKernel` tile dims at compile time (not hardcoded).
- **Packing-correct byte accounting:** device buffers are sized via
`HostTensor::get_element_space_size_in_bytes()`, which divides by
`numeric_traits<T>::PackedSize`, so fp4 (`PackedSize==2`, two e2m1 per
byte) and fp8 (`PackedSize==1`) are both correct.



## Verification
- fp8 derisk: PASS (`max_rel = 0.0`, kernel-name match, output fully
non-zero) on gfx950
- fp4: GPU-verified on gfx950
- `clang-format-18 -style=file` clean; Python `py_compile` clean



## Sibling PRs
- #8997 — TE→dispatcher GEMM bridge (fp16/bf16, all layouts) —
foundational (merged)
- #8998 — fp8/bf8/int8 bridge (merged)
- #9000 — grouped GEMM
- #9028 — stream-K GEMM
- #9305 — multi-ABD GEMM
- #9306 — batched GEMM
- #9307 — preshuffle GEMM
- #9308 — multi-D GEMM
- #9328 — batched-contraction GEMM

---------

Co-authored-by: Muhammed Emin Ozturk <3836908+ozturkosu@users.noreply.github.com>
@ozturkosu

Copy link
Copy Markdown
Contributor Author

gfx950 / MI350 A/B parity + functional equivalence (arch-fixed head ec3dc0e)

Built FRESH from PR tip on smci355 (gfx950, ROCm 7.2). This closes the second-arch gap (gfx942/MI300X already posted).

VERDICT: AT PARITY. Bridge builds, runs, and is numerically correct on gfx950; A/B timing at parity with Old-TE.

Functional equivalence

  • Old-TE multi_d gfx950 surface = fp16 / rcrr / MultiDMultiply (default -DGEMM_MULTI_D_ELEMENTWISE_FUNCTION=mul) / num_d=2. Bridge covers the FULL matched trait set: 16/16 kernels built (3 pipelines x 2 epilogues x scheduler x persistent). No coverage asymmetry.
  • Correctness: fp32 ref E=(A@B)*D0*D1, max rel-err 6.28e-04 across all 80 (kernel,shape) cases; Old-TE self-verify also passes.

Performance (80 rows = 16 kernels x {512,1024,2048,1024x512x256,4096}^3, A/B interleaved median-3)

scope n median within +/-5% within +/-15%
overall 80 -0.0% 88.8% 95.0%
epilogue=cshuffle 40 +0.15% 100% 100%
epilogue=default 40 -0.04% 77.5% 90%
pipeline=compv3/compv4 40 ~0% 90% 100%
pipeline=mem 40 +0.15% 87.5% 90%

gap = (bridge-old)/old (negative = bridge faster).

|gap|>15% outliers — standalone median-5 re-measure: ALL PERSIST, bridge FASTER

mem+default @ 4096^3: -17.5% to -19.5% (bridge 0.178-0.183 vs old 0.216-0.228 ms, tight variance). Reproducible, correctness identical -> a codegen win in the mem+default path, not a regression. cshuffle epilogue is 100% within +/-5%.

Fairness

  • warmup50/repeat100/flush/rotating1000 both sides; the multi_d ctypes lib is fair-by-default (overrides the unfair generic-backend defaults).
  • Identical -mllvm flag set both sides (--lsr-drop-solution=1, -enable-post-misched=0, -amdgpu-early-inline-all=true, -amdgpu-function-calls=false, -fno-offload-uniform-block, -O3); coerce-illegal-types probe rejected on both (matched).
  • A/B interleaved per (kernel,shape); Old-TE timing read from its 4-dp CSV (JSON stdout is 2-dp and quantizes tiny-kernel latency, fabricating phantom small-shape gaps that vanish at 4-dp).

@ozturkosu

Copy link
Copy Markdown
Contributor Author

Conflict-proofing update (merged develop, made shared regions byte-identical)

Merged origin/develop into this branch (no rebase). This PR was already mergeable and stays MERGEABLE; it now carries the shared-region superset so it won't re-conflict after a sibling merges. New tip: 895f62bc3a0.

What changed here (append-only identical-superset strategy):

Why: so that after ANY sibling bridge PR merges to develop, the others do not re-conflict on these shared files.

Note (transparency): the shared gemm_utils.py regions are byte-identical across all four branches, so cross-merges are conflict-free — except the pair #9305 <-> #9308, whose only residual is that both branches add a large op-specific runner class (MultiABDDispatcherLib vs MultiDGemmProblem) at the same file location. That is a trivially-resolvable "both-added" conflict of distinct operator code (keep both), not a shared-region conflict.

Validated: test_gemm_utils.py + test_multi_d_bridge.py pass (27 tests).

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