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
15 changes: 10 additions & 5 deletions modelopt/torch/utils/distributed.py
Original file line number Diff line number Diff line change
Expand Up @@ -910,14 +910,16 @@ def distributed_forward_loop(
dataloader: DataLoader,
device: torch.device | str | None = None,
ep_mesh: "torch.distributed.device_mesh.DeviceMesh | None" = None,
forward: Callable[[dict], object] | None = None,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[SUGGESTION] The annotation promises forward a dict, but what it actually receives depends on device: with device=None the loop never rebuilds the batch, so the callback gets the loader's own object — a processor's BatchFeature, say — and only the device is not None branch (line 941) hands it a real dict.

Why it matters: the loop's own type check one line above only requires Mapping, which is deliberately wide so a BatchFeature passes. A caller who reads the annotation and writes a callback doing something dict-specific (batch.pop(...), batch | extra, ** into a function expecting a plain dict is fine, but mutation is not) works under device="cuda" and breaks under device=None — the asymmetry is invisible at the call site. Narrowing the annotation to what is guaranteed documents the contract and keeps mypy honest for callers:

Suggested change
forward: Callable[[dict], object] | None = None,
forward: Callable[[Mapping[str, Any]], object] | None = None,

(Mapping and Any are both already imported, line 27 / 31.) Non-blocking either way — the alternative is to normalize unconditionally, but that would allocate a dict per batch for callers who pass no device.

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.

Left as is for now: it is annotation only, with no runtime path that breaks. Its one caller, hf_ptq's expert-parallel image calibration (#2754), always passes the run's device, so its forward always gets the loop's own dict, which it mutates (create_vlm_calibration_forward renames input_ids for encoder-decoder models). I will narrow the annotation to Mapping[str, Any] if this PR is touched again, rather than restart CI on an approved PR for a type hint.

) -> Callable[[torch.nn.Module], None]:
"""Build an ``mtq.quantize`` ``forward_loop`` for a distributed (e.g. expert-parallel) model.

Moves each batch onto ``device``, when given, and runs the captured ``model`` forward (the EP
all-reduce / all-to-all collectives fire inside that forward). A model whose plan
(:func:`distribution_plan`) is expert-parallel passes the ``ep_mesh`` its experts are sharded
over, for the all-to-all forwards that quantizer insertion drops to be re-installed before the
first forward; any other model passes none. ``mtq.quantize`` hands the
all-reduce / all-to-all collectives fire inside that forward), or ``forward(batch)`` when given,
for batches that need their own call into ``model``, such as a VLM's image-text batches. A
model whose plan (:func:`distribution_plan`) is expert-parallel passes the ``ep_mesh`` its
experts are sharded over, for the all-to-all forwards that quantizer insertion drops to be
re-installed before the first forward; any other model passes none. ``mtq.quantize`` hands the
``forward_loop`` callback an inner module that this closure ignores, calling the captured
top-level ``model`` instead so the full distributed forward (and its collectives) runs.

Expand Down Expand Up @@ -955,7 +957,10 @@ def calibrate(_inner_model):
k: (v.to(device) if isinstance(v, torch.Tensor) else v)
for k, v in batch.items()
}
model(**batch)
if forward is None:
model(**batch)
else:
forward(batch)
# Again for what runs next (amax sync, export collectives), past the forwards' pool.
_release_cached_cuda_memory()

Expand Down
22 changes: 18 additions & 4 deletions modelopt/torch/utils/memory_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ def __init__(self, monitor_interval: float = 10.0):
self.peak_memory = {} # GPU index to peak memory mapping
self.is_running = False
self.monitor_thread = None
self._stopped = False
nvmlInit()
self.device_count = nvmlDeviceGetCount()

Expand Down Expand Up @@ -116,18 +117,24 @@ def stop(self):
GPU that was monitored, and properly shuts down the NVML interface. It will
wait for the monitoring thread to complete before returning.

The peak memory usage is displayed in GB for each GPU index.
The peak memory usage is displayed in GB for each GPU index. Stopping a stopped monitor does
nothing, as when a caller stops it and the exit hook :func:`launch_memory_monitor` registered
runs too.
"""
if self._stopped:
return
self._stopped = True

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.

Bot comment.

[P2] Join the polling thread before printing the report. Setting is_running = False only stops the next outer while iteration; a worker already inside the GPU for loop can still insert another entry into peak_memory. On a host with at least two GPUs, stopping during the first sampling pass can therefore raise RuntimeError: dictionary changed size during iteration while the report iterates peak_memory.items(). That skips both join() and nvmlShutdown(), and the newly set _stopped flag makes the exit hook skip cleanup too.

Move the existing join() immediately after clearing is_running, before reading/reporting peak_memory. Extend the shutdown test using two fake devices and bounded event synchronization around an in-flight sample; the current one-device fake cannot expose this race.

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.

Fixed in 1876ae9: stop() now joins the polling thread before it reads and prints peak_memory, so a sampling pass in flight finishes first. New test test_the_report_waits_for_a_sampling_pass_in_flight: two fake GPUs; the poller's second sample is held until stop() calls join() (bounded, 5 s at most), and the report must list both GPUs. It failed in 10 of 10 runs with the old ordering, passed in 30 of 30 with the new one, and runs in milliseconds.

self.is_running = False
# The thread can be inside a sampling pass, still adding to peak_memory: let it finish first.
if self.monitor_thread:
self.monitor_thread.join()
Comment on lines +128 to +130

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[SUGGESTION] The reorder is right — the poller mutates peak_memory inside a pass, so reading it before the join could miss the last GPU or iterate a dict that grows under it, and the two-GPU test pins that. Two non-blocking notes on the shape of the wait, both consequences of this being the first commit where the thread actually runs:

  1. The report is now behind an unbounded join. _monitor_loop only checks is_running at the loop top, after time.sleep(self.monitor_interval), so stop() blocks for up to a full interval before printing anything. That latency is pre-existing (the join was unbounded before, just after the print), but the ordering change means a poller wedged in an NVML call — which does happen around an Xid / ECC / device-reset event, exactly when peak memory is interesting — now loses the report entirely instead of printing it and then blocking. Since launch_memory_monitor registers stop with atexit, that is a process that hangs at exit with nothing on stdout, once per rank.

  2. The sleep tail makes the wait longer than it needs to be. GPUMemoryMonitor's own default is monitor_interval=10.0, so a directly-constructed monitor's stop() sits for up to 10s. Both in-repo call sites (hf_ptq.py:1545, hf_sa.py:147) take the launch_memory_monitor default of 1.0s, so this is latent rather than live today.

An Event for the sleep fixes both — stop() returns as soon as the in-flight pass ends, and a bounded join keeps the report when the poller is wedged:

    def __init__(self, monitor_interval: float = 10.0):
        ...
        self.monitor_thread = None
        self._stopped = False
        self._stop_requested = threading.Event()
        nvmlInit()
        self.device_count = nvmlDeviceGetCount()

    def _monitor_loop(self):
        while not self._stop_requested.is_set():
            for i in range(self.device_count):
                ...
            self._stop_requested.wait(self.monitor_interval)

and in stop():

        self._stopped = True
        self.is_running = False
        self._stop_requested.set()
        # The thread can be inside a sampling pass, still adding to peak_memory: let it finish
        # first, but report what we have rather than hang here if it is wedged in an NVML call.
        if self.monitor_thread:
            self.monitor_thread.join(timeout=30)

Keep is_running as the public flag it already is. The existing two-GPU test still distinguishes the ordering; a timeout case is not worth a test of its own.

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.

Agreed it would be nicer; not changed in this PR, since neither point is a defect today:

  1. A poller wedged in an NVML call hangs the exit either way. Before the reorder, stop() printed and then blocked in the same unbounded join(), and the process still hung at exit. What changes is only whether the report printed first. A bounded join would also need to print a snapshot (dict(self.peak_memory)), since a poller that wakes after the timeout would grow the dict during the print again.
  2. The wait after the last pass is at most one monitor_interval. Both callers use launch_memory_monitor's default of 1 s.

I've noted the event-driven sleep, the bounded join and the snapshot as a follow-up for the memory monitor, rather than grow this PR, which Claude has approved.

# Print peak memory usage
print("########")
for gpu_idx, peak_mem in self.peak_memory.items():
print(
f"GPU {gpu_idx}: Peak memory usage = {peak_mem:.2f} GB for all processes on the GPU"
)
print("########")
if self.monitor_thread:
self.monitor_thread.join()
nvmlShutdown()


Expand All @@ -140,8 +147,15 @@ def launch_memory_monitor(monitor_interval: float = 1.0) -> GPUMemoryMonitor | N
Returns:
GPUMemoryMonitor: The monitor instance that was launched
"""
# Probe that NVML can report memory on this host before starting the monitor. NVML must be
# initialized for the probe itself; without it every call raises NVMLError_Uninitialized, and
# the monitor would never start anywhere.
try:
nvmlDeviceGetMemoryInfo(nvmlDeviceGetHandleByIndex(0))
nvmlInit()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[SUGGESTION] The fix itself is right (probe init → use → finally: nvmlShutdown() keeps pynvml's refcount balanced, and GPUMemoryMonitor.__init__ re-inits for the monitor's own reference), but it's worth noting what it turns on, since this is the first time the monitor actually starts.

Both call sites are unconditional and per-process — examples/hf_ptq/hf_ptq.py:1545 and examples/llm_sparsity/attention_sparsity/hf_sa.py:147 — while _monitor_loop samples every visible GPU and stop() prints one line per GPU. On an 8-rank torchrun job (exactly what this PR series targets) that becomes 8 daemon threads each polling all 8 GPUs every second, and 8 identical ######## blocks at exit. The printed number is already host-wide ("for all processes on the GPU"), so the 7 extra copies carry no information.

A rank gate at the hf_ptq call site would keep the restored monitoring useful without the duplication:

            # launch a memory monitor to read the currently used GPU memory.
            if args.dist_state.is_main:
                launch_memory_monitor()

Out of scope for this diff if you'd rather land it with [2/3], which already touches hf_ptq — just flagging it so the behavior change doesn't land unnoticed.

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.

Not gated here: the gate lands with the hf_ptq change, #2754 ([3/3] of this split), which starts the monitor once per node, on its local rank 0 (args.dist_state.world_size == 1 or local_rank() == 0, tested by test_one_rank_per_node_runs_the_gpu_memory_monitor). It is per node rather than is_main because the monitor reads every GPU on its own node, so rank 0 alone would leave the other nodes of a multi-node run unreported. If this PR merges first, a --use_fsdp2 run on several GPUs starts one monitor per rank until #2754 lands; the duplicates read the same host-wide numbers and change nothing else.

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.

[IMPORTANT Compatibility] The probe fix is right, but it makes stop()'s non-idempotence reachable for the first time.

Until this commit the probe always raised, so launch_memory_monitor always returned None and atexit.register(monitor.stop) on line 157 never ran. Now it does — and stop() (line 112) ends with an unconditional nvmlShutdown() against the single nvmlInit() that __init__ (line 88) issued. Call it twice and the second nvmlShutdown() hits refcount 0 and raises NVMLError_Uninitialized.

That is exactly what this module's own documented usage does (module docstring, lines 29–31):

>>> monitor = launch_memory_monitor(monitor_interval=1.0)
>>> # Run your GPU operations
>>> monitor.stop()  # Will print peak memory usage per GPU

The explicit stop() runs, then the atexit hook runs the same bound method again at exit: the peak-memory block prints a second time (with the same numbers, so it reads like a second GPU report) and nvmlShutdown() raises inside the atexit callback, which CPython reports as a traceback on stderr during interpreter shutdown. On a multi-rank torchrun job that is one spurious traceback per rank at the end of an otherwise successful run — noisy in exactly the place people look for real failures.

The new test is itself evidence the hazard is live: tests/unit/torch/utils/test_memory_monitor.py's launched fixture has to call atexit.unregister(monitor.stop) before its own monitor.stop(), i.e. the test works around the double-stop rather than the code preventing it.

The two in-repo call sites (examples/hf_ptq/hf_ptq.py:1545, examples/llm_sparsity/attention_sparsity/hf_sa.py:147) discard the return value and so only stop once — but launch_memory_monitor is public API in modelopt/torch/utils, and the docstring tells users to hold the handle and stop it.

Suggested fix — make stop() idempotent and drop the hook once it has run, so the test's atexit.unregister becomes unnecessary too:

    def stop(self):
        """..."""
        if self.monitor_thread is None and not self.is_running:
            return  # Already stopped; NVML's refcount is balanced once only.
        atexit.unregister(self.stop)
        self.is_running = False
        print("########")
        for gpu_idx, peak_mem in self.peak_memory.items():
            print(
                f"GPU {gpu_idx}: Peak memory usage = {peak_mem:.2f} GB for all processes on the GPU"
            )
        print("########")
        if self.monitor_thread:
            self.monitor_thread.join()
            self.monitor_thread = None
        nvmlShutdown()

A test that fails without it: launch against the fake NVML, call stop() twice, and assert nvml.inits == 0 and that the second call neither raises nor reprints — the current code raises on the second call.

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.

Fixed in e2ddb75: stop() now does nothing on a monitor that is already stopped, so an explicit stop() followed by the exit hook prints the report once and shuts NVML down once. The test fixture no longer unregisters the hook. New test test_stopping_a_stopped_monitor_does_nothing stops a monitor twice and checks the second call prints nothing and leaves NVML's count at 0; it fails on the previous stop().

try:
nvmlDeviceGetMemoryInfo(nvmlDeviceGetHandleByIndex(0))
finally:
nvmlShutdown()
except Exception as e:
print(f"Failed to get GPU memory info: {e}. Stopping GPU memory monitor.")
return None
Expand Down
15 changes: 15 additions & 0 deletions tests/unit/torch/utils/test_distributed_calibration.py
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,21 @@ def test_forward_loop_moves_tensors_to_the_device_it_is_given():
assert [call["logits_to_keep"] for call in model.calls] == [1, 1]


def test_forward_loop_runs_each_batch_through_the_forward_it_is_given():
"""A batch that needs its own call into the model, as a VLM's image-text batch does, goes
through ``forward``, on the device and with the cache disabled like any other."""
batches = [{"input_ids": torch.zeros(2, 3)}] * 2
model = _Recorder()

distributed_forward_loop(
model, batches, device="meta", forward=lambda batch: model(**batch, own_call=True)
)(model)

assert [call["own_call"] for call in model.calls] == [True, True]
assert [call["input_ids"].device.type for call in model.calls] == ["meta"] * 2
assert model.use_cache_seen == [False, False]


def test_forward_loop_disables_use_cache_only_while_calibrating():
"""KV/state caching is off for the calibration forwards (it breaks hybrid Mamba models)."""
model = _Recorder()
Expand Down
135 changes: 135 additions & 0 deletions tests/unit/torch/utils/test_memory_monitor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# 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.

"""Unit tests for launch_memory_monitor's NVML availability probe, against a fake NVML."""

import threading
from types import SimpleNamespace

import pytest

import modelopt.torch.utils.memory_monitor as mm


class _FakeNVML:
"""Reference-counted like NVML: every call but init raises while uninitialized."""

def __init__(self, memory_info_supported: bool = True, devices: int = 1, on_sample=None):
self.inits = 0
self.memory_info_supported = memory_info_supported
self.devices = devices
self.on_sample = on_sample # called with a device's handle before its memory is read

def install(self, monkeypatch):
for name in ("Init", "Shutdown", "DeviceGetCount", "DeviceGetHandleByIndex"):
monkeypatch.setattr(mm, f"nvml{name}", getattr(self, name))
monkeypatch.setattr(mm, "nvmlDeviceGetMemoryInfo", self.DeviceGetMemoryInfo)

def _require_init(self):
if self.inits <= 0:
raise RuntimeError("Uninitialized")

def Init(self): # noqa: N802
self.inits += 1

def Shutdown(self): # noqa: N802
self._require_init()
self.inits -= 1

def DeviceGetCount(self): # noqa: N802
self._require_init()
return self.devices

def DeviceGetHandleByIndex(self, index): # noqa: N802
self._require_init()
return index

def DeviceGetMemoryInfo(self, handle): # noqa: N802
self._require_init()
if not self.memory_info_supported:
raise RuntimeError("Not Supported")
if self.on_sample is not None:
self.on_sample(handle)
return SimpleNamespace(used=2 * 1024**3)


@pytest.fixture
def launched():
monitors = []
yield monitors
for monitor in monitors:
monitor.stop()


@pytest.mark.parametrize("supported", [True, False], ids=["reports_memory", "cannot"])
def test_the_monitor_starts_only_where_nvml_can_report_memory(monkeypatch, launched, supported):
"""The probe used to run before NVML was initialized, so it always failed and the monitor
never started on any host. Hosts where NVML cannot report memory still skip the monitor, and
leave NVML shut down; elsewhere the probe's own init is balanced and the monitor holds one."""
nvml = _FakeNVML(memory_info_supported=supported)
nvml.install(monkeypatch)
monitor = mm.launch_memory_monitor(monitor_interval=0.01)
if monitor is not None:
launched.append(monitor)

assert (monitor is not None) is supported
assert nvml.inits == (1 if supported else 0)


def test_stopping_a_stopped_monitor_does_nothing(monkeypatch, capsys):
"""launch_memory_monitor registers stop() to run at exit, so a monitor its caller stopped is
stopped again then: the second stop neither reports again nor shuts NVML down a second time."""
nvml = _FakeNVML()
nvml.install(monkeypatch)
monitor = mm.launch_memory_monitor(monitor_interval=0.01)

monitor.stop()
# The report's frame, which prints whether or not the monitor thread sampled a GPU yet.
assert "########" in capsys.readouterr().out
monitor.stop()

assert capsys.readouterr().out == ""
assert nvml.inits == 0


def test_the_report_waits_for_a_sampling_pass_in_flight(monkeypatch, capsys):
"""stop() can come while the thread is between two GPUs of a sampling pass: the report waits for
the pass to finish rather than miss the last GPU, or iterate the samples while they grow."""
in_flight, joining = threading.Event(), threading.Event()

def hold_the_second_gpu(handle):
if handle == 1 and threading.current_thread() is not threading.main_thread():
# The first GPU is sampled; hold the second until stop() waits for the thread (5 s max).
in_flight.set()
joining.wait(timeout=5)

nvml = _FakeNVML(devices=2, on_sample=hold_the_second_gpu)
nvml.install(monkeypatch)
monitor = mm.launch_memory_monitor(monitor_interval=0.01)
join = monitor.monitor_thread.join

def join_and_release(*args, **kwargs):
joining.set()
return join(*args, **kwargs)

monkeypatch.setattr(monitor.monitor_thread, "join", join_and_release)
assert in_flight.wait(timeout=5)

monitor.stop()

report = capsys.readouterr().out
assert "GPU 0: Peak memory usage" in report
assert "GPU 1: Peak memory usage" in report
assert nvml.inits == 0
Loading