Tile Libraries#
A Tile kernel works on whole tiles and never names a thread. A SIMT function
works on one element from one thread. A tile library joins the two. It
packages Rust device functions so the Tile compiler can call them from inside
a cuTile kernel, once per element or once per warp, and link them into the
kernel’s cubin. The Rust side is #[tile_export], the packaging is
cargo oxide tilelib, and the caller is cuTile Python’s foreign_call today,
with cutile-rs to follow.
#[tile_export(tile_shape = 128)]
pub fn relu(x: f32) -> f32 { .. } your Rust function
|
| cargo oxide tilelib <crate> --arch sm_120
v
<crate>.ltoir device code void relu_impl(float x, float *relu_out)
<crate>.iface.tilebc declaration @relu(tile<128xf32> in, tile<128xf32> out) -> tile<128xf32>
<crate>.impl.tilebc implementation @relu_impl implements @relu, one invocation per element
|
| tileiras --preview-create-tile-lib (CUDA 13.5 or newer)
v
<crate>.tilelib
|
| tp.foreign_call(symbol_name="relu", ...) inside a cuTile kernel
v
the Tile compiler links relu_impl into the kernel's cubin
See also
Kernels and Device Functions
covers #[device] and the LTOIR route to CUDA C++. A tile library is the same
route with the Tile compiler as the caller.
The direct-linkage path. cuda-oxide ships the code (LTOIR) and two
descriptions (interface and implementation bytecode); tileiras packs them
into one .tilelib, and a Tile kernel calls the Rust function from inside
its body.#
Writing an export#
Put #[tile_export(tile_shape = N)] on an ordinary function. N is the tile size
the caller will pass. The function stays as written, so the rest of your
device code keeps calling it. The attribute adds two items next to it: an
extern "C" implementation on the native SIMT ABI, named <name>_impl by
default, and a host-only description that the compiler writes out as JSON.
Leave tile_shape out and the call supplies the tile size instead; such an
export needs the provider form of the library, described below.
The
tilelib_relu
example exports three functions, and this chapter follows them:
use cuda_device::{tile_export, warp};
/// Elementwise `max(x, 0)`.
#[tile_export(tile_shape = 128)]
pub fn relu(x: f32) -> f32 {
if x > 0.0 { x } else { 0.0 }
}
/// Scale a tile in place. The cuTile side knows it as `scale_in_place`.
#[tile_export(
tile_shape = 128,
name = "scale_in_place",
symbol = "scale_in_place_impl"
)]
pub fn scale(x: &mut f32, factor: f32) {
*x *= factor;
}
/// Sum over the 32 lanes of each warp; every lane gets the warp total.
#[tile_export(tile_shape = 128, sync = "warp")]
pub fn warp_sum(x: f32) -> f32 {
let mut total = x;
let mut offset = 16;
while offset > 0 {
total += warp::shuffle_xor_f32_sync(u32::MAX, total, offset);
offset /= 2;
}
total
}
What each one becomes in the device artifact:
cuTile name |
Rust |
Implementation the call resolves to |
|---|---|---|
|
|
|
|
|
|
|
|
|
warp_sum uses warp shuffles, which are legal because sync = "warp" makes
all 32 lanes of a warp call it together. The
Warp-Level Programming chapter explains the
shuffle pattern.
Attribute arguments#
Argument |
Meaning |
Default |
|---|---|---|
|
1-D tile size, an integer literal; without it the call supplies the size and only the provider form ships the export |
parametric |
|
declaration name the cuTile caller uses |
the function name |
|
implementation symbol in the device artifact |
|
|
threads that must call the function together |
|
|
compute capabilities the implementation supports |
every architecture |
arch is normally omitted, which matches every target. When given, names are
compared after stripping _ and lowercasing, so "sm_120" also matches the
sm_120a the Tile compiler builds for.
Parameters and the ABI#
Every parameter is a plain identifier with one of four types. T is one of
f16, f32, f64, i8, i16, i32, i64, u8, u16, u32, u64.
Tile IR has no unsigned integers, so a u32 parameter is an i32 tile on the
Tile side, same bits.
Rust parameter |
Access |
Footprint |
ABI parameter |
|---|---|---|---|
|
|
1 |
|
|
|
1 |
|
|
|
F |
|
|
|
1 |
|
|
|
F |
|
|
|
1 |
|
The footprint is the number of contiguous tile elements one invocation owns.
Only an in scalar travels by value. Everything else is a pointer to the
invocation’s own elements, and the implementation returns nothing: a return
value is stored through the trailing out pointer. On the Tile side every
parameter, the return slot included, is one argument of the declaration, typed
as a tile of the export’s size. Slices, raw pointers, tuples, structs, bool,
generics, self, and patterns in parameter position are rejected.
Rules the tooling enforces#
The attribute reports these at compile time:
tile_shape, when given, is an integer literal and positive. A constant or an expression is not accepted: a static library ships one concrete symbol per export. Without it the export is parametric and only the provider form of the library can ship it.The export has an output: a return value, or at least one
&mutparameter. A declaration’s results are itsoutandinoutarguments.The
outandinoutparameters form one contiguous run.fn f(a: &mut f32, b: f32) -> f32is[inout, in, out]and is rejected; writefn f(b: f32, a: &mut f32) -> f32, with theinparameters first.Every parameter of one export has the same footprint, because the fragments of one implementation share their invocation count. A
&mut [i32; 4]next to a&i32would need 32 invocations for one argument and 128 for the other, sofn window(acc: &mut [i32; 4], bias: &i32)is rejected andbias: &[i32; 4]is the accepted form.sync = "warp"needs a tile size that is a multiple of 32 times the footprint, so every invocation fills whole warps.nameandsymbolare ASCII identifiers outside the reservedcuda_oxide_prefix.
cargo oxide tilelib repeats the same checks when it reads the descriptions
back, so a hand-edited description cannot slip past them.
Building the library#
cargo oxide tilelib tilelib_relu --arch sm_120 --print
--arch is required because the LTOIR inside a library is built for one
architecture. In a standalone project, such as one made with cargo oxide new,
the crate name can be omitted. The command runs emit-ltoir, then writes the
description modules next to the device artifact:
tilelib_relu.ltoir device code for sm_120
tilelib_relu.tileexports.json one description per export, written by the compiler
tilelib_relu.iface.tilebc declarations, Tile IR bytecode at the assembler's version
tilelib_relu.impl.tilebc implementations, same version
tilelib_relu.tilelib.mlir readable form of both; --print shows it
tilelib_relu.tilelib the library, when tileiras can build it
This is the static form: the LTOIR and the descriptions are final when the
command ends, and every export in it names a tile_shape. An export without
one is left out, and the command says so:
Left out of the static library: `relu_any`, `warp_sum_any` (no tile_shape, so the call supplies the extent); `cargo oxide tilelib --provider` ships them
Before writing the modules it checks the emitted NVVM IR: every implementation
symbol must be a define void with the parameter list the ABI table predicts,
for example define void @relu_impl(float, ptr). The assembler never looks
inside the code payload, so a missing or misdeclared symbol would otherwise
surface only when a cuTile program is compiled against the library. The two
bytecode modules are built by the oxide-tilebc crate, cuda-oxide’s own
writer for these two kinds of module; the Tile stack’s crates are not
involved.
writes its own Tile IR with.
Reading the dump#
--print shows both modules in Tile IR syntax. The text is the same as
tilelib_relu.tilelib.mlir. For tilelib_relu:
// tilelib_relu.iface.tilebc: the interface a cuTile program calls
cuda_tile.module @tilelib_relu {
cuda_tile.preview$func extern @relu(%x: !cuda_tile.tile<128xf32> {cuda_tile.preview$access = #cuda_tile.preview$access<in>}, %relu_out: !cuda_tile.tile<128xf32> {cuda_tile.preview$access = #cuda_tile.preview$access<out>}) -> !cuda_tile.tile<128xf32>
cuda_tile.preview$func extern @scale_in_place(%x: !cuda_tile.tile<128xf32> {cuda_tile.preview$access = #cuda_tile.preview$access<inout>}, %factor: !cuda_tile.tile<128xf32> {cuda_tile.preview$access = #cuda_tile.preview$access<in>}) -> !cuda_tile.tile<128xf32>
cuda_tile.preview$func extern @warp_sum(%x: !cuda_tile.tile<128xf32> {cuda_tile.preview$access = #cuda_tile.preview$access<in>}, %warp_sum_out: !cuda_tile.tile<128xf32> {cuda_tile.preview$access = #cuda_tile.preview$access<out>}) -> !cuda_tile.tile<128xf32>
}
// tilelib_relu.impl.tilebc: the implementations the LTOIR defines
cuda_tile.module @tilelib_relu {
cuda_tile.preview$extern_impl @relu_impl implements @relu(%x: !cuda_tile.preview$fragment<1xf32, local, mem_layout = #cuda_tile.preview$layout<():()>, fragmap = #cuda_tile.preview$layout<(128, 1, 1):(1, 0, 0)>> {cuda_tile.preview$access = #cuda_tile.preview$access<in>}, %relu_out: !cuda_tile.preview$fragment<1xf32, local, mem_layout = #cuda_tile.preview$layout<(1):(1)>, fragmap = #cuda_tile.preview$layout<(128, 1, 1):(1, 0, 0)>> {cuda_tile.preview$access = #cuda_tile.preview$access<out>})
cuda_tile.preview$extern_impl @scale_in_place_impl implements @scale_in_place(%x: !cuda_tile.preview$fragment<1xf32, local, mem_layout = #cuda_tile.preview$layout<(1):(1)>, fragmap = #cuda_tile.preview$layout<(128, 1, 1):(1, 0, 0)>> {cuda_tile.preview$access = #cuda_tile.preview$access<inout>}, %factor: !cuda_tile.preview$fragment<1xf32, local, mem_layout = #cuda_tile.preview$layout<():()>, fragmap = #cuda_tile.preview$layout<(128, 1, 1):(1, 0, 0)>> {cuda_tile.preview$access = #cuda_tile.preview$access<in>})
cuda_tile.preview$extern_impl @warp_sum_impl implements @warp_sum(%x: !cuda_tile.preview$fragment<1xf32, local, mem_layout = #cuda_tile.preview$layout<():()>, fragmap = #cuda_tile.preview$layout<(4, 32, 1):(32, 1, 0)>> {cuda_tile.preview$access = #cuda_tile.preview$access<in>}, %warp_sum_out: !cuda_tile.preview$fragment<1xf32, local, mem_layout = #cuda_tile.preview$layout<(1):(1)>, fragmap = #cuda_tile.preview$layout<(4, 32, 1):(32, 1, 0)>> {cuda_tile.preview$access = #cuda_tile.preview$access<out>})
synchronization = #cuda_tile.preview$synchronization<warp>
}
How to read it:
The interface has one
preview$func externper export. Every Rust parameter is atile<128xf32>argument with its access, and the results are theoutandinoutarguments in order.relureturns one tile, itsrelu_outslot.The implementation has one
preview$extern_implper export, naming the symbol itimplements. Every argument is afragment: the piece of the tile one invocation sees.mem_layoutis the buffer’s own layout:():()for a by-value scalar,(1):(1)for one element behind a pointer.fragmapmaps(invocations, participants, footprint)onto the tile.(128, 1, 1):(1, 0, 0)is one invocation per element, 128 of them.(4, 32, 1):(32, 1, 0)is 4 invocations of 32 lanes each, which is whatsynchronization = warprequires.
The summary line names the artifacts and their sizes:
✓ Tile library descriptions written: 3 export(s), Tile IR 13.5 with the preview tag
The tileiras requirement#
The descriptions are Tile IR public-preview content, which exists from
bytecode 13.5 on. An assembler reads preview content only at the version it
calls current, the newest numbered version its --list-versions prints, so
the command asks the resolved tileiras first and writes the descriptions at
that version: 13.5 for the CUDA 13.5 assembler, the newer version a newer
assembler names, and 13.5 when no assembler can read them yet.
tileiras --preview-create-tile-lib arrives with CUDA 13.5, the first
assembler whose --list-versions names 13.5. When the resolved tileiras
lists 13.5 or newer, the command assembles the interface, the
implementations, and the LTOIR into tilelib_relu.tilelib (or the path given
with -o) and prints the tileiras invocation it used.
On CUDA 13.4 the command stops after the descriptions and says why:
Tile library skipped: /usr/local/cuda/bin/tileiras lists bytecode versions 13.1, 13.2, 13.3, 13.4; the descriptions need 13.5 or newer
The installed tileiras cannot create tile libraries: the descriptions are Tile IR public-preview content, which exists from bytecode 13.5 on and needs the CUDA 13.5 or newer assembler.
It then prints the tileiras --preview-create-tile-lib command that turns the
written files into the library on a newer toolkit, and exits 2 so a script
notices the missing library. --library-optional makes that outcome exit 0
when the LTOIR and the descriptions are all you need. Both outcomes print a
line a script can match: Tile library written to on success,
Tile library skipped: otherwise. The assembler is resolved from
CUTILE_TILEIRAS_PATH first, then from PATH, the configured CUDA toolkit
root, and the standard install locations. The
installation guide covers toolkit
selection.
The provider form#
The provider form. The library is a shared object the Tile compiler loads while a kernel compiles; it asks, per call site, which implementations exist, and once for their code, so one Rust body serves every tile size.#
A declaration can be parametric in its tile size: @relu_any<[N: index]>
takes tiles of N elements, and each call binds N. The Tile compiler then
asks the library for one implementation symbol per size a kernel uses
(relu_any_impl__N128, relu_any_impl__N256), because code is never generic
in Tile IR. A static library would have to ship every such symbol ahead of
time. The provider form answers while the kernel compiles instead:
cargo oxide tilelib tilelib_relu --arch sm_120 --provider
emit-ltoir ──> tilelib_relu.ll + tilelib_relu.tileexports.json
│
target/oxide-provider/tilelib_relu/ a generated cdylib crate:
oxide-tile-provider runtime + the two files embedded
│ cargo build --release
libtilelib_relu_provider.so
│ tileiras --preview-create-tile-lib (interface + .so)
tilelib_relu.tilelib
The library is now a shared object with the interface spliced in. When a
Tile kernel is compiled against it, the assembler loads it and asks it, call
by call, which implementations exist; the provider answers with the
description of each export, parametric where the export named no
tile_shape. Once the assembler has chosen the implementations it asks once
more for their code and names the symbol it wants for each size. The provider
adds one wrapper per name around the Rust body in the embedded NVVM IR,
compiles the module for the kernel’s architecture with the assembler’s own
libNVVM, and hands back the LTOIR. Exports that do name a tile_shape are
served the same way under their own symbol, so one provider library carries
both kinds.
Two things the provider form needs that the static form does not:
The Tile IR compiler’s linkage provider header, which the runtime’s
-syscrate binds at build time fromTILEIR_LINKAGE_PROVIDER_INCLUDE_DIR. The header is part of the Tile IR compiler’s source tree, not of a CUDA toolkit, and the command stops with that explanation when the variable is unset.The cuda-oxide workspace. The generated crate depends on the runtime by path; standalone projects cannot build the provider form yet.
A caller passes the size as the declaration’s constant parameter. In Tile C++
the foreign declaration is a function template whose extent parameter is
N, and every distinct relu_any<128>, relu_any<256> is one instantiation:
template <size_t N>
__experimental_foreign_decl__ __experimental_foreign_name__("relu_any")
ct::tile<float, ct::shape<N>> relu_any(ct::tile<float, ct::shape<N>> x,
[[cutile::experimental_inout]] ct::tile<float, ct::shape<N>> relu_any_out);
relu128.store(relu_any<128>(view<128>(ramp128).load(0), relu128.load(0)), 0);
relu256.store(relu_any<256>(view<256>(ramp256).load(0), relu256.load(0)), 0);
In cuTile Python the size goes in constant_params:
(out,) = tp.foreign_call(
tilelibs=(PROVIDER,),
symbol_name="relu_any",
constant_params=(TILE,),
inputs=(tile, result_slot),
output_types=((ct.float32, (TILE,)),),
)
The example’s cpp/tile_caller_parametric.cu calls relu_any at 128 and 256
elements and warp_sum_any at 64 from one kernel; OXIDE_TILE_PROVIDER_LOG=1
in the compiler’s environment prints every question the assembler asked and
the symbols the provider compiled.
Calling it from cuTile Python#
A cuTile kernel reaches the export through cuda.tile_preview.foreign_call.
This is the kernel from the example’s python/call_from_cutile.py:
import cuda.tile as ct
from cuda import tile_preview as tp
TILE = 128
PROVIDER = tp.Tilelib(path="tilelib_relu.tilelib", version="0.1.0")
def relu_tile(x, y):
"""One block runs `relu` over one 128-float tile of `x` into `y`."""
pid = ct.bid(0)
tile = ct.load(x, index=(pid,), shape=(TILE,))
# Every declaration argument needs an input tile, the `out` slot included.
result_slot = ct.zeros((TILE,), dtype=ct.float32)
(out,) = tp.foreign_call(
tilelibs=(PROVIDER,),
symbol_name="relu",
constant_params=(),
inputs=(tile, result_slot),
output_types=((ct.float32, (TILE,)),),
)
ct.store(y, index=(pid,), tile=out)
Three things to get right:
symbol_nameis the declaration name,relu, not the implementation symbolrelu_impl. The Tile compiler resolves the call to the implementation when it links the library.Every declaration argument takes a tile, the
outones included.reluhas two arguments, soinputshas two tiles; a zero tile stands in for the result slot. A scalar needs a 0-d tile, and a Python literal is rejected.output_typeslists the results, one(dtype, shape)peroutorinoutargument in declaration order.reluhas one.
foreign_call reads the library as a compile-time constant, so the Tilelib
is a module global rather than a kernel argument, and its file has to exist
when the kernel is traced. The script’s --dry-run compiles the kernel to
bytecode without tileiras, which works on CUDA 13.4 today:
bytecode: 844 bytes, header 0d050100 (version 13.5, preview flag set)
PASS: the caller side compiles; the program calls `relu` through preview$call (dry run, no cubin)
The full run needs the .tilelib from a CUDA 13.5 or newer tileiras and
a cuTile Python that lists that assembler’s current bytecode version, because
preview content is read only at that version.
Calling it from Tile C++#
The same toolkit that brings the assembler teaches nvcc --enable-tile and
--tile-preview simt, and a Tile C++ kernel calls the library through a
foreign declaration. The example’s cpp/tile_caller.cu declares all three
exports; this is the relu one:
namespace ct = cuda::tiles;
using tile_f32 = ct::tile<float, ct::shape<128>>;
// @relu(%x: tile<128xf32> in, %relu_out: tile<128xf32> out) -> tile<128xf32>
__experimental_foreign_decl__ __experimental_foreign_name__("relu")
tile_f32 relu(tile_f32 x, [[cutile::experimental_inout]] tile_f32 relu_out);
[[cutile::hint(0, num_worker_warps_per_cta = 4)]] __tile_global__ void kernel(float* x, float* y) {
auto xs = ct::partition_view { ct::tensor_span { x, ct::shape<128> {} }, ct::shape<128> {} };
auto ys = ct::partition_view { ct::tensor_span { y, ct::shape<128> {} }, ct::shape<128> {} };
ys.store(relu(xs.load(0), ys.load(0)), 0);
}
A parameter the library reads carries no attribute; one it writes carries
[[cutile::experimental_inout]] and comes back as the call’s result, in
argument order. The declaration’s name is the library’s declaration name,
not the implementation symbol. Compile and run:
nvcc --enable-tile --tile-preview simt -Xtileiras=-l=tilelib_relu.tilelib \
-std=c++20 -arch=sm_120 -o tile_caller cpp/tile_caller.cu
./tile_caller
PASS relu: 128 elements through the tile library
PASS scale_in_place: 128 elements through the tile library
PASS warp_sum: 128 elements through the tile library, every lane holding its warp's total
SUCCESS: 3/3 tile exports called from a Tile C++ kernel
Because nvcc, tileiras and the Tile C++ front end come from one toolkit, the program and the library always agree on the bytecode version. That is the shortest path to a running Tile kernel on top of a Rust library; the cuTile Python path needs a cuTile Python that lists the same assembler’s version.
What happens at the boundary#
The Tile compiler never hands your function a whole tile. It splits each tile
argument into fragments, one per invocation, and calls the implementation once
per fragment. With sync = "none" a fragment is one element, so a 128-element
tile means 128 calls, each from one thread. With sync = "warp" an invocation
is a whole warp: 32 lanes call together, each with its own element, and warp
intrinsics are legal inside. The calling thread is a real thread of the Tile
kernel, and threadIdx.x reads the hardware register, so lane and warp
identity work as in any SIMT kernel (see the
Execution Model).
The pointers you receive point at the invocation’s own buffer and nothing
else. &mut f32 is one element and &mut [f32; 4] is four contiguous
elements; the function may not reach past them, and nothing outside the
invocation aliases them during the call. The function cannot allocate shared
memory, so do not declare a SharedArray in an export: the shared memory of a
Tile kernel is the Tile compiler’s to place, and this release has no way to
request a scratch buffer from it (see
Shared Memory and Synchronization
for what shared memory means in a SIMT kernel). Keep exports to registers,
the parameters they receive, and warp traffic.
Not in this release#
Warpgroup and CTA scope.
syncaccepts"none"and"warp"only.Scratch memory. An export cannot ask for a shared-memory buffer.
bf16parameters. The attribute acceptsf16but notbf16yet.2-D tiles.
tile_shapehas one extent.The provider form outside the cuda-oxide workspace, and without the Tile IR compiler’s linkage provider header. One provider library also serves one architecture per compilation.
A cutile-rs caller. Only cuTile Python has
foreign_calltoday.
The Supported Features appendix tracks the interop status alongside the rest of the compiler.
Verifying the ABI on CUDA 13.4#
The .tilelib needs the CUDA 13.5 or newer assembler, but the ABI can be
proven today. The example’s
host program stands in for the Tile compiler:
cargo oxide run tilelib_relu --emit-nvvm-ir --arch sm_120
It reads tilelib_relu.tileexports.json, generates mock/caller.cu with the
prototypes the ABI table predicts and one kernel per export, compiles that with
nvcc to LTOIR, links it with the Rust LTOIR through nvJitLink, the same
two-input link a consumed library gets, and runs one block of 128 threads per
export on the GPU. Every result is compared bit for bit:
PASS relu: 128 elements through `relu_impl`, relu of a ramp from -4 to 4
PASS scale_in_place: 128 elements through `scale_in_place_impl`, the ramp scaled in place by 0.5
PASS warp_sum: 128 elements through `warp_sum_impl`, 32-lane sums over 0..128, every lane holding its warp's total
SUCCESS: 3/3 tile exports verified through the native ABI
The prototypes in mock/caller.cu are derived from the descriptions, not
written by hand, so the generated file is the ABI as the tooling understands
it. Without nvcc, or on a GPU that does not match --arch, the program
prints skipping: with the reason and exits 0.