Skip to content

[Common][PyTorch] Fuse the RHT into grouped NVFP4 quantize on non-SM100 architectures - #3317

Open
davidkny22 wants to merge 3 commits into
NVIDIA:mainfrom
davidkny22:fuse-grouped-rht-nonsm100
Open

[Common][PyTorch] Fuse the RHT into grouped NVFP4 quantize on non-SM100 architectures#3317
davidkny22 wants to merge 3 commits into
NVIDIA:mainfrom
davidkny22:fuse-grouped-rht-nonsm100

Conversation

@davidkny22

Copy link
Copy Markdown
Contributor

Description

Follows #3265. That PR made split_quantize with RHT-enabled NVFP4 quantizers fall back to quantizing each split on its own outside the SM100 family, since the grouped Hadamard transform cast-fusion kernels are SM100 only. This makes that fallback fused.

The fallback runs two launches per split plus a separate HadamardTransformKernel that writes the transformed tensor to global memory for the quantize pass to read back. At G=64 with 256-row splits that is 320 launches per split_quantize call and 11.125 bytes of traffic per input element.

group_quantize_transpose already has most of what a fused version needs. MultiAmaxCastTransposeFusionArgs carries colwise_amax_list, output_colwise_data_list, output_colwise_scale_inv_list and output_colwise_scale_stride, all marked "unused for rowwise only scaling", and the transposed store path is present but commented out with TODO(zhongbo): add back when transpose is supported. This completes that path with per-split direct stores instead of the shared memory staging it replaces, folds the 16-point random Hadamard transform into the columnwise read as an in-register butterfly, and adds nvte_group_quantize_with_colwise_rht for the PyTorch dispatch to call. The same G=64 case becomes 24 launches and 3.125 bytes per element.

Direct stores make the transposed staging buffers dead, so they go, along with the commented-out stores that named them and two declarations (tid_Y_t, out_mem_rowwise_data) that only fed those buffers. Dynamic shared memory drops from 25728 to 20608 bytes per 128-thread CTA, 3 resident CTAs per SM to 4.

On GB10 (sm_121a) this is 2.22x median on split_quantize eager, 2.00x median of GPU work under graph capture, over 14 shapes. At te.GroupedLinear level it is 1.00x to 1.17x of a forward and backward step, median 1.07x.

The fused path is taken only when every split is a multiple of 128 rows. A split boundary is resolved once per 128-row chunk in the transposed direction, so a ragged split would write into the wrong buffer. Ragged cases decline to the existing per-split path. For MoE this means expert capacity has to be padded to 128 tokens to benefit.

The new launches are chunked by input bytes rather than by tensor count, at 8 MiB, so the amax pass prefetches for the quantize pass instead of being evicted from L2 before it reads the same data. Chunking by tensor count instead puts 128 MiB against a 24 MiB L2 at G=64, which costs most of the win.

Architecture behaviour

The transposed path is reachable only through the new entry point. group_quantize_transpose refuses a columnwise output unless nvfp4_colwise_rht is set, and only nvte_group_quantize_with_colwise_rht sets it, so generic nvte_group_quantize keeps today's refusal on every architecture. The new entry point additionally refuses on sm >= 100 && sm <= 110, the same band split_quantize_nvfp4_impl uses, so the SM100 family cannot reach the new code even through the C API.

No existing entry point changes behaviour on any architecture.

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

  • transformer_engine/common/cast/nvfp4/group_quantize_transpose_nvfp4.cuh: complete the transposed output path with per-split direct stores of columnwise data and scale factors, add the in-register 16-point RHT behind a COLWISE_RHT template switch, bind the columnwise pointers on the per-stage tensor switch, gate the transposed path on the columnwise RHT entry point and the architecture band, require 128-row-aligned splits, and drop the now-dead transposed staging buffers and the two declarations that only fed them.
  • transformer_engine/common/cast/cast.cu: nvte_group_quantize_with_colwise_rht.
  • transformer_engine/common/include/transformer_engine/cast.h: its declaration and docs.
  • transformer_engine/common/common.h: two internal quantization config fields, nvfp4_colwise_rht and nvfp4_rht_sign_mask_t, not exposed through the attribute API.
  • transformer_engine/pytorch/csrc/extensions/cast.cpp: use the grouped path inside the existing non-SM100 branch when the quantizers carry post-RHT amax, both usages, no 2D quantization, no row-scaled NVFP4 and no 4over6, and every split is a multiple of 128 rows. Empty splits are filtered out of the grouped launch. Launch pairs chunk at 8 MiB of input.
  • tests/pytorch/nvfp4/test_nvfp4_group_quantize.py: fused versus per-split equality across aligned, mixed, empty and ragged splits.

Checklist:

  • I have read and followed the contributing guidelines
  • 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

No documentation changes needed beyond the header docs on the new entry point. On the last box: the added tests pass and nothing regresses, but test_nvfp4_group_quantize.py has 120 pre-existing failures on main and test_nvfp4_group_quantize_graph_safe.py has 528, unchanged by this PR. Numbers and cause below.

Testing done

GB10 (DGX Spark), sm_121a, CUDA 13.0, driver 580.95.05. Built and gated at main @ d35eedf5f6d1cfba64b5e1dd4c8bfc3fc8214750, the #3265 merge commit, then re-verified as git apply --check clean against af1ed441255ec66e3555fd485dddb4344d7c495a, current main tip at submission, which adds one unrelated commit (#3276, NCCL EP) touching none of these six files. Built with NVTE_CUDA_ARCHS=121a. clang-format 18.1.6, cpplint 2.0.2 and black 24.4.2 clean on the changed files. No warning lines from any touched file, verified by rebuilding the affected translation units with ninja -v rather than trusting the pip install log, which hides per-file compiler output.

Both arms live in one binary. NVTE_NVFP4_DISABLE_GROUPED_RHT is read with std::getenv at the dispatch on every call, so the A/B is one build and one process on identical operands. The new test uses it to compare the two routes, and it is the way out of the one-ulp difference below for a caller that needs output identical to the per-split path.

Tests

test_rht_split_quantize_grouped_matches_unfused compares the two routes byte for byte over 12 cases: aligned, mixed-aligned, empty-front, empty-back, empty-mixed and two ragged split patterns, at two widths. It would have caught the empty-split bug I hit building this, where a leading zero-row split has has_data() true with a null data pointer and aborted the grouped launch. It does not assert which route ran; the launch counts below do that.

tests/pytorch/nvfp4/test_nvfp4_group_quantize.py goes 120 failed, 453 passed, 270 skipped to 120 failed, 465 passed, 270 skipped, the 12 being the new cases. test_nvfp4_group_quantize_graph_safe.py is 528 failed, 60 passed either way. Those failures are pre-existing on main: the optimize_for_gemm=True cases, where swizzled scale factor emission is gated on the same architecture band so outputs carry compact scale factors while the test swizzles the reference.

Numerics

17 case classes compare the fused route against the per-split route byte for byte: aligned, ragged, mixed, empty-front, empty-middle, empty-back, and 64-way splits. Each case runs the fallback twice and the fused route once; the second fallback run is the control, since scale buffers are allocated at roundup(rows, 128) and neither route writes the padding, so an uncontrolled comparison would report phantom mismatches that move with allocator state.

16 of 17 are identical over the full buffer and over the defined region, on rowwise data, columnwise data, both scale planes and both amaxes.

One is not. A single byte differs, columnwise_data[195, 78] of one split, reproducibly, with both arms individually deterministic. It is reduction order rather than a race. HadamardTransformKernel evaluates the 16x16 transform with mma_m16_n16_k16_b16_b16_b16_noacc, bf16 operands and fp32 accumulate; the fused path evaluates the same transform as a scalar fp32 butterfly. Both multiply by exactly representable values, so the only difference is the order of the fp32 additions in a 16-term reduction. For that element the input chunk spans 3.4375 down to 1.4e-07, the sum cancels heavily, the two orders land one bf16 ulp apart (-0.1000976562 against -0.1005859375), and the element sits on the FP4 rounding boundary for its block, so it moves one FP4 code. Block amax, both scale planes and the rowwise plane are unaffected.

Rate over 1,073,741,824 columnwise elements across 5 shapes and 4 seeds each: zero further instances. Counting the one known case against everything compared, 1 in 1.1e9, bounded to one code step, no measurable norm or sign bias.

Identical on every tested input except that one-ulp reduction-order difference, which can move a single FP4 code. Not bit identical, and it cannot be while the two paths reduce in different orders.

Launch counts

Kernel launches per split_quantize call, from the profiler:

case fallback fused
G=64, [256] x64, cols 2048 320 24
G=8, [1024] x8, cols 4096 40 24
G=8, [96,160,32,224,...], cols 512 40 40, declined

Performance, split_quantize

Clocks locked at 3003 MHz, 9 windows, 20 iterations per window, medians, arms alternating window by window so they share clock history. Both arms also captured into CUDA graphs, which removes per-launch submission cost from both sides. Milliseconds per call.

G rows/split cols fallback fused eager fallback graph fused graph graph
1 16384 4096 3.695 1.577 2.34x 3.667 1.583 2.32x
2 8192 4096 3.562 1.596 2.23x 3.652 1.587 2.30x
8 2048 4096 2.655 1.527 1.74x 2.656 1.483 1.79x
8 1024 2048 0.465 0.328 1.42x 0.429 0.349 1.23x
8 2048 14336 12.955 5.856 2.21x 13.078 5.766 2.27x
32 512 2048 1.228 0.740 1.66x 0.908 0.684 1.33x
32 512 4096 2.019 1.462 1.38x 1.716 1.384 1.24x
64 256 2048 1.775 0.706 2.51x 1.269 0.670 1.89x
64 256 4096 2.648 1.394 1.90x 1.912 1.320 1.45x
64 128 2048 1.336 0.461 2.90x 0.878 0.301 2.92x
64 128 4096 1.800 0.659 2.73x 1.287 0.605 2.13x
64 512 4096 4.052 2.917 1.39x 3.326 2.753 1.21x
128 128 2048 2.768 0.940 2.95x 1.753 0.603 2.91x
128 128 4096 3.511 1.311 2.68x 2.546 1.208 2.11x

Median 2.22x eager, range 1.38x to 2.95x. Median 2.00x of GPU work, range 1.21x to 2.92x. No shape regresses in either column. On a gate-only build, where both arms are the same fallback code, the same harness reads 1.00x median on 14 of 14 shapes, range 1.00x to 1.03x.

The gap between the columns is per-launch submission cost the fallback pays on 320 to 640 launches, removed by graph capture.

Performance, te.GroupedLinear

Forward and backward at the te.GroupedLinear level, steady-state trainer settings (is_first_microbatch=False, fuse_wgrad_accumulation=True). Expert counts and per-expert token counts come from published MoE configurations at tokens x top_k / experts. Recipe NVFP4BlockScaling(disable_stochastic_rounding=True), since the SR kernel uses a cvt.rs instruction sm_121 does not have. 7 windows, 5 iterations, medians. Milliseconds per step.

shape G rows/expert in out fallback fused eager graph
Mixtral-8x7B fc1 8 2048 4096 28672 117.17 100.21 1.17x 1.18x
Mixtral-8x7B fc2 8 2048 14336 4096 59.66 50.99 1.17x 1.18x
Qwen3-235B-A22B fc1 128 256 4096 3072 88.06 82.68 1.07x 1.03x
DeepSeek-V3 fc1, EP=8 32 1024 7168 4096 73.49 68.44 1.07x 1.09x
GPT-OSS-120B fc1 128 128 2880 5760 104.91 102.55 1.02x 0.99x
Qwen3 fc1, ragged router 128 32 to 1120 4096 3072 90.04 90.27 1.00x 1.00x

Median 1.07x, range 1.00x to 1.17x. Noise floor on a gate-only build with both arms identical: 1.00x eager on all six shapes, 0.98x to 1.01x graph.

The ragged case is the %128 decline, and it reads 1.00x with identical launch counts on both arms. GPT-OSS declines in the forward for an unrelated reason: split_quantize already refuses NVFP4 kernel fusion when the input's inner dimension is not a multiple of 128, and 2880 is not, so only its backward fuses.

The share of the step is why the module number is smaller than the kernel number: on Mixtral fc1 the RHT quantize path is 25.1% of device time in the fallback and 14.2% after; on Qwen3 fc1 the GEMM is roughly 85% of the step and the RHT path 7.4%, which caps anything done here at about 1.08x on that shape.

Open questions

  1. Is the fused transform meant to match HadamardTransformKernel bit for bit? The one-ulp difference is inherent to evaluating the same transform in a different reduction order, so if exact match is required this needs a different approach.
  2. Should kChunkBytes be derived from the device's L2 size rather than fixed at 8 MiB? I only have one L2 size to size it against.
  3. Is the %128 alignment requirement acceptable for how grouped quantize gets called in practice, or is ragged-split support worth pursuing?

cc @zhongbozhu @Oleg-Goncharov

Outside the SM100 family split_quantize with RHT-enabled NVFP4
quantizers quantizes each split on its own, which runs two launches per
split plus a separate Hadamard transform that materializes the
transformed tensor to global memory. Complete the transposed output path
in group_quantize_transpose with per-split direct stores, fold the
16-point random Hadamard transform into the columnwise read, and add
nvte_group_quantize_with_colwise_rht so the dispatch can use one grouped
launch pair per chunk. Chunk launch pairs by input bytes so the amax pass
prefetches for the quantize pass. Drop the transposed staging buffers in
shared memory, which the direct stores make dead.

The transposed path requires 128-row-aligned splits and is reachable only
through the columnwise RHT entry point, which refuses on the SM100 family,
so generic grouped quantize keeps its existing refusal everywhere.

Signed-off-by: David Kogan <davidkny22@gmail.com>
@github-actions github-actions Bot added the community-contribution PRs from external contributor outside the core maintainers, representing community-driven work. label Aug 5, 2026
@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR fuses the columnwise random Hadamard transform into grouped NVFP4 quantization on supported non-SM100 architectures.

  • Adds a grouped C API and direct per-split columnwise data and scale stores.
  • Selects and chunks the grouped PyTorch path for aligned splits while retaining the existing per-split fallback.
  • Adds numerical and kernel-selection coverage for aligned, ragged, empty, and heterogeneous-usage cases.

Confidence Score: 4/5

The PR is not yet safe to merge because a later split requesting 2D quantization can still be routed through the first quantizer's non-2D grouped configuration.

The added uniformity guard does not compare with_2d_quantization, while grouped eligibility reads that setting only from the first quantizer and then processes every accepted split through one shared grouped configuration.

Files Needing Attention: transformer_engine/pytorch/csrc/extensions/cast.cpp

Important Files Changed

Filename Overview
transformer_engine/pytorch/csrc/extensions/cast.cpp Adds grouped-path eligibility, chunking, amax, and quantization dispatch; the prior heterogeneous-quantizer issue remains for with_2d_quantization.
transformer_engine/common/cast/nvfp4/group_quantize_transpose_nvfp4.cuh Implements in-register 16-point RHT and direct per-split columnwise output stores with architecture and alignment guards.
transformer_engine/common/cast/cast.cu Adds the C API implementation that configures and dispatches grouped columnwise-RHT quantization.
transformer_engine/common/include/transformer_engine/cast.h Declares and documents the new grouped NVFP4 quantization entry point.
transformer_engine/common/common.h Adds internal configuration fields for enabling columnwise RHT and carrying its sign mask.
tests/pytorch/nvfp4/test_nvfp4_group_quantize.py Adds fused-versus-fallback comparisons and verifies grouped dispatch declines for heterogeneous output usage.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A["split_quantize with RHT"] --> B{"Non-SM100 fallback"}
  B --> C{"Quantizers uniform and splits 128-row aligned?"}
  C -->|No| D["Per-split quantize"]
  C -->|Yes| E["Chunk splits by input bytes"]
  E --> F["Grouped post-RHT amax"]
  F --> G["Grouped rowwise quantize plus fused columnwise RHT"]
  G --> H["Per-split NVFP4 outputs"]
Loading

Reviews (2): Last reviewed commit: "[pre-commit.ci] auto fixes from pre-comm..." | Re-trigger Greptile

Comment thread transformer_engine/pytorch/csrc/extensions/cast.cpp
The grouped launch configures itself from quantizers.front() alone:
with_post_rht_amax, rowwise_usage, columnwise_usage, row_scaled_nvfp4,
nvfp4_4over6_mode, stochastic_rounding and the RHT sign mask. Grouped
callers build one independent NVFP4Quantizer per split, and the
Python-side cross-expert validator does not cover these fields for
NVFP4Quantizer, so a heterogeneous list could silently take the
grouped path and apply the first split's settings to the rest.

Require every quantizer to agree with the first on these fields
before taking the grouped path, mirroring the scaling-mode check
already in this function. A mismatch falls through to the existing
per-split loop, which reads each quantizer on its own.

Added a launch-based regression test: two quantizers differing only
in columnwise_usage do not produce byte-different rowwise output on
the shapes tested, since rowwise computation does not depend on
columnwise_usage and TE allocates every split's columnwise buffer
regardless of that flag, so the test checks which kernel launches
rather than the output bytes.

Signed-off-by: David Kogan <davidkny22@gmail.com>
@davidkny22

Copy link
Copy Markdown
Contributor Author

Confirmed and fixed in the latest commit.

The exact mechanism Greptile named was not quite right (rht_matrix_random_sign_mask_t is memoized via functools.lru_cache on (with_random_sign_mask, device) in get_random_sign_mask_for_rht, so every quantizer on the same device already gets the same value there), but the underlying concern is real and broader than that one field.

rowwise_usage and columnwise_usage are the concrete case: they are the one axis _validate_grouped_quantizer_list in grouped_linear.py explicitly allows to vary per expert, and NVFP4Quantizer never defines _value_fields(), so that same validator's uniformity check is a no-op for every NVFP4-specific field, including these two. Traced it with a profiler: a heterogeneous quantizer list does engage GroupHadamardAmaxTmaKernel and group_quantize_transpose_nvfp4_kernel today, using only quantizers[0]'s settings for the whole launch.

Fix requires every quantizer in the list to agree with the first on the fields the grouped launch actually reads (with_post_rht_amax, rowwise_usage, columnwise_usage, row_scaled_nvfp4, nvfp4_4over6_mode, stochastic_rounding, the RHT sign mask), mirroring the scaling-mode check already in this function. A mismatch now falls through to the existing per-split loop.

Added a regression test asserting on which kernel launches, not on output bytes, since a heterogeneous columnwise_usage list does not actually produce diverging rowwise bytes on the shapes I tried (rowwise computation does not depend on it, and TE allocates every split's columnwise buffer regardless of the flag), so a byte-comparison test would have passed either way. Full suite unaffected: 120/467/270 on test_nvfp4_group_quantize.py (was 120/465/270, the +2 is the new test), 528/60 on the graph-safe file, both unchanged from before.

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

Labels

community-contribution PRs from external contributor outside the core maintainers, representing community-driven work.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant