cuda.stf._experimental: Sequential Task Flow#
The cuda.stf._experimental module provides a Python binding to the Sequential
Task Flow (STF) model for CUDA: you define logical data and submit tasks that read
or write that data; STF infers dependencies and orchestrates execution and data
movement. For the full description of the model, see the
C++ CUDASTF documentation.
Install the module with pip install cuda-stf[cu13] (or [cu12]). Install
cuda-cccl as well when using cuda.compute or compiling external C++ code
that needs the libcudacxx, CUB, or Thrust headers.
The module is exposed under the _experimental subpackage because the Python
API is still evolving and may change without notice. CUDASTF is currently Linux-only.
Example#
The following example registers three arrays as logical data and submits four GPU tasks
that scale and combine them. Each task only declares how it accesses its data
(read(), write(), rw()); from those annotations STF infers the dependency
graph, orders the tasks accordingly without any explicit synchronization, moves the data
to and from the device, and copies the results back into X, Y, and Z when the
context is finalized. scale and axpy are ordinary Numba CUDA kernels;
t.stream_ptr() and numba_arguments(t) (described in Tasks and interop below)
bridge each task to its kernel launch.
def axpy_chain_example():
"""Submit four interdependent GPU tasks; STF infers the ordering.
Each task only declares how it accesses its logical data
(``read``/``write``/``rw``). From those annotations STF derives the
dependency graph -- so no explicit synchronization is written -- moves the
data to and from the device, and copies the results back into ``X``, ``Y``,
and ``Z`` when the context is finalized.
``scale`` and ``axpy`` are ordinary Numba CUDA kernels; ``t.stream_ptr()``
and ``numba_arguments(t)`` bridge each task to its kernel launch.
"""
@cuda.jit
def scale(a, x):
i = cuda.grid(1)
if i < x.size:
x[i] = a * x[i]
@cuda.jit
def axpy(a, x, y):
i = cuda.grid(1)
if i < x.size:
y[i] = a * x[i] + y[i]
X, Y, Z = (np.ones(16, dtype=np.float32) for _ in range(3))
ctx = stf.context()
lX = ctx.logical_data(X)
lY = ctx.logical_data(Y)
lZ = ctx.logical_data(Z)
with ctx.task(lX.rw()) as t: # X = 2*X
nb_stream = cuda.external_stream(t.stream_ptr())
dX = numba_arguments(t)
scale[1, 16, nb_stream](2.0, dX)
with ctx.task(lX.read(), lY.rw()) as t: # Y += 2*X (waits for the writer of X)
nb_stream = cuda.external_stream(t.stream_ptr())
dX, dY = numba_arguments(t)
axpy[1, 16, nb_stream](2.0, dX, dY)
with ctx.task(lX.read(), lZ.rw()) as t: # Z += 2*X (independent of Y; may overlap)
nb_stream = cuda.external_stream(t.stream_ptr())
dX, dZ = numba_arguments(t)
axpy[1, 16, nb_stream](2.0, dX, dZ)
with ctx.task(lY.read(), lZ.rw()) as t: # Z += 2*Y (waits for both writers above)
nb_stream = cuda.external_stream(t.stream_ptr())
dY, dZ = numba_arguments(t)
axpy[1, 16, nb_stream](2.0, dY, dZ)
ctx.finalize() # results copied back into X, Y, Z
assert np.allclose(X, 2.0)
assert np.allclose(Y, 5.0)
assert np.allclose(Z, 15.0)
Context and logical data#
Create a context with context() (optionally use_graph=True for CUDA graph
execution). All logical data and tasks belong to one context.
When you are done submitting tasks, call finalize() to run the graph and
synchronize. A context does not finalize automatically when it is garbage
collected: it abandons its STF/CUDA resources and emits a ResourceWarning instead.
Always finalize explicitly, or – preferably – use the context as a context manager,
which finalizes on exit:
with stf.context() as ctx:
lX = ctx.logical_data(X)
with ctx.task(lX.rw()) as t:
...
# ctx.finalize() runs automatically here
For a default context, finalize() blocks until all work completes, matching the
C++ ctx.finalize() contract. If you create a context bound to a caller-owned CUDA
stream (context(stream=my_stream)), finalize() is asynchronous: it returns
without synchronizing your stream, exactly like the C/C++ API, and you are responsible
for synchronizing that stream before relying on results or reusing the resources.
Host callbacks scheduled with host_launch() run on a CUDA host thread and cannot
raise directly into your code. Any exception they raise is captured and re-raised by
the next blocking wait() or blocking finalize(). For caller-stream contexts
(whose finalize() is asynchronous and therefore cannot observe a callback that has
not run yet), call ctx.check_errors() after synchronizing your stream to surface a
captured callback exception.
Logical data represents a buffer that tasks access. Create it from existing buffers or allocate new ones:
logical_data(buf, ...)– from a NumPy array or any object implementing the CUDA Array Interface (CuPy, PyTorch, Numba device arrays) or the Python buffer protocol. The registration placement defaults todata_place.host(); pass a data_place (e.g.data_place.device(0)) for data that already lives on a device. The source must be C-contiguous, and a read-only source (a const CUDA Array Interface export or a non-writable buffer) may only be used withread()– requestingwrite()/rw()on it raisesValueError. The Python object backing the buffer is retained for the lifetime of the logical data, so do not resize or free it while the logical data is in use.logical_data_empty(shape, dtype, ...)– uninitialized allocation.logical_data_full(shape, fill_value, ...)– allocated and filled with a constant (likenumpy.full()). Any 1/2/4/8-byte element type is supported with no optional third-party dependency.logical_data_zeros(...)/logical_data_ones(...)– convenience wrappers.
Pass each logical data into a task with an access mode: read(), write(),
or rw(). Example: ctx.task(lX.read(), lY.rw()). Unlike the registration
placement, the dependency placement defaults to data_place.affine() (the
runtime places the working instance near the task’s execution place); override it
per dependency, e.g. lZ.rw(data_place.device(1)).
CUDA Array Interface views obtained inside a task (t.get_arg_cai() / t.args_cai())
are valid only while the task is active (until stf_task_end() / the end of the
with ctx.task(...) block). The views advertise no stream (the CUDA Array
Interface stream is None). This is intentional: inside a task you must launch
your own work on the task stream(s) – stream_ptr() for a scalar task, or
get_stream_at_index() / get_stream_ptrs() for a grid – and STF has already
ordered those streams behind the data’s producers, so no extra synchronization is
required. Advertising a concrete stream would instead trigger a host-side synchronize
in consumers such as Numba (illegal during graph capture) for no benefit. The same
rule makes the grid case correct with a single view: STF enforces the per-place
dependencies, and each place’s work runs on its own place stream.
Tasks and interop#
Use with ctx.task(...) as t: to get a task handle. Inside the block:
Stream –
t.stream_ptr()returns aCudaStreamobject for the task’s CUDA stream. It implements the__cuda_stream__protocol (and also behaves like an integer raw pointer), so you can pass it straight tocuda.computealgorithms or wrap it for your framework (e.g.numba.cuda.external_stream(t.stream_ptr())ortorch.cuda.ExternalStream(t.stream_ptr())).Buffer views –
t.get_arg_cai(index)andt.args_cai()return object(s) that implement the CUDA Array Interface, so you can pass them to Numba (cuda.from_cuda_array_interface(...)), PyTorch (torch.as_tensor(...)), or CuPy (cp.asarray(...)).Host callbacks –
ctx.host_launch(...)schedules a Python callback with dependency tracking; the dependencies are unpacked as NumPy arrays and passed to the callback (e.g.ctx.host_launch(lX.read(), fn=lambda x: print(x.sum()))).
For kernels that should become native CUDA graph nodes (instead of being captured from
a stream), use ctx.cuda_kernel(...). It accepts the same dependency and
exec_place arguments as ctx.task(...), and the resulting object exposes a
launch() method that describes the kernel to STF directly. The following excerpt
launches a precompiled AXPY kernel (see tests/stf/test_cuda_kernel.py for the
complete program):
with ctx.cuda_kernel(lX.read(), lY.rw(), symbol="axpy") as k:
dX, dY = k.get_arg(0), k.get_arg(1)
k.launch(kernel, grid=(4,), block=(256,),
args=[ctypes.c_int(N), ctypes.c_double(alpha), dX, dY])
Interop adapters#
On top of the raw CUDA Array Interface, cuda.stf._experimental ships small,
opt-in adapters for Numba and PyTorch under cuda.stf._experimental.interop.
Importing cuda.stf._experimental does not import Numba or PyTorch; the optional
runtime is imported lazily inside each adapter, and a missing dependency raises a clear
ImportError at first use. You import only the adapter you need.
PyTorch (cuda.stf._experimental.interop.pytorch) – pytorch_task opens a
task, makes the task’s CUDA stream the current PyTorch stream for the duration of the
block, and yields the task arguments as torch.Tensor views. The following excerpt
stores 2 * lX into lY (see tests/stf/interop/test_pytorch.py for the
complete program):
from cuda.stf._experimental.interop.pytorch import pytorch_task
with pytorch_task(ctx, lX.read(), lY.rw()) as (x_tensor, y_tensor):
y_tensor[:] = x_tensor * 2
tensor_arg(task, index) and tensor_arguments(task) convert one or all
task arguments to tensors if you manage the task block yourself.
Numba (cuda.stf._experimental.interop.numba) – numba_task opens a task and
yields (numba_arrays, stream), where stream can be passed straight to
cuda.compute algorithms. The following excerpt sums two logical data with
cuda.compute (see tests/stf/interop/test_cuda_compute.py for the complete
program):
from cuda.stf._experimental.interop.numba import numba_task
with numba_task(ctx, lA.read(), lB.read(), lC.rw()) as (args, stream):
cuda.compute.binary_transform(args[0], args[1], args[2], OpKind.PLUS, N, stream=stream)
The jit decorator wraps numba.cuda.jit so a kernel can be launched
directly with STF dep arguments; the conversion into device arrays (and the
task that scopes them) happens automatically. The following excerpt declares an
AXPY kernel and launches it on two logical data (see
tests/stf/interop/test_decorator.py for the complete program):
from numba import cuda
from cuda.stf._experimental.interop.numba import jit
@jit
def axpy(a, x, y):
i = cuda.grid(1)
if i < x.size:
y[i] = a * x[i] + y[i]
axpy[grid, block](2.0, lX.read(), lY.rw())
get_arg_numba(task, index) and numba_arguments(task) are the lower-level
converters used by these helpers.
Record-once task graphs#
For repeated work, use task_graph() to record an STF task DAG once and launch it
many times. The graph owns a stackable_context exposed as graph.context. Declare
logical data before recording, enter with graph: exactly once to submit tasks, then
call graph.launch() whenever the recorded graph should replay.
def _record_add_graph(n=256, value=1.0):
x_host = np.zeros(n, dtype=np.float32)
graph = stf.task_graph()
ctx = graph.context
lx = ctx.logical_data(x_host, name="X")
tpb = 128
bpg = (n + tpb - 1) // tpb
with graph:
with ctx.task(lx.rw()) as t:
nb_stream = cuda.external_stream(t.stream_ptr())
dx = numba_arguments(t)
add_kernel[bpg, tpb, nb_stream](dx, value)
return graph, x_host
def test_task_graph_relaunch():
graph, x_host = _record_add_graph(value=1.0)
for _ in range(5):
graph.launch()
graph.finalize()
assert np.allclose(x_host, 5.0), f"Expected 5.0, got {x_host[0]}"
graph.context.task(...) is intentionally valid only while with graph:
is active. This catches the common mistake of submitting a task to the owned
context outside the recorded graph. Data declarations such as
graph.context.logical_data(...) and graph.context.token() remain valid
outside the recording block so buffers and ordering tokens can be prepared
first.
Keep the Python objects backing recorded logical data alive for as long as the
graph may launch. task_graph() records work against the memory referenced by
objects such as NumPy arrays, Warp arrays, PyTorch tensors, or CuPy arrays; it
does not take ownership of those Python objects or extend their lifetime.
The lower-level stackable_context.pop_prologue_shared() API remains
available for advanced code that manages stackable scopes manually, but most
Python examples should prefer task_graph().
Device-side loops#
A stackable_context (created directly or owned by a task_graph()) can
nest scopes that become CUDA conditional graph nodes (CUDA 12.4+), so iteration
runs entirely on the device with no host round-trip per step:
with ctx.repeat(count):– run the body a fixed number of times.with ctx.while_loop() as loop:– run the body while a condition holds.
A while body executes at least once. Call loop.continue_while(...)
exactly once per body, after the body tasks; it schedules an internal task
that reads 1-element logical data and sets the continuation predicate for the
next iteration (tasks submitted after it still run in the current one).
Conditions compare device scalars against host constants and combine with
& (continue while all hold) or | (continue while any holds), with
~ for negation – the usual way to pair a convergence test with an
iteration cap so a non-converging solve cannot replay forever. The following
excerpt caps a solver loop on both the residual and an iteration counter (see
tests/stf/examples/cg.py for the complete program):
liter = ctx.logical_data_zeros((1,), np.float64, name="iter")
with ctx.while_loop() as loop:
# ... solver step updating lresidual, and a task incrementing liter ...
loop.continue_while((lresidual > tol) & (liter < max_iter))
(lresidual > tol) is sugar for the canonical leaf constructor
stf.cond(lresidual, ">", tol), which also suits generated code; both
forms lower onto a single condition task and one tiny kernel regardless of
the number of terms (at most 8, one &-chain or one |-chain per
condition). Use and / or and these expressions raise TypeError.
See tests/stf/examples/cg.py and tests/stf/examples/bicgstab.py for
complete solvers built on device-side while loops.
Places#
Execution place (
exec_place) – where the task runs. Pass as the first argument toctx.task(...):exec_place.device(device_id)orexec_place.host(). Example:ctx.task(exec_place.device(0), lX.read(), lY.rw()). Multi-device work can useexec_place_grid(viaexec_place_grid.from_devices([...])orexec_place_grid.create(places, grid_dims=..., mapper=...)); a grid retains its sub-places, and acompositedata place retains its grid and partition-function closure, so those Python objects need not be kept alive separately. Places created from an external CUDA context (exec_place.from_context(...), e.g. the green contexts produced bygreen_places()) expose the backing object through the read-onlyplace.backing_contextproperty and keep it alive for the place’s lifetime.exec_place_grid.create(places, grid_dims=...)arranges places into a C-order processor grid (the last axis enumerates fastest, like a NumPy shape). Existing grids can be viewed with new dimensions without reordering, replicating, or removing places:grid = exec_place_grid.create(places, grid_dims=(2, 3, 4)) flat = grid.reshape((24,)) grid_6x4 = grid.collapse_axes(0, 1)
reshape()requires the new extents to have the same product asgrid.size.collapse_axes(first, last)merges a contiguous inclusive range of C-order axes; for example, collapsing axes 0 and 1 above produces dimensions(6, 4). Both return independently owned grid wrappers with the same linear place order.
Data place (
data_place) – where logical data lives:data_place.host()(the default for registration, i.e.logical_data(...)),data_place.affine()(the default for a dependency, letting the runtime place data near the task’s execution place),data_place.device(device_id), anddata_place.managed(). Use when creating logical data or in a dependency, e.g.lZ.rw(data_place.device(1)).
Tokens#
ctx.token() creates a token (logical data with no buffer) for ordering tasks
without data transfer. Use token.read() or token.rw() in task dependencies.
Example collections#
For runnable examples, see the
STF tests and examples.
The interop/ subdirectory exercises the Numba and PyTorch adapters (kernels,
tokens, multi-GPU, FDTD), and examples/ holds larger end-to-end programs
(conjugate gradient, Cholesky, Burger, neural ODE).
For the full STF programming model, graph visualization, and C++ API, see CUDASTF (C++).