Tiles, without a tile type
TL;DR — Tile-based GPU programming structures kernels around sub-arrays of a tensor. Vx has no built-in tile type, yet nested loops over aTensor<T, [..], Memory::SMEM>compile to PTX: 8 bytes of.sharedstorage,bar.syncinstructions, and grid-strided execution over%ctaid.xand%tid.x. Tracking memory space in types turns shared-memory overcommit into a compile-time error. What Vx still lacks is a dedicated tile abstraction: there are no tile arithmetic operations, no tensor-core instructions, only one grid dimension, and loop bounds must be static literals.
A developer recently asked if Vx could support tile-based GPU programming like NVIDIA's cuTile. To find out, I wrote a shared-memory staging kernel in Vx and examined the compiler's PTX output.
Tiles versus threads
In standard CUDA programming, you write code from the perspective of an individual thread. The programmer coordinates the entire cooperative thread group: computing array offsets with threadIdx and blockIdx, staging data into __shared__ memory, synchronizing threads with __syncthreads(), and loading elements back out.
Tile-based programming frameworks like Triton or cuTile raise this level of abstraction. You express operations directly on multi-dimensional chunks of data called tiles. The compiler then handles the thread layout and schedules transfers between global DRAM and fast on-chip SRAM.
Working at the tile level eliminates common concurrency bugs. In CUDA, an off-by-one indexing error during staging silently corrupts numbers without crashing. A missing __syncthreads() creates a subtle data race that might only appear once in ten runs.
Vx takes a different angle by placing memory hierarchies directly into the type system. Every tensor type tracks which physical memory space holds it and which processor can access it. Can this same placement system compile a working tile-style kernel?
The staging kernel
To test this question, I wrote a minimal staging kernel. It copies slices of an array from GPU global memory (HBM) into shared memory (SMEM), synchronizes, and writes the results back to global memory. The arithmetic is intentionally simple so the memory patterns and loop structures remain easy to follow:
Memory CPU_DRAM {}
Memory GPU_HBM {
within: Memory::CPU_DRAM, capacity: 40 GiB, bandwidth: 3 TB/s, managed: cached
}
Memory SMEM {
within: Memory::GPU_HBM, capacity: 228 KiB, bandwidth: 128 B/cyc,
granule: 1 KiB, managed: explicit, scope: sm
}
Topology Dev {
arch: nvptx64,
memory: Memory::GPU_HBM,
visible: [Memory::GPU_HBM, Memory::SMEM],
transfer Memory::CPU_DRAM -> Memory::GPU_HBM : 63 GB/s,
transfer Memory::GPU_HBM -> Memory::SMEM
}
fn main() -> i32 {
let mut q_h = Tensor<f32, [8]>::uninit();
let mut o_h = Tensor<f32, [8]>::uninit();
for i in 0..8 {
q_h[i] = (i as f32);
o_h[i] = 0.0;
}
let q = transfer(q_h, Memory::GPU_HBM);
let mut o = transfer(o_h, Memory::GPU_HBM);
spawn on(Topology::Dev) {
// The tile. Its memory space is part of its type.
let mut qs = Tensor<f32, [2], Memory::SMEM>::uninit();
for bq in 0..4 { // one iteration per tile
for qi in 0..2 { // cooperative fill
qs[qi] = q[bq * 2 + qi] * 2.0;
}
barrier();
for qi in 0..2 { // cooperative consume
o[bq * 2 + qi] = qs[qi] + 1.0;
}
barrier();
}
}
print(o);
return 0;
}
Inside spawn on(Topology::Dev), the source omits traditional CUDA boilerplate. It contains no blockIdx, no threadIdx, no launch configuration parameters, no __shared__ annotations, and no __syncthreads() calls. Instead, the kernel defines four elements:
- A tile buffer
qstyped explicitly inMemory::SMEM. - An outer loop over tile batches (
bqfrom 0 to 4). - Inner loops (
qifrom 0 to 2) that cooperatively fill and consume the tile. - Two
barrier()calls to coordinate thread synchronization.
How the compiler maps loops to PTX
To compile this kernel, the compiler must determine which loop runs over thread blocks and which loop runs over individual threads. It does this through a check called parallel_two_level. To guarantee that threads never race or leave unwritten gaps, the analysis verifies every index expression that writes to a captured array.
A captured write index must follow one of two patterns:
- The thread loop variable itself (
qi). - The affine expression
bq * K + t, whereKequals the exact trip count of that thread loop.
The exact multiplier is critical. If t spans 0..4, an expression like bq * 8 + t leaves unwritten gaps in the output tensor. If t spans 0..16, different iterations overlap and overwrite the same indices. The multiplier bq * 2 + qi is exact, ensuring every (block, thread) coordinate owns one distinct element.
The staging kernel satisfies this proof, emitting the following MLIR attributes:
{arch = "nvptx64", vx_parallel_threads = 2 : i64, vx_parallel_trip = 4 : i64, vx_parallel_two_level}
The compiler derives the launch geometry directly from the loop bounds: 4 blocks of 2 threads. It passes launch=4,2 to the runtime dispatch payload and lowers the loop body to PTX:
.visible .entry vx_npu_kernel_0(
.shared .align 16 .b8 vx_npu_kernel_0_smem_0[8];
mov.u32 %r11, %ctaid.x;
mov.u32 %r1, %tid.x;
mov.u32 %r2, %ntid.x;
mov.u32 %r3, %nctaid.x;
...
ld.global.b32 %r6, [%rd12]; // read one element out of HBM
st.shared.b32 [%rd13], %r7; // write it into the tile
bar.sync 0;
ld.shared.b32 %r8, [%rd14]; // read it back out of the tile
st.global.b32 [%rd15], %r9; // write the result to HBM
bar.sync 0;
(An excerpt, with the address arithmetic between these lines cut. The comments are mine.)
The generated PTX has the ingredients of a hand-written staging kernel:
.shared .align 16 .b8 vx_npu_kernel_0_smem_0[8];reserves 8 bytes of shared memory, matching the two 4-byte floats declared inTensor<f32, [2], Memory::SMEM>.- Special registers
%ctaid.xand%tid.xsupply the block and thread IDs. ld.global.b32andst.shared.b32stage data into shared memory.bar.sync 0coordinates threads across stages.ld.shared.b32andst.global.b32read from shared memory and store the final results back to global memory.
The compiled kernel also employs grid-strided loops: the outer block loop steps by %nctaid.x, and the inner thread loop steps by %ntid.x. Grid-striding allows the kernel to execute correctly across any hardware launch configuration. The launch=4,2 payload is a sizing hint: it is the shape that leaves no thread idle and no iteration doubled, and any other shape still computes the same answer.
At a 1×1 launch (one block of one thread), the parallel loops reduce directly to the original serial loop nest. That degeneracy is why the same source also runs on a CPU host with no GPU in it, producing the verified result:
$ vxc tile_kernel.vx
[1, 3, 5, 7, 9, 11, 13, 15]
That output came from the host, not from a GPU. The degeneracy is the reason it is the right number: one launch shape is the serial program, so the host path and the device path stay the same program instead of two implementations that have to be kept in agreement.
There is one case where this equivalence breaks down: placing a barrier() inside a thread loop. No serial execution order can satisfy a barrier within a single thread's loop iterations. In that scenario, the compiler marks the payload with coop=1, instructing the runtime to reject host fallback and avoid returning corrupted data.
Two compile-time checks CUDA lacks
Writing this kernel triggered two compiler errors that have no direct equivalents in traditional GPU programming.
The first error occurred when I initially attempted to copy a row of the HBM tensor directly into the shared-memory tile using a standard assignment (=). That draft used two-dimensional tensors, which is why the rows below are [4] wide and the kernel above uses a [2] tile:
Error[E3004]: Type mismatch in assignment: cannot assign Tensor<f32, [4], GPU, GpuHbm>
to Tensor<f32, [4], Custom("SMEM"), Custom("SMEM")>
In CUDA C++, pointers do not track memory spaces at the type level. An assignment that crosses address spaces compiles without complaint, and the mistake surfaces when the program runs. In Vx, memory space is an explicit type parameter. An HBM tensor and an SMEM tile are incompatible types. Data movement across spaces requires an explicit transfer() call, which succeeds only along routes permitted by the machine topology. (The compiler renders internal space names like Custom("SMEM") in this message, which is a known rough edge.)
The second error prevents shared-memory overflow. Shared memory is a strict per-SM budget, and exceeding it is ordinarily something the device toolchain or the launch tells you, on a machine that has the part in it. In Vx the topology declares the capacity of Memory::SMEM and the tiles are statically sized, so the compiler adds up the tiles live at once before it emits any code:
Error[E6010]: the working set placed in memory space 'SMEM' (3 tiles) sums to 147456 bytes,
over its 131072 byte capacity; place fewer/smaller tiles or declare it `overcommit`
This message comes from tests/frontend/fail/three_dtcm_tiles_do_not_fit_a_cortex_m7.vx. That test targets the tightly-coupled memory of an ARM Cortex-M7 microcontroller, not a GPU. The check keys on the declared capacity of a space rather than on what kind of part holds it, which is the point: it runs on a laptop, with no device toolchain involved.
What is still missing
The kernel above demonstrates that Vx can compile the physical mechanics of a shared-memory staging pipeline. However, Vx does not yet provide a complete tile programming model. Five major features remain unimplemented:
- No tile type and no tile operators. Vx has no dedicated tile primitives. There is no tile-level matrix multiply, reduction, or broadcast. A tile is simply a
Tensor<T, [..], Memory::SMEM>, and you manipulate its contents by writing scalar loops element by element. (The custom-transfer design document usedTile<f32>in an example snippet, but no such type exists in the language; test fixtures use tensors.) - No tensor core instructions. The compiler never emits PTX
mmaorwmmainstructions. Matrix operations reach tensor cores only through vendor libraries: when an entire placed region consists of alinalg.matmul, the runtime dispatches it directly to cuBLAS. General user-written kernels receive scalar PTX instructions. - A single grid dimension. The loop lowering pass only generates block and thread coordinates along
Dimension::x(%ctaid.xand%tid.x). Multi-dimensional tile grids must be linearized into 1D indexing in source code. - Static shapes only. The affine loop prover requires literal integer bounds. The number of tiles and the dimensions of each tile must be known constants at compile time. Loops with dynamic runtime bounds fail verification and fall back to serial lowering.
- No hardware scheduling or pipelining. Block dimensions match the thread loop trip count, and grid dimensions match the block loop trip count. The compiler offers no autotuning, software pipelining, or double buffering. It does not generate asynchronous copy instructions (such as
cp.asyncor Hopper TMA) inside kernel bodies; asynchronous copies exist only during transfers managed by a declared hardwarecopy_engine.
Where this leaves us
Vx can already lower a shared-memory staging pattern to a functional GPU device image. It does so by using static analysis to prove properties of nested loops. The developer still writes explicit element loops, and hardware tensor instructions remain unavailable.
Tile frameworks and Vx address complementary concerns:
- Tile models treat a tile as a first-class mathematical value, eliminating manual element indexing.
- Vx encodes physical memory placement into types, verifying data transfers and memory capacity at compile time.
These two concepts fit together cleanly. Building tile operations on top of typed memory spaces would combine their strengths: developers could write concise matrix operations while the type system guarantees memory safety across hardware hierarchies. The existing loop lowering and memory tracking provide a solid foundation for that abstraction.
The two-level block/thread mapping is
Vx#379 and general device-kernel emission is
Vx#251. The kernel above is the shape of
tests/backend/pass/custom_topology_device_image.vx; the shared-memory budget check is
tests/frontend/fail/three_dtcm_tiles_do_not_fit_a_cortex_m7.vx. Vx is Apache 2.0 with
the LLVM exception — install it, or read
What a pointer forgets for why the memory space is in
the type in the first place.