Skip to content

Commit 95b8a95

Browse files
committed
[Quantization] Fold Triton FP8 sweep into NVFP4MSECalibrator
Per realAsma's review, collapse TritonNVFP4MSECalibrator into NVFP4MSECalibrator as an internal fast path rather than a separately-exported subclass: - mse.py: NVFP4MSECalibrator.collect() picks the fused Triton kernel via a predicate _can_use_triton_fast_path(x) that requires error_func is None, CUDA input, blocked layout matching the per-block amax, the kernel package importable, and MODELOPT_NVFP4_TRITON_SWEEP \!= "0". Otherwise falls back to the parent's reference 126-step sweep. Override reset() to clear only per-cycle state and keep _initial_amax (shape [num_blocks], small) so the calibrator is reusable; the multi-collect-after-fast-path case raises a RuntimeError with a clear message. TritonNVFP4MSECalibrator class deleted. - model_calib.py: always instantiate NVFP4MSECalibrator; drop the TritonNVFP4MSECalibrator import and the env-var dispatch (now internal). - tests: drop the TritonNVFP4MSECalibrator references. Force the requested path via a _force_sweep_path() context manager around the env var. New dispatch tests assert the predicate's behavior for the env opt-out, custom error_func, and CPU input cases. test_mse_calibrate_end_to_end exercises the full mtq.quantize wiring with default and MODELOPT_NVFP4_TRITON_SWEEP=0 and asserts bitwise-identical model outputs. This fixes a latent correctness issue: the previous TritonNVFP4MSECalibrator silently ignored a custom error_func, so a caller passing a Hessian-weighted loss (e.g. local-Hessian calibration) would have gotten plain squared-error results from the kernel. The new predicate routes any non-None error_func to the reference path so the user's metric is honored. Signed-off-by: Chenjie Luo <chenjiel@nvidia.com>
1 parent e57c7a8 commit 95b8a95

3 files changed

Lines changed: 232 additions & 147 deletions

File tree

modelopt/torch/quantization/calib/mse.py

Lines changed: 67 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"""Calibrator that returns the MSE amax of all collected tensors."""
1717

1818
import math
19+
import os
1920
from collections.abc import Callable
2021

2122
import torch
@@ -24,7 +25,7 @@
2425
from .. import utils as quant_utils
2526
from .calibrator import _Calibrator
2627

27-
__all__ = ["MseCalibrator", "NVFP4MSECalibrator", "TritonNVFP4MSECalibrator"]
28+
__all__ = ["MseCalibrator", "NVFP4MSECalibrator"]
2829

2930

3031
class MseCalibrator(_Calibrator):
@@ -172,7 +173,15 @@ def compute_amax(self, verbose: bool = False):
172173

173174

174175
class NVFP4MSECalibrator(MseCalibrator):
175-
"""Per-block FP8 scale sweep calibrator for NVFP4 static quantization."""
176+
"""Per-block FP8 scale sweep calibrator for NVFP4 static quantization.
177+
178+
Uses a fused Triton kernel as an internal fast path on the first ``collect`` call
179+
when (a) ``error_func is None``, (b) the input tensor is on CUDA in the standard
180+
blocked ``[n_blocks, block_size]`` layout, and (c) Triton + the kernel package are
181+
importable. Falls back to the reference 126-step Python sweep otherwise (custom
182+
``error_func`` users, multi-``collect`` activation flows, CPU inputs, or when the
183+
fast path is disabled via ``MODELOPT_NVFP4_TRITON_SWEEP=0``).
184+
"""
176185

177186
def __init__(
178187
self,
@@ -185,6 +194,8 @@ def __init__(
185194
"""Initialize NVFP4 MSE calibrator with per-block and global amax."""
186195
super().__init__(amax=amax, axis=axis, quant_func=quant_func, error_func=error_func)
187196
self._global_amax = global_amax
197+
# Set by the Triton fast path on its (one-shot) collect; consumed by compute_amax.
198+
self._best_amax_fast: torch.Tensor | None = None
188199

189200
def _compute_candidate_amax(self, candidates: torch.Tensor) -> torch.Tensor:
190201
if candidates.ndim != 0: # Called during final compute amax
@@ -204,94 +215,70 @@ def _generate_candidates(self, device: torch.device) -> torch.Tensor:
204215
fp8_values = fp8_values[valid_mask]
205216
return fp8_values / 448.0
206217

218+
def _can_use_triton_fast_path(self, x: torch.Tensor) -> bool:
219+
"""Whether the Triton fast path is usable for this ``collect`` input.
207220
208-
class TritonNVFP4MSECalibrator(NVFP4MSECalibrator):
209-
"""Triton-fused FP8 scale sweep calibrator for NVFP4 weight MSE.
210-
211-
Numerically equivalent to :class:`NVFP4MSECalibrator` but evaluates all 126
212-
candidates in a single fused Triton kernel — one weight read instead of 126.
213-
214-
Limitation: a single ``collect()`` call is supported per ``compute_amax`` cycle.
215-
This matches the static weight-MSE flow (``mse_calibrate``'s weight loop), where
216-
the calibrator is collected once per weight and immediately consumed. For
217-
activation calibration (multiple ``collect`` calls), use :class:`NVFP4MSECalibrator`.
218-
Call :meth:`reset` to free internal state and re-enable :meth:`collect`.
219-
"""
220-
221-
def __init__(
222-
self,
223-
amax: torch.Tensor,
224-
global_amax: torch.Tensor,
225-
axis: int | tuple | list | None = None,
226-
quant_func: Callable | None = None,
227-
error_func: Callable | None = None,
228-
):
229-
"""Initialize the Triton-fused NVFP4 MSE calibrator.
230-
231-
See :class:`NVFP4MSECalibrator`. ``quant_func``/``error_func`` are unused by
232-
the kernel path but accepted for API parity. Tile shape and ``num_warps`` are
233-
autotuned by the kernel per ``N_BLOCKS``.
221+
The kernel produces the final per-block amax in one shot, so it's only usable
222+
when the caller wants the standard squared-error sweep on a single CUDA tensor
223+
whose layout already matches the per-block amax.
234224
"""
235-
super().__init__(
236-
amax=amax,
237-
global_amax=global_amax,
238-
axis=axis,
239-
quant_func=quant_func,
240-
error_func=error_func,
241-
)
242-
# Stash shape metadata so collect() can keep working after reset() releases
243-
# the (potentially large) _initial_amax buffer.
244-
self._initial_amax_shape = tuple(amax.shape)
245-
self._initial_amax_dtype = amax.dtype
246-
self._n_blocks = int(amax.numel())
247-
self._best_amax: torch.Tensor | None = None
225+
if self._error_func is not None:
226+
return False
227+
if not x.is_cuda:
228+
return False
229+
if os.environ.get("MODELOPT_NVFP4_TRITON_SWEEP", "1") == "0":
230+
return False
231+
if self._initial_amax is None:
232+
return False
233+
if x.ndim != 2 or x.shape[0] != int(self._initial_amax.numel()):
234+
return False
235+
try:
236+
from modelopt.torch.kernels.quantization.gemm import nvfp4_fp8_scale_sweep # noqa: F401
237+
except ImportError:
238+
return False
239+
return True
248240

249241
@torch.no_grad()
250242
def collect(self, x: torch.Tensor):
251-
"""Run the fused FP8 sweep kernel and store the resulting per-block amax."""
252-
from modelopt.torch.kernels.quantization.gemm import nvfp4_fp8_scale_sweep
253-
254-
if self._best_amax is not None:
243+
"""Collect input statistics. Uses the Triton fast path when eligible."""
244+
if self._best_amax_fast is not None:
255245
raise RuntimeError(
256-
"TritonNVFP4MSECalibrator.collect() is one-shot; call reset() to "
257-
"discard the previous result before collecting again."
258-
)
259-
260-
x = x.detach()
261-
# The weight quantizer reshapes its input to [n_blocks, block_size] before
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)}."
246+
"NVFP4MSECalibrator: the Triton fast path produced a final amax on a "
247+
"previous collect() call; multi-collect after the fast path is not "
248+
"supported. Call reset() to start a fresh cycle, set "
249+
"MODELOPT_NVFP4_TRITON_SWEEP=0, or pass a non-None error_func to force "
250+
"the reference path for activation-style accumulation."
267251
)
268-
block_size = x.shape[-1]
269-
if block_size <= 0:
270-
raise ValueError(f"x.shape[-1] must be positive; got {block_size}.")
271-
n_blocks = x.shape[0]
272-
if n_blocks != self._n_blocks:
273-
raise ValueError(
274-
f"initial amax.numel() ({self._n_blocks}) does not match the number "
275-
f"of NVFP4 blocks in x ({n_blocks})."
252+
# Fast path is eligible only on the first call, before the reference accumulator
253+
# has produced any state.
254+
if self._losses_sum is None and self._can_use_triton_fast_path(x):
255+
from modelopt.torch.kernels.quantization.gemm import nvfp4_fp8_scale_sweep
256+
257+
best_flat = nvfp4_fp8_scale_sweep(x.detach(), self._global_amax, block_size=x.shape[-1])
258+
# Match the original shape/dtype of the initial amax so downstream
259+
# load_calib_amax behaves identically to the reference path.
260+
self._best_amax_fast = best_flat.reshape(self._initial_amax.shape).to(
261+
self._initial_amax.dtype
276262
)
277-
278-
best_amax_flat = nvfp4_fp8_scale_sweep(
279-
x,
280-
self._global_amax,
281-
block_size=block_size,
282-
)
283-
# Match the original shape/dtype of the initial amax so downstream
284-
# load_calib_amax behaves identically to the reference path.
285-
self._best_amax = best_amax_flat.reshape(self._initial_amax_shape).to(
286-
self._initial_amax_dtype
287-
)
263+
return
264+
super().collect(x)
288265

289266
@torch.no_grad()
290267
def compute_amax(self, verbose: bool = False):
291-
"""Return the per-block amax computed during ``collect``."""
292-
return self._best_amax
268+
"""Return the per-block amax — from the fast path if it ran, else from the reference sweep."""
269+
if self._best_amax_fast is not None:
270+
return self._best_amax_fast
271+
return super().compute_amax(verbose=verbose)
293272

294273
def reset(self):
295-
"""Reset the stored best amax. Subsequent ``collect`` calls are allowed."""
296-
self._best_amax = None
297-
super().reset()
274+
"""Reset per-cycle state. Keep ``_initial_amax`` so the calibrator stays reusable.
275+
276+
``MseCalibrator.reset()`` intentionally drops ``_initial_amax`` to free memory in
277+
the multi-step search, but the NVFP4 per-block amax is shape ``[num_blocks]`` —
278+
small enough to keep so a follow-up ``collect()`` can run again on the same
279+
calibrator instance.
280+
"""
281+
self._best_amax_fast = None
282+
self._losses_sum = None
283+
self._candidates = None
284+
self._amax = None

modelopt/torch/quantization/model_calib.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
"""Calibration utilities."""
1717

1818
import math
19-
import os
2019
import time
2120
import warnings
2221
from collections.abc import Callable
@@ -38,7 +37,7 @@
3837
from modelopt.torch.utils.distributed import DistributedProcessGroup, ParallelState
3938
from modelopt.torch.utils.network import bind_forward_method, unpatch_forward_method
4039

41-
from .calib import MseCalibrator, NVFP4MSECalibrator, TritonNVFP4MSECalibrator, _Calibrator
40+
from .calib import MseCalibrator, NVFP4MSECalibrator, _Calibrator
4241
from .conversion import create_and_replace_svdquant_linear_on_the_fly, set_quantizer_by_cfg_context
4342
from .nn import NVFP4StaticQuantizer, QuantModule, SequentialQuantizer, TensorQuantizer
4443
from .utils import (
@@ -355,11 +354,6 @@ def mse_calibrate(
355354
weight_quantizers = []
356355
seen_modules = set()
357356

358-
# Triton-fused FP8 sweep is on by default for NVFP4 static quant; set
359-
# MODELOPT_NVFP4_TRITON_SWEEP=0 to fall back to the reference for debugging.
360-
use_triton_fp8_sweep = os.environ.get("MODELOPT_NVFP4_TRITON_SWEEP", "1") != "0"
361-
nvfp4_calibrator_cls = TritonNVFP4MSECalibrator if use_triton_fp8_sweep else NVFP4MSECalibrator
362-
363357
for name, module in list(model.named_modules()):
364358
if isinstance(module, TensorQuantizer) and not module._disabled:
365359
if module._calibrator is not None and not module._dynamic and hasattr(module, "_amax"):
@@ -397,7 +391,10 @@ def mse_calibrate(
397391
continue
398392

399393
if fp8_scale_sweep and is_nvfp4_static:
400-
module._calibrator = nvfp4_calibrator_cls(
394+
# NVFP4MSECalibrator internally selects a fused Triton kernel for
395+
# the standard squared-error sweep; set MODELOPT_NVFP4_TRITON_SWEEP=0
396+
# to force the reference Python sweep for debugging.
397+
module._calibrator = NVFP4MSECalibrator(
401398
amax=initial_amax,
402399
axis=module._calibrator._axis,
403400
global_amax=module.global_amax,

0 commit comments

Comments
 (0)