Skip to content

Commit e55a371

Browse files
Andy-Jostclaude
andcommitted
test(cuda.bindings): reuse worker-thread contexts under pytest-run-parallel
The pytest-run-parallel plugin in the bindings conftest made each worker thread create and destroy a CUDA context around every test. The driver serializes context creation, so with four threads every test paid about 0.75 s. On free-threaded rows that run the bindings tests, the step took 36 to 39 minutes and the jobs hit the 60-minute limit. Keep one context per running thread, but reuse it. A pool hands each worker an idle context or creates one; the worker pushes it, runs the test, synchronizes, and pops it, so the previous current context is restored. A context that reports an error at the sync is destroyed, not reused. The plugin destroys the pooled contexts at each module boundary, so a context lives no longer than the module-scoped ctx fixture does in a run without threads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 166cd5a commit e55a371

1 file changed

Lines changed: 74 additions & 13 deletions

File tree

‎cuda_bindings/tests/conftest.py‎

Lines changed: 74 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import inspect
77
import pathlib
88
import sys
9+
import threading
910
from contextlib import contextmanager
1011

1112
import pytest
@@ -37,19 +38,72 @@ def pytest_configure(config):
3738
config.pluginmanager.register(_CudaBindingsParallelPlugin(), name="_cuda_bindings_parallel_plugin")
3839

3940

40-
@contextmanager
41-
def _thread_context():
42-
# Context setting up `device` and `ctx` for individual threads on
43-
# pytest-run-parallel
44-
err, device = cuda.cuDeviceGet(0)
45-
assert err == cuda.CUresult.CUDA_SUCCESS
46-
err, ctx = cuda.cuCtxCreate(None, 0, device)
47-
assert err == cuda.CUresult.CUDA_SUCCESS
48-
try:
49-
yield device, ctx
50-
finally:
51-
(err,) = cuda.cuCtxDestroy(ctx)
41+
class _ThreadContextPool:
42+
"""CUDA contexts for pytest-run-parallel worker threads, reused across tests.
43+
44+
pytest-run-parallel starts new threads for every test, and each thread
45+
needs a current context. Creating and destroying a context per thread per
46+
test serializes in the driver: with four threads it cost about 0.75 s per
47+
test and made the free-threaded bindings run more than ten times slower.
48+
49+
The pool gives each running thread a context of its own, as before, and
50+
keeps it for the next test. The plugin empties the pool at the end of each
51+
test module, so a context lives no longer than the module-scoped ``ctx``
52+
fixture does in a run without threads. A context that reports an error at
53+
the end of a test is destroyed rather than reused.
54+
"""
55+
56+
def __init__(self):
57+
self._lock = threading.Lock()
58+
self._idle = []
59+
60+
@staticmethod
61+
def _create():
62+
err, device = cuda.cuDeviceGet(0)
63+
assert err == cuda.CUresult.CUDA_SUCCESS
64+
# cuCtxCreate makes the new context current; pop it so that context()
65+
# can push and pop it like a reused one.
66+
err, ctx = cuda.cuCtxCreate(None, 0, device)
5267
assert err == cuda.CUresult.CUDA_SUCCESS
68+
err, _ = cuda.cuCtxPopCurrent()
69+
assert err == cuda.CUresult.CUDA_SUCCESS
70+
return device, ctx
71+
72+
@contextmanager
73+
def context(self):
74+
with self._lock:
75+
entry = self._idle.pop() if self._idle else None
76+
if entry is None:
77+
entry = self._create()
78+
device, ctx = entry
79+
(err,) = cuda.cuCtxPushCurrent(ctx)
80+
assert err == cuda.CUresult.CUDA_SUCCESS
81+
try:
82+
yield device, ctx
83+
finally:
84+
(sync_err,) = cuda.cuCtxSynchronize()
85+
# Pop rather than unset, so that the context that was current
86+
# before the test (on the main thread, for tests that are not run
87+
# in parallel) is current again.
88+
err, _ = cuda.cuCtxPopCurrent()
89+
assert err == cuda.CUresult.CUDA_SUCCESS
90+
if sync_err == cuda.CUresult.CUDA_SUCCESS:
91+
with self._lock:
92+
self._idle.append(entry)
93+
else:
94+
(err,) = cuda.cuCtxDestroy(ctx)
95+
assert err == cuda.CUresult.CUDA_SUCCESS
96+
97+
def clear(self):
98+
"""Destroy the idle contexts. Call only when no test is running."""
99+
with self._lock:
100+
entries, self._idle = self._idle, []
101+
for _, ctx in entries:
102+
(err,) = cuda.cuCtxDestroy(ctx)
103+
assert err == cuda.CUresult.CUDA_SUCCESS
104+
105+
106+
_thread_contexts = _ThreadContextPool()
53107

54108

55109
def _wrap_worker_cuda_test(func):
@@ -63,7 +117,7 @@ def _wrap_worker_cuda_test(func):
63117
@functools.wraps(func)
64118
def wrapper(*args, **kwargs):
65119
kwargs = dict(kwargs) # copy before mutating
66-
with _thread_context() as (device, ctx):
120+
with _thread_contexts.context() as (device, ctx):
67121
# device is None when reusing an existing context (defensive path);
68122
# keep whatever the fixture provided in kwargs as-is.
69123
if wants_device and device is not None:
@@ -103,6 +157,13 @@ def pytest_collection_modifyitems(self, config, items):
103157
if _item_needs_thread_ctx(item):
104158
item.obj = _wrap_worker_cuda_test(item.obj)
105159

160+
@pytest.hookimpl(trylast=True)
161+
def pytest_runtest_teardown(self, item, nextitem):
162+
# The worker threads of this test have joined. Destroy the pooled
163+
# contexts when the next test is in another module, or there is none.
164+
if nextitem is None or getattr(nextitem, "module", None) is not getattr(item, "module", None):
165+
_thread_contexts.clear()
166+
106167

107168
@pytest.fixture(scope="module")
108169
def cuda_driver():

0 commit comments

Comments
 (0)