Skip to content

Commit 98ff41e

Browse files
authored
Add Thermal Device Selection and Current Context Thermovision mapping (#51) - Fixes #35
* add thermal_device selection and current-context thermovision mapping * refactor thermovision CUDA-device mapping and add verbose for default current-device monitoring * Fixes #35
1 parent c1112a9 commit 98ff41e

5 files changed

Lines changed: 247 additions & 2 deletions

File tree

‎nsight/analyze.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ def kernel(
5050
thermal_wait: int | None = None,
5151
thermal_cont: int | None = None,
5252
thermal_timeout: int | None = None,
53+
thermal_device: int | None = None,
5354
combine_kernel_metrics: Callable[[float, float], float] | None = None,
5455
output_prefix: str | None = None,
5556
output_csv: bool = False,
@@ -79,6 +80,7 @@ def kernel(
7980
thermal_wait: int | None = None,
8081
thermal_cont: int | None = None,
8182
thermal_timeout: int | None = None,
83+
thermal_device: int | None = None,
8284
combine_kernel_metrics: Callable[[float, float], float] | None = None,
8385
output_prefix: str | None = None,
8486
output_csv: bool = False,
@@ -220,6 +222,14 @@ def wrapped_function(*args, configs=None, **kwargs) -> ProfileResults
220222
thermal_timeout: Maximum wait time in seconds for GPU to cool down.
221223
Default: 180 seconds
222224
225+
thermal_device: CUDA device ordinal to monitor for thermal throttling.
226+
227+
If set, monitor that CUDA device. If ``None``, monitor the current
228+
CUDA device context. Device numbering follows CUDA runtime ordinals and
229+
honors ``CUDA_VISIBLE_DEVICES``.
230+
231+
Default: ``None``
232+
223233
verbosity: Controls the verbosity level of the output. Selecting level N
224234
enables output at all levels with value <= N. Default: ``VerbosityLevel.INFO``.
225235
@@ -313,6 +323,7 @@ def _create_profiler() -> collection.core.NsightProfiler:
313323
thermal_wait=thermal_wait,
314324
thermal_cont=thermal_cont,
315325
thermal_timeout=thermal_timeout,
326+
thermal_device=thermal_device,
316327
output_prefix=prefix,
317328
output_csv=output_csv,
318329
)

‎nsight/collection/core.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ def run_profile_session(
241241
thermal_wait: int | None = None,
242242
thermal_cont: int | None = None,
243243
thermal_timeout: int | None = None,
244+
thermal_device: int | None = None,
244245
) -> None:
245246

246247
if verbosity >= VerbosityLevel.INFO:
@@ -255,6 +256,7 @@ def run_profile_session(
255256
thermal_wait=thermal_wait,
256257
thermal_cont=thermal_cont,
257258
thermal_timeout=thermal_timeout,
259+
thermal_device=thermal_device,
258260
verbose=verbosity >= VerbosityLevel.DEBUG,
259261
)
260262
thermovision_initialized = thermal_controller.init()
@@ -454,6 +456,16 @@ class ProfileSettings:
454456
If None, uses default value (180 seconds).
455457
"""
456458

459+
thermal_device: int | None
460+
"""
461+
CUDA device ordinal to monitor for thermal throttling.
462+
463+
If set, monitor that CUDA device. If None, monitor the current CUDA device
464+
context.
465+
Device numbering follows CUDA runtime ordinals and honors
466+
``CUDA_VISIBLE_DEVICES``.
467+
"""
468+
457469
output_prefix: str | None
458470
"""
459471
All intermediate profiler files are created with this prefix

‎nsight/collection/ncu.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,7 @@ def collect(
410410
settings.thermal_wait,
411411
settings.thermal_cont,
412412
settings.thermal_timeout,
413+
settings.thermal_device,
413414
)
414415
finally:
415416
end_profiling()

‎nsight/thermovision.py‎

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
# Guard NVML imports
1919
try:
20+
from cuda.core import Device as CudaDevice
2021
from cuda.core import system
2122

2223
CUDA_CORE_AVAILABLE = True
@@ -52,6 +53,7 @@ def __init__(
5253
thermal_wait: int | None = None,
5354
thermal_cont: int | None = None,
5455
thermal_timeout: int | None = None,
56+
thermal_device: int | None = None,
5557
verbose: bool = False,
5658
):
5759
"""Initialize thermal controller.
@@ -71,10 +73,17 @@ def __init__(
7173
thermal_timeout: Maximum wait time in seconds for GPU to cool down.
7274
If None, uses default (180 seconds).
7375
Default: None
76+
thermal_device: CUDA device ordinal to monitor for thermal throttling.
77+
If set, monitor that CUDA device. If None, monitor the current
78+
CUDA device context. Device numbering follows CUDA runtime ordinals and
79+
honors ``CUDA_VISIBLE_DEVICES``.
80+
Default: None
7481
verbose: Whether to print thermal messages.
7582
Default: False
7683
"""
7784
self.device: Any = None
85+
self.thermal_device = thermal_device
86+
self._current_cuda_device_id: int | None = None
7887
self.thermal_mode = thermal_mode
7988

8089
# Set adaptive_mode based on thermal_mode
@@ -101,17 +110,111 @@ def __init__(
101110
def init(self) -> bool:
102111
"""Initialize NVML and get GPU handle.
103112
113+
Resolves the NVML device that corresponds to the profiled CUDA device
114+
(honoring ``CUDA_VISIBLE_DEVICES``).
115+
104116
Returns:
105117
True if temperature retrieval is supported, False otherwise.
106118
"""
107119
if not CUDA_CORE_AVAILABLE:
108120
return False
109121

110122
if self.device is None:
111-
self.device = system.Device(index=0)
123+
resolved_device = self._resolve_device()
124+
if resolved_device is None:
125+
return False
126+
self.device = resolved_device
112127

113128
return self._is_temp_retrieval_supported()
114129

130+
def _map_cuda_to_system_device(self, cuda_device: Any) -> Any:
131+
"""Map a CUDA device object to its corresponding NVML system device.
132+
133+
Args:
134+
cuda_device: CUDA device object to map.
135+
136+
Returns:
137+
The corresponding NVML ``system.Device``.
138+
"""
139+
system_device = cuda_device.to_system_device()
140+
self._current_cuda_device_id = cuda_device.device_id
141+
return system_device
142+
143+
def _resolve_device(self) -> Any:
144+
"""Resolve the NVML device to monitor.
145+
146+
The CUDA device (explicitly requested via ``thermal_device`` or the
147+
current CUDA device context) is mapped to its underlying physical
148+
NVML device by UUID. This ensures the GPU actually running the profiled
149+
kernels is monitored, even when ``CUDA_VISIBLE_DEVICES`` remaps device
150+
ordinals.
151+
152+
Returns:
153+
The resolved NVML ``system.Device`` to monitor, or ``None`` if no
154+
device could be resolved.
155+
"""
156+
cuda_ordinal = self.thermal_device
157+
try:
158+
# ``None`` selects the current CUDA device and already reflects
159+
# CUDA_VISIBLE_DEVICES. Mapping to system device is done by UUID.
160+
if self.verbose and cuda_ordinal is None:
161+
print(
162+
"[Thermovision] thermal_device not set; using current CUDA device context "
163+
"(set thermal_device to pin monitoring)."
164+
)
165+
cuda_device = CudaDevice(cuda_ordinal)
166+
system_device = self._map_cuda_to_system_device(cuda_device)
167+
if self.verbose:
168+
print(
169+
f"[Thermovision] Monitoring CUDA device {self._current_cuda_device_id} "
170+
f"(NVML index {system_device.index}, {system_device.name}, UUID {system_device.uuid})"
171+
)
172+
return system_device
173+
except Exception as e:
174+
# Fall back to physical GPU 0 so thermal protection still works on
175+
# single-GPU systems and when CUDA cannot be initialized here.
176+
if cuda_ordinal is not None:
177+
print(
178+
f"Warning: Could not resolve thermal_device={cuda_ordinal} "
179+
f"to an NVML device ({e}). Falling back to physical GPU 0."
180+
)
181+
self._current_cuda_device_id = None
182+
try:
183+
return system.Device(index=0)
184+
except Exception:
185+
return None
186+
187+
def _refresh_device_for_current_cuda_context(self) -> None:
188+
"""Refresh monitored NVML device when current CUDA device changes.
189+
190+
This applies only when ``thermal_device`` is not explicitly set. It keeps
191+
Thermovision aligned with runtime CUDA context switches in user code
192+
(for example via ``torch.cuda.set_device``).
193+
"""
194+
if self.thermal_device is not None:
195+
return
196+
197+
try:
198+
cuda_device = CudaDevice(None)
199+
current_cuda_id = cuda_device.device_id
200+
if (
201+
self.device is not None
202+
and self._current_cuda_device_id == current_cuda_id
203+
):
204+
return
205+
206+
self.device = self._map_cuda_to_system_device(cuda_device)
207+
if self.verbose:
208+
print(
209+
f"[Thermovision] Switched monitoring to CUDA device {self._current_cuda_device_id} "
210+
f"(NVML index {self.device.index}, {self.device.name}, UUID {self.device.uuid})"
211+
)
212+
except Exception:
213+
# Keep existing device if refresh fails. If no device exists yet,
214+
# try the regular resolution path (including fallback).
215+
if self.device is None:
216+
self.device = self._resolve_device()
217+
115218
def throttle_guard(self) -> None:
116219
"""Check thermal state and pause if GPU is too hot.
117220
@@ -129,6 +232,7 @@ def throttle_guard(self) -> None:
129232
- Many iterations (>TARGET_MAX_ITERATIONS): GPU heats slowly → decrease thermal_cont (cool less)
130233
4. Wait until GPU cools back to thermal_cont, then repeat
131234
"""
235+
self._refresh_device_for_current_cuda_context()
132236
tlimit = self._get_gpu_tlimit()
133237
if tlimit is None:
134238
return

‎tests/test_thermovision.py‎

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
# SPDX-License-Identifier: Apache-2.0
33

4-
from unittest.mock import patch
4+
import os
5+
from typing import Any
6+
from unittest.mock import MagicMock, patch
57

68
import pytest
79
import torch
@@ -186,6 +188,121 @@ def mock_temp() -> int:
186188
), f"Expected thermal_cont=17, got {controller.thermal_cont}"
187189

188190

191+
def test_resolve_device_uses_current_cuda_device() -> None:
192+
"""Without thermal_device, the current CUDA device is monitored (honors CUDA_VISIBLE_DEVICES)."""
193+
from nsight.thermovision import ThermalController
194+
195+
controller = ThermalController(thermal_mode="auto", verbose=False)
196+
197+
system_device = MagicMock(name="system_device")
198+
cuda_device = MagicMock(name="cuda_device")
199+
cuda_device.to_system_device.return_value = system_device
200+
cuda_device_cls = MagicMock(return_value=cuda_device)
201+
202+
with patch("nsight.thermovision.CudaDevice", cuda_device_cls):
203+
controller.init()
204+
205+
# CudaDevice(None) selects the CUDA runtime's current device.
206+
cuda_device_cls.assert_called_once_with(None)
207+
cuda_device.to_system_device.assert_called_once_with()
208+
assert controller.device is system_device
209+
210+
211+
def test_resolve_device_honors_explicit_thermal_device() -> None:
212+
"""An explicit thermal_device ordinal selects that CUDA device for monitoring."""
213+
from nsight.thermovision import ThermalController
214+
215+
controller = ThermalController(thermal_mode="auto", thermal_device=3, verbose=False)
216+
217+
system_device = MagicMock(name="system_device")
218+
cuda_device = MagicMock(name="cuda_device")
219+
cuda_device.to_system_device.return_value = system_device
220+
cuda_device_cls = MagicMock(return_value=cuda_device)
221+
222+
with patch("nsight.thermovision.CudaDevice", cuda_device_cls):
223+
controller.init()
224+
225+
cuda_device_cls.assert_called_once_with(3)
226+
assert controller.device is system_device
227+
228+
229+
def test_resolve_device_falls_back_to_physical_gpu_zero() -> None:
230+
"""If CUDA-to-NVML mapping fails, monitoring falls back to physical GPU 0."""
231+
from nsight.thermovision import ThermalController
232+
233+
controller = ThermalController(thermal_mode="auto", verbose=False)
234+
235+
fallback_device = MagicMock(name="fallback_device")
236+
cuda_device_cls = MagicMock(side_effect=RuntimeError("no cuda"))
237+
238+
with (
239+
patch("nsight.thermovision.CudaDevice", cuda_device_cls),
240+
patch(
241+
"nsight.thermovision.system.Device", return_value=fallback_device
242+
) as mock_system_device,
243+
):
244+
controller.init()
245+
246+
mock_system_device.assert_called_once_with(index=0)
247+
assert controller.device is fallback_device
248+
249+
250+
def test_throttle_guard_refreshes_when_current_cuda_device_changes() -> None:
251+
"""Without explicit thermal_device, guard refresh follows CUDA device switches."""
252+
from nsight.thermovision import ThermalController
253+
254+
controller = ThermalController(thermal_mode="auto", verbose=False)
255+
256+
system_device_0 = MagicMock(name="system_device_0")
257+
system_device_1 = MagicMock(name="system_device_1")
258+
259+
cuda_device_0 = MagicMock(name="cuda_device_0")
260+
cuda_device_0.device_id = 0
261+
cuda_device_0.to_system_device.return_value = system_device_0
262+
263+
cuda_device_1 = MagicMock(name="cuda_device_1")
264+
cuda_device_1.device_id = 1
265+
cuda_device_1.to_system_device.return_value = system_device_1
266+
267+
with (
268+
patch(
269+
"nsight.thermovision.CudaDevice",
270+
side_effect=[cuda_device_0, cuda_device_1, cuda_device_1],
271+
),
272+
patch(
273+
"nsight.thermovision.ThermalController._get_gpu_tlimit", return_value=100
274+
),
275+
):
276+
controller.init()
277+
assert controller.device is system_device_0
278+
controller.throttle_guard()
279+
280+
assert controller.device is system_device_1
281+
282+
283+
def test_throttle_guard_keeps_explicit_thermal_device_binding() -> None:
284+
"""With explicit thermal_device, guard should not refresh from current device."""
285+
from nsight.thermovision import ThermalController
286+
287+
controller = ThermalController(thermal_mode="auto", thermal_device=2, verbose=False)
288+
289+
system_device = MagicMock(name="system_device")
290+
cuda_device = MagicMock(name="cuda_device")
291+
cuda_device.to_system_device.return_value = system_device
292+
293+
with (
294+
patch("nsight.thermovision.CudaDevice", return_value=cuda_device) as mock_cuda,
295+
patch(
296+
"nsight.thermovision.ThermalController._get_gpu_tlimit", return_value=100
297+
),
298+
):
299+
controller.init()
300+
controller.throttle_guard()
301+
302+
assert mock_cuda.call_count == 1
303+
assert controller.device is system_device
304+
305+
189306
def test_fixed_mode_no_adaptation() -> None:
190307
"""Test that fixed mode does not adapt thermal_cont."""
191308
from nsight.thermovision import ThermalController

0 commit comments

Comments
 (0)