teenygrad kernels / Real Patterns
Atomics
Every kernel so far has had a simple guarantee: each program writes to memory that no other program writes to. Programs never disagree, because they never touch the same address.
Some problems cannot be written that way. This chapter is about the tool for those, and about why it is the last tool to reach for.
The problem
Suppose several programs each need to add something to the same counter.
program A: read count (5) ... add 1 ... write 6
program B: read count (5) ... add 1 ... write 6
Both read 5. Both write 6. One increment vanished. This is a race, and on a GPU with thousands of programs in flight it is not a rare edge case — it is what happens.
The read, the modify and the write have to be one indivisible step. That is what an atomic is.
T::atomic_add(ptr.add_offsets(offsets), values, None, None, None);
Where they are genuinely needed
Scatter. When the output index is computed from data rather than from
program_id, two programs can land on the same place and you cannot prove
otherwise. Negative log-likelihood loss is exactly this: the gradient goes to
the target class, and the target comes from a label tensor.
The library’s kernel does it in two steps — a normal masked store for the bulk of the row, then an atomic for the one data-dependent position:
// Step 2: subtract dy at target position via atomic_add(-dy)
let base: T::Tensor<i32> = T::full::<i32>(&[1], row_base);
let flat_off: T::Tensor<i32> = base + tgt;
let neg_dy = T::full(&[1], -1.0_f32) * dy;
T::atomic_add(dx_ptr.add_offsets(flat_off), neg_dy, None, None, None);
Gradient accumulation in a backward pass. If a value was read by many
outputs in the forward pass, its gradient is the sum of many contributions.
Convolution backward is the standard case, and several of this tree’s conv
and pad backward kernels use atomics for exactly this.
Histograms and counters. Bin from data, increment. The definition of the problem is a race.
The full set
| Method | Operation | Dtypes |
|---|---|---|
atomic_add |
*p += v |
numeric |
atomic_max, atomic_min |
*p = max/min(*p, v) |
numeric |
atomic_and, atomic_or, atomic_xor |
bitwise | integer |
atomic_xchg |
*p = v, returns old |
any |
atomic_cas |
compare and swap | any |
All return the previous value, which is what makes atomic_add usable as a
“claim me a slot” primitive: the value you get back is your index.
All except atomic_cas take a mask, so only the lanes you want participate.
Ordering and scope
The last two arguments control how strongly the operation is ordered against everything else.
MemSem — memory semantics:
| Value | Meaning |
|---|---|
Relaxed |
Atomic, but no ordering guarantee about anything else |
Acquire |
Later operations cannot move before this one |
Release |
Earlier operations cannot move after this one |
AcqRel |
Both. The default |
MemScope — who has to agree:
| Value | Meaning |
|---|---|
Cta |
Only programs in this block |
Gpu |
All programs on this device. The default |
Sys |
The whole system, including the host |
Passing None for both gives AcqRel and Gpu, which is correct and is what
every kernel in this tree does. Weakening them is a real optimisation — a
Relaxed counter is cheaper than an AcqRel one — but it is the kind of change
to make when you are measuring and can explain why it is safe.
What they cost
Contention. When many programs hit the same address, the hardware serialises them. A histogram where 90% of values land in one bin runs at roughly the speed of one program. The fix is usually to reduce first and then use one atomic per program rather than one per lane.
Non-determinism. Floating-point addition is not associative, so atomics arriving in a different order give a different sum in the last bits. Run the same kernel twice on the same input and you may get answers that differ by an ulp.
That is worth stating clearly because of what it does to your tests. A backward pass using atomics is not bit-reproducible, so exact-equality assertions will fail intermittently. Compare with a tolerance, as the tests in this tree do.
The alternatives, first
Before an atomic, check these:
Can you change who owns the output? Often a race exists because the kernel is parallelised over inputs. Parallelise over outputs instead — one program owning each output element — and the race disappears. This is the single most common fix.
Can you use two kernels? Partial results per program, then a second kernel to combine them. More memory and another launch, but deterministic and usually faster under contention.
Can you reduce within the block first? If all 128 lanes are adding to the
same place, T::sum them and do one atomic instead of 128.
The reasonable default is: parallelise over outputs, and use atomics only when the output index genuinely comes from the data.
Next: making one kernel serve several dtypes.