Skip to content

Add Hopper FP8 grouped blockwise GEMM (sparse-groups) CuTeDSL example#3195

Open
Johnsonms wants to merge 4 commits into
NVIDIA:mainfrom
Johnsonms:cutedsl/hopper-fp8-grouped-blockwise-sparse-groups
Open

Add Hopper FP8 grouped blockwise GEMM (sparse-groups) CuTeDSL example#3195
Johnsonms wants to merge 4 commits into
NVIDIA:mainfrom
Johnsonms:cutedsl/hopper-fp8-grouped-blockwise-sparse-groups

Conversation

@Johnsonms

@Johnsonms Johnsonms commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a CuTeDSL port of CUTLASS Example 68's sparse-groups variant (68_..._grouped_gemm_with_blockwise_scaling_with_sparse_groups.cu) at examples/python/CuTeDSL/hopper/dense_gemm_fp8_grouped_blockwise_sparse_groups.py.

Same per-row SFA (ScaleGranularityM = 1) + blockwise SFB (ScaleGranularityN = 128) FP8 grouped GEMM as the dense Example 68 port, plus host-side support for problem distributions where many groups have zero problem sizes.

Sparse-groups behaviour:

  • --problem_sizes accepts groups with any zero dim. Validation only requires multiples of 128 for non-empty groups.
  • Empty groups share a single stub GMEM allocation, so the metadata pointer table is always valid (the kernel never reads from the stubs).
  • The host zeros all dims of every empty group before handing the problem-size table to the kernel. The persistent group tile scheduler computes a group's tile count from MN alone, so a group with M, N > 0 but K = 0 would otherwise consume MN linear tile slots and offset every later group's tiles. Forcing all dims to zero makes the scheduler reserve zero linear tiles for empty groups uniformly. The original sizes are kept for the reference and bandwidth paths.
  • Reported GBPS uses the original (un-padded) sizes; empty groups contribute nothing to throughput.
  • --sparse_fraction (with --seed) randomly empties a fraction of groups; the all-empty case is short-circuited before kernel launch.

Schedule note: the C++ source uses KernelPtrArrayTmaWarpSpecializedPingpongFP8Blockwise. This Python port keeps the cooperative schedule from the dense Example 68 variant (atom_layout_mnk = (2, 1, 1)). The sparse-groups host-side semantics are independent of pingpong vs cooperative scheduling, and the per-WG tensormap workspace required for true pingpong with grouped GEMM is left as a follow-up.

Test plan

  • Bit-exact match for dense (no empty groups) across all four cluster shapes
  • Bit-exact match with explicit empty groups (M=0, N=0, K=0 in different positions, mixed)
  • Bit-exact match with --sparse_fraction 0.5 / 0.9 / 1.0
  • compute-sanitizer --tool memcheck clean (including with --use_cold_l2)
  • All-empty case short-circuits before kernel launch
  • Per-iteration JitArguments._keepalive ensures --use_cold_l2 pre-generated workspaces survive the full benchmark
  • Performance comparison against the C++ Example 68 sparse-groups binary on H100

Correctness

Config Result
Default 4 groups, dense, all four cluster shapes PASS × 4
--num_groups 5 --problem_sizes "(256,256,256),(0,256,256),(128,256,256),(256,0,256),(256,256,0)" PASS (2 active groups)
--num_groups 8 --sparse_fraction 0.5 --seed 11 PASS (4 active groups)
--num_groups 8 --sparse_fraction 0.9 --seed 3 PASS (1 active group)
--num_groups 4 --sparse_fraction 1.0 PASS (no work, short-circuited)

compute-sanitizer --tool memcheck, sparse + --use_cold_l2: 0 errors.

Baseline performance — initial port only (H100 80GB HBM3, FP8 E4M3FN)

Numbers from the initial port (commit e1bcd94a, no perf optimizations). All non-empty groups M=N=K=2048, --sparse_fraction 0.5 --seed 11 --cluster_shape_mn 1,2, 200 iter + 10 warmup. The C++ binary's --m=N --groups=K mode randomizes per-group sizes, so its GFLOPS reflect a different work distribution than CuTeDSL's "exactly half-empty" — they are not directly comparable but are included as a sanity baseline.

Groups (half empty) C++ runtime (random sizes) C++ TFLOPS CuTeDSL runtime CuTeDSL TFLOPS
16 (8 active 2048³) 0.829 ms 332 0.159 ms 863
64 (32 active 2048³) 3.275 ms 336 0.592 ms 929

CuTeDSL numbers reflect actual non-empty work only and are the appropriate signal for "throughput on the active groups".

Performance optimizations on top of the baseline

Three small commits stacked on the initial port. PR4 shares PR3's mainloop and was already near its perf ceiling at baseline, so the cumulative delta is modest — each commit is kept primarily for structural alignment with the rest of the FP8 family.

Commit Optimization Δ (largest case) Notes
520db59c Register split 24/240 (was 40/232) ~0% DMA warpgroup only holds TMA descriptors + barrier addrs. Effect is small here because PR4's mainloop matches PR3's and PR3 was already at 86-88% of the C++ ratio at baseline; the spill this trick eliminates is not the binding constraint.
35420264 Programmatic Dependent Launch +0.8% (16 groups) griddepcontrol.wait + use_pdl=True. Visible on the smallest-runtime configuration.
e663f652 Lazy tma_store producer_acquire +0.5% Acquire moved to immediately before the next TMA store instead of trailing the commit. Structural alignment with DeepGEMM.

