teenygrad kernels / Your First Kernel

Loads, Stores, and Masks

A kernel that does not touch memory is not doing anything. This chapter is about the two operations that do — and about the mask, which is the thing that stops the last program in a launch from corrupting your program’s memory.

Pointers plus offsets

Memory in a kernel is addressed by pointer arithmetic, but on whole blocks at once:

a_ptr.add_offsets(offsets)

a_ptr is a single pointer — the base of the array. offsets is a tensor of BLOCK_SIZE integers. add_offsets combines them into a tensor of pointers, one per lane.

That is the type to keep in your head. T::Pointer<D> is one address; T::Tensor<T::Pointer<D>> is a block of them, and that is what T::load and T::store take. The offsets are in elements, not bytes — the dtype is in the type, so the scaling is done for you.

add_offsets comes from the AddOffsets trait, which is why every kernel in this book carries this line:

T::Pointer<D>: AddOffsets<i32, 1, T::I32Tensor, Output = T::Tensor<T::Pointer<D>>>,

Read it as: “a pointer to D, offset by a rank-1 tensor of i32, gives a tensor of pointers to D”. It says exactly what the sentence above says, in types.

The mask

    // The last program runs off the end of the vector. This says which of its
    // lanes are real.
    let in_bounds = offsets.lt(n_elements);

offsets.lt(n_elements) compares every lane against the length and produces a BoolTensor — one true or false per lane. For every program but the last, all 128 are true. For the last, some are false.

Here is why that matters. The grid is 1000 / 128 rounded up, which is 8. The eighth program computes offsets 896 through 1023. But the array has 1000 elements, so lanes for offsets 1000 through 1023 point past its end.

Without a mask, that kernel reads 24 values that are not yours and writes 24 values over memory that is not yours. On a GPU that is not a segfault. It is usually silence, sometimes a wrong number somewhere else in your program, and occasionally a crash much later in something unrelated.

The mask is not an optimisation. It is the bounds check.

When a lane’s mask is false, T::load does not read it and T::store does not write it. Nothing else about the kernel changes.

Choosing the fill value

A masked-off lane still holds something after a load. By default that value is undefined, which is fine when you are about to mask the store as well — the vector-add kernel never uses those lanes, so it does not care.

It is not fine when the lane feeds a reduction. A sum over a block where some lanes are garbage gives a garbage sum. Pass other and the masked lanes get a known value instead:

let zeros = T::zeros::<D>(&[BLOCK_A]);
let anchor_x = T::load(anchor_x_ptr.add_offsets(a_offs), Some(mask), Some(zeros), &[], None, None, None, false);

Zero is the right identity for a sum. For a maximum you want negative infinity, via T::full. Getting this wrong is a classic reduction bug and Chapter 10 hits it directly.

The full signatures

T::load takes eight arguments and T::store takes six. Two of each matter now; the rest have a sensible “no thanks” value that you will write a great many times.

fn load<D: Dtype, const N: usize>(
    ptr: Self::Tensor<Self::Pointer<D>>,   // where to read
    mask: Option<Self::BoolTensor>,        // which lanes are real
    other: Option<Self::Tensor<D>>,        // what masked lanes get
    boundary_check: &[i32; N],             // block-pointer mode only
    padding_option: Option<PaddingOption>, // block-pointer mode only
    cache_modifier: Option<CacheModifier>, // L1/L2 behaviour
    eviction_policy: Option<EvictionPolicy>,
    volatile: bool,
) -> Self::Tensor<D>;

fn store<D: Dtype, const N: usize>(
    dest: Self::Tensor<Self::Pointer<D>>,
    src: Self::Tensor<D>,
    mask: Option<Self::BoolTensor>,
    boundary_check: &[i32; N],
    cache_modifier: Option<CacheModifier>,
    eviction_policy: Option<EvictionPolicy>,
);

The last four on each are performance hints and an alternative addressing mode. boundary_check and padding_option do nothing unless you built the pointer with T::make_block_ptr, which Chapter 17 covers. cache_modifier and eviction_policy tell the hardware how to treat the data in cache; leave them None until you are measuring.

In Python Triton the same call is tl.load(a_ptr + offsets, mask=in_bounds), because Python has keyword arguments with defaults and Rust does not. There is no way around it today, and it is the single largest source of noise in these kernels. It is recorded in API-FRICTION.md as the first item.

Comparisons

lt is one of six, and each has a scalar form:

Method Meaning
lt, le less than, less than or equal
gt, ge greater than, greater than or equal
eq, ne equal, not equal

Written offsets.lt(n_elements) as a method — that spelling comes from the Comparison trait, the second of the three where clauses. There is also T::lt(x, y) for two tensors and T::lt_scalar(x, y) for a tensor against a scalar. All three exist; the method form is what the kernels in this tree use.

Masks combine with & and |, so a two-dimensional bounds check is one expression:

let in_bounds = row_offsets.lt(n_rows) & col_offsets.lt(n_cols);

And T::where_(cond, x, y) selects between two tensors lane by lane — Triton’s tl.where, spelled with a trailing underscore because where is a Rust keyword.

The rule

Every kernel that indexes memory with a computed offset needs a mask, unless you can prove the size divides the block exactly.

Sometimes you can. The softmax kernel in Chapter 10 requires the caller to round the row length up to a power of two and pass it as BLOCK_SIZE, so BLOCK_SIZE == n_cols holds and no mask is needed. That is a real constraint pushed onto the caller in exchange for a simpler, faster kernel — a trade worth recognising, and worth documenting loudly when you make it.

Next: what the macro built out of all this.