[FlyDSL] Fused online Hadamard rotation + MXFP4 quantization (flydsl_rot_quant) - #4549
Open
jiangyon-amd wants to merge 1 commit into
Open
jiangyon-amd wants to merge 1 commit into
jiangyon-amd wants to merge 1 commit into
Conversation
Contributor
🏷️ CI GuideRuns automatically on every PR:
Extended tests (opt-in via labels):
|
Add aiter.ops.flydsl.flydsl_rot_quant(x, RS, shuffle_scales=): a single gfx950 kernel that applies a block-diagonal Hadamard rotation to a bf16 activation, quantizes it to MXFP4, and emits the e8m0 block scales, optionally already in the CK swizzle that gemm_a4w4 consumes. Rotated MXFP4 checkpoints (QuaRot / SpinQuant style, as produced by Quark) fold the offline rotation into the weights, so the matching online rotation of the activation runs at every projection of every layer. With today's ops that is three passes over HBM on a memory-bound problem: a torch blockwise Hadamard matmul, then dynamic_mxfp4_quant, then shuffle_scale. No existing aiter op covers this: rotate_activation_fp4quant_inplace dequantizes back to bf16 at a fixed dim of 128/256, dynamic_mxfp4_quant does not rotate, and shuffle_scale is a separate pass. The FWHT needs no cross-lane communication -- one thread owns a COLS-wide chunk of independent RS-wide blocks -- so it is scalar-unrolled in registers. The 1/sqrt(RS) normalization is folded into the e8m0 exponent when log2(RS)/2 is an integer, which is exact because the RoundUp scale mode is exponent-linear. The block scale itself comes from the shared quant_utils.emit_mx_e8m0_scale helper in aiter's default RoundUp mode; this op has no scale convention of its own. MI350X, K=4096, RS=64, CUDA-graph timed: 3.19x (M=16384, 4.6 TB/s) to 4.69x (M=256) over the three-pass path. Tested by op_tests/test_flydsl_rot_quant.py: 39 byte-exact configurations against a pure-torch reference, 15 fused-swizzle parity checks against shuffle_scale, 9 end-to-end gemm_a4w4 checks, and 7 input guards. Verified on flydsl 0.3.0 and 0.2.4. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jiangyon-amd
force-pushed
the
flydsl-rot-quant
branch
from
August 4, 2026 08:55
fbafd80 to
0f6e54c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
[FlyDSL] Fused online Hadamard rotation + MXFP4 quantization (
flydsl_rot_quant)What
Adds
aiter.ops.flydsl.flydsl_rot_quant(x, RS, shuffle_scales=)— a single gfx950kernel that takes a bf16 activation, applies a block-diagonal Hadamard rotation,
quantizes to MXFP4, and writes the e8m0 scales, optionally already in the CK-gemm
swizzle that
gemm_a4w4consumes.Why
Rotated MXFP4 checkpoints (QuaRot / SpinQuant style, as produced by Quark) fold the
offline rotation into the weights. The matching online rotation of the activation
then has to run at every projection, every layer, every token. Written with today's
aiter ops that is three passes over HBM on a purely memory-bound op:
dynamic_mxfp4_quantgemm_a4w4shuffle_scaleRelation to existing ops — this is a gap, not a duplicate
rotate_activation_fp4quant_inplace(csrc/kernels/dsv4_rotate_quant.cu) rotates,fp4-quantizes and dequantizes back to bf16 in place, at a fixed
dimof 128 or256. That is the simulation/QAT path; it does not produce a gemm input.
dynamic_mxfp4_quantproduces packed fp4 + e8m0, but does not rotate.shuffle_scalere-lays-out the scales in a separate pass.Nothing currently produces rotated, packed fp4 + e8m0 scales in one launch, and
nothing emits the CK scale swizzle from inside the quantizing kernel.
Performance
MI350X (gfx950), K=4096, RS=64, CUDA-graph timed, 50 launches per replay.
Baseline is the three-pass path above (torch blockwise Hadamard +
dynamic_mxfp4_quant+shuffle_scale).At small M both sides are launch-bound (three launches vs one); the win there is
kernel count, not bandwidth. At large M the kernel reaches ~4.5 TB/s of moved bytes
(read 2 B + write 0.53 B per element), which is where a rotate+quant+swizzle fusion
should land on this part.
Arithmetic intensity: 2.53 bytes moved per element for
log2(RS)f32 add/sub(6 per element at RS=64) plus one
v_cvt_scalef32_pk_fp4_f32per pair — ~2.4FLOP/byte against a ~575 FLOP/byte machine balance. Firmly memory-bound, which is
exactly why the three passes are worth collapsing into one.
Scope of the claim: the numbers above are the claim being made. This op is one of
several per-projection steps in a decode iteration, so the end-to-end serving effect
is model-, shape- and gemm-tuning-dependent and is a small fraction of the
microbenchmark ratio — in our own measurements it is a low single-digit percentage of
total throughput, positive but not the 3–4× above.
Implementation
Work =
M * (K//COLS)independent thread-blocks; one thread owns a COLS-wide chunkholding
COLS//RSindependent RS-wide Hadamard blocks, so the FWHT needs nocross-lane communication — it is scalar-unrolled in registers (Sylvester,
(a+b, a-b)pairing). At RS=COLS=64 that is 64 f32 registers per thread. Loads are128-bit (
vec8bf16); fp4 stores are 128-bit (4× packed i32).Written against the post-#4501 stable FlyDSL interface:
buffer_ops/vectorfromaiter.ops.flydsl.kernels,tensor_shim._run_compiled/_to_rawfor the launchpath, and
quant_utils.emit_mx_e8m0_scalefor the block scale. No new dependency floor:verified on
flydsl 0.3.0(the versionrequirements.txtpins) and on0.2.4(the floor
aiter/ops/flydsl/__init__.pyenforces), identical results on both.Two details worth calling out for review:
1/sqrt(RS)normalization is folded into the e8m0 exponent whenlog2(RS)/2is an integer (RS=64), replacing a per-element f32 multiply with aninteger subtract on the shared group exponent. RS=32 and RS=128 take the
multiply path.
shuffle_scales=Truescatters each e8m0 byte directly to itsshuffle_scaledestination, computed by bit-slicing the global thread id. Thisremoves the separate swizzle pass and its padding-tile zeroing: the padded cells
are
torch.emptyand never written, exactly asshuffle_scaleitself leaves them,because the gemm slices its output back to
[:M].Scale convention
No bespoke convention here: the e8m0 block scale is built by the shared
quant_utils.emit_mx_e8m0_scaleIR helper in aiter's defaultMxScaleRoundMode::RoundUp(ceil_pow2(amax / 6)), and the torch reference in thetest uses its CPU mirror
fp4_utils.f32_to_mx_e8m0_scalewith the same mode. Thefold_kexponent fold in (1) is exact on top of it because RoundUp isexponent-linear:
e8m0(amax · 2⁻ᵏ) = e8m0(amax) − k.Testing
op_tests/test_flydsl_rot_quant.py. All three checks matter independently, becauseall three failure modes are silent — wrong numbers, no exception:
pure-torch reference (
_ref_rot_quant, in the test file — no external dependency).Byte equality, not
allclose:allclosewould hide a systematic off-by-one-binadescale bug. Covers RS ∈ {32, 64, 128} × K ∈ {2048, 4096} × M ∈ {1, 32, 256, 1024,
4096, 16384}, plus an outlier pass that drives elements into the saturating
[6, 8)band where an encoder that clamps differently from the hardware woulddiverge. All 39 configurations are exact.
shuffle_scale()of thenatural-layout output, compared only on the live cells (a deterministic 0/1 mask
pushed through the same reshape/permute;
shuffle_scalepads withtorch.empty,so the mask cannot be recovered from its output).
gemm_a4w4— the consumer the swizzle exists for. Aquantizer/gemm layout mismatch produces garbage with no error, so unit-level parity
is not sufficient evidence that the op works. Gated at N ≥ 512: aiter's CK asm
gemm_a4w4is numerically wrong below that (rel err ~0.94 at N=64, exact atN ≥ 512), so it is not a valid reference there. That is a property of the gemm,
not of this op.
Plus
test_rot_quant_rejects_bad_input: 7 guards (fp16/fp32 input, non-contiguous,3D,
K % RS != 0, unsupported RS, badAMAX) must raise rather than compute. Thekernel hardcodes bf16 buffer loads and linear element offsets, so every one of these
would otherwise be read as contiguous bf16 and yield plausible garbage.
Registered in
.github/scripts/split_tests.sh(FILE_TIMES=150; ~9 s warm, therest is cold FlyDSL JIT for the 27 kernel variants the matrix compiles). Peak GPU
memory 812 MiB — below the level that would warrant a
MEMORY_WEIGHT_FLOORentry.Scope / limits
v_cvt_scalef32_pk_fp4_f32. Raises on otherarchitectures.
K % RS == 0._pick_block/_pick_svecdefaults are swept on MI350X. They are overridableper call; other parts should re-sweep.
flydslis already a hard requirement ofamd-aiter, and theexport sits inside the existing
is_flydsl_available()guard inaiter/ops/flydsl/__init__.py.Files
aiter/ops/flydsl/kernels/rot_quant.pyaiter/ops/flydsl/__init__.py__all__entryop_tests/test_flydsl_rot_quant.py.github/scripts/split_tests.sh