Skip to content

test(cuda.bindings): reuse worker-thread contexts under pytest-run-parallel - #3122

Merged
Andy-Jost merged 2 commits into
NVIDIA:mainfrom
Andy-Jost:ajost/bindings-parallel-ctx-pool
Oct 15, 2026
Merged

Andy-Jost merged 2 commits into
NVIDIA:mainfrom
Andy-Jost:ajost/bindings-parallel-ctx-pool

Conversation

@Andy-Jost

@Andy-Jost Andy-Jost commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Every free-threaded nightly row on the newest CUDA has hit the 60-minute job limit since the limit went back to 60 minutes in #3019. The cuda.bindings test step causes it. On these rows it takes 36 to 39 minutes, against under 3 minutes on the GIL build of the same row.

The pytest-run-parallel plugin in cuda_bindings/tests/conftest.py wraps each test that uses the device fixture. The module-scoped autouse ctx fixture depends on device, so the plugin wraps every test. The wrapper then has each worker thread call cuCtxCreate and cuCtxDestroy around the test. With --parallel-threads=4, that is four context creations per test, and the driver serializes them.

This PR keeps one context per running thread but reuses it:

  • A pool hands each worker thread an idle context, or creates one if none is idle. The thread pushes the context, runs the test, and pops it. Pop restores the context that was current before, which matters for thread-unsafe tests run on the main thread.
  • The plugin destroys the pooled contexts when the next test is in another module, or when there is no next test. A context therefore lives no longer than the module-scoped ctx fixture does in a run without threads.
  • The pool makes no synchronizing call between tests. While another thread captures a stream in global mode, such a call fails and breaks that capture.

The worker threads of a test now start together. Before, the serialized context creation kept them about 0.15 s apart. Three tests begin a stream capture in global mode, and in that mode a call such as cuGraphInstantiate in one thread fails while another thread is capturing. The two host-function graph tests from #3061 failed this way on the first CI run of this PR, and one crashed on Windows. These three tests now capture in thread-local mode. What they check does not depend on the mode.

Runs without --parallel-threads do not register the plugin and are unchanged.

Evidence

From the nightly of 2026-10-13, comparing the Python 3.15t and 3.15 rows on linux-64, CUDA 13.4.2, L4:

bindings step 3.15t 3.15
median time per parallel test 0.745 s about 0 s
time per single-thread test 0.157 s about 0 s
parallel tests 3,059, total 1,953 s n/a
step total 2,238 s 165 s

The 3.14t rows on 12.9.1 and 13.0.2 pass because they test against released bindings and skip this step. The H100 x2 row spent 54 minutes in this step and never reached the cuda.core tests. The cost dates from #2194, which enabled parallel threads in CI. The longer nightly limit from #2802 hid it until #3019.

Testing

  • A/B run of the cuda.bindings suite on one H100 with --parallel-threads=4, Python 3.14 (GIL build) and cuda-bindings 13.4.1. Both arms used the same test tree and random seed; only conftest.py differed.

    arm wall time failed or errored items
    before this PR 422 s 25
    this PR 85 s 25
    this PR, --count=5 254 s 125

    The two single-run arms fail the same 25 items. They come from that machine's environment: libnvvm, the cuFile setup, and a build-hook test that needs a source checkout. The free-threaded rows of this PR's CI will confirm the result on free-threaded Python.

  • Free-threaded run on one H100 with Python 3.14.7t and the cuda.bindings wheel that this PR's CI built, --parallel-threads=4:

    arm wall time failed or errored items
    main 378 s 28
    this PR 87 s 28
    this PR, the three capture tests, --count=30 5 s 90 passed, 4 module import errors
    this PR's conftest with main's global-mode tests crashed segfault in cuStreamSynchronize

    Main and this PR fail the same 28 items, all from that machine's environment: missing libnvfatbin, setuptools, and pyglet, libnvvm, and the cuFile setup. The last arm reproduces the Windows crash. It confirms that the global-mode tests race once the threads start together.

  • Checked the pool logic with a fake driver module that keeps a context stack per thread. It ran under pytest 9.1.0 and pytest-run-parallel 0.10.0 with four threads, with and without --count=5 and random order. The checks: each thread sees its own context as current; the main thread's context is current before and after every test; no context is left alive at session end. A deliberate break, skipping the pop, fails the checks.

  • pre-commit run --all-files passes.

Expected effect: the free-threaded 13.4.2 L4 job should drop from over 60 minutes to about 40. This is an estimate from the CI step timings and the A/B run above.

🤖 Generated with Claude Code

…rallel

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>
@Andy-Jost Andy-Jost added this to the cuda.bindings next milestone Oct 15, 2026
@Andy-Jost Andy-Jost added P0 High priority - Must do! CI/CD CI/CD infrastructure test Improvements or additions to tests cuda.bindings Everything related to the cuda.bindings module labels Oct 15, 2026
@Andy-Jost Andy-Jost self-assigned this Oct 15, 2026
@coderabbitai

coderabbitai Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

📝 Summary

Summary by CodeRabbit

  • Tests
    • Improved CUDA test stability during parallel execution by reusing contexts across tests and using thread-local stream capture. This reduces conflicts between concurrent tests and avoids creating and destroying a context for every test.

Pytest workers reuse CUDA contexts through a lock-protected pool. The pool returns contexts for reuse and clears idle contexts at module boundaries. Three stream-capture tests now use thread-local capture mode.

Changes: Pytest CUDA Parallelism

Layer / File(s) Summary
Context pool lifecycle
cuda_bindings/tests/conftest.py
A lock-protected pool creates CUDA contexts on demand, pushes them for tests, and returns them for reuse. A teardown hook clears idle contexts when no next test exists or the next test belongs to another module.
Thread-local stream capture
cuda_bindings/tests/test_cuda.py, cuda_bindings/tests/test_cudart.py
Three stream-capture tests use thread-local mode instead of global mode. Their comments describe concurrent test execution and capture behavior.

Priority: ➖ Normal

Change: Bug fix

Merge Risk: 🔵 Low · up to 5a0ef

A failed capture test may interfere with later tests in its module. Discarding contexts after test failures would improve test isolation; the remaining risk is bounded.

- [ ] Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor
Doc Preview CI
Preview removed because the pull request was closed or merged.

@jpascucci-nv jpascucci-nv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lkgtm

@jpascucci-nv jpascucci-nv left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, it looks like one of the assert == CUDA_SUCCESS fails after some tests. Hum - maybe this change is unrealed? I can't tell.

…threads

With pooled contexts the worker threads of a test start together; before,
serialized context creation kept them apart. Three tests begin a stream
capture in global mode, and in that mode a call such as cuGraphInstantiate
in one thread fails while another thread is capturing. The host-function
graph tests failed this way on the free-threaded rows. Capture in
thread-local mode instead; what the tests check does not depend on it.

Drop the synchronize at the end of each pooled test for the same reason:
it could break a global capture running in another thread. Pooled contexts
now behave like the module-scoped ctx fixture, which does not synchronize
between tests either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository: NVIDIA/cuda-python/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Enterprise
  • Run ID: 8dd0495a-1801-469a-8d80-52f4691873de
📥 Commits

Reviewing files that changed from the base of the PR and between e55a371 and 5a0ef2e.

📒 Files selected for processing: 3
  • cuda_bindings/tests/conftest.py
  • cuda_bindings/tests/test_cuda.py
  • cuda_bindings/tests/test_cudart.py

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 11 remain after this review.

Comment on lines +92 to +93
with self._lock:
self._idle.append(entry)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '1,185p' cuda_bindings/tests/conftest.py
sed -n '600,655p' cuda_bindings/tests/test_cudart.py
sed -n '1460,1505p' cuda_bindings/tests/test_cudart.py
sed -n '695,735p' cuda_bindings/tests/test_cuda.py

Repository: NVIDIA/cuda-python

Length of output: 12896


🏁 Script executed:

set -o pipefail
printf '%s\n' '--- capture call sites ---'
rg -n -F --glob 'cuda_bindings/tests/test_cudart.py' -e 'cudaStreamBeginCapture' -e 'cudaStreamEndCapture' -e 'cudaStreamCaptureMode' .
rg -n -F --glob 'cuda_bindings/tests/test_cuda.py' -e 'cuStreamBeginCapture' -e 'cuStreamEndCapture' -e 'CU_STREAM_CAPTURE_MODE' .
printf '%s\n' '--- wrapper and teardown references ---'
rg -n -F --glob 'cuda_bindings/tests/*.py' -e '_thread_contexts' -e '_wrap_worker_cuda_test' -e 'pytest_runtest_teardown' -e 'pytest_runtest_call' -e 'pytest_runtest_makereport' .
printf '%s\n' '--- relevant complete blocks ---'
python3 - <<'PY'
from pathlib import Path
for name in ['cuda_bindings/tests/test_cudart.py', 'cuda_bindings/tests/test_cuda.py']:
    p=Path(name)
    lines=p.read_text().splitlines()
    print(f'### {name}')
    hits=[i for i,l in enumerate(lines) if 'StreamBeginCapture' in l or 'StreamEndCapture' in l]
    ranges=[]
    for i in hits:
        start=max(0,i-18); end=min(len(lines),i+35)
        if not ranges or start>ranges[-1][1]:
            ranges.append([start,end])
        else:
            ranges[-1][1]=max(ranges[-1][1],end)
    for start,end in ranges:
        print(f'--- lines {start+1}-{end} ---')
        for n in range(start,end):
            print(f'{n+1:5}: {lines[n]}')
PY

Repository: NVIDIA/cuda-python

Length of output: 10683


🌐 Web query:

CUDA Runtime API cudaStreamEndCapture same thread cudaStreamCaptureModeThreadLocal Relaxed

💡 Result:

`cudaStreamCaptureModeThreadLocal` does **not** relax the same-thread requirement. It only changes which threads are subject to capture’s restrictions on potentially unsafe API calls.

NVIDIA’s Runtime API says `cudaStreamEndCapture()` must be called on the thread that began capture whenever the mode is **not** `cudaStreamCaptureModeRelaxed`. So with `ThreadLocal`, begin and end capture on the same host thread. With `Relaxed`, ending on a different thread is permitted by that rule—but capture must still end on the same stream it began on, and other capture-conflict restrictions still apply. ([docs.nvidia.com](https://docs.nvidia.com/cuda/archive/13.4.2/cuda-runtime-api/cuda_runtime_api/group__CUDART__STREAM.html?utm_source=openai))

Citations:

- 1: https://docs.nvidia.com/cuda/archive/13.4.2/cuda-runtime-api/cuda_runtime_api/group__CUDART__STREAM.html?utm_source=openai

important: Discard the context when a worker test raises.

_ThreadContextPool.context() returns the popped context to _idle even when the test raises. Pytest clears the pool only at a module boundary or session end. A failed ThreadLocal capture can therefore leave active capture state and its stream in a context that a later worker reuses. CUDA requires a non-relaxed capture to end on its starting thread.

Destroy the context after an exception instead of adding it to _idle. This is a bounded test-isolation and resource-lifetime issue, not proof that every later test will fail.

@mdboom mdboom left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Only some really niche things found in robo-review.

@Andy-Jost

Copy link
Copy Markdown
Contributor Author

Actually, it looks like one of the assert == CUDA_SUCCESS fails after some tests. Hum - maybe this change is unrealed? I can't tell.

This appears to be an issue introduced with #3061 and uncovered by this change. When capturing graphs in global mode, while one thread is capturing, a sync or a graph instantiate in any other thread fails and breaks the capture.

Under the old conftest, each thread first created a context, which takes about 0.15 s and is serialized, so the four test bodies never overlapped.

@jpascucci-nv jpascucci-nv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SGTM

@Andy-Jost
Andy-Jost enabled auto-merge (squash) October 15, 2026 21:37
@Andy-Jost
Andy-Jost merged commit 2224499 into NVIDIA:main Oct 15, 2026
107 checks passed
@Andy-Jost
Andy-Jost deleted the ajost/bindings-parallel-ctx-pool branch October 15, 2026 21:50
github-actions Bot pushed a commit that referenced this pull request Oct 16, 2026
Removed preview folders for the following PRs:
- PR #2902
- PR #3061
- PR #3105
- PR #3108
- PR #3113
- PR #3122
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CI/CD CI/CD infrastructure cuda.bindings Everything related to the cuda.bindings module P0 High priority - Must do! test Improvements or additions to tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants