Skip to content

Commit e57c7a8

Browse files
committed
[Quantization] Tighten FP8 sweep input contracts and add dispatch test
Address realAsma's review feedback on the NVFP4 FP8 sweep kernel: - TritonNVFP4MSECalibrator.collect: replace `assert x.ndim == 2` with ValueError so the contract still holds under `python -O`, validate block_size > 0 before use, and derive n_blocks from x.shape[0] so a zero last-dim cannot trigger division before the shape check. - nvfp4_fp8_scale_sweep: drop the public `candidates` parameter. The candidate set is fixed (FP8 E4M3 valid values / 448) and a wrong length would silently inflate `tl.static_range` codegen, while nonpositive/nonfinite entries violate the kernel's scale assumptions. No internal caller used the override. - Add test_mse_calibrate_dispatch covering the public default + opt-out wiring: confirms `mse_calibrate(fp8_scale_sweep=True)` installs TritonNVFP4MSECalibrator by default and falls back to NVFP4MSECalibrator when MODELOPT_NVFP4_TRITON_SWEEP=0. Signed-off-by: Chenjie Luo <chenjiel@nvidia.com>
1 parent bd4fc3a commit e57c7a8

3 files changed

Lines changed: 71 additions & 19 deletions

File tree

modelopt/torch/kernels/quantization/gemm/nvfp4_fp8_sweep.py

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,6 @@ def nvfp4_fp8_scale_sweep(
113113
x: torch.Tensor,
114114
global_amax: torch.Tensor,
115115
block_size: int = 16,
116-
candidates: torch.Tensor | None = None,
117116
) -> torch.Tensor:
118117
"""Find the per-block FP8 scale that minimizes NVFP4 quantization MSE.
119118
@@ -126,8 +125,6 @@ def nvfp4_fp8_scale_sweep(
126125
``block_size``; layout is treated as a flat ``[N_BLOCKS, BLOCK_SIZE]``.
127126
global_amax: Scalar FP32 global amax (``= reduce_amax(per_block_amax)``).
128127
block_size: NVFP4 block size (typically 16).
129-
candidates: Optional precomputed candidate tensor of shape ``[126]`` (must
130-
be the FP8 E4M3 valid values divided by 448). Built lazily if omitted.
131128
132129
Returns:
133130
``best_amax`` of shape ``[N_BLOCKS]``, fp32, on the same device as ``x``.
@@ -139,13 +136,7 @@ def nvfp4_fp8_scale_sweep(
139136
if x.numel() % block_size != 0:
140137
raise ValueError(f"x.numel() ({x.numel()}) is not divisible by block_size ({block_size}).")
141138

142-
if candidates is None:
143-
candidates = fp8_scale_candidates(x.device)
144-
candidates = candidates.contiguous().to(device=x.device, dtype=torch.float32)
145-
if candidates.ndim != 1 or candidates.numel() == 0:
146-
raise ValueError(
147-
f"candidates must be a non-empty 1-D tensor; got shape {tuple(candidates.shape)}."
148-
)
139+
candidates = fp8_scale_candidates(x.device).to(dtype=torch.float32)
149140

150141
n_blocks = x.numel() // block_size
151142
x_flat = x.contiguous().view(-1)

modelopt/torch/quantization/calib/mse.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -259,10 +259,16 @@ def collect(self, x: torch.Tensor):
259259

260260
x = x.detach()
261261
# The weight quantizer reshapes its input to [n_blocks, block_size] before
262-
# calling collect (see TensorQuantizer._process_for_blockquant).
263-
assert x.ndim == 2, f"Expected x to be [n_blocks, block_size]; got shape {tuple(x.shape)}."
262+
# calling collect (see TensorQuantizer._process_for_blockquant). Validate
263+
# via ValueError so the contract still holds under ``python -O``.
264+
if x.ndim != 2:
265+
raise ValueError(
266+
f"Expected x to be [n_blocks, block_size]; got shape {tuple(x.shape)}."
267+
)
264268
block_size = x.shape[-1]
265-
n_blocks = x.numel() // block_size
269+
if block_size <= 0:
270+
raise ValueError(f"x.shape[-1] must be positive; got {block_size}.")
271+
n_blocks = x.shape[0]
266272
if n_blocks != self._n_blocks:
267273
raise ValueError(
268274
f"initial amax.numel() ({self._n_blocks}) does not match the number "

tests/gpu/torch/quantization/test_nvfp4_fp8_sweep_kernel.py

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ def test_reset_allows_recollect():
153153
@requires_triton
154154
def test_input_validation():
155155
"""``nvfp4_fp8_scale_sweep`` should reject malformed inputs cleanly."""
156-
from modelopt.torch.kernels.quantization.gemm import fp8_scale_candidates, nvfp4_fp8_scale_sweep
156+
from modelopt.torch.kernels.quantization.gemm import nvfp4_fp8_scale_sweep
157157

158158
device = "cuda"
159159
x = torch.randn(64, BLOCK_SIZE, device=device)
@@ -173,11 +173,66 @@ def test_input_validation():
173173
with pytest.raises(ValueError, match="not divisible"):
174174
nvfp4_fp8_scale_sweep(x, g, block_size=15)
175175

176-
# Empty / wrong-rank candidates.
177-
with pytest.raises(ValueError, match="non-empty 1-D"):
178-
nvfp4_fp8_scale_sweep(x, g, candidates=torch.empty(0, device=device))
179-
with pytest.raises(ValueError, match="non-empty 1-D"):
180-
nvfp4_fp8_scale_sweep(x, g, candidates=fp8_scale_candidates(device).reshape(2, -1))
176+
177+
@requires_triton
178+
def test_mse_calibrate_dispatch(monkeypatch):
179+
"""``mse_calibrate(fp8_scale_sweep=True)`` must install the right calibrator class.
180+
181+
Default path: ``TritonNVFP4MSECalibrator``.
182+
With ``MODELOPT_NVFP4_TRITON_SWEEP=0``: ``NVFP4MSECalibrator`` (and not its subclass).
183+
"""
184+
from _test_utils.torch.quantization.models import SimpleLinear
185+
186+
import modelopt.torch.quantization as mtq
187+
from modelopt.torch.quantization.extensions import get_cuda_ext_mx
188+
from modelopt.torch.quantization.nn import TensorQuantizer
189+
190+
if get_cuda_ext_mx() is None:
191+
pytest.skip("cuda_ext_mx is not available")
192+
193+
cfg = {
194+
"quant_cfg": [
195+
{
196+
"quantizer_name": "*weight_quantizer",
197+
"cfg": {
198+
"num_bits": (2, 1),
199+
"block_sizes": {-1: 16, "type": "static", "scale_bits": (4, 3)},
200+
"axis": None,
201+
},
202+
"enable": True,
203+
},
204+
{"quantizer_name": "*input_quantizer", "enable": False},
205+
],
206+
"algorithm": {"method": "mse", "fp8_scale_sweep": True},
207+
}
208+
209+
def _quantize_and_get_weight_calibrators(model):
210+
calib_data = [model.get_input().cuda() for _ in range(2)]
211+
212+
def forward_loop(m):
213+
for batch in calib_data:
214+
m(batch)
215+
216+
mtq.quantize(model, cfg, forward_loop=forward_loop)
217+
return [
218+
type(m._calibrator)
219+
for name, m in model.named_modules()
220+
if isinstance(m, TensorQuantizer)
221+
and name.endswith("weight_quantizer")
222+
and getattr(m, "_calibrator", None) is not None
223+
]
224+
225+
# Default: triton path.
226+
monkeypatch.delenv("MODELOPT_NVFP4_TRITON_SWEEP", raising=False)
227+
types_default = _quantize_and_get_weight_calibrators(SimpleLinear().cuda())
228+
assert types_default, "expected at least one weight quantizer with a calibrator"
229+
assert all(t is TritonNVFP4MSECalibrator for t in types_default), types_default
230+
231+
# Opt-out: reference path, exact class match (TritonNVFP4MSECalibrator is a subclass).
232+
monkeypatch.setenv("MODELOPT_NVFP4_TRITON_SWEEP", "0")
233+
types_optout = _quantize_and_get_weight_calibrators(SimpleLinear().cuda())
234+
assert types_optout, "expected at least one weight quantizer with a calibrator"
235+
assert all(t is NVFP4MSECalibrator for t in types_optout), types_optout
181236

182237

183238
def _bench(fn, warmup=2, iters=5):

0 commit comments

Comments
 (0)