teenygrad kernels / Your First Kernel
Compiling and Reading the Output
The pipeline from Chapter 3 has been a diagram until now. This chapter opens it up, because a kernel you can read the output of is a kernel you can debug.
Compiling
let ptx_path = compile_kernel(&kernel, &Target::new(env.capability), false)?;
Three arguments:
- the kernel, which supplies the source text and its id,
- the target, a compute capability such as
sm_89, force, which recompiles even when a cached result exists.
It returns a path, to a file with a .o extension that contains PTX text —
// Generated by LLVM NVPTX Back-End is its first line. Beside it, same name,
sit two more: .mlir and .rs. The MLIR is the most useful of the three, and
the .rs is the captured source from Chapter 8.
Compilation is cached under TEENYC_CACHE_DIR, keyed by the kernel’s id. The
first call shells out to teenyc; later calls with the same id return
immediately. This matters when you benchmark: an uncached first run measures the
compiler, not the kernel. Chapter 18 comes back to it.
Reading the MLIR
This is the compiler’s own record of what it understood your kernel to mean. It is close enough to the source to check line by line, and specific enough to show you what actually happens.
Here is the real MLIR for elemwise_add_forward — the library kernel that
vector_add is a copy of — with f32 and a block size of 128:
%c128_i32 = arith.constant 128 : i32
%0 = tt.get_program_id x : i32
%1 = arith.muli %0, %c128_i32 : i32
%2 = tt.make_range {end = 128 : i32, start = 0 : i32} : tensor<128xi32>
%3 = tt.splat %1 : i32 -> tensor<128xi32>
%4 = arith.addi %2, %3 : tensor<128xi32>
%5 = tt.splat %arg3 : i32 -> tensor<128xi32>
%6 = arith.cmpi slt, %4, %5 : tensor<128xi32>
%7 = tt.splat %arg0 : !tt.ptr<f32> -> tensor<128x!tt.ptr<f32>>
%8 = tt.addptr %7, %4 : tensor<128x!tt.ptr<f32>>, tensor<128xi32>
%9 = tt.load %8, %6 : tensor<128x!tt.ptr<f32>>
%10 = tt.splat %arg1 : !tt.ptr<f32> -> tensor<128x!tt.ptr<f32>>
%11 = tt.addptr %10, %4 : tensor<128x!tt.ptr<f32>>, tensor<128xi32>
%12 = tt.load %11, %6 : tensor<128x!tt.ptr<f32>>
%13 = tt.splat %arg2 : !tt.ptr<f32> -> tensor<128x!tt.ptr<f32>>
%14 = tt.addptr %13, %4 : tensor<128x!tt.ptr<f32>>, tensor<128xi32>
%15 = arith.addf %9, %12 : tensor<128xf32>
tt.store %14, %15, %6 {operandSegmentSizes = array<i32: 1, 1, 1>} : tensor<128x!tt.ptr<f32>>
tt.return
From kernels/teeny-kernels/tests/snapshots/test_elemwise_add__elemwise_add_forward_mlir.snap.
Now line it up against what you wrote:
| Your Rust | The MLIR |
|---|---|
T::program_id(Axis::X) |
tt.get_program_id x |
pid * BLOCK_SIZE |
arith.muli %0, %c128_i32 |
T::arange(0, BLOCK_SIZE) |
tt.make_range {start = 0, end = 128} |
+ block_start |
tt.splat then arith.addi |
offsets.lt(n_elements) |
tt.splat then arith.cmpi slt |
a_ptr.add_offsets(offsets) |
tt.splat then tt.addptr |
T::load(..., Some(in_bounds), ...) |
tt.load %8, %6 |
a + b |
arith.addf |
T::store(...) |
tt.store %14, %15, %6 |
Almost one to one. Four things are worth noticing.
BLOCK_SIZE is gone. It is 128, a constant, everywhere it appears — in
the multiply, in the range, in every tensor type. This is what “baked in at
compile time” means concretely.
Types carry the shape. tensor<128xi32> is a block of 128 integers;
tensor<128x!tt.ptr<f32>> is a block of 128 pointers to f32. The type system
is tracking your blocks all the way down, and a mismatch here is a mismatch you
would have got as a Rust type error first.
tt.splat is broadcasting. Whenever a scalar meets a block, it is copied
across every lane. block_start is one integer in your Rust and a
tensor<128xi32> here. Three of these appear because three scalars —
block_start, n_elements, and each base pointer — get broadcast.
The mask is an argument to the memory operations. tt.load %8, %6 — address
tensor, then mask. Nothing branches. That is the whole implementation of the
bounds check from Chapter 7: not a jump, just an operand.
The two functions
The full file has two functions, not one. Your kernel appears under a long mangled name, and beside it sits:
tt.func public @elemwise_add_forward_entry_point(...) {
tt.call @_RINvCslSnLtkXmXla_85elemwise_add_forward_7ef7...(%arg0, %arg1, %arg2, %arg3)
tt.return
}
That is the wrapper from Chapter 8, doing its one job: giving the loader a
predictable symbol to look up. CudaProgram::try_from_ptx resolves
{name}_entry_point and gets a function pointer to the thing that calls your
kernel.
Snapshot tests
The output above is not a screenshot. It is a committed test fixture, and the pattern behind it is worth stealing:
let kernel = ElemwiseAddForward::<f32>::new(BLOCK_SIZE);
let target = Target::new(Capability::Sm89);
let ptx_path = PathBuf::from(compile_kernel(&kernel, &target, true)?);
let mlir = std::fs::read_to_string(ptx_path.with_extension("mlir"))?;
assert_debug_snapshot!("elemwise_add_forward_mlir", mlir.trim());
This needs teenyc but not a GPU — it compiles, it does not run. So it is
the strongest check available on a machine without a card, and it makes any
change to the generated code visible in a diff. If you change a kernel and the
snapshot moves in a way you did not expect, something happened that you did not
intend.
Note Target::new(Capability::Sm89) — a fixed capability rather than the local
device’s, so the target is the same everywhere.
These snapshots are not portable between machines. The MLIR embeds the mangled Rust symbol of your kernel, and that name contains rustc’s crate disambiguator — a hash of the crate’s build environment, not of its source. Check out this tree on a second machine and every MLIR snapshot fails, with a diff whose only difference is
Cs<something>againstCs<something else>.The kernel body in the diff will be byte-identical. If that is all you see, nothing is wrong with your kernel. Normalising the disambiguator before comparing would fix it; today nothing does.
So treat a snapshot diff as a question, not a verdict: look at what actually changed inside it before believing it.
When something is wrong
A rough order to work through:
- Does it compile? A
teenycfailure is about the captured text. Check that you have not used anything outside the DSL — Chapter 3’s second consequence. - Does the MLIR match your intent? Count the loads and stores. Check the
constants. A missing mask operand on a
tt.loadis visible immediately. - Are the numbers wrong at the edges? Suspect the mask, or the
otherfill value for a reduction. - Are the numbers wrong everywhere? Suspect the index arithmetic, or the argument order at the launch site — nothing checks that the tuple you pass matches the parameters your kernel declares.
The end of Part 2
You can write a kernel, run it, and read what the compiler made of it.
That is the whole mechanism. Everything after this is patterns built on it: how to reduce across a row, how to tile a matrix multiply, how to fuse work into a kernel that has already paid for its loads.
Part 3 starts with the first kernel where programs have to do more than mind their own slice.