MegaKittens Internals
How a PyTorch function becomes one persistent Blackwell kernel: tracing, scheduling, the instruction stream, the per-SM interpreter, the six instruction types, and the ThunderKittens primitives underneath. Constants come from the source; the schedule, the JIT source and the barrier tables were produced by running the repository's own tracer and scheduler on tests/test_mlp.py. To step through that MLP stage by stage, from the aten graph to a playable run on 148 SMs, open the MLP walkthrough.
- Target
- sm_100a / sm_103a
- Grid
- 1 CTA per SM, clusters of 2
- CTA
- 384 threads · 12 warps
- Shared memory
- 7 pages × 32 KB
- Tensor memory
- 128 lanes × 512 cols
- Instruction
- 256 bytes · 64 × int32
00The idea
A normal PyTorch program launches one kernel per operator. Each launch waits for the previous one to drain, and every intermediate makes a round trip through HBM with the GPU partly idle at each boundary. MegaKittens replaces the whole graph with a single persistent kernel that runs an interpreter. The host compiles the graph into a flat list of tile-sized instructions: one 512×256 GEMM output tile, two 128×128 add tiles, a page of RMSNorm rows, and so on. Each SM walks its share of that list in order.
Inside an SM, five specialized warp roles pipeline consecutive instructions. While the consumer warps finish instruction i, the loader is already TMA-loading instruction i+1 into the shared-memory pages that i has released. Dependencies between SMs are tracked per tile with integer counters in global memory. A consumer tile starts as soon as the specific producer tiles it reads have been written, without waiting for the whole previous operator.
dynamic=False. The bottom row is the steady-state cost of a call: a cache lookup, one memset, and one launch.01Capture: megakittens.compile
compile() (megakittens/interface.py) is a thin wrapper around TorchDynamo:
torch._dynamo.optimize(
backend=aot_autograd(fw_compiler=_megakittens_backend, bw_compiler=_megakittens_backend),
nopython=True, # any graph break is an error
dynamic=False, # every new shape recompiles
disable=not enable)(fn)
- Dynamo captures the Python function into an FX graph.
nopython=Truemeans the entire function must be capturable, because one graph becomes one kernel. - AOTAutograd turns that into an aten-level FX graph. It functionalizes in-place ops (they reappear as
auto_functionalized_v2nodes), lifts module parameters to placeholders, and flattenslist[Tensor]inputs into individual tensors. The same backend is registered for the backward graph, but no backward instruction types exist, so training graphs fail at op resolution. - The backend (
backend.py) prints the FX table ifverbose, runstrace()and thenschedule(), builds aDispatcher, and returnsmake_boxed_func(dispatcher). Withdry_run=Trueit stops after tracing and returns the original graph. The gpt-fast example's--compile megakittensmode does exactly this (dry_run=True, save_dag=True) to dump the Llama DAG without running it.
| Option | Effect |
|---|---|
save_dag | Writes log/<fn>.NN.json and a Graphviz PNG of the DAG. |
save_schedule | Writes a text table of tensors, barriers and every instruction. |
use_jit_cache | Enables the on-disk cubin cache, keyed by a SHA-256 of the CUDA version, arch, symbol and source. The in-process functools.cache always applies. |
global_work_queue | Switches from a static per-SM queue to dynamic work-stealing with Cluster Launch Control (§07, §09). |
cluster_size | 1 or 2. GEMM and Attention static_assert 2. |
verify, profile | Accepted and forwarded to the backend, which never reads them. |
02Trace: FX graph → DAG
This is the actual aten graph AOTAutograd produced for the test MLP (x = relu(x @ W[i] + b[i]), three layers):
placeholder arg0_1 # W (3, 1024, 1024)
placeholder arg1_1 # x (1024, 1024)
placeholder arg2_1 # b (3, 1024, 1024)
select = aten.select.int(arg0_1, 0, 0)
mm = aten.mm.default(arg1_1, select)
select_1 = aten.select.int(arg2_1, 0, 0)
add = aten.add.Tensor(mm, select_1)
relu = aten.relu.default(add)
select_2 = aten.select.int(arg0_1, 0, 1)
mm_1 = aten.mm.default(relu, select_2)
… # layers 2 and 3 repeat
output = output((relu_2,))
tracer.trace() turns it into a node-centric DAG in five steps:
- Prune.
prune_fx_graphruns a DFS back from the output node and drops dead nodes. It also drops every view node (getitem,aten.select.int,aten.slice.Tensor), because views become properties of edges rather than nodes. - Classify.
placeholderandget_attrbecome input nodes, each carrying aTensorMeta(dtype, shape of at most 4 dims, device; the tensor must be contiguous).call_function,call_methodandcall_modulenodes go throughIType.from_torch. Anauto_functionalized_v2wrapper is first unwrapped back to the mutating op and its real arguments. - Resolve the op. Three global maps (function, method name, module type) are filled by each
ITypesubclass at import time. A map entry is either a plain IType instance or a list of resolvers. A resolver returns an IType, or(IType, aten_output_indices)when only some outputs of a multi-output aten op are kept. For example,_fused_rms_normand cuDNN SDPA keep only output 0. - Fold views into ranges. For each input edge,
resolve_input_nodewalks back through getitem/select/slice and records a slice chain.TensorRange.from_slice_chainreplays the chain into an absolute, left-padded 4-D half-open range on the producer's tensor.W[1]on a (3,1024,1024) tensor becomes((0,1),(1,2),(0,1024),(0,1024))on its padded shape (1,3,1024,1024). Slices with step ≠ 1 are rejected, as are views in front of the output. - Validate.
DAG.validatechecks that each node is exactly one of input/output/compute, that there is exactly one output node, that nodes are in topological order, that edges appear on both endpoints, and that every range fits inside its tensor.
| Torch / aten target | IType |
|---|---|
aten.mm, aten.bmm, aten.matmul, torch.matmul, @ | Gemm |
aten.add/sub/mul/div.Tensor, maximum, minimum, atan2 | ElementwiseBinary(op) |
aten.clone, relu, abs, exp, exp2, log, log2, neg, sqrt, rsqrt, nn.ReLU | ElementwiseUnary(op) |
aten._fused_rms_norm (output 0), nn.RMSNorm | RMSNorm(col_dim) |
aten._scaled_dot_product_cudnn_attention (output 0) | Attention(causal) |
torch.ops.megakittens.<name> (each IType's own custom op) | that IType |
megakittens/tracer.py, megakittens/schema/dag.py, megakittens/schema/tensor.py, megakittens/schema/itype.py
03The IType contract
An instruction type is defined twice: once in Python, which tells the scheduler how to tile and what each tile touches, and once in CUDA, which tells each warp role what to do with one instruction. The scheduler auto-discovers subclasses through IType.__subclasses__(). __init_subclass__ requires a custom op named torch.ops.megakittens.<snake_case_name>, registers it in the dispatch maps, and generates a test_fn.
Python: megakittens/itypes/<name>.py
inputs/outputs: oneTensorSpecper tensor, holding a dtype, a granularity on trailing dims (each dim must be a multiple of it), and the TMA tile types (st/sv) the kernel uses on that tensor.block_indices(...): one tuple of at most 16 ints per instruction. These becomeinstruction.indices.num_instructions(...): the count, computable without building the list.access_regions(index, ...): for one instruction, the boxes it reads in each source tensor and writes in each destination tensor. Barriers are derived from these.validate(...): shape, dtype and range rules.cpp_template, e.g.Gemm<MKConfig, MKGlobals, {tensors}>, pluscpp_include.inplace_mapping: output index → input index for ops that write in place.
CUDA: csrc/itypes/<name>.cuh
controller::lid_release_order(g, s, q): answers "which logical page do I release q-th?" (§10).controller::init_semaphores(g, s): initializes this instruction's dynamic mbarriers and returns how many it used.loader::run: runs on one warp; issues TMA loads and releases unused pages.launcher::run: runs on one warp; issues tcgen05 MMAs and owns the TMEM token.consumer::run: runs on all 8 consumer warps; does the math and the stores.storer::run: runs on one warp; empty in every current type.- Tensor indices are template parameters. A tensor is accessed as
g.template gls<I>().
04Scheduler: DAG → instructions, tensors, barriers
schedule() runs four phases in order. Each phase consumes the previous phase's output. The numbers below are from the MLP above, with M = H = 1024 and 148 SMs (B200).
Phase 1 · count and place
For every compute node in topological order, the scheduler asks the IType for num_instructions and assigns it a contiguous range in the global list. Each range is rounded up to a multiple of cluster_size. This guarantees that instruction pairs (2k, 2k+1) always belong to the same node, which matters because the two CTAs of a cluster run in lockstep (§09).
| Node | IType | Instructions | Why | Offset (layer 1 / 2 / 3) |
|---|---|---|---|---|
| mm | Gemm | 16 | (1024/512) × (1024/256) output tiles × 2 CTAs | 0 / 80 / 160 |
| add | ElementwiseBinary | 32 | 8 row tiles × 8 col tiles ÷ 2 tiles per instruction | 16 / 96 / 176 |
| relu | ElementwiseUnary | 32 | same tiling as add | 48 / 128 / 208 |
Phase 2 · assign tensor slots, reuse memory
Every node output gets an index into tensor_metas; there can be at most 256. A freed buffer goes into a pool keyed by its exact TensorMeta. A new output takes a pooled buffer only when its node starts at least #SMs instructions after the last instruction of every consumer of that buffer. In other words the reader must be a full wave behind, so the reuse barrier (Phase 3) is almost always already satisfied, and every old consumer sits earlier than the reuser in every SM's queue. Three kinds of buffer are never pooled: graph inputs, buffers that feed the graph output, and buffers that some consumer mutates in place. An output with an inplace_mapping aliases its input directly and adds a reuse barrier against the input's other readers.
In the MLP this produces 11 slots. T0 = W, T1 = x and T2 = b are the inputs, and T3–T10 are intermediates. The final ReLU writes into T3, the buffer of layer 1's GEMM output. It qualifies because its node starts at instruction 208, and the last reader of T3 (layer 1's add) ends at 48: 208 − 48 = 160 ≥ 148. Layer 3's add, starting at 176, misses the threshold (176 − 48 = 128) and gets a fresh buffer.
Phase 3 · fine-grained barriers
Barriers are global int32 counters. Every barrier has a target, the number of producer instructions that must arrive before a waiter may proceed. They are derived purely from access_regions:
- Collect each instruction's read and write boxes.
- For each producer → consumer edge, compute a unit cell: for each dimension, the GCD of every box extent on both sides. Every unit cell is mapped to the producer instructions that write it.
- For each consumer instruction, the union of producers over its unit cells is its dependency set. Sets are cached per box list.
- Every distinct (producer node, dependency set) pair becomes one barrier. Its target is the size of the set. Each producer in the set lists the barrier in
dst_barriers, and each consumer with that set lists(barrier, target)insrc_barriers. Consumers with identical dependencies share one barrier. - Reuse barriers are separate: one per reuse event. Every instruction of every old reader arrives, and every instruction of the reusing node waits for the total count before it writes.
- Each instruction may have at most 16 source and 8 destination barriers.
| Edge (MLP layer 1→2) | Producer box | Consumer box | Unit cell | Target | Barriers |
|---|---|---|---|---|---|
| mm → add (T3) | 512 × 256 | 128 × 256 | 128 × 256 | 2 | 8 |
| add → relu (T4) | 128 × 256 | 128 × 256 | 128 × 256 | 1 | 32 |
| relu → mm₂ (T5, operand A) | 128 × 256 | 512 × 1024 (full K) | 128 × 256 | 16 | 2 |
| reuse of T3 by the last relu | all 48 instructions of mm and add (layer 1) | 48 | 1 | ||
The GEMM target is 2 because the CTA-pair instructions are duplicates with identical write boxes: both CTAs must finish their half. The totals are 42 + 42 + 40 input barriers plus 1 reuse barrier, which gives the scheduler's reported 125. Layer 2's GEMM row block 0 (instructions 80–87) waits only on the 16 ReLU instructions that produce rows 0–511. It can start while layer 1 is still computing rows 512–1023.
Phase 4 · emit
Each node is validated and assigned an icode, one per unique (itype, src tensor indices, dst tensor indices). Icode 0 is reserved for Noop. Because tensor indices are compile-time template arguments, the three structurally identical MLP layers become nine distinct icodes, and the kernel instantiates nine instruction bodies. The instruction list is then built and padded with Noops at node boundaries to the cluster size.
megakittens/scheduler.py
05The real schedule, SM by SM
Below is the schedule the repository produced for the MLP, laid out as the GPU sees it in the default per-SM mode. Instruction i goes to SM i % 148 in iteration i // 148. Each column pair is one cluster. 240 instructions fill 1.62 waves, and the remaining 56 slots of the second wave are zero-filled, which decodes as icode 0, a Noop. Click any cell to decode it. The instructions it waits on are marked above their cells and the ones that wait on it below.
06The 256-byte instruction
struct instruction_t (csrc/schema.cuh) is exactly 256 bytes, enforced by a static_assert. Python packs it into 64 int32s with _pack_instruction, and the controller warp copies it with two coalesced loads per lane. The grid below shows the 64 words of the instruction selected in §05; select a field to see where it lives.
07Dispatcher, JIT and launch
First call: materialize
- Validate the inputs against their
TensorMetas, then allocate every non-input slot withtorch.empty. These buffers belong to the dispatcher, and the returned outputs are those same buffers on every call. - Pack the instructions. In per-SM mode they go into an
int32tensor of shape[⌈N/#SMs⌉, #SMs, 64]. In global mode the shape is[N, 64]. The barrier array iszeros(num_barriers). - Build a
gldescriptor per tensor. The Pythonglinjit/pykittens.pymirrors ThunderKittens'kittens::glbyte for byte: raw pointer, asize_tfor each runtime dim, then 64-byte-aligned 128-byteCUtensorMaps. For each tensor it collects the union of TMA tile types from every IType that touches it. In the MLP, T3 needs both GEMM's 128×32 epilogue box and the add's 128×128 box. Descriptors are encoded on the host withcuTensorMapEncodeTiled; swizzled tiles use the same 5-D box TK uses. - All
gls are packed into one C struct (pack_structapplies C alignment) and passed as a single__grid_constant__kernel parameter, about 3.5 KB for the MLP. The TMA descriptors therefore live in kernel-parameter space, where TMA can read them. The packed bytes are cached per tuple ofdata_ptrs.
Generated source
This is what Dispatcher._compile_kernel emitted for the MLP, captured by intercepting the NVRTC call (indentation tidied):
NVRTC compiles it with --std=c++20 --use_fast_math -DNDEBUG -lineinfo -DKITTENS_NO_HOST -DKITTENS_BLACKWELL --gpu-architecture=sm_100a, plus include paths for csrc, TK include and prototype, and CUDA/CCCL. The cubin is loaded with cuModuleLoadData, the mangled name of megakittens::kernel<MKConfig, MKGlobals> is resolved, and MAX_DYNAMIC_SHARED_SIZE_BYTES is set to 230,400.
Every call: launch
barrier_tensor.zero_()runs as a separate small kernel on the current stream.cuLaunchKernelExuses 384 threads per block and 230,400 bytes of dynamic shared memory. The grid is #SMs in per-SM mode, or ⌈N/2⌉·2 in global mode (one CTA per instruction, most of which never launch; see below). Cluster dimension 2 is set both as preferred and required.PROGRAMMATIC_STREAM_SERIALIZATIONenables PDL, so the kernel's prologue overlaps the previous kernel's tail, andgriddepcontrol.waitruns after semaphore init.- Global-work-queue mode uses Blackwell Cluster Launch Control. A CTA starts at instruction
blockIdx.x. For each next instruction, CTA rank 0 issuesclusterlaunchcontrol.try_cancel, which cancels a not-yet-launched cluster and multicasts its first block id to both CTAs. The pair adopts that cluster's instructions (x + cta_rank). When cancellation fails, the grid is exhausted and the CTA stops. This gives dynamic load balancing with the same instruction format.
megakittens/dispatcher.py, megakittens/jit/{pykittens,nvrtc_jit,cuda_utils,c_utils}.py
08Inside one CTA
megakittens::kernel (csrc/megakittens.cuh) sets everything up once and then splits the 12 warps into roles that each loop forever over instructions.
tcgen05.alloc by warp 0) once at kernel start and is then sliced by address arithmetic for each instruction. The dashed line is the controller fetching instructions.Prologue, in order
- Static shared memory. This holds
instruction_state[2](each is the 256-byte instruction, a 7-entrypid_orderpadded to 128 bytes, 32 dynamic semaphores and 128 bytes of scratch), two CLC handles, and the mbarriersclc_arrived[2],instruction_arrived[2],instruction_finished[2],page_finished[7]andtensor_finished. - Dynamic shared memory is rounded up to a 1024-byte boundary (needed by the 128-byte swizzle) and viewed as
page_t pages[7]. - TMEM. The
tensor_allocator<1, CLUSTER_SIZE>constructor has warp 0 runtcgen05.allocfor all 512 columns and relinquish the allocation permit. Its destructor does a cluster barrier and thentcgen05.dealloc. - Semaphores are initialized by distinct threads.
instruction_arrivedgets count =CLUSTER_SIZE,instruction_finishedgets 11 (every warp except the controller), andclc_arrivedgets 1.page_finished[p]andtensor_finishedget count 1 and are immediately arrived once, so the first instruction finds every page and the TMEM token free. - Cluster barrier (
barrier.cluster.arrive/wait), or__syncthreadswithout clusters. Thengriddepcontrol.waitfor PDL. - Role split. Warps 0–7 raise their register limit to 224 and enter
consumer_loop. Warpgroup 2 lowers to 56 and dispatches on its warp index: 0 = controller, 1 = loader, 2 = launcher, 3 = storer. - On exit, a final cluster barrier, so neither CTA's shared memory or TMEM goes away while its partner still addresses it.
| Constant | Formula | Value |
|---|---|---|
| MAX_SHARED_MEMORY | TK, Hopper/Blackwell | 232,448 |
| STATIC_SHARED_MEMORY_BASE | 512 + 2 × (256 + 128 + 32 × 8) | 1,792 |
| NUM_PAGES | ⌊(232,448 − 1,792 − 1,024) / 32,768⌋ | 7 |
| DYNAMIC_SHARED_MEMORY | 7 × 32,768 + 1,024 | 230,400 |
| SCRATCH_BYTES (per stage) | (232,448 − 1,792 − 230,400) / 2 | 128 |
09The instruction pipeline
Two stages (slots) hold decoded instructions, so at most two instructions are in flight per CTA. The controller produces into the stages and the other 11 warps consume from them. Because both CTAs of a cluster arrive on each other's barrier, the pair always works on the same instruction index.
tensor_commit<2> emits tcgen05.commit.cta_group::2…multicast::cluster [instruction_arrived], 0b11. With no tensor-core work outstanding it simply arrives on the same-offset mbarrier in both CTAs. Since the barrier needs two arrivals, neither CTA's workers start instruction k until both controllers have staged it. The code calls this a hack: it is the cheapest available cross-CTA arrive that needs no address mapping.Controller loop (warp 8), per iteration
- Recycle the slot. From iteration 2 on, wait on
instruction_finished[stage]with parity((iter−2)/2)&1. All 11 workers have then finished the instruction that last used this slot. Its dynamic semaphores are invalidated withmbarrier.inval, one lane each. - Fetch. In per-SM mode, if
iter ≥ depth(the number of waves), write icode −1, broadcast it and exit. Otherwise copyinstructions[iter][blockIdx.x][0..63]with lane l loading words l and l+32. In global mode, the first fetch usesblockIdx.xand later fetches use CLC as described in §07. - Rename pages. Lane l computes
pid_order[l] = prev.pid_order[prev.lid_release_order(l)], calling the previous instruction's function (§10). The very first instruction gets the identity order. - Init semaphores. Dispatch to the new instruction's
init_semaphores. Each lane initializes one entry, and the count is remembered for the later invalidation. Thenfence.proxy.async.shared::ctamakes the inits visible to the TMA and tensor-core async proxy. - Publish.
warp::sync, then one elected lane runstensor_commit<CLUSTER_SIZE>(instruction_arrived[stage]). Advance the stage.
Worker loop (every other warp)
for (iter = 0, stage = 0; ; ++iter) {
wait(instruction_arrived[stage], (iter / 2) & 1);
icode = instruction_states[stage].instruction.icode;
if (icode == -1) break;
dispatch_instruction<WorkerType::ROLE>(icode, g, s); // JIT switch → Op::ROLE::run
warp::sync();
if (warp::elect_leader()) arrive(instruction_finished[stage]);
stage ^= 1;
}
Roles drift apart freely within the two-slot window. The loader of instruction i+1 can run while consumers are still in i; what bounds that is the page and TMEM tokens (§10, §11) and the global barriers.
csrc/controller.cuh, csrc/workers.cuh, csrc/utils.cuh (dispatch_instruction)
10Pages and page renaming
The seven 32 KB pages are passed between consecutive instructions as tokens. Two rules make this work:
- Every instruction waits on and finishes every physical page exactly once.
page_wait(pid)iswait(page_finished[pid], iter&1)andpage_finish(pid)is one arrive. Because the count is 1 and each page was pre-arrived at startup, a page's phase advances exactly once per instruction. Pages an instruction does not need are waited on and released at once by a spare lane of the loader warp. - Instructions address logical pages (lids), not physical ones (pids). Each IType puts the data it needs first in its lowest lids. Through
lid_release_order(q), the previous instruction tells the controller which of its lids it releases q-th. The controller then maps new lid q to that page:new.pid_order[q] = old.pid_order[old.release(q)]. The next instruction's first loads therefore land in the pages that free up first.
The simulator runs the real lid_release_order functions. Add instructions to see how physical pages (by colour) move between logical slots. GEMM's order depends on (K/64) mod 4, because that decides which stage's pages its loader frees last.
csrc/schema.cuh (page_wait / page_finish), controller.cuh step 2, each itype's controller::lid_release_order
11The TMEM token and global barriers
TMEM token
tensor_finished works like a page token for the whole of tensor memory. Each instruction arrives on it exactly once (tensor_finish). A launcher that is about to write TMEM first calls tensor_wait with parity iter&1. Types that never touch TMEM (Noop, the elementwise ops, RMSNorm) have the launcher pass the token straight through. GEMM's consumers return it as soon as the accumulator has been copied to registers, before the epilogue stores. The next GEMM's MMAs can therefore overwrite TMEM while the current one is still writing results to HBM. Attention returns it only at the very end.
Global barriers between SMs
int32 tensor that is zeroed before every launch. The spin has no back-off: SPIN_LOOP_SLEEP_NS and nanosleep() are defined but never called.all_input_barrier_waitruns in the loader, before any load from a dependent tensor.all_reuse_barrier_waitruns in the consumer, right before the first store (WAR protection for reused or in-place buffers).all_barrier_arriveruns afterstore_async_wait(), when this instruction's writes have completed. It is issued by one elected thread.- Why this cannot deadlock (per-SM mode): every dependency points to a lower global index, and instruction i sits at iteration
i // #SMson SMi % #SMs. A producer is therefore always earlier in its own SM's queue than any consumer that could be blocking that SM. Reuse barriers are additionally a full wave back (Phase 2).
csrc/utils.cuh (barrier_wait / barrier_arrive), csrc/schema.cuh (tensor_wait / tensor_finish)
12The six instruction types
| Type | Work per instruction | Pages | Loader | Launcher | Consumers (8 warps) |
|---|---|---|---|---|---|
| Noop | nothing | 0 | passes all 7 page tokens | passes TMEM token | — |
| ElementwiseUnary | 1–2 tiles of 128×128 bf16 along columns; op chain fused | ≤ 2 | wait inputs, TMA each tile, free the rest | passes token | group<8>::load → op chain → store in place → TMA store |
| ElementwiseBinary | 1–2 tiles; A and B pages per tile | ≤ 4 | one mbarrier per tile expecting 2 × 32 KB | passes token | load both, apply op into A, free B early, store from A |
| RMSNorm(N) | ⌊32 KB / round₁₂₈(2N)⌋ rows, e.g. 4 rows at N = 4096 | 1 | one sv TMA per row | passes token | warp per row slice: Σx² → shuffle reduce → rstd · w → TMA store |
| Gemm | a 512×256 output tile per CTA pair | 6 | 4-stage K ring, cluster TMA | CTA 0: 2-CTA tcgen05.mma | TMEM → regs → bf16 → 2-slot smem → TMA |
| Attention | 512 query rows × one head per CTA pair | 6 | Q once, K/V 4-stage ring | CTA 0: QKᵀ and PV MMAs | 2 softmax warpgroups, online softmax in TMEM |
ElementwiseUnary and ElementwiseBinary
- A tile is
st<bf16,128,128>, exactly one 32 KB page. Each of the 8 consumer warps owns 16 rows as anrt_bf<16,128>, loaded withldmatrix.x4. - The unary op list is a template parameter pack applied with a fold expression, so
("abs","log","neg")is one pass over registers. NEG, SQRT and RSQRT usebf16_2intrinsics. The others use TK's register maps. - Binary is designed for N ops and N+1 inputs (
BinaryOps<...>, index layout4·NUM_INPUTS + 1), but the Python side currently capsMAX_OPS = 1. Inputs must have equal effective shapes; there is no broadcasting. That is why the MLP test uses a bias of shape (L, M, H). - Index layout (binary):
[a.b, a.d, a.row_tile, a.col_tile, b.b, b.d, b.row_tile, b.col_tile, d.b, d.d, d.row_tile, d.col_tile, n_tiles]. - Release order: unused pages first, then B pages, then the A/output pages, which are freed only after their TMA stores complete.
RMSNorm
- Each row is an
sv_bf<N>, padded to 128 bytes, and rows are packed into page lid 0. The weight is read directly from global memory (w_gl.raw_ptr, L2-resident) with no TMA. - Rows are split across 8 warps,
⌈rows/8⌉each, so with 4 rows per instruction half the warps idle. Each lane reads 4 bf16 (8 bytes) at a time with a stride of 128 elements, and__shfl_xor_syncreduces across the warp. EPSis a C++ constant,1e-6f. Theepscaptured from the graph is not passed to the kernel.- Requirements: N must be a multiple of 64, at most 256 or with 2N divisible by 128, and one row must fit in a page.
csrc/itypes/{noop,elementwise_unary,elementwise_binary,rmsnorm}.cuh and the matching megakittens/itypes/*.py
13GEMM in depth
A cluster of two CTAs computes a 512×256 tile of D = A·B (bf16 in, fp32 accumulate, bf16 out). The Python tile is TILE_M = 512. The C++ per-CTA constants are Mb = 256, Nb = 256, Kb = 64, with 2 consumer warpgroups (cid), a 4-stage load ring and an 8-step epilogue.
.cta_group::2 TMA, which is why only CTA 0's launcher waits and issues.- Loader (both CTAs, one lane). Wait on the input barriers. For iterations
i = 0 … K/64 + 3: on the first pass through the ring, claim the stage's A page and, for even stages, its B page. After that, wait oninputs_finished[stage]until the MMA has consumed the stage. Whilei < K/64, issue two A loads (one per cid) and one B load withtma::cluster::load_async(…, mask = 1<<rank, dst_mbar_cta = 0). In the four drain iterations, release each finished stage's pages, except A stage 0, which the epilogue reuses. A second lane releases lid 6 immediately. - Launcher (CTA 0 only). Take the TMEM token, then for each K step
expect_bytes(inputs_arrived[stage], 96 KB), wait, and issuemm2_AB(first step, overwrite) ormma2_AB(accumulate) for each cid. Each MMA commits toinputs_finished[stage], which has count 2 and is multicast to both CTAs, so both loaders learn the stage is free. Finallytensor_commit<2>(outputs_arrived). - Consumers (both CTAs, warpgroup = cid). Wait on
outputs_arrived. Eightwarpgroup::load_asynccalls pull the 128×256 accumulator as eight 128×32 chunks intort_bf<32,32>registers (fp32 → bf16). Thentensor_load_wait, the reuse-barrier wait, and an earlytensor_finish. The epilogue loops 8 times:store_async_read_wait<1>frees one of two 8 KB slots, the warpgroup stores registers to the slot, and a TMA store withEVICT_FIRSTwrites it. Finally wait for all stores, release lid 0, and arrive on the global barriers. - Tile order.
_swizzled_tile_ordervisits output tiles in supergroups of 8 column tiles, with serpentine rows, to improve L2 reuse of A and B.
csrc/itypes/gemm.cuh, megakittens/itypes/gemm.py
14Attention in depth
This is FlashAttention-style forward attention on BSHD tensors with head_dim = 128, using a 2-CTA cluster. One instruction pair covers 512 query rows of one (batch, head). Each CTA owns two 128-row Q tiles, one per softmax warpgroup (qid). K and V are streamed in 128-key steps. The softmax scale is fixed at 1/√128.
tcgen05.mma reads it as its A operand. That leaves the 7 shared-memory pages for Q, the K/V ring and O staging.Roles
- Loader (both CTAs). Load the two Q tiles for this CTA with
axis = DEPTH, so the tile's 128 rows run along the sequence dimension of a BSHD tensor without a transpose. Then for each key step, load one K tile (64 keys: this CTA's half of 128) and one V tile (128 keys × this CTA's 64 head-dim columns). Both go into a shared 4-stage ring over lids 2–3 (16 KB per stage), gated bytma::cluster::wait(kv_finished)starting from phase 1. - Launcher (CTA 0). Wait for Q from both CTAs, then issue the first QKᵀ for both qids. In the steady state, for each qid: wait for P quarter 0, issue PV quarter 0, then quarters 1–3 as their signals arrive (the last one commits
kv_finishedfor V). For qid 0 it also waits for the next K tile, then issues the next QKᵀ and commitsscores_arrived. After the last step, the final PV commitstile_arrived. - Consumers (two softmax warpgroups, both CTAs). Each thread owns one query row and loads the 128 fp32 scores with four
tcgen05.ld.32x32b.x32. The row max is a tree of 3-inputmax.f32.- Lazy rescale (as in FA4): if the new max exceeds the old by no more than 8 in log2-scaled units, the old max is kept and O is left alone. Otherwise O is rescaled in TMEM in 16-column chunks (
tcgen05.ld → __fmul2_rn → tcgen05.st), and only when some lane in the warp needs it. p = exp2(s·log2e/√d − m·log2e/√d)via__ffma2_rn, converted to bf16 and stored as P in four quarters. After each quarter,warp::tma::cluster::arrivesignals CTA 0. The row sum is accumulated with__fadd2_rn.
- Lazy rescale (as in FA4): if the new max exceeds the old by no more than 8 in log2-scaled units, the old max is kept and O is left alone. Otherwise O is rescaled in TMEM in 16-column chunks (
- Epilogue. Release the Q page, compute
rcp.approxof the row sum (guarded against 0/NaN), and claim the O page. Then load O from TMEM, multiply, convert, andst.sharedit with TK's 128-byte swizzle written out by hand:addr ^ (((addr & 0x380) >> 7) << 4), into two 64-column panels. Wait on the reuse barrier, TMA store, and finally release the K/V and O pages, return the TMEM token, and arrive on the global barriers. - Causal. The number of key steps becomes
4·m + 4, enough to reach the diagonal of the cluster's last tile. Steps before a warpgroup's own tile are unmasked. The diagonal step maskscol > rowusing the warpgroup thread index as the row; later steps are fully masked. On the host, instructions are emitted in LPT order (largestmfirst) with an L2 swizzle that groups heads so their K/V fit in about 50 MB of L2.
csrc/itypes/attention.cuh, megakittens/itypes/attention.py
15The ThunderKittens layer
ThunderKittens (TK, csrc/ThunderKittens/include, header-only) supplies the types and the PTX. MegaKittens uses about a dozen of its constructs. They are compiled under NVRTC with KITTENS_NO_HOST, which swaps the STL for cuda::std and stubs CUtensorMap as a 64-byte-aligned 128-byte blob. Everything TK would do on the host, such as building TMA descriptors, is re-implemented in pykittens.py.
ldmatrix load or a TMEM read).Execution scopes
kittens::warp = group<1>andkittens::warpgroup = group<4>. MegaKittens addsgroup<8>asconsumer_group.group<N>::laneid()is the thread index within the group (0…32N−1).warpid()is the warp within the group, andgroupid()isthreadIdx.x / 32N, which gives MegaKittens'cid/qid.group<N>::sync(id)emitsbar.sync id, 32N. Consumers use ids 1 and 4 across 256 threads, and ids 1 and 2 across 128 threads per warpgroup.elect_leader()emitselect.sync, so exactly one lane is true. The loader usesif (elect_leader()) {…} else if (elect_leader_from_active()) {…}. The second branch elects one of the other 31 lanes throughactivemask, so one lane issues TMA while another releases unused pages, concurrently.warpgroup::increase/decrease_registers<n>emitssetmaxnreg.inc/dec.sync.aligned.u32 n.
Semaphores (mbarriers)
init_semaphore(s, n)→mbarrier.init … n.arrive→mbarrier.arrive.release.cta.invalidate_semaphore→mbarrier.inval.wait(s, p)spins onmbarrier.try_wait.parity p, which succeeds once the phase with parity p has completed. On a fresh barrier, waiting with p = 1 returns immediately. Producers start at phase 1, as in attention'skv_phase = 1; tokens that start free are instead pre-arrived once.tma::expect_bytes(s, n)→mbarrier.arrive.expect_tx. It arrives once and adds n bytes to the transaction count, and TMA completion subtracts them. Every TMA barrier is therefore initialized with count 1.- Cluster variants map a CTA-local address into the peer CTA with
mapa.shared::cluster:tma::cluster::arrive(sem, cta)performs a remote arrive, andtma::cluster::waituses.acquire.cluster. tensor_commit<2>/detail::tcgen05::commit<2>→tcgen05.commit.cta_group::2.mbarrier::arrive::one.shared::cluster.multicast::cluster [sem], 0b11. This arrives on both CTAs' copies once the issuing thread's earlier MMAs finish.
Tiles, layouts, TMA
st<bf16,R,C>is stored as column panels of 64 elements (128 bytes) × R rows, XOR-swizzled withaddr ^ (((addr % 1024) >> 7) << 4). That is why pages are 1024-byte aligned and every in-page offset is a multiple of 1 KB. The swizzle width is picked fromC: 128 B for C ∈ {64, 128}, but 64 B for GEMM'sst_bf<128,32>epilogue tile. The Pythonstapplies the same rule.sv<T,N>is padded to 128 bytes. A TMA on a vector is split into ⌈N/≤256⌉ 4-D boxes.glisraw_ptr, then asize_tper runtime dim (or a 1-byte empty struct for compile-time dims), then a recursivedescriptor_dictofCUtensorMaps looked up at compile time by(tile type, axis). A missing pair is astatic_assert.- Coordinates are in tile units:
{b, d, r, c}where the axis named by the template'saxisis multiplied by the tile's rows. Attention usesaxis = DEPTHto tile along the sequence of BSHD. - Loads emit
cp.async.bulk.tensor.5d.shared::cluster.global.tile.mbarrier::complete_tx::bytes. The cluster form adds.cta_group::2.multicast::clusterwith the CTA's own bit as mask and CTA 0's mbarrier (viamapa). It does not multicast data; it lets CTA 0 count both CTAs' bytes. Stores emitfence.proxy.async+cp.async.bulk.tensor…bulk_group+commit_group.store_async_waitiscp.async.bulk.wait_group 0, andstore_async_read_wait<1>waits only until shared memory may be reused. group<8>::load(rt_bf<16,128>, st<128,128>)usesldmatrix.sync.aligned.m8n8.x4. Warps are interleaved over row slabs (warp w of warpgroup g gets rows 32·w + 16·g) to match TMEM lane quarters.
Tensor memory and tcgen05
tt<T,R,C>is just a 32-bit TMEM address(lane << 16) | column. 16-bit types pack two per column.tensor_allocator::allocate<tt>(col)is pure address arithmetic; no per-instruction allocation happens.mm2_AB/mma2_AB/mm2_ABtissuetcgen05.mma.cta_group::2.kind::f16once per K/16 slice, with shared-memory matrix descriptors (128 B swizzle, SBO = 1024) and an instruction descriptor encoding M, N, dtypes and transposes.mm*clears the accumulator on the first slice, andmma*accumulates. The PV product uses the TS form, where A comes from TMEM.warpgroup::load_async(rt, tt)usestcgen05.ld.sync.aligned.16x256b. Attention bypasses it with rawtcgen05.ld/st.32x32b, where lane i of warp w%4 is TMEM lane 32·(w%4) + i, i.e. one thread per row.tensor_load_wait/tensor_store_waitemittcgen05.wait::ld/st.
Clusters, CLC, PDL
everyone::tma::cluster::sync()→barrier.cluster.arrive.release; barrier.cluster.wait.acquire, at kernel start and end.clc::schedule→clusterlaunchcontrol.try_cancel.async…multicast::cluster::all.b128, andclc::query→query_cancel.is_canceled+get_first_ctaid.pdl::wait()→griddepcontrol.wait.launch_dependentsis never called, so dependents start when blocks exit.
TK files: common/util.cuh, ops/group/group.cuh, ops/thread/util/{sync,tma,util}.cuh, ops/thread/memory/tile/tma.cuh, ops/thread/mma/tcgen05.cuh, types/{shared/st,shared/sv,global/gl,tensor/tt,tensor/tensor}.cuh
16Limits and sharp edges
By design, today
- Static shapes only (
dynamic=False), whole-graph capture only (nopython=True), and at least one tensor output. No backward pass. - Contiguous tensors of at most 4 dims, slices with step 1, at most 256 tensors, 16 inputs and 8 outputs per node, 16 indices per instruction, 16 source and 8 destination barriers per instruction.
- Every IType is bf16-only. GEMM needs M % 512, N % 256 and K % 64 to be 0, with full-K ranges. Attention needs head_dim 128, sequence length % 512, BSHD layout, and a fixed scale. Binary ops do not broadcast.
- The dispatcher owns the output buffers, so a second call overwrites the tensors returned by the first.
- Each (op, tensor-index) combination is a separate icode and a separate template instantiation, so code size and compile time grow with graph depth even when the layers are identical.
Observed in the code
RMSNormhard-codesEPS = 1e-6fand ignores the tracedeps.- Barrier waits spin without
nanosleep(the helper exists but is unused). The storer warp has no work in any type. - The README points to
csrc/itypes/add.cuh, which does not exist; ElementwiseBinary replaced it. gemm.cuh's comment saysd_st_tis 128×64; the type is 128×32.
Worth verifying (not confirmed either way)
- TMEM handoff across the CTA pair. CTA 0's launcher waits only on CTA 0's
tensor_finishedbefore issuing 2-CTA MMAs that also write CTA 1's TMEM. I found no explicit wait on CTA 1's consumers having finished reading the previous accumulator. In practice the next MMA also waits for CTA 1's next loads, but that is timing, not a guarantee. - No
tcgen05.fence::before/after_thread_syncaround the mbarrier-based TMEM handoffs, and the ordering of the controller's plain shared-memory writes relative to thetcgen05.commit"broadcast". Both are worth checking against the PTX memory model. barrier_arriveisred.relaxedaftercp.async.bulk.wait_group 0, with no explicit release fence. The consumer side does usefence.acquire.gpu.
17Source map
| File | Lines | Role |
|---|---|---|
megakittens/interface.py | 51 | compile(): Dynamo + AOTAutograd wrapper |
megakittens/backend.py | 86 | trace → schedule → Dispatcher |
megakittens/tracer.py | 445 | FX → DAG, view folding |
megakittens/scheduler.py | 418 | counts, tensor reuse, barriers, icodes |
megakittens/dispatcher.py | 392 | buffers, instruction packing, JIT source, launch |
megakittens/schema/*.py | 574 | DAG, TensorMeta/Spec/Range, IType base, Instruction, DType |
megakittens/jit/*.py | 633 | Python mirrors of st/sv/gl, NVRTC, CUDA driver, arg packing |
megakittens/itypes/*.py | 1,155 | Python halves of the six types |
csrc/megakittens.cuh | 82 | kernel entry, prologue, role split |
csrc/schema.cuh | 156 | instruction_t, default_config, state_t, pages |
csrc/controller.cuh · workers.cuh · utils.cuh | 196 | controller loop, worker loops, dispatch, global barriers |
csrc/itypes/*.cuh | 1,487 | CUDA halves (attention alone is 721) |
tests/ | 1,281 | per-IType tests (both queue modes), MLP, in-place, no-cluster, JIT |
examples/gpt-fast/ | 1,081 | Llama decode harness; megakittens mode only dumps the DAG |