Skip to content

[WIP][feat] grouped fp8: add NT/wgrad persistent path and tile-cumsum group lookup for grouped gemm - #436

Open
kyle-256 wants to merge 13 commits into
mainfrom
dev/kyle/gptoss-grouped-fp8-nt-wgrad
Open

[WIP][feat] grouped fp8: add NT/wgrad persistent path and tile-cumsum group lookup for grouped gemm#436
kyle-256 wants to merge 13 commits into
mainfrom
dev/kyle/gptoss-grouped-fp8-nt-wgrad

Conversation

@kyle-256

Copy link
Copy Markdown
Collaborator

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.sum lookup. Byte-exact with the previous scan; empty groups are handled naturally.

Fixes # (n/a)

Type of change

  • Documentation change (change only to the documentation, either a fix or a new content)
  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Infra/Build change
  • Code refactoring

Changes

  • FlyDSL: persistent big-N/short-K NT forward/dgrad path in gemm_fp8_grouped_kernel.py
  • FlyDSL: L2-reuse-aware autotune — wide-gm swizzle band, cs_pipe, non-temporal cstore_aux
  • FlyDSL: distribution-invariant wgrad (flat-by-construction + long-K grid multiplier), helper updates in gemm_helper.py
  • Triton: restore register-resident tile-cumsum group lookup in the tensorwise persistent
    kernel, replacing the per-tile O(G) group_offs scan (grouped_gemm_fp8_kernel.py)

Checklist:

  • The functionality is complete
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

kyle-256 added 2 commits July 29, 2026 03:17
…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.
Copilot AI lite review requested due to automatic review settings July 29, 2026 05:33

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

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.

Comment on lines +605 to +606
max_g_next_pow2 = _next_pow2(G + 1)

@kyle-256 kyle-256 changed the title [feat] grouped fp8: add NT/wgrad persistent path and tile-cumsum group lookup for grouped gemm [WIP][feat] grouped fp8: add NT/wgrad persistent path and tile-cumsum group lookup for grouped gemm Jul 31, 2026
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.
Copilot AI review requested due to automatic review settings August 2, 2026 11:25

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 5 out of 6 changed files in this pull request and generated 1 comment.

Comment on lines +460 to +464
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).
Copilot AI review requested due to automatic review settings August 3, 2026 01:28

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 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_start is computed via a full-vector compare + tl.where + tl.sum, which adds an extra O(MAX_G_NEXT_POW2) reduction per tile. Since group_idx is already a scalar, this can be loaded directly from tile_cumsum_ptr (same as total_tiles above) 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 != 0 and K >= 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).
Copilot AI review requested due to automatic review settings August 3, 2026 01:58

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 10 out of 11 changed files in this pull request and generated 1 comment.

Comment on lines +428 to +437
# 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).
Copilot AI review requested due to automatic review settings August 4, 2026 02:12

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 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_impl assumes the padded row length Kp is divisible by the chosen vector UNROLL (it computes cols_per_row = Kp / UNROLL and only processes cols_per_row * UNROLL columns). Right now pack_size is chosen based on K only, so if a caller passes a non power-of-two padding_align_size (making Kp not divisible by pack_size/UNROLL), the kernel can leave the last Kp % UNROLL columns 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_size is part of the public op API and currently only checked for >= 1, but the pad kernel relies on alignment properties (see quantize_tensorwise_pad_impl). Without constraining this (e.g., power-of-two), callers can select values that produce a Kp not 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_size to 128 changes the output shape for any direct caller of torch.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[]");

Copilot AI review requested due to automatic review settings August 6, 2026 11:31

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 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_size argument defaults to 128, which changes quantize_fp8_tensorwise behavior 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=True and a non-128-aligned K (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).
Copilot AI review requested due to automatic review settings August 6, 2026 14:25
@kyle-256
kyle-256 force-pushed the dev/kyle/gptoss-grouped-fp8-nt-wgrad branch from 84bcec4 to b668699 Compare August 6, 2026 14:25

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 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 _kpad is true. FlyDSL tensorwise grouped GEMM is gfx950-only (see GroupedGEMMFP8FlyDSLBackend.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 _kpad behavior (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_tensorwise now hard-requires input.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=128 makes quantize_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).
Copilot AI review requested due to automatic review settings August 7, 2026 11:26

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 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[]");

Copilot AI review requested due to automatic review settings August 10, 2026 01:26

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 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_impl dispatch (so backend overrides via GlobalBackendManager are ignored, and num_cu uses a different sentinel convention). Since the tensors are already quantized, you can preserve normal dispatch by calling grouped_gemm_fp8_impl here.
            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
        )

Comment on lines +31 to +32
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.
Copilot AI review requested due to automatic review settings August 11, 2026 06:05
@kyle-256
kyle-256 force-pushed the dev/kyle/gptoss-grouped-fp8-nt-wgrad branch from 3d71ce1 to eedf181 Compare August 11, 2026 06:05

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 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_POW2 as a tl.constexpr to _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 for tl.static_range/tl.arange sizing.
    # 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=128 as the default. This changes the behavior of existing callers that invoke torch.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[]");

Comment on lines +332 to +348
@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)

Copilot AI review requested due to automatic review settings August 11, 2026 06:10

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 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_kernel uses for g in range(G) but G is a runtime Triton scalar here, which Triton cannot use as a Python range bound (this will fail to JIT/compile). Make G a tl.constexpr (or rewrite using tl.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 (and K >= 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 exercises trans_b=True with a non-128-aligned K (e.g. K=129 or K=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.
Copilot AI review requested due to automatic review settings August 11, 2026 06:51

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 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_kernel is gfx950/CDNA4-only (see GroupedGEMMFP8FlyDSLBackend.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_size is 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 last Kp % UNROLL columns unwritten because cols_per_row = Kp / UNROLL truncates in quantize_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., when Kp is 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");

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants