Skip to content

Commit 230f0eb

Browse files
Andy-Jostclaude
andauthored
feat(cuda.core): add host_launch for stream-ordered host functions (#3258)
* feat(cuda.core): add host_launch for stream-ordered host functions Add cuda.core.host_launch(stream, fn, *, user_data=None), the stream-side counterpart of GraphBuilder.callback. It enqueues a Python callable or a ctypes function pointer with the CUhostFn prototype on a stream through cuLaunchHostFunc; a GraphBuilder argument forwards to its callback. The callable and a copied user_data stay alive until the function has run and are released afterwards on the main thread through the deferred-cleanup queue, never on the driver thread. A plain stream under active capture is rejected, because the owners could not be attached to the captured node. The shared callback helper moves from cuda/core/graph/_host_callback to cuda/core/_host_callback, since a core module must not import the graph package at module level. The launch record and trampoline live in _cpp/rt/host_launch.cpp, and cuLaunchHostFunc joins the driver table. Written by Codex (gpt-6.1-sol); reviewed by Claude. Co-Authored-By: Claude Fable 5.1 <[email protected]> * cuda.core: address the self-review of host_launch State that a ctypes callback that wraps a Python function acquires the GIL through ctypes, so the deadlock rule applies to it; complete the Raises section; say the driver may reject CUDA calls from the callback rather than that it does. Upcast the launch record to the cleanup base before the void* parameter, as the graph producers do. Prove the user_data copy by changing the caller's buffer while the stream is held, pin the unraisable type and message, and link the example from the examples page. Co-Authored-By: Claude Fable 5.1 <[email protected]> * cuda.core: say that a CUDA call from a host callback may deadlock CodeRabbit's review of host_launch: the driver does not guarantee an error for a CUDA call made from a host function; such a call may also deadlock. State both outcomes in the warning. Co-Authored-By: Claude Fable 5.1 <[email protected]> --------- Co-authored-by: Claude Fable 5.1 <[email protected]>
1 parent 8124abe commit 230f0eb

20 files changed

Lines changed: 544 additions & 7 deletions

‎cuda_core/cuda/core/_cpp/rt/DESIGN.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ cuda/core/
124124
├── context_scope.hpp # Scoped-context helpers (namespace detail)
125125
├── internal.hpp # Registry, cleanup wrappers, deferred-cleanup item (namespace detail)
126126
├── py_report.cpp, py_deferred_cleanup.cpp # Python-coupled bodies
127+
├── host_launch.cpp # The stream-side host function launch record
127128
└── context.cpp, stream.cpp, event.cpp, memory.cpp, program.cpp,
128129
graph.cpp, graph_exec.cpp, texture.cpp # One resource family per file
129130
```

‎cuda_core/cuda/core/_cpp/rt/GRAPH_ATTACHMENTS.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,10 @@ deleters call CUDA or run Python finalizers.
127127
destructor callback only adds the attachment to the process-lifetime
128128
`DeferredCleanupQueue` and requests a `Py_AddPendingCall`.
129129

130+
The host launch record of `host_launch.cpp` is the one producer that is not a
131+
graph attachment;
132+
its trampoline enqueues the record after the user function returns.
133+
130134
One pending call drains all queued attachments from Python's main thread. The
131135
queue coalesces work because CPython's pending-call queue is bounded, and there
132136
could be many more deferred cleanup items than allowed pending calls. If

‎cuda_core/cuda/core/_cpp/rt/api.hpp‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,14 @@ StreamHandle get_per_thread_stream();
109109
// token is created. Returns an empty handle for an empty h_context.
110110
StreamHandle create_context_bound_legacy_stream(const ContextHandle &h_context);
111111

112+
// ============================================================================
113+
// Host function launch
114+
// ============================================================================
115+
116+
CUresult launch_host_function(
117+
CUstream stream, CUhostFn fn, void *user_data, OpaqueHandle fn_owner, OpaqueHandle data_owner
118+
) noexcept;
119+
112120
// ============================================================================
113121
// Event handle functions
114122
// ============================================================================

‎cuda_core/cuda/core/_cpp/rt/driver_api.hpp‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ namespace cuda_core::rt {
139139
/* cuda-bindings requests 7000 (PTDS) or 2000 (legacy) */ \
140140
X(cuStreamSynchronize, 7000) \
141141
X(cuStreamIsCapturing, 10000) \
142+
X(cuLaunchHostFunc, 10000) \
142143
X(cuThreadExchangeStreamCaptureMode, 10010)
143144

144145
#define CUDA_CORE_DECLARE_DRIVER_FN(name, introduced) extern decltype(&name) p_##name;
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
#include "py.hpp"
6+
#include "api.hpp"
7+
#include "driver_api.hpp"
8+
#include "internal.hpp"
9+
#include <new>
10+
#include <utility>
11+
12+
namespace cuda_core::rt {
13+
14+
namespace {
15+
16+
struct HostLaunchRecord : detail::DeferredCleanupItem {
17+
CUhostFn fn;
18+
void *user_data;
19+
OpaqueHandle fn_owner;
20+
OpaqueHandle data_owner;
21+
22+
HostLaunchRecord(CUhostFn fn, void *user_data, OpaqueHandle fn_owner, OpaqueHandle data_owner) noexcept
23+
: fn(fn), user_data(user_data), fn_owner(std::move(fn_owner)), data_owner(std::move(data_owner))
24+
{
25+
}
26+
};
27+
28+
void
29+
host_launch_trampoline(void *p) noexcept
30+
{
31+
auto *rec = static_cast<HostLaunchRecord *>(p);
32+
rec->fn(rec->user_data);
33+
// Owners may run Python finalizers or CUDA calls, so release them on the main thread.
34+
detail::enqueue_cleanup(static_cast<detail::DeferredCleanupItem *>(rec));
35+
}
36+
37+
} // namespace
38+
39+
CUresult
40+
launch_host_function(
41+
CUstream stream, CUhostFn fn, void *user_data, OpaqueHandle fn_owner, OpaqueHandle data_owner
42+
) noexcept
43+
{
44+
retry_deferred_cleanup();
45+
auto *record = new (std::nothrow) HostLaunchRecord(fn, user_data, std::move(fn_owner), std::move(data_owner));
46+
if (record == nullptr) {
47+
return CUDA_ERROR_OUT_OF_MEMORY;
48+
}
49+
CUresult status = DRIVER_CALL(cuLaunchHostFunc, stream, &host_launch_trampoline, record);
50+
if (status != CUDA_SUCCESS) {
51+
delete record;
52+
return status;
53+
}
54+
return CUDA_SUCCESS;
55+
}
56+
57+
} // namespace cuda_core::rt
File renamed without changes.

cuda_core/cuda/core/graph/_host_callback.pyi renamed to cuda_core/cuda/core/_host_callback.pyi

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# This file was generated by stubgen-pyx v0.2.23 from cuda_core/cuda/core/graph/_host_callback.pyx
1+
# This file was generated by stubgen-pyx v0.2.23 from cuda_core/cuda/core/_host_callback.pyx
22

33
import sys
44

File renamed without changes.

‎cuda_core/cuda/core/_launcher.pyi‎

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ from cuda.core._stream import Stream
66
from cuda.core.graph import GraphBuilder
77
from cuda.core.typing import IsStreamType
88

9-
__all__ = ['launch']
9+
__all__ = ['launch', 'host_launch']
1010

1111
def launch(stream: Stream | GraphBuilder | IsStreamType, config: LaunchConfig, kernel: Kernel, *kernel_args) -> None:
1212
"""Launches a :obj:`~_module.Kernel`
@@ -27,3 +27,65 @@ def launch(stream: Stream | GraphBuilder | IsStreamType, config: LaunchConfig, k
2727
launching kernel.
2828
2929
"""
30+
def host_launch(stream: Stream | GraphBuilder | IsStreamType, fn, *, user_data=None) -> None:
31+
"""Enqueue a host function on a stream in stream order.
32+
33+
The function runs on a driver-owned thread once all work enqueued on the
34+
stream before this call has completed. Work enqueued after this call waits
35+
until the function returns. This call returns when the function is enqueued;
36+
observe completion with :meth:`Stream.sync` or an :class:`Event` recorded
37+
after it.
38+
39+
Passing a :class:`~graph.GraphBuilder` forwards to
40+
:meth:`~graph.GraphBuilder.callback`.
41+
42+
Parameters
43+
----------
44+
stream : Stream or graph.GraphBuilder or IsStreamType
45+
The stream establishing execution order, or a graph builder capturing
46+
the callback.
47+
fn : callable or ctypes function pointer
48+
A Python callable taking no arguments, which runs with the GIL, or a
49+
ctypes function pointer declared as ``CUhostFn``, for example
50+
``ctypes.CFUNCTYPE(None, ctypes.c_void_p)``, which ``cuda.core``
51+
passes to the driver as is. A pointer to compiled code runs without
52+
the GIL; a ctypes callback that wraps a Python function acquires the
53+
GIL through ctypes and is subject to the same deadlock rule as a
54+
Python callable. ``cuda.core`` keeps ``fn`` alive until the function
55+
has run and releases it afterwards on the main thread.
56+
user_data : int or bytes-like, optional
57+
Only for ctypes function pointers. An ``int`` is a raw pointer that
58+
the caller must keep valid until the function has run. Bytes-like data
59+
is copied; ``cuda.core`` owns the copy until the function has run and
60+
releases it afterwards on the main thread.
61+
62+
Raises
63+
------
64+
TypeError
65+
If ``stream`` is ``None`` or not a stream, or if ``fn`` is not
66+
callable or its ctypes prototype is not ``CUhostFn``.
67+
ValueError
68+
If ``user_data`` is given for a Python callable.
69+
RuntimeError
70+
If ``stream`` is a :class:`Stream` that is capturing a graph, or a
71+
:class:`~graph.GraphBuilder` that is closed or not building.
72+
73+
Notes
74+
-----
75+
An exception raised by a Python callable cannot propagate to the caller.
76+
It is reported through :func:`sys.unraisablehook` and the stream continues.
77+
78+
.. warning::
79+
80+
The function must not call CUDA, directly or through a library. The
81+
driver does not permit such calls. Such a call may fail with an error,
82+
or it may deadlock.
83+
84+
A Python callable, or a ctypes callback that wraps a Python function,
85+
acquires the GIL on the driver's thread. A thread
86+
that holds the GIL while blocking inside a CUDA call waiting for this
87+
stream therefore deadlocks with the callback. ``cuda.core`` and
88+
``cuda.bindings`` release the GIL around CUDA calls. A library that
89+
calls CUDA with the GIL held must not be used to wait on a stream with
90+
a pending Python callback.
91+
"""

‎cuda_core/cuda/core/_launcher.pyx‎

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ from libc.stdint cimport uintptr_t
66

77
from cuda.bindings cimport cydriver
88

9+
from cuda.core._host_callback cimport _resolve_host_callback
910
from cuda.core._launch_config cimport LaunchConfig
1011
from cuda.core._kernel_arg_handler cimport ParamHolder
1112
from cuda.core._module cimport Kernel
12-
from cuda.core._rt cimport as_cu
13+
from cuda.core._rt cimport as_cu, launch_host_function, OpaqueHandle
1314
from cuda.core._stream cimport Stream_accept, Stream
1415
from cuda.core._utils.cuda_utils cimport (
1516
check_or_create_options,
@@ -24,7 +25,7 @@ if TYPE_CHECKING:
2425
from cuda.core.graph import GraphBuilder
2526
from cuda.core.typing import IsStreamType
2627

27-
__all__ = ['launch']
28+
__all__ = ['launch', 'host_launch']
2829

2930

3031
def launch(
@@ -69,6 +70,92 @@ def launch(
6970
HANDLE_RETURN(cydriver.cuLaunchKernelEx(&drv_cfg, func_handle, args_ptr, NULL))
7071

7172

73+
def host_launch(stream: Stream | GraphBuilder | IsStreamType, fn, *, user_data=None) -> None:
74+
"""Enqueue a host function on a stream in stream order.
75+
76+
The function runs on a driver-owned thread once all work enqueued on the
77+
stream before this call has completed. Work enqueued after this call waits
78+
until the function returns. This call returns when the function is enqueued;
79+
observe completion with :meth:`Stream.sync` or an :class:`Event` recorded
80+
after it.
81+
82+
Passing a :class:`~graph.GraphBuilder` forwards to
83+
:meth:`~graph.GraphBuilder.callback`.
84+
85+
Parameters
86+
----------
87+
stream : Stream or graph.GraphBuilder or IsStreamType
88+
The stream establishing execution order, or a graph builder capturing
89+
the callback.
90+
fn : callable or ctypes function pointer
91+
A Python callable taking no arguments, which runs with the GIL, or a
92+
ctypes function pointer declared as ``CUhostFn``, for example
93+
``ctypes.CFUNCTYPE(None, ctypes.c_void_p)``, which ``cuda.core``
94+
passes to the driver as is. A pointer to compiled code runs without
95+
the GIL; a ctypes callback that wraps a Python function acquires the
96+
GIL through ctypes and is subject to the same deadlock rule as a
97+
Python callable. ``cuda.core`` keeps ``fn`` alive until the function
98+
has run and releases it afterwards on the main thread.
99+
user_data : int or bytes-like, optional
100+
Only for ctypes function pointers. An ``int`` is a raw pointer that
101+
the caller must keep valid until the function has run. Bytes-like data
102+
is copied; ``cuda.core`` owns the copy until the function has run and
103+
releases it afterwards on the main thread.
104+
105+
Raises
106+
------
107+
TypeError
108+
If ``stream`` is ``None`` or not a stream, or if ``fn`` is not
109+
callable or its ctypes prototype is not ``CUhostFn``.
110+
ValueError
111+
If ``user_data`` is given for a Python callable.
112+
RuntimeError
113+
If ``stream`` is a :class:`Stream` that is capturing a graph, or a
114+
:class:`~graph.GraphBuilder` that is closed or not building.
115+
116+
Notes
117+
-----
118+
An exception raised by a Python callable cannot propagate to the caller.
119+
It is reported through :func:`sys.unraisablehook` and the stream continues.
120+
121+
.. warning::
122+
123+
The function must not call CUDA, directly or through a library. The
124+
driver does not permit such calls. Such a call may fail with an error,
125+
or it may deadlock.
126+
127+
A Python callable, or a ctypes callback that wraps a Python function,
128+
acquires the GIL on the driver's thread. A thread
129+
that holds the GIL while blocking inside a CUDA call waiting for this
130+
stream therefore deadlocks with the callback. ``cuda.core`` and
131+
``cuda.bindings`` release the GIL around CUDA calls. A library that
132+
calls CUDA with the GIL held must not be used to wait on a stream with
133+
a pending Python callback.
134+
"""
135+
from cuda.core.graph._graph_builder import GraphBuilder
136+
137+
if isinstance(stream, GraphBuilder):
138+
return stream.callback(fn, user_data=user_data)
139+
140+
cdef Stream s = Stream_accept(stream, allow_stream_protocol=True)
141+
cdef cydriver.CUstream c_stream = as_cu(s._h_stream)
142+
cdef cydriver.CUstreamCaptureStatus status
143+
with nogil:
144+
HANDLE_RETURN(cydriver.cuStreamIsCapturing(c_stream, &status))
145+
if status == cydriver.CU_STREAM_CAPTURE_STATUS_ACTIVE:
146+
raise RuntimeError(
147+
"host_launch cannot enqueue on a stream that is capturing a graph; "
148+
"pass the GraphBuilder, or call GraphBuilder.callback")
149+
150+
cdef cydriver.CUhostFn c_fn
151+
cdef void* c_user_data = NULL
152+
cdef OpaqueHandle fn_owner, data_owner
153+
_resolve_host_callback(fn, user_data, &c_fn, &c_user_data, &fn_owner, &data_owner)
154+
with nogil:
155+
HANDLE_RETURN(launch_host_function(c_stream, c_fn, c_user_data, fn_owner, data_owner))
156+
return None
157+
158+
72159
cdef _check_cooperative_launch(kernel: Kernel, config: LaunchConfig, stream: Stream):
73160
dev = stream.device
74161
num_sm = dev.properties.multiprocessor_count

0 commit comments

Comments
 (0)