Programming concepts#

Cooperative operations let a group of threads work on data together. This page explains the participating threads, their payloads and layouts, and the rules for results and temporary storage.

See the Numba-CUDA-MLIR Programming Guide for complete kernels and launch examples.

Registering a backend#

When you cannot ensure import order, call cuda.coop.register() on the host before compiling kernels to select the backend explicitly:

from cuda import coop

coop.register("numba-cuda-mlir")

from numba_cuda_mlir import cuda

Registration loads the backend and installs its compiler hooks, so this works regardless of whether cuda.coop or Numba-CUDA-MLIR was imported first. Repeated calls are safe and return None. The spelling "numba_cuda_mlir" is also accepted. Registration requires the backend’s dependencies to be installed; it does not install packages.

For convenience, importing cuda.coop after numba_cuda_mlir also registers the backend automatically. A standalone cuda.coop import does not discover or load optional compilers. Explicit registration is useful in libraries and notebooks where another import may already have loaded cuda.coop.

Importing the backend namespace also registers it:

import cuda.coop.numba_mlir as numba_coop

Use numba_coop when mixing common and backend calls. If your program uses only the backend namespace, you can import it as coop instead. See the namespace FAQ and API comparison.

Kernel API#

The common root and qualified backend expose matching entry points:

from numba_cuda_mlir import cuda, types

from cuda import coop

# Inside a Numba-CUDA-MLIR kernel with an items_per_thread argument:
block = coop.this_block()
items = coop.ThreadData(items_per_thread)
tile_items = cuda.blockDim.x * items_per_thread
tile_offset = cuda.blockIdx.x * tile_items
valid_items = count - tile_offset
if valid_items < 0:
    valid_items = 0
elif valid_items > tile_items:
    valid_items = tile_items
coop.load(
    block,
    source,
    items,
    valid_items=valid_items,
    oob_default=0,
    offset=tile_offset,
)
coop.store(
    block,
    destination,
    items,
    valid_items=valid_items,
    offset=tile_offset,
)

For a complete copy kernel, including allocation, launch, tail handling, and an output check, see the example in cuda.coop.load(). The API reference also includes tested examples for cuda.coop.store(), cuda.coop.ThreadData(), and cuda.coop.TempStorage().

Use the qualified namespace when backend-specific types or controls are required:

import cuda.coop.numba_mlir as coop

Both spellings are compiler markers. Calls must occur in a compatible compiler context; they are not host-side data movement operations.

Groups and thread data#

cuda.coop.this_block() describes the current CUDA thread block, and cuda.coop.this_warp() describes the current 32-thread physical warp. A physical warp can be partitioned with this_warp().group_by(width) into consecutive logical warps of 1, 2, 4, 8, 16, or 32 threads. Load and Store support all three forms. The enclosing block must contain a multiple of 32 threads, with no incomplete final physical warp. For a multidimensional block, threads are linearized in x-major order. Every member of a participating group must reach its collective; complete sibling logical groups may take different control-flow paths.

The common group vocabulary also includes thread, cluster, grid, and mapped groups of physical warps, but those are not Load or Store targets. ThreadGroup objects are descriptor-only in this release. group_by is compile-time vocabulary for describing a static partition. Runtime query, membership, and synchronization methods such as rank, count, rank_as, count_as, sync, sync_aligned, and is_member are not exposed.

Participation and synchronization#

Every member of a participating group must reach the same cooperative call. A branch around a block operation must be uniform across the block; a branch around a logical-warp operation must be uniform within that logical warp. Complete sibling logical groups may follow different paths. Warp operations require a block size divisible by 32, with no incomplete final physical warp.

Do not put a block Load or Store inside a per-element if index < count condition. Use valid_items to describe the valid prefix while all block threads participate. An early return by some threads also violates participation if the remaining threads later execute a block operation.

A scratch-reuse barrier protects temporary storage. Its presence depends on the algorithm and storage policy; arrange explicit synchronization wherever application-owned shared memory requires it. A barrier does not make divergent participation safe.

Per-thread payloads#

coop.ThreadData(items_per_thread) gives each participating thread a fixed-size payload with that many items. Pass items_per_thread as a kernel argument; Numba-CUDA-MLIR specializes the kernel for its value. Common and qualified calls use the same inference rules: an untyped Load output infers its dtype from the source, and Store combines the destination dtype with payload writes. Load fills the supplied output in place and returns None.

Both namespaces accept alignment as a compile-time positive power of two in bytes. It specifies minimum alignment when the compiler materializes payload storage; None lets the compiler choose. For example, coop.ThreadData(items_per_thread, alignment=16) requests at least 16-byte alignment. The backend may use stronger alignment, including for requests smaller than its minimum allocation alignment. This option does not assert alignment of source or destination arrays passed to Load or Store.

Payload slots start uninitialized. Write every slot before reading it. A partial Load leaves invalid slots unspecified unless you pass oob_default. Initialize those slots after the Load or supply a default before reading them. cuda.coop.ThreadDataLike names the shared payload interface in type signatures. Protocol compatibility alone does not make an arbitrary Python object a supported kernel value.

The payload’s items_per_thread attribute is a compile-time integer and can be used as a loop bound inside a kernel, including through payload aliases.

Supported payload types are signed and unsigned 8-, 16-, 32-, and 64-bit integers plus 32- and 64-bit floating-point values. Boolean, 16-bit floating point, complex, and mismatched payload types are rejected before NVRTC compilation.

Load and Store semantics#

The signatures are:

load(
    group, source, output, /, *,
    algorithm="direct",
    valid_items=None,
    oob_default=None,
    offset=None,
    temp_storage=None,
) -> None

store(
    group, destination, value, /, *,
    algorithm="direct",
    valid_items=None,
    offset=None,
    temp_storage=None,
) -> None

valid_items counts the valid prefix of the selected group tile, not the number of valid items per thread. A Warp-group tile contains group_size * items_per_thread elements. Here group_size is 32 for this_warp() or the width passed to group_by. The count must be uniform within that group. With Load, invalid output slots are unspecified unless oob_default is supplied, even if initialized before Load. A runtime default must also be uniform within the group. A default is valid only when valid_items is present. With Store, elements outside the valid prefix are not written.

Warning

valid_items must satisfy 0 <= valid_items <= group_size * items_per_thread. Static values outside that range are rejected while planning. Runtime values are checked rather than saturated; do not rely on CUB’s oversized-count behavior. An invalid runtime value executes a deterministic device trap before narrowing to CUB’s integer parameter, and that trap poisons the current CUDA context. Clamp grid-stride and tail counts as in the example above. Run intentional failure probes in disposable processes. For a Warp-group call, a block-wide remainder is not a valid count: subtract that group’s tile origin and clamp the result to [0, group_size * items_per_thread].

offset is an element offset into the source or destination. It is independent of valid_items and is not measured in bytes. The value must be uniform within each participating group; different groups may use different offsets. Static offsets must be nonnegative; a runtime offset is a caller-enforced nonnegative precondition. Source and destination arrays must be one-dimensional and contiguous. Store accepts both scalar values and multi-item ThreadData payloads.

Runtime valid_items and offset accept signed integer types through 64 bits and unsigned integer types through 32 bits. Boolean, floating-point, and uint64 runtime values are rejected. A runtime oob_default is already typed by the compiler and must exactly match the Load payload dtype. Ordinary Python integer and floating-point literals are converted contextually and range-checked against that dtype before provider generation.

For a Warp group of width group_size, the compiler first advances the memory base by group_index * (group_size * items_per_thread) and then applies the caller’s offset. The group index is the x-major linear thread rank divided by the group size, so every physical or logical Warp group in a block addresses a distinct tile. In a multi-block traversal, the caller offset must also include the block’s global tile origin. Do not add the compiler-provided group origin again. Runtime offsets must leave enough signed 64-bit range for the last group origin in the block; static offsets are checked during planning.

Store payloads must have exactly the destination dtype. Numba-CUDA-MLIR may promote integer arithmetic even when its operands are 32-bit. Cast a computed value explicitly before storing it:

value = types.int32(source[cuda.threadIdx.x] + 1)
coop.store(block, destination, value, algorithm="direct")

Data layouts and algorithms#

Both common and qualified entry points use the same string algorithm vocabulary: direct, striped, vectorize, transpose, warp_transpose, and warp_transpose_timesliced. All six work with the Numba-CUDA-MLIR backend. direct and vectorize use blocked ordering, so each thread owns a contiguous segment of the tile. striped exposes striped ordering, where item i for a thread is separated from its next item by the block size. The three transpose algorithms use striped memory transactions but present blocked ThreadData to the caller. The two warp-transpose variants perform that reordering within each warp and require a block size divisible by 32.

Physical and logical Warp Load and Store support direct, striped, vectorize, and transpose. Their layouts follow the same rules at the selected group width: direct and vectorize expose blocked payloads, striped exposes a striped payload, and transpose uses striped memory transactions while exposing a blocked payload. Common and qualified calls use the same lowercase string selectors. Selectors are normalized to lowercase underscore-delimited strings. Enum and integer selectors, including 0, are rejected.

For group size G, items per thread K, thread rank t, and item index i, blocked order uses tile position t * K + i; striped order uses t + i * G. The payload has no runtime layout tag that corrects a mismatched Load/Store pair.

Store consumes the arrangement associated with its selected algorithm. Transpose Store algorithms may rearrange the input payload in place, following CUB’s behavior. Reload or reinitialize the payload before using its previous arrangement again.

Temporary storage#

Block Load and Store accept an optional TempStorage descriptor.

Scratch belongs to one block during its kernel execution. A descriptor does not carry data between blocks or kernel launches.

For example:

scratch = coop.TempStorage(
    size_in_bytes=None,
    alignment=None,
    auto_sync=False,
    sharing="shared",
)

Only size_in_bytes may be positional; alignment, auto_sync, and sharing are keyword-only. As with ThreadData, alignment=None lets the compiler choose. An explicit positive power of two sets minimum alignment in bytes. The planner may strengthen it to satisfy every primitive using the storage. Integer-like values implementing __index__ are accepted. An explicit size_in_bytes must still be large enough for the planned storage.

For block, physical Warp, and logical Warp operations, direct, striped, and vectorize are storage-free. They default-construct the CUB primitive, report zero temporary bytes, and emit no shared-memory allocation, storage pointer, or synchronization barrier. For a block call, an explicit descriptor, including an unsized descriptor, is validated as compile-time vocabulary but does not change code generation for those algorithms. Construct TempStorage inside the kernel; the current Numba-CUDA-MLIR frontend does not resolve module-global storage descriptors. A descriptor may be passed to a device function that Numba-CUDA-MLIR inlines into the kernel, which is the default, but it cannot cross into a separately compiled device function.

The block transpose, warp_transpose, and warp_transpose_timesliced use CUB temporary storage. Without a descriptor, the compiler allocates the specialization’s exact storage and inserts a block reuse barrier. An explicit descriptor selects the sharing policy and may request capacity and alignment. The compiler selects static or dynamic backing from the total requirement, as described below. The provider remains authoritative for the required byte count and alignment, and the backend validates the descriptor against the concrete lowering plan.

Sharing selects only the slice layout: sharing="shared" overlaps every call that passes the same descriptor on one region, while sharing="exclusive" gives each call site its own slice. A call site inside a loop reuses its slice under either layout, so auto_sync is independent of sharing and defaults to False for both.

A descriptor names one region. Distinct descriptors and compiler-owned storage never alias each other. With auto_sync=True, the compiler appends cuda.syncthreads() immediately after every block call that consumes the storage, including the last one. Warp calls do not accept a descriptor. The compiler never inserts a barrier before a call. The trailing barrier orders only reuse of the temporary storage, and storage-free calls get none. Arrange explicit barriers for the kernel’s own shared-memory traffic. With auto_sync=False (the default), the compiler adds no reuse barrier. The caller issues cuda.syncthreads() between consecutive uses of the descriptor, and a call site inside a loop counts as a reuse on every iteration. Compiler-owned storage always synchronizes.

The compiler stages every descriptor and every compiler-owned requirement of a kernel into one shared-memory backing. Up to 48 KiB, it uses static shared memory without querying the device. Larger requests, from explicit size_in_bytes or implicit requirements, trigger a query of the device’s default and opt-in limits. Backing above the default limit uses dynamic shared memory, and the launch reserves its exact byte count within the opt-in limit. Supported Numba-CUDA-MLIR releases do not separate static and dynamic shared allocations reliably. A kernel using cooperative temporary storage must not also declare a zero-sized or runtime-sized cuda.shared.array. When cooperative backing becomes dynamic, user static shared arrays are also unsupported. Keep both user arrays and cooperative backing static, or move the user data out of shared memory. Storage-free operations do not add this restriction.

With auto_sync=False, a descriptor must originate from exactly one constructor site. Selecting between multiple manual-sync constructors is unsupported: the compiler cannot prove that caller barriers protect the merged region, even when a particular program supplies sufficient barriers.

Cooperative calls in device helpers must be inlined into the kernel; use @cuda.jit(device=True, inline="always") when selecting the helper’s policy explicitly. Standalone collective helpers and collectives inside standalone callbacks are unsupported. Values from literal_unroll cannot determine cooperative payload extents, group dimensions, selectors, or descriptor constructor arguments. Use separate calls with explicit constants, or an ordinary loop with one fixed cooperative shape. The restriction does not apply to an unrelated literal_unroll loop.

Warp transpose uses compiler-owned storage with one disjoint slice per physical or logical group. The compiler inserts syncwarp with the exact logical-group mask. Both the common and qualified APIs reject explicit TempStorage for every Warp Load and Store algorithm, including the storage-free modes.