[WIP][feat] grouped fp8: add NT/wgrad persistent path and tile-cumsum group lookup for grouped gemm - #436
[WIP][feat] grouped fp8: add NT/wgrad persistent path and tile-cumsum group lookup for grouped gemm#436kyle-256 wants to merge 13 commits into
Conversation
…une, distribution-invariant wgrad NT fwd/dgrad: persistent whole-device path with monotonic group-carry and a group_m/group_n L2-reuse swizzle grid, raced per shape against the 8-wave base. Epilogue levers (cshuffle ds-chain softpipe, non-temporal C store) and a wide-gm band are raced as byte-exact per-shape candidates. wgrad TN: distribution-invariant flat xcd=1 4-wave selection (minimax score), grid over-subscription tail fix, fully-padded boundary M-block a-half skip.
…le O(G) scan) Precompute per-group cumulative tile counts in a single-program device kernel (same stream, no host sync) and load them once into registers; the persistent main loop then finds each tile's group via a register-resident vectorized lookup instead of an O(G) linear scan over group_offs per tile. Byte-exact vs the previous scan (SNR unchanged). Speedup scales with G and inversely with per-tile work: gpt-oss G=32 K=2880 +3.8%, G=256 +38%, small-K G=256 up to 3.7x. Only the tensorwise persistent forward kernel and its launcher are touched.
There was a problem hiding this comment.
Pull request overview
This PR enhances the grouped FP8 GEMM implementation across both Triton and FlyDSL paths, focusing on improving persistent-kernel efficiency and making wgrad/autotune behavior less sensitive to token distribution skew.
Changes:
- Triton tensorwise persistent kernel: restore a tile-cumsum (prefix-sum) group lookup to eliminate per-tile O(G) scans.
- FlyDSL grouped NT: add persistent big‑N/short‑K path improvements (L2-reuse-aware autotune, optional cshuffle soft-pipe + non-temporal C store aux).
- FlyDSL wgrad: adjust candidate selection + autotune scoring to be more distribution-invariant (balanced + canonical skew synthetic loads) and improve long‑K tail utilization.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| primus_turbo/triton/grouped_gemm/grouped_gemm_fp8_kernel.py | Adds device-side tile-cumsum precompute and register-resident lookup to avoid per-tile group offset scans. |
| primus_turbo/flydsl/utils/gemm_helper.py | Extends CShuffle epilogue store to support optional depth-2 soft-pipe and non-temporal store modifier. |
| primus_turbo/flydsl/grouped_gemm/gemm_fp8_grouped_kernel.py | Updates grouped NT + wgrad kernels and autotuning logic to improve persistence, L2 reuse, and distribution invariance. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| max_g_next_pow2 = _next_pow2(G + 1) | ||
|
|
Land the per-tensor fp8 (tensorwise) grouped-GEMM optimizations: - gemm_fp8_grouped_kernel.py: NT fwd/dgrad + NN dgrad/wgrad tuning (lane-DPP group lookup, K-aware GROUP_M band, XCD/gm config levers). - fp8_tensorwise_quant_flydsl.py (new): FlyDSL cast-and-pad quant that writes 128-aligned (Kp=ceil128(K)) fp8 operands, killing the K=2880 leading-dim cache-line misalignment tax; real data in [:, :K], pad columns zeroed, pad rows HW-dropped via num_records. - grouped_gemm_fp8.py: wire the padded quant into FP8GroupedGemmTensorFunc when K % 128 != 0; aligned-K path unchanged. - gemm_helper.py: shared FlyDSL helpers (_lane_tbl_*, scale/store). mxfp8_grouped_kernel.py is intentionally the github (fefa6f8) version.
| ctx.save_for_backward(a_q, b_q, a_sc, b_sc, group_lens, go) | ||
| ctx.trans_a = False | ||
| ctx.trans_b = trans_b | ||
| ctx.use_nt_layout_gemm_in_bwd = False | ||
| ctx.config = config |
Fold the K-padded per-tensor fp8 quant into the existing quantize_fp8_tensorwise op via an int padding_align_size=128 param (mirrors the mxfp8 quant interface; padding is the default). The op now casts input rows of length K into output rows of length Kp=ceil(K/align)*align, real columns [0,K) byte-identical for the same scalar scale and columns [K,Kp) zeroed. This feeds the grouped GEMM 128-aligned operands and kills the K%128 leading-dim cache-line split tax on the real gpt-oss hidden K=2880. - new HIP kernel quantize_tensorwise_pad_impl (2D/3D via rows=numel/K) - merged host + meta paths; drop the separate _pad op - general Float8Quantizer path opts out with padding_align_size=1 (Kp==K, byte-identical to legacy) - wire the tensorwise grouped-GEMM forward K-pad branch to the HIP impl - delete the FlyDSL fp8 tensorwise quant implementation Verified byte-exact on real columns, pad columns zero, deterministic, and e2e through the public grouped_gemm_fp8 (SNR 55.6dB vs fp8-ideal).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (2)
primus_turbo/triton/grouped_gemm/grouped_gemm_fp8_kernel.py:441
tile_startis computed via a full-vector compare +tl.where+tl.sum, which adds an extra O(MAX_G_NEXT_POW2) reduction per tile. Sincegroup_idxis already a scalar, this can be loaded directly fromtile_cumsum_ptr(same astotal_tilesabove) to reduce instruction count and register pressure in the hot loop.
le_mask = (tile_cumsum_arr <= global_tile_id) & (g_idx_arr >= 1) & (g_idx_arr <= G)
group_idx = tl.sum(le_mask.to(tl.int32))
# tile_start = tile_cumsum[group_idx]
tile_start = tl.sum(tl.where(g_idx_arr == group_idx, tile_cumsum_arr, 0))
primus_turbo/pytorch/ops/grouped_gemm_fp8.py:468
- The new K-pad fast path is guarded on
K % 128 != 0andK >= 129, but the existing grouped GEMM FP8 test matrix only exercises 128-aligned K values. That means this new forward/backward path (including the gradient slicing back to the real K) is currently untested.
# K-pad fast path: a non-128-aligned K makes the fp8 row stride split each vector load across a 128B line, so cast-and-pad both operands to Kp=ceil128(K) (pad cols contribute 0*0=0, GEMM unchanged). Raw NT inputs only.
_kpad = (
trans_b
and not isinstance(a, QuantizedTensor)
and not isinstance(b, QuantizedTensor)
and a.dim() == 2
and b.dim() == 3
and (a.shape[-1] % 128 != 0)
and a.shape[-1] >= 129
)
if _kpad:
from primus_turbo.flydsl.grouped_gemm.gemm_fp8_grouped_kernel import (
grouped_gemm_fp8_tensorwise_flydsl_kernel,
)
from primus_turbo.pytorch.kernels.quantization.quantization_impl import (
quantize_fp8_tensorwise_pad_impl,
)
go = group_offs if group_offs is not None else group_offs_from_lens(group_lens)
fp8_dtype = _get_fp8_dtype(config.format, True)
a_q, a_sc = quantize_fp8_tensorwise_pad_impl(a, fp8_dtype) # [M, Kp]
b_q, b_sc = quantize_fp8_tensorwise_pad_impl(b, fp8_dtype) # [G, N, Kp]
out = grouped_gemm_fp8_tensorwise_flydsl_kernel(
a_q,
b_q,
a_sc,
b_sc,
go,
trans_b=True,
out_dtype=out_dtype,
num_cu=(num_cu if num_cu is not None else -1),
)
ctx.save_for_backward(a_q, b_q, a_sc, b_sc, group_lens, go)
ctx.trans_a = False
ctx.trans_b = trans_b
ctx.use_nt_layout_gemm_in_bwd = False
ctx.config = config
ctx.out_dtype = out_dtype
ctx.num_cu = num_cu
ctx.k_pad_real = a.shape[-1]
return out
…fp8 kernel Pre-existing CI code-lint debt on this branch (not from the quant refactor): - gemm_fp8_grouped_kernel.py: 12 F841 dead vars in the half-N-skip _do_body path (a_cur*/b_*/c**_frag outer decls shadowed by inner locals) + I001 import order. - gemm_helper.py + gemm_fp8_grouped_kernel.py: ruff-format normalization. Zero-logic change; grouped fp8 GEMM e2e byte-identical (SNR 28.5dB det=True). pre-commit run --all-files green (exit 0).
| # K-pad fast path: a non-128-aligned K makes the fp8 row stride split each vector load across a 128B line, so cast-and-pad both operands to Kp=ceil128(K) (pad cols contribute 0*0=0, GEMM unchanged). Raw NT inputs only. | ||
| _kpad = ( | ||
| trans_b | ||
| and not isinstance(a, QuantizedTensor) | ||
| and not isinstance(b, QuantizedTensor) | ||
| and a.dim() == 2 | ||
| and b.dim() == 3 | ||
| and (a.shape[-1] % 128 != 0) | ||
| and a.shape[-1] >= 129 | ||
| ) |
….923 -> 1.077)
Per-tensor (tw) grouped TN variable-K wgrad for gpt-oss-20b MoE now beats its own
frozen fwd (NT) FLOP-rate: geomean t_fwd/t_wgrad 0.9225 -> 1.0768, min 1.0184, over
the 6 deployed configs (gate_up/down x balanced/moderate/heavy), SNR 54.8 dB, byte-det.
Core: runtime-adaptive single-window split-K for the variable-K skew tail -- split one
on-device-picked window's contraction (token) dim into S in {1,2,3,4,8} slices, persistent
WS scratch, a narrow reduce kernel folds the window. Window chosen by rules A/B1/B2 from
group_offs (wave-uniform SALU, no host planner / no D2H); grid = TOTAL + N_MAX*(S_MAX-1)
with live s_endpgm truncation; offsets are division-free via reciprocal-multiply;
guardrails k_iters<6*S and TOTAL<NCU fall back to S=1. Plus A-pool step-interleave,
per-band XCD affinity gate, and S=3 (rule-A tail only).
The A-pool step-interleave is gated to the wgrad (a_plain=False) path so the shared NN
dgrad trace stays byte-identical to base; fwd (NT) is untouched.
gemm_helper: add StoreCPerTensor c_base to steer a tile's store to a scratch band of the
same row pitch (needed by the split-K slices), and factor the LDS row swizzle into
lds_row_swizzle (byte-identical to the prior fixed-128 form).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (3)
csrc/kernels/quantization/quantization_tensorwise.cu:156
quantize_tensorwise_pad_implassumes the padded row lengthKpis divisible by the chosen vectorUNROLL(it computescols_per_row = Kp / UNROLLand only processescols_per_row * UNROLLcolumns). Right nowpack_sizeis chosen based onKonly, so if a caller passes a non power-of-twopadding_align_size(makingKpnot divisible bypack_size/UNROLL), the kernel can leave the lastKp % UNROLLcolumns unwritten.
// Kp is a 128-multiple so Kp % pack == 0 for any pack in {8,4,2,1}. Require
// K % pack == 0 too so the per-row input base (row*K) keeps vector alignment.
int32_t pack_size = std::min(get_pack_size<FType>(x), get_pack_size<QType>(y));
while (pack_size > 1 && (K % pack_size != 0)) {
pack_size /= 2;
}
csrc/pytorch/quantization/quantization.cpp:56
padding_align_sizeis part of the public op API and currently only checked for>= 1, but the pad kernel relies on alignment properties (seequantize_tensorwise_pad_impl). Without constraining this (e.g., power-of-two), callers can select values that produce aKpnot compatible with vector packing, leading to incorrect output.
PRIMUS_TURBO_CHECK(input.is_contiguous(), "input must be contiguous");
PRIMUS_TURBO_CHECK(input.dim() >= 1, "input must have at least 1 dim");
PRIMUS_TURBO_CHECK(padding_align_size >= 1, "padding_align_size must be >= 1");
auto stream = at::cuda::getCurrentCUDAStream();
const int64_t K = input.size(-1);
const int64_t Kp = cdiv(K, padding_align_size) * padding_align_size;
const int64_t rows = input.numel() / std::max<int64_t>(K, 1);
csrc/pytorch/bindings_pytorch.cpp:32
- Changing the default
padding_align_sizeto 128 changes the output shape for any direct caller oftorch.ops.primus_turbo_cpp_extension.quantize_fp8_tensorwise(...)that relies on the previous shape-preserving behavior. Consider keeping the default shape-preserving (padding_align_size=1) and letting higher-level wrappers opt into padding explicitly (as this PR already does).
m.def("quantize_fp8_tensorwise(Tensor input, ScalarType dest_dtype, Tensor? scale_opt=None, "
"int padding_align_size=128) -> Tensor[]");
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (3)
primus_turbo/pytorch/ops/grouped_gemm_fp8.py:432
- This K-pad fast path directly calls the FlyDSL kernel without checking FlyDSL backend support (e.g., grouped fp8 FlyDSL is gated to gfx950 / compute capability >= (9,5) in
GroupedGEMMFP8FlyDSLBackend.is_supported). On other GPUs, this branch can run and fail at compile/runtime.
# K-pad fast path: a non-128-aligned K makes the fp8 row stride split each vector load across a 128B line, so cast-and-pad both operands to Kp=ceil128(K) (pad cols contribute 0*0=0, GEMM unchanged). Raw NT inputs only.
_kpad = (
trans_b
and not isinstance(a, QuantizedTensor)
and not isinstance(b, QuantizedTensor)
csrc/pytorch/bindings_pytorch.cpp:32
- The new
padding_align_sizeargument defaults to 128, which changesquantize_fp8_tensorwisebehavior for any existing callers that don’t pass this arg (output becomes K-padded and shape changes). To preserve backward-compatible, shape-preserving semantics, the default should be 1 and padding callers should explicitly pass 128.
m.def("quantize_fp8_tensorwise(Tensor input, ScalarType dest_dtype, Tensor? scale_opt=None, "
"int padding_align_size=128) -> Tensor[]");
primus_turbo/pytorch/ops/grouped_gemm_fp8.py:428
- The new K-padding branch introduces a distinct quantize+pad path and post-backward slicing, but existing FP8 grouped GEMM tests appear to use only K values divisible by 128, so this behavior likely isn’t exercised. Adding at least one test case with
trans_b=Trueand a non-128-alignedK(e.g., K=129/130) would cover both forward correctness and backward slicing.
# K-pad fast path: a non-128-aligned K makes the fp8 row stride split each vector load across a 128B line, so cast-and-pad both operands to Kp=ceil128(K) (pad cols contribute 0*0=0, GEMM unchanged). Raw NT inputs only.
…d yardstick (gpt-oss)
Goal: make gpt-oss per-tensor (tensorwise) grouped fwd (NT) and dgrad (NN) beat, in
FLOP-rate, the already-optimized tw wgrad (TN var-K) that is treated as a frozen yardstick,
across balanced/moderate/heavy token distributions. Score = geomean(t_wg/t_fwd, t_wg/t_dg)
over 12 configs (2 proj x 3 dist x {fwd,dgrad}), measured with in-process interleaved A/B
(drift/DVFS immune). Shapes locked to the e2e regime: G=4 (EP8), M=131072, K-pad KP=2944,
KH=2880, gate_up N=5760, down N=2880.
Results (measured on GPU3 chi2798, drift-immune):
- gm_ratio 0.9226 -> 0.9453 (+2.5%), no regression across the 12 configs.
- min_ratio 0.8655 -> ~0.89. Target min>1 not reached: the short pole is still down dgrad
(contract N=2880, %128=64 leading-dim cache-line misalignment); left for follow-up.
- Main gains: P0 per-shape autotune refresh + NT/NN L2-reuse/feed tuning.
Constraint (verified byte-exact): the wgrad var-K kernel
grouped_gemm_fp8_variable_k_tensorwise_flydsl_kernel and its shared helpers emit identical
ISA (frozen); only the NT/NN path (fly_tw) is changed.
Changes:
- primus_turbo/flydsl/grouped_gemm/gemm_fp8_grouped_kernel.py -- NT/NN kernel + autotune
- primus_turbo/flydsl/utils/gemm_helper.py -- NT/NN shared emit helper
… occ=2 (gpt-oss) down-dgrad contract dim N_out=2880 is the short pole; the NN path const_expr gate now allows BLOCK_N=128, cutting LDS 160KB->80KB and raising occupancy 1->2. min_ratio 0.896->0.914, gm_ratio -> 0.961 (GPU3/gfx950 kernel-only, byte-exact determinism, SNR 54.7dB). The frozen wgrad yardstick ISA is untouched.
Integrates upstream through 33d9f30. Key upstream changes reconciled with the grouped fp8 tensorwise work on this branch: - PR #437 removed the force_nt path in tensorwise grouped GEMM: dgrad (grad_a) now always runs NN (trans_b = not fwd_trans_b) over the same B tensor; there is no transpose cache. The branch's K-pad fast path already targets NN dgrad, so the dead ctx.use_nt_layout_gemm_in_bwd flag it set is dropped. - PR #444 added a pack16 fast path for aligned tensorwise FP8 casts; kept it alongside this branch's separate K-padded quant kernel (dispatched by padding_align_size, so pack16 serves the align=1 shape-preserving path). - Upstream mxfp4 SR support in quantization_impl.py preserved (disjoint region).
84bcec4 to
b668699
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (4)
primus_turbo/pytorch/ops/grouped_gemm_fp8.py:430
- The new K-pad fast path calls the FlyDSL grouped GEMM kernel unconditionally when
_kpadis true. FlyDSL tensorwise grouped GEMM is gfx950-only (seeGroupedGEMMFP8FlyDSLBackend.can_handle), so this will raise or miscompile on other devices instead of falling back to the existing quantize+Triton path.
if _kpad:
from primus_turbo.flydsl.grouped_gemm.gemm_fp8_grouped_kernel import (
grouped_gemm_fp8_tensorwise_flydsl_kernel,
)
from primus_turbo.pytorch.kernels.quantization.quantization_impl import (
primus_turbo/pytorch/ops/grouped_gemm_fp8.py:420
- This new K-pad branch introduces a separate execution path, but the existing grouped_gemm_fp8 tests only exercise K values that are multiples of 128. That means the
_kpadbehavior (pad+quantize+kernel + backward slicing) is currently untested.
# K-pad fast path: a non-128-aligned K makes the fp8 row stride split each vector load across a 128B line, so cast-and-pad both operands to Kp=ceil128(K) (pad cols contribute 0*0=0, GEMM unchanged). Raw NT inputs only.
_kpad = (
trans_b
and not isinstance(a, QuantizedTensor)
and not isinstance(b, QuantizedTensor)
csrc/pytorch/quantization/quantization_meta.cpp:18
- The runtime
quantize_fp8_tensorwisenow hard-requiresinput.is_contiguous(), but the Meta implementation doesn’t check this. That can cause torch.compile/meta shape propagation to succeed and then fail at runtime for non-contiguous inputs.
PRIMUS_TURBO_CHECK(input.dim() >= 1, "input must have at least 1 dim");
PRIMUS_TURBO_CHECK(padding_align_size >= 1, "padding_align_size must be >= 1");
const int64_t K = input.size(-1);
const int64_t Kp = ((K + padding_align_size - 1) / padding_align_size) * padding_align_size;
csrc/pytorch/bindings_pytorch.cpp:32
- Changing the schema default to
padding_align_size=128makesquantize_fp8_tensorwise(input, dtype)no longer shape-preserving (it will pad K to a 128-multiple). That’s a breaking behavior change for any callers using the op directly; internal call sites already pass an explicit align size (1 for legacy, 128 for pad).
m.def("quantize_fp8_tensorwise(Tensor input, ScalarType dest_dtype, Tensor? scale_opt=None, "
"int padding_align_size=128) -> Tensor[]");
…dy gating (gpt-oss E=32)
Tunes the 4-wave (occ=1) TN wgrad kernel for the gpt-oss-20b E=32 full-model
regime (M_total ~2.36M, gate_up N=5760 / 23 N-blocks, down N=2880 / 12
N-blocks, K-pad 2944). Lifts min_wgrad_tf 2405.9 -> 2810.9 (+16.85%) over the
frozen fwd/dgrad yardstick, no config regressed. Numbers below are the probe
data kept out of the source per code-style.
1. XCD-affine swizzle now sized for deep launches (_wgrad_xcd_span /
_wgrad_xcd_tile / _wgrad_xcd_aff_geom).
- Super-block spanning gp groups keeps a dispatch-id class inside one L2
slice: down 8 runs of 6x3 -> 4 runs of 6x6, 16 -> 12 resident slabs;
E=32 down L2 hit 45.3 -> 54.3%, DRAM read 104.6 -> 87.5 GB, +2.0%.
- Per-super-block class rotation balances heavy vs half-tile boundary runs:
down 12x12 four 6x6 runs cost 36.0/33.1/33.1/30.5 units, busiest 10% over
mean; rotating +1.0%.
- h=2 row-pairing on square runs: down 6x6 +0.36 / +0.56% (two probes,
bit-exact); deeper h counterproductive (h=6/h=3 @ w=3 = 2508/2487 TF vs
2602 for h=1), one-column band keeps h=1.
- Leads the candidate list only when the launch is deep (>= 8 dispatch
rounds): full-model +10.2% down / +0.0% gate_up (prime 23 N-blocks -> thin
strip); an EP-sharded slice measures flat, so it stays behind the band
incumbent there and only wins the race on a real margin.
2. Boundary-body gating by launch depth and MFMA-vs-phase cost (_HALF_M /
_HALF_N / _wgrad_bnd_ntb).
- A short last-M-/N-block frees its CU early and the refilling WG runs out of
L2 phase with its neighbours; at occ=1 nothing resyncs them. This phase
cost is ~5% of the wall, shape-independent (_WGRAD_BND_PHASE_INV=20).
Shallow both bodies pay (down +4.6%, gate_up +0.6%); deep they jointly do
not (-4.0%).
- Split apart the two sides differ: short-M alone is -4.3% deep (its row is a
whole affine run, so making it cheap desyncs entire runs) -> gated on launch
depth; short-N alone is +1.2% (one tile inside a run) -> gated on MFMA saved
beating the phase cost; deep down +0.94% / gate_up -2.33% (bit-exact).
- _wgrad_bnd_ntb drops the MFMA tiles the OUT_N=2880 remainder never stores
(keeps 64 of 128, saves 2.22% of launch MFMA); LDS slab, g2s and transposed
read pattern all unchanged. Peeling a boundary strip into its own dispatch
segment loses the 2D-run operand reuse (-2.5% M row / -3.7% N column).
3. gn4 band leads only once a group holds more tiles than the device runs at
once (operand slab no longer wholly L2 resident): -1.9% gate_up (276
tiles/group > 256 CU), flat down (144).
Split-K stays excluded under xcd_aff: an _XCD_GP>1 super-block interleaves its
groups, so any window worth splitting straddles them. Dormant at E=32 anyway
(TOTAL % ncu == 0 shuts the policy).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (3)
primus_turbo/flydsl/utils/gemm_helper.py:874
- Same issue as the non-transposed store path: n_valid becomes None when col_safe=True, but is still passed as mask=... to _buffer_ops.buffer_store. Prefer a constant True mask to indicate an unmasked store.
for i in range_constexpr(4):
n = base_row + n_local + i
n_valid = None if self.col_safe else n < self.c_cols
val = vec_f32[i]
primus_turbo/flydsl/utils/gemm_helper.py:843
- When col_safe=True, this sets col_valid to None and passes it as mask=... to _buffer_ops.buffer_store. There are no other call sites using mask=None in the codebase, so this is likely to break codegen or produce an invalid store mask. If the intent is an always-on mask (no bounds check), use a constant True mask instead.
This issue also appears on line 871 of the same file.
col = base_col + tj * 16 + self.lane_id % 16
col_valid = None if self.col_safe else col < self.c_cols
vec_f32 = Vec(c_frag[self.c_idx_fn(ti, tj)])
# Whole-fragment scale: the wave-uniform per-tensor scale packs to v_pk_mul_f32, bit-identical to the per-element form.
csrc/pytorch/bindings_pytorch.cpp:32
- The new padding_align_size argument is exposed with a default of 128. Existing callers that still invoke quantize_fp8_tensorwise(input, dtype, scale_opt) (3 args) will now silently get a padded output shape (K->Kp), which is a breaking API/ABI behavior change compared to the previous shape-preserving op. Consider defaulting padding_align_size to 1 (shape-preserving) and having callers explicitly request padding (as the new Python pad wrapper already does).
m.def("quantize_fp8_tensorwise(Tensor input, ScalarType dest_dtype, Tensor? scale_opt=None, "
"int padding_align_size=128) -> Tensor[]");
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 22 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
primus_turbo/pytorch/ops/grouped_gemm_fp8.py:447
- The K-pad fast path forces the FlyDSL kernel and bypasses
grouped_gemm_fp8_impldispatch (so backend overrides viaGlobalBackendManagerare ignored, andnum_cuuses a different sentinel convention). Since the tensors are already quantized, you can preserve normal dispatch by callinggrouped_gemm_fp8_implhere.
out = grouped_gemm_fp8_tensorwise_flydsl_kernel(
a_q,
b_q,
a_sc,
b_sc,
go,
trans_b=True,
out_dtype=out_dtype,
num_cu=(num_cu if num_cu is not None else -1),
)
primus_turbo/pytorch/ops/grouped_gemm_fp8.py:425
- This adds a new behavior path for tensorwise grouped GEMM when
K % 128 != 0(quantize+pad on fwd, then slice grads on bwd), but the existing grouped GEMM FP8 tests only cover K values that are multiples of 128. Add at least one test case (e.g.K=1409,trans_b=True) to ensure the K-pad path is exercised and grad shapes/results remain correct.
# K-pad fast path: a non-128-aligned K makes the fp8 row stride split each vector load across a 128B line, so cast-and-pad both operands to Kp=ceil128(K) (pad cols contribute 0*0=0, GEMM unchanged). Raw NT inputs only.
_kpad = (
trans_b
and not isinstance(a, QuantizedTensor)
and not isinstance(b, QuantizedTensor)
and a.dim() == 2
and b.dim() == 3
and (a.shape[-1] % 128 != 0)
and a.shape[-1] >= 129
)
| m.def("quantize_fp8_tensorwise(Tensor input, ScalarType dest_dtype, Tensor? scale_opt=None, " | ||
| "int padding_align_size=128) -> Tensor[]"); |
… row-addr epilogue store gpt-oss-20b MoE tensorwise fp8 wgrad (grouped TN, dW[g]=A[g]^T@B[g]) at the deploy regime (EP1/TP1, G=32 all-local, per-expert M=4096, K=2880->KP=2944; gate_up N=5760, down N=2880). Drift-immune score gm_wgrad = TF(deploy G=32/M=4096) / TF(ref G=4/M=32768): 0.8807 -> 0.8895 (+1.00%). Per role gate_up 0.893 / down 0.871, both still < 0.95 (not yet at the adoption gate); next structural lever is BLOCK_N=128 (256x128 tile, half B-LDS + half AGPR -> occ=2 to hide the store), blocked today by a ds_read_b64_tr_b8 N=128 HW-transpose correctness bug. Changes are a compile-time no-op for divisible-K shapes. Rationale moved out of source (source keeps WHY-only, <=3-line comments): - DRAM-band np-wgrad candidate: when G*N*K exceeds the ~256 MB LLC the weight tensor streams from DRAM, so lead with a half-depth band. group_m=8 keeps group_m*BLOCK_M*K bytes of A live across the whole N sweep (~6.0 MB at K=2944), overrunning the ~4 MB per-XCD L2 slice; group_m=4 halves it (~3.0 MB) and it stays resident. num_xcd keeps the table's wide/narrow split (wide-N spreads a chunk over two XCDs, narrow-N pins one per XCD). Measured on gpt-oss E=32 interleaved vs the E=4 twin: gate_up -2.6% wall, down -2.6%, E=4 flat. The online race scores at the balanced _NP_PM_CANON where the arms only lead 0.1-0.9% (inside the adoption margin), so this branch leads with them instead of racing for them. Constants (256,4,4,0)/(256,8,4,0), _NP_B_LLC=256<<20. - half_bnd=-1 sentinel gates the two boundary bodies (short last M-/N-block). At occ=1 a cheap tile frees its CU early and the refilling WG desyncs the launch's L2 phase cohorts, which stop reusing each other's slabs. The M row is a whole XCD-affine run (loses deep), the N block is one tile inside a run (only shortens each row's tail). So -1 gates M off past _WGRAD_AFF_ROUNDS*ncu launch depth and N off unless the saved MFMA beats the phase cost (~1/_WGRAD_BND_PHASE_INV of the wall). Both gates read launch depth only as a proxy for per-group contraction length; the autotuner races an explicit half_bnd=3 at the live per-group M, the only stage that sees it. - split_k=False flag: the split-K window only pays when the makespan quantizes badly, and its grid extension + reduce pass + per-workgroup O(G) policy scan are not free. _wgrad_4wave_cands races the path off for deep-but-short-group launches. Split-K stays excluded when num_xcd>1 or _XCD_GP>1 (a remapped/interleaved id carries a window across a group boundary). - _wgrad_4wave_cands tuple is now (group_m, group_n, num_xcd, xcd_aff, half_bnd, split_k); -1/True defer boundary bodies and split-K to the factory gates. For deep launches it races a (half_bnd=3, split_k=False) pairing on the two leading geometries (a deep launch of SHORT groups is the case both gates read backwards), and prepends a doubled-depth affine band h=2*aff[0] when n_blocks_m % h == 0 and h*group_n <= tiles_per_group//xcd_k (_wgrad_xcd_aff_geom caps lockstep depth at h=2: h A-slabs resident -> each B-slab reused h times; the deeper band and the boundary bodies pay off only jointly, each ~1/3 of the pair, so the pair rides the affine run and the race's own 1.5% noise picks the small-M config). A (1,1) affine run is excluded from leading: a one-block-wide walk takes the affine constraints (and split-K exclusion) without the compact rectangle they pay for. - Race hysteresis: incumbent yields only on a >1.5% win; freeze the bar off the INITIAL best (bar = best_s * 0.985) and adopt the fastest arm clearing min(best_s, bar). Chaining the margin off the running best lets candidate ORDER pick the winner. - Grid: one workgroup per dispatch id (grid = TOTAL + split_ext); the dispatcher backfills each freed CU, which the deep band's lean boundary bodies are raced against. cap_cu>0 makes the grid resident to reserve CUs for comm-compute overlap. Removed the PT_WL_GRIDMUL env knob and the persistent-grid framing. - StoreCPerTensor.row_addr=True: hoist the store address to one register per output row so the column step is a compile-time byte constant the buffer offset field absorbs, dropping a per-store address VALU; and convert all four fragment rows before storing any of them (a single live data register otherwise serialises convert behind the prior store). Enabled for the wgrad TN boundary body (_wave4_do_tile_tn). - _kpad fwd path now imports grouped_gemm_fp8_kernel (was the stale gemm_fp8_grouped_kernel module), fixing an import-time crash.
3d71ce1 to
eedf181
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 11 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
primus_turbo/triton/grouped_gemm/grouped_gemm_fp8_kernel.py:605
- After adding
MAX_G_NEXT_POW2as atl.constexprto_compute_tile_cumsum_kernel, the launch needs to pass it as a meta-parameter. Otherwise Triton compilation will fail due to the missing compile-time constant fortl.static_range/tl.arangesizing.
# Precompute per-group tile cumulative sums on the device (single-program kernel,
# same CUDA stream → no host sync). The main kernel then loads this tiny array once
# into registers and skips the per-tile O(G) scan over group_offs.
num_pid_n_int = (N + blk_n - 1) // blk_n
tile_cumsum = torch.empty(G + 1, device=a.device, dtype=torch.int32)
_compute_tile_cumsum_kernel[(1,)](
group_offs,
tile_cumsum,
G,
num_pid_n_int,
BLOCK_SIZE_M=blk_m,
)
max_g_next_pow2 = _next_pow2(G + 1)
csrc/pytorch/bindings_pytorch.cpp:32
- The Torch op schema sets
padding_align_size=128as the default. This changes the behavior of existing callers that invoketorch.ops.primus_turbo_cpp_extension.quantize_fp8_tensorwise(input, dtype, scale)(previously shape-preserving) to now return a K-padded output by default.
If backward compatibility is desired, consider defaulting padding_align_size to 1 (shape-preserving), and have callers that want padding explicitly pass 128.
m.def("quantize_fp8_tensorwise(Tensor input, ScalarType dest_dtype, Tensor? scale_opt=None, "
"int padding_align_size=128) -> Tensor[]");
| @triton.jit() | ||
| def _compute_tile_cumsum_kernel( | ||
| group_offs_ptr, # [G+1] int64 | ||
| tile_cumsum_ptr, # [G+1] int32 (output) | ||
| G, # runtime number of groups | ||
| num_pid_n, # runtime int = cdiv(N, BLOCK_SIZE_N) | ||
| BLOCK_SIZE_M: tl.constexpr, | ||
| ): | ||
| """Single-program kernel; precomputes per-group cumulative tile counts.""" | ||
| cumsum: tl.int32 = 0 | ||
| tl.store(tile_cumsum_ptr, 0) | ||
| for g in range(G): | ||
| m_g = (tl.load(group_offs_ptr + g + 1) - tl.load(group_offs_ptr + g)).to(tl.int32) | ||
| tiles_g = tl.cdiv(m_g, BLOCK_SIZE_M) * num_pid_n | ||
| cumsum += tiles_g | ||
| tl.store(tile_cumsum_ptr + g + 1, cumsum) | ||
|
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (2)
primus_turbo/triton/grouped_gemm/grouped_gemm_fp8_kernel.py:347
_compute_tile_cumsum_kernelusesfor g in range(G)butGis a runtime Triton scalar here, which Triton cannot use as a Pythonrangebound (this will fail to JIT/compile). MakeGatl.constexpr(or rewrite usingtl.static_range) so the loop can be unrolled.
def _compute_tile_cumsum_kernel(
group_offs_ptr, # [G+1] int64
tile_cumsum_ptr, # [G+1] int32 (output)
G, # runtime number of groups
num_pid_n, # runtime int = cdiv(N, BLOCK_SIZE_N)
BLOCK_SIZE_M: tl.constexpr,
):
"""Single-program kernel; precomputes per-group cumulative tile counts."""
cumsum: tl.int32 = 0
tl.store(tile_cumsum_ptr, 0)
for g in range(G):
m_g = (tl.load(group_offs_ptr + g + 1) - tl.load(group_offs_ptr + g)).to(tl.int32)
tiles_g = tl.cdiv(m_g, BLOCK_SIZE_M) * num_pid_n
cumsum += tiles_g
tl.store(tile_cumsum_ptr + g + 1, cumsum)
primus_turbo/pytorch/ops/grouped_gemm_fp8.py:455
- The new K-pad fast path is only taken when
K % 128 != 0(andK >= 129), but the existing grouped GEMM FP8 tests currently use only 128-aligned K values, so this branch is likely untested. Please add/extend a unit test that exercisestrans_b=Truewith a non-128-alignedK(e.g.K=129orK=256+64) and validates forward+backward shapes/accuracy.
# K-pad fast path: a non-128-aligned K makes the fp8 row stride split each vector load across a 128B line, so cast-and-pad both operands to Kp=ceil128(K) (pad cols contribute 0*0=0, GEMM unchanged). Raw NT inputs only.
_kpad = (
trans_b
and not isinstance(a, QuantizedTensor)
and not isinstance(b, QuantizedTensor)
and a.dim() == 2
and b.dim() == 3
and (a.shape[-1] % 128 != 0)
and a.shape[-1] >= 129
)
if _kpad:
from primus_turbo.flydsl.grouped_gemm.grouped_gemm_fp8_kernel import (
grouped_gemm_fp8_tensorwise_flydsl_kernel,
)
from primus_turbo.pytorch.kernels.quantization.quantization_impl import (
quantize_fp8_tensorwise_pad_impl,
)
go = group_offs if group_offs is not None else group_offs_from_lens(group_lens)
fp8_dtype = _get_fp8_dtype(config.format, True)
a_q, a_sc = quantize_fp8_tensorwise_pad_impl(a, fp8_dtype) # [M, Kp]
b_q, b_sc = quantize_fp8_tensorwise_pad_impl(b, fp8_dtype) # [G, N, Kp]
out = grouped_gemm_fp8_tensorwise_flydsl_kernel(
a_q,
b_q,
a_sc,
b_sc,
go,
trans_b=True,
out_dtype=out_dtype,
num_cu=(num_cu if num_cu is not None else -1),
)
ctx.save_for_backward(a_q, b_q, a_sc, b_sc, group_lens, go)
ctx.trans_a = False
ctx.trans_b = trans_b
ctx.config = config
ctx.out_dtype = out_dtype
ctx.num_cu = num_cu
ctx.k_pad_real = a.shape[-1]
return out
…-style gate Comment/docstring-only cleanup across the branch's grouped fp8 GEMM code (fwd/dgrad/ wgrad and the shared gemm_helper store primitives). Long WHAT-narration and moved-out derivations are collapsed to <=3-line WHY comments; the rationale lives in the optimization commits' messages. No executable line changes: every edited line is a comment or a docstring, so the emitted kernels are byte-identical (AST with docstrings stripped is unchanged). ruff check + format clean.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (3)
primus_turbo/pytorch/ops/grouped_gemm_fp8.py:420
- This K-pad fast path forces the FlyDSL kernel, but it isn’t gated by hardware capability.
grouped_gemm_fp8_tensorwise_flydsl_kernelis gfx950/CDNA4-only (seeGroupedGEMMFP8FlyDSLBackend.can_handle), so on other GPUs this branch can raise at import-time or fail at runtime even though the regular Triton path would work.
Gate _kpad on is_gfx950() (or add a safe fallback to the Triton implementation when FlyDSL can’t run).
# K-pad fast path: a non-128-aligned K makes the fp8 row stride split each vector load across a 128B line, so cast-and-pad both operands to Kp=ceil128(K) (pad cols contribute 0*0=0, GEMM unchanged). Raw NT inputs only.
_kpad = (
trans_b
and not isinstance(a, QuantizedTensor)
and not isinstance(b, QuantizedTensor)
csrc/pytorch/quantization/quantization.cpp:51
padding_align_sizeis now a public argument, but the pad kernel assumes the padded K (Kp) is divisible by the chosen vectorization (UNROLL). With the current checks, callers can pass values like 3/65 and silently leave the lastKp % UNROLLcolumns unwritten becausecols_per_row = Kp / UNROLLtruncates inquantize_tensorwise_pad_kernel.
Add a stricter validation here (e.g., require padding_align_size == 1 or padding_align_size % 8 == 0) so Kp is always a multiple of any UNROLL in {1,2,4,8}.
PRIMUS_TURBO_CHECK(input.is_contiguous(), "input must be contiguous");
PRIMUS_TURBO_CHECK(input.dim() >= 1, "input must have at least 1 dim");
PRIMUS_TURBO_CHECK(padding_align_size >= 1, "padding_align_size must be >= 1");
csrc/pytorch/quantization/quantization_meta.cpp:16
- The meta implementation should mirror the runtime constraints on
padding_align_size. Without validating the alignment here too, shape inference will succeed for values that the real kernel cannot safely handle (e.g., whenKpis not divisible by the chosen vectorization width).
PRIMUS_TURBO_CHECK(input.dim() >= 1, "input must have at least 1 dim");
PRIMUS_TURBO_CHECK(padding_align_size >= 1, "padding_align_size must be >= 1");
Description
Two commits on the gpt-oss grouped-fp8 GEMM path (FlyDSL + Triton), rebased onto latest main.
1. grouped fp8 NT + wgrad (FlyDSL). Adds a persistent big-N / short-K path for the
NT forward/dgrad, L2-reuse-aware autotune (NT-store unlocks a wide-gm swizzle band,
plus cs_pipe software-pipelining and non-temporal cstore_aux), and a
distribution-invariant wgrad (flat-by-construction candidate set + grid multiplier for
long-K tails). Motivation: the tensorwise grouped path was L2-reuse-bound, and wgrad
throughput drifted with token-distribution skew.
2. grouped fp8 tensorwise: restore tile-cumsum group lookup (Triton). The tensorwise
persistent kernel had regressed to a per-tile O(G) linear scan (2G group_offs loads per
tile) versus the tile prefix-sum in mpo/0.2.0. This restores a register-resident
tile-cumsum lookup: the host computes cumulative tile counts once (single-program
kernel, same stream, no host sync), and the kernel loads it once and does a vectorized
tl.sumlookup. Byte-exact with the previous scan; empty groups are handled naturally.Fixes # (n/a)
Type of change
Changes
gemm_fp8_grouped_kernel.pygemm_helper.pykernel, replacing the per-tile O(G) group_offs scan (
grouped_gemm_fp8_kernel.py)Checklist: