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
3 changes: 3 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ Changelog

**New Features**

- 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``, which records a PTQ run through the above and additionally uploads the resolved recipe (``$import``\ s expanded) and the quantization summaries. The run is opened before the model loads so an unreachable server fails in seconds instead of after calibration, and a failed run is still recorded with its log 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
52 changes: 52 additions & 0 deletions examples/hf_ptq/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,58 @@ scripts/huggingface_example.sh --model <model> --quant nvfp4 --vlm --calib_with_
> Note: when `--calib_with_images` is set, `--calib_size` must be a single value, and the calibration dataset is nvidia/nemotron_vlm_dataset_v2.
This functionality is currently in beta and has been tested on `nvidia/NVIDIA-Nemotron-Nano-12B-v2-VL-BF16`.

### Tracking runs with MLflow

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.

Should we move this to the end? I feel this is lot of details and come before feature examples like AutoQuantize


Pass `--mlflow <tracking-uri>` to record a PTQ run on an MLflow server, so the run 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 |

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.

can we hide this table by default to make the readme visualization shorter.

| --- | --- |
| `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`.
- Passing `--mlflow` with no value uses `$MLFLOW_TRACKING_URI`.

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.

### Megatron-Bridge Example Script

Please refer to [examples/megatron_bridge/README.md](../megatron_bridge/README.md) for example scripts for PTQ / QAD with Megatron-Bridge which is generally more performant than the Hugging Face scripts.
Expand Down
159 changes: 135 additions & 24 deletions examples/hf_ptq/hf_ptq.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@
import random
import time
import warnings
from contextlib import AbstractContextManager, nullcontext
from pathlib import Path
from typing import Any

import numpy as np
import torch
import yaml
from accelerate.hooks import remove_hook_from_module
from cast_mxfp4_to_nvfp4 import apply_to_model as apply_cast_mxfp4_to_nvfp4
from cast_mxfp4_to_nvfp4 import force_weight_quantizers_static
Expand Down Expand Up @@ -88,6 +90,11 @@
get_supported_datasets,
)
from modelopt.torch.utils.memory_monitor import launch_memory_monitor
from modelopt.torch.utils.mlflow import (
MlflowRunLogger,
default_experiment_name,
validate_tracking_uri,
)
from modelopt.torch.utils.plugins.model_load_utils import parallel_load_and_prepare_fsdp2
from modelopt.torch.utils.speech_dataset_utils import get_speech_dataset_dataloader
from modelopt.torch.utils.vlm_dataset_utils import get_vlm_dataset_dataloader
Expand Down Expand Up @@ -1622,7 +1629,43 @@ def parse_args() -> argparse.Namespace:
),
)

parser.add_argument(
"--mlflow",
nargs="?",
const=os.environ.get("MLFLOW_TRACKING_URI", ""),
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. Pass the flag without a value to use $MLFLOW_TRACKING_URI."
),
)
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.",
)

args = parser.parse_args()
if args.mlflow is not None:
try:
args.mlflow = validate_tracking_uri(args.mlflow)
except ValueError as e:
parser.error(f"--mlflow: {e}")
Comment on lines +1658 to +1662

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.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
python - <<'PY'
from urllib.parse import urlparse

uri = "https://user:token@mlflow.example.com"
parsed = urlparse(uri)
assert parsed.netloc
assert parsed.username == "user"
assert parsed.password == "token"
print("Credential-bearing URI is accepted by urlparse.")
PY

Repository: NVIDIA/Model-Optimizer

Length of output: 208


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the validation helper and the MLflow code paths.
rg -n "validate_tracking_uri|mlflow|sys\.argv|tracking_uri|set_tracking_uri|start_run|log_params|log_artifact|log_text" examples/hf_ptq/hf_ptq.py

# Show the relevant sections around the validation and logging.
sed -n '1600,1835p' examples/hf_ptq/hf_ptq.py

Repository: NVIDIA/Model-Optimizer

Length of output: 10323


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the helper definition if it lives elsewhere in the repo.
rg -n "def validate_tracking_uri|validate_tracking_uri\(" -S .

Repository: NVIDIA/Model-Optimizer

Length of output: 700


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the helper and the MLflow setup/logging block.
FILE=examples/hf_ptq/hf_ptq.py

# Locate the helper definition range.
nl -ba "$FILE" | sed -n '1,220p' | rg -n "validate_tracking_uri|def main|mlflow"

# Print the helper definition and the MLflow setup region.
nl -ba "$FILE" | sed -n '1,260p'
nl -ba "$FILE" | sed -n '1760,1835p'

Repository: NVIDIA/Model-Optimizer

Length of output: 196


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE=modelopt/torch/utils/mlflow.py

# Show the validator and any related tests.
sed -n '1,180p' "$FILE"
printf '\n--- tests ---\n'
sed -n '90,150p' tests/unit/torch/utils/test_mlflow.py

Repository: NVIDIA/Model-Optimizer

Length of output: 8851


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the MLflow logger implementation for any logging/serialization of the tracking URI.
sed -n '1,260p' modelopt/torch/utils/mlflow.py

Repository: NVIDIA/Model-Optimizer

Length of output: 10183


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,180p' modelopt/torch/utils/mlflow.py
printf '\n--- tests ---\n'
sed -n '90,150p' tests/unit/torch/utils/test_mlflow.py

Repository: NVIDIA/Model-Optimizer

Length of output: 8851


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find every use of run_url / tracking_uri in the MLflow helper.
rg -n "run_url|tracking_uri|command.txt|sys\.argv|log_artifact|log_text|texts\[|params\[|artifact" modelopt/torch/utils/mlflow.py

# Print the start/finish methods and any code that writes command or URL artifacts.
sed -n '180,360p' modelopt/torch/utils/mlflow.py

Repository: NVIDIA/Model-Optimizer

Length of output: 9836


Reject credentials in MLflow tracking URIs. https://user:token@host passes validate_tracking_uri(), then gets echoed in [mlflow] run: ... and captured in the uploaded run log. Strip or reject userinfo before enabling --mlflow; use environment-based auth instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/hf_ptq/hf_ptq.py` around lines 1657 - 1661, Update the --mlflow
validation flow around validate_tracking_uri to reject tracking URIs containing
userinfo credentials such as username or password before assigning the validated
URI to args.mlflow. Raise a ValueError so parser.error reports the invalid
option, while preserving valid credential-free URI handling and directing
authentication through environment-based configuration.

Source: Path instructions

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,
)

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 +1701,70 @@ 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.
_MLFLOW_NON_PARAM_ARGS = frozenset({"dist_state", "mlflow", "mlflow_experiment", "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,
)


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."""
return {"model": Path(args.pyt_ckpt_path).name, "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",
}


def main(args: argparse.Namespace):
if not torch.cuda.is_available():
raise OSError("GPU is required for inference.")
Expand All @@ -1668,31 +1775,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")
# 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)

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 +1795,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,5 +1,6 @@
compressed-tensors
fire
flash-attn>=2.6.0
mlflow-skinny>=2.9
transformers_stream_generator
zstandard
36 changes: 36 additions & 0 deletions modelopt/torch/utils/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@

__all__ = [
"DeprecatedError",
"TeeStream",
"atomic_print",
"capture_io",
"no_stdout",
Expand Down Expand Up @@ -219,5 +220,40 @@ def custom_showwarning(message, category, filename, lineno, file=None, line=None
warnings.showwarning = original_showwarning


class TeeStream:

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.

This feels like an agent shortcut hack to me. I'd much rather just replace print() in hf_ptq.py with a proper logger. That would give us all of the benefits of logging library over print including the stream redirection you want to do for mlflow.

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.

Fair challenge, and I agree logging would be better for hf_ptq on its own merits. Two reasons I would rather not fold it into this PR:

It would not remove the tee. tqdm writes progress bars straight to sys.stderr, and transformers / huggingface_hub both print and log. A logging.FileHandler captures neither. The tee — plus the pre-bound-handler redirect in _redirect_log_handlers — is what got huggingface_hub's rate-limit warnings into the captured log at all; before that they reached the console and vanished. So a logging conversion would sit alongside this, not replace it.

Scope. 34 print() calls in hf_ptq.py, 86 across examples/hf_ptq/, in a PR already around +1400.

Happy to do the conversion as a follow-up PR — it improves hf_ptq independently of tracking. Let me know if you would rather it block this one.

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.

Follow-up: the "restore is incomplete" half of this is now fixed in 9f15e9e.

Teardown rescans for handlers pointing at the tee rather than replaying the list captured on the way in, so a handler bound by a library during the run is handed back too — previously those were left pointing at a file about to close, which is why TeeStream needed to tolerate a closed sink at all. The bookkeeping list is gone.

That change immediately exposed a bug of its own: logging._StderrHandler exposes stream as a read-only property that already resolves to whatever sys.stderr currently is, so setStream() raises on it. The teardown scan runs while sys.stderr is the tee, so it hits that handler where the start-time scan did not. It is now skipped explicitly — correct on the merits, since that handler follows the tee unaided.

On the blast-radius concern more broadly, I checked the property rather than assuming it: with a capture active, a child process's output is not captured and the child runs normally, while our prints and library logs both are. The tee acts on Python objects; subprocesses inherit file descriptors. Under torchrun each rank is its own process and only rank 0 enables any of this — so nothing outside the process is affected, and in-process state is restored on the way out.

The logging conversion is still on offer as a follow-up if you would like it.

"""Mirror a text stream to *sink* while passing writes through to *stream*.

Scripts that report progress with bare ``print()`` have no log file; wrapping
``sys.stdout``/``sys.stderr`` in this is what produces one. Attribute access falls
through to the wrapped stream so ``isatty()`` keeps progress bars behaving. Native
(C-level) writes go straight to the real file descriptor and are *not* captured.
"""

def __init__(self, stream, sink):
"""Wrap *stream*, mirroring everything written to it into the open file *sink*."""
self._stream = stream
self._sink = sink

def write(self, data: str) -> int:
"""Write to both the original stream and the sink."""
self._stream.write(data)
if not self._sink.closed:
self._sink.write(data)
return len(data)

def flush(self) -> None:
"""Flush both the original stream and the sink."""
self._stream.flush()
if not self._sink.closed:
self._sink.flush()

def __getattr__(self, name):
# Guard the wrapped attributes themselves: __getattr__ runs whenever they are absent
# (during unpickling, or on a copy), and delegating then would recurse forever.
if name in ("_stream", "_sink"):
raise AttributeError(name)
return getattr(self._stream, name)


class DeprecatedError(NotImplementedError):
"""Error for deprecated functions."""
Loading
Loading