teenygrad kernels / Kernels in a Real Model
Wiring the Runtime
CustomOp described your operation to the graph. RuntimeOp describes it to
the thing that launches kernels: how many inputs, what buffers, which arguments
in what order, and how big a grid.
The trait has twenty methods. You need five.
The five
fn n_activation_inputs(&self) -> usize;
fn param_shapes(&self, input_shapes: &[&[usize]], output_shape: &[usize]) -> Vec<Vec<usize>>;
fn pack_args(&self, inputs, params, output, output_shape, output_row_stride, visitor);
fn block(&self) -> [u32; 3];
fn grid(&self, output_shape: &[usize]) -> [u32; 3];
Everything else has a default that is correct for a single-launch, inference-only op.
Note the shapes here are &[usize], not the Shape of the last chapter. By the
time RuntimeOp is consulted the batch dimension is known, so there are no
Nones left. Symbolic shapes are a graph-construction concern; runtime shapes
are concrete.
Inputs versus parameters
The distinction matters and is easy to get backwards.
An activation input comes from another node in the graph. It changes every
inference. n_activation_inputs says how many your op consumes, and they arrive
in pack_args as inputs, in recording order.
A parameter is a buffer your op owns. Weights, biases, lookup tables,
precomputed geometry. It is allocated once when the model loads and persists.
param_shapes declares them; they arrive as params.
param_shapes receives concrete shapes, so a parameter can be sized from the
input:
fn param_shapes(&self, input_shapes: &[&[usize]], _output_shape: &[usize]) -> Vec<Vec<usize>> {
let a = input_shapes[0][2]; // boxes is [B, 4, A]
vec![vec![a], vec![a], vec![a]] // anchor_x, anchor_y, strides
}
Parameters are zero-initialised by default, because they are usually trained
weights loaded from a checkpoint. When yours is a constant you computed on the
host, param_init_data uploads it:
fn param_init_data(&self, param_idx: usize) -> Option<Vec<u8>> {
let data: &[f32] = match param_idx {
0 => &self.anchor_x,
1 => &self.anchor_y,
2 => &self.strides,
_ => return None,
};
Some(data.iter().flat_map(|&f| D::from_f64(f as f64).to_le_bytes()).collect())
}
From vision-rs/src/models/yolo/kernels/detect_decode.rs.
Little-endian bytes, in the buffer’s element type — which is why that conversion
goes through D::from_f64 rather than writing f32 bytes directly. The byte
count must match param_shapes()[idx]'s product times the dtype size, and
nothing checks it.
If a parameter needs a name, for loading from a checkpoint under a dotted key,
param_names supplies one per slot.
Packing arguments
This is where your op meets your kernel, and it is the part with no safety net.
fn pack_args(
&self,
inputs: &[(RawPtr, &[usize])],
params: &[RawPtr],
output: RawPtr,
output_shape: &[usize],
_output_row_stride: i32,
visitor: &mut dyn ArgVisitor,
) {
let b = output_shape[0] as i32;
let a = output_shape[2] as i32;
visitor.visit_ptr(inputs[0].0); // boxes_ptr
visitor.visit_ptr(params[0]); // anchor_x_ptr
visitor.visit_ptr(params[1]); // anchor_y_ptr
visitor.visit_ptr(params[2]); // strides_ptr
visitor.visit_ptr(output); // out_ptr
visitor.visit_i32(b); // _B
visitor.visit_i32(a); // A
}
Seven calls, in exactly the order the kernel declares its parameters. The trailing comments are load-bearing: they are the only thing connecting this sequence to the function signature.
Nothing checks it. Not the order, not the count, not the types. Swap two
visit_ptr calls and you get wrong numbers, silently. Pass six arguments to a
seven-parameter kernel and the seventh is whatever was in that register.
The macro already knows the right answer — it generates type Args<'a> = (*mut f32, ..., i32) for the by-hand launch path — and that knowledge is simply not
used here. It is the third item in
API-FRICTION.md.
Until that changes: write pack_args immediately after the kernel signature,
keep the comments, and make the first test one that checks numbers rather than
that it runs.
Block and grid
fn block(&self) -> [u32; 3] { [self.block_a as u32, 1, 1] }
fn grid(&self, output_shape: &[usize]) -> [u32; 3] {
let b = output_shape[0];
let a = output_shape[2];
let a_tiles = a.div_ceil(self.block_a as usize);
[(b * a_tiles) as u32, 1, 1]
}
block is threads per program. For an elementwise kernel that is the same
number as its BLOCK_SIZE const generic, and your op has to keep the two in
step because nothing else will.
For a tiled kernel it is not. conv2d_bn_silu’s BLOCK_OW is 16 — the width of
an output tile — while its bench launches with 128 threads. The const generic
describes the data one program covers; the block describes the threads that
cover it. Chapter 16 pulls those apart.
grid is Chapter 6’s division, rounded up, now with the real output shape.
detect_decode launches a flat grid over both the batch and the anchor tiles,
which the kernel then splits apart with a divide and a remainder — the pattern
from Chapter 6.
Multi-launch ops
Some operations need several launches. Channel-concatenation scatters N input chunks into one output buffer and wants one launch per chunk.
Override n_launches, and the executor calls pack_args_for_launch and
grid_for_launch with the index instead:
fn n_launches(&self) -> usize { self.n_chunks }
fn pack_args_for_launch(&self, launch_idx: usize, inputs, params, output, ...) {
// pack for chunk `launch_idx`
}
The defaults delegate to pack_args and grid, so an op with one launch never
sees these.
Row stride
pack_args receives output_row_stride, which is not always the last dimension
of the shape.
The default is the natural row-major stride. But a kernel using tensor
descriptors — the TMA path from Chapter 11 — needs rows aligned to 16 bytes,
which for f32 means a multiple of 4 elements. Override
forward_output_row_stride to round up, and the executor allocates the padded
buffer and tells you the real stride.
Use the argument, not output_shape.last(), or a TMA kernel will read the wrong
addresses.
The entry point, and an open question
CustomOp::lower returns four things:
fn lower(&self) -> Option<(String, String, String, Arc<dyn RuntimeOp>)> {
let kernel = DetectDecodeForward::<D>::new(self.block_a);
let runtime_op = Arc::new(DetectDecodeRuntimeOp::<D>::new(...));
Some((
"detect_decode_forward".to_string(), // name
kernel.source, // source
"entry_point".to_string(), // entry-point symbol
runtime_op,
))
}
The third element is meant to be the PTX symbol to resolve, and the tree is
inconsistent about it. The trait’s documentation says “conventionally
{name}_entry_point”, which is what every built-in op in teeny-kernels
produces. Every CustomOp in vision-rs passes the bare literal
"entry_point".
It does not matter, because the value is never read. LoadedModel resolves
kernels with CudaProgram::<ErasedKernel>::try_from_ptx(&ptx), an overload that
takes no entry-point argument — it parses the symbol out of the compiled PTX’s
.visible .entry directive instead. The only remaining consumer of the field
anywhere in the workspace is a println! in a test.
So pass whatever you like and it will work. Pass
format!("{}_entry_point", name) anyway: it matches what the macro actually
emits, it matches Kernel::entry_point_name(), and it is what will still be
right if the field is ever wired up.
This is worth knowing rather than just worth ignoring. A parameter that looks
load-bearing and is not is a trap for the next person, which is why it stays in
KNOWN-GAPS.md
as item 1 with a suggested fix — delete it and derive it from the name.
Next: making the op differentiable.