teenygrad kernels / Real Patterns
Softmax: Your First Reduction
Every kernel so far has been embarrassingly parallel: lane i reads element
i, does arithmetic, writes element i. No lane needed to know anything about
any other.
Softmax breaks that. To compute one output you need a sum over the whole row, which means the lanes have to combine their values. That operation is a reduction, and it is the first genuinely new idea in this book.
The operation
Softmax turns a row of numbers into a probability distribution:
softmax(x)_i = exp(x_i) / sum_j exp(x_j)
Every output depends on every input in its row. The denominator is the reduction.
Why the obvious version is wrong
Write that formula directly and it breaks. exp(x) overflows f32 at about
x = 88, and logits above 88 are entirely ordinary. You get inf / inf, which
is NaN, and the NaN spreads through the rest of your model.
The fix relies on softmax being invariant to shifts. Subtract any constant from every element and the result is unchanged, because the constant cancels:
exp(x_i - c) / sum_j exp(x_j - c)
Choose c = max(x). Now the largest exponent is exp(0) = 1, nothing
overflows, and the terms that underflow to zero were negligible anyway.
That is what “numerically stable softmax” means, and it costs a second reduction: one for the maximum, one for the sum.
The kernel
Here is the library’s implementation:
#[kernel]
pub fn softmax_forward<T: Triton, D: Float, const BLOCK_SIZE: i32>(
x_ptr: T::Pointer<D>,
y_ptr: T::Pointer<D>,
_n_rows: i32,
n_cols: i32,
) where
T::I32Tensor: types::Tensor<i32, 1>,
T::I32Tensor: Comparison<i32, BoolTensor = T::BoolTensor>,
T::Pointer<D>: AddOffsets<i32, 1, T::I32Tensor, Output = T::Tensor<T::Pointer<D>>>,
{
let pid = T::program_id(Axis::X);
let row_offset = pid * n_cols;
let col_offsets = T::arange(0, BLOCK_SIZE);
let offsets = col_offsets + row_offset;
let x = T::load(
x_ptr.add_offsets(offsets),
None,
None,
&[],
None,
None,
None,
false,
);
// Triton's builtin: numerically-stable softmax (max subtraction, exp, sum, div).
let y = T::softmax(x, None, false, false);
T::store(y_ptr.add_offsets(offsets), y, None, &[], None, None);
}
The shape of it is different from anything in Part 2. One program handles one
whole row. pid is the row index, not a slice index, and row_offset jumps
to the start of that row.
There is no mask, and no T::arange(0, BLOCK_SIZE) + block_start either —
col_offsets covers the entire row in one go.
The constraint, and why it is there
Look at the doc comment: BLOCK_SIZE must equal n_cols. The caller is
required to round the row length up to the next power of two and pass that as
the block size.
That is a real burden pushed onto the caller. In exchange:
- No mask is needed, because the block exactly covers the row.
- No loop is needed, because the whole row is in registers at once.
- The reduction is a single tree, with no partial-result bookkeeping.
The cost is that a row wider than the largest workable block size cannot use this kernel at all, and a row of 513 elements pays for 1024.
This is a fair trade and a common one, but it is exactly the kind of constraint that must be shouted rather than buried. If you write a kernel with a precondition like this, say so in the doc comment, as this one does.
Doing the reduction
The kernel uses T::softmax, a builtin that does the whole stable sequence.
Written out, it is:
let row_max = T::max(x, Some(0), true); // reduce
let shifted = x - row_max; // broadcast back
let numerator = T::exp(shifted);
let denominator = T::sum(numerator, Some(0), true); // reduce
let y = numerator / denominator;
Five lines, two of which are reductions. Three things about them:
The axis argument selects what to reduce. Some(0) reduces along
dimension 0; None reduces everything to a scalar.
keep_dims decides the shape of the result. With true, reducing a
[128] tensor gives [1] rather than a scalar — which is what lets x - row_max broadcast back across the row. With false you get the scalar, and the
subtraction will not line up. This is the single most common mistake in a first
reduction.
You do not write the reduction. In CUDA, T::sum would be a shared-memory
tree: each thread writes a partial, barrier, half the threads combine pairs,
barrier, repeat. Here the compiler emits all of that. Chapter 2 promised this
would be the payoff of the block model, and this is it.
Watch the masked lanes
The softmax kernel avoids masks entirely, which sidesteps a trap. Most reduction
kernels cannot, and then the other argument from Chapter 7 becomes essential:
// Summing: masked lanes must be 0, the identity for addition.
let zeros = T::zeros::<D>(&[BLOCK_SIZE]);
let x = T::load(ptr.add_offsets(offs), Some(mask), Some(zeros), &[], None, None, None, false);
let total = T::sum(x, Some(0), true);
If you leave other as None, the masked lanes hold undefined values, and
those undefined values go into the sum. The result is wrong in a way that
depends on whatever was in memory — so it will be right in testing and wrong in
production.
The identity depends on the reduction:
| Reduction | Fill masked lanes with |
|---|---|
sum |
0 |
max |
the most negative representable value |
min |
the most positive representable value |
| product | 1 |
For a maximum, T::full(&[BLOCK], D::from_f64(f64::NEG_INFINITY)).
Running it
The library kernel has tests, including one that runs on a device:
cargo test -p teeny-kernels --features cuda --test test_softmax
There is also a snapshot test that needs teenyc but no GPU, which compiles the
kernel and checks its MLIR — the Chapter 9 pattern.
The backward pass
Softmax has an unusually neat gradient. Given the saved output y and the
upstream gradient dy:
dx_i = y_i * (dy_i - sum_j(y_j * dy_j))
That inner sum is another row-wide reduction, and it is a scalar broadcast back
across the row — the same shape of computation as the forward pass. The library
implements it as softmax_backward in the same file, and Chapter 22 covers how
a backward kernel gets wired to its forward.
Next: the reduction’s opposite problem — a kernel where the arithmetic, not the memory, is the cost.