Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions cuda_core/cuda/core/graph/_graph_builder.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -409,10 +409,12 @@ class GraphBuilder:
- **Python callable**: Pass any callable. The GIL is acquired
automatically. The callable must take no arguments; use closures
or ``functools.partial`` to bind state.
- **ctypes function pointer**: Pass a ``ctypes.CFUNCTYPE`` instance.
The function receives a single ``void*`` argument (the
``user_data``). The caller must keep the ctypes wrapper alive
for the lifetime of the graph.
- **ctypes function pointer**: The function receives a single
``void*`` argument (the ``user_data``), and the caller must keep
the ctypes wrapper alive for the lifetime of the graph. Its
declared prototype must match the driver's ``CUhostFn``
(``void (*)(void*)``): ``ctypes.CFUNCTYPE(None, ctypes.c_void_p)``,
or ``ctypes.WINFUNCTYPE(None, ctypes.c_void_p)`` on Windows.

.. warning::

Expand All @@ -432,6 +434,14 @@ class GraphBuilder:
Only for ctypes function pointers. If ``int``, passed as a raw
pointer (caller manages lifetime). If bytes-like, the data is
copied and its lifetime is tied to the graph.

Raises
------
TypeError
If ``fn`` is a ctypes function pointer whose declared prototype
does not match ``CUhostFn``.
ValueError
If ``user_data`` is given for a Python callable.
"""

class Graph:
Expand Down
18 changes: 14 additions & 4 deletions cuda_core/cuda/core/graph/_graph_builder.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -860,10 +860,12 @@ cdef class GraphBuilder:
- **Python callable**: Pass any callable. The GIL is acquired
automatically. The callable must take no arguments; use closures
or ``functools.partial`` to bind state.
- **ctypes function pointer**: Pass a ``ctypes.CFUNCTYPE`` instance.
The function receives a single ``void*`` argument (the
``user_data``). The caller must keep the ctypes wrapper alive
for the lifetime of the graph.
- **ctypes function pointer**: The function receives a single
``void*`` argument (the ``user_data``), and the caller must keep
the ctypes wrapper alive for the lifetime of the graph. Its
declared prototype must match the driver's ``CUhostFn``
(``void (*)(void*)``): ``ctypes.CFUNCTYPE(None, ctypes.c_void_p)``,
or ``ctypes.WINFUNCTYPE(None, ctypes.c_void_p)`` on Windows.

.. warning::

Expand All @@ -883,6 +885,14 @@ cdef class GraphBuilder:
Only for ctypes function pointers. If ``int``, passed as a raw
pointer (caller manages lifetime). If bytes-like, the data is
copied and its lifetime is tied to the graph.

Raises
------
TypeError
If ``fn`` is a ctypes function pointer whose declared prototype
does not match ``CUhostFn``.
ValueError
If ``user_data`` is given for a Python callable.
"""
GB_callback(self, fn, user_data, False)

Expand Down
18 changes: 14 additions & 4 deletions cuda_core/cuda/core/graph/_graph_node.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -333,10 +333,12 @@ class GraphNode:
- **Python callable**: Pass any callable. The GIL is acquired
automatically. The callable must take no arguments; use closures
or ``functools.partial`` to bind state.
- **ctypes function pointer**: Pass a ``ctypes.CFUNCTYPE`` instance.
The function receives a single ``void*`` argument (the
``user_data``). The caller must keep the ctypes wrapper alive
for the lifetime of the graph.
- **ctypes function pointer**: The function receives a single
``void*`` argument (the ``user_data``), and the caller must keep
the ctypes wrapper alive for the lifetime of the graph. Its
declared prototype must match the driver's ``CUhostFn``
(``void (*)(void*)``): ``ctypes.CFUNCTYPE(None, ctypes.c_void_p)``,
or ``ctypes.WINFUNCTYPE(None, ctypes.c_void_p)`` on Windows.

.. warning::

Expand All @@ -361,6 +363,14 @@ class GraphNode:
-------
HostCallbackNode
A new HostCallbackNode representing the callback.

Raises
------
TypeError
If ``fn`` is a ctypes function pointer whose declared prototype
does not match ``CUhostFn``.
ValueError
If ``user_data`` is given for a Python callable.
"""

def if_then(self, condition: GraphCondition) -> IfNode:
Expand Down
18 changes: 14 additions & 4 deletions cuda_core/cuda/core/graph/_graph_node.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -490,10 +490,12 @@ cdef class GraphNode:
- **Python callable**: Pass any callable. The GIL is acquired
automatically. The callable must take no arguments; use closures
or ``functools.partial`` to bind state.
- **ctypes function pointer**: Pass a ``ctypes.CFUNCTYPE`` instance.
The function receives a single ``void*`` argument (the
``user_data``). The caller must keep the ctypes wrapper alive
for the lifetime of the graph.
- **ctypes function pointer**: The function receives a single
``void*`` argument (the ``user_data``), and the caller must keep
the ctypes wrapper alive for the lifetime of the graph. Its
declared prototype must match the driver's ``CUhostFn``
(``void (*)(void*)``): ``ctypes.CFUNCTYPE(None, ctypes.c_void_p)``,
or ``ctypes.WINFUNCTYPE(None, ctypes.c_void_p)`` on Windows.

.. warning::

Expand All @@ -518,6 +520,14 @@ cdef class GraphNode:
-------
HostCallbackNode
A new HostCallbackNode representing the callback.

Raises
------
TypeError
If ``fn`` is a ctypes function pointer whose declared prototype
does not match ``CUhostFn``.
ValueError
If ``user_data`` is given for a Python callable.
"""
return GN_callback(self, fn, user_data)

Expand Down
18 changes: 17 additions & 1 deletion cuda_core/cuda/core/graph/_host_callback.pyi
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
# This file was generated by stubgen-pyx v0.2.6 from cuda_core/cuda/core/graph/_host_callback.pyx

from __future__ import annotations
from __future__ import annotations

import sys

_CUHOSTFN_HINT = 'ctypes.CFUNCTYPE(None, ctypes.c_void_p)' if sys.platform != 'win32' else 'ctypes.CFUNCTYPE(None, ctypes.c_void_p) or ctypes.WINFUNCTYPE(None, ctypes.c_void_p)'

def _cuhostfn_type_error(detail):
"""Build the rejection message for a non-conforming ctypes callback."""

def _validate_ctypes_host_callback(fn):
"""Reject ctypes callbacks whose declared prototype is not CUhostFn.

``restype`` and ``argtypes`` are the prototype the caller declared, and are
what CUDA calls through. A function pointer taken from a shared library
keeps ctypes' defaults -- a ``c_int`` result and unspecified arguments --
until the caller declares otherwise, so it must be declared to be accepted.
"""
42 changes: 42 additions & 0 deletions cuda_core/cuda/core/graph/_host_callback.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,47 @@ from cuda.core._resource_handles cimport (
make_opaque_py,
)

import sys
import ctypes as ct


# CUhostFn is `void (CUDA_CB *)(void*)`. CUDA_CB is __stdcall on Windows and
# empty elsewhere, but ctypes only honors that distinction when it builds a
# callback on 32-bit x86 Windows, which cuda.core does not support: on win-64
# and ARM64 both CFUNCTYPE and WINFUNCTYPE produce a FFI_DEFAULT_ABI thunk. The
# declared result and argument types are all that remain worth checking.
_CUHOSTFN_HINT = (
"ctypes.CFUNCTYPE(None, ctypes.c_void_p)"
if sys.platform != "win32"
else "ctypes.CFUNCTYPE(None, ctypes.c_void_p) or "
"ctypes.WINFUNCTYPE(None, ctypes.c_void_p)"
)


def _cuhostfn_type_error(detail):
"""Build the rejection message for a non-conforming ctypes callback."""
return TypeError(
f"host callback {detail}; CUDA requires a callback matching CUhostFn "
f"(void (*)(void*)), declared as {_CUHOSTFN_HINT}. "
"Alternatively, pass a Python callable."
)


def _validate_ctypes_host_callback(fn):
"""Reject ctypes callbacks whose declared prototype is not CUhostFn.

``restype`` and ``argtypes`` are the prototype the caller declared, and are
what CUDA calls through. A function pointer taken from a shared library
keeps ctypes' defaults -- a ``c_int`` result and unspecified arguments --
until the caller declares otherwise, so it must be declared to be accepted.
"""
restype = fn.restype
argtypes = fn.argtypes
if restype is not None or argtypes is None or tuple(argtypes) != (ct.c_void_p,):
raise _cuhostfn_type_error(
f"has prototype restype={restype!r}, argtypes={argtypes!r}")


cdef void _py_host_trampoline(void* data) noexcept with gil:
(<object>data)()

Expand All @@ -36,8 +74,12 @@ cdef void _resolve_host_callback(
``cuGraphAddHostNode`` or ``cuLaunchHostFunc``. ``*out_fn_owner`` owns the
callback object; ``*out_data_owner`` owns a copied ``user_data`` buffer and
is left null otherwise. The caller attaches both owners to the graph node.

ctypes callbacks are validated against the ``CUhostFn`` ABI before their
address is passed to CUDA.
"""
if isinstance(fn, ct._CFuncPtr):
_validate_ctypes_host_callback(fn)
out_fn[0] = <cydriver.CUhostFn><uintptr_t>ct.cast(fn, ct.c_void_p).value
if user_data is None:
out_user_data[0] = NULL
Expand Down
9 changes: 9 additions & 0 deletions cuda_core/cuda/core/graph/_subclasses.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,11 @@ class HostCallbackNode(GraphNode):
def update(self, fn, *, user_data=None) -> None:
"""Replace the callback and user-data binding for this node.

``fn`` accepts the same forms as :meth:`~graph.GraphNode.callback`: a
Python callable, or a ctypes function pointer whose declared prototype
matches ``CUhostFn`` (``void (*)(void*)``). A mismatched ctypes
prototype raises ``TypeError``.

.. warning::

Callbacks must not call CUDA API functions. Doing so may
Expand Down Expand Up @@ -508,6 +513,10 @@ class ExecutableHostCallbackNode(ExecutableGraphNode):
def update(self, fn, *, user_data=None) -> None:
"""Replace the callback and user-data binding for future launches.

``fn`` may be a Python callable, or a ctypes function pointer whose
declared prototype matches ``CUhostFn`` (``void (*)(void*)``); a
mismatched prototype raises ``TypeError``.

.. warning::

Callbacks must not call CUDA API functions. Doing so may deadlock
Expand Down
9 changes: 9 additions & 0 deletions cuda_core/cuda/core/graph/_subclasses.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -1172,6 +1172,11 @@ cdef class HostCallbackNode(GraphNode):
def update(self, fn, *, user_data=None) -> None:
"""Replace the callback and user-data binding for this node.

``fn`` accepts the same forms as :meth:`~graph.GraphNode.callback`: a
Python callable, or a ctypes function pointer whose declared prototype
matches ``CUhostFn`` (``void (*)(void*)``). A mismatched ctypes
prototype raises ``TypeError``.

.. warning::

Callbacks must not call CUDA API functions. Doing so may
Expand Down Expand Up @@ -1603,6 +1608,10 @@ cdef class ExecutableHostCallbackNode(ExecutableGraphNode):
def update(self, fn, *, user_data=None) -> None:
"""Replace the callback and user-data binding for future launches.

``fn`` may be a Python callable, or a ctypes function pointer whose
declared prototype matches ``CUhostFn`` (``void (*)(void*)``); a
mismatched prototype raises ``TypeError``.

.. warning::

Callbacks must not call CUDA API functions. Doing so may deadlock
Expand Down
14 changes: 14 additions & 0 deletions cuda_core/docs/source/release/1.2.0-notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,20 @@ Fixes and enhancements
(`#2517 <https://github.com/NVIDIA/cuda-python/pull/2517>`__,
closes `#2516 <https://github.com/NVIDIA/cuda-python/issues/2516>`__)

- ``cuda.core`` now checks ctypes host callbacks against the driver's
``CUhostFn`` signature (``void (*)(void*)``) before passing the function
pointer to CUDA. :meth:`graph.GraphNode.callback`,
:meth:`graph.GraphBuilder.callback`, and the host-callback ``update()``
methods raise ``TypeError`` for a mismatched prototype, rather than leaving
the driver to call through an incompatible signature, which is undefined
behavior. Declarations that previously reached the driver, such as
``ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p)``, are now rejected at the
call site. A function pointer obtained from a shared library keeps ctypes'
default ``c_int`` result type until it is declared, so set its ``restype``
and ``argtypes`` (or cast it to the prototype above) before passing it. On
Windows, both ``ctypes.CFUNCTYPE`` and ``ctypes.WINFUNCTYPE`` are accepted.
(`#2439 <https://github.com/NVIDIA/cuda-python/issues/2439>`__)

Deprecation Notices
-------------------

Expand Down
15 changes: 15 additions & 0 deletions cuda_core/tests/graph/test_graph_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,21 @@ def read_byte(data):
assert result[0] == 0xAB


@pytest.mark.agent_authored(model="cursor-grok-4.5")
def test_graph_capture_callback_ctypes_rejects_incompatible_signature(init_cuda):
"""Stream-capture host callbacks use the same ctypes ABI check."""
import ctypes

bad_type = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p)
launch_stream = Device().create_stream()
gb = launch_stream.create_graph_builder().begin_building()
try:
with pytest.raises(TypeError, match="CUhostFn"):
gb.callback(bad_type(0))
finally:
gb.end_building()


@pytest.mark.agent_authored(model="claude-opus-4.8")
def test_graph_capture_callback_python_survives_del(init_cuda):
"""Captured callback is retained by its graph-node user object after del."""
Expand Down
84 changes: 84 additions & 0 deletions cuda_core/tests/graph/test_graph_definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

"""Tests for GraphDefinition topology, node types, instantiation, and execution."""

import ctypes
import sys
from collections.abc import Callable
from dataclasses import dataclass, field

Expand Down Expand Up @@ -1159,6 +1161,88 @@ def test_host_callback_user_data_rejected_for_python_callable(sample_graphdef):
sample_graphdef.callback(lambda: None, user_data=b"hello")


_INCOMPATIBLE_CTYPES_HOST_CALLBACKS = [
pytest.param(ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p), id="bad-restype"),
pytest.param(ctypes.CFUNCTYPE(None, ctypes.c_int), id="bad-argtype"),
pytest.param(ctypes.CFUNCTYPE(None), id="missing-arg"),
pytest.param(ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.c_void_p), id="extra-arg"),
]

# Prototypes that declare CUhostFn but differ in ctypes bookkeeping. ctypes
# builds the same thunk for all of them, so all must be accepted.
_COMPATIBLE_CTYPES_HOST_CALLBACKS = [
pytest.param(ctypes.CFUNCTYPE(None, ctypes.c_void_p), id="cfunctype"),
pytest.param(ctypes.CFUNCTYPE(None, ctypes.c_void_p, use_errno=True), id="use-errno"),
pytest.param(ctypes.PYFUNCTYPE(None, ctypes.c_void_p), id="pyfunctype"),
]


@pytest.mark.agent_authored(model="cursor-grok-4.5")
@pytest.mark.parametrize("callback_type", _INCOMPATIBLE_CTYPES_HOST_CALLBACKS)
def test_host_callback_ctypes_rejects_incompatible_signature(sample_graphdef, callback_type):
"""Incompatible ctypes prototypes are rejected before CUDA sees them."""
with pytest.raises(TypeError, match="CUhostFn"):
sample_graphdef.callback(callback_type(0))


@pytest.mark.agent_authored(model="cursor-grok-4.5")
def test_host_callback_ctypes_update_rejects_incompatible_signature(sample_graphdef):
"""HostCallbackNode.update applies the same ctypes ABI check."""
good_type = ctypes.CFUNCTYPE(None, ctypes.c_void_p)
bad_type = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p)

@good_type
def good(data):
pass

node = sample_graphdef.callback(good)
with pytest.raises(TypeError, match="CUhostFn"):
node.update(bad_type(0))


@pytest.mark.agent_authored(model="claude-opus-5")
@pytest.mark.parametrize("callback_type", _COMPATIBLE_CTYPES_HOST_CALLBACKS)
def test_host_callback_ctypes_accepts_equivalent_prototypes(sample_graphdef, callback_type):
"""Prototypes that declare CUhostFn are accepted and run."""
called = [False]

@callback_type
def raw_fn(data):
called[0] = True

sample_graphdef.callback(raw_fn)
graph = sample_graphdef.instantiate()

stream = Device().create_stream()
graph.upload(stream)
graph.launch(stream)
stream.sync()

assert called[0]


@pytest.mark.agent_authored(model="cursor-grok-4.5")
@pytest.mark.skipif(sys.platform != "win32", reason="WINFUNCTYPE is Windows-only")
def test_host_callback_ctypes_accepts_winfunctype(sample_graphdef):
"""On Windows, WINFUNCTYPE matches CUDA_CB (__stdcall) and is accepted."""
callback_type = ctypes.WINFUNCTYPE(None, ctypes.c_void_p)
called = [False]

@callback_type
def raw_fn(data):
called[0] = True

sample_graphdef.callback(raw_fn)
graph = sample_graphdef.instantiate()

stream = Device().create_stream()
graph.upload(stream)
graph.launch(stream)
stream.sync()

assert called[0]


def test_instantiate_and_execute_event_record_wait(sample_graphdef):
"""Graph with event record and wait nodes can be executed."""
event = Device().create_event()
Expand Down