All skills
Skillintermediate

Numba CUDA Reference

Numba compiles Python directly into CUDA kernels, giving you full control over GPU threads, blocks, shared memory, and synchronization. Use Numba when your algorithm needs custom GPU logic that can't be expressed as standard array operations.

Claude Code Knowledge Pack7/10/2026

Overview

Numba CUDA Reference

Numba compiles Python directly into CUDA kernels, giving you full control over GPU threads, blocks, shared memory, and synchronization. Use Numba when your algorithm needs custom GPU logic that can't be expressed as standard array operations.

Full documentation: https://numba.readthedocs.io/en/stable/cuda/index.html

Table of Contents

  1. Installation and Setup
  2. Core Concepts: Kernels, Threads, Blocks, Grids
  3. Writing CUDA Kernels
  4. Thread Positioning
  5. Memory Management
  6. Shared Memory
  7. Device Functions
  8. Atomic Operations
  9. GPU Ufuncs: @vectorize and @guvectorize
  10. GPU Reductions
  11. Streams and Async Operations
  12. Random Number Generation
  13. Cooperative Groups
  14. Common Patterns for Scientific Computing
  15. Performance Optimization
  16. Debugging
  17. Interoperability
  18. Common Pitfalls

Installation and Setup

Always use uv add (never pip install or conda install) in all install instructions, docstrings, comments, and error messages.

uv add numba numba-cuda

The numba-cuda package is the actively maintained NVIDIA implementation. It implements functionality under the numba.cuda namespace — no code changes needed vs the old built-in target.

Requirements: CUDA Toolkit >= 11.2, GPU with Compute Capability >= 5.0 (Maxwell or newer).

from numba import cuda

# Verify GPU is available
print(cuda.is_available())   # True if CUDA works
cuda.detect()                # Prints GPU details

Core Concepts

CUDA organizes parallel execution in a hierarchy:

Grid (of blocks) → Blocks (of threads) → Threads
  • Thread: The smallest unit of execution. Each runs your kernel function.
  • Block: A group of threads that can share fast on-chip memory and synchronize with each other. Max 1024 threads per block.
  • Grid: The collection of all blocks for a kernel launch.

A kernel is a function that runs on the GPU, launched from the CPU. A device function runs on the GPU but is called from other GPU code (not from CPU).


Writing CUDA Kernels

The @cuda.jit Decorator

from numba import cuda

@cuda.jit
def my_kernel(input_array, output_array):
    i = cuda.grid(1)                    # Get this thread's global index
    if i < input_array.size:            # Bounds check — ALWAYS do this
        output_array[i] = input_array[i] * 2.0

Key parameters for @cuda.jit:

ParameterPurpose
device=TrueMakes this a device function (callable from GPU only, can return values)
fastmath=TrueEnables fast math (fast sqrt, division, FMA, trig/exp/log approximations on float32). Use when IEEE-754 strictness isn't required
max_registers=NLimits registers per thread to increase occupancy
cache=TrueCaches compiled kernel to disk
debug=TrueEnables exception checking (slow — for debugging only, pair with opt=False)
lineinfo=TrueSource line info for profiling without full debug overhead

Launching Kernels


from numba import cuda

data = np.random.rand(1_000_000).astype(np.float32)
out = np.zeros_like(data)

# Transfer to GPU
d_data = cuda.to_device(data)
d_out = cuda.device_array_like(out)

# Calculate launch configuration
threads_per_block = 256
blocks_per_grid = (data.size + threads_per_block - 1) // threads_per_block

# Launch
my_kernel[blocks_per_grid, threads_per_block](d_data, d_out)

# Get results back
result = d_out.copy_to_host()

Launch syntax: kernel[grid_dim, block_dim, stream, dynamic_shared_mem_bytes](...args)

The 3rd and 4th parameters are optional (stream and dynamic shared memory size in bytes).

2D Launch Configuration

@cuda.jit
def kernel_2d(matrix, output):
    x, y = cuda.grid(2)
    if x < matrix.shape[0] and y < matrix.shape[1]:
        output[x, y] = matrix[x, y] * 2.0

threads = (16, 16)
blocks = (
    (matrix.shape[0] + threads[0] - 1) // threads[0],
    (matrix.shape[1] + threads[1] - 1) // threads[1],
)
kernel_2d[blocks, threads](d_matrix, d_output)

Convenience: .forall() for 1D

# Automatically computes grid dimensions for 1D
my_kernel.forall(len(data))(d_data, d_out)

Critical Rules for Kernels

  1. Kernels CANNOT return values. All output must be written to arrays passed as arguments.
  2. Always check array bounds. If grid_size > array_size, out-of-bounds threads corrupt memory silently.
  3. Kernel launches are asynchronous. Use cuda.synchronize() before reading results on the CPU.

Thread Positioning

Intrinsics

IntrinsicDescription
cuda.threadIdx.x/y/zThread index within its block
cuda.blockIdx.x/y/zBlock index within the grid
cuda.blockDim.x/y/zThreads per block
cuda.gridDim.x/y/zBlocks in the grid
cuda.grid(ndim)Absolute position in entire grid (1D → int, 2D/3D → tuple)
cuda.gridsize(ndim)Total number of threads in entire grid

Grid-Stride Loop Pattern

For processing data larger than the grid, use a grid-stride loop. This decouples grid size from problem size and is essential for reusing RNG states.

@cuda.jit
def process_large(data, out):
    start = cuda.grid(1)
    stride = cuda.gridsize(1)
    for i in range(start, data.shape[0], stride):
        out[i] = data[i] * 2.0

Memory Management

Data Transfer

# Host → Device
d_array = cuda.to_device(numpy_array)                    # Synchronous copy
d_array = cuda.to_device(numpy_array, stream=stream)     # Async copy

# Allocate on device (no copy)
d_array = cuda.device_array(shape=(1000,), dtype=np.float32)
d_array = cuda.device_array_like(numpy_array)

# Device → Host
host_array = d_array.copy_to_host()                      # New array
d_array.copy_to_host(existing_array)                     # Into pre-allocated
d_array.copy_to_host(stream=stream)                      # Async

Memory Types

TypeAPIUse Case
Devicecuda.device_array(), cuda.to_device()Standard GPU memory
Pinnedcuda.pinned_array(), cuda.pinned() context managerPage-locked host memory — faster transfers
Mappedcuda.mapped_array()Accessible from both host and device
Managedcuda.managed_array()Unified memory — auto-migrates between host/device (Linux/x86 recommended)
Constantcuda.const.array_like(arr)Read-only, cached, set from host

Pinned Memory for Fast Transfers

# Allocate pinned host memory (page-locked — faster PCI-e transfers)
with cuda.pinned(host_array):
    d_array = cuda.to_device(host_array, stream=stream)
    # Transfer is faster because the OS can't page this memory out

# Or allocate directly
pinned = cuda.pinned_array(shape=(1000,), dtype=np.float32)

Deallocation Control

with cuda.defer_cleanup():
    # All GPU deallocation deferred here — avoids implicit synchronization
    # Use this in performance-critical sections
    run_many_kernels()
# Cleanup happens here

Shared Memory

Shared memory is fast on-chip memory (tens of TB/s bandwidth) shared within a block. It's the key to high-performance kernels — use it to cache data that multiple threads in a block will access.

Static Shared Memory (size known at compile time)

from numba import cuda, float32

@cuda.jit
def kernel_with_shared(data, output):
    # Allocate shared memory — visible to all threads in this block
    shared = cuda.shared.array(256, dtype=float32)

    tid = cuda.threadIdx.x
    i = cuda.grid(1)

    # Each thread loads one element into shared memory
    if i < data.size:
        shared[tid] = data[i]

    # BARRIER: wait for ALL threads in block to finish loading
    cuda.syncthreads()

    # Now safe to read any element in shared[]
    if i < data.size and tid > 0:
        output[i] = shared[tid] + shared[tid - 1]

Dynamic Shared Memory (size set at launch)

@cuda.jit
def kernel_dynamic_shared(data):
    # size=0 means "use dynamic shared memory"
    dyn = cuda.shared.array(0, dtype=float32)
    tid = cuda.threadIdx.x
    dyn[tid] = data[cuda.grid(1)]
    cuda.syncthreads()
    # ...

# Specify size at launch (4th parameter = bytes)
kernel_dynamic_shared[blocks, threads, stream, 1024](data)  # 1024 bytes of shared mem

Important: All cuda.shared.array(0, ...) calls in the same kernel alias the same memory region. To use multiple dynamic shared arrays, take disjoint slices manually.

Local Memory (per-thread scratchpad)

@cuda.jit
def kernel_with_local(data):
    # Each thread gets its own private array
    local_buf = cuda.local.array(10, dtype=float32)
    i = cuda.grid(1)
    for j in range(10):
        local_buf[j] = data[i * 10 + j]
    # Process local_buf...

Device Functions

Device functions run on the GPU and are called from kernels or other device functions. Unlike kernels, they can return values.

@cuda.jit(device=True)
def compute_distance(x1, y1, x2, y2):
    return math.sqrt((x2 - x1)**2 + (y2 - y1)**2)

@cuda.jit
def kernel(points, distances):
    i = cuda.grid(1)
    if i < points.shape[0] - 1:
        distances[i] = compute_distance(
            points[i, 0], points[i, 1],
            points[i+1, 0], points[i+1, 1]
        )

Cross-compilation note: A function decorated with @numba.jit (CPU JIT) can also be called from CUDA kernels — useful for sharing logic between CPU and GPU code paths.


Atomic Operations

Atomics ensure thread-safe updates to shared data. All return the old value.

cuda.atomic.add(array, index, value)       # +=   (int32, float32, float64)
cuda.atomic.sub(array, index, value)       # -=   (int32, float32, float64)
cuda.atomic.max(array, index, value)       # max  (int/uint 32/64, float 32/64)
cuda.atomic.min(array, index, value)       # min  (same types)
cuda.atomic.nanmax(array, index, value)    # max ignoring NaN
cuda.atomic.nanmin(array, index, value)    # min ignoring NaN
cuda.atomic.and_(array, index, value)      # &=   (int/uint 32/64)
cuda.atomic.or_(array, index, value)       # |=   (int/uint 32/64)
cuda.atomic.xor(array, index, value)       # ^=   (int/uint 32/64)
cuda.atomic.exch(array, index, value)      # exchange
cuda.atomic.cas(array, index, old, value)  # compare-and-swap

Multi-dimensional indexing works via tuples: cuda.atomic.add(result, (row, col), value)

Example: Histogram

@cuda.jit
def histogram(data, bins):
    i = cuda.grid(1)
    if i < data.size:
        bin_idx = int(data[i] * len(bins))
        if 0 <= bin_idx < len(bins):
            cuda.atomic.add(bins, bin_idx, 1)

GPU Ufuncs

@vectorize — Element-wise Operations on GPU

The simplest way to run element-wise operations on GPU. Write a scalar function, Numba broadcasts it over arrays automatically.

from numba import vectorize, float32, float64

@vectorize([float32(float32, float32),
            float64(float64, float64)],
           target='cuda')
def gpu_hypot(x, y):
    return math.sqrt(x**2 + y**2)

# Usage — just call it like a NumPy ufunc
result = gpu_hypot(array_x, array_y)

# Pass device arrays to avoid transfers
d_x = cuda.to_device(x)
d_y = cuda.to_device(y)
d_result = gpu_hypot(d_x, d_y)

@guvectorize — Generalized Ufuncs

For operations on sub-arrays (not just scalars). Uses NumPy's generalized ufunc signature.

from numba import guvectorize, float32

@guvectorize([float32[:,:], float32[:,:], float32[:,:]],
             '(m,n),(n,p)->(m,p)', target='cuda')
def gpu_matmul(A, B, C):
    for i in range(A.shape[0]):
        for j in range(B.shape[1]):
            total = 0.0
            for k in range(A.shape[1]):
                total += A[i, k] * B[k, j]
            C[i, j] = total

GPU Reductions

from numba import cuda

# Define reduction operation
sum_reduce = cuda.reduce(lambda a, b: a + b)

# Use it
result = sum_reduce(array)                    # Full reduction
result = sum_reduce(array, init=0)            # With initial value
sum_reduce(array, res=device_result)          # Write to device array (no D→H copy)
sum_reduce(array, stream=stream)              # Async

Custom reduction:

@cuda.reduce
def max_reduce(a, b):
    return a if a > b else b

maximum = max_reduce(data_array)

Streams

Streams enable overlapping computation with data transfer and running multiple kernels concurrently.

stream = cuda.stream()

# Async transfer → kernel → transfer back
d_data = cuda.to_device(host_data, stream=stream)
my_kernel[blocks, threads, stream](d_data, d_out)
result = d_out.copy_to_host(stream=stream)
stream.synchronize()  # Wait for everything on this stream

# Context manager that auto-synchronizes
with stream.auto_synchronize():
    d_data = cuda.to_device(host_data, stream=stream)
    my_kernel[blocks, threads, stream](d_data, d_out)
    result = d_out.copy_to_host(stream=stream)
# Synchronizes here automatically

Pipeline Pattern (overlap transfer and compute)

stream1 = cuda.stream()
stream2 = cuda.stream()

# Chunk 1: transfer on stream1
d_chunk1 = cuda.to_device(data[:half], stream=stream1)
# Chunk 2: transfer on stream2 (overlaps with stream1 transfer)
d_chunk2 = cuda.to_device(data[half:], stream=stream2)

# Process chunk1 on stream1
kernel[blocks, threads, stream1](d_chunk1, d_out1)
# Process chunk2 on stream2 (overlaps with stream1 compute)
kernel[blocks, threads, stream2](d_chunk2, d_out2)

cuda.synchronize()  # Wait for all streams

Random Number Generation

Numba provides GPU-native random number generation using the xoroshiro128+ algorithm.

from numba import cuda
from numba.cuda.random import (
    create_xoroshiro128p_states,
    xoroshiro128p_uniform_float32,
    xoroshiro128p_uniform_float64,
    xoroshiro128p_normal_float32,
    xoroshiro128p_normal_float64,
)

# Create RNG states — one per thread
n_threads = 256 * 128
rng_states = create_xoroshiro128p_states(n_threads, seed=42)

@cuda.jit
def monte_carlo_pi(rng_states, iterations, out):
    gid = cuda.grid(1)
    if gid < out.size:
        inside = 0
        for _ in range(iterations):
            x = xoroshiro128p_uniform_float32(rng_states, gid)
            y = xoroshiro128p_uniform_float32(rng_states, gid)
            if x**2 + y**2 <= 1.0:
                inside += 1
        out[gid] = inside / iterations * 4.0

monte_carlo_pi