Skip to content

Tutorial: CPU + CUDA kernels for DLPack → Python

Goal. Write one numeric kernel — say batched matrix inversion — that runs on CPU and CUDA, ingests an ndarray-like input (NumPy / PyTorch / CuPy / JAX via DLPack), and returns an ndarray-like output. The inner matrix is small (C×C, C ∈ {2,3,4}); the input is (*batch, C, C) with an arbitrary number of batch axes, all only known at run time — (C,C), (B,C,C), (B0,B1,C,C), … all go through the same code.

The C×C math compiles to unrolled code (C is static) while the batch stays dynamic. The trick that makes the batch rank irrelevant is peel_front<-2>: keep the trailing two axes static, iterate everything in front as one runtime loopwithout flattening, so it works on any stride and never copies the input. Four steps.


1. The kernel core (shape-generic, folds when static)

Write the numerics once, on teeny views. Because C is a compile-time constant in the view's type, the loops unroll and the strides fold to immediates.

#include <teeny/teeny.h>
using namespace tny;

// Invert one static C×C matrix in place (Gauss–Jordan, no pivoting — assumes the
// matrix is non-singular). `A` and `out` are (C,C) views of ANY stride; `C` is a
// compile-time constant in their type, so every loop below unrolls.
template <class MatA, class MatOut>
_TNY_API void invert(const MatA & A, MatOut & out) {
    constexpr int C = (int) decltype(A.shape(Int<0>()))::value;   // static shape -> folds

    double m[C][C], inv[C][C];
    for (int i = 0; i < C; ++i)
        for (int j = 0; j < C; ++j) {
            m[i][j]   = A(i, j);
            inv[i][j] = (i == j) ? 1.0 : 0.0;              // inv starts as the identity
        }

    for (int col = 0; col < C; ++col) {
        const double pivot = 1.0 / m[col][col];
        for (int j = 0; j < C; ++j) { m[col][j] *= pivot; inv[col][j] *= pivot; }
        for (int row = 0; row < C; ++row) {
            if (row == col) continue;
            const double f = m[row][col];
            for (int j = 0; j < C; ++j) {
                m[row][j]   -= f * m[col][j];
                inv[row][j] -= f * inv[col][j];
            }
        }
    }

    for (int i = 0; i < C; ++i)
        for (int j = 0; j < C; ++j) out(i, j) = inv[i][j];
}

_TNY_API = __host__ __device__, so the same invert is callable from a CPU thread and from a CUDA kernel.


2. Peel one batch element

Given a rank-erased (*batch, C, C) carrier (in, built in §3), the i-th matrix is one peel_front_at<-2> away. peel_front<-2> means "keep the last two axes, peel everything in front" — so this is rank-agnostic: (C,C), (B,C,C), (B0,B1,C,C) all work unchanged, and the batch keeps its real (possibly strided) layout — nothing is flattened or copied.

// Invert the i-th batch element. `peel_front_at(i, shape<c,c>{})` folds the batch
// index `i` into the pointer AND recovers the compile-time C×C in ONE step: the shape
// tag says "keep the last 2 dims, and they're (c,c)". The value form (a tag, not a
// `<...>` arg) needs no `.template` on the dependent receiver, and there's no separate
// recast. (`c` is the static C from §4.)
template <long c, class In, class Out>
_TNY_API void invert_cell(const In & in, Out & out, long i) {
    auto Oi = out.peel_front_at(i, shape<c, c>{});                 // rank-2 (c,c) view, static extents
    invert(in.peel_front_at(i, shape<c, c>{}), Oi);               // A binds as const&
}

Random access vs. the incremental range-for

peel_front_at(i, …) re-derives the cell's pointer by decoding i over the batch dims — O(#batch dims) integer ops per call. That's the right tool for a grid-stride loop (i += nthreads), where consecutive i for one worker are a stride apart. When a worker instead sweeps a contiguous block, prefer the range-for for (auto cell : in.peel_front<-2>()), which is incremental — it advances the pointer and reuses the cell mapping (O(1) per cell, no re-decode) — and .subrange(lo, hi) for one worker's chunk. Next to a C×C inversion the decode is noise, but for a light per-cell body it's a 2–3× swing. See Efficient kernels §3.


3. The two drivers (CPU threads and CUDA), one line apart

size_front<-2>() is the batch count — the product of every axis but the last two — read straight from the shape, without building a peel range. Both drivers index a single flat [0, n) loop no matter how many batch axes the caller passed.

#include <thread>
#include <vector>

// CPU: split the batch across threads (grid-stride so any n / nthreads works).
template <long c, class In, class Out>
void invert_cpu(const In & in, Out & out) {
    const long n = in.template size_front<-2>();                       // product of all batch axes
    unsigned nthreads = std::max(1u, std::thread::hardware_concurrency());   // never 0
    std::vector<std::thread> pool;
    for (unsigned t = 0; t < nthreads; ++t)
        pool.emplace_back([&, t] {
            for (long i = t; i < n; i += nthreads) invert_cell<c>(in, out, i);
        });
    for (auto & th : pool) th.join();
}

// CUDA: one thread per matrix, grid-stride. The carriers pass BY VALUE into the
// kernel, so they must be copy_meta carriers (shape/stride inline — see §4).
#ifdef __CUDACC__
template <long c, class In, class Out>
__global__ void invert_kernel(In in, Out out) {
    const long n = in.template size_front<-2>();
    for (long i = blockIdx.x * blockDim.x + threadIdx.x; i < n;
             i += gridDim.x * blockDim.x)
        invert_cell<c>(in, out, i);
}
template <long c, class In, class Out>
void invert_cuda(const In & in, Out & out) {
    const long n = in.template size_front<-2>();
    if (n == 0) return;                                   // a 0-block launch is a CUDA error
    const int block = 256, grid = (int)((n + block - 1) / block);
    invert_kernel<c><<<grid, block>>>(in, out);
    // NB async on the default stream — synchronize before Python reads (see §4).
}
#endif

Random access vs. contiguous chunks

The grid-stride split above (i += nthreads) is right when each worker's cells are a stride apart — it needs the random-access peel_front_at(i), used here for both in and out at the same index. If a worker instead owns a contiguous block of the batch, a single-tensor sweep can use the incremental range-for and for (auto cell : t.peel_front<-2>().subrange(lo, hi)) — seed once, then O(1) per cell (a 2–3× swing for a light body; see Efficient kernels §3). For a two-input kernel like this one, either keep the shared index (as above) or advance one range incrementally alongside a running index for the other.

This tutorial deliberately applies the Efficient-kernels playbook: a static inner block via peel_front_at(i, shape<c,c>{}) (§2), the peel_front batch idiom (§3), snapshotting inputs through a local before writing the result (§7), and dispatch_value/dispatch_index at the boundary (§4). The C×C body nests its unit-stride axis innermost and its m/inv workspaces are fully overwritten (so an empty scratch would skip the zero-fill) — the §8 strided-loop rules.


4. The boundary: array metadata → teeny carrier → dispatch

At the boundary you get plain array metadata — a data pointer, a shape array, a strides array (in elements), a rank, and a device. Wrap it in an anyrank (a rank-erased carrier) and let teeny peel the batch; do not flatten. Two runtime facts become static exactly where they need to:

  • the rank disappears into peel_front<-2> (any number of batch axes);
  • the trailing C is turned into a compile-time c with dispatch_value.
// `in`/`out` are anyrank carriers over the SAME (*batch, C, C) layout — the batch
// keeps its real shape (no flatten, no contiguity assumption). The carrier's memory
// SPACE (host vs device) lives in its TYPE, so `if constexpr (In::is_device)` picks
// the driver — no runtime device flag threads through.
template <class In, class Out>
void invert_nd(const In & in, Out & out) {
    const int C = (int) in.size(in.ndim - 1);             // trailing C×C; runtime C

    dispatch_value<2, 3, 4>(C, [&](auto CC) {
        // `CC` is a cuda::std::integral_constant — `CC.value` is a constexpr, so it
        // can be a template argument / array bound. (You need the *value*, not `CC`
        // itself, precisely there: a compile-time shape<c,c> below.)
        constexpr long c = CC.value;
#ifdef __CUDACC__
        if constexpr (In::is_device) { invert_cuda<c>(in, out); return; }
#endif
        invert_cpu<c>(in, out);
    });
}

anyrank carries a compile-time memory space (see Dynamic dispatch): a host pointer peels into host views, a device pointer (as_anyrank<storage::gpu_view> / a kDLCUDA capsule via from_dlpack<T, storage::gpu_view>) peels into gpu_view views. That is why invert_nd can branch on In::is_device — the space is in the type, not a runtime flag.


5. Bind to Python

Any framework that speaks DLPack (np.from_dlpack, torch.from_dlpack, cupy.from_dlpack) can hand teeny a zero-copy array. Use nanobind, whose nb::ndarray<> speaks DLPack natively — an ndarray parameter accepts any numpy/torch/cupy/jax array (CPU or CUDA), and an ndarray return value is auto-exported via __dlpack__.

We take the input at any stride (no c_contig — the peel handles arbitrary strides, so nanobind never has to copy a non-contiguous batch) and write a dense output on the same device.

#include <nanobind/nanobind.h>
#include <nanobind/ndarray.h>
#include <cuda_runtime.h>          // cudaMalloc / cudaFree / cudaDeviceSynchronize
namespace nb = nanobind;

// (`invert_nd` is the boundary function from §4.)

nb::ndarray<> invert(nb::ndarray<double> x) {     // any (*batch, C, C) float64, any stride
    const int  ndim    = (int) x.ndim();
    const bool on_cuda = x.device_type() == nb::device::cuda::value;

    // Copy nanobind's metadata into int64 arrays (DLPack strides are in ELEMENTS).
    // The FULL (*batch, C, C) shape is kept — no flatten.
    std::vector<int64_t> shape(ndim), stride(ndim);
    for (int d = 0; d < ndim; ++d) {
        shape[d]  = (int64_t) x.shape(d);
        stride[d] = (int64_t) x.stride(d);
    }

    // A DENSE row-major output of the same shape.
    int64_t total = 1;
    for (int d = 0; d < ndim; ++d) total *= shape[d];
    std::vector<int64_t> ostride(ndim);
    { int64_t s = 1; for (int d = ndim - 1; d >= 0; --d) { ostride[d] = s; s *= shape[d]; } }

    double* out = nullptr;
    if (on_cuda) cudaMalloc((void**) &out, sizeof(double) * (size_t) total);
    else         out = new double[(size_t) total];
    nb::capsule owner = on_cuda
        ? nb::capsule(out, [](void* p) noexcept { cudaFree(p); })
        : nb::capsule(out, [](void* p) noexcept { delete[] (double*) p; });

    // Build the carriers and dispatch. A device carrier COPIES its shape/stride
    // inline (copy_meta) so it survives the trip into the kernel, and is tagged
    // storage::gpu_view so its peeled cells are device views; the host carrier WRAPS the
    // metadata (the vectors outlive the synchronous CPU call).
    if (on_cuda) {
#ifdef __CUDACC__
        auto in  = as_anyrank<TNY_MAX_RANK, storage::gpu_view>(x.data(), shape.data(),  stride.data(),  ndim, copy_meta);
        auto od  = as_anyrank<TNY_MAX_RANK, storage::gpu_view>(out,      shape.data(),  ostride.data(), ndim, copy_meta);
        invert_nd(in, od);
        cudaDeviceSynchronize();                  // finish before Python reads the result
#endif
    } else {
        auto in = as_anyrank(x.data(), shape.data(), stride.data(),  ndim);   // host wrap carrier
        auto od = as_anyrank(out,      shape.data(), ostride.data(), ndim);
        invert_nd(in, od);
    }

    std::vector<size_t> oshape(shape.begin(), shape.end());
    return nb::ndarray<>(out, (size_t) ndim, oshape.data(), owner, /*dense strides*/ nullptr,
                         nb::dtype<double>(),
                         on_cuda ? nb::device::cuda::value : nb::device::cpu::value);
}

NB_MODULE(fastinvert, m) { m.def("invert", &invert); }

nanobind parses the DLPack capsule for you, so you read x.data() / x.shape(i) / x.stride(i) / x.device_type() and hand them to as_anyrank. The return ndarray is auto-exported via __dlpack__, so np.from_dlpack / torch.from_dlpack pick it up zero-copy. With nanobind you never touch a DLManagedTensor — nor teeny's from_dlpack/to_dlpack (those in <teeny/dlpack.h> are for a raw-C boundary that isn't nanobind).

import numpy as np, torch, fastinvert
a = np.random.rand(1000, 3, 3) + 3*np.eye(3)      # 1 batch axis
inv = np.from_dlpack(fastinvert.invert(a))         # -> CPU kernel, shape (1000,3,3)
b = np.random.rand(4, 8, 3, 3) + 3*np.eye(3)      # 2 batch axes — SAME binding
binv = np.from_dlpack(fastinvert.invert(b))        # -> CPU kernel, shape (4,8,3,3)
bt = torch.as_strided(torch.rand(8, 3, 3), (4, 3, 3), (0, 3, 1))  # non-contiguous batch
btinv = np.from_dlpack(fastinvert.invert(bt))      # -> no copy: peel reads the real strides
g  = torch.rand(16, 100, 2, 2, device="cuda") + 3*torch.eye(2, device="cuda")
ginv = torch.from_dlpack(fastinvert.invert(g))     # -> CUDA kernel, zero-copy

That's the whole shape of a teeny binding: framework-agnostic memory in via DLPack → anyrank carrier (no flatten) → peel_front<-2> the batch + dispatch_value to a static inner shape → one _TNY_API kernel core → DLPack out. The numerics are written once, work for any number of batch axes at any stride, and run on CPU threads or CUDA with no shape/stride overhead.

See examples/batched_inverse.cpp in the repo for the runnable core (the CPU path plus the CUDA kernel), including the assembly proof that a static C×C access folds to constant-offset loads.