Skip to content
Open
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
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ Changelog

- Add the ``nvfp4_act_headroom`` calibration algorithm for NVFP4 **activation** global scales. Plain ``max`` sets a tensor's global scale from the largest per-block amax seen during calibration, leaving no room above it, so any activation larger than the calibration max saturates. ``nvfp4_act_headroom`` instead anchors the global scale to a low percentile of the per-block amax distribution, ``amax = max(rho * anchor, upper)``, placing the calibrated blocks in the lower part of the FP8 block-scale range and leaving the rest as headroom. ``upper`` is the top of the range the scale commits to representing and defaults to the 99.99th percentile rather than the literal maximum: chasing a lone freak block would drag the global scale up until every other block's FP8 block scale falls below subnormal and flushes to zero, so the rarest blocks are clipped instead. Set ``upper_percentile=100`` to use the literal observed max, which guarantees no calibration data is clipped. The calibrator warns when the per-block range is too wide for ``rho`` to clear any headroom. Tunable via ``anchor_percentile`` (default 1), ``upper_percentile`` (default 99.99) and ``rho`` (default 16384). Applies only to NVFP4 dynamic-block input quantizers. Weight scales are an orthogonal axis, selected by a nested ``weight_scale_algorithm`` (``max`` by default, or ``mse`` / ``local_hessian`` with that algorithm's own options), so one recipe can combine a weight calibration with this activation policy in a single pass. ``SequentialQuantizer`` activation quantizers are not supported and raise. Ships ``modelopt_recipes/general/ptq/nvfp4_act_headroom-kv_fp8_cast.yaml``, which mirrors ``nvfp4_default-kv_fp8_cast`` (dynamic NVFP4 W4A4 plus FP8 KV-cache cast) with only the calibration algorithm swapped, so it exports a standard NVFP4 checkpoint.

*Misc*

- Add ``modelopt.torch.utils.mlflow.MlflowRunLogger`` for recording a script run on an MLflow tracking server: it uploads the invocation, the ModelOpt version, the run's log (captured by teeing ``stdout``/``stderr``) and any caller-supplied artifacts, with configuration as searchable params. ``mlflow`` is an optional dependency, imported only when tracking is enabled.
- Add ``--mlflow <tracking-uri>`` to ``examples/hf_ptq/hf_ptq.py``, and honour MLflow's own ``MLFLOW_TRACKING_URI`` so a shell can opt in without changing the command. A tracked run records the invocation, the resolved recipe (``$import``\ s expanded), the run log and the quantization summaries, with every command-line argument as a searchable param. The run is opened before the model loads, so an unusable server fails there rather than after calibration, and a failed run is still recorded with its traceback attached. The experiment defaults to ``$USER/hf_ptq/<checkpoint basename>-<recipe name or --qformat>`` and can be overridden with ``--mlflow_experiment`` / ``--mlflow_run_name``.

**Backward Breaking Changes**

**Deprecations**
Expand Down
56 changes: 56 additions & 0 deletions examples/hf_ptq/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ This section focuses on Post-training quantization, a technique that reduces mod
| Evaluate Accuracy | Evaluate your model's accuracy! | \[[Link](#evaluate-accuracy)\] | |
| Exporting Checkpoints | Export to Hugging Face Unified Checkpoint and deploy on TRT-LLM/vLLM/SGLang | \[[Link](#exporting-checkpoints)\] | \[[docs](https://nvidia.github.io/Model-Optimizer/deployment/3_unified_hf.html)\] |
| Pre-Quantized Checkpoints | Ready to deploy Hugging Face pre-quantized checkpoints | \[[Link](#pre-quantized-checkpoints)\] | |
| Tracking runs with MLflow | Record a PTQ run on an MLflow server so it can be reproduced from its entry alone | \[[Link](#tracking-runs-with-mlflow)\] | |
| Resources | Extra links to relevant resources | \[[Link](#resources)\] | |

</div>
Expand Down Expand Up @@ -638,6 +639,61 @@ 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!

## Tracking runs with MLflow

Set MLflow's own `MLFLOW_TRACKING_URI`, or pass `--mlflow <tracking-uri>`, to record a PTQ
run on an MLflow server so it can be reproduced later from its MLflow entry alone:

```bash
python hf_ptq.py \
--pyt_ckpt_path <huggingface_model_card> \
--recipe general/ptq/nvfp4_default-kv_fp8_cast \
--export_path <quantized_ckpt_path> \
--mlflow https://<your-mlflow-server>/
```

The run is opened *before* the model loads, so a bad URI or a missing token fails within
seconds rather than after a full calibration.

<details>
<summary>Uploaded artifacts</summary>

| Artifact | Contents |
| --- | --- |
| `command.txt` | The full invocation, copy-pasteable, with credentials masked |
| `version.txt` | The ModelOpt version that ran |
| `recipe/resolved_recipe.yaml` | The `--recipe` with its `$import`s expanded, so it stands alone |
| `logs/hf_ptq.log` | The run's Python stdout/stderr, including the traceback if it crashed |
| `summary/quant_summary.txt` | The per-quantizer summary (unless `--no-verbose`) |
| `summary/moe.html` | Per-expert calibration token counts, when the run produces them |

</details>

Every command-line argument is also logged as a searchable param, alongside
`user` / `hostname` / `modelopt_version` / `git_sha` tags. A run that fails is
still recorded, with status `FAILED` and its log attached.

Other flags:

- `--mlflow_experiment` — defaults to `$USER/hf_ptq/<checkpoint basename>-<recipe name>`,
falling back to `--qformat` when no `--recipe` is used.
- `--mlflow_run_name` — defaults to the UTC start time, `YYYYmmdd-HHMMSS`.
- `$MLFLOW_TRACKING_URI` enables tracking on its own; `--mlflow` overrides it. A URI taken
from the environment is best-effort — if the client is missing or the server is
unreachable the run warns and continues untracked, since the variable is often exported
for other tooling. An explicit `--mlflow` fails loudly instead.

Authentication uses MLflow's own environment variables (`MLFLOW_TRACKING_TOKEN`, or
`MLFLOW_TRACKING_USERNAME` / `MLFLOW_TRACKING_PASSWORD`).

The tracking itself lives in `modelopt.torch.utils.mlflow`
([`MlflowRunLogger`](../../modelopt/torch/utils/mlflow.py)), so other example scripts can
record runs the same way; `hf_ptq.py` only supplies the params and artifacts specific to PTQ.

> Note: only the main rank uploads, so `--use_fsdp2` runs produce a single run. The log
> captures Python output; output written directly by native libraries (NCCL, CUDA) goes to
> the terminal only. On SLURM, keep the job's own `.out` file for those.

## Resources

- 📅 [Roadmap](https://github.com/NVIDIA/Model-Optimizer/issues/1699)
Expand Down
135 changes: 135 additions & 0 deletions examples/hf_ptq/example_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.

import argparse
import copy
import glob
import hashlib
Expand All @@ -23,13 +24,15 @@
import shutil
import warnings
from collections.abc import Callable, Iterable
from contextlib import AbstractContextManager, nullcontext
from dataclasses import dataclass
from datetime import timedelta
from pathlib import Path
from typing import Any

import torch
import transformers
import yaml
from accelerate import infer_auto_device_map, init_empty_weights
from accelerate.utils import get_max_memory
from safetensors import safe_open
Expand All @@ -43,6 +46,7 @@
ProcessorMixin,
)

from modelopt.recipe import load_recipe
from modelopt.torch.export.model_utils import is_multimodal_model

try:
Expand All @@ -51,6 +55,11 @@
snapshot_download = None

from modelopt.torch.utils import distributed as dist_utils
from modelopt.torch.utils.mlflow import (
MlflowRunLogger,
default_experiment_name,
validate_tracking_uri,
)

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -1070,3 +1079,129 @@ def resolve_checkpoint_dir(quant_cfg: dict, model_path: str) -> tuple[dict, str]
if isinstance(algo.get("layerwise"), dict) and "checkpoint_dir" in algo["layerwise"]:
algo["layerwise"]["checkpoint_dir"] = resolved
return quant_cfg, resolved


def add_mlflow_args(parser: argparse.ArgumentParser) -> None:
"""Add the MLflow tracking flags."""
parser.add_argument(
"--mlflow",
default=None,
help=(
"Track this run on an MLflow server (e.g. https://<your-mlflow-server>/), "
"uploading the command, the resolved recipe, the run log and the quantization "
"summaries. MLflow's own $MLFLOW_TRACKING_URI enables tracking without this "
"flag, which overrides it. A URI taken from the environment is best-effort: if "
"it is unusable the run warns and continues untracked."
),
)
parser.add_argument(
"--mlflow_experiment",
default=None,
help=(
"MLflow experiment name. Default: "
"$USER/hf_ptq/<checkpoint basename>-<recipe name, or --qformat if no --recipe>."
),
)
parser.add_argument(
"--mlflow_run_name",
default=None,
help="MLflow run name. Default: the UTC start time as YYYYmmdd-HHMMSS.",
)


def resolve_mlflow_args(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
"""Settle where tracking is configured from, and name the experiment."""
# MLflow's own variable enables tracking on its own; --mlflow overrides it. Only the
# flag is a deliberate request, so only the flag is fatal when the URI is unusable: the
# variable is commonly exported for unrelated tooling and must not fail a quantization.
args.mlflow_required = args.mlflow is not None
args.mlflow = args.mlflow or os.environ.get("MLFLOW_TRACKING_URI") or None
if args.mlflow:
try:
args.mlflow = validate_tracking_uri(args.mlflow)
except ValueError as e:
if args.mlflow_required:
parser.error(f"--mlflow: {e}")
warnings.warn(f"Ignoring MLFLOW_TRACKING_URI, continuing untracked: {e}")
args.mlflow = None
else:
args.mlflow_experiment = args.mlflow_experiment or default_experiment_name(
"hf_ptq",
args.pyt_ckpt_path,
Path(args.recipe).stem if args.recipe else args.qformat,
)


_MLFLOW_NON_PARAM_ARGS = frozenset(
{"dist_state", "mlflow", "mlflow_experiment", "mlflow_required", "mlflow_run_name"}
)


def _mlflow_run_inputs(args: argparse.Namespace) -> tuple[dict, dict]:
"""Params and start-time artifacts describing this PTQ run."""
params = {k: v for k, v in vars(args).items() if k not in _MLFLOW_NON_PARAM_ARGS}
# dist_state is an object, so record the one field worth searching on.
params["world_size"] = args.dist_state.world_size
texts = {}
if args.recipe:
# The resolved recipe, not the source file: a recipe may be a directory or use
# $imports, and only the resolved form is self-contained.
resolved = load_recipe(args.recipe).model_dump(mode="json")
texts["recipe/resolved_recipe.yaml"] = yaml.safe_dump(resolved, sort_keys=False)
return params, texts


def _mlflow_logger(args: argparse.Namespace) -> MlflowRunLogger:
"""Build this run's logger; inert unless --mlflow was given and this is the main rank."""
return MlflowRunLogger(
args.mlflow,
args.mlflow_experiment,
run_name=args.mlflow_run_name,
enabled=bool(args.mlflow) and args.dist_state.is_main,
required=args.mlflow_required,
)


def mlflow_run(args: argparse.Namespace) -> AbstractContextManager:
"""Track this invocation for the duration of the block, or do nothing if untracked."""
logger = _mlflow_logger(args)
if not logger.enabled:
# Gathering the inputs re-reads the recipe, so keep it off the untracked path.
return nullcontext()
params, texts = _mlflow_run_inputs(args)
return logger.track(
params=params,
tags=_mlflow_run_tags(args),
texts=texts,
files=_mlflow_run_outputs(args),
)


def _mlflow_run_tags(args: argparse.Namespace) -> dict[str, str]:
"""Tags shared with the evaluation side, so a PTQ run and the evaluations of the
checkpoint it produced can be found together on one tracking server.

``checkpoint_path`` is the checkpoint this run *writes*, because that is what an
evaluation is later pointed at (NEL takes ``deployment.checkpoint_path``); the input is
kept separately. It is resolved because ``--export_path`` defaults to a relative path,
which is useless as a join key.
"""
return {
"model": Path(args.pyt_ckpt_path).name,
"checkpoint_path": str(Path(args.export_path).resolve()),
"source_checkpoint_path": args.pyt_ckpt_path,
}


def _mlflow_run_outputs(args: argparse.Namespace) -> dict[str, Path]:
"""Summaries written by post_quantize, keyed by artifact path.

Uploaded without the leading dot, which is awkward to browse in the MLflow UI. Missing
entries are skipped: the MoE table only exists for MoE models, and neither file is
written under ``--no-verbose``.
"""
export_path = Path(args.export_path)
return {
"summary/quant_summary.txt": export_path / ".quant_summary.txt",
"summary/moe.html": export_path / ".moe.html",
}
61 changes: 37 additions & 24 deletions examples/hf_ptq/hf_ptq.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from cast_mxfp4_to_nvfp4 import force_weight_quantizers_static
from example_utils import (
_resolve_model_path,
add_mlflow_args,
build_quant_cfg,
cleanup_distributed,
copy_custom_model_files,
Expand All @@ -39,9 +40,11 @@
is_enc_dec,
is_nemotron_vl,
load_mtp_weights,
mlflow_run,
mtp_layer_prefixes_from_checkpoint,
needs_checkpoint_path_update,
resolve_checkpoint_dir,
resolve_mlflow_args,
run_nemotron_vl_preview,
setup_distributed_args,
validate_fsdp2_supported,
Expand Down Expand Up @@ -1622,7 +1625,11 @@ def parse_args() -> argparse.Namespace:
),
)

add_mlflow_args(parser)

args = parser.parse_args()
resolve_mlflow_args(args, parser)

if args.moe_calib_experts_ratio is not None and not (0.0 < args.moe_calib_experts_ratio <= 1.0):
parser.error("--moe_calib_experts_ratio must be in the range (0.0, 1.0].")

Expand Down Expand Up @@ -1658,6 +1665,8 @@ def parse_args() -> argparse.Namespace:
return args


# Derived state and the tracking settings themselves; everything else argparse parsed is a
# parameter of the run. Deriving the list means a new flag is tracked without touching this.
def main(args: argparse.Namespace):
if not torch.cuda.is_available():
raise OSError("GPU is required for inference.")
Expand All @@ -1668,31 +1677,17 @@ def main(args: argparse.Namespace):
setup_distributed_args(args)

try:
# launch a memory monitor to read the currently used GPU memory.
launch_memory_monitor()
# 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
# until the NCCL timeout.
with mlflow_run(args):
# launch a memory monitor to read the currently used GPU memory.
launch_memory_monitor()

# Force eager execution for all model types.
torch.compiler.set_stance("force_eager")

(
full_model,
language_model,
model_type,
calibration_only,
processor,
tokenizer,
default_padding_side,
default_pad_token,
device,
) = load_model(args)
# Force eager execution for all model types.
torch.compiler.set_stance("force_eager")

if args.sparsity_fmt != "dense":
# Sparse
sparsity_main(args, full_model, tokenizer, device)
else:
# Quantize
quantize_main(
args,
(
full_model,
language_model,
model_type,
Expand All @@ -1702,7 +1697,25 @@ def main(args: argparse.Namespace):
default_padding_side,
default_pad_token,
device,
)
) = load_model(args)

if args.sparsity_fmt != "dense":
# Sparse
sparsity_main(args, full_model, tokenizer, device)
else:
# Quantize
quantize_main(
args,
full_model,
language_model,
model_type,
calibration_only,
processor,
tokenizer,
default_padding_side,
default_pad_token,
device,
)
finally:
cleanup_distributed(args)

Comment thread
cjluo-nv marked this conversation as resolved.
Expand Down
1 change: 1 addition & 0 deletions examples/hf_ptq/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
compressed-tensors
fire
flash-attn>=2.6.0
mlflow-skinny>=2.9
psutil
transformers_stream_generator
zstandard
Loading
Loading