Skip to content

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

Open
justinchuby wants to merge 2 commits into
mainfrom
feat/qwen3.6-nvfp4-fp8
Open

Add NVFP4/FP8 (ModelOpt) weight reconstruction foundation for Qwen3.6#446
justinchuby wants to merge 2 commits 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 8a565d7d08c344

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 8a565d7d08c344

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.
Address PR review feedback on the NVFP4 dequant path:

- unpack_nvfp4_codes now rejects non-2D input with a clear ValueError so a
  loader shape/dtype mistake surfaces instead of being silently reinterpreted
  via the uint8 cast.
- dequantize_nvfp4 enforces the fixed NVFP4 16-element block size; a derived
  size other than 16 means mismatched weight/scale shapes and now raises.
- Rewrite the known-value and raw-uint8 block-scale tests to use full K=16
  blocks (spec-realistic), asserting trailing zero-code reconstruction.
- Fix stale "repeat_interleave" comment to "np.repeat".
- Add regression tests for the two new validation errors.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: justinchuby <justinchuby@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 2, 2026 15:41

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

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

Suppressed comments (8)

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

  • After making ml_dtypes optional, dequantize_nvfp4() should explicitly fail fast with a clear message when ml_dtypes is unavailable; otherwise this will error later with an AttributeError when trying to access ml_dtypes.bfloat16.
    codes = unpack_nvfp4_codes(weight_u8).astype(np.int64)  # [N, K]
    mag = FP4_E2M1_LUT[codes & 0x7]  # [N, K]
    val = np.where((codes & 0x8) > 0, -mag, mag).astype(np.float32)  # [N, K]

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

  • _to_float32() reinterprets raw uint8 bytes as FP8, but if ml_dtypes isn't installed this will currently fail with a cryptic NameError/AttributeError. Raise a clear ImportError when this conversion is requested without ml_dtypes.
    arr = np.asarray(arr)
    if arr.dtype == np.uint8:
        arr = arr.view(ml_dtypes.float8_e4m3fn)
    return arr.astype(np.float32)

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

  • dequantize_fp8() returns ml_dtypes.bfloat16 but doesn't check whether ml_dtypes is available (unlike the suggested guard for dequantize_nvfp4). With an optional ml_dtypes import, this will otherwise fail with an AttributeError.
    weight = _to_float32(weight_f8)
    return (weight * np.float32(weight_scale)).astype(ml_dtypes.bfloat16)

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

  • unpack_nvfp4_codes() currently casts inputs to uint8, which can silently reinterpret incorrect dtypes (e.g. int8/float) and contradicts the docstring intent to surface upstream dtype mistakes. Validate dtype==uint8 instead of coercing.
    if packed_nk2.ndim != 2:
        raise ValueError(
            f"NVFP4 packed codes must be 2D [N, K/2], got shape {packed_nk2.shape}."
        )
    packed = np.ascontiguousarray(packed_nk2).astype(np.uint8)
    low = packed & 0x0F

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

  • dequantize_nvfp4() assumes block_scale_e4m3 is 2D and has matching N, but it never validates either. A mismatched shape can silently broadcast and reconstruct wrong weights. Also, np.repeat materializes a full [N,K] scale tensor, doubling peak memory for large weights; reshape/broadcast 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 n_blocks == 0 or k % n_blocks != 0:
        raise ValueError(f"NVFP4 K={k} is not divisible by the block count {n_blocks}.")
    block_size = k // n_blocks
    if block_size != NVFP4_BLOCK_SIZE:
        # NVFP4 pins the block size to 16; a different derived size means the
        # weight/scale shapes are mismatched (silently-wrong reconstruction).
        raise ValueError(
            f"NVFP4 block size must be {NVFP4_BLOCK_SIZE}, got {block_size} "
            f"(K={k}, block scales={n_blocks})."
        )
    block_scale = np.repeat(block_scale, block_size, axis=1)  # [N, K]

    dequant = val * block_scale * np.float32(global_scale)
    return dequant.astype(ml_dtypes.bfloat16)

src/mobius/_configs/_quantization.py:55

  • quant_method is used for branching but isn't normalized. If HuggingFace ever serializes values like "FP8"/"None" with different casing, this logic can mis-route into the INT4 path. Normalizing to lowercase makes this parser more robust.
        method = qc.get("quant_method", "none")

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

  • mobius.integrations.modelopt imports ml_dtypes at module import time. Since ml_dtypes is not a declared core dependency, this can raise ImportError when QuantizationConfig.from_transformers() tries to import is_modelopt_quant_config, preventing the intended NotImplementedError guard from firing. Make the ml_dtypes import optional at module import time and fail with a clear error only when dequantization is actually invoked.

This issue also appears in the following locations of the same file:

  • line 86
  • line 117
  • line 145
import ml_dtypes
import numpy as np

src/mobius/integrations/modelopt/_dequant_test.py:9

  • This test module imports ml_dtypes unconditionally. Since ml_dtypes is not a declared core dependency, test collection can fail in environments where it isn't installed. Prefer pytest.importorskip("ml_dtypes") so the suite can still run without this optional dependency.
import ml_dtypes
import numpy as np

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