Cluster-aware swizzle skipped on PR4 — the grouped tile scheduler does not take a swizzle parameter. Two further follow-up opportunities are left out of scope: TMA-loaded per-row SFA staged through SMEM (see PR2 / DeepGEMM sm90_fp8_gemm_1d2d.cuh:192-200), and the in-kernel "compute-invalid but pipeline-valid" tile path that DeepGEMM uses to skip work for unfilled rows without stalling the barrier (sm90_fp8_gemm_1d2d.cuh:255-352) — this last one is particularly relevant for very sparse PR4 workloads.

Final performance

Same harness as the baseline table:

Groups (half empty) C++ runtime C++ TFLOPS CuTeDSL runtime CuTeDSL TFLOPS Δ vs baseline
16 (8 active 2048³) 0.829 ms 332 0.157 ms 873 +1%
64 (32 active 2048³) 3.275 ms 336 0.590 ms 931 ~0%

C++: examples/68_..._with_blockwise_scaling_with_sparse_groups --m=2048 --n=2048 --k=2048 --groups=N --iterations=200. CuTeDSL: --iterations 200 --warmup_iterations 10 --sparse_fraction 0.5 --seed 11 --skip_ref_check. For an apples-to-apples C++ comparison the maintainers should generate a benchmark file (--benchmark=path.txt) listing exactly the same half-empty problem distribution.

CuTeDSL port of CUTLASS Example 68's sparse-groups variant
(68_..._grouped_gemm_with_blockwise_scaling_with_sparse_groups). Same
per-row SFA (ScaleGranularityM = 1) + blockwise SFB (ScaleGranularityN
= 128) FP8 grouped GEMM as the dense Example 68 port; the host driver
adds support for problem distributions where many groups have zero
problem sizes.

Sparse-groups behaviour:

  - --problem_sizes accepts groups with any zero dim. Validation only
    requires multiples of 128 for non-empty groups.

  - Empty groups share a single stub GMEM allocation, so the metadata
    pointer table is always valid (the kernel never reads from the
    stubs).

  - The host zeros all dims of every empty group before handing the
    problem-size table to the kernel. The persistent group tile
    scheduler computes a group's tile count from M*N alone, so a group
    with M, N > 0 but K = 0 would otherwise consume M*N linear tile
    slots and offset every later group's tiles. Forcing all dims to
    zero makes the scheduler reserve zero linear tiles for empty
    groups uniformly. The original sizes are kept for the reference
    and bandwidth paths.

  - Reported GBPS uses the original (un-padded) sizes; empty groups
    contribute nothing to throughput.

  - --sparse_fraction (with --seed) randomly empties a fraction of
    groups; the all-empty case is short-circuited before kernel launch.

Schedule note: the C++ source uses
KernelPtrArrayTmaWarpSpecializedPingpongFP8Blockwise. This Python port
keeps the cooperative schedule from the dense Example 68 variant
(atom_layout_mnk = (2, 1, 1)). The sparse-groups host-side semantics
are independent of pingpong vs cooperative scheduling, and the per-WG
tensormap workspace required for true pingpong with grouped GEMM is
left as a follow-up.
@Johnsonms Johnsonms marked this pull request as ready for review May 26, 2026 00:02
Johnsonms added 3 commits May 30, 2026 19:15
Same register split as PR1/PR2/PR3. Effect on PR4 is small (within noise)
because PR4 shares PR3's mainloop and PR3 was already near the C++ ratio
at 86-88%; the spill that this trick eliminates simply was not the binding
constraint here.

Measured on H100 (M=N=K=2048 per active group, cluster 1,2, sparse 0.5,
seed 11, 200 iters):
  16 groups (8 active)  863 GFLOPS  (was 863, ~0%)
  64 groups (32 active) 926         (was 929, ~0% within noise)

Kept for consistency with the rest of the FP8 family.
Insert griddepcontrol_wait at kernel entry, pass use_pdl=True on launch.
Same change as PR1/PR2/PR3.

Measured on H100 (M=N=K=2048 per active group, cluster 1,2, sparse 0.5,
seed 11, 200 iters):
  16 groups (8 active)   870 GFLOPS  (was 863, +0.8%)
  64 groups (32 active)  926         (was 926, ~0%)
Move producer_acquire from after producer_commit to before the next TMA
store. Same change as PR1/PR2/PR3.

Measured on H100 (M=N=K=2048 per active group, cluster 1,2, sparse 0.5,
seed 11, 200 iters):
  16 groups (8 active)   873 GFLOPS  (was 870, +0.3%)
  64 groups (32 active)  931         (was 926, +0.5%)
@hwu36

hwu36 commented May 31, 2026

Copy link
Copy Markdown
Collaborator

@github-actions

Copy link
Copy Markdown

This PR has been labeled inactive-30d due to no recent activity in the past 30 days. Please close this PR if it is no longer required. Otherwise, please respond with a comment indicating any updates. This PR will be labeled inactive-90d if there is no activity in the next 60 days.

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