Skip to content

[BUG]: Legacy driver callback bookkeeping races under free-threaded Python, causing native abort #3282

Description

@jpascucci-nv

Is this a duplicate?

Type of Bug

Runtime Error

Component

cuda.bindings

Describe the bug

A nightly CI job aborted with exit code 134 while running tests/legacy_api/test_legacy_cuda.py::test_culogs_functions with four parallel threads.
Failing job

Environment:

  • Commit: d649075
  • Linux aarch64, Python 3.15.2 free-threaded
  • CUDA Toolkit 13.4.2, NVIDIA driver 595.99.02, L4 GPU
  • pytest-run-parallel==0.10.0, --parallel-threads=4, --count=5
    The Python stack points to cuLogsUnregisterCallback(callback_handle). Disassembly of the exact wheel places the native abort in map-node deletion during std::map::erase().
    The legacy bindings store callback allocations in the shared m_global._allocated C++ map.

cuLogsRegisterCallback inserts entries, while cuLogsUnregisterCallback frees callback data and erases entries without synchronization. Concurrent access under free-threaded Python is the likely cause of memory corruption. Async-notification callback APIs also access this map.

Expected behavior: concurrent registration and unregistration of independent callbacks completes safely.

Suggested fix: synchronize callback bookkeeping at the binding-generation source, covering all APIs that share the map, and add concurrent regression coverage. Serializing the affected test could provide an interim CI mitigation.

The full CUDA test has not been reproduced locally. The matching ARM Python 3.14t job passed on the same driver version, suggesting an intermittent race.

Some history:

