Skip to content

Optimize NVFP4 4over6 candidate error path - #3068

Merged
timmoon10 merged 11 commits into
NVIDIA:mainfrom
zianglih:nvfp4-4over6-fp16-error-modes
Jun 9, 2026
Merged

Optimize NVFP4 4over6 candidate error path#3068
timmoon10 merged 11 commits into
NVIDIA:mainfrom
zianglih:nvfp4-4over6-fp16-error-modes

Conversation

@zianglih

@zianglihzianglih commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Description

@HumansAnd
This PR adds a fast NVFP4 4over6 candidate-error path that compares map-to-4 and map-to-6 candidates in the E4M3-scaled E2M1 product domain after the E2M1 and E4M3 values are rounded through FP16 conversion.

Earlier revisions exposed this as two additional public error modes, MAE_FP16 and MSE_FP16. The interface has been refactored so the public 4over6 error mode remains the selection metric, MAE or MSE, while NVTE_NVFP4_4OVER6_ERR_USE_FAST_MATH=1 selects the faster FP16 product-domain implementation for that metric.

Fixes # (issue): N/A

Motivation:

  • The parent 4over6 PR, NVIDIA/TransformerEngine#2972, showed that 4over6 quantization can become compute-bound. In particular, the previous error path is bottlenecked by E2M1 dequantization and FP32 arithmetic instruction count.
  • Blackwell exposes dedicated PTX conversion instructions for both FP4 E2M1 and FP8 E4M3 into FP16 (cvt.rn.f16x2.e2m1x2 and cvt.rn.f16x2.e4m3x2). This lets the 4over6 kernel construct the candidate E4M3 x E2M1 product with fewer scalar FP32 operations.
  • E4M3 x E2M1 products can be represented exactly in FP16 for this use case, while BF16 does not have enough mantissa bits for the same guarantee.
  • The fast error path compares candidates in the E4M3 x E2M1 scaled range. The final error difference and accumulation are still FP32, matching the previous design, but candidate reconstruction uses the Blackwell FP16 conversion/multiply path instead of the heavier FP32 dequantization expression.
  • We scale the original input into the E4M3-scaled domain instead of fully decoding each candidate back to the original input domain. That applies the FP32 global scaling once to the original input; fully decoding both candidates would require per-element FP32 scaling on each decoded candidate, increasing the arithmetic cost.

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

Please list the changes introduced in this PR:

  • Add NVTE_NVFP4_4OVER6_ERR_USE_FAST_MATH to select the fast FP16 product-domain candidate-error implementation for NVFP4 4over6.
  • Keep NVTE_NVFP4_4OVER6_ERR_MODE focused on the selection metric, MAE or MSE, rather than encoding implementation details in additional public modes.
  • Thread the 4over6 error fast-math option through NVTEQuantizationConfig as a dedicated boolean config.
  • Add the FP16 candidate-error path to the NVFP4 4over6 CUDA kernel using Blackwell FP16 conversion PTX with the rn modifier and FP16 multiply.
  • Extend the PyTorch reference implementation with an emulated FP16 candidate path. PyTorch does not expose this exact FP16 round-to-nearest conversion/multiply sequence, so the reference reconstructs the FP16 result from integer fields and checks bitwise exactness against the kernel.
  • Extend NVFP4 exact quantization tests to cover 4over6 MAE / MSE, E4M3 max 448 / 256, and error fast-math enabled / disabled.
  • Update the environment variable docs for NVTE_NVFP4_4OVER6_ERR_MODE and NVTE_NVFP4_4OVER6_ERR_USE_FAST_MATH.

Testing note:

  • The C++ operator test was intentionally not expanded for the FP16 product-domain path. That test suite checks that each 4over6 block matches either map-to-4 or map-to-6 exactly; it does not validate candidate selection. Since this PR changes error computation and candidate selection, the strict PyTorch reference tests provide the meaningful coverage.
  • Although this path is controlled by NVTE_NVFP4_4OVER6_ERR_USE_FAST_MATH, the tests still require bitwise exactness against the PyTorch reference implementation. The reference emulates the FP16 conversion/multiply sequence that PyTorch does not expose directly, and the exact quantization tests use zero tolerance.

Fast-vs-strict agreement check:

To quantify the review concern about how much NVTE_NVFP4_4OVER6_ERR_USE_FAST_MATH=1 differs from the regular path, I compared emitted rowwise E4M3 scale bytes with fast error math disabled vs enabled on random M, K = 98304, 7168. The agreement rate is consistently above 99.9% across tensor-scaled 1D, tensor-scaled 2D, row-scaled 1D, E4M3 max 256 / 448, BF16 / FP16 inputs, and MAE / MSE selection modes.

scalequante4m3 maxdtypeerrsame E4M3 %different E4M3total E4M3
tensor1d256bf16MAE99.980177873044040192
tensor1d256bf16MSE99.993304294944040192
tensor1d256fp16MAE99.995965177744040192
tensor1d256fp16MSE99.99894046744040192
tensor1d448bf16MAE99.9312923025944040192
tensor1d448bf16MSE99.977966970444040192
tensor1d448fp16MAE99.99871956444040192
tensor1d448fp16MSE99.9997829644040192
tensor2d256bf16MAE99.9999641644040192
tensor2d256bf16MSE99.9997829644040192
tensor2d256fp16MAE100.000000044040192
tensor2d256fp16MSE99.9999273244040192
tensor2d448bf16MAE99.9999641644040192
tensor2d448bf16MSE99.99945524044040192
tensor2d448fp16MAE100.000000044040192
tensor2d448fp16MSE100.000000044040192
row1d256bf16MAE99.977505990744040192
row1d256bf16MSE99.993213298944040192
row1d256fp16MAE99.996885137244040192
row1d256fp16MSE99.99925532844040192
row1d448bf16MAE99.983867710544040192
row1d448bf16MSE99.996428157344040192
row1d448fp16MAE99.997593106044040192
row1d448fp16MSE99.99950521844040192

Validation:

python3 -m pytest --tb=auto tests/pytorch/nvfp4/test_nvfp4_quantize_exact.py -k 4over6
# 1824 passed, 1632 skipped, 432 deselected, 2 warnings
pre-commit run --all-files
# Passed

Performance:

No backend implementation changed in the interface refactor, so the benchmark values below are unchanged from the original FP16 error-path implementation.

Standalone quantization-kernel benchmark:

I also ran a local direct-quantizer benchmark that times NVFP4Quantizer.update_quantized with CUDA events, isolating quantization-kernel latency from grouped-linear dispatch and GEMM work.

python3 -u benchmarks/benchmark_4over6.py --shapes profile --warmup 20 --iters 2000

Shape: (16384, 6144). Slowdown is relative to the current non-4over6 NVFP4 kernel in the same 1D or 2D mode.

modeerrnon-4over6 usstrict 4over6 usstrict slowdownfast 4over6 usfast slowdownfast vs strict speedup
1dMAE103.259800.8927.756x289.1002.800x2.770x
1dMSE103.259829.6938.035x289.1482.800x2.869x
2dMAE126.737847.0706.684x306.0122.415x2.768x
2dMSE126.737867.0816.842x305.8422.413x2.835x

The direct kernel numbers show the main effect of this PR clearly: the fast 4over6 error path substantially reduces quantization-kernel overhead compared with strict original-domain error compute, while preserving bitwise exactness against the PyTorch reference.

Commands for the 2D activation sweep:

# Baseline, no 4over6.
NVTE_NVFP4_DISABLE_RHT=1 \
NVTE_NVFP4_DISABLE_STOCHASTIC_ROUNDING=1 \
python3 benchmarks/linear/benchmark_grouped_linear.py --recipe nvfp4
# 4over6 activation modes.
NVTE_NVFP4_4OVER6=activations \
NVTE_NVFP4_4OVER6_ERR_MODE=<MAE|MSE> \
NVTE_NVFP4_4OVER6_ERR_USE_FAST_MATH=<0|1> \
NVTE_NVFP4_DISABLE_RHT=1 \
NVTE_NVFP4_DISABLE_STOCHASTIC_ROUNDING=1 \
python3 benchmarks/linear/benchmark_grouped_linear.py --recipe nvfp4

Raw 2D activation grouped forward/backward timings, in ms per microbatch:

err_modeerr_use_fast_mathmknrecipenum_gemmsgrouped_fwd_bwd_time_ms
baseline-1638471682048nvfp440.768440
baseline-3276871682048nvfp441.246045
baseline-6553671682048nvfp442.226334
baseline-9830471682048nvfp443.220651
baseline-1638471682048nvfp480.999235
baseline-3276871682048nvfp481.428313
baseline-6553671682048nvfp482.400536
baseline-9830471682048nvfp483.387845
MAE01638471682048nvfp441.638763
MAE03276871682048nvfp442.870079
MAE06553671682048nvfp445.330869
MAE09830471682048nvfp447.818854
MAE01638471682048nvfp481.914825
MAE03276871682048nvfp483.168478
MAE06553671682048nvfp485.612699
MAE09830471682048nvfp488.076360
MSE01638471682048nvfp441.672350
MSE03276871682048nvfp442.936419
MSE06553671682048nvfp445.464571
MSE09830471682048nvfp448.020864
MSE01638471682048nvfp481.944005
MSE03276871682048nvfp483.234871
MSE06553671682048nvfp485.745626
MSE09830471682048nvfp488.274712
MAE11638471682048nvfp440.979436
MAE13276871682048nvfp441.648099
MAE16553671682048nvfp442.966817
MAE19830471682048nvfp444.395422
MAE11638471682048nvfp481.176896
MAE13276871682048nvfp481.848563
MAE16553671682048nvfp483.148987
MAE19830471682048nvfp484.552140
MSE11638471682048nvfp440.978000
MSE13276871682048nvfp441.644019
MSE16553671682048nvfp442.974658
MSE19830471682048nvfp444.401849
MSE11638471682048nvfp481.176988
MSE13276871682048nvfp481.846523
MSE16553671682048nvfp483.150669
MSE19830471682048nvfp484.554252

2D activation slowdown relative to baseline:

mknrecipenum_gemmsMAE, fast=0MSE, fast=0MAE, fast=1MSE, fast=1fast vs strict speedup
1638471682048nvfp442.133x2.176x1.275x1.273xMAE 1.673x / MSE 1.709x
3276871682048nvfp442.303x2.357x1.323x1.319xMAE 1.741x / MSE 1.787x
6553671682048nvfp442.394x2.455x1.333x1.336xMAE 1.796x / MSE 1.838x
9830471682048nvfp442.428x2.490x1.365x1.367xMAE 1.779x / MSE 1.822x
1638471682048nvfp481.916x1.945x1.178x1.178xMAE 1.626x / MSE 1.651x
3276871682048nvfp482.218x2.265x1.294x1.293xMAE 1.714x / MSE 1.752x
6553671682048nvfp482.338x2.393x1.312x1.312xMAE 1.782x / MSE 1.824x
9830471682048nvfp482.384x2.442x1.344x1.344xMAE 1.774x / MSE 1.817x

The fast error path consistently reduces 4over6 overhead compared with the default MAE / MSE original-domain error path in this sweep. We also see the same speedup trend on another grouped-linear NVFP4 recipe.

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

Signed-off-by: Ziang Li <ziangli@umich.edu>
Signed-off-by: Ziang Li <ziangli@umich.edu>
@github-actionsgithub-actionsBot added the community-contribution PRs from external contributor outside the core maintainers, representing community-driven work. label Jun 1, 2026
@zianglih
zianglih marked this pull request as ready for review June 1, 2026 07:19
@greptile-apps

greptile-appsBot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a fast NVFP4 4over6 candidate-error path controlled by NVTE_NVFP4_4OVER6_ERR_USE_FAST_MATH=1. Instead of decoding candidates fully back to the original input domain in FP32, the new path uses Blackwell PTX (cvt.rn.f16x2.e2m1x2, cvt.rn.f16x2.e4m3x2, mul.rn.f16x2) to compute the E2M1 × E4M3 product in FP16 and compare candidates in the E4M3-scaled domain, roughly halving 4over6 overhead.

  • CUDA kernel (quantize_4over6_nvfp4.cuh): introduces FP16ErrorScalePair, compute_fp16_error_scales, f16x2_scaled_to_float2, and accumulate_fp16_scaled_error_pair; removes the old non-rn fast_math arithmetic branch from accumulate_dequant_error and folds it cleanly into a compile-time if constexpr (Cfg::err_use_fast_math) dispatch.
  • Python reference (quantization_ref_nvfp4.py): emulates the FP16 multiplication sequence via integer bit manipulation in _ref_nvfp4_4over6_fp16_candidate; adds _sum_4over6_2d_error with an assert tile_len_y == 16 guard; threads nvfp4_4over6_err_use_fast_math through the quantizer constructor and all call sites.
  • Tests (test_nvfp4_quantize_exact.py): consolidates parametrization into a NVFP44Over6TestConfig dataclass; covers MAE/MSE × e4m3_max=448/256 × fast_math on/off; uses an env-var context manager that correctly saves and restores NVTE_NVFP4_4OVER6_ERR_USE_FAST_MATH.

Confidence Score: 5/5

Safe to merge; all paths verified correct and covered by zero-tolerance bitwise tests.

The PTX register layout in compute_fp16_error_scales is verified: map4 is packed into bits [7:0] of fp8_pair, cvt.rn.f16x2.e4m3x2 converts lower→lower/upper→upper, and the two mov.b32 replications correctly broadcast each FP16 scale into an f16x2 for mul.rn.f16x2. The Python reference _ref_nvfp4_4over6_fp16_candidate correctly decomposes E2M1 and E4M3 bit fields, and the subnormal FP16 branch is dead code in practice (minimum product exponent is -10, well above the -14 threshold). All error accumulation differences and the tree-reduction ordering match between CUDA and Python. The 1824-passed, zero-tolerance test run provides strong bitwise correctness evidence.

No files require special attention.

Important Files Changed

FilenameOverview
transformer_engine/common/cast/nvfp4/quantize_4over6_nvfp4.cuhAdds FP16 fast-error path using Blackwell PTX (cvt.rn.f16x2.e4m3x2, cvt.rn.f16x2.e2m1x2, mul.rn.f16x2); removes the old non-rn fast_math branch from accumulate_dequant_error; bit-packing and PTX register layout verified correct.
transformer_engine/pytorch/custom_recipes/quantization_ref_nvfp4.pyAdds _ref_nvfp4_4over6_fp16_candidate and _sum_4over6_2d_error helpers; threads nvfp4_4over6_err_use_fast_math through the reference quantizer; NaN E4M3 guard is dead code in practice (scale values are always ≤ 0x7E/448).
tests/pytorch/nvfp4/test_nvfp4_quantize_exact.pyReplaces three separate parametrize decorators with a consolidated NVFP44Over6TestConfig dataclass; adds environment-variable context manager; correctly covers MAE/MSE × e4m3_max=448/256 × fast_math=on/off combinations.
docs/envvars.rstUpdates NVTE_NVFP4_4OVER6_ERR_MODE and NVTE_NVFP4_4OVER6_ERR_USE_FAST_MATH descriptions to accurately reflect the refactored interface.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["cvt_fp32_to_fp4_8x_with_error()"] --> B{err_use_fast_math?}
B -->|false| C["accumulate_dequant_error()\nFP32 decode: dequant x sf x global_amax / denom\n__fdiv_rn / __fmul_rn / __fsub_rn"]
B -->|true| D["accumulate_fp16_scaled_error_pair()\nFP16: mul.rn.f16x2(q_h2, scale_h2)\nthen cvt.f32.f16 to FP32 diff"]
D --> E["f16x2_scaled_to_float2()\nmul.rn.f16x2 then cvt.f32.f16 x2"]
D --> F["original = x x global_encode_scale\n(__fmul_rn)"]
E --> G["diff = candidate minus original"]
F --> G
G --> H["compute_error_rn: abs diff or diff squared"]
C --> H
H --> I["FP32 error accumulation\n(__fadd_rn)"]
Loading

Reviews (7): Last reviewed commit: "Drop scripts" | Re-trigger Greptile

Comment on lines +517 to +524
def _sum_4over6_2d_error(err: torch.Tensor, tile_len_y: int) -> torch.Tensor:
"""Reduce 16 row errors in the same tree order as the CUDA warp reduction."""
rows = err.view(err.shape[0] // tile_len_y, tile_len_y, err.shape[1], 1)
rows = rows.squeeze(-1)
rows = rows[:, 0:8, :] + rows[:, 8:16, :]
rows = rows[:, 0:4, :] + rows[:, 4:8, :]
rows = rows[:, 0:2, :] + rows[:, 2:4, :]
return (rows[:, 0, :] + rows[:, 1, :]).unsqueeze(-1)

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.

P2Hardcoded 16-row tree reduction without enforcement

The function accepts tile_len_y as a parameter and uses it for the initial view, but the subsequent binary-tree steps unconditionally slice at [8:16], [4:8], [2:4], [0:2], which are only correct when tile_len_y == 16. A caller passing tile_len_y = 32 would silently sum only the first 16 rows and discard the remaining 16; a caller passing tile_len_y = 8 would produce a shape mismatch on the first add. Adding an assert tile_len_y == 16 at the top would make the contract explicit and catch future regressions immediately.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 5136b58. _sum_4over6_2d_error now asserts tile_len_y == 16 before the tree reduction, and I also updated the reference docstring/comment to distinguish original-domain MAE/MSE from E4M3-scaled FP16 product-domain MAE_FP16/MSE_FP16.

Signed-off-by: Ziang Li <ziangli@umich.edu>
@zianglih

Copy link
Copy Markdown
ContributorAuthor

Since this is identical to the orginal mode in the algebraic sense, may be a better interface design it to let this mode replace the original fast math modes, instead of extending error modes.

Signed-off-by: Ziang Li <ziangli@umich.edu>
Signed-off-by: Ziang Li <ziangli@umich.edu>
Signed-off-by: Ziang Li <ziangli@umich.edu>
Signed-off-by: Ziang Li <ziangli@umich.edu>
@zianglihzianglih changed the title Add FP16 error modes for NVFP4 4over6Add fast FP16 error path for NVFP4 4over6Jun 2, 2026
@zianglih

Copy link
Copy Markdown
ContributorAuthor

Interface refactor update:

I refactored the core 4over6 error interface so the selection metric and implementation fast path are separate knobs:

  • NVTE_NVFP4_4OVER6_ERR_MODE remains the candidate-selection metric and only accepts MAE / MSE.
  • NVTE_NVFP4_4OVER6_ERR_USE_FAST_MATH=1 enables the FP16 product-domain error path for the selected metric.
  • The C++ quantization config mirrors this split with NVTENVFP44Over6Mode for disabled / min-MAE / min-MSE, plus a separate nvfp4_4over6_err_use_fast_math boolean.

This replaces the earlier public MAE_FP16 / MSE_FP16 mode design. The backend FP16 error implementation and benchmark numbers did not change in this interface refactor; the PR body now relabels the commands and tables around ERR_MODE + ERR_USE_FAST_MATH instead.

@zianglihzianglih changed the title Add fast FP16 error path for NVFP4 4over6Optimize NVFP4 4over6 candidate error pathJun 2, 2026
@zianglih

Copy link
Copy Markdown
ContributorAuthor

@greptile-apps I have updated PR body, review again.

@zianglih

Copy link
Copy Markdown
ContributorAuthor

FlashInfer PR that implements the same contract:

@ptrendx

Copy link
Copy Markdown
Member

Do you have any data on how different the result is between the regular path and this new fast path? Maybe we could just make it a default path rather than introduce another env variable?

@zianglih

zianglih commented Jun 2, 2026

Copy link
Copy Markdown
ContributorAuthor

Hi @ptrendx ,

I have some results using this script:

"""Compare NVFP4 4over6 E4M3 scales with and without error fast math."""importosfromcontextlibimportcontextmanagerimporttorchfromtransformer_engine.pytorchimportNVFP4Quantizerimporttransformer_engine_torchastexM, K=98304, 7168@contextmanagerdef_error_fast_math(enabled: bool):
old_value=os.environ.get("NVTE_NVFP4_4OVER6_ERR_USE_FAST_MATH")
os.environ["NVTE_NVFP4_4OVER6_ERR_USE_FAST_MATH"] ="1"ifenabledelse"0"try:
yieldfinally:
ifold_valueisNone:
os.environ.pop("NVTE_NVFP4_4OVER6_ERR_USE_FAST_MATH", None)
else:
os.environ["NVTE_NVFP4_4OVER6_ERR_USE_FAST_MATH"] =old_valuedef_quantize_scale_bytes(
x: torch.Tensor,
err_mode: str,
err_fast_math: bool,
row_scaled: bool,
with_2d_quantization: bool,
nvfp4_e4m3_max: int,
) ->torch.Tensor:
quantizer=NVFP4Quantizer(
fp4_dtype=tex.DType.kFloat4E2M1,
rowwise=True,
columnwise=False,
with_amax_reduction=False,
amax_reduction_group=None,
with_rht=False,
with_post_rht_amax=False,
with_2d_quantization=with_2d_quantization,
row_scaled_nvfp4=row_scaled,
nvfp4_use_4over6=True,
nvfp4_e4m3_max=nvfp4_e4m3_max,
nvfp4_4over6_err_mode=err_mode,
)
with_error_fast_math(err_fast_math):
quantized=quantizer(x)
assertquantized._rowwise_scale_invisnotNonereturnquantized._rowwise_scale_inv.contiguous().view(torch.uint8)
def_compare_e4m3(
x: torch.Tensor,
dtype_name: str,
scale_mode: str,
row_scaled: bool,
quant_mode: str,
with_2d_quantization: bool,
nvfp4_e4m3_max: int,
) ->None:
forerr_modein ("MAE", "MSE"):
regular=_quantize_scale_bytes(
x, err_mode, False, row_scaled, with_2d_quantization, nvfp4_e4m3_max
)
fast=_quantize_scale_bytes(
x, err_mode, True, row_scaled, with_2d_quantization, nvfp4_e4m3_max
)
same=torch.count_nonzero(regular==fast).item()
total=regular.numel()
print(
f"{scale_mode:>6}{quant_mode:>5}{nvfp4_e4m3_max:8d} "f"{dtype_name:>5}{err_mode:>3} "f"{100.0*same/total:12.6f}{total-same:15d}{total}"
)
defmain():
torch.set_grad_enabled(False)
print(f"shape=({M}, {K}), 1d_e4m3_values={M*K//16}")
print("scale quant e4m3_max dtype mode same_e4m3_pct different_e4m3 total_e4m3")
forscale_mode, row_scaled, quant_mode, with_2d_quantizationin (
("tensor", False, "1d", False),
("tensor", False, "2d", True),
("row", True, "1d", False),
):
fornvfp4_e4m3_maxin (256, 448):
fordtype, dtype_namein ((torch.bfloat16, "bf16"), (torch.float16, "fp16")):
torch.manual_seed(1234)
x=torch.randn((M, K), dtype=dtype, device="cuda")
_compare_e4m3(
x,
dtype_name,
scale_mode,
row_scaled,
quant_mode,
with_2d_quantization,
nvfp4_e4m3_max,
)
delxtorch.cuda.empty_cache()
if__name__=="__main__":
main()

The agreement rate is consistently >99.9% on random M, K = 98304, 7168:

shape=(98304, 7168), 1d_e4m3_values=44040192
scale quant e4m3_max dtype mode same_e4m3_pct different_e4m3 total_e4m3
tensor 1d 256 bf16 MAE 99.980177 8730 44040192
tensor 1d 256 bf16 MSE 99.993304 2949 44040192
tensor 1d 256 fp16 MAE 99.995965 1777 44040192
tensor 1d 256 fp16 MSE 99.998940 467 44040192
tensor 1d 448 bf16 MAE 99.931292 30259 44040192
tensor 1d 448 bf16 MSE 99.977966 9704 44040192
tensor 1d 448 fp16 MAE 99.998719 564 44040192
tensor 1d 448 fp16 MSE 99.999782 96 44040192
tensor 2d 256 bf16 MAE 99.999964 16 44040192
tensor 2d 256 bf16 MSE 99.999782 96 44040192
tensor 2d 256 fp16 MAE 100.000000 0 44040192
tensor 2d 256 fp16 MSE 99.999927 32 44040192
tensor 2d 448 bf16 MAE 99.999964 16 44040192
tensor 2d 448 bf16 MSE 99.999455 240 44040192
tensor 2d 448 fp16 MAE 100.000000 0 44040192
tensor 2d 448 fp16 MSE 100.000000 0 44040192
row 1d 256 bf16 MAE 99.977505 9907 44040192
row 1d 256 bf16 MSE 99.993213 2989 44040192
row 1d 256 fp16 MAE 99.996885 1372 44040192
row 1d 256 fp16 MSE 99.999255 328 44040192
row 1d 448 bf16 MAE 99.983867 7105 44040192
row 1d 448 bf16 MSE 99.996428 1573 44040192
row 1d 448 fp16 MAE 99.997593 1060 44040192
row 1d 448 fp16 MSE 99.999505 218 44040192

We did not introduce any extra env var in this PR. NVTE_NVFP4_4OVER6_ERR_USE_FAST_MATH was already there previously but for controlling rounding modifiers in dequant arithmetic instrcutions (which did not lead to noticeable speedup). We just replace that backend with a new implementation.

I do think we can make NVTE_NVFP4_4OVER6_ERR_USE_FAST_MATH =1 the default but I am not sure if we should make this specific contract the only kept 4over6 implementation. Also in the future we may land other performance improvements which may change the contract numerics. I think it is better to support the canonical 4over6 contract from the original paper by NVTE_NVFP4_4OVER6_ERR_USE_FAST_MATH=0, and use NVTE_NVFP4_4OVER6_ERR_USE_FAST_MATH =1 to land this contract and potential future improvements.

template <typename Cfg, int E4M3_MAX>
__device__ __forceinline__ uint32_t cvt_fp32_to_fp4_8x_with_error(const float (&x)[8],
const float block_scale_inverse,
__device__ __forceinline__ float2 e2m1x2_scaled_e4m3_to_float2(const uint32_t e2m1_byte,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we only compute 2 values in this function? Also, we don't use half of the scale_h2 here. Why don't we instead try to convert here values from both of the branches (so both 4 and 6 would be there, the scaling factors for both of these branches would be converted in a single instruction). Ideally we would then reuse those scaling factors rather than recasting them for every element in a block - considering we are math bound here, we need to make sure that we eliminate as many instructions as possible.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried a refactored implementation in 54797b3 but it did not show meaningful speedup:

NVTE_NVFP4_4OVER6=activations \
NVTE_NVFP4_4OVER6_ERR_MODE=<MAE|MSE> \
NVTE_NVFP4_4OVER6_ERR_USE_FAST_MATH=1 \
NVTE_NVFP4_DISABLE_RHT=1 \
NVTE_NVFP4_DISABLE_STOCHASTIC_ROUNDING=1 \
python3 benchmarks/linear/benchmark_grouped_linear.py --recipe nvfp4

Extended Fast-Path Timing Table:

mknrecipenum_gemmsbaseline_msold_MAE_fast1_msrefactor_MAE_fast1_msMAE_refactor_speedupold_MSE_fast1_msrefactor_MSE_fast1_msMSE_refactor_speedup
1638471682048nvfp440.7684400.9794360.9842380.995x0.9780000.9840610.994x
3276871682048nvfp441.2460451.6480991.6435751.003x1.6440191.6452520.999x
6553671682048nvfp442.2263342.9668172.9774670.996x2.9746582.9904810.995x
9830471682048nvfp443.2206514.3954224.3961481.000x4.4018494.4095210.998x
1638471682048nvfp480.9992351.1768961.1822250.995x1.1769881.2587030.935x
3276871682048nvfp481.4283131.8485631.8537780.997x1.8465231.8571250.994x
6553671682048nvfp482.4005363.1489873.1521500.999x3.1506693.1536900.999x
9830471682048nvfp483.3878454.5521404.5498761.000x4.5542524.5602870.999x

the refactor-vs-old geomean was:

MAE: 0.9983x
MSE: 0.9889x
combined: 0.9936x

This refactor is essentially common instruction lifting/reuse, and it keeps the same core PTX instructions (cvt.rn.f16x2.e4m3x2, cvt.rn.f16x2.e2m1x2, mul.rn.f16x2) rather than introducing a different PTX operation. I think the compiler can already do this in the old implementation but I am not sure.

@zianglihzianglihJun 3, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kernel level benchmark does not show speedup either:

modekernelerrmetricold usrefactor usold/refactor
1dnvfp4-strict103.260103.2591.000x
1d4over6MAEstrict799.852800.8920.999x
1d4over6MAEfast288.834289.1000.999x
1d4over6MSEstrict829.294829.6931.000x
1d4over6MSEfast287.426289.1480.994x
2dnvfp4-strict126.692126.7371.000x
2d4over6MAEstrict847.700847.0701.001x
2d4over6MAEfast306.286306.0121.001x
2d4over6MSEstrict866.949867.0811.000x
2d4over6MSEfast306.748305.8421.003x

Script: 83e2308 , shape (16384, 6144), with --warmup 20 --iters 2000

@zianglihzianglihJun 3, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I keep the refactoring in beaed67 .The perf behavior of the explicit pattern is more robust to compiler optimizations.

Signed-off-by: Ziang Li <ziangli@umich.edu>
This reverts commit 54797b3.
Signed-off-by: Ziang Li <ziangli@umich.edu>
Signed-off-by: Ziang Li <ziangli@umich.edu>
Signed-off-by: Ziang Li <ziangli@umich.edu>
aleozlx pushed a commit to flashinfer-ai/flashinfer that referenced this pull request Jun 3, 2026
<!-- .github/pull_request_template.md -->
## 📌 Description
@HumansAnd
Adds a CuTe DSL NVFP4 quantization backend with support for per-token
activation quantization and NVFP4 4over6 scale-candidate selection.
The 4over6 configuration remains environment-driven to match the
existing kernel configuration flow:
- `FLASHINFER_NVFP4_4OVER6=1`
- `FLASHINFER_NVFP4_4OVER6_ERR_MODE=MAE|MSE`
- `FLASHINFER_NVFP4_4OVER6_ERR_USE_FAST_MATH=0|1`
- `FLASHINFER_NVFP4_4OVER6_E4M3_USE_256=0|1`
- `FLASHINFER_DISABLE_FP4_QUANT_FAST_MATH=0|1`
- C++ dispatch also accepts `TRTLLM_DISABLE_FP4_QUANT_FAST_MATH=1` as a
compatibility fallback. Python/CuTe DSL paths use the FlashInfer env
name.
The public 4over6 error-mode surface is now only `MAE` and `MSE`. The
former FP16-specific public modes were removed. Instead,
`FLASHINFER_NVFP4_4OVER6_ERR_USE_FAST_MATH` selects the candidate-error
contract:
- `0`: strict full-dequant candidate scoring with explicit RN
arithmetic.
- Per-token NVFP4 uses the online row amax and scores candidates with
the TE-style expression `(e2m1 * sf * global_amax) / (6 * e4m3_max)`.
- Per-tensor NVFP4 uses the global decode scale path derived from the
provided global scale, rather than materializing a separate global amax
in the Python API. This avoids adding an always-on input reduction for
per-tensor callers.
- CUDA and CuTe DSL follow the same per-token/per-tensor split.
- `1`: FP16-domain candidate scoring. Candidates are decoded through the
E2M1 x E4M3 FP16 conversion path and compared against `x *
global_encode_scale`; this path also uses explicit RN arithmetic for
candidate error scoring.
~~Earlier revisions treated the strict full-dequant path as a single
TE-style expression for both per-token and per-tensor NVFP4.~~ The
current contract only requires the TE expression for the per-token path
where amax is already computed online; per-tensor uses the existing
global decode scale expression to avoid extra overhead.
CUDA, CuTe DSL, and fused MoE dispatch all route through the shared
`NVFP44Over6Config<e4m3_max, err_mode, err_use_fast_math>`
configuration. The old 4over6 candidate-error paths that used
unqualified fast-math arithmetic were removed.
## 🔍 Related Issues
TE PR that implements the same contract:
- NVIDIA/TransformerEngine#3068
Other relevant PRs:
- #3387
- NVIDIA/TransformerEngine#2972
- #3264
- #3027
## 🚀 Pull Request Checklist
Thank you for contributing to FlashInfer! Before we review your pull
request, please make sure the following items are complete.
### ✅ Pre-commit Checks
- [x] I have installed `pre-commit` by running `pip install pre-commit`
(or used your preferred method).
- [x] I have installed the hooks with `pre-commit install`.
- [x] I have run the hooks manually with `pre-commit run --all-files`
and fixed any reported issues.
> If you are unsure about how to set up `pre-commit`, see [the
pre-commit documentation](https://pre-commit.com/).
## 🧪 Tests
- [x] Tests have been added or updated as needed.
- [x] All tests are passing (`unittest`, etc.).
Unit tests extended:
- `tests/test_helpers/utils_fp4.py` extends the TE-style NVFP4/4over6
reference path for both full-dequant scoring and FP16-domain scoring.
- `tests/utils/test_fp4_quantize.py` extends
`test_nvfp4_quantize_te_reference` to cover strict bitwise CUDA and CuTe
DSL agreement for NVFP4, per-token activation, scale-factor layouts,
zero inputs, and 4over6 MAE/MSE configurations.
Commands run:
```bash
pre-commit run --all-files
```
Passed.
```bash
python3 -m compileall \
flashinfer/quantization/nvfp4_quantization_utils.py \
tests/test_helpers/utils_fp4.py
```
Passed.
```bash
CUDA_VISIBLE_DEVICES=0 \
python3 -m pytest -q --tb=short tests/utils/test_fp4_quantize.py::test_nvfp4_quantize_te_reference
```
Passed: `9504 passed in 756.79s (0:12:36)`.
```bash
CUDA_VISIBLE_DEVICES=0 \
python3 -m pytest -q --tb=short tests/moe/test_trtllm_cutlass_fused_moe.py
```
Passed: `75 passed, 25 skipped, 1 warning in 74.79s (0:01:14)`.
```bash
CUDA_VISIBLE_DEVICES=0 \
python3 -m pytest -q --tb=short tests/moe/test_trtllm_gen_per_token_moe.py
```
Passed: `108 passed in 785.08s (0:13:05)`.
## Benchmarking
Updated benchmark run compares PR head (`b44e847b`) against the earliest
PR commit with CUDA 4over6 support (`e30a0b14`). The denominator for
every 4over6 speedup is the baseline CUDA 4over6 MSE no-fast time for
the same `(M, K)` case.
Common config:
- dtype: `bfloat16`
- scale-factor layout: `swizzled_128x4`
- `FLASHINFER_NVFP4_4OVER6=1`
- `FLASHINFER_NVFP4_4OVER6_ERR_MODE=MSE`
- `FLASHINFER_NVFP4_4OVER6_E4M3_USE_256=1`
- `FLASHINFER_DISABLE_FP4_QUANT_FAST_MATH=1`
- CUDA graph disabled, CUPTI timing enabled, cold L2 cache enabled
Per-token runs add `--per-token-activation`; per-tensor runs omit it.
`FLASHINFER_NVFP4_4OVER6_ERR_USE_FAST_MATH` is set to `0` for no-fast
rows and `1` for fast rows.
Each table cell is `geomean / min / max` speedup over the scanned M/K
cases.
| Activation scale | CUDA no fast math | CUDA fast math | CuTe DSL no
fast math | CuTe DSL fast math |
| --- | ---: | ---: | ---: | ---: |
| Per-tensor | 2.83x / 1.65x / 5.81x | 3.22x / 1.67x / 7.25x | 2.17x /
1.21x / 8.26x | 4.12x / 2.45x / 20.92x |
| Per-token | 0.89x / 0.71x / 0.98x | 1.41x / 1.05x / 3.40x | 2.05x /
1.03x / 2.89x | 3.00x / 1.80x / 4.17x |
Pure per-token NVFP4 without 4over6, same dtype/layout/quant-fast-math
setting:
| Mode | CuTe DSL vs CUDA geomean/min/max |
| --- | ---: |
| Pure per-token no 4over6 | 2.24x / 1.04x / 3.76x |
Heat maps:
![NVFP4 4over6 MSE per-tensor speedup heat
map](https://gist.githubusercontent.com/zianglih/1e1fdd42d27244692d66cd6a5b2b904f/raw/2f4f7965c0b57e7b148acdde64a632daa11072a6/nvfp4_4over6_mse_speedup_vs_baseline_per_tensor.svg)
![NVFP4 4over6 MSE per-token speedup heat
map](https://gist.githubusercontent.com/zianglih/1e1fdd42d27244692d66cd6a5b2b904f/raw/23fc9c84605795b9c17febac47f0175fc14c3a07/nvfp4_4over6_mse_speedup_vs_baseline_per_token.svg)
![NVFP4 pure per-token no-4over6 CuTe DSL vs CUDA heat
map](https://gist.githubusercontent.com/zianglih/1e1fdd42d27244692d66cd6a5b2b904f/raw/34e161c89dc0a23378c5d23041567953b8f7c9b2/nvfp4_pure_per_token_no_4over6_cute_vs_cuda.svg)
Previous benchmark snapshot kept for review context:
| Activation scale | Baseline CUDA no-fast geomean (ms) | CUDA no-fast |
CUDA fast | CuTe DSL no-fast | CuTe DSL fast |
| --- | ---: | ---: | ---: | ---: | ---: |
| Per-tensor | ~~0.074336~~ | ~~1.000x~~ | ~~1.668x~~ | ~~1.434x~~ |
~~1.738x~~ |
| Per-token | ~~0.065345~~ | ~~0.974x~~ | ~~1.554x~~ | ~~2.297x~~ |
~~3.267x~~ |
- ~~Previous benchmark run compared current PR head (`e5349fc2`) against
a pre-FP16-refactor baseline (`99a21fcd`).~~
- ~~Previous common config used
`FLASHINFER_NVFP4_4OVER6_E4M3_USE_256=0`.~~
- ~~Previous table reported only geomean speedup.~~
Full 22x15 per-case data was generated separately and kept out of the PR
body.
## Reviewer Notes
The 4over6 path can become compute-bound at large inputs. The benchmark
path keeps the built-in sweep and uses environment variables for 4over6
mode selection rather than adding 4over6-specific shape or mode CLI
arguments.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Added 4over6 quantization mode for NVFP4 with configurable error
metrics (MAE/MSE)
* Added per-token activation support for NVFP4 quantization
* Extended CuTe-DSL backend capabilities for improved quantization
operations
* **Documentation**
* Updated quantization benchmarks with new 4over6 mode configuration
examples
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Signed-off-by: Ziang Li <ziangli@umich.edu>
@timmoon10

Copy link
Copy Markdown
Member

/te-ci

@zianglih

Copy link
Copy Markdown
ContributorAuthor

pytorch failure is attention:

=========================== short test summary info ============================
FAILED ../../tests/pytorch/attention/test_attention.py::test_dpa_fp8_vs_f16[mxfp8-True-True-bshd_bshd_bshd-fp8_9-dtype1] - AssertionError: fused_attn_bwd_fp8[0] vs fused_attn_bwd_f16[0] RMSE nan is over tolerance nan (0.11000 * nan)
FAILED ../../tests/pytorch/attention/test_attention.py::test_dpa_fp8_vs_f16[mxfp8-True-True-bshd_bshd_bshd-fp8_11-dtype1] - AssertionError: fused_attn_bwd_fp8[0] vs fused_attn_bwd_f16[0] RMSE nan is over tolerance nan (0.11000 * nan)
FAILED ../../tests/pytorch/attention/test_attention.py::test_dpa_fp8_vs_f16[mxfp8-True-True-sbhd_sbhd_sbhd-fp8_9-dtype1] - AssertionError: fused_attn_bwd_fp8[0] vs fused_attn_bwd_f16[0] RMSE nan is over tolerance nan (0.11000 * nan)
==== 3 failed, 2197 passed, 1554 skipped, 31 warnings in 971.42s (0:16:11) =====
Error: sub-test failed: NVTE_ALLOW_NONDETERMINISTIC_ALGO=0 test_attention.py

This PR does not touch JAX

@timmoon10timmoon10 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@timmoon10
timmoon10 merged commit b972fa8 into NVIDIA:mainJun 9, 2026
29 of 33 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@zianglih@ptrendx@timmoon10