teenygrad kernels / Kernels in a Real Model
Your Kernel as a Graph Op
Every kernel so far has been launched by hand: you built it, compiled it,
allocated buffers, and called launch. That is the whole mechanism, and for a
standalone kernel it is all you need.
Models do not work that way. A model is a graph of operations, and the framework decides what runs, in what order, with which buffers. To put your kernel in one, you have to describe it in terms the graph understands.
This part is about that. It is more machinery than the rest of the book, and none of it is needed to make a kernel work — only to make it a citizen of a model.
The two halves
There are two separate jobs, and separating them is the thing to understand first.
CustomOp is the graph-level description. What the operation is called,
what shape it produces, and how to get a kernel out of it. It is consulted while
the graph is being built and lowered, before anything runs.
RuntimeOp is the launch-time description. How many inputs it takes, what
scratch buffers it needs, how to pack arguments, and how big a grid to launch.
It is consulted every time the op executes.
One is about shapes and identity. The other is about pointers and grids. The
next chapter is RuntimeOp; this one is everything before it.
Symbolic tensors
A graph is built by recording. You start with a placeholder and every operation on it appends a node rather than computing anything:
let (x, graph) = SymTensor::input(DtypeRepr::F32, vec![None, Some(784)]);
SymTensor is a handle: a node index, a dtype, a shape, and a shared reference
to the graph. Cloning one is cheap; it shares the graph.
The shape is a Vec<Option<usize>>, and the None is the point. It means “this
dimension is not known yet” — almost always the batch axis. So vec![None, Some(784)] is “any number of rows of 784”. Concrete sizes arrive later, when
the model is loaded and given real inputs.
This is why your op cannot simply be handed shapes. It has to be able to infer its output shape from symbolic inputs, before anyone knows the batch size.
Recording your op
let y = x.record_custom(CustomData::new(MyOp::new(block_size)), &[], None);
Three arguments:
- the op, wrapped in
CustomData, which is anArc<dyn CustomOp>that also implementsDebug; - additional inputs, since
selfis the first one — a two-input op passes&[&other]; - an output dtype, or
Noneto keep the primary input’s.
It returns a new SymTensor pointing at the node it just added. From the
graph’s point of view your op is now indistinguishable from a built-in one.
Implementing CustomOp
Four methods matter:
pub trait CustomOp: Any + Send + Sync {
fn name(&self) -> &str;
fn infer_output_shape(&self, input_shapes: &[&Shape]) -> Shape;
fn as_any(&self) -> &dyn Any;
fn lower(&self) -> Option<(String, String, String, Arc<dyn RuntimeOp>)> { None }
fn lower_backward_source(&self) -> String { String::new() }
}
name is used in errors and debug output. Namespace it — the vision-rs ops
use "yolo.detect_decode" — because a bare "decode" in a lowering failure
tells nobody anything.
infer_output_shape is the one with real content. It gets every input’s
symbolic shape, in the order they were recorded, and returns the output’s.
Shape-preserving ops are one line:
fn infer_output_shape(&self, input_shapes: &[&Shape]) -> Shape {
input_shapes[0].clone()
}
An op that changes rank does the arithmetic here, propagating None wherever a
dimension stays dynamic. Getting this wrong does not fail here — it fails much
later, when a buffer is allocated at the wrong size.
as_any is boilerplate, always fn as_any(&self) -> &dyn Any { self }. It
lets a lowering downcast back to your concrete type.
lower is how your op becomes a kernel, and is the subject of the next
chapter.
A real one
There is no CustomOp implementation in the teenygrad repository itself — the
built-in ops go through Op variants instead. The worked examples are in
vision-rs, and DetectDecodeOp is the clearest:
pub struct DetectDecodeOp<D: FloatBytes + Send + Sync + 'static> {
pub anchor_x: Vec<f32>,
pub anchor_y: Vec<f32>,
pub strides: Vec<f32>,
pub block_a: i32,
_phantom: PhantomData<D>,
}
impl<D: FloatBytes + Send + Sync + 'static> CustomOp for DetectDecodeOp<D> {
fn name(&self) -> &str { "yolo.detect_decode" }
fn infer_output_shape(&self, input_shapes: &[&Shape]) -> Shape {
// boxes [B, 4, A] → [B, 4, A]: shape-preserving
input_shapes[0].clone()
}
fn as_any(&self) -> &dyn Any { self }
// lower() — next chapter
}
From vision-rs/src/models/yolo/kernels/detect_decode.rs.
Notice what the struct holds: not tensors, but the configuration the kernel
needs — a precomputed anchor grid and a block size. A CustomOp is built once,
when the model is defined, and consulted many times. It should hold parameters,
never per-inference state.
What the lowering does with it
When the graph is compiled, TritonLowering walks it and turns every node into
something executable. Your node hits this arm:
Op::Custom { data } => match data.0.lower() {
Some((name, kernel_source, entry_point, runtime_op)) => {
Box::new(KernelExecutable { name, kernel_source, entry_point, ... })
}
None => {
return Err(anyhow::anyhow!(
"custom op '{}' is not handled — implement CustomOp::lower()",
data.name()
));
}
},
From kernels/teeny-kernels/src/graph/mod.rs.
So lower returning None — the default — is a runtime error naming your op,
not a compile error. If you implement CustomOp and forget lower, this is the
message you will get.
The result is a KernelExecutable: kernel source, entry-point symbol, output
shape and dtype, and the RuntimeOp. From there it is compiled to PTX exactly
as in Chapter 9 — the graph path and the by-hand path converge on the same
compiler.
Where fusion happens
Chapter 12 fused operations by writing one kernel that did several things. The graph has its own kind: the lowering can recognise a pattern of nodes and emit a single kernel for them, or split one node into several.
Both happen in this tree. Conv2d-with-bias becomes two DAG nodes, which is why
Lowering::extra_dag_names exists — the extra node needs a name so its weights
load. And the fused conv kernels from Chapter 12 are selected by shape, in the
lowering, from one graph node.
Your custom op does not participate in this. It lowers to exactly the kernel you give it, and the graph will not fuse it with its neighbours. If you want fusion, fuse it yourself, in the kernel.
Next: the half that runs.