Skip to content

Commit ef4bf66

Browse files
Alok-Joshiclaude
andcommitted
Use pytest-isolated for tests that need a fresh process
Add pytest-isolated to the hatch test environment and use it where a test needs a clean interpreter: - Rewrite test_cuda_init_before_nsight as an isolated test that asserts on the raised ProfilerException instead of matching subprocess stderr. - Add unit tests for nsight.activate, deactivate and get_active_tool. The active tool is module-global state, so each test runs in its own process with the tool loaders mocked. - Make conftest.py skip its tool setup inside the isolated child process, so the tests control import order themselves. Rename the --tool-init mode ncu-lazy to none, since it means that no tool is active up front. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Signed-off-by: Alok Joshi <aljoshi@nvidia.com>
1 parent 26e191e commit ef4bf66

5 files changed

Lines changed: 159 additions & 54 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ are skipped without them.
291291

292292
```bash
293293
pip install -e ".[cu13]" # or [cu12]; add ,cupti for the CUPTI backend
294-
pip install pytest torch # pick the torch build matching your CUDA, see pytorch.org
294+
pip install pytest pytest-isolated torch # pick the torch build matching your CUDA, see pytorch.org
295295
hatch run lint:check # black --check, isort --check, mypy --strict (whole repo, incl. examples/tests)
296296
hatch run lint:format # apply black + isort
297297
hatch run docs:build # sphinx-build -W (warnings are errors)

‎conftest.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"ncu-at-import": ("1", None),
1313
# Nothing is active up front, so nsight.analyze.kernel activates NCU on the
1414
# first call to a decorated function.
15-
"ncu-lazy": ("0", None),
15+
"none": ("0", None),
1616
# CUPTI is activated here instead; nsight must not claim NCU at import.
1717
"cupti": ("0", "cupti"),
1818
}
@@ -26,7 +26,7 @@ def pytest_addoption(parser: pytest.Parser) -> None:
2626
help=(
2727
"How the nsight tool is initialized for the suite. 'ncu-at-import' "
2828
"(the default) lets nsight activate NCU while it is imported. "
29-
"'ncu-lazy' leaves no tool active so the lazy NCU load in "
29+
"'none' leaves no tool active so the lazy NCU load in "
3030
"nsight.analyze.kernel is exercised on the first decorated call. "
3131
"'cupti' activates CUPTI before the suite runs. Only one tool can be "
3232
"active per process, so this mode fully determines both "
@@ -36,6 +36,14 @@ def pytest_addoption(parser: pytest.Parser) -> None:
3636

3737

3838
def pytest_configure(config: pytest.Config) -> None:
39+
# pytest-isolated re-runs selected tests in a fresh child pytest process
40+
# with the same arguments. That child must start with nsight unimported and
41+
# no tool active, since the isolated tests control import order themselves
42+
# (e.g. initializing CUDA before importing nsight). The parent's
43+
# NSPY_NCU_INIT_AT_IMPORT is inherited by the child through the environment.
44+
if os.environ.get("PYTEST_RUNNING_IN_SUBPROCESS") == "1":
45+
return
46+
3947
init_at_import, tool_name = _INIT_MODES[config.getoption("--tool-init")]
4048

4149
# Set before nsight is imported: it reads this while importing, which is why

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ format = [
105105
dependencies = [
106106
"pytest",
107107
"pytest-cov",
108+
"pytest-isolated",
108109
]
109110
system-packages = true
110111
[tool.hatch.envs.test.scripts]

‎tests/test_cuda_init_before_nsight.py‎

Lines changed: 33 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -5,65 +5,47 @@
55
This is used to test the scenario when a user initializes CUDA before importing nsight.
66
NCU attach-mode requires CUDA to not be initialized before the injection library is loaded
77
so profiling fails with an error like "Cuda is initialized before the tool".
8-
"""
98
10-
import subprocess
11-
import sys
12-
from pathlib import Path
9+
The test runs in a fresh subprocess via pytest-isolated, because nsight must not have
10+
been imported yet. The root conftest.py skips its tool setup in that subprocess.
11+
"""
1312

1413
import pytest
1514
import torch
1615

1716

18-
def main() -> None:
19-
20-
torch.cuda.init() # any torch call that initializes CUDA would do
21-
import nsight # this loads NCU, and the test needs it loaded after CUDA init
22-
23-
@nsight.analyze.kernel()
24-
def run_simple_kernel(n: int) -> None:
25-
a = torch.randn(n, n, device="cuda")
26-
b = torch.randn(n, n, device="cuda")
27-
with nsight.annotate("test"):
28-
_ = a @ b
29-
30-
# under NSPY_NCU_INIT_AT_IMPORT=0 the load is deferred to here instead, still after CUDA init
31-
run_simple_kernel(64)
32-
33-
34-
def test() -> None:
35-
"""Run this program as a subprocess; profiling must fail with the expected error."""
36-
# Imported here, not at module scope: importing nsight before torch.cuda.init()
37-
# in main() would defeat what this test exercises.
38-
import nsight
39-
40-
if nsight.get_active_tool() == nsight.Tool.CUPTI:
17+
@pytest.mark.isolated
18+
def test_cuda_init_before_nsight(request: pytest.FixtureRequest) -> None:
19+
"""Profiling must fail with the expected error when CUDA is initialized first."""
20+
if request.config.getoption("--tool-init") == "cupti":
4121
pytest.skip(
4222
"NCU-only test: validates injection failure when CUDA is pre-initialized"
4323
)
4424

45-
this_file = Path(__file__).resolve()
46-
result = subprocess.run(
47-
[sys.executable, str(this_file)],
48-
capture_output=True,
49-
text=True,
50-
timeout=120,
51-
cwd=this_file.parent.parent,
52-
)
53-
54-
# Process should fail with a ProfilerException raised by begin_profiling()
55-
assert result.returncode != 0, "Subprocess unexpectedly succeeded"
56-
57-
stderr_lower = (result.stderr or "").lower()
58-
# Expect the standardized injection error message with the code emitted by ncu injection
59-
# When CUDA is already initialized before injection, we expect code 3 (NV_INJ_ERROR_NOT_INITIALIZED)
60-
assert (
61-
"failed to begin profiling" in stderr_lower
62-
), f"Unexpected stderr: {result.stderr}"
63-
assert (
64-
"(code=3)" in stderr_lower
65-
), f"Expected injection error code 3 in stderr, got: {result.stderr}"
66-
25+
torch.cuda.init() # any torch call that initializes CUDA would do
6726

68-
if __name__ == "__main__":
69-
main()
27+
# Importing nsight (or, with --tool-init=none, the first decorated call) loads NCU,
28+
# and the test needs that to happen after CUDA init.
29+
with pytest.raises(Exception) as exc_info:
30+
import nsight
31+
32+
@nsight.analyze.kernel()
33+
def run_simple_kernel(n: int) -> None:
34+
a = torch.randn(n, n, device="cuda")
35+
b = torch.randn(n, n, device="cuda")
36+
with nsight.annotate("test"):
37+
_ = a @ b
38+
39+
run_simple_kernel(64)
40+
41+
from nsight.exceptions import ProfilerException
42+
43+
assert isinstance(
44+
exc_info.value, ProfilerException
45+
), f"Unexpected exception: {exc_info.value!r}"
46+
message = str(exc_info.value).lower()
47+
# Expect the standardized injection error message with the code emitted by ncu injection.
48+
# When CUDA is already initialized before injection, we expect code 3
49+
# (NV_INJ_ERROR_NOT_INITIALIZED)
50+
assert "failed to begin profiling" in message, f"Unexpected error: {message}"
51+
assert "(code=3)" in message, f"Expected injection error code 3, got: {message}"

‎tests/test_tools_manager.py‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""
5+
Unit tests for nsight.activate / nsight.deactivate / nsight.get_active_tool.
6+
7+
The active tool is module-global state, and NCU cannot be unloaded once loaded, so
8+
every test runs in its own subprocess via pytest-isolated. The root conftest.py
9+
leaves the subprocess with no tool active. The tool loaders are replaced with
10+
recorders, so no GPU, ncu or CUPTI is needed.
11+
"""
12+
13+
import warnings
14+
from typing import Any
15+
16+
import pytest
17+
18+
19+
class Recorder:
20+
"""Stands in for the tool loaders and records how often each was called."""
21+
22+
def __init__(self) -> None:
23+
self.calls: list[str] = []
24+
25+
26+
@pytest.fixture
27+
def tools(monkeypatch: pytest.MonkeyPatch) -> Any:
28+
# Keep `import nsight` from loading NCU itself, which would make NCU active.
29+
monkeypatch.setenv("NSPY_NCU_INIT_AT_IMPORT", "0")
30+
31+
import nsight
32+
from nsight.collection import cupti, ncu
33+
34+
assert nsight.get_active_tool() is None, "child must start with no tool active"
35+
36+
rec = Recorder()
37+
monkeypatch.setattr(ncu, "init_injection", lambda: rec.calls.append("ncu.init"))
38+
monkeypatch.setattr(cupti, "init", lambda: rec.calls.append("cupti.init"))
39+
monkeypatch.setattr(cupti, "finalize", lambda: rec.calls.append("cupti.finalize"))
40+
return nsight, rec
41+
42+
43+
@pytest.mark.isolated
44+
def test_no_tool_active_initially(tools: Any) -> None:
45+
nsight, rec = tools
46+
assert nsight.get_active_tool() is None
47+
assert rec.calls == []
48+
49+
50+
@pytest.mark.isolated
51+
def test_activate_rejects_non_tool(tools: Any) -> None:
52+
nsight, rec = tools
53+
with pytest.raises(ValueError, match="nsight.Tool"):
54+
nsight.activate("ncu")
55+
assert nsight.get_active_tool() is None
56+
assert rec.calls == []
57+
58+
59+
@pytest.mark.isolated
60+
@pytest.mark.parametrize(
61+
"tool_name, load_call", [("NCU", "ncu.init"), ("CUPTI", "cupti.init")]
62+
)
63+
def test_activate_loads_tool_once(tools: Any, tool_name: str, load_call: str) -> None:
64+
nsight, rec = tools
65+
tool = nsight.Tool[tool_name]
66+
nsight.activate(tool)
67+
assert nsight.get_active_tool() == tool
68+
nsight.activate(tool) # same tool again is a no-op
69+
assert nsight.get_active_tool() == tool
70+
assert rec.calls == [load_call]
71+
72+
73+
@pytest.mark.isolated
74+
@pytest.mark.parametrize("first, second", [("NCU", "CUPTI"), ("CUPTI", "NCU")])
75+
def test_activate_different_tool_raises(tools: Any, first: str, second: str) -> None:
76+
nsight, rec = tools
77+
nsight.activate(nsight.Tool[first])
78+
with pytest.raises(RuntimeError, match="already active"):
79+
nsight.activate(nsight.Tool[second])
80+
assert nsight.get_active_tool() == nsight.Tool[first]
81+
82+
83+
@pytest.mark.isolated
84+
def test_deactivate_without_active_tool_is_noop(tools: Any) -> None:
85+
nsight, rec = tools
86+
with warnings.catch_warnings():
87+
warnings.simplefilter("error")
88+
nsight.deactivate()
89+
assert nsight.get_active_tool() is None
90+
assert rec.calls == []
91+
92+
93+
@pytest.mark.isolated
94+
def test_deactivate_ncu_warns_and_stays_active(tools: Any) -> None:
95+
nsight, rec = tools
96+
nsight.activate(nsight.Tool.NCU)
97+
with pytest.warns(RuntimeWarning, match="cannot be deactivated"):
98+
nsight.deactivate()
99+
assert nsight.get_active_tool() == nsight.Tool.NCU
100+
# NCU stays loaded, so no other tool can take over.
101+
with pytest.raises(RuntimeError):
102+
nsight.activate(nsight.Tool.CUPTI)
103+
104+
105+
@pytest.mark.isolated
106+
def test_deactivate_cupti_clears_selection(tools: Any) -> None:
107+
nsight, rec = tools
108+
nsight.activate(nsight.Tool.CUPTI)
109+
nsight.deactivate()
110+
assert nsight.get_active_tool() is None
111+
assert rec.calls == ["cupti.init", "cupti.finalize"]
112+
# With CUPTI torn down, another tool can be activated.
113+
nsight.activate(nsight.Tool.NCU)
114+
assert nsight.get_active_tool() == nsight.Tool.NCU

0 commit comments

Comments
 (0)