[PyTorch] MXFP4 weight QAT on MXFP8 and FP8 block-scaling recipes#3264
[PyTorch] MXFP4 weight QAT on MXFP8 and FP8 block-scaling recipes#3264xiuhu17 wants to merge 14 commits into
Conversation
MXFP4QATMXFP8BlockScaling / MXFP4QATFloat8BlockScaling project weights onto the MXFP4 (E2M1, 1x32 power-of-two scale) grid before the host recipe quantizes them; activations and gradients are untouched. The projection is a fused fake-quantization with three bit-identical implementations (CUDA kernel, CuTe DSL, PyTorch reference) dispatched via NVTE_MXFP4_QAT_IMPL, with an identity-STE gradient. - scale contract matches the TileKernels deployment path (floor 2^-126, cap 2^125); integer-bit amax/scale-exponent derivation and non-FTZ PTX keep results bit-exact under --use_fast_math builds - the rowwise MXFP8 and 128x128 blockwise weight encodings of the projected weight are bitwise lossless (raw payload/scale verified); the columnwise 32x1 encoding is bounded - fix ptx.cuh exp2f expansion of UE8M0 code 0 (2^-127) and code 255 (NaN) - reject MXFP4 QAT loudly on surfaces that bypass the weight-quantization hook (te.ops, Userbuffers, fused grouped MLP, quantized primary weights); project the weight in FSDP2/GTP backward rematerialization; invalidate cached weight workspaces on base<->QAT recipe switches - tests: per-step bitwise losslessness, bf16-exhaustive and fp32 bit-fuzz oracles, RTNE midpoint/threshold vectors, fast-math immunity build, STE, misaligned/non-contiguous inputs, and a TileKernels/CuTe-DSL/CUDA/torch four-way bitwise matrix plus a 24-config e2e backward-override matrix
The MXFP4 grid reaches 6*2^125, far beyond fp16 range, so an fp16 pipeline cannot represent the projected weight or its dequantized form. quantize_weight now raises when a QAT recipe is active and the workspace/activation dtype is fp16 (fp16 weights were already rejected by the projection itself); documented in both recipe docstrings.
- exp2f JIT test now checks all 256 codes bitwise including the code-255 NaN payload (0x7FFFFFFF) - add an end-to-end nvte MXFP8 software-dequantize test with planted extreme scale codes through the real kernel: demands code 0 -> 2^-127 and code 255 -> NaN on a fixed build, and pins the pre-fix wheel behavior (flush to zero / Inf) until then - the bf16-dequant losslessness test auto-detects a fixed build instead of hard-asserting the pre-fix flush
for more information, see https://pre-commit.ci
NVFP4-style control: MXFP4 weight QAT is now a field on the MXFP8 and Float8 block-scaling host recipes, defaulting from NVTE_MXFP4_QAT, so a stock --fp8-recipe mxfp8/blockwise launch enables QAT without framework changes. The QAT subclasses remain as the explicit API (field pinned True); blockwise validation moves to the host, guarded on the field.
…t raw-tuple identity The host encoder re-canonicalizes (payload, scale): TE-native normalizes the block amax into (224, 448] while a fixed-shift direct converter keeps the original factorization, so the raw E4M3/UE8M0 bytes generally differ between the two even though every decoded value is identical. 'Losslessly' in the module docstring was readable as raw-bit identity; say exactly what holds.
- Move the composite-torch-ops reference implementation to tests/pytorch/references/mxfp4_qat_reference.py (the numerical oracle, matching the existing blockwise reference layout); the main module no longer carries a runtime fallback path. - transformer_engine/pytorch/mxfp4_qat.py now only validates inputs, applies the straight-through-estimator wrapper, and calls the tex.mxfp4_fake_quantize binding; a missing binding is a hard error and the NVTE_MXFP4_QAT_IMPL/REQUIRE_KERNEL/DISABLE_CUDA_KERNEL knobs are removed. - Drop the CuTe DSL implementation: the cast layer has no DSL precedent and TE carries no cutlass-DSL runtime dependency. - Tests compare the kernel against the reference directly and demand the fixed UE8M0 code-0/255 dequantize semantics unconditionally.
Direct conversion of the MXFP4 decomposition into the host FP8 representation without a bf16 bridge tensor: fixed-shift-6 rowwise MXFP8 (deployment-canonical, invertible bytes), host-amax-rule columnwise (dgrad byte parity with the bridge), and per-tile exponent-folding 128x128 blockwise with RTNE onto E4M3 (exact through spread 2^14, no device assert). In-tree CUDA kernels (direct_convert_mxfp4.cuh) with C APIs and bindings, a composite-torch reference/fallback, recipe field mxfp4_qat_direct (NVTE_MXFP4_QAT_DIRECT), quantize_weight routing, and a parity suite incl. bitwise e2e equality against the bridge mode.
They imported an external TileKernels checkout with a developer-machine default path and can therefore never run in CI; the cross-backend comparison lives on as a standalone harness outside the tree. The in-tree suite keeps the kernel-vs-reference parity, exhaustive bf16 and fp32 fuzz coverage, and the raw-byte encode oracles.
Same rationale as the bridge suite: it imported an external TileKernels checkout via a developer-machine default path and cannot run in CI. The direct converters keep kernel-vs-reference bitwise parity, canonical-byte assertions, and the e2e bitwise equality against the bridge mode.
for more information, see https://pre-commit.ci
Greptile SummaryThis PR adds direct MXFP4-to-FP8 weight conversion for MXFP4 QAT.
Confidence Score: 3/5This PR should not merge until direct cached-weight updates honor CUDA-graph requests to preserve the existing workspace. The new direct cache-hit branch bypasses the noop flag used by every existing quantized update path, allowing non-first CUDA-graph microbatches to overwrite weights that the replay contract requires them to reuse. Files Needing Attention: transformer_engine/pytorch/module/base.py; transformer_engine/pytorch/mxfp4_qat_direct.py Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart LR
W[BF16/FP32 master weight] --> Q[MXFP4 decomposition]
Q --> R[Direct MXFP8 rowwise conversion]
Q --> C[Composite MXFP8 columnwise conversion]
Q --> B[Direct FP8 128x128 blockwise conversion]
R --> WS[Cached quantized workspace]
C --> WS
B --> WS
WS --> G[Forward/backward GEMM]
S[CUDA-graph skip-update flag] -. must gate updates .-> WS
Reviews (2): Last reviewed commit: "[pre-commit.ci] auto fixes from pre-comm..." | Re-trigger Greptile |
| if _mxfp4_qat_direct: | ||
| mxfp4_qat_direct_update_(tensor, workspace) | ||
| return workspace, None |
There was a problem hiding this comment.
When a non-first CUDA-graph microbatch requests cached-weight reuse, this direct branch calls mxfp4_qat_direct_update_ without forwarding the skip flag, so it unconditionally rewrites the workspace and the replayed GEMM can consume different weights than the cached first-microbatch weights.
Knowledge Base Used: PyTorch Fused Modules (transformer_engine/pytorch/module)
…izer The backward_override=None columnwise representation is the literal spec chain bf16 -> mxfp4(row) -> dequantized bf16 -> host MXFP8 columnwise quantize, and both steps already have production kernels (tex.mxfp4_fake_quantize and the MXFP8 columnwise encoder). Use them instead of the composite-torch replication of the host amax rule: the columnwise buffers are now the bridge path's byte for byte by construction, and the module carries no torch numerics.
for more information, see https://pre-commit.ci
Description
Weight-only MXFP4 quantization-aware training on the MXFP8 and FP8 block-scaling (128x128) recipes: training sees exactly the deployment weight (E2M1 payloads, 1x32 power-of-two UE8M0 scales) while activations and gradients keep the base recipe untouched. Supersedes #3263 (closed; its commits are included here).
Motivation: the DeepSeek-V4 technical report observes that FP4-to-FP8 dequantization is lossless -- E4M3's wider exponent range fully absorbs the fine-grained 1x32 scales whenever the scale spread inside an FP8 quantization block stays under a threshold -- letting MXFP4 QAT reuse the existing FP8 training pipeline.
Two weight-conversion modes:
bf16/fp32 -> MXFP4 grid -> bf16/fp32(tex.mxfp4_fake_quantize: per 1x32 block, scale2^clamp(ceil(log2(amax/6)), -126, 125)derived from the amax bit pattern, RTNE onto E2M1, straight-through-estimator gradient). All MXFP8 and 128x128 blockwise FP8 encoding/decoding of the projected weight runs through the existing quantizer pipeline unchanged; since every projected value is a grid pointp * 2^e, those encodings are decoded-value exact (MXFP8 rowwise unconditionally; 128x128 blockwise up to tile scale spread 2^14).mxfp4_qat_directfield /NVTE_MXFP4_QAT_DIRECT=1): converts the MXFP4 decomposition straight into the host FP8 representation, skipping the bf16 re-encode. MXFP8 rowwise = fixed-shift-6 lift (deployment-canonical, invertible bytes; scale clamps at UE8M0 code 0 with exact payload absorption); MXFP8 columnwise = fresh 32x1 quantization with the host amax rule (dgrad bytes match mode 1 bit for bit); blockwise = per-tile exponent folding (tile scale2^(max fp4 exp - 6)clamped at 2^-127, RTNE onto E4M3 incl. subnormals, no device-side assert).API:
MXFP4QATMXFP8BlockScaling/MXFP4QATFloat8BlockScalingrecipe subclasses, or themxfp4_qat_weightsfield on the stock hosts (NVTE_MXFP4_QAT=1).backward_overridekeeps its base-recipe semantics. Also fixesptx::exp2f(e8m0)to decode UE8M0 code 0 as 2^-127 and code 255 as NaN.Weight dataflows
MXFP8 host:
backward_overridedequantizedhigh_precisionNoneFP8 blockwise (128x128) host -- one square encoding, no row/col distinction:
backward_overridedequantizedhigh_precisionNoneSTE: identity gradient into the weight parameter (
.gradand fusedmain_gradverified end to end), so updates propagate directly to the FP32 master weights. fp16 is rejected (the MXFP4 grid exceeds fp16 range).Test results (B200)
tests/pytorch/test_mxfp4_qat.py-- the projection and the host encodings:--use_fast_mathvs normal buildbackward_overridemodes x fused/unfused wgrad, dgrad-lineage discriminators; recipe-switch cache invalidation; loud rejection of unsupported surfaces (ops API, fused Userbuffers/grouped-MLP, fp16, primary FP8 weights)tests/pytorch/test_mxfp4_qat_direct.py-- the direct converters:backward_overridemodes x cache miss/hit/update paths--use_fast_mathbuildsCross-validation against TileKernels (the independent deployment MXFP4 implementation, power-of-two scale mode): the projection values match bit-for-bit on the bf16-exhaustive corpus (2036/2048 finite below-saturation blocks) and fp32 edge/fuzz corpora; direct-row payload bytes and scale codes, and direct-blockwise payload bytes and tile scale codes, match TileKernels'
per_token_cast/per_block_cast_losslessdevice kernels on the same corpora plus spread-banded and zero/2^-127/negative-zero tiles. Divergence only outside the shared domain (amax > 6*2^125, non-finite policy, spreads > 2^11 where TileKernels device-asserts while this implementation stays exact to 2^14). The tests carrying these comparisons depend on the external TileKernels package and were dropped from this PR in 2f40ca8 and 7a4e47e.Known limitations
Type of change
Checklist: