[stf] Enable free-threaded Python (3.14t) support for the cuda.stf bindings - #8
Open
caugonnet wants to merge 10 commits into
Open
[stf] Enable free-threaded Python (3.14t) support for the cuda.stf bindings#8caugonnet wants to merge 10 commits into
caugonnet wants to merge 10 commits into
Conversation
…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
force-pushed
the
stf-python-freethreading
branch
from
August 20, 2026 12:33
8a2c1c1 to
d111130
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Enable the
cuda.stfPython 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 extensionfreethreading_compatible(so importingcuda.stfno 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)
_AliveFlag+PyThread_type_lock): serializes child__dealloc__check-then-destroy againstfinalize()flip-and-free, makes racingfinalize()calls resolve to exactly one teardown, and guards the stackable open-scope counter.PyThread_type_lockis used because it survivesnogilregions (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._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).LaunchableGraph: atomic claim of the C-level shared handle so duplicatereset()/__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.exec_placeaffinity scopes: per-thread scope stacks (threading.local) instead of a single slot on the shared place object; also makes single-thread nesting ofwith place:work.check_errors(): EAFP pop so each pending host-callback error is surfaced to exactly one caller.TaskGraph: a lock around the record-once/launch-many state machine;launch()checks state under the lock but launches outside it.RuntimeErrorinstead of passing a null handle to the C layer (the C-side null checks are_CCCL_ASSERTs, compiled out of release builds).freethreading_compatible=Trueon the extension module -- deliberately ordered after the fixes.finalize()/fence()/wait()as single-caller phase operations, thread-confined stackable scope recording, place configuration before submission. This mirrors the C++ contract.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 withfinalize(), finalize-vs-finalize, submission-after-finalize raising, LaunchableGraph reset idempotency + concurrent no-op resets, per-thread scopes, multi-threadedpytorch_task, and (on free-threaded builds)sys._is_gil_enabled()stayingFalseafter import. Thread/iteration counts scale viaSTF_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:
pytest tests/-- 265 passed, 23 skipped (skips are optional-dependency gates), no failures.sys._is_gil_enabled()isFalseafter import and staysFalse.STF_TEST_NUM_THREADS=16 STF_TEST_ITERS=100, 5/5 repetitions green.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-baseis a snapshot of upstreammain(2f03020); the PR is opened fork-internal for review before submitting upstream.🤖 Generated with Claude Code