Files
Keval MorabiaandClaude Opus 5 0058a15537 [2/2] Track every Megatron-Bridge script with MLflow (#2514)
### What does this PR do?

Type of change: new feature

**[2/2] of a split. Based on #2544 — merge that first; this PR's diff is
only the Megatron-Bridge half.**

#2477 added MLflow tracking to `examples/megatron_bridge/quantize.py`.
It was one of five scripts in that directory that write a checkpoint;
the other four recorded nothing, so the provenance chain stopped at the
PTQ checkpoint and a deployed model could not be traced back to the run
that produced it.

All five now take the same `--mlflow` / `--mlflow_experiment` /
`--mlflow_run_name` flags, and **each declares what it records as a
`Tool` beside its own flags** — the shared `mlflow_utils.py` knows none
of them:

| Script | Records |
| --- | --- |
| `prune_minitron.py` | command, arguments, log, `prune_score` metric,
pointer |
| `quantize.py` (#2477, moved onto the shared `Tool` in #2544) | +
resolved recipe, quantizer summary |
| `distill.py` | + Megatron-Bridge's per-iteration metrics and resolved
config |
| `export_quantized_megatron_to_hf.py` | command, arguments, log,
pointer |
| `export_distilled_megatron_to_hf.py` | same, one pointer per exported
checkpoint |

Each writes `.experiment.json` into the checkpoint it produced, and each
tags what it consumed, so `prune → quantize → distill → export` is
walkable both from disk and by tag query on the server.

**`distill.py` opens the run and Megatron-Bridge joins it.** Its
`LoggerConfig` records per-iteration metrics and the full resolved
config — which a wrapper around `main()` cannot see — but nothing of
`distill.py`'s own arguments and no invocation. Megatron-Bridge takes
`mlflow.active_run()` when one exists, applies the tags and logs into
it, so `distill_run()` opens the run on the rank Megatron-Bridge looks
at (the **last** one) and the two share it. Its early exit is handled
explicitly: `train()` leaves through `sys.exit(0)` on `--exit_interval`,
which a blanket handler would record as `FAILED`.

**The library pieces that exist for that shared run land here with their
first caller**, rather than in [1/2] where they would have none:
`split_tracking_credentials`, so a URI handed to something which
*records* it carries no credential; `log_active_run_experiment_json`,
for pointing a checkpoint at a run this process did not open; and
`MlflowRunLogger._reattach`, because a co-owner can end the run first —
Megatron-Bridge does, as `KILLED`, when SIGTERM arrives mid-training.

Two of Megatron-Bridge's defaults are deliberately not inherited:
**checkpoint artifact upload stays off** unless
`--mlflow_log_checkpoints` (it pushes the whole checkpoint over HTTP
after every save), and **an untracked run passes no `mlflow_*` fields at
all**, since they landed in Megatron-Bridge 0.6 and sending them
unconditionally would break an untracked run on an older one.

### Usage

```bash
# Any of the five, same flags:
torchrun --nproc_per_node 8 prune_minitron.py  ... --mlflow https://<server>/
torchrun --nproc_per_node 8 quantize.py        ... --mlflow https://<server>/
torchrun --nproc_per_node 8 distill.py         ... --mlflow https://<server>/
torchrun --nproc_per_node 8 export_quantized_megatron_to_hf.py ... --mlflow https://<server>/

# Each checkpoint names the run that wrote it:
cat /output/qad/checkpoints/.experiment.json
```

Experiments default to
`$USER/megatron_bridge_{prune,quantize,distill,export,distill_export}/<model
basename>-<variant>`.

### Testing

- Real runs on a toy Qwen3 in one MLflow experiment covering all five
Megatron-Bridge scripts and `hf_ptq` — prune, quantize, QAD
distillation, quantized export, BF16 distillation, distilled export, HF
PTQ — each closing `FINISHED` with the invocation, its arguments as
params, its log, and a matching `.experiment.json` on disk. The chain
tags line up: each stage's `source_checkpoint_path` is the previous
stage's `checkpoint_path`.
- `tests/examples/megatron_bridge` in `nvcr.io/nvidia/nemo:26.08`, the
only lane that runs it: **76 passed**. Plus the three suites from #2544:
**195 pass**.
- `pre-commit run --files <changed>`: all hooks pass.
- Each fix from the review rounds has a test that fails with the fix
reverted: the resumed run, the foreign active run, the percent-decoded
credential, the credential that cannot be moved, the rank-dependent
`LoggerConfig`, the exit-callback guard, and the `iter_*` join.

### Before your PR is "*Ready for review*"

- Is this change backward compatible?: ✅
- If you copied code from any other sources or added a new PIP
dependency, did you follow guidance in `CONTRIBUTING.md`: N/A
- Did you write any new necessary tests?: ✅
- Did you update
[Changelog](https://github.com/NVIDIA/Model-Optimizer/blob/main/CHANGELOG.rst)?:
✅
- Did you get Claude approval on this PR?: several rounds; re-requested
on this head.

### Additional Information

Split from a single ~1150-line PR at review's request; #2544 carries the
library consolidation this builds on, and this branch is based on it.
Earlier review threads here show as outdated after the rebases — they
are all resolved and their fixes are in this branch.

One known gap, stated in the README rather than implied: `distill.py
--hf_export_path` writes a second HuggingFace checkpoint from rank 0,
which is not the rank that owns the run, so it carries no pointer yet.
For the same reason the uploaded `logs/distill.log` holds the last
rank's output — `print_rank_0` keeps the script's own lines on rank 0 —
which the README now says outright; carrying rank 0's log into a run
owned by another rank needs cross-rank upload and is a follow-up.

Two defects found on shared-run paths during review, both verified
against the installed Megatron-Bridge 0.6 rather than its docs.
Megatron-Bridge ends the run it shares with `distill.py` as `KILLED`
from its SIGTERM handler (`train.py:1413`) and then leaves through
`sys.exit()` (`train.py:805`), i.e. before `distill_run`'s `finally` —
and MLflow's fluent calls resolve their target by *opening* a run when
none is active, so a preempted distillation's log and metrics went to a
second, empty run and its `KILLED` status was overwritten. Separately,
an unreachable server disabled our logger but `logger_kwargs` still
handed Megatron-Bridge the same URI, and `state.py` calls
`set_experiment` unguarded from inside the training loop — so a
best-effort `$MLFLOW_TRACKING_URI` aborted the training instead of
degrading to untracked.

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

---------

Signed-off-by: Keval Morabia <28916987+kevalmorabia97@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 21:29:25 +00:00

1096 lines
48 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.
"""Record a script run on an MLflow tracking server.
Lets an example script upload its invocation, configuration, log and outputs so the run can
be reproduced from its MLflow entry alone. ``mlflow`` is an optional dependency, imported
only once tracking is actually enabled.
"""
import argparse
import contextlib
import getpass
import json
import logging
import os
import re
import shlex
import shutil
import socket
import sys
import tempfile
import time
import traceback
import warnings
from collections.abc import Callable, Iterator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
from typing import Any
from urllib.parse import unquote, urlparse
import yaml
import modelopt
from modelopt.torch.utils.logging import TeeStream
__all__ = [
"EXPERIMENT_JSON",
"TRACKING_URI_ENV",
"MlflowRunLogger",
"Tool",
"add_mlflow_args",
"command_text",
"current_user",
"default_experiment_name",
"default_run_name",
"describe_run",
"drop_experiment_json",
"log_active_run_experiment_json",
"mask_tracking_uri",
"masked_args",
"resolve_mlflow_args",
"resolve_tracking_uri",
"resolved_recipe_texts",
"run_tags",
"split_tracking_credentials",
"tracked_run",
"validate_tracking_uri",
]
# MLflow experiment names are stored in a VARCHAR(256) column by the SQL-backed stores. The
# per-component cap stops one pathological component from crowding out the others; the name
# cap is what actually keeps the result storable.
_MAX_COMPONENT_LEN = 100
_MAX_NAME_LEN = 250
_UNSAFE_CHARS = re.compile(r"[^A-Za-z0-9._-]+")
# Anything uploaded or printed passes through _redact first: a tracking URI may carry
# ``user:token@`` and a caller's own flags may carry a secret.
_SECRET_NAME = re.compile(r"token|api[-_]?key|password|passwd|secret|credential", re.IGNORECASE)
_URI_USERINFO = re.compile(r"(?<=://)[^/\s@]+(?=@)")
_MASK = "***"
# Provenance pointer a checkpoint carries, dotted like the other sidecars a quantization
# drops next to the weights so a checkpoint loader ignores it and it does not look like part
# of the model.
EXPERIMENT_JSON = ".experiment.json"
# MLflow's own variable, so a shell that already exports it opts in without a flag. Public
# because the vLLM example republishes the resolved URI under it for its worker processes.
TRACKING_URI_ENV = "MLFLOW_TRACKING_URI"
def _experiment_json(
tracking_uri: str, experiment_name: str, info: Any, run_name: str | None = None
) -> dict[str, str]:
"""The provenance record's fields, read off the run the server returned.
Shared by the two writers -- a run this process opened, and one it merely found.
"""
uri = _redact(tracking_uri).rstrip("/")
experiment_id = str(info.experiment_id)
run_id = str(info.run_id)
return {
"tracking_uri": uri,
"experiment_name": experiment_name,
"experiment_id": experiment_id,
"run_id": run_id,
"run_name": getattr(info, "run_name", None) or run_name or "",
"run_url": f"{uri}/#/experiments/{experiment_id}/runs/{run_id}",
}
def _stat_key(path: Path) -> tuple[int, int] | None:
"""Identity of a file's contents-in-time, or ``None`` when it does not exist."""
try:
stat = path.stat()
except OSError:
return None
return (stat.st_mtime_ns, stat.st_size)
def _redact(value: Any) -> Any:
"""Mask credentials embedded in a URI, leaving non-strings untouched."""
return _URI_USERINFO.sub(_MASK, value) if isinstance(value, str) else value
def _redact_argv(argv: list[str]) -> list[str]:
"""Mask the value of any ``--*token*`` style option, and credentials in any URI."""
redacted: list[str] = []
mask_next = False
for token in argv:
if mask_next:
# Unconditionally, since a secret may itself start with "-"; an option there
# instead would mean the caller passed no value, which argparse rejects anyway.
redacted.append(_MASK)
elif token.startswith("-") and _SECRET_NAME.search(token):
option, sep, _ = token.partition("=")
redacted.append(option + sep + _MASK if sep else option)
else:
redacted.append(_redact(token))
mask_next = (
token.startswith("-") and _SECRET_NAME.search(token) is not None and "=" not in token
)
return redacted
def validate_tracking_uri(uri: str) -> str:
"""Validate an MLflow tracking URI and return it without a trailing slash.
Only ``http(s)`` servers are accepted; MLflow's local ``file:`` / ``sqlite:`` backends
are not a useful destination for a shared record of a run.
Raises:
ValueError: If *uri* is empty, has no host, or is not an http(s) URL.
"""
if not uri:
raise ValueError(
"MLflow tracking URI is empty; pass one explicitly or set MLFLOW_TRACKING_URI."
)
parsed = urlparse(uri)
if parsed.scheme not in ("http", "https"):
message = f"MLflow tracking URI must be http(s), got {uri!r}."
if not parsed.scheme:
# Only a bare host is plausibly a forgotten scheme; suggesting https://sqlite:///...
# for a URI that already has one would be nonsense.
message += f" Did you mean https://{uri.lstrip('/')}?"
raise ValueError(message)
if not parsed.netloc:
raise ValueError(f"MLflow tracking URI {uri!r} has no host.")
return uri.rstrip("/")
def default_experiment_name(tool: str, model: str, variant: str, user: str | None = None) -> str:
"""Build an experiment name of the form ``<user>/<tool>/<model>-<variant>``.
Only the basename of *model* is used, so a local checkpoint directory and an
``org/name`` Hugging Face id collapse to the same readable name; *variant* is whatever
distinguishes this run of *tool* on *model*, such as a recipe name or a quantization
format. Each component is reduced to ``[A-Za-z0-9._-]`` so the ``/`` separators stay
meaningful, and *user* defaults to the current user.
Example:
>>> default_experiment_name("hf_ptq", "/models/Qwen3-0.6B/", "nvfp4", user="alice")
'alice/hf_ptq/Qwen3-0.6B-nvfp4'
"""
owner = user if user is not None else current_user()
name = (
f"{_sanitize(owner)}/{_sanitize(tool)}/{_sanitize(Path(model).name)}-{_sanitize(variant)}"
)
return name[:_MAX_NAME_LEN]
def default_run_name() -> str:
"""The UTC start time as ``YYYYmmdd-HHMMSS``, which is what the flags document.
Used by :class:`MlflowRunLogger` and by a caller handing the name to something else that
opens the run, so both honour the documented default rather than MLflow's random one.
"""
return datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
def current_user() -> str:
"""Return the current username, or ``"unknown"`` if the uid has no passwd entry."""
try:
return getpass.getuser()
except OSError: # container without a passwd entry for the uid
return "unknown"
def _sanitize(component: str) -> str:
"""Reduce one experiment-name component to ``[A-Za-z0-9._-]``."""
cleaned = _UNSAFE_CHARS.sub("_", component).strip("._-")
return cleaned[:_MAX_COMPONENT_LEN] or "unknown"
def _git_sha() -> str:
"""Short commit of the ModelOpt source, or ``"unknown"`` outside a git checkout.
Read out of ``.git`` rather than by shelling out to ``git``, which keeps the library
free of subprocess use. Handles worktrees, where ``.git`` is a file pointing at the
real git directory and refs live in the main checkout alongside it.
"""
try:
git_path = Path(__file__).resolve().parents[3] / ".git"
if git_path.is_file():
git_dir = Path(git_path.read_text().split("gitdir:", 1)[1].strip())
else:
git_dir = git_path
head = (git_dir / "HEAD").read_text().strip()
if not head.startswith("ref: "):
return head[:9] # detached HEAD
ref = head.removeprefix("ref: ")
# A worktree keeps HEAD locally but shares refs with the checkout named by commondir.
bases = [git_dir]
commondir = git_dir / "commondir"
if commondir.is_file():
bases.append((git_dir / commondir.read_text().strip()).resolve())
for base in bases:
if (base / ref).is_file():
return (base / ref).read_text().strip()[:9]
packed = base / "packed-refs"
if packed.is_file():
for line in packed.read_text().splitlines():
sha, _, name = line.partition(" ")
if name.strip() == ref:
return sha[:9]
except (OSError, IndexError):
pass
return "unknown"
def command_text(argv: list[str] | None = None) -> str:
"""The invocation, as a copy-pasteable line, with credentials masked.
*argv* defaults to this process's own ``sys.argv``. Pass another process's argv when the
run is opened somewhere the user never typed a command -- a worker subprocess, whose own
``sys.argv`` is an implementation detail rather than a reproducible invocation.
"""
lines = [shlex.join([sys.executable, *_redact_argv(sys.argv if argv is None else argv)])]
world_size = int(os.environ.get("WORLD_SIZE", "1"))
if world_size > 1:
lines += [
"",
f"# Launched under torchrun with WORLD_SIZE={world_size}, "
f"LOCAL_WORLD_SIZE={os.environ.get('LOCAL_WORLD_SIZE', '?')}. The torchrun "
"wrapper is not part of sys.argv and is therefore not shown above.",
]
return "\n".join(lines) + "\n"
def _ask(what: str, callback: Callable[[], Any], default: Any) -> Any:
"""Read what a run reports about itself, never at the cost of the caller's exception."""
try:
return callback()
except Exception as e:
print(f"[mlflow] WARNING: could not read this run's {what}: {e}")
return default
@contextmanager
def _closing_run(finish: Callable[[str], None]) -> Iterator[None]:
"""Run the block, then *finish* the run with the status the block earned."""
status = "FAILED"
try:
yield
status = "FINISHED"
except SystemExit as e:
# A script that ends by exiting -- Megatron-Bridge does, from inside its training
# loop -- finished if it exited cleanly.
status = "FINISHED" if e.code in (0, None) else "FAILED"
raise
finally:
finish(status)
class MlflowRunLogger:
"""Record one script invocation as an MLflow run.
:meth:`start` opens the run *before* the expensive work begins, so a bad URI, a missing
token or an unreachable server fails there rather than after hours; it also
uploads the invocation and any configuration passed to it, which keeps a crashed run
useful. :meth:`finish` uploads the captured log plus any outputs and closes the run.
Everything is a no-op when ``enabled`` is false, so callers need no branching.
While the run is open, ``stdout``/``stderr`` are teed to a file that is uploaded as
``logs/<script>.log``. Logging handlers that libraries bound to ``sys.stderr`` at import
time are re-pointed at the tee for the duration and handed back afterwards.
Failures after the run is open are reported as warnings and never raised: losing a
tracking server must not turn a successful job into a failed one.
Note:
``command.txt`` masks ``--*token*``-style option values and credentials embedded in
a URI, but the captured log is whatever the script printed, so a secret echoed to
stdout still reaches the server. Prefer passing credentials via the environment.
*tracking_uri* must already be validated (see :func:`validate_tracking_uri`),
*experiment_name* is created if absent, *run_name* defaults to the UTC start time
``YYYYmmdd-HHMMSS``, and ``enabled=False`` makes every method a no-op -- which is how
callers skip non-main ranks or an absent flag. ``required=False`` additionally downgrades
a failure to open the run into a warning: use it when tracking was inferred from the
environment rather than asked for, so an uninstalled client or an unreachable server
cannot take the job down with it.
Example:
>>> logger = MlflowRunLogger(uri, "alice/hf_ptq/Qwen3-0.6B-nvfp4")
>>> logger.start(params={"model": ckpt}, texts={"config.yaml": config_yaml})
>>> status = "FAILED"
>>> try:
... quantize_and_export()
... status = "FINISHED"
... finally:
... logger.finish(status, files={"summary/report.txt": report_path})
"""
def __init__(
self,
tracking_uri: str,
experiment_name: str,
run_name: str | None = None,
enabled: bool = True,
required: bool = True,
):
"""Configure the run without contacting the server; see the class docstring."""
self.tracking_uri = tracking_uri
self.experiment_name = experiment_name
self.run_name = run_name
self.enabled = enabled
self.required = required
self._mlflow: Any = None
self._run: Any = None
self._log_path: Path | None = None
self._saved_streams: tuple | None = None
self._tees: tuple | None = None
self._file_stats: dict[str, tuple[int, int] | None] = {}
self._start_time = 0.0
# The status a co-owner closed this run with, if one did; see _reattach.
self._closed_as: str | None = None
@property
def run_url(self) -> str:
"""Link to this run in the MLflow UI, or ``""`` before the run is open."""
return self.run_info.get("run_url", "")
@property
def run_info(self) -> dict[str, str]:
"""Identity of this run on the server, or ``{}`` before it is open.
Enough for a consumer holding only this run's outputs to find it again: ``run_id``
is MLflow's own identifier for the run, a uuid4 hex, unique across experiments.
Every field is read back off the run the server returned rather than off what was
requested, so a run MLflow resolved differently is reported as it really is.
"""
if self._run is None:
return {}
return _experiment_json(
self.tracking_uri, self.experiment_name, self._run.info, self.run_name
)
def start(
self,
params: dict[str, Any] | None = None,
tags: dict[str, Any] | None = None,
texts: dict[str, str] | None = None,
files: Mapping[str, Path | str] | None = None,
) -> None:
"""Open the run: capture output, verify the server, upload the inputs.
*params* are searchable; *tags* merge over the defaults (user, hostname, ModelOpt
version and commit); *texts* maps artifact path to content, uploaded here rather
than at the end so it survives a crash. *files* names the outputs the run is
expected to produce, so :meth:`finish` can tell them from files that were already
there -- pass the same mapping to both.
Opening the run is the readiness check: it is MLflow's own first request, so it
honours the client's TLS and retry configuration rather than second-guessing it.
Set ``MLFLOW_HTTP_REQUEST_MAX_RETRIES`` to shorten the wait on a dead host.
Raises:
ImportError: If ``mlflow`` is not installed and ``required``.
Exception: Whatever MLflow raises for an unusable server, if ``required``.
"""
if not self.enabled or self._run is not None:
return
self._start_time = time.time()
# Keyed through Path on both sides: a caller may pass strings, and "./out/x" and
# "out/x" are the same file but not the same string.
self._file_stats = {str(p): _stat_key(p) for p in map(Path, (files or {}).values())}
self._start_capture()
try:
self._open_run()
self._log_inputs(params, tags, texts)
except Exception as e:
# start_run() may already have succeeded, and the caller gets an exception
# rather than a logger to call finish() on, so close the run here.
self._abort_run()
self._stop_capture()
if self.required:
raise
self.enabled = False
print(f"[mlflow] WARNING: tracking disabled, continuing without it ({e})")
@contextmanager
def track(
self,
params: dict[str, Any] | None = None,
tags: dict[str, Any] | None = None,
texts: dict[str, str] | None = None,
files: Mapping[str, Path | str] | None = None,
metrics: dict[str, float] | None = None,
) -> Iterator["MlflowRunLogger"]:
"""Open the run for the duration of the block, closing it with the right status.
Mirrors ``mlflow.start_run()``. *files* and *metrics* are uploaded when the block
exits; naming the paths upfront is fine because only files this run actually wrote
are uploaded (see :meth:`finish`).
Example:
>>> with logger.track(params={"model": ckpt}, files={"summary.txt": report}):
... quantize_and_export()
"""
self.start(params=params, tags=tags, texts=texts, files=files)
with _closing_run(lambda status: self.finish(status, files=files, metrics=metrics)):
yield self
def log_text(self, artifact_path: str, text: str) -> None:
"""Upload *text* as an artifact while the run is open, best-effort.
For a value that is only settled midway through the run and is worth having even if
the run later crashes -- the quantization config a calibration is about to apply,
say. :meth:`start` and :meth:`finish` cover everything known at the two ends.
"""
if not self.enabled or self._run is None:
return
try:
if self._reattach():
self._log_texts({artifact_path: text})
except Exception as e:
print(f"[mlflow] WARNING: could not upload {artifact_path}: {e}")
def log_experiment_json(self, checkpoint_dir: Path | str | None = None) -> None:
"""Record which MLflow run produced a checkpoint, on the server and in the checkpoint.
Tags point from a run to the checkpoint it wrote; this is the reverse, so a checkpoint
found on disk can be traced back to the run that produced it without searching the
server. The artifact goes up for any run that opened, so a failure is traceable from
the server side too.
*checkpoint_dir* also writes the JSON there as :data:`EXPERIMENT_JSON`. Pass it only
once the checkpoint is really on disk, since the file claims authorship of the weights
sitting next to it: an output directory existing proves nothing, as it may hold a
checkpoint from an earlier attempt whose weights this run never touched.
After the checkpoint is written, the pointer beside it is this run's or absent --
never a previous run's. So a run that never opened *removes* the pointer rather than
leaving one: tracking can disable itself mid-flight (an unreachable server or an
uninstalled client, which a URI inherited from the environment tolerates by design),
and the caller's untracked cleanup was skipped because tracking looked configured.
"""
info = self.run_info
if not info:
if checkpoint_dir is not None:
drop_experiment_json(checkpoint_dir)
return
text = json.dumps(info, indent=2) + "\n"
self.log_text(EXPERIMENT_JSON.removeprefix("."), text)
if checkpoint_dir is None:
return
target = Path(checkpoint_dir) / EXPERIMENT_JSON
try:
target.write_text(text)
except OSError as e:
print(f"[mlflow] WARNING: could not write {target}: {e}")
def _abort_run(self) -> None:
"""End a run that failed before :meth:`start` returned, so it is not left RUNNING."""
if self._run is None:
return
try:
self._mlflow.end_run(status="FAILED")
except Exception as e:
print(f"[mlflow] WARNING: could not close the interrupted run: {e}")
self._run = None
def finish(
self,
status: str,
texts: dict[str, str] | None = None,
files: Mapping[str, Path | str] | None = None,
metrics: dict[str, float] | None = None,
) -> None:
"""Upload the run's outputs and close it with *status*, e.g. ``"FINISHED"``.
*texts* and *files* both map artifact path to content, from memory and from disk
respectively. A *files* entry is skipped when its file is absent, or was last
modified before the run started -- so callers can list optional outputs, and a run
that produced none of them does not upload a previous run's leftovers.
*metrics* merges over the default ``total_time_s``.
"""
if not self.enabled or self._run is None:
self._stop_capture()
return
if status != "FINISHED":
self._note_active_exception()
ours = False
try:
ours = self._reattach()
if ours:
self._log_outputs(texts, files, metrics)
except Exception as e:
print(f"[mlflow] WARNING: could not upload run outputs: {e}")
self._stop_capture()
# A co-owner's status is kept only when it reports trouble this block cannot see --
# Megatron-Bridge ends the run as KILLED on SIGTERM -- so a clean close elsewhere
# never masks a failure here, and a non-terminal status never reaches end_run.
if self._closed_as in ("FAILED", "KILLED"):
status = self._closed_as
try:
if ours:
self._mlflow.end_run(status=status)
else:
# Another run owns the fluent slot, so close this one by id rather than leave
# it RUNNING; MLflow's atexit only terminates whatever is active.
from mlflow.tracking import MlflowClient
MlflowClient().set_terminated(self._run.info.run_id, status=status)
print(f"[mlflow] {status}: {self.run_url}")
except Exception as e:
print(f"[mlflow] WARNING: could not close the run: {e}")
def _reattach(self) -> bool:
"""Make this run the fluent API's target again, reporting whether it is.
A co-owner can end the run first -- Megatron-Bridge does, as ``KILLED``, on SIGTERM --
and a fluent call with none active opens one, so this run's log would land there. The
status it was closed with is remembered here, since re-attaching sets it ``RUNNING``.
A *different* run being active is reported rather than uploaded through.
"""
active = self._mlflow.active_run()
if active is not None:
if str(active.info.run_id) == str(self._run.info.run_id):
return True
print(
f"[mlflow] WARNING: run {active.info.run_id} is active instead of this one, "
f"so {self.run_url} keeps neither its outputs nor a final status."
)
return False
self._closed_as = str(self._mlflow.get_run(self._run.info.run_id).info.status)
self._mlflow.start_run(run_id=self._run.info.run_id)
return True
def _note_active_exception(self) -> None:
"""Append the exception being handled to the captured log.
:meth:`finish` runs from the caller's ``finally``, which is *before* the interpreter
prints the traceback to ``sys.stderr`` -- no longer teed by then -- so the log would
otherwise stop at the last line the script printed. Written to the file only, so the
console still shows the traceback exactly once.
"""
if sys.exc_info()[0] is None or self._saved_streams is None:
return
sink = self._saved_streams[2]
if not sink.closed:
sink.write("\n" + traceback.format_exc())
def _open_run(self) -> None:
try:
import mlflow # optional dependency: only needed once tracking is enabled
except ImportError as e:
raise ImportError(
"MLflow tracking requires the 'mlflow' package: pip install nvidia-modelopt[mlflow]"
) from e
self._mlflow = mlflow
mlflow.set_tracking_uri(self.tracking_uri)
mlflow.set_experiment(self.experiment_name)
# Settled here rather than passed straight through, so run_info reports the name the
# run actually carries.
self.run_name = self.run_name or default_run_name()
self._run = mlflow.start_run(run_name=self.run_name)
print(f"[mlflow] experiment: {self.experiment_name}\n[mlflow] run: {self.run_url}")
def _log_inputs(self, params, tags, texts) -> None:
if params:
self._mlflow.log_params(
{k: _MASK if _SECRET_NAME.search(k) else _redact(v) for k, v in params.items()}
)
self._mlflow.set_tags(
{
"user": current_user(),
"hostname": socket.gethostname(),
"modelopt_version": modelopt.__version__,
"git_sha": _git_sha(),
**(tags or {}),
}
)
# The version is a tag as well, for searching; the artifact travels with the run.
self._log_texts(
{
"command.txt": command_text(),
"version.txt": f"{modelopt.__version__}\n",
**(texts or {}),
}
)
def _log_outputs(self, texts, files, metrics) -> None:
elapsed = time.time() - self._start_time
self._mlflow.log_metrics({"total_time_s": elapsed, **(metrics or {})})
self._log_texts(texts)
sys.stdout.flush()
sys.stderr.flush()
if self._log_path is not None:
self._log_file(f"logs/{self._log_path.name}", self._log_path)
for artifact_path, local in (files or {}).items():
path = Path(local)
if not path.is_file():
continue
# Only what this run produced: an export directory is commonly reused across
# attempts, so a run that crashes early would otherwise upload the previous
# run's summary as its own. Compared against the stat taken when the run opened
# rather than against the wall clock, whose resolution outruns the filesystem's.
if str(path) in self._file_stats and self._file_stats[str(path)] == _stat_key(path):
continue
self._log_file(artifact_path, path)
def _log_texts(self, texts) -> None:
for artifact_path, text in (texts or {}).items():
self._mlflow.log_text(text, artifact_path)
def _log_file(self, artifact_path: str, local: Path) -> None:
"""Upload *local* to *artifact_path*, staging a copy when it must be renamed."""
target = PurePosixPath(artifact_path)
directory = str(target.parent) if str(target.parent) != "." else None
if local.name == target.name:
self._mlflow.log_artifact(str(local), artifact_path=directory)
return
# log_artifact keeps the local basename, so rename via a staged copy rather than
# reading the file into memory -- these can be hundreds of MB.
with tempfile.TemporaryDirectory() as staging:
staged = Path(staging) / target.name
shutil.copy2(local, staged)
self._mlflow.log_artifact(str(staged), artifact_path=directory)
def _start_capture(self) -> None:
script = Path(sys.argv[0]).stem or "run"
self._log_path = Path(tempfile.mkdtemp(prefix="modelopt-mlflow-")) / f"{script}.log"
sink = open(self._log_path, "w", buffering=1, encoding="utf-8")
stdout, stderr = sys.stdout, sys.stderr
self._saved_streams = (stdout, stderr, sink)
self._tees = (TeeStream(stdout, sink), TeeStream(stderr, sink))
sys.stdout, sys.stderr = self._tees
self._repoint_handlers({stdout: self._tees[0], stderr: self._tees[1]})
print(f"[mlflow] capturing this run's log to {self._log_path}")
@staticmethod
def _repoint_handlers(replacements: dict) -> None:
"""Move already-configured logging handlers from one stream to another.
transformers and huggingface_hub bind ``sys.stderr`` into a ``StreamHandler`` when
they are imported, long before the capture starts; without this their warnings reach
the console but never the log. Scanning again on the way out -- rather than replaying
a list captured on the way in -- also hands back handlers a library bound *during*
the run, so nothing is left pointing at the tee once its file is closed.
"""
loggers = [logging.getLogger(), *list(logging.Logger.manager.loggerDict.values())]
for logger in loggers:
for handler in list(getattr(logger, "handlers", [])):
if not isinstance(handler, logging.StreamHandler):
continue
if handler.stream not in replacements:
continue
# logging._StderrHandler exposes ``stream`` as a read-only property that
# already resolves to whatever sys.stderr currently is, so it follows the tee
# on its own and cannot -- and must not -- be repointed.
with contextlib.suppress(AttributeError):
handler.setStream(replacements[handler.stream])
def _stop_capture(self) -> None:
if self._saved_streams is None:
return
stdout, stderr, _ = self._saved_streams
if self._tees is not None:
self._repoint_handlers({self._tees[0]: stdout, self._tees[1]: stderr})
self._tees = None
sys.stdout, sys.stderr, sink = self._saved_streams
sink.close()
self._saved_streams = None
if self._log_path is not None:
shutil.rmtree(self._log_path.parent, ignore_errors=True)
self._log_path = None
# The CLI surface below is shared by the example scripts that offer tracking, so a run is
# configured the same way and named by the same convention whichever script opened it.
@dataclass(frozen=True)
class Tool:
"""What distinguishes one script's tracking from another's.
A script declares one of these and the functions below do the rest. Each callable reads
the arguments naming this run's input, output and what it consumed -- *source* is what
the next run in a chain joins on, *variant* names what this run did. *settles_pointer* is
false when something other than :func:`tracked_run` writes the provenance pointer.
"""
name: str
tracks: str
variant_help: str
variant: Callable[[argparse.Namespace], str]
model: Callable[[argparse.Namespace], str]
checkpoint: Callable[[argparse.Namespace], str | None] = field(default=lambda args: None)
source: Callable[[argparse.Namespace], str] | None = None
texts: Callable[[argparse.Namespace], dict[str, str]] = field(default=lambda args: {})
outputs: Callable[[argparse.Namespace], dict[str, Path]] = field(default=lambda args: {})
# Read on the way *out*, so it can report something the run computed -- a pruning score,
# say -- which the script stashes on its own namespace.
metrics: Callable[[argparse.Namespace], dict[str, float]] = field(default=lambda args: {})
non_params: frozenset[str] = frozenset()
settles_pointer: bool = True
# The tracking settings describe the destination rather than the work, so they are never
# params; a script adds its own bookkeeping through ``Tool.non_params``.
_NEVER_PARAMS = frozenset({"mlflow", "mlflow_experiment", "mlflow_required", "mlflow_run_name"})
_ENV_HELP = (
f"MLflow's own ${TRACKING_URI_ENV} 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."
)
_TRACKS_HELP = (
"Track this run on an MLflow server (e.g. https://<your-mlflow-server>/), uploading the "
"command, the resolved configuration, the run log and the run's summaries."
)
def add_mlflow_args(parser: argparse.ArgumentParser, tool: Tool) -> None:
"""Add ``--mlflow``, ``--mlflow_experiment`` and ``--mlflow_run_name`` to *parser*.
The help text comes from *tool*: its ``tracks`` describes what this script uploads and its
``variant_help`` says what the experiment name's variant is derived from. Pair with
:func:`resolve_mlflow_args`.
The multi-word flags are registered under both the underscored and the dashed spelling:
vLLM's ``FlexibleArgumentParser`` rewrites every ``--foo_bar`` on the command line to
``--foo-bar`` before matching, so the dashed spelling has to exist for the flag to be
reachable there at all, and a user moving between the example scripts should not have to
remember which spelling each one took.
"""
parser.add_argument("--mlflow", default=None, help=f"{tool.tracks} {_ENV_HELP}")
parser.add_argument(
"--mlflow_experiment",
"--mlflow-experiment",
default=None,
help=(
f"MLflow experiment name. Default: "
f"$USER/{tool.name}/<model basename>-<{tool.variant_help}>."
),
)
parser.add_argument(
"--mlflow_run_name",
"--mlflow-run-name",
default=None,
help="MLflow run name. Default: the UTC start time as YYYYmmdd-HHMMSS.",
)
def split_tracking_credentials(uri: str) -> str | None:
"""Move any ``user:token@`` out of *uri* into MLflow's own credential variables.
For a caller that hands the URI to something which *records* it -- Megatron-Bridge logs
its resolved config as params and writes it into the checkpoint. Masking is not an option
there, since the value is also what authenticates. Returns ``None`` when the credential
cannot be moved, so the caller can decline to record it. Variables the caller already
exported win.
"""
parsed = urlparse(uri)
if parsed.scheme not in ("http", "https"):
# Fail closed. Without a scheme urlparse puts a "user:tok@host" in .path, where the
# userinfo below never sees it and the URI would be handed back with the credential
# still in it -- the one outcome this function exists to prevent. Callers reach this
# through validate_tracking_uri, which rejects the same URIs, but the precondition is
# not the caller's to remember.
return None
userinfo, separator, host = parsed.netloc.rpartition("@")
if not separator:
return uri
username, colon, password = userinfo.partition(":")
if not (username and colon and password):
# Half a credential cannot be moved: MLflow sends basic auth only when both variables
# are set, while requests authenticates off the URI it is given -- so the URI keeps
# working where it is passed through, and only a caller that *records* it is stuck.
return None
# Percent-decoded, because userinfo in a URI is percent-encoded and the variables hold
# the credential itself: a token containing "/" *must* be written "%2F" in the URI, and
# requests -- which is what authenticates when the credential is left in the URI -- has
# already decoded it there, so copying it across verbatim would authenticate differently.
os.environ.setdefault("MLFLOW_TRACKING_USERNAME", unquote(username))
os.environ.setdefault("MLFLOW_TRACKING_PASSWORD", unquote(password))
return parsed._replace(netloc=host).geturl()
def mask_tracking_uri(uri: str | None) -> str | None:
"""Mask any ``user:token@`` a tracking URI carries, for printing.
Credentials in the URI are a supported form, so everything this module prints or uploads
masks them -- ``command.txt``, the logged params, :attr:`MlflowRunLogger.run_url`. A
caller that prints the URI itself (a script echoing its parsed arguments, say) has to do
the same, or the secret reaches a console log that is routinely archived.
"""
return _redact(uri)
def resolve_tracking_uri(
uri: str | None, parser: argparse.ArgumentParser
) -> tuple[str | None, bool]:
"""Settle the tracking URI from ``--mlflow`` and the environment.
Returns ``(uri or None, required)``, where *required* records that the flag was passed.
Only the flag is a deliberate request, so only the flag is fatal when the URI is unusable:
the environment variable is commonly exported for unrelated tooling and must not fail a
job that would otherwise have worked.
"""
# An empty value is not a deliberate request: ``--mlflow "$UNSET_VAR"`` is a wrapper
# script whose variable did not resolve, so it neither names a server nor should take a
# job down. It falls back like an absent flag -- but loudly, since the caller did ask
# for tracking and what it gets is whatever the environment names, or nothing.
required = bool(uri)
if not uri:
if uri is not None:
warnings.warn(
f"--mlflow was given an empty value; falling back to ${TRACKING_URI_ENV} "
"if it is set, and running untracked otherwise."
)
uri = os.environ.get(TRACKING_URI_ENV) or None
if uri is None:
return None, required
try:
return validate_tracking_uri(uri), required
except ValueError as e:
if required:
parser.error(f"--mlflow: {e}") # exits
warnings.warn(f"Ignoring ${TRACKING_URI_ENV}, continuing untracked: {e}")
return None, required
def resolve_mlflow_args(
args: argparse.Namespace, parser: argparse.ArgumentParser, tool: Tool
) -> None:
"""Settle where tracking is configured from, and name the experiment, in place.
Sets ``args.mlflow`` to the validated URI or ``None``, ``args.mlflow_required`` to whether
the flag asked for it, and defaults ``args.mlflow_experiment`` from *tool*. Pair with
:func:`add_mlflow_args`.
"""
args.mlflow, args.mlflow_required = resolve_tracking_uri(args.mlflow, parser)
if args.mlflow:
args.mlflow_experiment = args.mlflow_experiment or default_experiment_name(
tool.name, tool.model(args), tool.variant(args)
)
def log_active_run_experiment_json(checkpoint_dir: Path | str) -> bool:
"""Record MLflow's *currently active* run as the producer of a checkpoint.
For a caller whose run is owned by something else -- Megatron-Bridge opens it for a
training job, on its last rank. Call it from the rank that owns the run, once the
checkpoint is on disk. With no run to name, any pointer already there is *removed*: the
weights are new, so a previous run's pointer would misname their author. Returns whether
a run was found, so a caller that asked for tracking can tell that from an untracked job.
"""
run = None
try:
import mlflow
# last_active_run() covers a run mlflow's atexit has already closed, which is what
# a caller running from its own atexit or shutdown path sees.
run = mlflow.active_run() or mlflow.last_active_run()
except Exception:
# mlflow absent, or unusable: an untracked run reaches here too, and quietly.
pass
if run is None:
# Same invariant as MlflowRunLogger.log_experiment_json: after a save the pointer
# beside the checkpoint is this run's or absent, never a previous run's.
drop_experiment_json(checkpoint_dir)
return False
try:
# The only field that needs the server. Everything else -- the ids, the run name, and
# the URL built from them -- is on run.info already, and run_id is what anything
# resolves the run by, so a blip here costs a display name rather than the pointer.
experiment_name = mlflow.get_experiment(str(run.info.experiment_id)).name
except Exception as e:
print(f"[mlflow] WARNING: could not read the run's experiment name: {e}")
experiment_name = ""
try:
info = _experiment_json(mlflow.get_tracking_uri(), experiment_name, run.info)
text = json.dumps(info, indent=2) + "\n"
except Exception as e:
# Same invariant as the branch above: the weights are new, so a pointer naming an
# earlier run is worse than none at all.
print(f"[mlflow] WARNING: could not read the active run: {e}")
drop_experiment_json(checkpoint_dir)
return False
# The file beside the weights first: it is the record that travels with the checkpoint,
# and it must not be lost to an upload that fails. A failure here is reported on its own;
# the return value answers "was there a run to name", which is what the callers ask.
try:
(Path(checkpoint_dir) / EXPERIMENT_JSON).write_text(text)
except OSError as e:
print(f"[mlflow] WARNING: could not write {Path(checkpoint_dir) / EXPERIMENT_JSON}: {e}")
try:
# Through the client, not the fluent ``mlflow.log_text``: the fluent one resolves its
# target with ``_get_or_start_run()``, which on the closed-run branch above opens a
# second run, and its ``run_id`` argument postdates this project's mlflow floor.
from mlflow.tracking import MlflowClient
MlflowClient().log_text(info["run_id"], text, EXPERIMENT_JSON.removeprefix("."))
except Exception as e:
print(f"[mlflow] WARNING: could not upload {EXPERIMENT_JSON.removeprefix('.')}: {e}")
return True
def drop_experiment_json(checkpoint_dir: Path | str) -> None:
"""Remove a provenance pointer an untracked export would otherwise inherit.
A fresh checkpoint written into a reused output directory would keep the previous run's
pointer, and one produced from a tracked source checkpoint could be handed that source's
pointer. Either way the file would name a run that did not produce these weights. Call it
only for a completed export; a failed run leaves whatever checkpoint was already there,
pointer included.
"""
stale = Path(checkpoint_dir) / EXPERIMENT_JSON
try:
stale.unlink(missing_ok=True)
except OSError as e:
print(f"[mlflow] WARNING: could not remove stale {stale}: {e}")
def masked_args(args: argparse.Namespace, attr: str = "mlflow") -> argparse.Namespace:
"""A copy of *args* whose tracking URI cannot leak credentials into a printed namespace.
For a script that echoes its parsed arguments: a ``user:token@`` in the URI is a supported
form that this module masks wherever it prints or uploads one, and a job log is routinely
archived. Uploaded artifacts are unaffected -- :func:`command_text` redacts, and the URI
is not worth logging as a param.
"""
return argparse.Namespace(**{**vars(args), attr: mask_tracking_uri(getattr(args, attr, None))})
def resolved_recipe_texts(recipe: str | None) -> dict[str, str]:
r"""``{artifact path: content}`` for *recipe*, or ``{}`` when the run used none.
The resolved recipe, not the source file: a recipe may be a directory or use ``$import``\ s,
and only the resolved form stands alone.
"""
if not recipe:
return {}
# Lazy import: modelopt.recipe imports modelopt.torch.quantization, which imports this
# package at top level (circular), as with the other upward imports in modelopt/torch/utils.
from modelopt.recipe import load_recipe
resolved = load_recipe(recipe).model_dump(mode="json")
return {"recipe/resolved_recipe.yaml": yaml.safe_dump(resolved, sort_keys=False)}
def run_tags(args: argparse.Namespace, tool: Tool) -> dict[str, str]:
"""This run's join keys, shared with whatever is later done with what it produced.
``checkpoint_path`` is the checkpoint the run *writes*, because that is what an export or
an evaluation is later pointed at (NEL takes ``deployment.checkpoint_path``), and
``source_checkpoint_path`` is what it consumed, so a chain of runs joins on the pair: a
distillation's source is the checkpoint it continues from, not the model that was
quantized. Both are resolved, since a relative path is useless as a join key -- except a
source that names no directory, such as a Hub ``org/name`` id.
"""
source = tool.source(args) if tool.source else tool.model(args)
checkpoint = tool.checkpoint(args)
tags = {
"model": Path(tool.model(args)).name,
"source_checkpoint_path": (
str(Path(source).resolve()) if os.path.exists(source) else str(source)
),
}
# Omitted rather than empty when the run writes no checkpoint: a search for runs that
# produced one should not match it.
if checkpoint is not None:
tags["checkpoint_path"] = str(Path(checkpoint).resolve())
return tags
def describe_run(args: argparse.Namespace, tool: Tool, world_size: int = 1) -> dict:
"""The keyword arguments :meth:`MlflowRunLogger.track` takes, for this run.
Every command-line argument becomes a searchable param, so a flag added later is tracked
without touching this. *world_size* is recorded separately because the parallelism flags
say how a run was laid out but not how many processes it took.
"""
skip = _NEVER_PARAMS | tool.non_params
params = {k: v for k, v in vars(args).items() if k not in skip}
params["world_size"] = world_size
return {
"params": params,
"tags": run_tags(args, tool),
"texts": tool.texts(args),
"files": tool.outputs(args),
}
@contextmanager
def tracked_run(
args: argparse.Namespace,
tool: Tool,
is_main: bool,
exported: Callable[[], bool],
world_size: int = 1,
) -> Iterator[MlflowRunLogger]:
"""Track one invocation of *tool* for the duration of the block.
Inert unless ``--mlflow`` settled a URI and this is the rank that records it, so the
caller needs no branching. *is_main* is that rank, and also gates the writes every rank
would otherwise race on; *exported* is read on the way out, once the run knows whether it
wrote the checkpoint its pointer would claim.
Example:
>>> with tracked_run(args, HF_PTQ, is_main, lambda: args.exported, world_size):
... quantize_and_export(args)
"""
logger = MlflowRunLogger(
args.mlflow or "",
args.mlflow_experiment,
run_name=args.mlflow_run_name,
enabled=bool(args.mlflow) and is_main,
required=args.mlflow_required,
)
# None when the run writes no checkpoint at all -- a pruning run that only scores, say,
# or a script that points each of several checkpoints at the run itself -- so there is
# nothing to point at and nothing that could inherit a stale pointer.
path = None
if tool.settles_pointer and (checkpoint := tool.checkpoint(args)) is not None:
path = Path(checkpoint)
if not logger.enabled:
# Gathering the inputs re-reads the recipe, so keep it off the untracked path.
try:
yield logger
finally:
if path is not None and is_main and _ask("exported flag", exported, False):
drop_experiment_json(path)
return
described = describe_run(args, tool, world_size)
logger.start(**described)
def close(status: str) -> None:
# Only a completed export may claim the checkpoint the pointer sits next to: the
# directory usually exists before the weights do.
wrote_it = path is not None and _ask("exported flag", exported, False)
logger.log_experiment_json(path if wrote_it else None)
logger.finish(
status,
files=described["files"],
metrics=_ask("metrics", lambda: tool.metrics(args), {}),
)
with _closing_run(close):
yield logger