cuda.coop Programming Guide#
cuda.coop lets threads cooperate inside a Python GPU kernel. You can
load a tile, operate on its per-thread values, and write the result
without leaving the kernel. You choose the participating group and the data
each thread contributes.
The visualizations show how values move through these operations. Each explorer includes an example kernel and lets you step through the algorithm. The glossary defines terms and layouts; the FAQs explain common API choices.
This guide assumes you have written a CUDA kernel and know how threads,
blocks, and device arrays work. The examples use Numba-CUDA-MLIR. The
installation instructions describe the matching
cuda-coop extra; the current backend requires
numba-cuda-mlir>=0.5.0,<0.6. Check the
validation scope for environment coverage.
Keep a compiled kernel in its original CUDA context; see the
device and context-lifetime limitation
before reusing a dispatcher across devices or recreated contexts.
This guide describes the experimental Numba-CUDA-MLIR 0.5.x API. Operation support varies by group and backend. The examples below use supported block and warp operations.
Requirements#
Use Python 3.10 through 3.14 and numba-cuda-mlir>=0.5.0,<0.6. The
installation instructions describe the cuda-coop extra
matching your CUDA major version. Running the examples requires a
CUDA-capable GPU. The developer guide’s
validation scope describes the environments
covered by CI. Before reusing compiled kernels, check the
device and context-lifetime rules and
disk-cache behavior.
A first kernel: copying a tile#
The cuda.coop.load() reference contains a complete, tested copy kernel,
including allocation, launch, partial-tile handling, and an output check.
All threads participate in Load and Store, while valid_items selects the
valid prefix of the final tile. The examples below use these imports:
import numpy as np
from numba_cuda_mlir import cuda, types
from cuda import coop
Choosing the common or qualified API#
The common API is imported with:
from numba_cuda_mlir import cuda
from cuda import coop
cuda.coop expresses operations through a common vocabulary of groups,
numeric values, ThreadData, and TempStorage. The kernel
compiler uses its registered backend to implement those calls. Start here
when these operations cover your kernel’s needs. Numba-CUDA-MLIR is the first
backend; CUTLASS support is planned.
The qualified import selects the Numba-CUDA-MLIR API explicitly:
import cuda.coop.numba_mlir as numba_coop
Both imports can be used in the same program. This guide uses coop for
common calls and numba_coop for qualified calls so the choice is visible.
In a program that uses only the qualified API, importing it as coop is
also fine.
The qualified API accepts Numba-specific payloads and adds controls to several operations:
Need |
Common API |
Numba-CUDA-MLIR-qualified API |
|---|---|---|
Fixed data per thread |
|
Also accepts fixed local arrays in supported array operations;
exposes |
Load/Store algorithms and explicit scratch |
String algorithm selectors and |
Same shared controls; qualifying the import is unnecessary for these |
The common API defines backend-independent contracts. You must still check that the selected backend implements the requested group, dtype, and operation. The kernels here also contain Numba launch and indexing code; porting the complete kernel to another DSL involves those parts too.
Registering the compiler backend#
Register explicitly on the host before compiling when imports may occur in any order, as in a library or notebook:
from cuda import coop
coop.register("numba-cuda-mlir")
from numba_cuda_mlir import cuda
Repeated calls are safe. "numba_cuda_mlir" is also accepted. Importing
Numba-CUDA-MLIR before cuda.coop activates the backend automatically;
importing cuda.coop.numba_mlir as numba_coop does so explicitly and gives
you the backend namespace. See backend registration for details.
These primitive calls belong inside kernels compiled by a compatible backend. Registration itself is a host-side operation.
Compilation and kernel reuse#
CUDA devices and context lifetime#
With Numba-CUDA-MLIR 0.5.0 through 0.5.4, select the CUDA device before a
kernel’s first compilation and keep its dispatcher and configured launch
callables in that CUDA context. Reusing them on another device or after
destroying and recreating the context is not qualified: the compiler can
reuse architecture, compiled overload, or launch state from the original
context. This also applies to kernels that use cuda.coop.
The upstream context-isolation fix must be released and
qualified with this integration before relying on that reuse. Switching
devices does not require re-registering cuda.coop; registration installs
compiler hooks and does not repair dispatcher context state.
Disk caching#
With Numba-CUDA-MLIR 0.5.x, activating the cuda.coop backend disables
the compiler’s cache=True disk cache for all kernels in that process.
Compiled kernels still have an in-memory cache. The provider cache controlled
by CUDA_COOP_ENABLE_CACHE is separate; see
compiler activation and reuse for its
configuration and lifetime.
Groups: which threads cooperate#
A group defines the participants in an operation. this_block() uses
all threads in the current block; this_warp() uses a complete physical
warp of 32 threads. this_warp().group_by(8) partitions it into four
consecutive logical warps of eight threads. The compiler obtains the block
shape from the kernel launch; the group factories take no size arguments.
Load and Store support blocks, physical warps, and logical warp widths of 1, 2, 4, 8, 16, or 32. Warp operations require a block size divisible by 32. The partition width is a compile-time constant. Every member of a participating group must reach the same primitive invocation.
The common API also declares other hierarchy descriptors. Their presence does not imply executable operations or queries on those scopes in this backend.
Participation and synchronization#
Every required participant must reach the same primitive invocation. For a block operation, a branch around the call must be uniform across the block. For a logical-warp operation, it must be uniform within that logical warp. Complete sibling logical groups can follow different paths.
In particular, putting a block primitive inside if index < count
breaks participation on a partial tile. Keep the primitive outside the
per-element condition. For a partial Load, supply valid_items and
oob_default, or initialize invalid slots after the Load and before reading
them. An early return by some block threads has the same problem if the
remaining threads later execute a block primitive.
Use cuda.syncthreads() for explicit block synchronization in
Numba kernels. Every thread in the block must reach the barrier.
An operation’s scratch-reuse barrier protects its temporary storage.
Automatic synchronization inserts a trailing barrier after each call that
consumes scratch. It does not prove that arbitrary user control flow is safe;
every member must still reach the primitive and its barrier.
Arrange synchronization for your own shared-memory communication as well.
Constructing a group or a ThreadData object does not synchronize threads.
ThreadData: the part of a tile owned by one thread#
coop.ThreadData(items_per_thread) gives each thread that many slots.
With 128 threads, the group owns 128 * items_per_thread values. Each
thread indexes its own slots with items[i]. Each thread accesses only
its own slots.
ThreadDataLike names the common payload interface used
in API signatures. It describes the item count, dtype, and indexed reads and
writes. Use ThreadData() to construct a payload for the
active compiler backend. Other payload representations require support from
that backend.
The item count must be a positive compile-time integer. Pass
items_per_thread as a kernel argument; the compiler specializes the
kernel for each supplied value. You can also use items.items_per_thread
as a loop bound:
for i in range(items.items_per_thread):
items[i] = types.int32(items[i] * 2)
Initialize every slot before reading it. Constructing ThreadData does
not fill it with zeros. A full Load initializes the entire payload; a
partial Load leaves out-of-bounds slots unspecified unless oob_default is
provided. Initialize those slots after the Load or supply oob_default
before reading them.
Blocked and striped order#
The glossary defines blocked and striped ownership and compares their layouts.
A primitive needs to know how the per-thread slots correspond to the group’s tile. Here is a small layout illustration with four threads and two items each. The numbers are positions in the tile; this illustration does not prescribe a supported CUDA launch size.
Thread rank |
Blocked: |
Striped: |
|---|---|---|
0 |
0, 1 |
0, 4 |
1 |
2, 3 |
1, 5 |
2 |
4, 5 |
2, 6 |
3 |
6, 7 |
3, 7 |
For group size G, items per thread K, thread rank t, and local
item index i, blocked order uses tile position t * K + i.
Striped order uses t + i * G.
The payload does not carry a runtime layout tag that corrects mismatched operations. The algorithms you call determine the interpretation. Pairing a striped Load with a direct Store without a conversion permutes the data.
Compare the Load and Store visualizations to see how each algorithm maps memory positions to per-thread slots.
Dtypes and storage#
The current numeric payload types are signed and unsigned integers of 8, 16, 32, or 64 bits, and 32- or 64-bit floating point. Boolean, half precision, complex, and structured payloads are outside this contract.
Leave the constructor’s element type unspecified for normal use. Load infers it from the source, and Store can establish it from the destination. Supported typed assignments also provide the element type; use a typed scalar when your computation needs a particular width or precision. Inference follows the backend’s supported producers and assignments. Conflicting type requirements are errors. See element-type inference for cases that need additional information.
Load writes into the payload supplied by the caller. Transpose Store
algorithms may rearrange their input payload in place, as in CUB. Copy values
before Store if they are needed later. Both operations return None.
Numba can promote integer arithmetic. Store requires an exact match to the
destination dtype, so cast computed values when necessary, as in the
types.int32 assignment above. Accumulation dtype also matters: an
integer sum can overflow, and parallel floating-point sums can differ from
a sequential CPU sum because of evaluation order.
The compiler can keep payload elements in registers. Indexing patterns, address-taking, and register pressure influence the final placement. Increasing the item count increases the amount of live data per thread.
The optional alignment keyword requests a minimum power-of-two alignment
in bytes when the compiler materializes the payload. For example,
coop.ThreadData(items_per_thread, alignment=16) requests at least
16-byte alignment. The compiler may strengthen it. This setting applies
to payload storage; alignment of the input and output arrays remains a
separate property.
Load, operate, store#
Load and Store accept one-dimensional contiguous arrays. offset counts
elements from the array’s beginning. valid_items counts elements in
the valid prefix of the selected group’s tile. Both must be uniform within
that group.
For a block, a tile holds block_threads * items_per_thread elements.
For a physical or logical warp, it holds
warp_width * items_per_thread elements. Clamp the valid count to that
range before calling Load or Store. An out-of-range runtime count causes
a device trap and invalidates the CUDA context.
Load leaves invalid slots unspecified unless you pass oob_default,
even if those slots were initialized before Load.
Store leaves destination elements outside the valid prefix untouched.
Supply an operation-appropriate identity when processing padded data:
zero for sum, one for multiplication, and a suitable upper or lower bound
for minimum or maximum. For a runtime oob_default, use exactly the
payload dtype and the same value across the group.
Algorithm choices#
Algorithm |
Payload order |
Memory access and rearrangement |
Groups |
|---|---|---|---|
|
Blocked |
Each thread accesses its consecutive items |
Block and Warp |
|
Striped |
Neighboring threads access neighboring elements at each item index |
Block and Warp |
|
Blocked |
Uses vector accesses where the specialization and alignment allow |
Block and Warp |
|
Blocked |
Uses striped accesses and shared scratch to rearrange values |
Block and Warp |
|
Blocked |
Rearranges through warp-sized portions of the block tile |
Block |
|
Blocked |
Reuses scratch across those portions |
Block |
The two block warp-transpose variants require a block size divisible by
32. Use string selectors. Begin with direct for a simple kernel;
measure alternatives with the actual dtype, item count, and surrounding
work. Coalesced accesses can justify rearrangement costs, but the best
choice depends on the kernel.
Warp tile addresses#
Warp Load and Store automatically add the group’s origin within the
block. For width G and K items per thread, this origin is
(linear_thread_rank // G) * G * K. The explicit offset is added
after that origin. In a multi-block traversal, pass the block’s global
tile origin as offset.
The valid count still belongs to each individual group. Compute it using both the block origin and the group’s origin:
@cuda.jit
def copy_warp_tiles(source, destination, count, items_per_thread):
group = coop.this_warp().group_by(8)
items = coop.ThreadData(items_per_thread)
block_origin = cuda.blockIdx.x * cuda.blockDim.x * items_per_thread
group_origin = (cuda.threadIdx.x // 8) * 8 * items_per_thread
valid = min(
max(count - block_origin - group_origin, 0), 8 * items_per_thread
)
coop.load(
group,
source,
items,
offset=block_origin,
valid_items=valid,
oob_default=0,
)
coop.store(
group, destination, items, offset=block_origin, valid_items=valid
)
for items_per_thread in (1, 4):
source = np.arange(531, dtype=np.int32)
destination = np.full_like(source, -1)
blocks = (source.size + 128 * items_per_thread - 1) // (
128 * items_per_thread
)
copy_warp_tiles[blocks, 128](
source, destination, source.size, items_per_thread
)
cuda.synchronize()
np.testing.assert_array_equal(destination, source)
Adding group_origin to offset here would count it twice. This
automatic origin applies to Warp Load and Store; ordinary array indexing
in a kernel uses exactly the index you write.
Device helpers#
Use @cuda.jit(device=True) to put cooperative operations in a reusable
helper. Pass a group and a ThreadData payload as arguments just as you
would pass them to an operation in the kernel. The group still names the
caller’s participating threads; each thread passes its own payload. Changes
to that payload’s items are visible to the caller.
Cooperative helpers must be inlined into the calling kernel before planning.
Numba-CUDA-MLIR defaults device helpers to inline="always"; the example
spells it out on load_into. inline="never" prevents this frontend
inlining and is unsupported for a helper containing cooperative operations.
Inlining during later optimization or device linking comes too late to
supply the group and payload information this planner needs.
This example uses nested helpers. load_tile creates a payload, passes it
and the group to load_into, and returns the initialized payload to the
kernel. Load infers the element type from source:
import numpy as np
from numba_cuda_mlir import cuda
from cuda import coop
@cuda.jit(device=True, inline="always")
def load_into(group, source, items):
coop.load(group, source, items)
@cuda.jit(device=True)
def load_tile(group, source, items_per_thread):
items = coop.ThreadData(items_per_thread)
load_into(group, source, items)
return items
@cuda.jit
def copy_tile(source, destination, items_per_thread):
block = coop.this_block()
items = load_tile(block, source, items_per_thread)
coop.store(block, destination, items)
for items_per_thread in (1, 4):
expected = np.arange(128 * items_per_thread, dtype=np.int32)
source = cuda.to_device(expected)
destination = cuda.device_array_like(source)
copy_tile[1, 128](source, destination, items_per_thread)
np.testing.assert_array_equal(destination.copy_to_host(), expected)
After inlining, the planner can see the helpers’ operations, group arguments,
and payload uses together. The calling kernel supplies the compiler context
and launch dimensions. An inlined helper can also create or return a group.
coop.this_block() inside the helper refers to the same block as the
caller. Group descriptors are resolved during compilation and have no
separate runtime representation for an out-of-line device call.
Every required group member must reach each cooperative call inside the helper, including through nested calls or loops. Moving an operation into a helper does not change its participation or synchronization requirements. Cooperative operations inside standalone operation callbacks remain unsupported; those callbacks may perform ordinary device computation.
An ordinary inline="never" helper can index and modify a payload whose
element type is already established. It receives a local array, so
items.items_per_thread is unavailable there. Continue using the original
payload in the caller: returning the array from an out-of-line helper loses
the payload metadata needed by subsequent common cooperative calls. Inline
helpers that need that metadata or return a payload for those calls.
Ordinary scalar helpers can also remain out of line. Their return values may
need a typed assignment or another supported operation to establish a
ThreadData element type, even when the helper has an explicit signature.
This is separate from cooperative helper inlining; see
element-type inference for workarounds.
Keep cooperative group mappings, operation selectors, and payload or storage
shapes known at compile time. The current planner does not support
literal_unroll values that determine these properties. Write those calls
with compile-time constants. Ordinary runtime loops with fixed cooperative
shapes, and unrelated uses of literal_unroll, remain supported.
TempStorage: scratch used during a primitive#
Some algorithms exchange intermediate values through shared memory.
By default, cuda.coop allocates the scratch they need and inserts
the required reuse barrier. Start with that behavior.
TempStorageLike names the common interface for explicit
scratch descriptors in API signatures. Construct one inside the kernel with
TempStorage(). Its size_in_bytes, alignment,
auto_sync, and sharing properties control capacity, alignment,
synchronization, and allocation sharing. The active compiler backend must
recognize the descriptor.
An explicit descriptor lets several supported block calls reuse an
allocation. Its contents are opaque; keep application values in
ThreadData or your own arrays.
Calls |
Scratch behavior in the current backend |
|---|---|
Direct, striped, or vectorize Load/Store |
No shared scratch or reuse barrier |
Block transpose-family Load/Store |
Automatic scratch, or an explicit |
Warp transpose Load/Store |
Automatic scratch per group; explicit descriptors are rejected |
Capacity, alignment, and lifetime#
TempStorage() defaults to sharing="shared" and auto_sync=False,
leaving reuse synchronization to the caller. size_in_bytes=None and
alignment=None let the compiler determine the requirements. An explicit
capacity must be large enough for the operations using it. An explicit
alignment requests a minimum positive power of two in bytes; the compiler
can strengthen it.
Only size_in_bytes may be positional. The other options are keyword-only.
sharing="exclusive" gives distinct call sites separate slices. With
auto_sync=False, those calls need no barrier solely to reuse each other’s
scratch. This uses more shared memory than sharing a slice. A loop reuses each
call site’s slice on its next iteration, so that reuse still needs a barrier.
sharing and auto_sync are independent controls; application data
dependencies may require additional synchronization. See
the exclusive-storage FAQ.
Omitted storage also participates in the backend’s shared-memory plan and launch accounting. The compiler may reuse its scratch across compatible calls and inserts the required reuse barriers.
With the default auto_sync=False, the kernel must provide reuse barriers.
Put an explicit block barrier before a later call reuses the same scratch,
including between loop iterations.
Set auto_sync=True to insert automatic trailing barriers for scratch
reuse. Without an explicit descriptor, the compiler synchronizes scratch
automatically. An unrelated memory access between calls does not establish a
block barrier. The compiler rejects merging multiple manually synchronized
TempStorage constructors into one descriptor, including conditional
definitions. Use one constructor and keep its synchronization explicit. This
restriction reflects what the planner can establish; it does not mean that
every rejected program necessarily races.
Scratch lasts for the kernel’s execution on that block. It cannot carry state between blocks or kernel launches. Keep persistent application state in a separate payload.
When the combined scratch requirement exceeds the default static shared-memory limit, the backend can use dynamic shared memory, subject to the GPU’s opt-in limit. Numba-CUDA-MLIR automatically includes those required bytes in the launch configuration. You do not need to copy a compiler-reported byte count into the launch yourself. Requirements above the device limit are rejected. The required dynamic byte count is a minimum: launch-time shared bytes are not added to it as a separate allocation. Dynamic cooperative backing supports alignment requirements up to 16 bytes.
With the currently supported compiler, do not combine user dynamic or
runtime-sized cuda.shared.array allocations with cooperative scratch.
User static shared arrays may coexist with static cooperative backing, but are
rejected when the cooperative backing becomes dynamic, including an implicit
oversized allocation. These checks apply after helper inlining. Keep both
allocations static within the device limit, use global memory for the user
buffer, or separate the work into kernels. Storage-free operations do not
create this conflict. The compatibility restrictions remain until a released
compiler with the shared-memory fix has passed the coexistence tests.
Extra shared memory can reduce resident blocks per multiprocessor. The default inferred allocation and unsized shared descriptor are sufficient for the kernels above; use an explicit capacity when you have a reason to reserve that amount of shared memory.
Checking and tuning a kernel#
Check results before comparing algorithms. Useful cases include one full tile, several tiles, a single valid element in the final tile, and an empty input handled on the host. Layout conversions are easier to inspect with distinct input values. Compare computed values against a CPU reference with a suitable accumulation dtype and floating-point tolerance.
Keep launch dimensions, logical-warp widths, payload extents, and algorithm
choices consistent with the code. The compiler specializes group operations
using these facts. A runtime tile offset or valid count can change from
one call to the next; the size of a ThreadData payload must be known
at compile time.
Warm up the kernel before timing it, use device-resident arrays, and account for asynchronous execution with CUDA events or explicit synchronization. Then vary one choice at a time: threads per block, items per thread, or a Load/Store algorithm. More items per thread can amortize primitive work while increasing register pressure. Scratch-heavy choices consume shared memory and can reduce occupancy. Measure the complete kernel, including conversions and synchronization.
If compilation fails, check the group/operation combination, payload dtype, static parameters, and import order first. The API reference records exact signatures; the overview collects operation restrictions and configuration. For generated-source diagnostics and the compiler integration, see the Developer Overview.