Skip to content

Add NVFP4/FP8 (ModelOpt) weight reconstruction foundation for Qwen3.6 - #446

Open
justinchuby wants to merge 1 commit into
mainfrom
feat/qwen3.6-nvfp4-fp8
Open

Add NVFP4/FP8 (ModelOpt) weight reconstruction foundation for Qwen3.6#446
justinchuby wants to merge 1 commit into
mainfrom
feat/qwen3.6-nvfp4-fp8

Conversation

@justinchuby

Copy link
Copy Markdown
Member

What

Foundation for consuming NVIDIA ModelOpt NVFP4/FP8 mixed-precision checkpoints (e.g. quantized Qwen3.6), porting the verifiable numeric core of ORT GenAI #2350 / #2351.

ModelOpt Qwen3.6 layout:

  • Routed MoE experts / shared expert / lm_headW4A16_NVFP4: block-16 E2M1 (fp4) weights, FP8-E4M3 block scales, per-tensor FP32 global scale (weight_scale_2).
  • Attention / linear-attn projectionsFP8 (E4M3, per-tensor weight_scale).

How

New mobius/integrations/modelopt/ package with the loader's numeric core:

  • dequantize_nvfp4()e2m1(code) × e4m3(block_scale[k//16]) × global_scale → bf16
  • dequantize_fp8()e4m3(weight) × weight_scale → bf16
  • unpack_nvfp4_codes(), is_modelopt_quant_config(), FP4_E2M1_LUT

Following ORT GenAI's loader, dense FP8 and NVFP4 shared-expert/lm_head weights are reconstructed to BF16 exactly (ORT has no FP8 attention GEMM), so the standard BF16 build path can consume the checkpoint. Fully unit-tested against hand-computed references — numeric-only, no runtime/GPU dependency.

QuantizationConfig.from_transformers now fails loudly (NotImplementedError) on ModelOpt schemes instead of silently routing packed E2M1/float8 weights through the INT4 MatMulNBits path (which would mis-dequantize them). The check runs before the quant_method=="none" early-return since ModelOpt may name its scheme only via quant_algo/quant_cfg.

Scope / verification

  • ✅ Verifiable here: dequant math (7 unit tests, hand-computed references) + config detection/guard (89 tests pass in _configs_test.py + module).
  • Out of scope (documented follow-up): full checkpoint weight-load wiring and native routed-expert NVFP4 QMoE emission (CUDA/Blackwell, onnxruntime_USE_FP4_QMOE=ON). That path is not buildable or verifiable in this environment (no Blackwell GPU, no FP4-QMOE ORT build, no real ModelOpt checkpoint), so it is intentionally gated rather than shipped unverified — the safe increment given the repo's "no silently-wrong weights" bar.

Please review — do not merge yet.

NVIDIA TensorRT Model Optimizer (ModelOpt) exports mixed-precision NVFP4 + FP8
checkpoints (e.g. quantized Qwen3.6): routed MoE experts / shared expert /
lm_head as W4A16_NVFP4 (block-16 E2M1 weights, FP8-E4M3 block scales, per-tensor
FP32 global scale weight_scale_2), and attention/linear-attn projections as FP8
(E4M3, per-tensor scale).

Add the numeric core of the ModelOpt loader under
mobius/integrations/modelopt:
  - dequantize_nvfp4(): E2M1 codes x FP8-E4M3 block scale x FP32 global -> BF16
  - dequantize_fp8():  FP8 (E4M3) x per-tensor scale -> BF16
  - unpack_nvfp4_codes(), is_modelopt_quant_config(), FP4_E2M1_LUT
Following ORT GenAI's loader, dense FP8 and NVFP4 shared-expert/lm_head weights
are reconstructed to BF16 exactly (ORT has no FP8 attention GEMM), so the
standard BF16 build path can consume the checkpoint. Fully unit-tested against
hand-computed references (numeric-only; no runtime dependency).

Guard QuantizationConfig.from_transformers: ModelOpt schemes now raise a clear
NotImplementedError instead of being silently routed through the INT4
MatMulNBits path (which would mis-dequantize packed E2M1/float8 weights). The
check runs before the quant_method=="none" early-return because ModelOpt may
name its scheme only via quant_algo/quant_cfg.

Out of scope (documented, follow-up): full checkpoint weight-load integration
and native routed-expert NVFP4 QMoE emission (CUDA/Blackwell,
onnxruntime_USE_FP4_QMOE=ON) — neither buildable nor verifiable without that
runtime and a real ModelOpt checkpoint.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: justinchuby <justinchuby@users.noreply.github.com>
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

🏗️ Architecture Diff

Comparing 8a565d75fb09c2

Model Sub-model Changes Status

No architecture changes detected.


Legend: ⚪ No change · 🔵 Minor (attrs/inits) · 🟡 Moderate (nodes added/removed) · 🔴 Major (interface changed)

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

Performance Comparison

Comparing 8a565d75fb09c2

Model Metric Baseline Current Delta
bert (feature-extraction) model_size_bytes 359 KB 359 KB +0.0%
bert (feature-extraction) num_nodes 60 60 +0.0%
falcon model_size_bytes 364 KB 364 KB +0.0%
falcon num_nodes 68 68 +0.0%
gemma2 model_size_bytes 428 KB 428 KB +0.0%
gemma2 num_nodes 107 107 +0.0%
gpt2 model_size_bytes 388 KB 388 KB +0.0%
gpt2 num_nodes 54 54 +0.0%
llama model_size_bytes 425 KB 425 KB +0.0%
llama num_nodes 62 62 +0.0%
llama (static-cache) model_size_bytes 425 KB 425 KB +0.0%
llama (static-cache) num_nodes 58 58 +0.0%
mamba (ssm-text-generation) model_size_bytes 296 KB 296 KB +0.0%
mamba (ssm-text-generation) num_nodes 98 98 +0.0%
phi3 model_size_bytes 421 KB 421 KB +0.0%
phi3 num_nodes 60 60 +0.0%
phi3 (static-cache) model_size_bytes 421 KB 421 KB +0.0%
phi3 (static-cache) num_nodes 56 56 +0.0%
qwen2 model_size_bytes 425 KB 425 KB +0.0%
qwen2 num_nodes 62 62 +0.0%
qwen2 (static-cache) model_size_bytes 425 KB 425 KB +0.0%
qwen2 (static-cache) num_nodes 58 58 +0.0%
qwen3_5_moe (hybrid-text-generation) model_size_bytes 506 KB 506 KB +0.0%
qwen3_5_moe (hybrid-text-generation) num_nodes 275 275 +0.0%
qwen3_5_text (hybrid-text-generation) model_size_bytes 458 KB 458 KB +0.0%
qwen3_5_text (hybrid-text-generation) num_nodes 129 129 +0.0%
qwen3_5_vl (hybrid-qwen-vl) model_size_bytes 977 KB 977 KB +0.0%
qwen3_5_vl (hybrid-qwen-vl) num_nodes 413 413 +0.0%
t5 (seq2seq) model_size_bytes 836 KB 836 KB +0.0%
t5 (seq2seq) num_nodes 166 166 +0.0%
whisper (speech-to-text) model_size_bytes 1008 KB 1008 KB +0.0%
whisper (speech-to-text) num_nodes 128 128 +0.0%

No performance regressions.

Copilot AI left a comment

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.

Pull request overview

This PR adds a new mobius.integrations.modelopt package that implements the numeric core for reconstructing NVIDIA ModelOpt NVFP4 and FP8 checkpoint weights back to BF16, and adds an explicit guard in QuantizationConfig.from_transformers() to fail loudly on ModelOpt quantization configs to avoid silently mis-dequantizing them via the INT4 path.

Changes:

  • Add NVFP4/FP8 dequantization utilities (unpack_nvfp4_codes, dequantize_nvfp4, dequantize_fp8) plus ModelOpt quant-config detection.
  • Add unit tests covering dequant math and config detection.
  • Add a NotImplementedError guard in HF quantization-config parsing to prevent misrouting ModelOpt schemes through the existing INT4 quantization path.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/mobius/integrations/modelopt/_dequant.py Adds NVFP4/FP8 unpack/dequant math and ModelOpt quant-config detection helpers.
src/mobius/integrations/modelopt/_dequant_test.py Adds unit tests for the new dequantization functions and config detection.
src/mobius/integrations/modelopt/init.py Exposes the ModelOpt integration API via package exports.
src/mobius/_configs/_quantization.py Adds a loud failure path for ModelOpt quantization configs to avoid silent mis-dequantization.
src/mobius/_configs_test.py Adds a regression test ensuring ModelOpt configs raise NotImplementedError.
Suppressed comments (1)

src/mobius/integrations/modelopt/_dequant.py:85

  • dequantize_nvfp4 only checks K % n_blocks == 0 and then repeats scales. If block_scale_e4m3 is accidentally loaded with the wrong second dimension (e.g. K/32 or K/8), this will still run and silently produce incorrect dequantized weights. Since NVFP4 is specified as fixed 16-element blocks, enforce K % NVFP4_BLOCK_SIZE == 0 and block_scale.shape[1] == K // NVFP4_BLOCK_SIZE. Also, expanding scales with np.repeat creates a large temporary; multiplying in [N, blocks, 16] form avoids that extra allocation.
    block_scale = _to_float32(block_scale_e4m3)  # [N, K/16]
    k = codes.shape[1]
    n_blocks = block_scale.shape[1]
    if k % n_blocks != 0:
        raise ValueError(f"NVFP4 K={k} is not divisible by the block count {n_blocks}.")

Comment on lines +50 to +56
packed = np.ascontiguousarray(packed_nk2).astype(np.uint8)
low = packed & 0x0F
high = packed >> 4
n = packed.shape[0]
# Interleave (low, high) along a new trailing axis, then flatten to [N, K].
codes = np.stack((low, high), axis=-1).reshape(n, -1)
return np.ascontiguousarray(codes)
Comment on lines +38 to +55
def test_dequantize_nvfp4_known_values():
# K=4 codes: k0=+1.0, k1=-0.5, k2=+4.0, k3=-2.0
k0 = _e2m1_code(0, 2) # mag 1.0
k1 = _e2m1_code(1, 1) # -0.5
k2 = _e2m1_code(0, 6) # 4.0
k3 = _e2m1_code(1, 4) # -2.0
byte0 = k0 | (k1 << 4)
byte1 = k2 | (k3 << 4)
weight_u8 = np.array([[byte0, byte1]], dtype=np.uint8)

block_scale = np.array([[2.0]], dtype=ml_dtypes.float8_e4m3fn) # one block
global_scale = 0.5

out = dequantize_nvfp4(weight_u8, block_scale, global_scale)
assert out.dtype == ml_dtypes.bfloat16
# val * 2.0 (block) * 0.5 (global) == val
expected = np.array([[1.0, -0.5, 4.0, -2.0]], dtype=np.float32)
np.testing.assert_array_equal(out.astype(np.float32), expected)
Comment on lines +71 to +81
def test_dequantize_nvfp4_raw_uint8_block_scale():
# Block scales may arrive as raw uint8 e4m3 code bytes; result must match a
# typed float8 view.
k0 = _e2m1_code(0, 2)
weight_u8 = np.array([[k0 | (k0 << 4)]], dtype=np.uint8) # K=2, both +1.0
typed = np.array([[3.0]], dtype=ml_dtypes.float8_e4m3fn)
raw = typed.view(np.uint8)
a = dequantize_nvfp4(weight_u8, typed, 1.0).astype(np.float32)
b = dequantize_nvfp4(weight_u8, raw, 1.0).astype(np.float32)
np.testing.assert_array_equal(a, b)
np.testing.assert_array_equal(a, np.array([[3.0, 3.0]], dtype=np.float32))


def test_dequantize_nvfp4_block_scale_repeat():
# Two 16-element blocks with distinct block scales exercise repeat_interleave.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants