Files
Keval MorabiaandClaude Opus 5 4eb86524f0 [1/2] One MLflow tracking core behind a Tool record (#2544)
### What does this PR do?

Type of change: refactor (no functional change)

**[1/2] of a split. Merge this first; #2514 is [2/2] and is based on
this branch.**

Three example scripts had each reimplemented the same MLflow wiring: the
flags, the `$USER/<tool>/<model>-<variant>` experiment convention, the
params/tags/artifacts a run uploads, and the open/close dance with its
status. The copies had already drifted — only `hf_ptq` wrote a
provenance pointer, only `vllm_serve` republished the resolved URI — and
every new tracked script meant another copy.

What a script records is now one declarative `Tool` record, **declared
in the script itself, beside the flags it reads**:

```python
# examples/megatron_bridge/quantize.py
QUANTIZE = Tool(
    name="megatron_bridge_quantize",
    tracks="Track this run on an MLflow server, uploading the command, the resolved recipe, ...",
    variant_help="recipe name, or --quant_cfg if no --recipe",
    variant=lambda args: Path(args.recipe).stem if args.recipe else (args.quant_cfg or "none"),
    model=lambda args: args.hf_model_name_or_path,
    checkpoint=lambda args: args.export_megatron_path,
    texts=lambda args: resolved_recipe_texts(args.recipe),
    outputs=lambda args: {"summary/quant_summary.txt": Path(args.export_megatron_path) / ".quant_summary.txt"},
)
```

`tracked_run` takes that record and runs the whole thing, so a script
adds tracking in three lines: `add_mlflow_args(parser, TOOL)`,
`resolve_mlflow_args(args, parser, TOOL)`, and `with mlflow_run(args,
TOOL):`. The shared module knows no script's flags.

`examples/hf_ptq`, `examples/vllm_serve` and
`examples/megatron_bridge/quantize.py` move onto it. Three helpers fall
away as redundant (`track_run`, `checkpoint_run_tags`, and `hf_ptq`'s
two flag pass-throughs).

### Usage

No user-facing change. The flags, their spellings and the experiment
naming are exactly as before; a script author now writes a `Tool`
instead of four functions.

### Testing

- `tests/unit/torch/utils/test_mlflow.py`,
`tests/examples/hf_ptq/test_hf_ptq_args.py`,
`tests/examples/vllm_serve/test_vllm_mlflow_utils.py` — **179 pass**.
- `tests/examples/megatron_bridge` in `nvcr.io/nvidia/nemo:26.08` (the
only lane that runs it), which drives `quantize.py` for real: **34
passed**, locally and in this PR's `megatron` lane.
- `pre-commit run --files <changed>`: all hooks pass.
- The four suites shared four copies of a stand-in for the `mlflow`
module, which had drifted — one recorded artifacts as a list, another as
a dict, a third made `log_artifact` a no-op, so a test asserting on an
upload asserted nothing. They now share one
`tests/_test_utils/mlflow.py`, which also emulates the fluent API's
habit of opening a run when none is active.

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

- Is this change backward compatible?: ✅ — `track_run` and
`checkpoint_run_tags` are removed, but neither shipped in a release
(0.47.0's `__all__` is `MlflowRunLogger`, `command_text`,
`current_user`, `default_experiment_name`, `validate_tracking_uri`, all
unchanged here). Three deliberate behaviour changes, each in shared code
and each tested:
- The `source_checkpoint_path` tag resolves to an absolute path where it
recorded the raw argument, which a chain of runs needs to join on the
pair. `run_tags` is shared, so this applies to every script that writes
the tag — `hf_ptq` **and** `megatron_bridge/quantize.py`, for a local
`--hf_model_name_or_path`. A source that names no directory, such as a
Hub `org/name` id, is still recorded as given.
- `MlflowRunLogger.track()` — which *did* ship in 0.47.0 — records a
block ending in `SystemExit(0)` as `FINISHED` where it recorded
`FAILED`, since a script that ends by calling `sys.exit()` rather than
returning has still finished.
- `.experiment.json`'s `tracking_uri` and the `run_url` built from it
drop a trailing `/` from the tracking URI, so the link is
`https://host/#/...` rather than `https://host//#/...`. Only reachable
by constructing `MlflowRunLogger` directly; every CLI path already
stripped the slash in `resolve_tracking_uri`.
- 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)?:
N/A — no user-visible change; the entry is in [2/2].
- Did you get Claude approval on this PR?: several rounds; re-requested
on this head.

### Additional Information

Split out of #2514. This half is the enabling refactor with no behaviour
change; #2514 is the feature it unlocks and is based on this branch. At
~605 changed lines of core logic it is over the ~500 guideline; the
owner accepted a two-PR split rather than three, and everything #2514
alone consumes — `split_tracking_credentials`,
`log_active_run_experiment_json`, `MlflowRunLogger._reattach` — lands
there rather than here.

🤖 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 19:57:03 +00:00

195 lines
6.3 KiB
Python

# SPDX-FileCopyrightText: Copyright (c) 2024 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.
import os
import platform
import sys
from pathlib import Path
import pytest
import torch
import torch.distributed as dist
from _test_utils.fs_utils import assert_unmodified_tree
from _test_utils.mlflow import FakeMlflow, pin_tracking_env
from _test_utils.torch.distributed.utils import init_process
import modelopt.torch.opt as mto
@pytest.fixture(scope="session")
def verbose(request):
return request.config.getoption("verbose")
def pytest_addoption(parser):
parser.addoption(
"--run-manual",
action="store_true",
default=False,
help="Run manual tests",
)
parser.addoption(
"--run-release",
action="store_true",
default=False,
help="Run release tests",
)
# Default per-test `call` wall-clock cap (seconds) by top-level tests/ subdirectory
# Every collectible test group must be listed here else collection errors occur
# A test can override its cap by adding ``@pytest.mark.timeout(...)``
_DEFAULT_TIMEOUT = {
"examples": int(os.environ.get("MODELOPT_QA_TEST_TIMEOUT", 300)),
"gpu": 120,
"gpu_megatron": 120,
"gpu_trtllm": 60,
"gpu_vllm": 60,
"regression": 180,
"unit": 120 if platform.system() == "Windows" else 60,
}
def pytest_collection_modifyitems(config, items):
"""Skip flag-gated tests and apply a default per-test timeout based on the test directory."""
skip_marks = [
("manual", "--run-manual"),
("release", "--run-release"),
]
for mark_name, option_name in skip_marks:
if not config.getoption(option_name):
skipper = pytest.mark.skip(reason=f"Only run when {option_name} is given")
for item in items:
if mark_name in item.keywords:
item.add_marker(skipper)
tests_root = Path(__file__).parent
for item in items:
if item.get_closest_marker("timeout") is not None or not item.path.is_relative_to(
tests_root
):
continue
# First path component under tests/ is the group dir (unit, gpu, examples, ...).
# Crash loudly (rather than silently skip) if a group has no configured default, so a
# newly added tests/<group>/ must be given an explicit timeout in the mapping above.
group = item.path.relative_to(tests_root).parts[0]
if group not in _DEFAULT_TIMEOUT:
raise pytest.UsageError(
f"tests/{group}/ has no default timeout; add '{group}' to "
"_DEFAULT_TIMEOUT in tests/conftest.py."
)
item.add_marker(pytest.mark.timeout(_DEFAULT_TIMEOUT[group]))
# General Fixtures #################################################################################
@pytest.fixture
def skip_on_windows():
if platform.system() == "Windows":
pytest.skip("Skipping on Windows")
@pytest.fixture(scope="session")
def num_gpus():
return torch.cuda.device_count()
@pytest.fixture(scope="session")
def cuda_capability():
if not torch.cuda.is_available():
pytest.skip("CUDA is not available")
return torch.cuda.get_device_capability()
@pytest.fixture
def distributed_setup_size_1():
init_process(rank=0, size=1, backend="nccl")
yield
dist.destroy_process_group()
@pytest.fixture
def need_2_gpus():
if torch.cuda.device_count() < 2:
pytest.skip("Need at least 2 GPUs to run this test")
@pytest.fixture
def need_4_gpus():
if torch.cuda.device_count() < 4:
pytest.skip("Need at least 4 GPUs to run this test")
@pytest.fixture
def need_8_gpus():
if torch.cuda.device_count() < 8:
pytest.skip("Need at least 8 GPUs to run this test")
@pytest.fixture(scope="module")
def set_torch_dtype(request):
orig_dtype = torch.get_default_dtype()
torch.set_default_dtype(request.param)
yield
torch.set_default_dtype(orig_dtype)
@pytest.fixture(scope="session", autouse=True)
def enable_hf_checkpointing():
mto.enable_huggingface_checkpointing()
@pytest.fixture(scope="session")
def project_root_path(request: pytest.FixtureRequest) -> Path:
"""Fixture providing the project root path for tests."""
return Path(request.config.rootpath)
# Transformers Models Fixtures #####################################################################
@pytest.fixture
def tiny_tokenizer():
"""Real tiny HF tokenizer (vocab=128) shared across unit and gpu test lanes."""
# Lazy import: transformers_models.py runs ``pytest.importorskip("transformers")``
# at module load, which we don't want to trigger at conftest import time.
from _test_utils.torch.transformers_models import get_tiny_tokenizer
return get_tiny_tokenizer()
@pytest.fixture(scope="session")
def tiny_wan22_path(tmp_path_factory):
"""Tiny Wan 2.2 pipeline dir, built once per session (the build is the expensive part).
Shared by the gpu sparse-attention tests and the diffusers example tests.
"""
# Lazy import for the same reason as ``tiny_tokenizer``: diffusers_models.py pulls in
# transformers at module load.
from _test_utils.torch.diffusers_models import create_tiny_wan22_pipeline_dir
pipeline_dir = create_tiny_wan22_pipeline_dir(tmp_path_factory.mktemp("tiny_wan22"))
with assert_unmodified_tree(pipeline_dir) as path:
yield str(path)
@pytest.fixture
def fake_mlflow(monkeypatch):
"""Stand in for the ``mlflow`` module; see ``_test_utils.mlflow.FakeMlflow``."""
# A suite that takes the fake without also importing clean_env would otherwise read the
# developer's own $MLFLOW_TRACKING_URI and flip the branch under test.
pin_tracking_env(monkeypatch)
fake = FakeMlflow()
monkeypatch.setitem(sys.modules, "mlflow", fake)
return fake