teenygrad kernels / Orientation
From Rust to PTX
Your kernel is a Rust function. Your GPU does not run Rust. This chapter is about what happens in between.
It is worth reading before you write a kernel rather than after, because the answer is genuinely unusual, and almost every rule in Parts 2 and 3 is a consequence of it.
The surprise
Here is the thing to know:
Your kernel function is never called by your program. It is captured as text, and compiled by a different compiler.
When you write #[kernel] on a function, the macro keeps the function — it
compiles normally, as ordinary Rust, and the Rust compiler type-checks it. But
the macro also converts the function’s source code back into a string and stores
that string in a generated struct.
That string is the real artefact. It gets handed to teenyc, a separate
compiler binary, which compiles it for the GPU.
So the function is type-checked twice, by two different compilers, and executed by neither of them in the way you would expect.
Why do it that way
Because the two compilers need different things from the same code.
The Rust compiler you already have is very good at checking that your kernel
makes sense: that you did not pass a float where an index belongs, that the
tensor ranks line up, that the dtype you loaded is the dtype you stored. Doing
that check is exactly what T: Triton and those where clauses are for. None
of it needs a GPU.
But that compiler cannot emit GPU code. Producing something a graphics card will
run means going through MLIR and the Triton compiler passes, and that is what
teenyc — a modified rustc — exists to do.
Capturing the source is the seam between the two. You get Rust’s type checking
on kernels, from an ordinary cargo check, without the GPU toolchain being
involved at all.
The pipeline
flowchart TD
A["your #[kernel] fn<br/><i>Rust</i>"] -->|"macro captures source text"| B["kernel source + generated entry point<br/><i>a String</i>"]
B -->|teenyc| C["MLIR, Triton dialect<br/><i>tt.load, tt.store, tensors</i>"]
C -->|"Triton passes"| D["MLIR, GPU dialects<br/><i>layouts, coalescing, pipelining</i>"]
D --> E["LLVM IR"]
E -->|"NVPTX backend"| F["PTX<br/><i>NVIDIA assembly</i>"]
F -->|"driver JIT, at load time"| G["SASS<br/><i>machine code for your card</i>"]
The stages that matter to you:
The captured string. Source text, plus a small wrapper the macro generates. Chapter 8 shows it in full.
MLIR in the Triton dialect. Your kernel, still recognisable, expressed as operations on tensors. This is the most useful thing to look at when a kernel misbehaves, and Chapter 9 reads one.
The Triton passes. Where the compiler decides how your block maps onto real threads, how loads are combined, and where data lives. This is the part you do not control directly, and mostly should not want to.
PTX. NVIDIA’s portable assembly. This is what compile_kernel gives you back
— a .ptx file on disk.
SASS. The actual machine code, produced by the driver when the PTX is loaded, targeted at the exact chip present. You never see this, and it is why a single PTX file works across several card generations.
What this costs you
Four consequences, all of which you will meet.
The kernel body is compiled in a different world. teenyc compiles your
captured text against a small generated environment — not against your crate,
and not against the real standard library. So a println! in a kernel body will
type-check happily in your editor and then fail in an unfamiliar compiler. You
cannot call your own helper functions from a kernel body either, unless they
are part of that environment.
Everything must be knowable from the text. This is why BLOCK_SIZE is a
const generic rather than an argument, and why T::reduce takes a plain fn
pointer instead of a closure — a closure that captured a variable could not be
written out as source. Chapter 13 comes back to this.
Compilation happens at run time, not build time. cargo build does not
produce PTX. The first time your program calls compile_kernel, it shells out
to teenyc. Results are cached on disk, keyed by the kernel’s identity, so it
happens once rather than every launch.
You need teenyc installed to run anything, but not to build anything. This
is genuinely useful: cargo check on a crate full of kernels works on a laptop
with no GPU and no toolchain. Only running needs the rest.
Where the pieces live
| Piece | Crate | What it does |
|---|---|---|
#[kernel] |
teeny-macros |
Captures the source, generates the struct and entry point |
Triton trait |
teeny-triton |
The operations a kernel body may use |
compile_kernel |
teeny-compiler |
Finds teenyc, runs it, caches the PTX |
teenyc |
separate toolchain | The modified rustc that emits GPU code |
Device, launch |
teeny-cuda |
Loads the PTX and runs it |
Two environment variables are worth knowing now, because they are how you fix things when the toolchain is not where it should be:
TEENYC_PATH— theteenycbinary to use. Without it, the compiler looks for a single rustup toolchain whose name containsteenyc, and fails clearly if there are none or several.TEENYC_CACHE_DIR— where compiled PTX is cached. Defaults to/tmp/teenyc_cache.
Next: getting all of that installed.