Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Changelog
- GPTQ no longer runs a max-calibration forward when no activation quantizer needs one, and ``hf_ptq.py`` now sizes the default layerwise calibration batch at 8,192 tokens (16 samples at ``--calib_seq 512``) instead of 1. Layerwise GPTQ on HF fused-MoE models runs over 10x faster; pass ``--batch_size 1`` for the old batching, which pads no sample.
- Add a checkpoint-specific NVIDIA Nemotron 3.5 Super VL AutoQuantize recipe with a 4.9-effective-bit NVFP4-MSE/FP8/BF16 search and fixed NVFP4 MTP experts. Select it under ``models/nvidia/NVIDIA-Nemotron-3.5-Super-VL-120B-A12B-BF16/auto_quantize/``.
- Add ``examples/hf_ptq/convert_ggml_to_int8.py`` to expand a checkpoint exported with a GGML recipe (IQ formats, ``q8_0``) into an INT8 weight-only compressed-tensors checkpoint. Stock vLLM serves it with its integer kernels at about half the BF16 weight memory, reproducing the GGML weights up to rounding their scales to the model dtype.
- Add ``python -m modelopt.torch.quantization.utils.scale_compare`` to diff the quantization scales of two exported checkpoints within a tolerance, e.g. to check how reproducible a PTQ run is across repeats or parallel layouts.
- Add ``--deterministic`` to ``examples/hf_ptq/hf_ptq.py`` for best-effort reproducible PTQ: it seeds every random number generator and selects deterministic torch kernels, and requires an explicit ``--batch_size`` because the default probes it from free GPU memory.

*Speculative Decoding*

Expand Down
12 changes: 12 additions & 0 deletions examples/hf_ptq/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,18 @@ After the TensorRT-LLM checkpoint export, you can use the `trtllm-build` build c
- Deployable on [TensorRT-LLM](https://github.com/NVIDIA/TensorRT-LLM), [vLLM](https://github.com/vllm-project/vllm) and [SGLang](https://github.com/sgl-project/sglang)
- More models coming soon!

## Reproducible PTQ

On the configurations we measured (Qwen3-30B-A3B NVFP4 on one and two GPUs, Qwen3-8B AWQ), repeating a run with the same model, data, recipe, batch size, hardware and software gave identical scales. What moved them was the calibration batch size: the default `--batch_size 0` probes it from free GPU memory, so another GPU or memory state silently calibrates with another batch size. `--deterministic` pins what the run still depends on: it requires an explicit `--batch_size`, seeds every random number generator and selects deterministic torch kernels. This is best effort, not a guarantee: an op without a deterministic kernel still runs and only warns. It costs some run time.

```bash
python hf_ptq.py ... --batch_size 8 --deterministic --export_path run_a
python hf_ptq.py ... --batch_size 8 --deterministic --export_path run_b
python -m modelopt.torch.quantization.utils.scale_compare run_a run_b
```

`scale_compare` lists, per scale kind (`input_scale`, `weight_scale`, ...), how many tensors differ and by how much, and exits non-zero when a scale differs by more than `--rtol` / `--atol` (default 0: identical). Run it on checkpoints that differ in batch size or GPU layout to see how far the scales move.

## Tracking runs with MLflow

Set MLflow's own `MLFLOW_TRACKING_URI`, or pass `--mlflow <tracking-uri>`, to record a PTQ
Expand Down
28 changes: 24 additions & 4 deletions examples/hf_ptq/hf_ptq.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
EagleOfflineDataCollator,
OfflineSupervisedDataset,
)
from modelopt.torch.utils import print_rank_0
from modelopt.torch.utils import enable_determinism, print_rank_0
from modelopt.torch.utils.dataset_utils import (
create_forward_loop,
get_dataset_dataloader,
Expand Down Expand Up @@ -1208,6 +1208,17 @@ def parse_args() -> argparse.Namespace:
type=int,
default=512,
)
parser.add_argument(
"--deterministic",
help=(
"Best-effort reproducible PTQ: seed every random number generator and use "
"deterministic torch kernels (an op without one only warns). Needs an explicit "
"--batch_size, since the default probes it from free GPU memory and so differs "
"between GPUs and runs."
),
default=False,
action="store_true",
)
parser.add_argument("--export_path", default="exported_model")
parser.add_argument(
"--dataset",
Expand Down Expand Up @@ -1436,6 +1447,12 @@ def parse_args() -> argparse.Namespace:
if args.calib_seq <= 0:
parser.error("--calib_seq must be positive.")

if args.deterministic and args.batch_size == 0:
parser.error(
"--deterministic needs an explicit --batch_size: the default probes it from free GPU "
"memory, so it differs between GPUs and runs."
)

if args.specdec_offline_dataset is not None and args.sparsity_fmt != "dense":
parser.error("--specdec_offline_dataset is only supported with --sparsity_fmt dense (PTQ).")

Expand Down Expand Up @@ -1499,11 +1516,14 @@ def main(args: argparse.Namespace):
if not torch.cuda.is_available():
raise OSError("GPU is required for inference.")

random.seed(RAND_SEED)
np.random.seed(RAND_SEED)

setup_distributed_args(args)

if args.deterministic:
enable_determinism(RAND_SEED)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] enable_determinism runs before setup_distributed_args(args), so its print_rank_0 is not yet rank-aware: dist.is_master() returns True on every rank until torch.distributed is initialized, and under torchrun all N ranks print the "Deterministic mode" line.

Harmless, but it's the one line of this feature's output and duplicating it per rank is the opposite of what print_rank_0 is there for. Moving the call just after setup_distributed_args(args) keeps it comfortably before the first CUDA matmul (the constraint the docstring actually cares about) and makes the message appear once.

The ordering in tests/examples/hf_ptq/test_hf_ptq_args.py::test_main_enables_determinism_before_loading ("before loading") still holds either way, since load_model comes well after.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved in 133fb1d: the seeding block now runs right after setup_distributed_args(args), so the "Deterministic mode" line is printed once instead of on every rank under torchrun. It is still before ensure_local_checkpoint, the model load and the first matmul.

else:
random.seed(RAND_SEED)
np.random.seed(RAND_SEED)

try:
# Entered inside the try: opening the run is fatal by design, and skipping
# cleanup_distributed would leave the other ranks blocked on the first collective
Expand Down
206 changes: 206 additions & 0 deletions modelopt/torch/quantization/utils/scale_compare.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
# 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.

"""Compare the quantization scales of two PTQ runs.

Export both runs as safetensors checkpoints and diff every scale tensor::

python -m modelopt.torch.quantization.utils.scale_compare ckpt_a ckpt_b --rtol 1e-3

The exit status is 0 when all scales agree within the tolerance, so the command doubles as a
reproducibility check for repeated runs or different parallel layouts. :func:`compare_scales`
takes any two ``{name: tensor}`` mappings, e.g. flattened ``get_quantizer_state_dict`` outputs.
"""

import argparse
import re
import statistics
from collections.abc import Iterator, Mapping, Sequence
from dataclasses import dataclass, field
from pathlib import Path

import torch
from safetensors import safe_open

__all__ = ["ScaleDiff", "TensorDiff", "compare_scales", "load_checkpoint_scales", "main"]

# Exported scales (input_scale, weight_scale, weight_scale_2, weight_scale_inv, pre_quant_scale,
# k_scale, v_scale) and the quantizer buffers (_amax) they come from.
_SCALE_NAME = re.compile(r"(_scale(_2|_inv)?|_amax)$")


@dataclass
class TensorDiff:
"""Difference between one scale tensor of the two runs."""

name: str
elements: int
outside_tolerance: int
max_rel: float # largest |a - b| / max(|a|, |b|) over the elements

@property
def kind(self) -> str:
"""Last component of the name, e.g. ``input_scale``."""
return self.name.rsplit(".", 1)[-1]


@dataclass
class ScaleDiff:
"""Result of :func:`compare_scales`."""

tensors: list[TensorDiff] = field(default_factory=list)
only_in_a: list[str] = field(default_factory=list)
only_in_b: list[str] = field(default_factory=list)
shape_mismatch: list[str] = field(default_factory=list)

@property
def within_tolerance(self) -> bool:
"""Whether tensors were compared and none is missing, reshaped or outside the tolerance."""
return (
bool(self.tensors)
and not (self.only_in_a or self.only_in_b or self.shape_mismatch)
and all(t.outside_tolerance == 0 for t in self.tensors)
)

def summary(self, top: int = 5) -> str:
"""Per-kind table (kind = last component of the tensor name) and the worst tensors.

``differing`` counts the tensors outside the tolerance and ``median rel`` is the median of
their largest relative differences; ``max rel`` is the largest over all tensors of the
kind, including those inside the tolerance.
"""
by_kind: dict[str, list[TensorDiff]] = {}
for t in self.tensors:
by_kind.setdefault(t.kind, []).append(t)
lines = [
f"{'kind':<16}{'tensors':>9}{'differing':>11}{'diff elems':>12}"
f"{'median rel':>12}{'max rel':>10}"
]
for kind, ts in sorted(by_kind.items()):
differing = [t.max_rel for t in ts if t.outside_tolerance]
elements = sum(t.elements for t in ts)
outside = sum(t.outside_tolerance for t in ts)
median = statistics.median(differing) if differing else 0.0
lines.append(
f"{kind:<16}{len(ts):>9}{len(differing):>11}{outside / max(elements, 1):>12.2%}"
f"{median:>12.2e}{max(t.max_rel for t in ts):>10.2e}"
)
worst = sorted((t for t in self.tensors if t.outside_tolerance), key=lambda t: -t.max_rel)
Comment on lines +87 to +100

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] Two columns in the table mean something narrower than their headers suggest, and the docstring on summary doesn't say which:

  • median rel is the median of per-tensor maxima, taken over the differing tensors only — not a median over elements, and not over all tensors. With rtol large enough that nothing differs it reads 0.00e+00, which looks like "no difference anywhere" rather than "nothing exceeded the tolerance".
  • max rel is over all tensors of the kind, including those inside the tolerance — so a kind can show differing 0 next to a non-zero max rel. That combination is correct but reads like a contradiction.

The PR description spells both out; the tool's own output is what a user will have in front of them months later. One extra clause on the summary docstring would carry it:

    def summary(self, top: int = 5) -> str:
        """Per-kind table (kind = last component of the tensor name) and the worst tensors.

        ``median rel`` is the median over the differing tensors of each tensor's largest
        relative difference; ``max rel`` is the largest over all tensors of the kind.
        """

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 133fb1d: the summary docstring now says that differing counts the tensors outside the tolerance, median rel is the median of their largest relative differences, and max rel is the largest over all tensors of the kind, including those inside the tolerance.

if worst:
lines.append("worst:")
lines += [f" {t.max_rel:.2e} {t.name}" for t in worst[:top]]
for label, names in (
("only in A", self.only_in_a),
("only in B", self.only_in_b),
("shape mismatch", self.shape_mismatch),
):
if names:
lines.append(f"{label}: {len(names)} tensors, e.g. {names[0]}")
lines.append("result: " + ("within tolerance" if self.within_tolerance else "DIFFERENT"))
return "\n".join(lines)


def _diff(a: torch.Tensor, b: torch.Tensor, rtol: float, atol: float) -> tuple[int, float]:
"""Return the number of elements outside the tolerance and the largest relative difference."""
dtype = torch.float64 if torch.float64 in (a.dtype, b.dtype) else torch.float32
a, b = a.to(dtype), b.to(dtype) # fp8 and bf16 scales have no arithmetic of their own
same = (a == b) | (a.isnan() & b.isnan())
delta = (a - b).abs()
magnitude = torch.maximum(a.abs(), b.abs())
rel = torch.nan_to_num(delta / magnitude, nan=torch.inf).masked_fill(same, 0.0)
close = same | (a.isfinite() & b.isfinite() & (delta <= atol + rtol * magnitude))
return int((~close).sum()), float(rel.max()) if rel.numel() else 0.0


def compare_scales(
a: Mapping[str, torch.Tensor],
b: Mapping[str, torch.Tensor],
rtol: float = 0.0,
atol: float = 0.0,
) -> ScaleDiff:
"""Diff the tensors of ``a`` and ``b`` by name.

An element is outside the tolerance when ``|a - b| > atol + rtol * max(|a|, |b|)``. The default
tolerance of zero asks for identical scales.
"""
# Plain name sets: a lazy mapping's `in` would load the tensor.
names_a, names_b = set(a), set(b)
result = ScaleDiff(only_in_a=sorted(names_a - names_b), only_in_b=sorted(names_b - names_a))
for name in sorted(names_a & names_b):
ta, tb = a[name], b[name]
if ta.shape != tb.shape:
result.shape_mismatch.append(name)
continue
outside, max_rel = _diff(ta, tb, rtol, atol)
result.tensors.append(TensorDiff(name, ta.numel(), outside, max_rel))
return result


class _CheckpointScales(Mapping[str, torch.Tensor]):
"""Lazy ``name -> tensor`` view of the scale tensors of a safetensors checkpoint."""

def __init__(self, path: str | Path):
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"{path} does not exist")
files = sorted(path.glob("*.safetensors")) if path.is_dir() else [path]
if not files:
raise FileNotFoundError(f"No .safetensors files in {path}")
self._handles: dict[str, safe_open] = {}
for file in files:
handle = safe_open(file, framework="pt")
names = list(filter(_SCALE_NAME.search, handle.keys()))
if duplicates := self._handles.keys() & set(names):
raise ValueError(f"{sorted(duplicates)[0]} is in more than one file of {path}")
self._handles.update(dict.fromkeys(names, handle))
if not self._handles:
raise ValueError(f"No quantization scale tensors in {path}")

def __getitem__(self, name: str) -> torch.Tensor:
return self._handles[name].get_tensor(name)

def __iter__(self) -> Iterator[str]:
return iter(self._handles)

def __len__(self) -> int:
return len(self._handles)


def load_checkpoint_scales(path: str | Path) -> Mapping[str, torch.Tensor]:
"""Scale tensors of a safetensors file or of every ``*.safetensors`` file in a directory."""
return _CheckpointScales(path)
Comment on lines +153 to +183

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] _CheckpointScales keeps one safe_open handle per shard alive for the object's whole lifetime and offers no way to release them.

For the CLI that's fine — the process exits. For the documented library use (load_checkpoint_scales is in __all__), a caller comparing a many-shard MoE export holds every shard's file descriptor and mmap until the object is garbage-collected, and has no deterministic point to drop them. A __enter__/__exit__ pair closing set(self._handles.values()) would make load_checkpoint_scales usable as a context manager without changing the CLI path.

Minor, separate: when path is a non-existent file the FileNotFoundError("No .safetensors files in ...") branch is skipped (files = [path]) and the user gets safetensors' lower-level error instead of this module's message. A path.exists() check at the top would route both cases to the clear message.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly done in 133fb1d: a path that does not exist now raises FileNotFoundError("... does not exist") (covered in test_nothing_to_compare_is_not_a_pass). I am not adding a context manager. The shard handles are released as soon as the mapping is dropped (CPython reference counting), the CLI exits right after, and I would rather not add API surface for a long-lived caller that does not exist yet.



def main(argv: Sequence[str] | None = None) -> int:
"""Compare two checkpoints; the exit status is 0 when their scales agree within tolerance."""
parser = argparse.ArgumentParser(description="Compare the quantization scales of two PTQ runs.")
parser.add_argument("checkpoint_a", help="Safetensors file or checkpoint directory.")
parser.add_argument("checkpoint_b", help="Safetensors file or checkpoint directory.")
parser.add_argument("--rtol", type=float, default=0.0, help="Relative tolerance (default 0).")
parser.add_argument("--atol", type=float, default=0.0, help="Absolute tolerance (default 0).")
parser.add_argument("--top", type=int, default=5, help="Number of worst tensors to list.")
args = parser.parse_args(argv)
result = compare_scales(
load_checkpoint_scales(args.checkpoint_a),
load_checkpoint_scales(args.checkpoint_b),
args.rtol,
args.atol,
)
print(result.summary(args.top))
return 0 if result.within_tolerance else 1


if __name__ == "__main__":
raise SystemExit(main())
1 change: 1 addition & 0 deletions modelopt/torch/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from ._pytree import *
from .cpp_extension import *
from .dataset_utils import *
from .determinism import *
from .graph import *
from .import_utils import *
from .list import *
Expand Down
52 changes: 52 additions & 0 deletions modelopt/torch/utils/determinism.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# 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.

"""Best-effort determinism for reproducible runs."""

import os
import random

import numpy as np
import torch

from .logging import print_rank_0, warn_rank_0

__all__ = ["enable_determinism"]

_DETERMINISTIC_CUBLAS_CONFIGS = (":4096:8", ":16:8")


def enable_determinism(seed: int) -> None:
"""Seed every random number generator and ask PyTorch for deterministic kernels.

An operation without a deterministic kernel only warns, so repeated runs on the same hardware,
software and parallel layout agree as closely as the remaining kernels allow. Call this at
startup, before the first CUDA matmul: cuBLAS reads ``CUBLAS_WORKSPACE_CONFIG`` when it creates
its handle. A value of that variable that is already set is kept, with a warning if cuBLAS does
not treat it as deterministic.
"""
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
cublas_config = os.environ.setdefault(
"CUBLAS_WORKSPACE_CONFIG", _DETERMINISTIC_CUBLAS_CONFIGS[0]
)
if cublas_config not in _DETERMINISTIC_CUBLAS_CONFIGS:
warn_rank_0(
f"CUBLAS_WORKSPACE_CONFIG={cublas_config} is not one of {_DETERMINISTIC_CUBLAS_CONFIGS}: "
"cuBLAS reductions are not made deterministic."
)
torch.use_deterministic_algorithms(True, warn_only=True)
print_rank_0(f"Deterministic mode: seeded every RNG with {seed}, deterministic torch kernels.")
36 changes: 36 additions & 0 deletions tests/examples/hf_ptq/test_hf_ptq_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -964,6 +964,42 @@ def load_model(args):
]


def test_deterministic_needs_an_explicit_batch_size(monkeypatch):
flags = ["--pyt_ckpt_path", "/models/Qwen3-0.6B", "--deterministic"]
with pytest.raises(SystemExit):
_parse_hf_ptq_args(monkeypatch, *flags)

_, args = _parse_hf_ptq_args(monkeypatch, *flags, "--batch_size", "8")

assert args.deterministic


@pytest.mark.usefixtures("clean_env")
def test_main_enables_determinism_before_loading(monkeypatch, tmp_path):
hf_ptq, args = _parse_hf_ptq_args(
monkeypatch, "--pyt_ckpt_path", "org/model", "--deterministic", "--batch_size", "8"
)
seeds = []

class _LoadReachedError(Exception):
pass

def load_model(args):
raise _LoadReachedError

monkeypatch.setattr(torch.cuda, "is_available", lambda: True)
monkeypatch.setattr(torch.compiler, "set_stance", lambda *args, **kwargs: None)
monkeypatch.setattr(hf_ptq, "launch_memory_monitor", lambda: None)
monkeypatch.setattr(hf_ptq, "ensure_local_checkpoint", lambda path: (path, str(tmp_path)))
monkeypatch.setattr(hf_ptq, "enable_determinism", seeds.append)
monkeypatch.setattr(hf_ptq, "load_model", load_model)

with pytest.raises(_LoadReachedError):
hf_ptq.main(args)

assert seeds == [hf_ptq.RAND_SEED]


def test_tensorrt_llm_export_copies_non_model_files_from_the_local_copy(monkeypatch, tmp_path):
"""The deprecated TensorRT-LLM exporter does not copy them itself; hf_ptq does, from disk."""
hf_ptq, args = _parse_hf_ptq_args(
Expand Down
Loading
Loading