Repository navigation
[OMNIML-6085] [1/3] Add forward= to distributed_forward_loop, and fix the GPU memory monitor's NVML probe #3133
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: shengliangx/hfep-base-6a-ep-loader
Are you sure you want to change the base?
Changes from all commits
e98194d
83332a2
d4a7f0e
b6fd97c
be5a45b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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() | ||
|
|
||
|
|
@@ -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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[P2] Join the polling thread before printing the report. Setting Move the existing
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 1876ae9: |
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [SUGGESTION] The reorder is right — the poller mutates
An 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 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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
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() | ||
|
|
||
|
|
||
|
|
@@ -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() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [SUGGESTION] The fix itself is right (probe init → use → Both call sites are unconditional and per-process — 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.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 (
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [IMPORTANT Compatibility] The probe fix is right, but it makes Until this commit the probe always raised, so 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 GPUThe explicit The new test is itself evidence the hazard is live: The two in-repo call sites ( Suggested fix — make 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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in e2ddb75: |
||
| 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 | ||
|
|
||
| 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[SUGGESTION] The annotation promises
forwardadict, but what it actually receives depends ondevice: withdevice=Nonethe loop never rebuilds the batch, so the callback gets the loader's own object — a processor'sBatchFeature, say — and only thedevice is not Nonebranch (line 941) hands it a realdict.Why it matters: the loop's own type check one line above only requires
Mapping, which is deliberately wide so aBatchFeaturepasses. A caller who reads the annotation and writes a callback doing somethingdict-specific (batch.pop(...),batch | extra,**into a function expecting a plain dict is fine, but mutation is not) works underdevice="cuda"and breaks underdevice=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:(
MappingandAnyare 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.There was a problem hiding this comment.
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_forwardrenamesinput_idsfor encoder-decoder models). I will narrow the annotation toMapping[str, Any]if this PR is touched again, rather than restart CI on an approved PR for a type hint.