teenygrad kernels / Real Patterns
Matrix Multiplication
Matrix multiply is the operation most machine learning time is spent in, and the first one in this book where arithmetic — not memory — is the cost.
It is also where the GPU’s specialised hardware comes in, and where the shape of your kernel starts to matter enormously.
The naive version
The library ships a straightforward implementation. One program computes one element of the output:
#[kernel]
pub fn matmul_forward<
T: Triton,
D: Num,
const BLOCK_M: i32,
const BLOCK_N: i32,
const BLOCK_K: i32,
const GROUP_M: i32,
>(
a_ptr: T::Pointer<D>,
b_ptr: T::Pointer<D>,
c_ptr: T::Pointer<D>,
M: i32,
N: i32,
K: i32,
) {
let pid = T::program_id(Axis::X);
let num_pid_m = T::cdiv(M, BLOCK_M);
let num_pid_n = T::cdiv(N, BLOCK_N);
let num_pid_in_group = GROUP_M * num_pid_n;
let group_id = pid / num_pid_in_group;
let first_pid_m = group_id * GROUP_M;
let remaining_m = num_pid_m - first_pid_m;
let group_size_m = if remaining_m < GROUP_M {
remaining_m
} else {
GROUP_M
};
let pid_in_group = pid % num_pid_in_group;
let pid_m = first_pid_m + (pid_in_group % group_size_m);
let pid_n = pid_in_group / group_size_m;
let a_desc = T::make_tensor_descriptor(
a_ptr,
&[M, K],
&[K, 1],
&[BLOCK_M, BLOCK_K],
Some(PaddingOption::Zero),
);
let b_desc = T::make_tensor_descriptor(
b_ptr,
&[K, N],
&[N, 1],
&[BLOCK_K, BLOCK_N],
Some(PaddingOption::Zero),
);
let mut acc = T::zeros::<D>(&[BLOCK_M, BLOCK_N]);
let k_tiles = T::cdiv(K, BLOCK_K);
for k in 0..k_tiles {
let a = T::load_tensor_descriptor(a_desc, &[pid_m * BLOCK_M, k * BLOCK_K]);
let b = T::load_tensor_descriptor(b_desc, &[k * BLOCK_K, pid_n * BLOCK_N]);
acc = T::dot::<D, D>(a, b, Some(acc), InputPrecision::TF32, None);
}
let c_desc = T::make_tensor_descriptor(
c_ptr,
&[M, N],
&[N, 1],
&[BLOCK_M, BLOCK_N],
Some(PaddingOption::Zero),
);
T::store_tensor_descriptor(c_desc, &[pid_m * BLOCK_M, pid_n * BLOCK_N], acc);
}
Read the middle of it. To compute C[m, n] you need row m of A and column
n of B, multiply them element-wise, and sum — which is Chapter 10’s
reduction again, as T::sum(a_row * b_col, Some(0), true).
Two details worth stopping on.
The stride. A is stored row-major, so row m is contiguous: offsets
m*K + 0..K. Column n of B is not contiguous — consecutive elements are N
apart, hence k_offsets * N + n. That difference costs real performance, and
Chapter 17 is about why.
The early return. if m >= M { return; } is a bounds check on the program
rather than on the lanes. A whole program exits. That is fine and cheap, and is
the right tool when the entire block is out of range rather than part of it.
Why this is slow
Count the memory traffic. Every program loads K elements of A and K of
B, then writes one number. For an M × N output that is 2·M·N·K loads for
M·N results.
But every element of A is only actually needed N times and every element of
B is needed M times. The naive kernel re-reads them from memory on every
use, when it could have read them once and reused them.
That gap is the entire subject of fast matrix multiplication.
Tiles
The fix is to compute a tile of the output at a time instead of one element.
A program that produces a BLOCK_M × BLOCK_N tile needs BLOCK_M rows of A
and BLOCK_N columns of B. It loads them once and performs BLOCK_M × BLOCK_N multiply-accumulates with them. The bigger the tile, the more
arithmetic per byte loaded.
That ratio has a name — arithmetic intensity — and raising it is how a memory-bound kernel becomes compute-bound.
The K dimension usually will not fit in registers, so it is walked in chunks
of BLOCK_K, accumulating as you go:
acc = 0
for each k-chunk:
load A tile [BLOCK_M, BLOCK_K]
load B tile [BLOCK_K, BLOCK_N]
acc += A_tile @ B_tile
store acc
The accumulator lives in registers across the whole loop and is written to memory exactly once.
T::dot
That A_tile @ B_tile is one operation:
acc = T::dot::<f32, f32>(w_tile, x_tile, Some(acc), Some(InputPrecision::IEEE), None);
T::dot maps onto the card’s Tensor Cores — dedicated units that do a small
matrix multiply as a single instruction, many times faster than the general
arithmetic units. This is the whole reason a tiled matmul is fast, and you get
it by calling dot rather than by writing multiply and add.
Its two type parameters are separate on purpose: D is the input dtype, O is
the accumulator’s. Multiplying f16 inputs into an f32 accumulator is the
normal arrangement, and Chapter 19 explains why mixing them that way is not a
compromise but the correct choice.
The acc argument is what makes the K loop work. Passing Some(acc) adds the
product to the existing accumulator in one operation instead of two.
InputPrecision controls how f32 × f32 is handled:
| Value | Meaning |
|---|---|
TF32 |
19-bit mantissa, Tensor Cores, fastest — the default on capable hardware |
TF32x3 |
Three TF32 products to recover most of f32’s precision |
IEEE |
Full f32. Slowest, and exact |
Read that table again, because there is a trap in it. IEEE does not mean
“Tensor Cores, but careful”. It means no Tensor Cores at all: Triton’s code
generation only routes a dot to the MMA path for the reduced-precision modes,
so IEEE silently drops you onto the software fused-multiply-add fallback.
The fused conv kernel in this tree was written with IEEE, to match cuDNN’s
f32 accumulation, and lost its Tensor Cores for it. It now passes TF32, with
a comment naming the exact code path:
// TF32 precision is what actually routes this dot to the tensor-core MMA
// path (see getMmaTypeDot in Triton's MMAv2.cpp) — IEEE forces the
// software FMA fallback and silently disables tensor cores entirely.
acc = T::dot::<f32, f32>(w_tile, x_tile, Some(acc), Some(InputPrecision::TF32), None);
TF32 is the default and is what you want. Reach for IEEE only when you need
exact f32 more than you need the hardware — and know that you are giving up
the hardware, not trading a little speed for a little accuracy.
The real tiled loop
The complete tiled matmul in this tree is inside
kernels/teeny-kernels/src/nn/fused/conv2d_bn_silu_gemm.rs, which does a
convolution as a GEMM. Its inner loop:
let mut acc = T::zeros::<f32>(&[BLOCK_N, BLOCK_M]);
let k_tiles = T::cdiv(C_IN, BLOCK_K);
for k in 0..k_tiles {
let x_tile = T::load_tensor_descriptor(x_desc, &[b * C_IN + k * BLOCK_K, pid_m * BLOCK_M]);
let w_tile = T::load_tensor_descriptor(w_desc, &[pid_n * BLOCK_N, k * BLOCK_K]);
acc = T::dot::<f32, f32>(w_tile, x_tile, Some(acc), Some(InputPrecision::IEEE), None);
}
The loads use tensor descriptors rather than the pointer arithmetic of Chapter 7. A descriptor is built once from a shape, strides and a tile shape:
let w_desc = T::make_tensor_descriptor(
w_ptr,
&[C_OUT, C_IN], // full shape
&[C_IN, 1], // strides
&[BLOCK_N, BLOCK_K], // tile shape
Some(PaddingOption::Zero),
);
after which load_tensor_descriptor(desc, &[row, col]) fetches the tile at that
offset. Bounds are handled by the descriptor — out-of-range reads come back as
zero because of PaddingOption::Zero, so there is no mask in the loop at all.
On recent cards this maps to the Tensor Memory Accelerator, hardware that moves
tiles between global and shared memory without occupying the arithmetic units.
It also imposes an alignment requirement, which is what
RuntimeOp::forward_output_row_stride exists to satisfy — Chapter 17 returns
to it.
Choosing the tile
Three constants to pick, and they interact:
BLOCK_M×BLOCK_Nis the output tile. Larger means better arithmetic intensity and more registers per program. Past a point the card can keep fewer programs in flight, and there is less work available to hide memory latency.BLOCK_Kis how much of the reduction dimension is loaded per iteration. Larger means fewer iterations and more shared memory per program.
The fused conv kernel uses 32 for all three with a group size of 8. That is a reasonable starting point, not a universal answer.
There is no autotuner to search this for you. Chapter 18 shows how to measure the alternatives, which is the only honest way to choose.
kernels/teeny-kernels/src/math/matmul.rslooks like it should be relevant. It is not — the whole file is commented-out Python, including a large@autotuneconfiguration table, and it exports nothing. The working code isgemm.rsand the fused conv kernel above.
Next: what else to do while the data is already in registers.