-
Notifications
You must be signed in to change notification settings - Fork 603
Fix HF export crash when a dynamic-block quantizer has zero amax #2438
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1084,10 +1084,38 @@ def _check_per_channel_block_sizes(block_sizes): | |
| # remove block_sizes | ||
| self._block_sizes = None | ||
|
|
||
| def _sanitize_export_amax(self, amax: torch.Tensor) -> torch.Tensor: | ||
| """Replace zero/NaN amax entries with ``maxbound`` so exported scales stay positive. | ||
|
|
||
| A zero amax means calibration never activated the layer; downstream exporters divide by | ||
| the exported amax, so a zero would fail export or produce inf at inference. Kept | ||
| branch-free because export flows may pass a meta ``amax``; only the warning reads values. | ||
| """ | ||
| sanitized = torch.nan_to_num( | ||
| torch.where(amax == 0, torch.full_like(amax, self.maxbound), amax), | ||
| nan=self.maxbound, | ||
| ) | ||
|
|
||
| if not amax.is_meta: | ||
| num_invalid = int((torch.isnan(amax) | (amax == 0)).sum()) | ||
| if num_invalid: | ||
| warnings.warn( | ||
| f"{num_invalid}/{amax.numel()} amax entries of this " | ||
| f"{type(self).__name__} are zero or NaN at export time, which means " | ||
| "calibration never activated the corresponding layer or expert (or saw NaN " | ||
| "activations). Substituting maxbound so the exported scaling factor stays " | ||
| "positive. Consider increasing the calibration size if the layer is expected " | ||
| "to be active.", | ||
| stacklevel=3, | ||
| ) | ||
| return sanitized | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| def export_amax(self) -> torch.Tensor | None: | ||
| """Export correctly formatted/shaped amax.""" | ||
| if self.block_sizes is not None and self.block_sizes.get("type", None) == "dynamic": | ||
| return self.amax | ||
| # Dynamic block quantizers keep a per-tensor amax (the NVFP4 second-level scale) that | ||
| # needs no reshaping, but it still has to be positive for the exporters. | ||
| return None if self.amax is None else self._sanitize_export_amax(self.amax) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [SUGGESTION] The sibling activation-scale path bypasses this sanitization entirely, so the same zero amax can still abort export under a slightly different recipe.
amax = quantizer.export_amax() # sanitized value discarded for NVFP4
if quantizer.num_bits == (2, 1):
scaling_factor = NVFP4QTensor.get_weights_scaling_factor_2_from_quantizer(quantizer)
...
assert torch.all(scaling_factor > 0), f"scaling factor {scaling_factor} not positive."
The reported recipe happens to land on the fixed branch (its assert text matches
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Verified the analysis is accurate — Leaving it as a follow-up rather than widening this PR, for the reason you note: format dispatch is weight-driven, so reaching that path needs dynamic NVFP4 on an input quantizer whose weight quantizer is a different format or disabled — a combination the reported recipe does not produce, and one I have no repro for. Fixing it properly means deciding whether Noted explicitly in the PR description so it is not lost. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [SUGGESTION] The dynamic branch now shares the zero/NaN substitution with the static path, but not the
Reaching this needs activations below ~1e-38, so it is far less likely than the exact-zero case that motivated the PR — this is a consistency/robustness point, not a live bug. But routing both branches through the same floor is close to free and means "sanitized" has one meaning: def _sanitize_export_amax(self, amax: torch.Tensor) -> torch.Tensor:
sanitized = torch.nan_to_num(
torch.where(amax == 0, torch.full_like(amax, self.maxbound), amax),
nan=self.maxbound,
)
sanitized = sanitized.clamp(
min=torch.finfo(sanitized.dtype).tiny, max=torch.finfo(sanitized.dtype).max
)
...and dropping the now-redundant |
||
|
|
||
| if self.amax is None: | ||
| return None | ||
|
|
@@ -1096,8 +1124,7 @@ def export_amax(self) -> torch.Tensor | None: | |
| amax = self.amax | ||
| else: | ||
| amax = self.amax.reshape(self._amax_shape_for_export) | ||
| amax[amax == 0] = self.maxbound | ||
| amax = torch.nan_to_num(amax, nan=self.maxbound) | ||
| amax = self._sanitize_export_amax(amax) | ||
| clamp_min, clamp_max = torch.finfo(amax.dtype).tiny, torch.finfo(amax.dtype).max | ||
| amax = amax.clamp(min=clamp_min, max=clamp_max) | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: NVIDIA/Model-Optimizer
Length of output: 28598
🏁 Script executed:
Repository: NVIDIA/Model-Optimizer
Length of output: 8771
Sanitize all invalid dynamic export amax values.
The public
amaxsetter accepts negative and non-finite tensors without validation. With onlynanspecified,torch.nan_to_num()converts-infto the dtype’s most-negative finite value and leaves negative finite values unchanged. The dynamic branch returns this value directly at line 1118, bypassing the later clamp andvalidate_attr()call. Export consumers require a strictly positive scaling factor, so they can fail on this value.Treat every non-finite or non-positive entry as invalid. Update the warning text and count, and add
+infand-infregression cases.Proposed fix
🤖 Prompt for AI Agents