Files
Model-Optimizer/modelopt/torch/quantization/utils/numeric_utils.py
T
ZhiyuandClaude Opus 4.8 f335459dc0 refactor(examples): rename llm_ptq → hf_ptq (symlink for back-compat) (#1759)
## What does this PR do?

**Type of change:** refactor / deprecation (examples)

Follow-up to #1705 (which consolidated `examples/vlm_ptq` into
`examples/llm_ptq`). Since that example now covers Hugging Face **LLM
and VLM** PTQ, the `llm_ptq` name is a misnomer. This renames the
directory to `examples/hf_ptq` and leaves a relative symlink
`examples/llm_ptq → hf_ptq` so existing paths/commands keep working
during a deprecation window.

Requested by @kevalmorabia97 on #1705 (with the symlink-for-back-compat
approach), targeted for the **same 0.46 release** as the consolidation.

### Changes
- `git mv examples/llm_ptq → examples/hf_ptq` and
`tests/examples/llm_ptq → tests/examples/hf_ptq` (the CI runner maps the
matrix name to both `examples/<name>` and `tests/examples/<name>`).
- Add a tracked back-compat symlink `examples/llm_ptq → hf_ptq`.
- Update CI matrices and all repo **path references** (docs, READMEs,
agent skills, launcher/debugger tools, tests) from `llm_ptq` to
`hf_ptq`.
- Keep Python identifiers / test-util module names
(`run_llm_ptq_command`, `llm_ptq_utils`) — they name the LLM-PTQ task,
not the directory.
- Preserve the CODEOWNERS team slug
(`modelopt-examples-llm_ptq-codeowners`) and historical CHANGELOG
entries; add a CHANGELOG deprecation note.

### Back-compat caveats (inherent to git directory symlinks)
- ✅ Linux/macOS CLI usage and Python `cwd`/pytest resolution work
through the symlink.
- ⚠️ Windows git checkouts don't materialize symlinks by default (low
impact — this example is Linux-only in practice).
- ⚠️ GitHub web doesn't follow directory symlinks, so legacy external
deep-links to `examples/llm_ptq/...` won't navigate in. All **internal**
references are repointed to `hf_ptq`, so the symlink is only for legacy
external/CLI use.

### Usage (unchanged via symlink)
```bash
# New canonical path
cd examples/hf_ptq
scripts/huggingface_example.sh --model <hf_model> --quant fp8

# Old path still works (forwards via symlink)
cd examples/llm_ptq && scripts/huggingface_example.sh --model <hf_model> --quant fp8
```

### Testing
- `bash -n` on moved/edited shell scripts (new path + via symlink).
- `py_compile` on moved/edited Python; test re-export shim repointed to
`examples/hf_ptq/example_utils`.
- Verified git tracks `examples/llm_ptq` as a single symlink (mode
120000), not a duplicated tree (no pre-commit / pytest
double-processing).
- `pre-commit run` on all changed files passes.

### Before your PR is "*Ready for review*"
- Is this change backward compatible?: ✅ (relative symlink keeps
`examples/llm_ptq` paths valid; see caveats above)
- Did you write any new necessary tests?: N/A (pure rename; existing
tests moved with the dir)
- Did you update Changelog?: ✅

### Additional Information
Follow-up (later release): remove the `examples/llm_ptq` symlink once
external references have migrated.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* PTQ guidance now directs to the unified Hugging Face PTQ flow,
including VLM quantization via the shared `--vlm` entry point.
* **Documentation**
* Updated README and guide links, references, and command snippets to
use `hf_ptq` (replacing `llm_ptq`).
* Deprecated and consolidated `vlm_ptq` into `hf_ptq`; removed
VILA/NVILA coverage from the Hugging Face PTQ examples.
* **Bug Fixes**
* Improved detection and routing so local/manual setup uses the correct
PTQ source.
* **Tests / Chores**
  * CI and example tests updated to run the `hf_ptq` variants.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Signed-off-by: Zhiyu Cheng <zhiyuc@nvidia.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 08:48:48 +00:00

185 lines
7.8 KiB
Python

# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Closed-form numerics for the bit-exact MXFP4 -> NVFP4 weight cast.
These helpers turn an MXFP4 source layer's E8M0 block scales into the per-tensor
``global_amax`` and per-NVFP4-block ``amax`` that pin NVFP4's two-level scale so
the cast reproduces the source MXFP4 weights bit-for-bit (see PR #1372 for the
derivation). They are pure tensor math with no model or checkpoint dependencies,
shared by the GPT-OSS (``examples/hf_ptq``) and DeepSeek-V4
(``examples/deepseek``) PTQ cast paths.
"""
import torch
__all__ = [
"E2M1_MAX",
"E4M3_KMAX",
"E4M3_KMIN",
"E4M3_MAX",
"E8M0_BIAS",
"mxfp4_to_nvfp4_global_amax",
"mxfp4_to_nvfp4_per_block_amax",
]
E8M0_BIAS = 127 # E8M0 stores k_j as uint8 with bias 127
E2M1_MAX = 6.0
E4M3_MAX = 448.0
E4M3_MAX_46 = 256.0 # 4 Over 6 max FP8 scale
E4M3_KMAX = 8
E4M3_KMIN = -9 # E4M3 represents 2^k exactly for k in [-9, 8]
# E2M1 magnitude grid indexed by the low 3 bits of an FP4 nibble.
_E2M1_MAGNITUDE = [0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0]
# Cache of the E2M1 magnitude lookup table per (device, dtype) so we don't
# rebuild it for every layer in a batched cast.
_E2M1_MAG_CACHE: "dict[tuple, torch.Tensor]" = {}
def _e2m1_magnitude_table(device: torch.device, dtype: torch.dtype = torch.float32) -> torch.Tensor:
"""Return ``_E2M1_MAGNITUDE`` as a tensor on the requested device, cached."""
key = (device, dtype)
cached = _E2M1_MAG_CACHE.get(key)
if cached is None:
cached = torch.tensor(_E2M1_MAGNITUDE, dtype=dtype, device=device)
_E2M1_MAG_CACHE[key] = cached
return cached
def mxfp4_to_nvfp4_global_amax(e8m0_scales: torch.Tensor) -> tuple[float, dict]:
"""Closed-form per-tensor ``global_amax``: ``m = k_max - 8``, ``global_amax = 6 * 448 * 2^m``.
Args:
e8m0_scales: uint8 tensor of E8M0 scales for one MXFP4 source layer.
Returns:
global_amax: scalar (float) — pins NVFP4 scale_2 to 2^m.
info: diagnostic dict with k_min, k_max, m, lossless-block stats.
"""
# k_j = e8m0 - 127. MXFP4 quantize emits e8m0=0 (=> k=-127) for all-zero
# blocks; treat those as "ignore me" when computing k_max.
k = e8m0_scales.to(torch.int32) - E8M0_BIAS
nonzero_mask = e8m0_scales > 0
if nonzero_mask.any():
k_nonzero = k[nonzero_mask]
k_min = int(k_nonzero.min().item())
k_max = int(k_nonzero.max().item())
else:
k_min = k_max = 0
m = k_max - E4M3_KMAX
global_amax = E2M1_MAX * E4M3_MAX * float(2.0**m)
# A block is lossless under this cast iff k_max - k_j <= 17 (its k_j - m sits
# in E4M3's [-9, 8] window). All-zero blocks are trivially lossless because
# their reconstruction is 0 regardless of the snapped scale.
n_total = e8m0_scales.numel()
in_range = (k >= (k_max - 17)) | (~nonzero_mask)
n_lossless = int(in_range.sum().item())
pct_lossless = 100.0 * n_lossless / n_total if n_total else 100.0
return global_amax, {
"k_min": k_min,
"k_max": k_max,
"m": m,
"n_total_blocks": n_total,
"n_lossless_blocks": n_lossless,
"pct_lossless": pct_lossless,
"n_zero_blocks": int((~nonzero_mask).sum().item()),
}
def mxfp4_to_nvfp4_per_block_amax(blocks: torch.Tensor, e8m0_scales: torch.Tensor) -> torch.Tensor:
"""Hybrid per-NVFP4-block amax for MXFP4 -> NVFP4 cast.
Each MXFP4 block of 32 elements has one E8M0 exponent ``k_j``. Two cases
based on whether ``k_j`` fits in NVFP4's E4M3 scale grid (with
``m = k_max - 8`` chosen by ``mxfp4_to_nvfp4_global_amax``):
- **In-range** (``k_j - m`` in ``[-9, 8]``): ``6 * 2^k_j`` (closed-form
ideal). The resulting per-block scale ``2^(k_j - m)`` is exactly
representable in E4M3 — no rounding loss — and
``round_to_E2M1(value / 2^k_j)`` yields the original MXFP4 nibble
verbatim. Bit-exact reconstruction.
- **Out of range** (``|k_j - m| > 8/9``): ``max_nibble * 2^k_j``, i.e.
``max(|w_block|)`` where ``w`` is the MXFP4-dequantized block. This is
the data-derived per-block amax. The per-block scale will still get
clamped at the E4M3 boundary, but data-derived amax keeps the post-clamp
scale closer to the block's actual magnitude than the closed-form ideal
would, which reduces re-bucketing error for OOR blocks where
``max_nibble < 6``.
Two NVFP4 blocks of 16 share each MXFP4 block's ``k_j``, so the result is
expanded by ``repeat_interleave(2, dim=-1)``.
Args:
blocks: uint8 tensor of packed E2M1 nibbles, shape
``(..., num_mxfp4_blocks, 16)`` (16 bytes per 32-element MXFP4 block).
e8m0_scales: uint8 tensor of E8M0 scales, shape
``(..., num_mxfp4_blocks)``.
Returns:
float32 tensor of shape ``(..., 2 * num_mxfp4_blocks)``.
"""
if blocks.shape[-1] != 16 or blocks.shape[:-1] != e8m0_scales.shape:
raise ValueError(
f"shape mismatch: blocks {tuple(blocks.shape)} "
"(expected (..., num_mxfp4_blocks, 16)) "
f"vs scales {tuple(e8m0_scales.shape)}"
)
k = e8m0_scales.to(torch.int32) - E8M0_BIAS # (..., num_mxfp4_blocks)
pow2_k = torch.exp2(k.float())
closed_form_ideal = E2M1_MAX * pow2_k # (..., num_mxfp4_blocks)
# ``m = k_max - 8`` over non-zero blocks. Compute via masked ``amax`` so
# ``m`` stays a 0-d tensor and we avoid a GPU->CPU sync just to get a
# Python int. All-zero scales fall through with the -E8M0_BIAS sentinel,
# which leaves every block trivially in-range (closed_form_ideal == 0 there).
nonzero = e8m0_scales > 0
sentinel = torch.full_like(k, -E8M0_BIAS)
k_max = torch.where(nonzero, k, sentinel).amax()
delta = k - (k_max - E4M3_KMAX)
in_range = (delta >= E4M3_KMIN) & (delta <= E4M3_KMAX)
# Fast path: if every block fits E4M3's [-9, 8] window the per-block amax
# is just the closed-form ideal, and we can skip the per-byte nibble scan
# over the block tensor (which is 16x larger than the scales). For typical
# MXFP4 checkpoints (e.g. gpt-oss-20b) this is the only path ever taken.
if bool(in_range.all()):
return closed_form_ideal.repeat_interleave(2, dim=-1)
# OOR fallback: data-derived per-block amax = max(|w_block|) after MXFP4
# dequant = ``max_nibble * 2^k_j``. The MXFP4 nibble is sign-magnitude with
# sign in bit 3 and magnitude index in bits 0-2; we extract per-byte
# magnitudes, take the byte-wise max, then reduce across the 16 bytes to
# get the largest magnitude index in the 32-element block.
low = blocks & 0x07
high = (blocks >> 4) & 0x07
max_idx = torch.maximum(low, high).amax(dim=-1).long()
max_nibble = _e2m1_magnitude_table(blocks.device)[max_idx]
data_derived = max_nibble * pow2_k
per_block_amax_mxfp4 = torch.where(in_range, closed_form_ideal, data_derived)
# Each MXFP4 block of 32 splits into two NVFP4 blocks of 16 sharing k_j.
return per_block_amax_mxfp4.repeat_interleave(2, dim=-1)
def fp8_max_for_normalization(quantizer) -> float:
"""FP8 normalization max: 256 for 4/6, else 448."""
bs = getattr(quantizer, "block_sizes", None) or {}
return E4M3_MAX_46 if bool(bs.get("four_over_six", False)) else E4M3_MAX