Skip to content

Commit f15e9a2

Browse files
sebergrwgk
andauthored
test(core,bindings): add mini parallel plugins in test conftests (#2228)
* TST: add mini parallel plugins in test conftests Add lightweight pytest plugins in bindings/core conftest files to wrap parallel worker tests with CUDA context setup and fixture-aware initialization paths used by mini plugin execution. * Add note that future pytest-run-parallel should make this nicer * Copy dict before mutating (this worked out but it is shady) --------- Co-authored-by: Ralf W. Grosse-Kunstleve <rwgkio@gmail.com>
1 parent 18f9bb5 commit f15e9a2

2 files changed

Lines changed: 155 additions & 20 deletions

File tree

cuda_bindings/tests/conftest.py

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

4+
import functools
5+
import inspect
46
import pathlib
57
import sys
8+
from contextlib import contextmanager
69
from importlib.metadata import PackageNotFoundError, distribution
710

811
import pytest
@@ -25,6 +28,80 @@
2528
sys.path.insert(0, test_helpers_root)
2629

2730

31+
def pytest_configure(config):
32+
# When using `parallel-threads` set up mini-plugin to ensure each thread has a CUDA context
33+
parallel_threads = getattr(config.option, "parallel_threads", 0)
34+
if parallel_threads == "auto" or int(parallel_threads) > 1:
35+
config.pluginmanager.register(_CudaBindingsParallelPlugin(), name="_cuda_bindings_parallel_plugin")
36+
37+
38+
@contextmanager
39+
def _thread_context():
40+
# Context setting up `device` and `ctx` for individual threads on
41+
# pytest-run-parallel
42+
err, device = cuda.cuDeviceGet(0)
43+
assert err == cuda.CUresult.CUDA_SUCCESS
44+
err, ctx = cuda.cuCtxCreate(None, 0, device)
45+
assert err == cuda.CUresult.CUDA_SUCCESS
46+
try:
47+
yield device, ctx
48+
finally:
49+
(err,) = cuda.cuCtxDestroy(ctx)
50+
assert err == cuda.CUresult.CUDA_SUCCESS
51+
52+
53+
def _wrap_worker_cuda_test(func):
54+
if getattr(func, "_cuda_bindings_worker_cuda_wrapped", False):
55+
return func
56+
57+
sig = inspect.signature(func)
58+
wants_device = "device" in sig.parameters
59+
wants_ctx = "ctx" in sig.parameters
60+
61+
@functools.wraps(func)
62+
def wrapper(*args, **kwargs):
63+
kwargs = dict(kwargs) # copy before mutating
64+
with _thread_context() as (device, ctx):
65+
# device is None when reusing an existing context (defensive path);
66+
# keep whatever the fixture provided in kwargs as-is.
67+
if wants_device and device is not None:
68+
kwargs["device"] = device
69+
if wants_ctx:
70+
kwargs["ctx"] = ctx
71+
return func(*args, **kwargs)
72+
73+
wrapper._cuda_bindings_worker_cuda_wrapped = True
74+
return wrapper
75+
76+
77+
def _item_needs_thread_ctx(item):
78+
fixturenames = getattr(item, "fixturenames", ())
79+
# The 'device' fixture is the main fixture to set up a CUDA context.
80+
# 'driver' is specific to the cufile tests and used there instead.
81+
return "device" in fixturenames or "driver" in fixturenames
82+
83+
84+
class _CudaBindingsParallelPlugin:
85+
"""A mini pytest plugin used only for pytest-run-parallel testing.
86+
pytest-run-parallel spawns new threads for each test and we need to
87+
initialize and pass the correct CUDA context for each these.
88+
89+
This plugin looks for context specific fixtures and replaces them
90+
new context specific fixtures may have to be added.
91+
92+
93+
This plugin approach is not ideal, it would be nicer to introduce hooks
94+
into pytest-run-parallel. Once that issue is closed this would be good
95+
to refactor: https://github.com/Quansight-Labs/pytest-run-parallel/issues/189
96+
"""
97+
98+
@pytest.hookimpl()
99+
def pytest_collection_modifyitems(self, config, items):
100+
for item in items:
101+
if _item_needs_thread_ctx(item):
102+
item.obj = _wrap_worker_cuda_test(item.obj)
103+
104+
28105
@pytest.fixture(scope="module")
29106
def cuda_driver():
30107
(err,) = cuda.cuInit(0)

cuda_core/tests/conftest.py

Lines changed: 78 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
# SPDX-License-Identifier: Apache-2.0
33

4+
import functools
45
import multiprocessing
56
import os
67
import pathlib
@@ -91,6 +92,75 @@ def xfail_if_mempool_oom(err_or_exc, api_name=None, device=0):
9192
sys.path.insert(0, test_helpers_root)
9293

9394

95+
def pytest_configure(config):
96+
# When using `parallel-threads` set up mini-plugin to ensure each thread has a CUDA context
97+
parallel_threads = getattr(config.option, "parallel_threads", 0)
98+
if parallel_threads == "auto" or int(parallel_threads) > 1:
99+
config.pluginmanager.register(_CudaCoreParallelPlugin(), name="_cuda_core_parallel_plugin")
100+
101+
102+
@contextmanager
103+
def _init_cuda_context():
104+
# TODO: rename this to e.g. init_context
105+
device = Device(0)
106+
device.set_current()
107+
108+
# Set option to avoid spin-waiting on synchronization.
109+
if int(os.environ.get("CUDA_CORE_TEST_BLOCKING_SYNC", 0)) != 0:
110+
handle_return(
111+
driver.cuDevicePrimaryCtxSetFlags(device.device_id, driver.CUctx_flags.CU_CTX_SCHED_BLOCKING_SYNC)
112+
)
113+
114+
try:
115+
yield device
116+
finally:
117+
_ = _device_unset_current()
118+
119+
120+
def _wrap_worker_cuda_test(func):
121+
if getattr(func, "_cuda_core_worker_cuda_wrapped", False):
122+
return func
123+
124+
@functools.wraps(func)
125+
def wrapper(*args, **kwargs):
126+
kwargs = dict(kwargs) # copy before mutating
127+
with _init_cuda_context() as device:
128+
if "init_cuda" in kwargs:
129+
kwargs["init_cuda"] = device
130+
if "mempool_device_x2" in kwargs:
131+
kwargs["mempool_device_x2"] = _mempool_device_impl(2)
132+
if "mempool_device_x3" in kwargs:
133+
kwargs["mempool_device_x3"] = _mempool_device_impl(3)
134+
return func(*args, **kwargs)
135+
136+
wrapper._cuda_core_worker_cuda_wrapped = True
137+
return wrapper
138+
139+
140+
def _item_uses_init_cuda(item):
141+
return "init_cuda" in getattr(item, "fixturenames", ())
142+
143+
144+
class _CudaCoreParallelPlugin:
145+
"""A mini pytest plugin used only for pytest-run-parallel testing.
146+
pytest-run-parallel spawns new threads for each test and we need to
147+
initialize and pass the correct CUDA context for each these.
148+
149+
This plugin looks for context specific fixtures and replaces them
150+
new context specific fixtures may have to be added.
151+
152+
This plugin approach is not ideal, it would be nicer to introduce hooks
153+
into pytest-run-parallel. Once that issue is closed this would be good
154+
to refactor: https://github.com/Quansight-Labs/pytest-run-parallel/issues/189
155+
"""
156+
157+
@pytest.hookimpl(tryfirst=True)
158+
def pytest_collection_modifyitems(self, config, items):
159+
for item in items:
160+
if _item_uses_init_cuda(item):
161+
item.obj = _wrap_worker_cuda_test(item.obj)
162+
163+
94164
def skip_if_pinned_memory_unsupported(device):
95165
try:
96166
if not device.properties.host_memory_pools_supported:
@@ -194,18 +264,8 @@ def session_setup():
194264

195265
@pytest.fixture
196266
def init_cuda():
197-
# TODO: rename this to e.g. init_context
198-
device = Device(0)
199-
device.set_current()
200-
201-
# Set option to avoid spin-waiting on synchronization.
202-
if int(os.environ.get("CUDA_CORE_TEST_BLOCKING_SYNC", 0)) != 0:
203-
handle_return(
204-
driver.cuDevicePrimaryCtxSetFlags(device.device_id, driver.CUctx_flags.CU_CTX_SCHED_BLOCKING_SYNC)
205-
)
206-
207-
yield device
208-
_ = _device_unset_current()
267+
with _init_cuda_context() as device:
268+
yield device
209269

210270

211271
def _device_unset_current() -> bool:
@@ -247,7 +307,7 @@ def pop_all_contexts():
247307

248308

249309
@pytest.fixture
250-
def ipc_device():
310+
def ipc_device(init_cuda):
251311
"""Obtains a device suitable for IPC-enabled mempool tests, or skips.
252312
253313
The fixture also tracks every ``multiprocessing.Process`` spawned during
@@ -257,8 +317,7 @@ def ipc_device():
257317
"""
258318
from helpers.child_processes import track_child_processes
259319

260-
device = Device(0)
261-
device.set_current()
320+
device = init_cuda
262321

263322
if not device.properties.memory_pools_supported:
264323
pytest.skip("Device does not support mempool operations")
@@ -296,10 +355,9 @@ def ipc_memory_resource(request, ipc_device):
296355

297356

298357
@pytest.fixture
299-
def mempool_device():
358+
def mempool_device(init_cuda):
300359
"""Obtains a device suitable for mempool tests, or skips."""
301-
device = Device(0)
302-
device.set_current()
360+
device = init_cuda
303361

304362
if not device.properties.memory_pools_supported:
305363
pytest.skip("Device does not support mempool operations")
@@ -326,13 +384,13 @@ def _mempool_device_impl(num):
326384

327385

328386
@pytest.fixture
329-
def mempool_device_x2():
387+
def mempool_device_x2(init_cuda):
330388
"""Fixture that provides two devices if available, otherwise skips test."""
331389
return _mempool_device_impl(2)
332390

333391

334392
@pytest.fixture
335-
def mempool_device_x3():
393+
def mempool_device_x3(init_cuda):
336394
"""Fixture that provides three devices if available, otherwise skips test."""
337395
return _mempool_device_impl(3)
338396

0 commit comments

Comments
 (0)