Recent test changes likely exposed an older bug. I found no recent change introducing the unsafe map operations.

  • May 2025: The cuLogs* wrappers already used the shared map without synchronization. (#597)
  • September 2, 2026: CI enabled four parallel test threads for free-threaded Python. (#2194)
  • October 5: test_culogs_functions was added, exercising registration and cleanup. (#2978)
  • October 15: Context pooling increased actual overlap between worker threads. The commit explicitly notes that context creation previously kept them apart. This is the strongest candidate for making the existing race easier to trigger. (#3122)

How to Reproduce

Not reproducible.

Expected behavior

Expected behavior: concurrent registration and unregistration of independent callbacks completes safely.

Operating System

No response

nvidia-smi output

No response

Activity

  1. added theissue type on Oct 29, 2026
  2. juenglin commented on Oct 29, 2026

    @juenglin
    Contributor

    Another instance of the same thread-unsafety class turned up, this time on Windows and in a different (non-legacy) binding module, so I'm recording it here rather than opening a near-duplicate.

    Observation

    Failing job: Test win-64 / Python 3.14t, CUDA 13.4.2 (wheels), GPU a100 (MCDM)

    cuda_bindings_13/tests/test_cuda.py::test_private_function_pointer_inspector failed under pytest-run-parallel (the test ran in PARALLEL mode):

    PARALLEL FAILED tests/test_cuda.py::test_private_function_pointer_inspector - SystemError: ...\Python-3.14.10\Objects\dictobject.c:2468: bad argument to internal function
    

    The test is a one-liner that just calls into the private inspector:

    def test_private_function_pointer_inspector():
        from cuda.bindings._internal.driver import _inspect_function_pointer
        assert _inspect_function_pointer("__cuGetErrorString") != 0

    The failure is unrelated to the PR whose CI surfaced it; it is a pre-existing latent race, consistent with the "recent parallelism changes expose an older bug" history in the issue description.

    Diagnosis

    Same root pattern as the callback map, but in generated host-API bindings: a module-level mutable global mutated with an unsynchronized check-then-set. In cuda/bindings/_internal/driver_windows.pyx (the same shape exists in driver_linux.pyx and in every other _internal/*.pyx module: runtime, nvrtc, nvjitlink, nvvm, cufile, ...):

    cdef dict _cyb_func_ptrs = None      # module-level global
    
    cpdef dict _inspect_function_pointers():
        global _cyb_func_ptrs
        if _cyb_func_ptrs is not None:
            return _cyb_func_ptrs
        _check_or_init_driver()
        cdef dict data = {}
        # ... populate data["__cu..."] = <intptr_t>__cu... for every entry point ...
        _cyb_func_ptrs = data
        return data
    
    cpdef _inspect_function_pointer(str name):
        global _cyb_func_ptrs
        if _cyb_func_ptrs is None:
            _cyb_func_ptrs = _inspect_function_pointers()
        return _cyb_func_ptrs[name]

    Under free-threaded Python the lazy init races: multiple threads can simultaneously observe _cyb_func_ptrs is None, each build a separate data dict, and assign/read the global concurrently. _cyb_func_ptrs[name] is a cdef dict subscript that Cython lowers to the internal CPython dict C-API; when that API is entered against a dict object being mutated/replaced on another thread without the GIL, it trips the dictobject.c precondition assertion and raises SystemError: bad argument to internal function. The GIL previously made this check-then-set effectively atomic, which is why it only shows up on the free-threaded build.

    So this is the same bug class as the m_global._allocated map in this issue — shared binding state mutated without synchronization — just manifesting as a corrupted/racily-replaced lazy singleton dict in the _inspect_function_pointer* path rather than a std::map::erase abort.

    Suggested fix

    Fix it at the binding-generation source, consistent with the fix proposed here for the callback map:

    • Make the lazy init of _cyb_func_ptrs atomic. The simplest robust option is to populate it eagerly at module import (the data is just a static table of entry-point addresses after _check_or_init_driver()), which removes the racy singleton entirely. If lazy init must be retained, guard the check-then-set with a module-level lock (e.g. a threading.Lock), or build into a local and publish with a single atomic assignment plus a re-check, so readers only ever see a fully-populated dict.
    • Apply the change in the generator so it covers every _internal/*.pyx module and both _linux/_windows variants, since they all share this pattern.
    • Add concurrent regression coverage that calls _inspect_function_pointer from multiple threads.

    Interim CI mitigation (symptom-only, mirrors the "serialize the affected test" suggestion above): mark test_private_function_pointer_inspector with @pytest.mark.thread_unsafe so it is not scheduled in parallel. This unblocks CI but leaves the underlying API thread-unsafe for real callers (e.g. cuda.core._linker calls nvjitlink._inspect_function_pointer("__nvJitLinkVersion")).

  3. lijinf2 commented on Oct 30, 2026

    @lijinf2
    Contributor

    Thanks for the detailed report. This looks like two separate problems, but I suspect they share a root cause: both involve a global variable being used by multiple threads at the same time without protection.

    I've got a chance to look at the first one closely and can now reproduce it (Thanks for the detailed info @jpascucci-nv !). The suspected cause is the global m_global._allocated map in the generated driver bindings. cuLogsRegisterCallback inserts into it and cuLogsUnregisterCallback frees and erases from it, with no locking. With the GIL disabled, concurrent threads modify the same std::map, which corrupts the heap.

    Next, I'll add a lock around the map accesses locally to confirm that this makes the crash go away. If it does, I'd like to check with the author who introduced this variable whether a lock-based fix is acceptable.

    Will look into the problem @juenglin mentioned to see whether it has the same cause.

  4. added a commit that references this issue on Nov 2, 2026
    65935f1
  5. added this to the cuda.core next milestone on Nov 3, 2026
  6. lijinf2 commented on Nov 3, 2026

    @lijinf2
    Contributor

    Need to backport to 12.9 and investigate test_private_function_pointer_inspector

  7. reopened this on Nov 3, 2026
  8. lijinf2 commented on Nov 4, 2026

    @lijinf2
    Contributor

    The Windows failure in test_private_function_pointer_inspector reported above (by @juenglin) is a separate problem (an unsynchronized lazy init, not the cuLogs callback map), so I split it out into #3373.

    This issue now only tracks the cuLogs crash fixed in #3308, with the backport to 12.9 remaining.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P0High priority - Must do!bugSomething isn't working

Type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions