Skip to content

Commit d08c344

Browse files
justinchubyCopilot
andcommitted
fix(modelopt): harden NVFP4 dequant shape validation and tests
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>
1 parent 5fb09c2 commit d08c344

2 files changed

Lines changed: 48 additions & 10 deletions

File tree

src/mobius/integrations/modelopt/_dequant.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,16 @@ def unpack_nvfp4_codes(packed_nk2: np.ndarray) -> np.ndarray:
4646
K-axis E2M1 codes for the same output row ``N`` (low nibble = even ``K``,
4747
high nibble = odd ``K``) — the layout ModelOpt writes. Returns uint8 codes
4848
``[N, K]`` in ``0..15``.
49+
50+
Raises:
51+
ValueError: if ``packed_nk2`` is not a 2D ``[N, K/2]`` array. Loader
52+
code should surface an upstream shape/dtype mistake (e.g. passing
53+
already-unpacked codes) rather than silently reinterpreting it.
4954
"""
55+
if packed_nk2.ndim != 2:
56+
raise ValueError(
57+
f"NVFP4 packed codes must be 2D [N, K/2], got shape {packed_nk2.shape}."
58+
)
5059
packed = np.ascontiguousarray(packed_nk2).astype(np.uint8)
5160
low = packed & 0x0F
5261
high = packed >> 4
@@ -81,9 +90,17 @@ def dequantize_nvfp4(
8190
block_scale = _to_float32(block_scale_e4m3) # [N, K/16]
8291
k = codes.shape[1]
8392
n_blocks = block_scale.shape[1]
84-
if k % n_blocks != 0:
93+
if n_blocks == 0 or k % n_blocks != 0:
8594
raise ValueError(f"NVFP4 K={k} is not divisible by the block count {n_blocks}.")
86-
block_scale = np.repeat(block_scale, k // n_blocks, axis=1) # [N, K]
95+
block_size = k // n_blocks
96+
if block_size != NVFP4_BLOCK_SIZE:
97+
# NVFP4 pins the block size to 16; a different derived size means the
98+
# weight/scale shapes are mismatched (silently-wrong reconstruction).
99+
raise ValueError(
100+
f"NVFP4 block size must be {NVFP4_BLOCK_SIZE}, got {block_size} "
101+
f"(K={k}, block scales={n_blocks})."
102+
)
103+
block_scale = np.repeat(block_scale, block_size, axis=1) # [N, K]
87104

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

src/mobius/integrations/modelopt/_dequant_test.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,27 +36,34 @@ def test_unpack_nvfp4_codes_splits_nibbles():
3636

3737

3838
def test_dequantize_nvfp4_known_values():
39-
# K=4 codes: k0=+1.0, k1=-0.5, k2=+4.0, k3=-2.0
39+
# One full NVFP4 block (K=16). First 4 codes are known non-zero values;
40+
# the trailing 12 codes are zero (index 0 -> magnitude 0.0).
4041
k0 = _e2m1_code(0, 2) # mag 1.0
4142
k1 = _e2m1_code(1, 1) # -0.5
4243
k2 = _e2m1_code(0, 6) # 4.0
4344
k3 = _e2m1_code(1, 4) # -2.0
45+
zero = _e2m1_code(0, 0) # 0.0
46+
# Pack 16 codes into 8 bytes (low nibble = even K, high nibble = odd K).
4447
byte0 = k0 | (k1 << 4)
4548
byte1 = k2 | (k3 << 4)
46-
weight_u8 = np.array([[byte0, byte1]], dtype=np.uint8)
49+
weight_u8 = np.array([[byte0, byte1] + [zero | (zero << 4)] * 6], dtype=np.uint8)
4750

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

5154
out = dequantize_nvfp4(weight_u8, block_scale, global_scale)
5255
assert out.dtype == ml_dtypes.bfloat16
5356
# val * 2.0 (block) * 0.5 (global) == val
54-
expected = np.array([[1.0, -0.5, 4.0, -2.0]], dtype=np.float32)
57+
expected = np.zeros((1, 16), dtype=np.float32)
58+
expected[0, :4] = [1.0, -0.5, 4.0, -2.0]
5559
np.testing.assert_array_equal(out.astype(np.float32), expected)
60+
# Trailing 12 elements reconstruct to exactly zero.
61+
np.testing.assert_array_equal(out.astype(np.float32)[0, 4:], np.zeros(12))
5662

5763

5864
def test_dequantize_nvfp4_block_scale_repeat():
59-
# Two 16-element blocks with distinct block scales exercise repeat_interleave.
65+
# Two 16-element blocks with distinct block scales exercise the np.repeat
66+
# per-block scale broadcast.
6067
n, k = 1, 32
6168
# All codes = +1.0 (index 2) -> byte 0x22 packs two such codes.
6269
weight_u8 = np.full((n, k // 2), 0x22, dtype=np.uint8)
@@ -70,15 +77,29 @@ def test_dequantize_nvfp4_block_scale_repeat():
7077

7178
def test_dequantize_nvfp4_raw_uint8_block_scale():
7279
# Block scales may arrive as raw uint8 e4m3 code bytes; result must match a
73-
# typed float8 view.
74-
k0 = _e2m1_code(0, 2)
75-
weight_u8 = np.array([[k0 | (k0 << 4)]], dtype=np.uint8) # K=2, both +1.0
80+
# typed float8 view. One full NVFP4 block (K=16), all codes +1.0.
81+
k0 = _e2m1_code(0, 2) # +1.0
82+
weight_u8 = np.full((1, 8), k0 | (k0 << 4), dtype=np.uint8) # K=16, all +1.0
7683
typed = np.array([[3.0]], dtype=ml_dtypes.float8_e4m3fn)
7784
raw = typed.view(np.uint8)
7885
a = dequantize_nvfp4(weight_u8, typed, 1.0).astype(np.float32)
7986
b = dequantize_nvfp4(weight_u8, raw, 1.0).astype(np.float32)
8087
np.testing.assert_array_equal(a, b)
81-
np.testing.assert_array_equal(a, np.array([[3.0, 3.0]], dtype=np.float32))
88+
np.testing.assert_array_equal(a, np.full((1, 16), 3.0, dtype=np.float32))
89+
90+
91+
def test_unpack_nvfp4_codes_rejects_non_2d():
92+
# Loader code should surface a shape mistake, not silently reinterpret it.
93+
with np.testing.assert_raises(ValueError):
94+
unpack_nvfp4_codes(np.zeros(8, dtype=np.uint8)) # 1D
95+
96+
97+
def test_dequantize_nvfp4_rejects_non16_block():
98+
# A derived block size other than 16 indicates mismatched weight/scale shapes.
99+
weight_u8 = np.full((1, 2), 0x22, dtype=np.uint8) # K=4
100+
block_scale = np.array([[1.0]], dtype=ml_dtypes.float8_e4m3fn) # 1 block -> size 4
101+
with np.testing.assert_raises(ValueError):
102+
dequantize_nvfp4(weight_u8, block_scale, 1.0)
82103

83104

84105
def test_dequantize_fp8_per_tensor_scale():

0 commit comments

Comments
 (0)