Skip to content

[stf] Enable free-threaded Python (3.14t) support for the cuda.stf bindings - #8

Open
caugonnet wants to merge 10 commits into
stf-python-freethreading-basefrom
stf-python-freethreading
Open

[stf] Enable free-threaded Python (3.14t) support for the cuda.stf bindings#8
caugonnet wants to merge 10 commits into
stf-python-freethreading-basefrom
stf-python-freethreading

Conversation

@caugonnet

Copy link
Copy Markdown
Owner

Summary

Enable the cuda.stf Python bindings on free-threaded CPython (3.13t/3.14t). The bindings were correct under the GIL; free-threading is a new requirement that removes the implicit serialization the wrapper objects relied on for lifetime management. This PR adds the synchronization the wrappers need to own, declares the extension freethreading_compatible (so importing cuda.stf no longer re-enables the GIL), documents the threading contract, and adds multi-threaded contract tests.

Motivation: concurrent task submission on a shared context is supported and internally synchronized by the C++ runtime (contract documented and tested in NVIDIA#10892), and free-threaded submitters measured beneficial for host-side-bound workloads. Today, importing the extension on 3.14t re-enables the GIL (RuntimeWarning: The global interpreter lock (GIL) has been enabled to load module ...), which forfeits that benefit.

What is in here (one commit per concern)

  1. Per-context lifetime lock (_AliveFlag + PyThread_type_lock): serializes child __dealloc__ check-then-destroy against finalize() flip-and-free, makes racing finalize() calls resolve to exactly one teardown, and guards the stackable open-scope counter. PyThread_type_lock is used because it survives nogil regions (a critical section would not) and exists on every supported CPython. Contended acquisition releases the GIL while blocking, and the locked regions are kept free of Python allocation so the non-reentrant lock cannot be re-entered by a GC-triggered __dealloc__ on the owning thread.
  2. _PrimaryContextPin.release: atomic test-and-set so the driver's primary-context refcount is decremented exactly once (an over-decrement could tear down a primary context other libraries still use).
  3. LaunchableGraph: atomic claim of the C-level shared handle so duplicate reset()/__dealloc__ cannot double-free it, and the owner scope closes exactly once. The release itself remains thread-confined (the stackable scope structure is per-thread in the runtime) and this is now documented.
  4. exec_place affinity scopes: per-thread scope stacks (threading.local) instead of a single slot on the shared place object; also makes single-thread nesting of with place: work.
  5. check_errors(): EAFP pop so each pending host-callback error is surfaced to exactly one caller.
  6. TaskGraph: a lock around the record-once/launch-many state machine; launch() checks state under the lock but launches outside it.
  7. Clean errors on finalized contexts: creating tasks/logical_data/tokens on a finalized context now raises RuntimeError instead of passing a null handle to the C layer (the C-side null checks are _CCCL_ASSERTs, compiled out of release builds).
  8. freethreading_compatible=True on the extension module -- deliberately ordered after the fixes.
  9. Documented threading contract (README "Thread safety" section + docstrings): safe from multiple threads -- task submission on a shared context, wrapper teardown from any thread, idempotent finalize, per-thread affinity scopes; requires external quiescence -- finalize()/fence()/wait() as single-caller phase operations, thread-confined stackable scope recording, place configuration before submission. This mirrors the C++ contract.
  10. Tests (tests/stf/test_free_threading.py): contract assertions, not luck -- N submitter threads on a shared context, concurrent logical_data create/destroy, wrapper references dropped concurrently with finalize(), finalize-vs-finalize, submission-after-finalize raising, LaunchableGraph reset idempotency + concurrent no-op resets, per-thread scopes, multi-threaded pytorch_task, and (on free-threaded builds) sys._is_gil_enabled() staying False after import. Thread/iteration counts scale via STF_TEST_NUM_THREADS / STF_TEST_ITERS.

Design note: no locks were added to the per-task submission hot path. The runtime already synchronizes submission on the C++ side; the Python-side fixes are strictly about wrapper-object lifetime, which lives on cold paths (creation guards, teardown, finalize, scope open/close).

Testing (GB300, CUDA 13.4, aarch64)

Build (editable, no build isolation), on both interpreters:

uv venv /tmp/stf-312 --python 3.12 && uv venv /tmp/stf-314t --python 3.14t  # cpython-3.14.7+freethreaded
uv pip install --python <venv> scikit-build-core setuptools_scm 'cython>=3.1' 'cmake>=3.30' ninja
cd python/cuda_stf && uv pip install --python <venv> --no-build-isolation -e '.[test-sysctk13]'
  • Python 3.12 (GIL) regression: full existing suite pytest tests/ -- 265 passed, 23 skipped (skips are optional-dependency gates), no failures.
  • Python 3.14.7 free-threaded: new test file 12/12 passed (including the pytorch interop test with the free-threaded torch 2.13.0+cu130 wheel and cupy-cuda13x installed); sys._is_gil_enabled() is False after import and stays False.
  • Stress campaign on 3.14t: STF_TEST_NUM_THREADS=16 STF_TEST_ITERS=100, 5/5 repetitions green.
  • Before/after on the unpatched base (same node): importing the extension re-enables the GIL with the documented RuntimeWarning; forcing it off (PYTHON_GIL=0, i.e. what shipping the directive alone would mean) segfaults deterministically (3/3) on the submission-after-finalize path fixed by commit 7. The narrower lifetime races (e.g. __dealloc__-vs-finalize) did not fire in 40 targeted repetitions on the unpatched build -- their windows are a few instructions wide -- so those fixes rest on the interleaving analysis in the commit messages plus the contract tests, which pass 5/5 under stress on the patched build.

Base

Branch stf-python-freethreading-base is a snapshot of upstream main (2f03020); the PR is opened fork-internal for review before submitting upstream.

🤖 Generated with Claude Code

caugonnet and others added 10 commits August 20, 2026 11:59
…a per-context lifetime lock

The shared _AliveFlag sentinel was checked and flipped without
synchronization, which is sufficient only while the GIL guarantees that
a child wrapper's __dealloc__ cannot overlap context.finalize(). On
free-threaded CPython (and with the nogil finalize call even on GIL
builds) the following interleavings become possible:

- a child (logical_data/task/cuda_kernel/stackable_*) __dealloc__ reads
  alive == True, another thread's finalize() flips the flag and frees
  the context, and the child then calls its C destroy function against
  the freed context;
- two threads call finalize() concurrently, both read a non-NULL
  context handle, and both call stf_ctx_finalize on it;
- a scope open/close on one thread races the finalize() open-scopes
  check on another (stackable_context._open_scopes lost update).

Give _AliveFlag a PyThread_type_lock (survives nogil sections, unlike a
critical section) and hold it across every check-then-destroy sequence
in the child __dealloc__ paths and across flip+take-handle+finalize in
(stackable_)context.finalize()/__dealloc__, and around the
_open_scopes counter. Contended acquisition blocks with the GIL
released, and the locked regions are kept free of Python allocation so
the non-reentrant lock cannot be re-entered by a GC-triggered
__dealloc__ on the owning thread.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The idempotency of release() relied on an unlocked check of the
_released flag. If two threads reach release() concurrently (finalize()
racing another teardown path), both can pass the check and both call
cuDevicePrimaryCtxRelease, over-decrementing the driver's
primary-context refcount -- which can tear down a primary context that
other libraries sharing it (Numba, PyTorch, CuPy) still use.

Claim the retained-device list under a private PyThread lock so exactly
one caller performs the driver releases; the driver round-trips
themselves run outside the locked region.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ree of the shared handle

LaunchableGraph is documented as storable and shareable, but reset()
performed an unlocked read-then-clear of the C-level shared handle:
reset() overlapping a duplicate reset() or the final __dealloc__ could
observe the same non-zero handle twice and call
stf_launchable_graph_shared_free twice, double-freeing the shared
reference; the owning stackable_context's open-scope count could
likewise be decremented twice.

Claim the handle and the owner-scope reference atomically under a
private PyThread lock; only the claiming caller performs the free and
the scope close, so both run exactly once and reset() is idempotent
(resetting an already-reset graph is a safe no-op from any thread).
The release itself remains thread-confined: it must run on a thread
that has entered the owning stackable context (normally the recording
thread), since the stackable scope structure is per-thread in the
underlying runtime; reset-vs-launch likewise remains a quiesce-first
contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
'with place:' stashed the scope handle in a single slot on the shared
exec_place object. Two consequences: (1) with two threads entering the
same place, the second __enter__ overwrites the first thread's scope
handle, and the first __exit__ then closes the wrong thread's scope
(and the overwritten one leaks); (2) even on a single thread, nested
'with place:' blocks lose the outer scope.

Keep the open scope handles in a per-thread stack (threading.local)
instead: each thread only sees its own scopes, so no locking is needed,
and nesting works by construction. Scope handles are now always closed
by the matching __exit__ on the opening thread, so the __dealloc__
scope cleanup (unreachable through the context-manager protocol) is
dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ck_errors caller

check_errors() used check-then-pop on the pending-error list. With two
concurrent callers, both can see a single-element list as truthy and
both call pop(0); the loser gets an unrelated IndexError instead of
either the real callback error or a clean return.

Switch to EAFP: pop under the exception handler and treat a lost race
as 'nothing left to surface'. Each pending error is now raised by
exactly one caller.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TaskGraph guarded its record-once / launch-many lifecycle with plain
boolean check-then-act sequences. With two threads entering
'with graph:' concurrently, both can pass the not-recording /
not-yet-recorded guards and both push a recording scope on the owned
stackable context, leaving a mismatched scope count; reset()/finalize()
had the same shape of race on _reset/_finalized.

Guard the state transitions with a threading.Lock. launch() checks
readiness under the lock but launches outside it, so replay does not
serialize on the recording lock. Recording itself (task submission
between __enter__ and __exit__) remains single-threaded by contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With the object-lifetime synchronization now in place, mark the Cython
extension module freethreading_compatible so importing cuda.stf no
longer re-enables the GIL on free-threaded CPython (3.13t/3.14t). On
GIL-enabled builds this is a no-op.

Concurrent task submission on a shared context is supported by the
underlying runtime (the submission path is internally synchronized);
phase operations such as finalize() and fence() require external
quiescence, as documented in the threading-contract section of the
README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a 'Thread safety' section to the package README stating what is
safe from multiple threads (task submission on a shared context --
guaranteed by the runtime's internally synchronized submission path --
wrapper teardown from any thread, idempotent finalize, per-thread
affinity scopes) and what requires external quiescence (finalize /
fence / wait as single-caller phase operations, stackable scope
recording, place configuration), mirroring the C++ contract. Add
matching docstring notes on finalize(), fence() and
set_affine_data_place().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The C shim's null-context checks are _CCCL_ASSERTs, which compile out
of release builds, so ctx.task() / ctx.logical_data() / token creation
after finalize() dereferenced the null handle. Guard the wrapper
constructors and creation entry points with an explicit check that
raises RuntimeError('context has been finalized') instead.

This makes the documented phase contract testable: after finalize(),
submission attempts fail with a clean Python exception. (Submission
*concurrent* with finalize remains a quiesce-first contract, as in the
C++ API.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Assert the documented threading contract rather than absence of luck:

- concurrent task submission and logical_data create/destroy from N
  threads on one shared context;
- wrapper references dropped concurrently with finalize() on another
  thread (the per-context lifetime lock's flagship interleaving);
- finalize() racing finalize(): exactly one teardown, no exceptions,
  idempotent afterwards;
- submission on a finalized context raises RuntimeError;
- quiesce-then-finalize from a non-main thread;
- LaunchableGraph.reset() idempotency on the recording thread plus
  concurrent duplicate resets as safe no-ops;
- per-thread, nestable 'with exec_place:' affinity scopes;
- pytorch_task from several threads on a shared context (skipped when
  torch is unavailable);
- on free-threaded builds, verifies sys._is_gil_enabled() stays False
  after importing the bindings.

Thread count and iterations scale via STF_TEST_NUM_THREADS /
STF_TEST_ITERS for stress campaigns. The tests also run (and pass)
on GIL builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@caugonnet
caugonnet force-pushed the stf-python-freethreading branch from 8a2c1c1 to d111130 Compare August 20, 2026 12:33
@caugonnet caugonnet changed the title [cuda_stf] Enable free-threaded Python (3.14t) support for the cuda.stf bindings [stf] Enable free-threaded Python (3.14t) support for the cuda.stf bindings Aug 25, 2026
@caugonnet caugonnet self-assigned this Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant