Skip to content

Commit fd5ef19

Browse files
committed
test(cuda_core): capture machine state on the first CUDA OOM
1 parent f5fafa3 commit fd5ef19

3 files changed

Lines changed: 343 additions & 0 deletions

File tree

‎cuda_core/tests/conftest.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,23 @@ def pytest_configure(config):
5454
config.pluginmanager.register(_CudaCoreParallelPlugin(), name="_cuda_core_parallel_plugin")
5555

5656

57+
@pytest.hookimpl(wrapper=True)
58+
def pytest_runtest_makereport(item, call):
59+
# Captures machine state on the first CUDA OOM of a session; see issue
60+
# #2381 and helpers/oom_diagnostics.py for why this is latched.
61+
report = yield
62+
from helpers import oom_diagnostics
63+
64+
oom_diagnostics.record_if_oom(item, call, report)
65+
return report
66+
67+
68+
def pytest_terminal_summary(terminalreporter):
69+
from helpers import oom_diagnostics
70+
71+
oom_diagnostics.report_terminal_summary(terminalreporter)
72+
73+
5774
@contextmanager
5875
def _init_cuda_context():
5976
# TODO: rename this to e.g. init_context
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Machine-state capture for the first CUDA OOM of a test session (issue #2381).
5+
6+
A failing ``cuda_core`` run reports ~190 ``CUDA_ERROR_OUT_OF_MEMORY`` failures
7+
that all descend from a single earlier event, so only the first is worth
8+
capturing; running ``nvidia-smi`` on every one would add minutes and bury the
9+
log. Hence the latch in :class:`OomDiagnosticsRecorder`.
10+
"""
11+
12+
import os
13+
import pathlib
14+
import subprocess
15+
import sys
16+
import threading
17+
18+
from cuda.bindings import driver
19+
20+
OOM_MARKER = "CUDA_ERROR_OUT_OF_MEMORY"
21+
DEFAULT_FILENAME = "cuda_core_oom_diagnostics.txt"
22+
23+
_BANNER = "=" * 78
24+
25+
26+
def format_probe(label, fn, *args):
27+
"""Call a driver API and render its raw result."""
28+
try:
29+
return f"{label} -> {fn(*args)!r}"
30+
except Exception as exc: # diagnostics must never mask the original test failure
31+
return f"{label} -> <raised {exc!r}>"
32+
33+
34+
def probe_driver_state():
35+
"""Query the driver directly, bypassing cuda.core's error reporting.
36+
37+
cuda.core surfaces handle-creation failures through a thread-local "last
38+
error" slot (see cuda/core/_cpp/DESIGN.md). Reading the driver directly
39+
shows whether the device is genuinely out of memory and whether the default
40+
mempool is actually unavailable, which is what distinguishes real
41+
exhaustion from a mempool setup failure.
42+
"""
43+
lines = ["--- direct driver probe (bypasses cuda.core error reporting) ---"]
44+
lines.append(format_probe("cuCtxGetCurrent()", driver.cuCtxGetCurrent))
45+
lines.append(format_probe("cuMemGetInfo()", driver.cuMemGetInfo))
46+
47+
try:
48+
err, count = driver.cuDeviceGetCount()
49+
except Exception as exc: # see format_probe
50+
lines.append(f"cuDeviceGetCount() -> <raised {exc!r}>")
51+
return "\n".join(lines)
52+
53+
lines.append(f"cuDeviceGetCount() -> ({err!r}, {count})")
54+
if err != driver.CUresult.CUDA_SUCCESS:
55+
return "\n".join(lines)
56+
57+
for ordinal in range(count):
58+
try:
59+
err, dev = driver.cuDeviceGet(ordinal)
60+
except Exception as exc: # see format_probe
61+
lines.append(f"cuDeviceGet({ordinal}) -> <raised {exc!r}>")
62+
continue
63+
if err != driver.CUresult.CUDA_SUCCESS:
64+
lines.append(f"cuDeviceGet({ordinal}) -> {err!r}")
65+
continue
66+
lines.append(format_probe(f"cuDeviceGetMemPool(dev {ordinal})", driver.cuDeviceGetMemPool, dev))
67+
lines.append(format_probe(f"cuDeviceGetDefaultMemPool(dev {ordinal})", driver.cuDeviceGetDefaultMemPool, dev))
68+
69+
return "\n".join(lines)
70+
71+
72+
def run_nvidia_smi(*args):
73+
cmd = ["nvidia-smi", *args]
74+
printable = " ".join(cmd)
75+
try:
76+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60, check=False) # noqa: S603
77+
except (OSError, subprocess.SubprocessError) as exc:
78+
return f"$ {printable}\n<could not run: {exc!r}>"
79+
output = proc.stdout.strip() or proc.stderr.strip() or "<no output>"
80+
return f"$ {printable}\n(exit {proc.returncode})\n{output}"
81+
82+
83+
class OomDiagnosticsRecorder:
84+
"""Captures machine state the first time a CUDA OOM is seen, and only then."""
85+
86+
def __init__(self, filename=DEFAULT_FILENAME):
87+
self._filename = filename
88+
self._lock = threading.Lock()
89+
self._captured = False
90+
self._nodeid = None
91+
self._artifact_path = None
92+
self._artifact_written = False
93+
94+
@property
95+
def captured(self):
96+
return self._captured
97+
98+
@property
99+
def nodeid(self):
100+
"""Node id of the test that triggered capture, or None."""
101+
return self._nodeid
102+
103+
@property
104+
def artifact_path(self):
105+
"""Where the report was written, or None if nothing was captured."""
106+
return self._artifact_path
107+
108+
@property
109+
def artifact_written(self):
110+
return self._artifact_written
111+
112+
@staticmethod
113+
def matches(exc_text):
114+
return OOM_MARKER in exc_text
115+
116+
def build_report(self, nodeid, phase, exc_text):
117+
return "\n".join(
118+
[
119+
_BANNER,
120+
"cuda_core diagnostics: first CUDA_ERROR_OUT_OF_MEMORY of this session",
121+
_BANNER,
122+
f"test: {nodeid}",
123+
f"phase: {phase}",
124+
f"pid: {os.getpid()}",
125+
f"platform: {sys.platform}",
126+
f"exception: {exc_text}",
127+
"",
128+
probe_driver_state(),
129+
"",
130+
run_nvidia_smi("-q"),
131+
"",
132+
run_nvidia_smi("--query-compute-apps=timestamp,pid,process_name,used_memory", "--format=csv"),
133+
_BANNER,
134+
]
135+
)
136+
137+
def capture(self, nodeid, phase, exc_text, directory):
138+
"""Build and persist the report. Returns None if already captured."""
139+
with self._lock:
140+
if self._captured:
141+
return None
142+
self._captured = True
143+
144+
report = self.build_report(nodeid, phase, exc_text)
145+
destination = pathlib.Path(directory) / self._filename
146+
self._nodeid = nodeid
147+
self._artifact_path = destination
148+
try:
149+
destination.write_text(report, encoding="utf-8")
150+
self._artifact_written = True
151+
return f"{report}\n(diagnostics also written to {destination})"
152+
except OSError as exc:
153+
return f"{report}\n(could not write {destination}: {exc!r})"
154+
155+
156+
_default_recorder = OomDiagnosticsRecorder()
157+
158+
159+
def record_if_oom(item, call, report, recorder=None):
160+
"""Capture diagnostics when ``report`` is the session's first CUDA OOM.
161+
162+
``recorder`` defaults to a module-level singleton so the conftest hook does
163+
not have to hold session state; tests pass their own to stay isolated.
164+
165+
Returns the emitted text, or None when nothing was captured.
166+
"""
167+
if recorder is None:
168+
recorder = _default_recorder
169+
170+
if recorder.captured or not report.failed or call.excinfo is None:
171+
return None
172+
173+
exc_text = str(call.excinfo.value)
174+
if not recorder.matches(exc_text):
175+
return None
176+
177+
text = recorder.capture(item.nodeid, call.when, exc_text, item.config.rootpath)
178+
if text is None:
179+
return None
180+
181+
# terminalreporter writes outside pytest's stdout capture, so this survives
182+
# into a redirected log; a bare print() would not.
183+
terminal_reporter = item.config.pluginmanager.get_plugin("terminalreporter")
184+
if terminal_reporter is not None:
185+
terminal_reporter.write_line("")
186+
terminal_reporter.write_line(text)
187+
return text
188+
189+
190+
def report_terminal_summary(terminalreporter, recorder=None):
191+
"""Point at the diagnostics artifact from pytest's end-of-run summary.
192+
193+
The report itself is emitted beside the failing test, which in a real
194+
failing run is thousands of lines above the summary people actually read.
195+
196+
Returns the emitted line, or None when nothing was captured.
197+
"""
198+
if recorder is None:
199+
recorder = _default_recorder
200+
201+
if not recorder.captured:
202+
return None
203+
204+
verb = "written to" if recorder.artifact_written else "could NOT be written to"
205+
line = f"first CUDA OOM at {recorder.nodeid}; diagnostics {verb} {recorder.artifact_path}"
206+
terminalreporter.write_sep("=", "cuda_core OOM diagnostics", red=True)
207+
terminalreporter.write_line(line)
208+
return line

‎cuda_core/tests/test_helpers.py‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,21 @@
33
# SPDX-License-Identifier: Apache-2.0
44

55
import time
6+
import types
67

78
import pytest
89
from helpers.buffers import PatternGen, compare_equal_buffers, make_scratch_buffer
910
from helpers.latch import LatchKernel
1011
from helpers.logging import TimestampedLogger
12+
from helpers.oom_diagnostics import (
13+
DEFAULT_FILENAME,
14+
OOM_MARKER,
15+
OomDiagnosticsRecorder,
16+
probe_driver_state,
17+
record_if_oom,
18+
report_terminal_summary,
19+
run_nvidia_smi,
20+
)
1121

1222
from cuda.core import Device
1323
from cuda_python_test_helpers import under_compute_sanitizer
@@ -80,3 +90,111 @@ def test_patterngen_values():
8090
pgen = PatternGen(device, NBYTES)
8191
pgen.verify_buffer(ones, value=1)
8292
pgen.verify_buffer(twos, value=2)
93+
94+
95+
# helpers.oom_diagnostics (issue #2381). These only ever run on a failing
96+
# session, so no other test would notice if they silently broke.
97+
98+
99+
def _fake_report(failed):
100+
return types.SimpleNamespace(failed=failed)
101+
102+
103+
def _fake_call(exc_text, when="call"):
104+
excinfo = None if exc_text is None else types.SimpleNamespace(value=RuntimeError(exc_text))
105+
return types.SimpleNamespace(excinfo=excinfo, when=when)
106+
107+
108+
def _fake_item(rootpath, nodeid="tests/test_x.py::test_a"):
109+
# get_plugin returns None so record_if_oom skips the terminal write.
110+
config = types.SimpleNamespace(
111+
rootpath=rootpath,
112+
pluginmanager=types.SimpleNamespace(get_plugin=lambda _: None),
113+
)
114+
return types.SimpleNamespace(nodeid=nodeid, config=config)
115+
116+
117+
@pytest.mark.agent_authored(model="claude-opus-5")
118+
@pytest.mark.parametrize(
119+
("failed", "exc_text", "should_capture"),
120+
[
121+
(True, f"boom {OOM_MARKER}", True),
122+
(True, "CUDA_ERROR_INVALID_CONTEXT", False),
123+
(False, f"boom {OOM_MARKER}", False),
124+
(True, None, False),
125+
],
126+
)
127+
def test_oom_diagnostics_fire_only_on_a_failing_oom(tmp_path, failed, exc_text, should_capture):
128+
recorder = OomDiagnosticsRecorder()
129+
result = record_if_oom(_fake_item(tmp_path), _fake_call(exc_text), _fake_report(failed), recorder=recorder)
130+
131+
assert (result is not None) == should_capture
132+
assert recorder.captured == should_capture
133+
134+
135+
@pytest.mark.agent_authored(model="claude-opus-5")
136+
def test_oom_diagnostics_write_an_artifact_naming_the_failing_test(tmp_path, monkeypatch):
137+
# Omitting `recorder` also pins the call signature conftest.py relies on.
138+
# Patching the singleton keeps this from latching diagnostics for the run.
139+
recorder = OomDiagnosticsRecorder()
140+
monkeypatch.setattr("helpers.oom_diagnostics._default_recorder", recorder)
141+
142+
text = record_if_oom(
143+
_fake_item(tmp_path, nodeid="tests/test_x.py::test_first"),
144+
_fake_call(f"boom {OOM_MARKER}"),
145+
_fake_report(True),
146+
)
147+
148+
written = (tmp_path / DEFAULT_FILENAME).read_text(encoding="utf-8")
149+
assert "tests/test_x.py::test_first" in written
150+
assert OOM_MARKER in written
151+
assert "tests/test_x.py::test_first" in text
152+
153+
154+
@pytest.mark.agent_authored(model="claude-opus-5")
155+
def test_oom_diagnostics_summary_points_at_the_artifact(tmp_path):
156+
# The report is emitted beside the failing test, thousands of lines above
157+
# the summary; without this line the artifact is effectively invisible.
158+
recorder = OomDiagnosticsRecorder()
159+
lines = []
160+
reporter = types.SimpleNamespace(write_sep=lambda *_, **__: None, write_line=lines.append)
161+
162+
assert report_terminal_summary(reporter, recorder=recorder) is None
163+
assert lines == []
164+
165+
recorder.capture("tests/test_x.py::test_first", "call", OOM_MARKER, tmp_path)
166+
emitted = report_terminal_summary(reporter, recorder=recorder)
167+
168+
assert "tests/test_x.py::test_first" in emitted
169+
assert str(tmp_path) in emitted
170+
assert emitted in lines
171+
172+
173+
@pytest.mark.agent_authored(model="claude-opus-5")
174+
def test_oom_diagnostics_latch_to_the_first_oom(tmp_path):
175+
# A failing run produces ~190 OOMs; capturing each would bury the log.
176+
recorder = OomDiagnosticsRecorder()
177+
assert recorder.capture("first", "call", OOM_MARKER, tmp_path) is not None
178+
assert recorder.captured
179+
assert recorder.capture("second", "call", OOM_MARKER, tmp_path) is None
180+
181+
assert "first" in (tmp_path / DEFAULT_FILENAME).read_text(encoding="utf-8")
182+
183+
184+
@pytest.mark.agent_authored(model="claude-opus-5")
185+
def test_oom_diagnostics_survive_a_missing_nvidia_smi(monkeypatch):
186+
# Raising inside the pytest hook would mask the failure being diagnosed.
187+
def _explode(*args, **kwargs):
188+
raise FileNotFoundError("nvidia-smi")
189+
190+
monkeypatch.setattr("helpers.oom_diagnostics.subprocess.run", _explode)
191+
assert "could not run" in run_nvidia_smi("-q")
192+
193+
194+
@pytest.mark.agent_authored(model="claude-opus-5")
195+
def test_oom_diagnostics_probe_reports_live_driver_state(init_cuda):
196+
text = probe_driver_state()
197+
assert "cuMemGetInfo()" in text
198+
assert "cuDeviceGetMemPool" in text
199+
# A healthy device must report a usable default mempool.
200+
assert "CUDA_SUCCESS" in text

0 commit comments

Comments
 (0)