Parameter buffers are bound with T.match_buffer; 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 BufferRegion), and take a
pointer with A.ptr_to([i, j]) or the raw data pointer A.data.
Declaring buffers
Two fundamental APIs create a buffer:
T.alloc_buffer(shape, dtype, scope=..., ...) — allocates new storage
(emits an AllocBuffer node) and returns the Buffer. T.alloc_shared /
T.alloc_local are just alloc_buffer with scope="shared" /
scope="local".
T.decl_buffer(shape, dtype, data=..., ...) — declares a view over an
existing pointer data (no allocation); use it to alias or reinterpret
storage — a sub-region of a pool, or a tensor-memory address. With data=None
it allocates, like alloc_buffer.
A buffer’s data pointer is an immutable Var (alloc_buffer defines it;
decl_buffer takes one). To back a buffer with a pointer expression, bind it
first — see Data types and expressions.
Both share one descriptor; the parameters that matter most:
Parameter
Meaning
dtype
element type — "float32", "float16", "float4_e2m1fn", …
elem_offset (or byte_offset) places a view at an offset into data; allocated_addr carries a pre-assigned address (tensor memory)
align
alignment of the data pointer, in bytes
The scope argument selects the memory space:
Scope
Shorthand
Memory
"global"
(default)
device global memory
"shared"
T.alloc_shared
static shared memory (__shared__)
"shared.dyn"
(pool)
dynamic shared memory (pooled — see below)
"local"
T.alloc_local
per-thread registers
"tmem"
(TMEM pool)
Blackwell tensor memory (see below)
A = T.match_buffer(A_ptr, (M, K), "float16", align=16) # parameter bufferAs = T.alloc_shared((BM, BK), "float16") # new shared tileacc = T.alloc_local((4,), "float32") # register accumulatorview = T.decl_buffer((BM, BK), "float16", data=As.data) # 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::
(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:
Shared memory comes in two flavors — static (fixed at compile time) and
dynamic (sized at launch) — plus a pool helper that manages the dynamic case.
Static
The simplest shared buffer is a static one — T.alloc_shared (that is,
scope="shared"), sized at compile time. Stage data into it, cta_sync so the
whole block sees the writes, then read it back:
Dynamic shared memory (scope="shared.dyn") is sized per launch (the
sharedMemBytes launch parameter), not at compile time. A kernel may have only
one dynamic-shared allocation — the arena. So you allocate it once and decl
each buffer as a view into it: T.decl_buffer with data= the arena pointer
and an elem_offset:
Both views share the single extern __shared__ arena (generated CUDA,
boilerplate elided; arena named smem for clarity):
extern __shared__ __align__(64) float smem[]; // the one dynamic-shared arenasmem[tx] = A_ptr[tx]; // As — view at offset 0smem[tx + 64] = B_ptr[tx]; // Bs — view at offset 64__syncthreads();C_ptr[tx] = smem[tx] + smem[tx + 64];
(Two separate alloc_buffer(scope="shared.dyn") calls are an error — only one
dynamic shared memory allocation is allowed.) So static shared memory is sized at compile
time (__shared__ T x[N];); dynamic shared memory is this one launch-sized arena
with views decl’d at offsets inside it.
Note
How TVM annotates the dynamic-shared size. The arena’s size is known at
compile time (here 128 floats = 512 bytes). During lowering TVM appends
a "tirx.use_dyn_shared_memory" tag to the device kernel’s
tirx.kernel_launch_params, and the host launcher computes the total bytes and
passes them as the last launch argument:
At run time that 512 becomes config.sharedMemBytes in the
cuLaunchKernelEx call. You never set it by hand — it is derived from the
shared.dyn allocation’s size.
Pool sugar
T.SMEMPool automates that arena bookkeeping — it bump-allocates the offsets so
you don’t decl views by hand. Beyond alloc / commit, it offers
per-buffer align=, an alloc_mma helper that builds an MMA-compatible
swizzle layout for you, and move_base_to to rewind the cursor and reuse space:
pool = T.SMEMPool() # bump allocator over shared.dynAs = pool.alloc((BM, BK), "float16", align=128) # carve a tileBs = pool.alloc((BK, BN), "float16", align=128)Cs = pool.alloc_mma((BM, BN), "float16") # MMA-compatible, swizzle inferredpool.commit() # finalize the pool's size# pool.move_base_to(offset) rewinds the cursor to reuse space
The TMEM pool (Tensor memory, below) is layered on top of an SMEMPool.
Registers
Per-thread scratch lives in registers. Allocate it with T.alloc_local(shape, dtype) (i.e. scope="local"): it is private to each thread and lowers to a
local array kept in registers.
r = T.alloc_local((4,), "float32") # per-thread register arrayfor k in T.unroll(4): r[k] = A[tx, k]# ... compute on r[0..3] ...
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]:
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 += 1s = 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.int32is that one-element
local buffer, and phase / phase += 1arephase[0] /
phase[0] += 1. tvm.ir.assert_structural_equal on the two kernels passes, and
the printer even renders the explicit alloc_local + [0] form back as the
scalar form — so once parsing is done there is no difference at all. Both therefore
lower to the same alignas(64) int phase_ptr[1];; the scalar just lets you drop
the [0]. (T.local_scalar / T.shared_scalar / T.alloc_scalar choose
the scope explicitly.)
Note
Why not aVar? A TIRx Var is immutable — a single static
binding (it is exactly what T.let produces, below). A scalar needs to be
mutable — you reassign it in loops and accumulators — so it must be backed by a
one-element buffer you can store into repeatedly, not a Var.
let
A T.let binding is immutable — a single LetStmt (a named value, not a
buffer). Use it for derived constants:
n: T.let = M * K # immutable binding (LetStmt)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 (analyzer.Bind(var, value) when it
simplifies a LetStmt), 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 T.ptx.tcgen05.alloc /
tcgen05.dealloc intrinsics, and each tensor is a view into it declared with
T.decl_buffer(..., scope="tmem", allocated_addr=<column>, layout=<tmem layout>).
The allocated_addr (a column offset) is mandatory — the tensor-core dispatch
asserts it — so T.alloc_buffer(scope="tmem") (which does not set it) will not
work. Unlike shared memory, tensor memory is not directly addressable: it is read
and written only through tcgen05mma / 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") # slot for the allocated baseif warp_id == alloc_warp: # tcgen05.alloc is warp-uniform T.ptx.tcgen05.alloc(T.address_of(addr), n_cols=512, cta_group=cta_group)acc = T.decl_buffer((CTA_M, 512), "float32", scope="tmem", allocated_addr=0, layout=tmem_layout) # view at column 0# ... use acc as a gemm_async / copy_async operand ...if warp_id == alloc_warp: T.ptx.tcgen05.relinquish_alloc_permit(cta_group=cta_group) T.ptx.tcgen05.dealloc(addr, n_cols=512, cta_group=cta_group)
You manage the column offsets and the tmem_layout (a datapath D/F layout)
yourself. This is exactly the sequence the pool below emits.
Pool
T.TMEMPool wraps all of that — the warp-uniform alloc/dealloc, the column
bump-allocation, and the datapath layout:
tmem_addr = pool.alloc((1,), "uint32") # pool = the kernel's smem pooltmem_pool = T.TMEMPool(pool, total_cols=512, cta_group=cta_group, tmem_addr=tmem_addr)acc = tmem_pool.alloc((CTA_M, 512), "float32") # allocated_addr set for youtmem_pool.commit() # emits tcgen05.alloc (one warp)# ... use acc ...tmem_pool.dealloc() # emits tcgen05.dealloc (one warp)
See the Part III GEMM kernels for full examples.
Buffer APIs
A Buffer is metadata over a pointer (see Declaring buffers above), 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
B.data
the raw data pointer (a Var); prints as B_ptr
B.ptr_to([i, j])
a typed pointer to an element (address_of); prints as &B_ptr[…]
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; A.permute(1, 0) transposes the axes: