Skip to content

Numerical accuracy issue with Triton MXFP4 kernel with respect to quantization #665

Description

@amital-amd

Summary

The Triton MXFP4 cast-transpose path (te_cast_transpose_mxfp4_triton, selected by
NVTE_USE_CAST_TRANSPOSE_TRITON=1) does not honor the quantizer's shuffle_columnwise_data /
shuffle_rowwise_data flags. It applies the scale swizzle (with_gemm_swizzled_scales) but
stores the packed FP4 data in the plain transposed layout, ignoring the data-preshuffle flags
entirely.

The native/HIP path (tex.quantizete_mxfp4::cast_transpose_mxfp4_shuffled) does apply the data
preshuffle. So when a recipe sets shuffle_columnwise_data=True (the real MXFP4 training layout),
the Triton kernel's columnwise operand is unshuffled and does not match the preshuffled layout
the AITER a4w4 GEMM consumes (bpreshuffle=True).

Because the columnwise operand feeds the backward weight-gradient (wgrad) GEMM, the FP4 values
are correct but their ordering is wrong. This produces structurally-wrong weight gradients that
compound over many steps into a slow grad-norm blow-up (not an immediate NaN).

Reproduction

Attaching a script which helps in reproduction of bug. triton_mxfp4_ignores_columnwise_shuffle.py

The attached script reproduces the bug on a single GPU (random bf16 tensor, no training). It
quantizes the same tensor with both kernels and compares the columnwise FP4 data with the shuffle
flag on and off: the native output changes when the flag flips (it preshuffles), while the Triton
output stays byte-identical (it ignores the flag).

Expected vs. actual

  • Expected: with shuffle_columnwise_data=True, the Triton kernel's columnwise FP4 data is
    preshuffled identically to the native kernel.
  • Actual: the Triton kernel emits identical columnwise data whether the flag is True or False —
    i.e. it ignores the flag and leaves the data unshuffled.

Environment

  • GPU: AMD Instinct MI355X (gfx950), ROCm 7.x
  • Image (reproduces the bug): rocm/primus:pytorch-2.12.0-rocm7.14.0a20260608_te-2.14.0.dev0-e6ede467_v26.4

Root cause (code pointers)

In transformer_engine/pytorch/triton_kernels/cast_transpose.py, the launcher only ever reads the
scale-swizzle flag; the data-shuffle flags are never consulted:

# te_cast_transpose_mxfp4_triton
shuffle_scales = out_metadata["quantizer"].with_gemm_swizzled_scales
SHUFFLE_ROWWISE_SCALING = shuffle_scales and USE_ROWWISE_SCALING
SHUFFLE_COLWISE_SCALING = shuffle_scales and USE_COLWISE_SCALING
# quantizer.shuffle_columnwise_data / shuffle_rowwise_data are never read.

Inside the kernel (_cast_transpose_triton_mxfp4), the columnwise branch stores FP4 data with a
plain transposed store and then swizzles only the scales:

if USE_COLWISE:
    packed_col, bs_col = _mxfp4_quantize_32x32_block(tl.trans(x_chunk), MXFP4_BLOCK_SIZE)
    tl.store(colwise_fp4_ptr + ..., packed_col, mask=col_fp4_mask)  # plain transpose, no data preshuffle
    if SHUFFLE_COLWISE:
        ...  # shuffles SCALES only

The native C++/HIP path reads the same flags and applies the data preshuffle in-kernel, so the two
kernels diverge only when a data-shuffle flag is set.

Impact

With NVTE_USE_CAST_TRANSPOSE_TRITON=1 and a recipe that sets shuffle_columnwise_data=True, wgrad
reads a misordered FP4 operand. In a Flux-12B MXFP4 run (SR off) this turns a converging run
(loss ~0.63, grad norm ~0.2) into a diverging one (grad norm climbing to ~58 over ~1000 iters),
while the native kernel converges under identical settings.

Additional: the Triton path is also slower

Independently of the correctness bug above, the Triton cast-transpose is structurally slower than the
native kernel, for these reasons:

  1. Two quantizes plus an in-kernel transpose per tile. The columnwise operand is produced by
    physically transposing each tile (tl.trans) and re-running the full quantizer, reusing none of
    the rowwise work. The native kernel emits both the rowwise and columnwise layouts in a single
    fused pass.
  2. Expensive per-element encoding. The E8M0 scale is computed with transcendentals
    (log2/floor/exp2) instead of reading the exponent bits directly, and the E2M1 data uses a
    dependent chain of tl.where comparisons instead of the gfx950 hardware packed-FP4 cast.
  3. Not autotuned. The kernel uses a fixed tile size and default launch parameters with generic
    codegen, so it under-uses the GPU and cannot adapt to different tensor shapes. The native kernel
    is hand-tuned for gfx950.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions