spec → model Chapter 1. Everything Is a Reduction

spec⟶model

Or, How I Learned to Stop Interpreting
and Write the Program Down
for the llmopt project
Second printing, October 2026

Abstract

There is a model. It has weights. You would like it to produce words. The usual approach is to load a very large framework that can run any model, and then ask it to run yours. Our approach is to notice that you only have the one model, and to write a program that runs exactly that one, and nothing else, as fast as the hardware will let it. This is called compiling. People have been doing it since the 1950s. We are just doing it again, with tensors.1

Chapter 1

Everything Is a Reduction

Plate I. The pile of numbers, in layers. A vector goes in at the bottom; eventually a word comes out the top.

The way a language model works, roughly, is that you have a big pile of numbers, and you multiply a vector by some of the numbers, and then by some more of the numbers, and you do this a few dozen times, and then a word comes out. Then you do it again for the next word.

And you might think: well, if I know exactly which numbers are in the pile, and I know exactly which multiplications happen in which order, couldn't I just write that program down? Like, the actual program, the one that does those multiplications and no others? And the answer is yes, obviously, and also that is the whole idea of this book. It is a compiler. You give it a description of a model, and it gives you back a directory of kernels, a driver and a Makefile, and you type make and you get ./model, and ./model decodes.

I want to be careful here, because people get very excited about this sort of thing, and then they get disappointed. The compiler does not make your model smarter. It does not make the pile of numbers smaller.2 What it does is remove everything between you and the multiplications that is not a multiplication. That turns out to be a lot.

Definition 1.1 (Compiler, the boring version). A compiler from S to T, written in I, is a program in I that turns every valid S program into a T program that does the same thing, and turns every invalid one into an error message with a line number. We draw it as a T, because in 1961 someone did and it stuck [Bra61].

1.1Five tools in a trench coat

The compiler is not one program. It is five programs standing on each other's shoulders, and each of them reads a text file and writes a text file. This is either a deeply principled architectural decision or what happens when you like grep a lot. It is both. You can stop the chain at any arrow: ./compile --emit-bir, --emit-tir or --emit-kir writes that stage and goes home. The front end, spec, reads your description and flattens it. The middle, tir, turns that into a graph of tensor operations and tidies it up. kir decides how that graph becomes kernels on one particular piece of hardware. And emit writes the kernels out as source code, which some other compiler, written by people with much larger budgets, finishes off.

Strictly, the coat holds more than five. emit is one binary, but it's really a set of back ends: one that writes C for a Mac, one that writes CUDA, one that writes HIP. Which one you get depends on a sixth input that isn't a program at all: the target, a small file naming the device (m2_max, ada, mi300x, …). kir reads it to decide the kernels; emit reads it to decide the language.

Figure 1.1: The pipeline, drawn the way compiler textbooks have drawn pipelines since before anyone reading this was born. The first three Ts are one compiler each. emit is one program but really a deck of back ends, one per kernel language, and behind each sits the vendor's own compiler, which is not our problem, in a good way. The .target file is the extra input: it tells kir which chip to schedule for and emit which card to pull from the deck.
Plate II. Each stage leaves a file behind, like a responsible adult leaving a paper trail.

The nice thing about putting a text file between every stage is that when something goes wrong, there is a place to stand and look at it. “The output is wrong” becomes “the .tir is fine and the .kir is wrong,” which is a much smaller and more pleasant problem. You can also edit the files by hand. You should not do this. But you can, and knowing that you can is calming.3

Table 1.1: The intermediate languages, from most opinions to fewest.
FormWritten byOne line isLines†
.specyoua declaration, field or binding—
.birspeca block, step or bind68
.tirtira tensor or a builtin node90
.kirkira buffer, map or reduce223
project/emitkernels, driver, Makefile—

1.2You describe the model; you do not program it

Here is what a model looks like going in. You say how big things are. You say what the layers are. You say “do this twice” and the front end does it twice. And then, importantly, you say where every single weight comes from in the checkpoint, because the compiler is not going to go rummaging through a 30-gigabyte file hoping something has the right name.

Listing 1.1: specs/moonlight_mini.spec, lightly abridged. A mixture of experts, at a size you can debug.
-- Moonlight-mini: same architecture as Moonlight-16B-A3B at exercise size.model Moonlight_Mini {  dim d = 128    dim L = 2    dim H = 4    dim V = 512    dim T = 256  const eps = 1e-5   const theta = 1e4   const scale = 2.446  embed tok : Embed(V, d)  spine PreNorm(d)  for i in 1..L {    layer attn{i} : MLA(d, H, dq, dc, dn, dr, dv; norm = RMSNorm(d), ...)    layer ffn{i}  : if i == 1 then GLU(d, Fd; norm = RMSNorm(d))                    else MoE(d, F, E, k, Es; norm = RMSNorm(d), act = silu, ...)  }  norm final : RMSNorm(d)  head lm    : Linear(d, V)}

You will notice that line 10 says the first feed-forward layer is a plain GLU and the rest are a mixture of experts. This is a real thing that real models do, and the language just lets you say it. The grammar for this part is short enough to print, so here it is:4

⟨model⟩::=model id { ⟨decl⟩* } ⟨decl⟩::=dim id = int  |  const id = num |⟨field⟩  |  for id in int..id { ⟨decl⟩* }  |  ⟨bind⟩ ⟨field⟩::=( embed | pos | tokenizer | spine | layer | norm | head ) …

1.3Follow one normalization all the way down

Plate III. A word goes in. Loops come out.

One way to think about a compiler is that it is a machine for making things more specific. You start with “normalize this,” which is the kind of thing you say at a meeting, and you end with “for each of these 128 numbers, do this exact arithmetic,” which is the kind of thing a GPU will actually do. Each step in between is a reduction: a phrase in one language is replaced by phrases in a lower one, until nothing from the higher language is left. Here is line 13 of the listing, on its way down. These are the real lines out of the real files; I did not tidy them.

norm final : RMSNorm(d).spec ⟹specblock norm final : RMSNorm(128).bir ⟹tirnode s5 = rmsnorm(s4, final.w; eps = eps).tir ⟹kirreduce s5.ss[0] = sum(k_ < 128: s4[0, k_] * s4[0, k_]).kir map s5[0, i1_] = s4[0, i1_] * rsqrt(s5.ss[0] / 128.0 + eps) * final.w[i1_] where i1_ < 128

So: the front end replaces d with 128, so nobody downstream ever has to ask what d is. The middle end gives the result a name, s5, and makes the weight explicit. And the kernel stage takes “rmsnorm,” which is a word, and turns it into a reduce and a map, which are loops. Loops are what hardware wants. Hardware has never once wanted a word. If you took a compilers course you learned “reduce” as the thing a parser does on the way up. Here we are going down. Same word, opposite direction. Compiler people are like this.

Pspec  ⟹*  Pkir   iff   ⟦Pspec⟧ = ⟦Pkir⟧  and  Pkir contains no builtin node.
(1.1)

Equation (1.1) is just the previous paragraph again, but with the kind of brackets that make it look like it was reviewed.

1.4The compiler will not guess

Plate IV. The checkpoint, as a filing cabinet. The compiler will not open a drawer that has no label.

There is a certain kind of software that, when you give it something incomplete, fills in the gap with whatever seems plausible and carries on cheerfully. This is great until you find out, three weeks later, that your embedding table has been random numbers the whole time. This compiler does the other thing. If a weight has nowhere to come from, it tells you which one, on which line, and stops. Formally there are two cases, and exactly one of them applies:

(final.w ← "model.norm.weight") ∈ Σfinal.w : [d]
Σ ⊢ final.w ⇝ tensor final.w : [128]
(Bind)
tok.E ∉ dom Σ
Σ ⊢ tok.E ⇝ ⊥  weight 'tok.E' has no tensor
(Unbound)

The rule on the right is my favourite rule in the book. It is not an edge case. It is a feature, it has a test, and the test checks the exact wording of the error message, because a vague error message is just a guess with extra steps.

1.5Same seed, same tokens, every time

Here is a thing that is surprisingly hard to get from a GPU: the same answer twice.

Theorem 1.2 (Reproducible decode). For any model M, back end B and seed s, two runs of the program compiled from M for B, decoding from s, produce byte-identical output.
Plate V. Eight numbers, added in the same order every time. Boring, on purpose.

Proof (sketch). Floating-point addition is not associative, which is the kind of fact that sounds pedantic until it ruins your week. If you add up a thousand numbers in a different order, you can get a slightly different total. On a GPU, the order is often “whichever thread got there first,” which is to say, it varies. So the compiler simply does not let it vary. There are no floating-point atomics in the emitted kernels, and every sum is added up in an order that was decided at compile time. Every output is then a function of M, B and s and nothing else. To check, run it twice and cmp the outputs. It says nothing. That is the proof.5 ∎

Corollary 1.3. If a change to the compiler changes a single token, you will know, and you cannot blame noise. This is bad for your excuses and good for everything else.

1.6Where it runs

The compiler is written in C11 and has no dependencies beyond the C library, which these days is almost an act of protest. To get it running, you hand it to the C compiler you already have, and a native compiler comes out the other side. Textbooks draw this as one T sitting in the arm of another, which looks like a small construction site and is in fact the entire story of how compilers have been built for sixty years.

Figure 1.2: Bootstrapping. Our compiler, written in C11, goes in the left arm of your cc; the same compiler comes out the right arm, now in arm64. The shield at the bottom is the machine, by tradition [MHW70]. Nobody knows why it is a shield.
Plate VI. A chip. The target file says which one, because it matters.

Then you pick where the model should run, which is one argument. The back end picks what language the kernels are written in. The target picks the specific chip, because “a GPU” is about as useful a description as “a car” when you are trying to decide how many threads to launch.

Table 1.2: Back ends and their targets, as of this printing.
Back endKernels inTargets
macC with Accelerate / NEONm2_max, generic_mac
cudaCUDAada, generic_cuda
rocmHIP (gfx942)mi300x, generic_rocm

The models that ship with a description include GPT-2 (124M), Mixtral 8×7B, Moonlight-16B-A3B and some of the GLM family, each with a miniature version you can compile in a few seconds and break on purpose. Weights can stay in bf16, or be squeezed into 4 or 8 bits in groups of 128, if you are the sort of person who looks at a memory bandwidth number and takes it personally.6

1.7Exercises

Plate VII. Your first meaningless tokens. They are the same both times. That is the point.

An asterisk means you will need a checkpoint. Two asterisks means you will need a free afternoon and a tolerance for reading C.

1.1
Build the five tools and run the tests. If they pass, congratulations, you have done the hardest part of most software jobs.
make && make -j test
1.2
Compile the model from Listing 1.1 and run it. With no checkpoint it uses made-up but deterministic weights, and prints token ids. They are meaningless. They are also the same every time, which is the point.
./compile specs/moonlight_mini.spec && ./out/model
1.3
Stop after the kernel stage and find the reduce and the map from §1.3. They are in there. I promise.
./compile specs/moonlight_mini.spec --emit-kir
1.4*
Bake a real GPT-2 checkpoint in and get actual words out. They will be GPT-2 words, so manage your expectations.
./compile specs/gpt2_124m.spec --ckpt path/to/gpt2 --vocab path/to/vocab
1.5**
Add a back end under emit/. Then prove Theorem 1.2 for it: decode twice and show that cmp has nothing to say.

1.8Things happen

Bake can now read a data checkpoint, and bake export learned to cut fused concatenations back into their operands. Sequence RoPE arrived for multi-head latent attention. Part three of the long, quiet campaign to remove t0. Somebody, somewhere, is asking whether this could be done in Python. It could not.7


1 “With tensors” is doing a lot of work in that sentence, but honestly less than you would think.

2 Well. See footnote 6.

3 It is like having the fire extinguisher in the kitchen. You are not planning a fire. But you sleep better.

4 The full grammars live at the top of each parser's header, spec/include/spec/deploy.h and libs/ir/include/ir/bir.h, because a grammar that lives anywhere else eventually stops being true.

5 Mathematicians will object that running it twice is not a proof. They are right, and the sketch above is the proof. But cmp runs in CI and the mathematicians do not.

6 The quantized weights are packed contraction-major, so a kernel reads each group exactly once. This is the sort of sentence that makes some people's eyes glaze over and makes other people very happy. You know which one you are.

7 There is no Python in this codebase. Not in the tools, not in the tests, not in the little one-off scripts. It is C, plus a bit of bash for gluing the stages together. Is this a strong position to take? Yes.

† For moonlight_mini.

Chapter 2

The Front End, or, Unrolling Loops So Nobody Else Has To

spec : .spec → .bir  ·  written in C11  ·  spec/

In one sentence. Parse the model, evaluate every number, expand every loop, and hand back a flat list where nothing is left to work out.

Plate VIII. One loop goes in; L copies come out. The loop does not survive the trip.

You know what a front end does. You have written one, probably in a course, probably for a language with while loops and a sad little type checker. This one is easier and stricter. The language has no runtime: every dim is a constant, every for has constant bounds, and every if is decidable at compile time. So the front end just runs the whole program, and what's left is the trace.

That trace is the .bir, the block IR: one op per line, in six kinds (dim, const, block, share, step, bind). It is deliberately boring. Boring is what you want from the file every other stage will read.

-- what line 13 of Listing 1.1 turns into block norm final : RMSNorm(128) step s5 = final(s4) bind final.w <- "model.norm.weight" dtype bf16

The loop is more fun. Listing 1.1 says “for i in 1..L, the feed-forward is a GLU if i = 1, otherwise a MoE.” After the front end has run it, the if has been decided, the {i} has been spliced into every name, and the bind block's {i-1} prefix has turned into real checkpoint keys, slices and all:

-- i = 1: the if picked GLU block layer ffn1 : GLU(128, 256; norm = RMSNorm(128)) -- i = 2: the if picked MoE block layer ffn2 : MoE(128, 64, 4, 2, 2; norm = RMSNorm(128), act = silu, ...) -- head h of the query, carved out of one checkpoint tensor bind attn1.Wqn[h] <- "model.layers.0.self_attn.q_proj.weight"[h * 24:h * 24 + 16] dtype bf16

That last line is worth a second look. The checkpoint stores every head's query projection in one tensor, 24 rows per head: 16 for the content part, 8 for the rotary part. The spec says so once, with a slice, and the front end writes it out per layer. Nobody downstream ever does index arithmetic on checkpoint keys again.

Definition 2.1 (Flat). A .bir is flat if it contains no loop, no conditional and no unevaluated dimension. The front end's only job is to make every program flat, or refuse it with a line number.

Notice what the front end does not do: optimize. It has no opinions. It doesn't know what a GPU is. That's on purpose, and the next chapter is about why.

Try it.
./compile specs/moonlight_mini.spec --emit-bir
Chapter 3

The Middle End, Where the Algebra Lives

tir : .bir → .tir  ·  written in C11  ·  tir/

In one sentence. Turn each block into tensor operations from a closed set of 30 builtins, then rewrite the graph wherever the arithmetic says it's cheaper.

Plate IX. Every rewrite in tir gets weighed first. Flops and bytes, nothing else.

A layer like MLA(…) is a word. The middle end replaces it with a small graph of builtins: einsum, rmsnorm, rope, sdpa and friends. There are exactly 30 of them, and a _Static_assert makes sure there are never more than 32, because each one gets a bit in a mask.Is a 32-bit mask a reason to keep a language small? It is now.

Then it optimizes. Here is the rule that keeps the whole compiler sane, and it applies to every stage:

Principle 3.1 (Who owns an optimization). Each stage normalizes its input, translates each construct locally, and only then optimizes its own output, priced by what its own language can express. A decision made inside a translation is a bug. The same optimization in two stages is also a bug.

3.1Worked example: absorbing MLA

Plate X. One token, cached two ways: every head's key and value, or the one latent they all came from.

The poster child is multi-head latent attention, the attention in DeepSeek-style models and in Moonlight. The pitch of MLA is that each token is squeezed into one small latent vector c, and every head's key and value are recovered from it by an up-projection:

kh(t) = Wuk,h ct    vh(t) = Wuv,h ct
(3.1)

The lowering writes exactly that. It's the obvious program: build every head's k and v for every token, cache them, run sdpa. It is also the expensive one, because the cache now holds the expanded keys and values and not the small latent they came from. You compressed the token and then uncompressed it straight into the most expensive memory you have.

The fix is two lines of linear algebra that you could do on a napkin. Matrix multiplication is associative, so the up-projections can move to the other side of the dot product and the other side of the sum:

qh⊤kh(t) = (Wuk,h⊤qh)⊤ct    Σt atvh(t) = Wuv,h Σt atct
(3.2)

Read left to right: fold Wuk into the query once per step, attend over the latents directly, and apply Wuv once to the weighted sum. Nothing per head is ever cached. That's the rewrite called absorb_mla, and Figure 3.1 is the whole idea.

Figure 3.1: MLA in moonlight_mini, before and after absorb_mla. Cache widths are to scale: per token and layer the plain form caches 160 values (every head's k and v), the absorbed form 40 (the latent ckv and the shared rope key kr).

Here is what tir actually writes for layer 1. Note the state lines: those are the caches, and they are now 32 and 8 wide. mla_q takes Wuk; mla_attn takes Wuv. Equation (3.2), in the wild:

node attn1.q = mla_q(attn1.x, attn1.Wqn, attn1.Wqr, attn1.Wuk, 10000.0, 8, 16, 32, 4) state ckv of attn1.a : [T, 32] state kr of attn1.a : [T, 8] node attn1.a = mla_attn(attn1.q, attn1.cn, attn1.krr, attn1.Wuv, 16, 32, 8, 4)

On a toy model that's a 4× smaller cache. On the real thing it is the whole reason MLA exists:

Table 3.1: KV cache, per token and per layer, and for a full context.
ModelPlainAbsorbedFull context, fp32
moonlight_mini16040328 KB → 82 KB
moonlight_16b_a3b5 1205764.5 GB → 0.51 GB

So why isn't absorption just always on? Because it isn't always cheaper. Folding Wuk into the query costs flops on every step. If the context is short and the heads are few, the plain form can win. So the rewrite is priced: bytes cached per position plus flops, and it fires only when the sum goes down. That is Principle 3.1 doing its job. The translation stays dumb; the optimizer makes the call, out loud, where you can turn it off.Full context = T positions × L layers: 256 × 2 for the mini, 8 192 × 27 for the 16B. Plain counts each head's key at dn + dr and value at dv.

Want the plain version back? tir --no-opt, or --opt-disable=absorb_mla to switch off just this rule. That's also the oracle: optimized and unoptimized have to agree. And notice what tir did not price: kernel launches. It can't; launches aren't in its language. So it doesn't get a vote on fusion. That's the next chapter's job.

Try it.
build/bin/tir --no-opt specs/moonlight_mini.bir
Chapter 4

Kernels, or, Deciding Where the Seams Go

kir : .tir → .kir  ·  written in C11  ·  kir/

In one sentence. Write every tensor op as an explicit loop nest, then decide which loops share a kernel, for exactly one device.

Plate XI. Seven ops, three kernels. The dashed lines are the expensive part.

Here the builtins disappear. Every node becomes one of five loop-nest forms, map, reduce, contract, gather and scatter, each with an explicit index map for every array it touches. You saw a reduce and a map back in §1.3. Here is a contract, which is a matrix-vector product admitting what it is:

contract attn1.o[0, i1_] = sum(k_ < 64: attn1.a[k_] * attn1.Wo[k_, i1_]) where i1_ < 128

Now kir has the one thing tir didn't: it can see launches, barriers and memory traffic. So kernel boundaries get decided here, once, for every back end. That includes the fun one, horizontal fusion. When several matvecs read the same input, kir concatenates their weights into one derived tensor (that's the hf0 you will find in the file) and does one big matvec in place of several small ones.The concatenated weight never exists in the checkpoint. bake builds it. See Chapter 6.

y1 = x·W1y2 = x·W2cost(1 launch) < cost(2 launches)
[y1 | y2] = x·[W1 | W2]
(H-Fuse)

4.1Worked example: two projections, one matvec

Go back to MLA for a second. Every step, the normalized input x is multiplied by Wdkv (128 → 32, the latent) and, separately, by Wkr (128 → 8, the rope key). Same x, two skinny matvecs, two launches, and x read from memory twice. kir sees that and writes this instead:

tensor hf0.W : [128, 40] = concat(attn1.Wdkv, attn1.Wkr) contract hf0.o[0, i1_] = sum(k_ < 128: attn1.x[k_] * hf0.W[k_, i1_]) where i1_ < 40 -- the latent's norm reads columns 0..31 of the result reduce attn1.cn.ss[0] = sum(k_ < 32: hf0.o[0, k_] * hf0.o[0, k_]) -- rope reads columns 32..39 map (attn1.krr[...]) = (hf0.o[0, ... + 32] * cos(...) - ...)
Figure 4.1: Horizontal fusion in layer 1. Two weights that read the same x are glued side by side into hf0.W; one matvec produces both outputs, and each reader takes its own columns.

The GLU in layer 1 gets the same treatment, and it's a bigger win: gate and up projection, 256 columns each, become hf1.W : [128, 512] = concat(ffn1.Wg, ffn1.Wu). Note the = where you'd expect a <-. A <- means “load this from the checkpoint”; an = means “compute this from other weights.” Somebody has to do that computing, and it's not the GPU at runtime.

Raw kir, before any of this scheduling, is still runnable. That's the oracle again: whatever the scheduler does, raw kir is the answer it has to match.

Try it.
build/bin/kir --check specs/moonlight_mini.kir
Chapter 5

Back Ends, or, Somebody Else's Compiler

emit : .kir → project/  ·  written in C11  ·  emit/

In one sentence. Print the kernels as C, CUDA or HIP, add a driver and a Makefile, and let the vendor's compiler do the last mile.

Plate XII. One plan, three dialects. Nobody re-decides the plan.

By the time a program reaches emit, every interesting decision has been made. Which ops share a kernel? kir said. Which form of attention? tir said. emit's job is to be a very good typist. It writes kernels.*, main.*, bench.*, a manifest.txt and a Makefile, and then it's done.

“Typist” undersells it a little. The back end still owns the instruction level: tile shapes, vector widths, microkernels. On CUDA a 4-bit weight is read 32 nibbles per 16-byte load, one warp per output. On ROCm it's one wave per R outputs, with the dtype as a template constant. What a back end may not do is scan the graph for its own fusions. That would be the same optimization in two stages, and Principle 3.1 calls that a bug.

Table 5.1: Who decides what, in one place.
StageOptimizesPriced by
specnothing—
tiralgebra, values, layoutsflops, bytes of values
kirfusion, barriers, recomputelaunches, memory traffic
emittiles, vector widththe instruction level
Try it.
./compile specs/moonlight_mini.spec --backend mac && make -C out bench
Chapter 6

Weights, or, The Part With 30 Gigabytes

bake : .tir + checkpoint → weights.bin  ·  written in C11  ·  bake/

In one sentence. Read a Hugging Face checkpoint, follow each tensor's recipe from the .tir, and write one flat file in exactly the order the program will read it.

Plate XIII. Shards in, one file out, in the order init_weights reads it.

bake is optional. Without it, ./model fills its weights deterministically and prints token ids, which is all you need to test a compiler. With it, you get words. Weirdly, it reads the .tir and not the .kir, because that's where each tensor's recipe lives: which checkpoint key, which slice, which dtype.

-- a recipe, as bake reads it from the .tir tensor attn1.Wqn[h] : [128, 16] <- "model.layers.0.self_attn.q_proj.weight"[h * 24:h * 24 + 16] dtype bf16 -- and one that isn't in the checkpoint at all (Chapter 4) tensor hf0.W : [128, 40] = concat(attn1.Wdkv, attn1.Wkr)

Three rules do most of the work. Order is the manifest's, not the file's. Orientation is the declared shape: if the source is stored the other way round, bake transposes it. Derived tensors, like kir's horizontally fused weights, are computed in fp32 from their operands' real values, never from already-quantized bytes, and then packed like anything else. Quantized weights go in contraction-major, so a dot product walks one contiguous run of nibbles and one scale per 128.

6.1Worked example: packing a 4-bit weight

Say a spec binds a weight with dtype q4g128. bake quantizes it to symmetric int4 in groups of 128 consecutive elements, each group with its own fp32 scale. That's half a byte per weight plus 4 bytes per 128 of them, so about 4.25 bits a weight, down from 16 in bf16.

The interesting decision is which 128 elements make a group. The kernel will compute yj = Σi xiWij, walking down a column. So bake lays each column out as one contiguous run, element (i, j) at offset j*n_in + i, and groups run down the column too. One 16-byte load is then 32 consecutive weights of the same dot product under one scale, which is exactly what a GPU lane wants to eat.

Figure 6.1: Contraction-major packing. Column j, the one a dot product walks, becomes one strip of nibbles; every 128 of them share a scale s. A row-major layout would scatter the same dot product across the whole matrix.
Theorem 6.1 (bake does not guess). If two readers of a square weight disagree about which axis holds its input features, bake stops with an error. It doesn't pick one, it doesn't use the more popular one, it doesn't flip a coin.

Proof. A square matrix looks the same either way round. Guessing wrong produces a model that runs, prints plausible numbers and is quietly wrong, which is the worst kind of wrong. See §1.4. ∎

Try it.
./compile specs/gpt2_124m.spec --ckpt path/to/gpt2 --vocab path/to/vocab
Appendix A

Supported Architectures

models/*.spec  ·  specs/*.spec  ·  tests/graph_coverage.txt

In one sentence. Fifty-nine architectures written down as specs, and a ratchet that records how far through the compiler each one gets.

models that stop at each stage —22 spec6 bir9 tir11 kir0 target11
Plate XIV. The ratchet. A spec may move down a row; the test fails if it ever moves back up.

Writing a model down is cheap. You read its config.json and its modeling file, and an afternoon later you have a page of spec. Compiling it is the expensive part, because every architecture lab has had at least one idea that nobody else has had, and the compiler has to learn each one before it will stop complaining. So we write the specs first, sometimes well ahead of the compiler, and keep score.

The scorekeeping is a ratchet. Every make runs each spec as far down the pipeline as it will go: does it parse, does it flatten into .bir, does tir accept it, does it schedule into kernels for a target. tests/graph_coverage.txt records the furthest stage per spec, and the test fails if any spec falls short of its row. A spec can climb but never slip, which is the kind of guarantee you want from a ratchet and almost never get from a roadmap.

“Supported,” honestly, means the right-hand end of the table: compiles to a .target. The ones in specs/ (GPT-2, Moonlight, Mixtral, GLM-4.7-Flash and GLM-5.3) go further still. They have byte-exact fixtures at every stage, so a change that moves one of their files moves a diff you have to explain. A dash means the spec was written ahead of the compiler: it uses something the front end does not parse yet. That is a to-do list, not an embarrassment, although we would not object if it got shorter.

Table A.1: Every architecture with a spec, grouped by what mixes the tokens. L is layers, d the model width, and the pips run spec, bir, tir, kir, target.
ModelMixerFeed-forwardLdReaches
Dense transformers
GPT-2 124MMHAMLP12768target
GPT-J 6BMHA, parallel blockMLP284,096—
BLOOM 7B1MHA, ALiBiMLP304,096bir
Llama 3 8BGQAGLU324,096target
Mistral 7BGQA, sliding windowGLU324,096tir
Ministral 3 8BGQAGLU344,096spec
Phi-4 14BGQAGLU405,120target
Qwen3 32BGQAGLU645,120tir
OLMo 2 7BMHA, QK-normGLU324,096target
OLMo 3 7BMHA, 3 sliding : 1 globalGLU324,096bir
SmolLM3 3BGQA, NoPE every 4th layerGLU362,048target
Gemma 2 27BGQA, sliding / global alternatingGLU464,608—
Gemma 3 27BGQA, 5 local : 1 globalGLU625,376spec
Gemma 3n E4BGQA, 4 local : 1 global, per-layer embeddingsGLU352,048—
Gemma 4 31BGQA, 5 local : 1 globalGLU605,376spec
Command AGQA, 3 sliding : 1 global NoPEGLU6412,288—
Tiny AyaGQA, 3 sliding : 1 global NoPEGLU362,048—
Muse Glimmer 30BGQA, 3 sliding : 1 global NoPEGLU526,656spec
Mixture of experts
Mixtral 8x7BGQAMoE 8, top-2324,096target
DBRX 132BGQAMoE 16, top-4406,144target
Grok-1 314BGQAMoE 8, top-2646,144tir
Snowflake ArcticMHAdense GLU + MoE 128, top-2357,168—
GLM-4.5GQAMoE 160, top-8925,120tir
gpt-oss-120bGQA, sliding / full alternating, sinksMoE 128, top-4362,880—
Qwen3 30B-A3BGQAMoE 128, top-8482,048tir
ERNIE 4.5 300B-A47BGQAMoE 64, top-8548,192—
MiniMax-M1lightning, 1 GQA : 7MoE 32, top-2806,144tir
MiniMax-M2.5GQAMoE 256, top-8623,072tir
MiniMax-M3GQA, sparse after layer 3MoE 128, top-4606,144—
Llama 4 ScoutGQA chunked, 1 in 4 global NoPEMoE 16, top-1485,120—
Step-3.5 FlashGQA, 3 sliding : 1 globalMoE 288, top-8454,096bir
MiMo-V2-FlashGQA, sliding with sinks, 1 in 6 globalMoE 256, top-8484,096tir
Trinity Largegated GQA, 3 sliding : 1 globalMoE 256, top-4603,072bir
Multi-head latent attention
DeepSeek-V2-LiteMLAMoE 64, top-6272,048—
DeepSeek-V2MLAMoE 160, top-6605,120—
DeepSeek-V3MLAMoE 256, top-8617,168bir
DeepSeek-V3.2MLA, sparse (indexer)MoE 256, top-8617,168—
Moonlight 16B-A3BMLAMoE 64, top-6272,048target
Kimi K2MLAMoE 384, top-8617,168bir
GLM-4.7-FlashMLAMoE 64, top-4472,048target
GLM-5MLA, sparse (DSA)MoE 256, top-8786,144target
GLM-5.3MLA, sparse (DSA)MoE 256, top-8786,144target
Hy4MLA, sparse (indexer)MoE 256, top-8786,144—
LongCat-FlashMLAMoE 512, top-12286,144—
Hybrids: linear attention and state spaces
Qwen3-Next 80B-A3BGated DeltaNet, 1 in 4 attentionMoE 512, top-10482,048bir
Qwen3.5 27BGated DeltaNet, 1 in 4 attentionGLU645,120bir
Qwen3.5 397B-A17BGated DeltaNet, 1 in 4 attentionMoE 512, top-10604,096—
Qwen3.8 Flash NextGated DeltaNet + sparse attention, n-gram embeddingsMoE 512, top-10482,560—
Qwen3.8 MaxGated DeltaNet + attentionMoE 512, top-10928,192—
Kimi Linear 48BKDA, 1 in 4 MLAMoE 256, top-8272,304spec
Kimi K3KDA, 1 in 4 MLAMoE 896, top-16937,168spec
Ling 2.5 1TLightning attention, 1 in 8 MLAMoE 256, top-8808,192bir
Jamba 1.5 MiniMamba, 1 in 8 attentionMoE 16, top-2, every other layer324,096tir
Granite 4.0-H SmallMamba2, 1 in 10 attention (NoPE)MoE 72, top-10404,096—
Nemotron-H 8BMamba2, 4 attention layersMLP (ReLU²)524,096—
Falcon-H1 7Battention ∥ Mamba2GLU443,072—
Hymba 1.5Battention ∥ Mamba, mostly slidingGLU321,600—
LFM2 1.2Bshort convolution, 6 GQA layersGLU162,048tir
Mamba-2 2.7BMamba2—642,560tir

Two things stand out. First, the dense transformer is no longer the typical case: two thirds of these architectures route each token to a few of many experts, compress their keys, or replace attention with something linear for most of their layers. Second, the newer the idea, the shorter the bar: nearly half of the latent-attention models and the hybrids still stop at a dash, against about a quarter of the dense ones. The compiler learns ideas in roughly the order the labs had them, which is either reassuring or a little sad.

Try it.
./compile models/llama3-8b.spec --emit-kir --target targets/m2_max.target

Bibliography

[ASU86]A. V. Aho, R. Sethi and J. D. Ullman. Compilers: Principles, Techniques, and Tools. Addison-Wesley, 1986. The one with the dragon. [Bra61]H. Bratman. An alternate form of the “UNCOL diagram”. Communications of the ACM, 4(3):142, 1961. One page long. Still cited. Aspirational. [Knu84]D. E. Knuth. The TeXbook. Addison-Wesley, 1984. [Kri26]M. S. Kristiansen. spec ⟶ model. martinkristiansen.com, 2026. This book. Listed so that it has at least one citation. [MHW70]W. M. McKeeman, J. J. Horning and D. B. Wortman. A Compiler Generator. Prentice-Hall, 1970. [Vas17]A. Vaswani et al. Attention is all you need. In Advances in Neural Information Processing Systems 30, 2017. The reason any of this is necessary.
1
Written by Martin S. Kristiansen [Kri26]. Typeset in the manner of [Knu84], with more footnotes than he would have liked.