Buffers and memory#
Parameters are annotated with T.Tensor; scratch buffers are created
in the body with one of two declaration APIs (below). Index a buffer with
A[i, j], slice it with A[m0:m0+BM, 0:BK] (a TensorRegion), and take a
pointer with A.ptr_to([i, j]) or the raw data pointer A.data.
Declaring buffers#
Two fundamental APIs create a tensor:
T.alloc_tensor(ty_args=[T.Tensor(shape, dtype, scope=..., layout=...)])allocates new storage and returns a tensor value withTensorType.T.alloc_shared/T.alloc_localare allocation shorthands.T.decl_tensor(data, ty_args=[T.Tensor(...)])declares a view over an existing typed pointer without allocating storage. UseT.cuda.decl_tmemwith a column address for tensor memory, as described below.
A buffer’s data property is an immutable, typed pointer Expr; it need not
be a Var. T.decl_tensor accepts a compatible pointer expression directly —
see Data types and expressions.
Both share one descriptor; the parameters that matter most:
Parameter |
Meaning |
|---|---|
|
element type — |
|
logical shape (a tuple of extents) |
|
physical mapping (TileLayout); |
pointer / TMEM column |
Offset a pointer with |
|
alignment of the data pointer, in bytes |
The scope argument selects the memory space:
Scope |
Shorthand |
Memory |
|---|---|---|
|
(default) |
device global memory |
|
|
static shared memory ( |
|
(pool) |
dynamic shared memory (pooled — see below) |
|
|
per-thread registers |
|
(TMEM pool) |
Blackwell tensor memory (see below) |
A = T.decl_tensor(A_ptr, ty_args=[T.Tensor((M, K), "float16", scope="global", align=16)]) # parameter buffer
As = T.alloc_shared((BM, BK), "float16") # new shared tile
acc = T.alloc_local((4,), "float32") # register accumulator
view = T.decl_tensor(As.data, ty_args=[T.Tensor((BM, BK), "float16", scope="shared", layout="default")]) # a view over As
A ptr-based buffer is just metadata over a pointer. For any non-tmem buffer, the declaration is a pointer plus a layout, and indexing resolves to an address:
addr(buffer[coord]) = buffer.data + elem_offset + layout.apply(*coord, shape=shape)["m"]
(layout.apply returns the per-axis mapping; its "m" component is the
element offset.) So the same logical access compiles to different address
arithmetic depending purely on the buffer’s metadata. Writing
B[i, j] = A[i, j] + 1 over a 4×8 region, with B declared four ways:
from tvm_tirx.tirx.layout import TileLayout, S
from tvm_tirx.backend.cuda.axis import laneid
B = T.decl_tensor(p, ty_args=[T.Tensor((4, 8), "float32", scope="global")]) # row-major
B = T.decl_tensor(p, ty_args=[T.Tensor((4, 8), "float32", scope="global", layout=TileLayout(S[(4, 8):(1, 4)]))]) # column-major
B = T.decl_tensor(T.ptr_byte_offset(p, (64) * 4, ty=T.Ptr("float32", "global")), ty_args=[T.Tensor((4, 8), "float32", scope="global")]) # shifted view
B = T.decl_tensor(p, ty_args=[T.Tensor((4, 8), "float32", scope="global", layout=TileLayout(S[(4, 8):(16, 1)]))]) # row stride 16
each makes B[i, j] lower to a different index in the generated CUDA (the
A[i, j] load stays i*8 + j — only B’s metadata changed):
B_ptr[((i * 8) + j)] = ...; // row-major: i*8 + j
B_ptr[((j * 4) + i)] = ...; // column-major: j*4 + i
B_ptr[(((i * 8) + j) + 64)] = ...; // elem_offset=64: i*8 + j + 64
B_ptr[((i * 16) + j)] = ...; // row stride 16: i*16 + j
Registers#
Per-thread scratch uses local scope. Allocate it with T.alloc_local(shape,
dtype) (i.e. scope="local"): it is private to each thread. Statically indexed
local arrays are normally scalarized into registers, while dynamically indexed
arrays or values under high register pressure may use local memory.
r = T.alloc_local((4,), "float32") # per-thread register array
for k in T.unroll(4):
r[k] = A[tx, k]
# ... compute on r[0..3] ...
alignas(64) float r_ptr[4]; // per-thread, register-resident
r_ptr[0] = A_ptr[tx * 4 + 0];
r_ptr[1] = A_ptr[tx * 4 + 1];
// ...
Note
The alignas(64) is the default buffer alignment — a buffer’s
data_alignment defaults to runtime::kAllocAlignment (64 bytes), and the
CUDA codegen stamps it onto every allocation, including per-thread local
arrays where it is meaningless. For these register-resident arrays it has no
performance impact: a thread-local array with statically-resolvable indices is
promoted to registers by nvcc/ptxas (scalar replacement of aggregates, SROA), so
it never lives in addressable local memory and the alignment is a no-op. (A
dynamically-indexed array that spilled to local memory would actually pick up the
over-alignment, but that is the unusual case.) This over-alignment of register
locals is a known rough edge we plan to fix (use the dtype’s natural alignment
for local scope).
Scalar#
A scalar is just a register array with one element — strictly, you don’t need a
separate concept. You can allocate a size-1 local buffer and index [0]:
phase = T.alloc_local((1,), "int32") # 1-element register array
phase[0] = 0
while phase[0] < 4:
acc = acc + A[tx, phase[0]]
phase[0] += 1
But writing phase[0] everywhere is clumsy, so a scalar is sugar for exactly
this — a one-element register buffer you read and write by name:
phase: T.int32 = 0 # mutable scalar (sugar for the above)
while phase < 4:
acc = acc + A[tx, phase]
phase += 1
s = T.local_scalar("int32") # explicit form; assign by name (s = ..., not s[0])
acc: T.float32 = 0.0 # a type-annotated assignment also makes one
The two are not just similar — they parse to structurally identical TIRx. The
sugar is resolved entirely in the parser: phase: T.int32 is that one-element
local buffer, and phase / phase += 1 are phase[0] /
phase[0] += 1. tvm_tirx.ir.assert_structural_equal on the two kernels passes.
The scalar accesses the same one-element local tensor while letting you drop the
[0]. (T.local_scalar / T.shared_scalar / T.alloc_scalar choose the
scope explicitly.)
Note
Why not a scalar-typed Var? Both forms have a Var identity in
typed TIRx. A buffer value is a Var with TensorType; stores mutate the
buffer’s contents without rebinding that Var. By contrast, T.let below
creates a scalar-typed Var with one immutable binding. A mutable scalar must
therefore be backed by a one-element buffer whose contents can be stored
repeatedly.
let#
A T.let binding is immutable — a single Bind node (a named value, not a
buffer). Use it for derived constants:
n: T.let = M * K # immutable binding (Bind)
half: T.let[T.int32] = N // 2 # ... with an explicit type
It lowers to a plain scalar C variable — not a buffer (no array, no [0]).
For half: T.let = m * 2 (with a runtime m):
int half = m * 2; // the `let` -> a const-like local
Because the value is immutable, the simplifier is free to propagate and CSE it, so
at the use sites you often see m * 2 substituted directly (or shared through a
common-subexpression temporary) rather than a reference to half.
Note
Why have an immutable binding at all? Because the value cannot change, the
arithmetic analyzer binds the var to it (arith::Analyzer::Bind internally when it
simplifies a Bind), so facts proven about the value — constant bounds, the
modular set (divisibility / alignment), ranges — propagate through every use.
That feeds index simplification, bounds-check elimination, and
alignment/vectorization decisions. A mutable scalar is a memory load
(buf[0]): the analyzer cannot assume it stays constant, so none of those
properties carry through. A let is also a pure value — no allocation, and
free to inline / substitute / CSE — whereas a scalar is a one-element buffer with
load/store semantics.
Tensor memory#
Blackwell tensor memory is not a plain scratch scope: it must be explicitly
reserved and freed with the warp-uniform tcgen05.alloc /
tcgen05.dealloc instructions. Declare each tensor view with
T.cuda.decl_tmem(column, ty_args=[T.Tensor(..., scope="tmem", layout=...)]).
The column address is mandatory; T.alloc_tensor alone does not reserve TMEM
and will not work. Unlike shared memory, tensor memory is not directly addressable: it is read
and written only through tcgen05 mma / ld / st / cp.
By hand, one warp issues the allocation into a shared slot, you decl each
tensor as a view at a column offset, and one warp frees it at the end:
addr = T.alloc_shared((1,), "uint32")
if warp_id == alloc_warp:
T.ptx[f"tcgen05.alloc.cta_group::{cta_group}.sync.aligned.shared::cta.b32"](
addr.ptr_to([0]), 512)
T.cuda.cta_sync()
acc = T.cuda.decl_tmem(addr[0], ty_args=[T.Tensor(
(CTA_M, 512), "float32", scope="tmem", layout=tmem_layout)])
# ... issue and wait for all MMA / TMEM transfers ...
T.cuda.cta_sync()
if warp_id == alloc_warp:
T.ptx[f"tcgen05.relinquish_alloc_permit.cta_group::{cta_group}.sync.aligned"]()
T.ptx[f"tcgen05.dealloc.cta_group::{cta_group}.sync.aligned.b32"](addr[0], 512)
You manage the column offsets and the tmem_layout (a datapath D/F layout)
yourself; views below share the same allocation.
Views#
Partition an existing TMEM allocation into views without allocating again:
acc0 = acc.sub[:, :128]
acc1 = acc.sub[:, 128:256]
See the Part III GEMM kernels for full examples.
Buffer APIs#
A tensor value carries a pointer and a TensorType access contract, so most of
its methods are compile-time reshapes/reinterprets that change index arithmetic
or hand you a pointer — they emit no runtime op of their own. The common ones:
Method |
What it is |
|---|---|
|
the typed base-pointer expression; prints as |
|
a typed pointer to an element ( |
vector-typed |
a vectorized load / store; prints as |
|
reinterpret the same storage under a new shape/layout (no copy) |
|
the calling thread’s private register slice of a |
|
a view with axes permuted (a transposed layout) |
Pointers — ``ptr_to`` / ``data``. ptr_to is how you hand an element address
to an intrinsic or inline function; data is the base pointer:
B[tx] = T.cuda.func_call("ld", A.ptr_to([tx]), SRC, ty="float32")
B_ptr[tx] = ld(&A_ptr[tx]); // ptr_to([tx]) -> &A_ptr[tx]; A.data -> A_ptr
Vectorized access — typed vector views. Move several elements as one wide transfer (see also Data types and expressions):
A4 = T.decl_tensor(A.data, ty_args=[T.Tensor((N // 4,), "float32x4")])
B4 = T.decl_tensor(B.data, ty_args=[T.Tensor((N // 4,), "float32x4")])
B4[tx] = A4[tx]
*(float4*)(B_ptr + tx * 4) = *(float4*)(A_ptr + tx * 4);
Reshape / reinterpret — ``view`` / ``permute``. Both are pure metadata; the
data pointer is unchanged, only the index arithmetic differs. A.view(64, 4)
sees the 256-element buffer as 64×4; A2.permute(1, 0) transposes those axes:
A2 = A.view(64, 4); y = A2[tx, 0] + A2[tx, 3] # A2[tx, j] -> A_ptr[tx*4 + j]
At = A2.permute(1, 0); z = At[i, j] # At[i, j] -> A_ptr[j*4 + i]
A2_ptr[tx * 4] /* +3 */ // view: row-major 64x4 index
At_ptr[(j * 4) + i] // permute: swapped strides
Registers — explicit local views. A thread-axis layout distributes logical elements across the threads. Declare a dense view of the current thread’s eight physical registers, then reshape that view:
R = T.alloc_tensor(ty_args=[T.Tensor((32, 8), "float32", scope="local", layout=TileLayout(S[(32, 8) : (1 @ laneid, 1)]))])
R_flat = T.decl_tensor(R.data, ty_args=[T.Tensor((8,), "float32", scope="local", layout="default")])
R_2d = R_flat.view(2, 4)
alignas(64) float R_flat_ptr[8]; // the lane's private registers