A from-scratch PyTorch language model & its full efficiency stack

I built a language model from scratch, then made it cheap enough to actually serve.

Every algorithm here is written out in the repository: the BPE merges, attention with GQA and RoPE, the Newton–Schulz orthogonalization inside the Muon optimizer, the DPO loss, GPTQ's Hessian error compensation, the speculative-decoding accept/reject rule. No high-level trainer library appears anywhere in src/nanoscale/. This page explains all of it: the mathematics, the engineering, and the measurements, including the ones that contradicted the plan. It is the complete documentation for the project: architecture, methodology, decisions, results and limitations in one place , ending with what the finished artifact is actually good for, which turns out to be a compressor and an anomaly detector rather than a chatbot.

11.3×lossless compression — 3.5× past xz
40.4Mparams, trained in 3.2 h
580tests, mypy strict
7 / 9phenomena above chance
17.7×distillation shrink
$0total compute spend
§

How to read this

Written for someone whose deep-learning knowledge runs ANN → CNN → RNN → LSTM → encoder–decoder → attention → Transformer, and stops there.

If you understand a Transformer decoder: multi-head self-attention, the feed-forward block, residual connections, layer normalization, causal masking, cross-entropy on next-token prediction; you have every prerequisite for this page. Everything past that point is developed here from first principles.

That is a deliberate choice about what "advanced" means. Almost nothing in a modern language model is conceptually harder than attention. What happened between 2017 and now is that a few dozen specific, individually-comprehensible modifications accumulated, each fixing a concrete problem with the 2017 design. RoPE fixes how position is encoded. RMSNorm removes a computation nobody could show was needed. Grouped-query attention fixes a memory bottleneck that only appears at inference time. Muon fixes an assumption Adam makes about weight matrices. Every one of them is a paragraph of motivation and a formula.

So this page is structured as a sequence of deltas from what you already know. Each one states the problem with the baseline, the fix, the mathematics, and what it measured in this repository. The two arcs are independent enough to read separately:

Arc 1: build the model

Tokenizer, architecture, optimizer, pretraining, alignment. This arc answers how is a language model actually made, and ends with a trained, instruction-following checkpoint.

Sections 01–06.

Arc 2, make it cheap

Distillation, quantization, speculative decoding, serving. This arc answers why does inference cost what it costs, and what can be done about it. It takes Arc 1's checkpoint as input.

Sections 07–10, after a short detour on the inference cost model.

A note on the numbers

Every measurement on this page was produced by a script committed to the repository, stamped with the git commit and the hardware that produced it, and is regenerable offline from committed data. Several results here are negative; the technique did not do what the literature predicted at this scale. Those are reported in the same detail as the positive ones, because a page where every prediction came true is a page that either got lucky or stopped looking.

00

Your starting point

Fixing notation, and naming the four problems the rest of the page solves.

Here is the model you already know, written the way this repository writes it. A decoder-only Transformer maps a sequence of token IDs to a probability distribution over the next token at every position simultaneously. Tokens go in, an embedding table turns each into a vector, a stack of identical blocks refines those vectors, and a final linear layer projects each vector to a score for every token in the vocabulary.

tokens embedding + pos emb × N blocks multi-head attn + LayerNorm residual FFN (ReLU) + LayerNorm linear → V logits
The baseline this page departs from. Post-norm residuals (normalize after adding), learned absolute position embeddings added once at the input, a ReLU feed-forward network, and every attention head with its own key and value projections. Each of those four choices has since been replaced, and the sections below explain what replaced them and why.

Notation used throughout

V, T, d, L, H
Vocabulary size, sequence length, model width (d_model), number of layers, number of attention heads. d_head = d / H.
N, D
Parameter count and training-token count. The Chinchilla heuristic says a compute-optimal run sets D ≈ 20N.
πθ(y | x)
The model treated as a policy: the probability it assigns to response y given prompt x. Used from the alignment section onward.
p, q
In Arc 2, p is always the big/target/teacher distribution and q the small/draft/student one. Keeping this consistent is what makes the distillation and speculative-decoding sections rhyme.

The four problems

Everything in Arc 1 is a response to one of the first three problems; everything in Arc 2 responds to the fourth. Naming them now makes the rest of the page a set of answers rather than a list of techniques.

ProblemWhy the 2017 design suffersAnswered in
Position Learned absolute position embeddings are a fixed-size table: the model cannot be run on a sequence longer than the table, and it must learn "5 tokens apart" separately at every absolute offset. §02, RoPE
Trainability Post-norm residuals mean the identity path is normalized at every layer, so deep stacks need learning-rate warmup on a knife-edge. Attention logits grow without bound during training and can saturate the softmax. §02: pre-norm, RMSNorm, QK-norm, zero-init
Optimization Adam rescales every weight independently. A weight matrix is not a bag of independent scalars; its useful structure is spectral, and per-coordinate scaling is blind to it. §03, Muon
Serving cost Generation is sequential and, at scale, bound by memory bandwidth rather than arithmetic. Every token generated re-reads the entire weight matrix from memory. Arc 2; the cost model, then §07–§09
01

The tokenizer

Byte-level BPE. Why a language model's vocabulary is a compression problem, and why working over bytes eliminates an entire class of bug.

Before any neural network runs, text has to become integers. The naive options are both bad. Characters give a tiny vocabulary but very long sequences, and attention costs O(T²), so long sequences are expensive. Words give short sequences but an unbounded vocabulary: you will always meet a word you did not see in training, and every such word collapses to a single <unk> token that destroys information.

Byte-pair encoding is the compromise, and it is genuinely a compression algorithm before it is an NLP one. Start with an alphabet. Repeatedly find the most frequent adjacent pair of symbols in the corpus, and add a new symbol that means "that pair". Stop at the target vocabulary size. Frequent sequences,  the, ing,  Congratulations , become single tokens; rare ones stay decomposed into pieces.

Byte-level is the part that matters

The critical design choice is what the starting alphabet is. If it is Unicode characters, the alphabet is effectively unbounded (there are ~150,000 assigned code points) and you are back to needing an <unk>. If it is the 256 possible byte values, the alphabet is exactly 256 and closed. Every possible string: any language, any emoji, any corrupted file, any lone surrogate, is some sequence of bytes, so it is always encodable.

decode(encode(s)) == s  for every string s, with no exceptions

This is not a soft guarantee, it is a structural one, and the repository asserts it with property-based tests that let Hypothesis generate adversarial strings. There is no <unk> token in this tokenizer because there cannot be one.

Pre-tokenization: the constraint that keeps merges sane

Left alone, BPE will happily learn a single token for " of the", because that string is extremely frequent. That is wasteful, it spends vocabulary slots on phrase-level accidents instead of morphology, and it makes the tokenization of a word depend on what precedes it. The fix is a pre-tokenization regex that first splits text into chunks (roughly: words with their leading space, runs of digits, runs of punctuation), and merges are only ever allowed inside a chunk.

// GPT-2's pattern, implemented in src/nanoscale/tokenizer/bpe.py
's|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+
Worked example: one sentence through the real 16k vocabulary

"The boy near the cats runs quickly." becomes 8 tokens:

The 417␣boy 505␣near 826 ␣the 262␣cats 2996␣runs 1801 ␣quickly 1512. 46

Note the leading space is part of the token: ␣the is a different token from the. That is what the pre-tokenization regex buys, and why a model can tell "thecat" from "the cat". Common whole words survive intact (␣quickly, and even ␣Congratulations, one token each), while a rarer word splits into pieces: ␣unhappiness becomes ␣unh + appiness.

This has a pleasant efficiency consequence. Because merges never cross chunk boundaries, training does not need to rescan the corpus: it can count chunk types with their frequencies, and maintain a pair &rarr, which chunks contain it inverted index so that applying a merge only touches the chunks that actually contain that pair. Encoding gets the same benefit as a cache keyed on the chunk, which is why this pure-Python tokenizer still encodes at ~14 MB/s.

A property I assumed and Hypothesis disproved

I wrote a test asserting that token counts are subadditive under concatenation : that len(encode(a + b)) ≤ len(encode(a)) + len(encode(b)), on the reasoning that concatenating can only create merge opportunities. Hypothesis found a = "eps", b = "ep": one token each, but "epsep" encodes to three. Concatenation creates a new chunk whose greedy merge order differs from either part's, and greedy merging is not monotone. The counterexample is now a named regression test, and the true property, token counts are additive across pre-token boundaries, is tested instead.

What was measured

Two vocabularies are trained and committed. The nano tier uses 1,024 tokens (761 learned merges) over the synthetic corpus; the micro tier uses 16,384 tokens (16,121 merges) over TinyStories. Correctness against a reference implementation is checked as three separate claims rather than one vague "parity" number:

ClaimResult
Pre-tokenization is exactly GPT-2's Chunk-for-chunk identical to tiktoken's split on every test passage. This is the part that can be exactly equal, so it is tested for exact equality.
In-domain compression is comparable 4.19 bytes/token in-domain, 4.23 on a held-out seed; a 1,024-token vocabulary matching a 50,257-token one on the distribution it was trained for.
Out-of-domain degradation is bounded 2.25 bytes/token on prose the vocabulary never saw, against tiktoken's 4.79. A 2.1× regression: reported, not hidden. A small in-domain vocabulary should degrade off-distribution; a test that demanded parity here would be testing the wrong thing.

Longest learned tokens in the 16k TinyStories vocabulary:  enthusiastically,  Congratulations,  understandingly,  recommendations, whole words, which is what a healthy merge schedule produces.

02

The architecture

Eight deltas from the 2017 block. Each one states the problem, the fix, and what it cost or bought.

This is the model the repository builds. Read it against the 2017 diagram above: the skeleton is identical: attention sublayer, feed-forward sublayer, residuals, stacked N times , and every difference is annotated below.

residual stream (never normalised) token embedding RMSNorm grouped-query attention q,k → QK-norm → RoPE 8 query heads / 4 kv heads causal softmax · KV cache o_proj  init = 0 + RMSNorm SwiGLU feed-forward SiLU(x·W_gate) ⊙ (x·W_up) → W_down W_down  init = 0 + × N blocks, then RMSNorm → untied LM head logits
The residual stream is never normalized. That is the single most consequential difference from the 2017 diagram: normalization moved inside each branch, so there is an unbroken identity path from the embedding to the logits. Both branch outputs are initialized to exactly zero, so at step 0 the whole stack computes the identity function.

2.1  Pre-norm residuals; the trainability fix

In the 2017 block the order is x → sublayer → add → normalize. That means the residual stream itself is renormalized at every layer. Whatever the embedding wrote into it gets rescaled L times before reaching the output, and the gradient flowing backward is rescaled L times too. In practice deep post-norm stacks only train with carefully tuned learning-rate warmup; without it they diverge.

The pre-norm arrangement is x → normalize → sublayer → add. The normalization applies to the input of the branch, not to the stream. The stream itself is a pure sum:

xout = xin + Attn(Norm(xin)) + MLP(Norm(·)) ∂x_out/∂x_in contains an exact identity term, at every layer

The gradient reaching layer 0 therefore contains an undamped copy of the gradient at the output. This is why modern models are trained at depth without a warmup knife-edge, and it is the reason the residual stream is often described as a "highway" that each block reads from and writes to.

2.2  RMSNorm, delete the part nobody could justify

LayerNorm subtracts the mean, divides by the standard deviation, then applies a learned gain and bias. RMSNorm keeps only the scaling:

LayerNorm(x) = γ ⊙ (x − μ) / √(σ² + ε) + β RMSNorm(x)   = γ ⊙ x / √(mean(x²) + ε)

Zhang & Sennrich showed empirically that the mean-subtraction ("re-centering") contributes nothing measurable, and that the useful work is entirely in the rescaling. Dropping it removes one full pass over the vector, one reduction, and the bias parameters. At a 5M-parameter scale that saving is invisible; at 70B it is not, and every current open model uses RMSNorm.

Worked example: a 4-dimensional vector
x = [3, −1, 4, −2] LayerNorm: μ = 1.0, so centre first: [2, −2, 3, −3]; σ = 2.55 → [0.78, −0.78, 1.18, −1.18] RMSNorm: rms = √((9+1+16+4)/4) = √7.5 = 2.74; no centring → [1.09, −0.36, 1.46, −0.73]

RMSNorm leaves the vector pointing the same way and only fixes its length. LayerNorm additionally slides it so the components average zero, which costs a second pass over the vector and, per Zhang & Sennrich, buys nothing measurable.

An engineering detail that turned out to matter

The mean(x²) reduction must accumulate in higher precision than the activations, or bf16 training produces garbage. The obvious way to write that is x.float(). But .float() means "cast to fp32": which promotes bf16 as intended and silently demotes float64. The repository's numerical tests use fp64 reference implementations to get their sharpness, and this demotion capped their agreement at 1e−5 when the true agreement is 1e−10. A test passing at a tolerance five orders of magnitude looser than reality is not testing anything. There is now a one-function module, model/numerics.py, whose whole job is accumulation_dtype(): promote to at least fp32, never demote.

2.3  RoPE, position as a rotation

This is the delta most worth understanding properly, because the trick is genuinely elegant and the implementations look opaque until you see it.

The problem with adding a learned position vector to the embedding is that it makes position absolute. The model must learn, separately for every pair of absolute offsets, that positions 5 and 7 are two apart in the same way that 105 and 107 are. And the table has a fixed number of rows, so the model cannot run on longer sequences than it was trained on.

Rotary position embedding attacks this from the observation that attention only ever consumes q and k through their dot product. So instead of adding anything, rotate them. Split each head's d_head-dimensional query and key vector into d_head/2 consecutive pairs, and treat each pair as a point in a 2-D plane. For a token at position m, rotate pair j by angle m θj:

θj = base−2j/d_head  base = 10000; low j rotates fast, high j rotates slowly [ q2j ] [ cos(mθj) −sin(mθj) ] [ q2j ] [ q2j+1 ] ← [ sin(mθj)   cos(mθj) ] [ q2j+1 ]

Now the payoff. Rotation matrices compose by adding angles, and a rotation is orthogonal, so RmTRn = Rn−m. Therefore:

⟨ Rmq , Rnk ⟩ = qTRmTRnk = qTRn−mk the attention score depends on (n − m) alone, relative position, for free

No parameters were added. No table can be run off the end of. And the model learns "two tokens apart" once, not once per absolute offset. The multi-frequency construction : fast rotations in the early dimensions, slow ones in the late dimensions, is the same idea as sinusoidal encodings: it gives the dot product a basis in which both short-range and long-range offsets are distinguishable.

Worked example: the actual angles, d_head = 64
θ0 = 10000−0/64 = 1.0000 pair 0 rotates fastest θ1 = 10000−2/64 = 0.7499 θ31 = 10000−62/64 = 0.0001 pair 31 barely moves across the whole context token at position 1, pair 0: rotate by 1 × 1.0000 = 57.3° token at position 2, pair 0: rotate by 2 × 1.0000 = 114.6° token at position 5, pair 1: rotate by 5 × 0.7499 = 214.8°

So a query at position 5 and a key at position 3 end up 2 × 57.3° = 114.6° apart in pair 0, and 2 × 43.0° = 86.0° apart in pair 1. Every pair encodes the same distance at a different frequency, exactly like the hands of a clock: the fast hand distinguishes neighbouring tokens, the slow hand distinguishes distant ones, and together they identify the gap without ambiguity.

q (pos 3) k (pos 5) angle between = 2θ q (pos 103) k (pos 105) angle between = 2θ = same score
Why the dot product only sees distance. Both vectors of a pair get rotated by their own absolute position, so the angle between them, which is what the dot product measures, is the difference of those rotations. Tokens two apart look identical to attention whether they sit at positions 3 and 5 or 103 and 105.
Conventions differ, and mixing them is silent

There are two layouts in the wild: interleaved pairs ([0,1], [2,3], …, the original paper) and split-half pairs ([0, d/2], [1, d/2+1], …, common in Hugging Face ports). Both are correct rotations and both train, so mismatching them produces no error, only a checkpoint that quietly does not transfer. This repository uses interleaved and pins it with a test against a slow fp64 reference rotation written independently of the fast path.

2.4  Grouped-query attention: a fix for a problem you only see at inference

During training, attention reads the whole sequence at once and nothing is stored between steps. During generation the picture inverts. Generating token t+1 needs the keys and values of all t previous tokens, and recomputing them every step would make generation O(T²) per token. So they are cached, the KV cache , and the cache is the thing that dominates inference memory.

KV bytes = 2 × L × n_kv × d_head × T × batch × bytes_per_element 2 for K and V; grows linearly in sequence length and in batch size

Put a real model in that formula. Llama-2-7B has L=32, 32 heads of d_head=128, and standard multi-head attention, so n_kv=32. At a 4,096-token context in fp16, for one single sequence:

2 × 32 × 32 × 128 × 4096 × 1 × 2 bytes = 2.1 GB

Two gigabytes of cache for one user, against 13 GB of weights that are shared by everyone. Batch sixteen users and the cache is 34 GB; the cache, not the model, is what decides how many people your GPU can serve. That is the bottleneck GQA exists to attack.

The observation is that n_kv does not have to equal the number of query heads. Keep all H query heads; they are where attention's expressiveness lives , but let groups of them share one key/value head. With n_kv = H you have classic multi-head attention; with n_kv = 1 you have multi-query attention, which is maximally cheap and measurably hurts quality; GQA is the middle that Ainslie et al. showed keeps almost all the quality.

MHA  n_kv = 8 cache ×1.0 Q KV GQA  n_kv = 4 cache ×0.5, used here MQA  n_kv = 1 cache ×0.125 query heads (hollow) stay at 8 in all three, only the shaded key/value heads are cut and the KV cache is exactly proportional to that shaded count
GQA cuts the cache, not the query capacity. The micro tier here uses 8 query heads over 4 KV heads, halving cache memory. In the code this is implemented by projecting k and v to n_kv heads and expanding them with repeat_kv before the dot product; a view, not a copy, so nothing is materialized.
Worked example: this project's own micro tier
L = 8 layers, 8 query heads, 4 kv heads, d_head = 64, context 512, fp32 KV cache = 2 × 8 × 4 × 64 × 512 × 4 bytes = 8.0 MB with plain MHA (8 kv heads) = 16.0 MB

8 MB against a 154 MB model looks negligible, and that is exactly the trap: the cache is per sequence and the weights are shared. Serve 64 users at once and the weights are still 154 MB while the caches are 512 MB. GQA turns that into 256 MB, which is the difference between 64 concurrent users and 128 on the same card.

2.5  QK-norm, keeping the softmax out of saturation

Attention scores are q·k / √d_head. Nothing bounds the norms of q and k, and during training they tend to grow. Once the scores get large, the softmax saturates: it becomes nearly one-hot, its gradient goes to nearly zero, and that head stops learning. It is a slow, silent failure that shows up as a loss spike or a dead head, not as an error.

The fix is one line: RMS-normalize q and k per-head before the dot product, with a learned gain. The score's magnitude is now controlled by that learned gain rather than by whatever the projections happened to drift to.

Ordering matters, and the wrong order still trains

QK-norm must be applied before RoPE, not after. Normalizing after rotating would rescale the rotated vector and break the exact orthogonality the relative-position identity depends on. Doing it in the wrong order produces a model that trains perfectly well and has subtly wrong position handling, so the repository pins the order with an explicit test rather than a comment.

2.6  SwiGLU: a gate on the feed-forward network

The 2017 feed-forward network is ReLU(xW₁)W₂: project up, threshold, project down. SwiGLU splits the up-projection into two and uses one half to gate the other:

FFN2017(x) = ReLU(x W₁) W₂ SwiGLU(x)    = ( SiLU(x Wgate) ⊙ (x Wup) ) Wdown SiLU(z) = z · σ(z);  ⊙ is elementwise

The gate is the point. In the ReLU version the only decision available per unit is "pass or zero". In the gated version one branch computes a value and the other computes, from the same input, a smooth multiplicative weight on that value, so a unit can be attenuated by any amount rather than only switched off, and the attenuation is input-dependent. Because three matrices replace two, the hidden width is conventionally scaled by 2/3 (here to 8/3 · d rounded to a multiple of 64) so parameter count stays comparable.

Shazeer's paper introducing this is unusually honest: it offers no theoretical justification and ends "we attribute their success, as all else, to divine benevolence." It won on benchmarks and the field adopted it. The ungated ReLU² alternative from the modded-nanoGPT speedrun is implemented here too, as a togglable ablation, see §05 for what it actually measured.

Worked example: what the gate does to one unit
unit value from the up branch : 4.0 gate pre-activation : −3.0 → SiLU(−3.0) = −0.14 output : 4.0 × −0.14 = −0.57 same value, gate pre-activation: 2.0 → SiLU(2.0) = 1.76 output : 4.0 × 1.76 = 7.05

One value, two gates, a 12× swing in what reaches the residual stream. A ReLU network can only pass 4.0 or 0; the gate lets the same unit be strongly suppressed, lightly attenuated, or amplified, and the decision is computed from the same input.

2.7  Zero-init output projections and an untied head

Both sublayers' final projections (o_proj and W_down) are initialized to exactly zero. At step 0 every block therefore outputs zero, the residual stream passes the embedding through untouched, and the model computes the identity. Training starts from a provably well-conditioned point and each block "switches itself on" as its gradient grows.

This has a testable consequence that the repository uses as a canary. If the model outputs nothing but the embedding, and the embedding is small and roughly isotropic, the logits are near-uniform, so the initial loss must be exactly the entropy of a uniform distribution over the vocabulary:

loss(step 0) = ln(V) measured: 9.7041 for V = 16,384; ln(16384) = 9.70406, agreement to five decimals

If a refactor breaks the initialization, the very first logged number changes, and it changes against a value derived from theory rather than from a previous run.

Worked example: the first number every run prints
nano (V = 1,024): ln(1024) = 6.9315 measured first loss: 6.9315 micro (V = 16,384): ln(16384) = 9.70406 measured first loss: 9.7041

Both agree to five decimals with a constant nobody chose. If a refactor breaks the initialization the very first logged number moves, and it moves against theory rather than against a previous run, so the test cannot rot.

Untying the LM head means the output projection is a separate matrix from the input embedding table rather than its transpose. Tying saves V×d parameters and was standard when vocabularies were small relative to model width. It also forces one matrix to serve two different jobs, "what does this token mean" and "how likely is this token here" , and at modern vocabulary sizes the parameter saving is not worth the constraint.

2.8  Two optional heads

Both are implemented and off by default, because their effect is what the ablation harness exists to measure rather than assert.

tanh logit soft-cap
logits ← c · tanh(logits / c). A smooth ceiling on logit magnitude (Gemma-2 uses c = 30), which prevents a single overconfident logit from dominating the softmax. Unlike clipping it is differentiable everywhere, so gradients keep flowing through capped logits.
multi-token prediction (MTP)
Extra small heads that predict token t+2, t+3, … alongside the main t+1 head, as an auxiliary loss. The training argument is that forcing the representation to carry information about tokens further ahead is a denser learning signal than next-token alone. The inference argument is more interesting and is why this belongs in a project with an Arc 2: those heads are already a draft model, which makes them reusable for self-speculation with no separate network: the Medusa idea in §09.

The size ladder

One architecture; only depth, width, context and token budget change. Parameter counts are asserted as exact constants in the test suite, against both the analytic formula and the built nn.Module, so an accidental architecture change fails loudly rather than quietly shifting every number downstream.

TierParamsNon-embedding L × d × Hn_kvContextTrains on
nano4,952,0644,427,776 6 × 256 × 42256laptop CPU, 95 s
micro40,379,90423,602,688 8 × 512 × 84512free Colab T4
small125,849,85675,518,208 12 × 768 × 1241024scale-up recipe

Both totals are reported because "parameter count" is ambiguous without saying whether embeddings and an untied head are included. At micro the embedding table plus head is 16.8M of the 40.4M, 42% of the model is the vocabulary interface, which is what small models look like.

03

The optimizer: Muon

Adam treats a weight matrix as a bag of independent numbers. It isn't one. This is the largest single lever in the whole project.

Start from what Adam does, because Muon is best understood as a critique of it. Adam keeps two running averages per parameter, the gradient m and its square v, and steps by m / (√v + ε). Dividing by the root-mean-square gradient makes the step size roughly scale-invariant per coordinate: a weight whose gradients are consistently tiny still moves, and one whose gradients are huge does not blow up. AdamW's refinement is to apply weight decay directly to the weight rather than through the gradient, so the decay is not itself rescaled by v.

Notice the word per coordinate. Adam's update rule is applied elementwise, which means it makes an implicit assumption: that the parameters are exchangeable scalars whose only relevant property is their own gradient history. For a bias vector or a norm gain, that is true. For a 512×1536 weight matrix, it is false.

What a weight matrix actually does

A weight matrix is a linear map. Its meaningful structure is its singular value decomposition, W = UΣVT: a set of input directions (columns of V), a set of output directions (columns of U), and a gain σi for each pairing. What the layer does is amplify some directions and suppress others.

Raw gradients of such matrices are typically very badly conditioned: their top singular value dominates the rest by orders of magnitude. So a gradient step, and an Adam step too, moves the matrix mostly along one direction. The other directions get a step that is smaller by that same factor, which is to say they barely move at all. You are doing a low-rank update while paying for a full one.

The fix: orthogonalize the update

Muon's proposal is to take the momentum buffer B, decompose it, and throw away the singular values, keeping only the directions:

B = UΣVT  →   update = UVT every singular value becomes exactly 1; the nearest orthogonal matrix to B

UVT is the orthogonal polar factor of B: the closest orthogonal matrix in Frobenius norm. Every direction the gradient identified now receives an equally-sized step. The update still says which way to move each direction; that information is entirely in U and V, it just refuses to let the dominant direction monopolize the step budget.

momentum buffer B one direction takes the whole step σ singular value index condition number ≈ 3.6 × 10⁷ 5 NS steps orthogonalized update every direction gets an equal step σ=1 condition number ≈ 1.9 × 10⁵
What five Newton–Schulz steps do to a badly-conditioned update. Measured on a synthetic matrix with a logarithmic spectrum, exactly as the notebook reproduces it. Note the honest part: after five steps the smallest directions are still under-amplified; this is not a perfectly orthogonal matrix. It does not need to be. The purpose is to stop the top direction monopolizing the step, and a ~190× spectral compression achieves that at a fraction of an SVD's cost.
Worked example: a 3×3 momentum buffer
singular values of B : [12.0, 0.8, 0.05] Adam-style step follows B, so the first direction moves 240× further than the third, and 15× further than the second. after orthogonalization: [1.0, 1.0, 1.0] all three directions now receive the same size of step.

The update still knows which way to move each direction, that lives in U and V and is untouched. What it loses is the ability to spend the entire step budget on one direction while the other two sit still.

Newton–Schulz: orthogonalizing without an SVD

Computing an SVD every step for every weight matrix would be far more expensive than the forward pass. Newton–Schulz iteration approximates the polar factor using only matrix multiplications, which is exactly the operation accelerators are built for. Normalize B so its spectral norm is at most 1, then iterate five times:

X ← a·X + b·X(XTX) + c·X(XTX)² (a, b, c) = (3.4445, −4.7750, 2.0315)

This is a quintic polynomial applied to the singular values: because X and XTX share singular vectors, each σ independently follows σ ← aσ + bσ³ + cσ⁵. Those specific coefficients are not the textbook Newton–Schulz ones; Keller Jordan tuned them so the polynomial pushes small singular values up aggressively in very few iterations, deliberately trading a slight overshoot near σ=1 for speed. Five steps of three matmuls is cheap enough to run every step on every hidden matrix.

update ← √(max(1, rows/cols)) × orthogonalize(B) shape-aware scale, so the update's RMS magnitude is comparable across differently-shaped layers

The router, which parameters get Muon

Muon applies to 2-D hidden weight matrices only. Everything else: norm gains, biases, the token embedding, and the LM head, goes to AdamW. The embedding and head are two-dimensional, so this needs a reason rather than a shape check.

The reason is that they are not linear maps between hidden representations; they are lookup tables indexed by token. Their rows are near-independent, each row's gradient is sparse (only the tokens in this batch get one), and there is no meaningful spectrum to balance. Orthogonalizing an embedding gradient mixes updates across unrelated tokens. This split is the standard one, and the repository's router asserts it by parameter name.

Muon  : 4,423,680 params  [blocks.*.attn.{q,k,v,o}_proj, blocks.*.mlp.{gate,up,down}]
AdamW :   528,384 params  [embed_tokens, lm_head, *.attn_norm, *.q_norm, *.k_norm, final_norm]

Cautious weight decay

A February 2026 refinement, implemented behind a flag: apply weight decay only to coordinates where the decay direction agrees in sign with the optimizer's update. Where they disagree, decay is actively fighting the gradient, and masking it out there empirically improves both Muon and AdamW. The repository reports the fraction of coordinates masked, so the flag's effect is observable rather than assumed.

What it measured

OptimizerFinal val lossVal ppl Steps to targetSeconds to target
AdamW only0.40391.498 10533.5
Muon + AdamW0.38961.476 5021.1

2.1× fewer steps to reach the same loss, and 3.5% better final loss at an equal step budget. Both arms share one seed, one data order, one schedule; they differ only in the optimizer. This reproduces, at 5M parameters, the finding that the AdamW→Muon swap was the single biggest lever in the modded-nanoGPT speedrun.

Two results that made me revise what I was claiming

Muon loses to AdamW on a convex problem. My first toy benchmark was single-matrix least squares. Muon lost, clearly. That is not a bug; a convex quadratic in one matrix is precisely the regime where Adam's per-coordinate scaling is near-optimal, and there is no badly-conditioned composition of layers for orthogonalization to help with. Muon's advantage appears on deep, badly-conditioned stacks. Both outcomes are now pinned as named tests, because the negative one is what makes the positive one meaningful.

My AdamW diverges from torch.optim.AdamW by 1.9e−5 after 200 steps. That looks like a formula bug. It is not: I measured the divergence growth and it goes 1e−17 → 1e−15 → 1e−12 → 1e−5 over the run. That is floating-point noise being amplified by a chaotic trajectory, not a systematic difference ; a formula error would show a constant or linearly-growing gap from step one. The test asserts the growth pattern, which is a stronger statement than any fixed tolerance would have been.

04

Training mechanics

The plumbing that separates a loop that runs from a loop that is correct.

Packing

Documents are concatenated with <eos> separators and sliced into fixed-length windows, rather than padded per document. Padding a batch of 40-token stories to 512 wastes 92% of the compute on tokens that contribute nothing. Packing wastes none.

Token budgets, not epochs

Runs stop on tokens seen, following the Chinchilla 20:1 heuristic (D ≈ 20N). Every manifest records the fraction of the compute-optimal budget actually covered, so an under-trained run is labelled as one instead of being quietly compared against a fully-trained baseline.

Gradient accumulation

Large effective batches on small memory: run k micro-batches, scale each loss by 1/k, step once. The subtlety is that the loss must be divided before backward, not after, or gradient clipping sees a gradient k times too large and clips a healthy update.

Mixed precision with fp32 masters

Forward and backward in bf16; the master copy of every weight stays fp32. Without that, a small update added to a large bf16 weight rounds away entirely and the parameter silently stops learning. On CPU the autocast degrades to fp32 rather than erroring, which is what keeps the whole pipeline runnable with no accelerator.

Schedules

Cosine decay with linear warmup by default; warmup–stable–decay (WSD) behind a flag. WSD's appeal is that the stable phase can be extended indefinitely and the decay applied whenever you decide to stop; you do not have to know the total step count in advance, which matters when your compute budget is "until Colab disconnects".

Determinism as a tested property

Two runs at the same seed produce identical loss curves. A different seed produces a different one (also tested, otherwise the first test could pass on a loop that ignores the seed entirely). A committed golden trajectory pins what the model learns across refactors.

A real bug this found: resume was silently worse

Checkpoint resume restored the model, the optimizer state and the RNG, and then started the data iterator from the beginning of its epoch. The run continued, the loss curve looked plausible, and final validation loss came out at 5.28 against 5.09 for the uninterrupted run. The model was being re-shown data it had already seen while never reaching the tail of the epoch. Nothing errored; the only symptom was a slightly worse number that would be easy to blame on noise.

The fix is that TokenBatcher.stream() takes a starting batch offset and derives the epoch and within-epoch position from it. The test is not "resume runs" but "resume produces the bit-identical trajectory to never having stopped", which is the only version of the test that would have caught this.

05

Ablations

Controlled A/Bs on the architecture choices above. Three of the four found nothing, which is the result.

Every architectural claim in §02 came from a paper that demonstrated it at a scale 100–1000× larger than this. The honest thing to do is measure whether it reproduces here, and report the answer either way. All arms share one seed, one data order, one schedule and a fixed step budget; they differ only in the named field.

VariantVal lossVal ppl Steps to targetVerdict
default (QK-norm, zero-init, SwiGLU)0.38961.476 50baseline
− QK-norm0.38931.476 75no loss difference; 1.5× more steps
− zero-init output0.38421.468 45no measurable difference
ReLU² instead of SwiGLU0.38621.471 55no measurable difference

All three toggles land within 1.4% of the default on final loss, which is below the 2% threshold this project is willing to call a result from a single seed. Reported as "no measurable difference", not as a win for whichever arm happened to come out ahead.

The one signal that is above noise is in the steps-to-target column: without QK-norm the model needs 75 steps to reach the loss the default reaches in 50. The two converge to the same place at different rates, which is exactly what a stability aid should do, and exactly what a final-loss-only comparison would have missed.

Why steps-to-target and not wall clock

These arms ran sequentially on a shared laptop, so tokens/second is contaminated by whatever else the machine was doing. A per-step cost difference that is real (Muon adds five Newton–Schulz matmuls per 2-D weight) gets mixed with measurement noise. The step counts are the result; the seconds columns are reported but explicitly labelled indicative.

What a null result at this scale does and does not mean

A small model on a narrow corpus is exactly the regime where stability aids have little to stabilize. QK-norm exists because attention logits blow up over long training runs at large width; 400 steps at d=256 never gets there. The correct conclusion is "this experiment cannot see the effect", not "the effect is not real". These are directional confirmations, or non-confirmations, of findings obtained elsewhere.

06

Alignment

A pretrained model completes text. Turning it into something that answers you takes SFT, then preference optimization, and the preference step has a failure mode this project ran straight into.

6.1  SFT, and why the mask matters

A pretrained model is a text continuer. Ask it a question and a reasonable continuation is another question, because that is what documents containing questions look like. Supervised fine-tuning fixes this by training on (prompt, response) pairs formatted with role tokens.

The important detail is the loss mask. Cross-entropy is computed on the response tokens only; prompt tokens contribute nothing. Training on the prompt teaches the model to generate prompts, which is not the task, and it dilutes the gradient signal with tokens the model will always be given rather than asked to produce. The repository verifies this with a gradient check: perturbing a prompt-position label must leave the loss exactly unchanged.

6.2  DPO, derived

Classical RLHF has three stages: collect human preferences, fit a reward model, then optimize the language model against that reward with PPO. It works and it is a lot of machinery: a second network to train, and an RL loop that is notoriously sensitive. Direct Preference Optimization removes both. The derivation is short and worth following, because the conclusion is genuinely surprising.

Step 1. Write down the RLHF objective: maximize reward, but stay close to the reference policy so the model does not destroy its language ability chasing the reward.

maxπ  Ex~D, y~π[ r(x,y) ]  −  β KL( π(·|x) ‖ πref(·|x) )

Step 2. This constrained problem has a known closed-form solution. It is a standard result; the optimum is the reference policy reweighted by exponentiated reward:

π*(y|x) = (1 / Z(x)) · πref(y|x) · exp( r(x,y) / β ) Z(x) = Σy π_ref(y|x) exp(r(x,y)/β): intractable, sums over all possible responses

Step 3. Now invert it. Instead of solving for the policy given the reward, solve for the reward given the policy:

r(x,y) = β log( π*(y|x) / πref(y|x) ) + β log Z(x)

This says something strange: any language model is already a reward model, read off as its log-ratio against a reference, up to a term that depends only on the prompt.

Step 4. Preferences are modelled with Bradley–Terry: the probability that yw is preferred over yl is the sigmoid of their reward difference. Substitute step 3 into it, and because both responses share the same prompt, β log Z(x) appears in both terms and cancels exactly:

P(yw ≻ yl | x) = σ( r(x,yw) − r(x,yl) ) = σ( β log(πθ(yw|x)/πref(yw|x)) − β log(πθ(yl|x)/πref(yl|x)) ) the intractable Z(x) is gone

Step 5. Maximize the likelihood of the observed preferences under that expression. That is the DPO loss, and it is a plain supervised objective: no reward model, no sampling, no RL:

LDPO = −E [ log σ( β log(πθ(yw|x)/πref(yw|x)) − β log(πθ(yl|x)/πref(yl|x)) ) ]
Worked example: one preference pair
prompt "How do I make tea?" chosen "Boil water, add a tea bag, steep for three minutes." rejected "tea is a drink you can make it hot or cold whatever" β = 0.1 log πθ log πref ratio chosen −18.2 −19.0 +0.8 rejected −31.5 −28.0 −3.5 margin = 0.1 × (0.8 − (−3.5)) = +0.43 loss = −log σ(0.43) = 0.50

The model is rewarded for raising the chosen response's probability relative to the reference and lowering the rejected one's. Notice there is nothing in that arithmetic that requires log πθ(chosen) to go up. Which is the whole problem.

6.3  The failure this project hit

Look at that loss again. It depends only on the difference of two log-ratios. There are two ways to make a difference bigger: raise the first term, or lower the second. Nothing in the objective prefers the first.

My DPO run looked healthy by every standard diagnostic. The implicit reward margin climbed steadily to +7.88. Preference accuracy hit 100%. Then the aligned model went head-to-head against the SFT model it started from and lost 0–7–33.

Logging the absolute log-probabilities rather than only their difference showed exactly what happened:

no change DPO chosen -0.0454 rejected -4.2385 head-to-head 0–7–33 DPO + NLL anchor chosen +0.0104 rejected -3.9170 head-to-head 3–0–37 change in mean per-token log-probability over the run
Both bars point left for plain DPO. The margin grew because the rejected response's log-probability collapsed, and the chosen one fell too, just less far. The objective was satisfied by making the model worse at everything, slightly less so at good answers. Adding the NLL anchor flips the chosen bar positive, and the head-to-head with it.
MethodΔ log p(chosen)Δ log p(rejected) Δ marginWins–losses–ties vs SFT
DPO−0.0454−4.2385 +4.19310–7–33
DPO + NLL anchor+0.0104−3.9170 +3.92743–0–37
SimPO−0.0223−2.2134 +2.19110–4–36

The margin rose because the rejected response's likelihood fell off a cliff, and the chosen response's likelihood fell too, just less far. The model was not learning to produce good answers; it was learning to produce nothing in particular, slightly less unlike good answers than bad ones. A rising margin was concealing a model getting worse at everything.

The fix, and why it generalizes

Add an auxiliary negative-log-likelihood term on the chosen response: the RPO-style anchor, exposed here as align.preference.sft_loss_weight. It pins the absolute likelihood so the objective can no longer satisfy itself by pushing everything down. Δ log p(chosen) flips positive and the head-to-head becomes 3–0–37.

The transferable lesson is not about DPO. Any objective defined on a difference can be satisfied by moving both terms. The remedy is never a better margin metric; it is reporting the terms.

Length exploitation

The other known DPO pathology: log π(y) is a sum over tokens, each negative, so longer responses have systematically lower log-probability. The objective can be partly satisfied by changing length rather than content, and DPO-tuned models are widely observed to become verbose. The repository measures this directly: not by comparing the lengths of training responses, which only describes the data, but by measuring what the model actually generates for held-out prompts, before and after. Here mean generated length went 26.6 → 24.3 tokens: no exploitation at this scale, on this data.

6.4  SimPO and GRPO

SimPO, reference-free
Drops πref entirely, normalizes by length, and adds an explicit target margin γ: −log σ( (β/|yw|) log π(yw) − (β/|yl|) log π(yl) − γ ). Length normalization attacks the verbosity bias at its root, and removing the reference model halves the memory needed for preference tuning; you no longer hold a frozen copy of the network. It produced the largest reward margin here (+16.2) and still lost its head-to-head, which is a useful reminder that the margin is not the objective you care about.
GRPO; RL with a verifiable reward
For tasks where correctness can be checked by a program (arithmetic here), you do not need a learned reward model at all. Sample a group of G completions per prompt, score each 0/1, and use the group itself as the baseline: Ai = (ri − mean(r)) / std(r). No value network, which is what makes this cheap enough to matter. The KL penalty to the reference uses the k3 estimator, E[ρ − log ρ − 1] with ρ = πrefθ, unbiased, and unlike the naive −log ρ it is guaranteed non-negative, so it cannot pay the policy to move away from the reference.

The judge used for head-to-head comparisons is programmatic and stated in the code: on-topic overlap with the prompt, absence of degenerate repetition, and whether the model emitted <eot> rather than running to the token cap. Those are the properties the preference labels encode, so this measures did the model learn the labels: not is the model good. It is deliberately length-insensitive, so a model that learned to game length gains nothing from it.

The inference cost model

One number explains every technique in Arc 2. Without it they look like three unrelated tricks; with it they are three attacks on the same bottleneck.

Generation has two phases with completely different cost characteristics, and conflating them is the most common way to reason wrongly about inference.

Prefill processes the whole prompt at once. Every weight matrix is loaded from memory once and multiplied against T token vectors. Lots of arithmetic per byte loaded ; this phase is compute-bound, and it parallelizes beautifully.

Decode generates one token at a time, and each token depends on the one before it, so it cannot be parallelized across time. To produce a single token you must read every weight in the model from memory and use each one for exactly one multiply-accumulate.

arithmetic intensity of decode = 2N FLOPs / (N × bytes_per_weight)   fp16: 2N / 2N = 1 FLOP per byte

Now compare that to what the hardware wants. An A100 delivers roughly 312 TFLOP/s of bf16 arithmetic against 1.5 TB/s of memory bandwidth, a ratio of about 200 FLOPs per byte. Decode offers it one. The arithmetic units sit idle by a factor of two hundred while the memory bus does all the work.

Make that concrete. A 7B model in fp16 is 14 GB. Reading 14 GB at 1.5 TB/s takes 9.3 ms. So the hard ceiling is about 107 tokens/second, for a single sequence, no matter how fast the GPU can multiply. You are not compute-limited. You are waiting for memory.

Worked example: why a 7B model tops out around 107 tokens/second
weights 7B params × 2 bytes (fp16) = 14 GB A100 bandwidth = 1,555 GB/s one decode step must read all 14 GB: 14 / 1555 = 9.0 ms → 111 tokens/second, ceiling arithmetic actually done in those 9 ms: 2 × 7e9 = 14 GFLOP what the card could have done in 9 ms : 2,800 GFLOP utilisation : 0.5%

Buying a card with twice the arithmetic throughput changes this number by nothing at all. Halving the bytes doubles it. That single asymmetry is why Arc 2 is about bytes and passes rather than about FLOPs.

PREFILL, compute bound weights 14 GB 24 prompt tokens per weight read ALUs saturated DECODE, memory bound weights 14 GB 1 token per weight read ALUs ~99.5% idle every Arc 2 technique either shrinks the bytes read, or gets more tokens out of one read
The asymmetry that Arc 2 exploits. Prefill amortizes one weight read over the whole prompt. Decode amortizes it over one token. The gap between those two is where every inference optimization lives.
Now the three techniques stop looking arbitrary

Each attacks the same ratio from a different side:

  • Distillation shrinks N; there are simply fewer weights to read.
  • Quantization shrinks bytes_per_weight: the same weights, in a quarter of the bytes.
  • Speculative decoding increases the numerator: verify several candidate tokens in one pass, so one weight read yields several tokens instead of one.
And this is exactly why the wall-clock results in this project are honest but unimpressive

At 5M parameters on a CPU, there is no memory-bandwidth bottleneck. The weights fit comfortably in cache, and a forward pass is dominated by Python dispatch overhead. Quantization removes bytes that were not costing anything, and speculation adds Python work to save passes that were cheap. So the repository measures speculative decoding as 0.79× slower in wall clock while cutting target forward passes 3×. Both numbers are real. The pass-count reduction is the hardware-independent one, and it is the one reported as the result.

07

Distillation

Train a small model to imitate a big one. The interesting question is not how; it is which direction of the KL divergence you minimize, because the two answers behave oppositely.

A trained model's output distribution carries far more information than the one-hot label it was trained on. Told the next token is  cat, the label says nothing else. The teacher's distribution says  cat 0.6,  dog 0.25,  kitten 0.1, encoding that dogs are more like cats than staplers are. Hinton called these the dark knowledge in the logits, and distillation is training a student on that distribution rather than on the labels.

7.1  Forward KL: the baseline, and its problem

L = α H(y, student) + (1 − α) τ² KL( teacherτ ‖ studentτ ) τ softens both distributions; the τ² factor restores the gradient magnitude, which softening scales by 1/τ²

Write out what KL(p ‖ q) = Σ p log(p/q) punishes. The sum is weighted by p, so it only cares about places the teacher puts mass. And wherever the teacher has mass and the student has none, log(p/q) → ∞. The student is therefore forced to put probability everywhere the teacher does, including the long low-confidence tail. This is mode-covering.

For a student with comparable capacity that is fine. For a student 17× smaller it is a trap: it cannot represent the teacher's full distribution, so being punished for every gap forces it to smear probability thinly across regions it cannot model well, including the empty space between modes, where the teacher has nothing but the student now does.

7.2  Reverse KL, permission to give up

Swap the arguments. KL(q ‖ p) = Σ q log(q/p) is weighted by the student, so it only cares about places the student puts mass, and the infinite penalty now lands where the student puts mass and the teacher does not. The student may ignore as much of the teacher as it likes, provided everything it does claim is something the teacher agrees with. This is mode-seeking, and for an undersized student it is the right trade.

forward KL, mode covering puts mass in the valley the teacher never visits reverse KL, mode seeking abandons one mode, commits to the other grey: teacher  ·  teal: student
The same teacher, the same student capacity, opposite failure modes. A student that must cover everything ends up assigning real probability to the gap between modes, text that is a blend of two plausible continuations and is itself implausible. A student allowed to abandon a mode produces narrower but self-consistent output. This is why perplexity ranks these two backwards from generation quality.
Worked example: a teacher with two modes, a student that can only fit one
teacher p = [0.45, 0.45, 0.10] two good continuations plus a tail student A = [0.50, 0.50, 0.00] picks both modes, drops the tail student B = [0.33, 0.33, 0.34] spreads out to cover everything forward KL(p ‖ q): A = ∞ (p₃>0 where q₃=0) B = 0.15 reverse KL(q ‖ p): A = 0.11 B = 0.31

Forward KL calls student A infinitely bad for ignoring a 10% tail, and prefers B, which has smeared a third of its mass onto the teacher's least-likely option. Reverse KL prefers A. When the student is 17× smaller and cannot represent the tail anyway, A is the model you want, and B is the one that generates the mush.

7.3  Why reverse KL needs a policy gradient

There is a catch that makes reverse KL harder to implement than forward KL. The expectation Σ q log(q/p) is taken over the student's own distribution, and the student is what you are differentiating. You cannot just backpropagate through a sample. This is the standard score-function situation, and MiniLLM's contribution is the machinery to make it stable:

  • On-policy sampling. Trajectories come from the student, which also fixes exposure bias; the mismatch where a model trained only on teacher-generated prefixes has never seen its own mistakes and cannot recover from them.
  • Reward-to-go decomposition. Credit each token with the reward accumulated from that point forward, rather than a single sequence-level score, which drastically cuts variance.
  • Single-step regularization. A per-token term added directly, sidestepping the policy-gradient estimator for the part that does not need it.
The failure that taught me why the warm-start is not optional

My first reverse-KL runs produced students with perplexity 1028, against 28 and 129 for the other two objectives. I assumed a bug in the policy gradient. There was no bug. A policy gradient optimizes the distribution of trajectories the policy actually generates, and a randomly-initialized student generates noise. Reverse KL was faithfully teaching it to produce slightly less surprising noise, from a starting point it could never escape.

MiniLLM's paper says to warm-start with plain MLE. Adding that (applied identically to all three arms, so the comparison stays controlled) moved the three to 2.05 / 4.45 / 41. Then scaling the learning rate down for the on-policy phase; a policy gradient is far higher-variance than a supervised loss, gave the final 2.05 / 4.45 / 2.50. Both fixes are config fields with defaults, not hard-coded constants, so their effect stays measurable.

7.4  What was measured

ObjectiveStudent val pplRepetition rate Gen lengthWall clock
forward KL2.05360.006445.518.2 s
SeqKD4.44990.037239.233.5 s
reverse KL, on-policy2.49740.000030.218.1 s
teacher, for reference, 0.038346.8,

Reverse KL has worse perplexity and better generations, and that is the MiniLLM finding, reproduced. It is not a contradiction. Perplexity rewards a model for spreading probability over everything in the evaluation set, which is precisely the mode-covering behaviour reverse KL is designed to avoid. A mode-seeking student scores worse on a coverage metric and degenerates less when it actually generates.

The sharpest number in that table is the repetition rate: 0.0000 for the reverse-KL student against 0.0383 for its own teacher. Distilling on-policy against a teacher's distribution is not the same as copying its outputs, and the student can end up better-behaved than the model it learned from. Judging these three by perplexity alone would have ranked them backwards, which is exactly why the repetition diagnostic is reported beside it.

Compression: 4,952,064 → 279,168 parameters (17.7× overall, 29.9× on non-embedding parameters). Teacher and student share a tokenizer by necessity, so the embedding table and LM head are the same width in both and their cost is irreducible; the non-embedding figure is the one that describes the actual depth and width reduction.

08

Quantization

Store weights in 4 bits instead of 32. The good algorithms all rest on one idea: minimize the error in what the layer outputs, not in the weights themselves.

The mechanism is simple. Pick a scale, round each weight to the nearest point on a 2k-point grid, store the integer:

s = (max − min) / (2k − 1)   z = −round(min / s) q = clamp( round(w / s) + z , 0 , 2k−1 )   ŵ = s (q − z)

One scale for a whole matrix is far too coarse: a single outlier weight stretches the grid and everything else collapses onto a few levels. So scales are computed per group of columns (64 here). That costs storage, and the repository accounts for it honestly:

effective bits = k + (scale_bits + zero_bits) / group_size   4-bit, group 64, fp16 scale + fp16 zero = 4 + 32/64 = 4.5 bits every "4-bit" figure in this project is plotted at 4.5
Worked example: one group of weights at 4 bits
weights [ 0.42, −0.13, 0.88, −0.71, 0.05 ] min −0.71, max 0.88 → s = 1.59/15 = 0.106, z = 7 quantised [ 11, 6, 15, 0, 7 ] <- 4 bits each restored [ 0.424, −0.106, 0.848, −0.742, 0.000 ] error [ +0.004, +0.024, −0.032, −0.032, −0.050 ]

Each weight lands within half a step (0.053) of where it started, which is the best any independent rounding can do. The last weight, 0.05, suffers most in relative terms: it rounds to exactly zero. Whether that matters depends entirely on how large the activation it multiplies usually is, and that is the information RTN throws away and GPTQ uses.

8.1  Why round-to-nearest is not the best you can do

RTN rounds every weight independently to its closest grid point. That minimizes ‖W − Ŵ‖, the error in the weights. But nobody cares about the weights. A layer exists to compute WX, and what matters is:

minimize  ‖ WX − ŴX ‖²F weighted by the actual input statistics X, not by the weights alone

Those are different objectives. A weight multiplying an input channel that is almost always zero can be rounded badly at no cost; a weight multiplying a high-variance channel cannot. And crucially, the rounding errors interact, if one weight rounds up, another can round down to partially cancel it in the output.

8.2  GPTQ: spend the error where it does least damage

Expand that objective and the second-order structure appears immediately. The Hessian of the reconstruction error with respect to the weights of one row is:

H = 2 X XT estimated from a few hundred calibration sequences; identical for every row of the layer

GPTQ (following Optimal Brain Quantization) processes columns one at a time. Quantize column q, measure the error you just introduced, and then adjust all the not-yet-quantized columns to compensate for it, using the Hessian to work out which adjustment best cancels the error in the output:

δremaining = − ( wq − ŵq ) / [H−1]qq × [H−1]q,:

The later columns absorb the earlier columns' mistakes. By the end, the accumulated output error is far smaller than independent rounding would give, even though the individual weights have moved further from their original values.

Three engineering details make this practical rather than theoretical, and all three are in the repository: a Cholesky factorization of H−1 for numerical stability (the naive iterative inverse-update becomes indefinite and produces NaNs on real layers), lazy block updates that batch the compensation over 128 columns instead of applying it one at a time, and activation ordering: quantizing the highest-activation-magnitude columns first, while the largest pool of un-quantized columns is still available to absorb their error.

A real bug: activation ordering made GPTQ worse than RTN

With act_order enabled, my 2-bit GPTQ was losing to plain round-to-nearest. The cause: to restore the original column order after quantizing in permuted order, the code was un-permuting the dequantized matrix, which meant re-quantizing it, adding a second, uncompensated rounding pass on top of the error the algorithm had just carefully minimized. The fix is to carry the permutation with the integer codes (QuantizedTensor.perm) and apply it at dequantization time, so the codes are only ever produced once. After the fix GPTQ wins at every bit-width where there is anything to win.

And a bug in my test, not my code

I wrote a test asserting "GPTQ's advantage comes from the Hessian's off-diagonal structure", checking that GPTQ's edge over RTN disappears with white (uncorrelated) calibration activations. It failed: error compensation helps even with white activations, because the benefit comes from sequentially absorbing error at all, not only from cross-channel correlation. The hypothesis was wrong, not the implementation. It is now two narrower tests that are each true. While fixing it I also found the test was measuring act_order's effect as exactly zero: because I built the synthetic activations with logspace, which is already sorted, so the "ordering" permutation was the identity. A shuffle now makes it non-vacuous.

8.3  AWQ: protect the channels that matter

A different attack on the same problem. AWQ observes that a small fraction of weight channels are salient: they multiply consistently large activations, so their error dominates the output. Rather than compensating after the fact, scale those channels up by s before quantizing and divide the corresponding activation by s:

W X = (W · diag(s)) · (diag(s)−1 X) mathematically identical, but the scaled-up channels now span more of the quantization grid, so their relative rounding error shrinks

No mixed precision, no second pass over the data at inference: the scaling is folded into the weights, and s is found by a small grid search on calibration statistics.

8.4  What was measured

MethodNominal bitsEffective bits Val perplexityvs fp32Mean layer weight error
FP323232.01.47641.000×0.0
RTN44.51.47671.000×0.0917
GPTQ44.51.47661.000×0.1351
AWQ44.51.47651.000×0.0737
RTN33.51.47831.001×0.1962
GPTQ33.51.47641.000×0.2919
RTN22.51.54051.043×0.4590
GPTQ22.51.49971.016×0.7141
AWQ22.51.54681.048×0.3695
The row that proves the whole argument

At 2 bits, GPTQ has the worst weight error of the three (0.714 against RTN's 0.459 and AWQ's 0.370) and the best perplexity (1.4997 against 1.5405 and 1.5468). It deliberately moves weights further from their original values in order to move the layer's outputs closer. If you had been measuring weight error, you would have concluded GPTQ was the worst method here. That inversion is the entire thesis of second-order quantization, and it shows up cleanly at 5M parameters.

Negative result: GPTQ does not beat RTN at 4 bits here

The plan predicted GPTQ would beat RTN at 4 bits "by a clear margin". It does not; all three methods are indistinguishable from fp32 (1.4767 / 1.4766 / 1.4765 against a 1.4764 baseline). There is no margin left to win: a 5M-parameter model on a narrow corpus has little redundancy for 4-bit rounding to destroy in the first place. The separation only appears at 2 and 3 bits, where there is real damage to compensate for. Reporting a tie is the honest outcome, and the prediction is left in the write-up rather than quietly deleted.

KV-cache quantization

The weights are not the only thing occupying memory bandwidth. As the cost model showed, the KV cache can exceed the model itself at long context and large batch. It is quantized with the same machinery: per-token, per-head scales, with independent bit-widths for keys and values, since keys are empirically more sensitive than values. The repository reports the memory saved and the perplexity cost side by side, at several context lengths, because the trade only becomes worthwhile as context grows.

09

Speculative decoding

Guess several tokens with a cheap model, check them all with the expensive one in a single pass, and; this is the surprising part, get output that is exactly as if the expensive model had generated every token itself.

Recall the cost model: a decode step reads every weight to produce one token, and the arithmetic units are 99.5% idle. That idle capacity is the opportunity. A forward pass over γ+1 positions costs almost exactly the same wall-clock time as a pass over one position, because both are bounded by the same weight read.

So: let a small draft model generate γ candidate tokens autoregressively (cheap; it is a small model). Then feed the whole candidate sequence to the target model in one forward pass. Because the sequence is known, the target can score every position in parallel, exactly as in training. Now decide which candidates to keep.

1: draft proposes γ=4, sequentially (4 cheap passes) prefix…  the  cat  sat  down 2, target scores all 5 positions in ONE pass single target forward pass → p₁ p₂ p₃ p₄ p₅ 3: accept/reject, left to right  the accept  cat accept  sat accept  down reject  quietly from residual 4 tokens produced 1 target pass spent
One target pass, four tokens. Note the last box: a rejection is not wasted work. The target's distribution at that position is already computed, so the round always yields at least one token: speculation degrades to ordinary autoregressive decoding in the worst case, never worse.

9.1  The accept rule, and why it is exactly lossless

The naive approach, accept a draft token if the target also likes it, would bias the output. The correct rule is a form of rejection sampling:

draw x ~ q(·)  the draft's proposal accept with probability  min( 1 , p(x) / q(x) ) on rejection, draw from  norm( max(0, p − q) )  the residual

The claim is that the token you end up with is distributed exactly as p. Here is the whole proof; it is four lines.

P(output = x)   = P(draft proposed x) · P(accept | x)  +  P(reject) · P(residual gives x) P(proposed x, accepted) = q(x) · min(1, p(x)/q(x)) = min(p(x), q(x)) P(reject) = 1 − Σy min(p(y), q(y)) = Σy max(0, p(y) − q(y)) since Σp = 1, and p = min(p,q) + max(0, p−q) pointwise P(residual gives x) = max(0, p(x) − q(x)) / Σy max(0, p(y) − q(y)) ⇒ P(output = x) = min(p(x), q(x)) + max(0, p(x) − q(x)) = p(x)  ∎

The normalizing constant of the residual is exactly the rejection probability, so the two cancel. Notice what the proof does not assume: nothing about q at all. The draft can be a badly-trained model, a random one, or an adversarial one, and the output distribution is still exactly the target's. A bad draft costs you speed, never correctness.

Worked example: one token, three outcomes
draft proposes " cat", with q(" cat") = 0.30 target says p(" cat") = 0.45 → accept with prob min(1, 0.45/0.30) = 1.00 the target likes it more; always keep it draft proposes " cat", with q(" cat") = 0.30 target says p(" cat") = 0.12 → accept with prob min(1, 0.12/0.30) = 0.40 keep it 40% of the time; otherwise resample on rejection, draw from norm(max(0, p − q)) over the whole vocabulary, which is the part of the target's mass the draft under-supplied.

The rule is doing bookkeeping, not judgement. It keeps a proposal exactly as often as the target would have produced it, and makes up any shortfall from the residual. That is why the arithmetic comes out to exactly p and not approximately.

Interactive, verify it yourself

A deliberately poor draft q against a target p over six tokens. Draw samples through the accept/reject rule above and watch the empirical distribution converge to p: not to q, and not to anything in between.

No samples drawn yet.

9.2  What actually costs what

The expected number of tokens per target pass, for acceptance rate α and draft length γ, is the mean of a truncated geometric:

E[tokens per target pass] = (1 − αγ+1) / (1 − α)

This explains the shape of the results below. Larger γ has diminishing returns ; each extra proposal is only reached if all before it were accepted, while the draft cost grows linearly in γ. There is an optimum, and it moves with α.

9.3  Medusa: speculation without a second model

Maintaining a separate draft model is operationally annoying: two checkpoints, two tokenizers to keep in sync, extra memory. Medusa's alternative is to bolt extra prediction heads onto the target itself: exactly the multi-token-prediction heads from §02.8. Head k predicts token t+k, each head proposes its top few candidates, and the cross product forms a tree of candidate continuations.

Verifying a tree in one pass needs a custom attention mask: every node must attend to its ancestors and to nothing else, so that sibling branches cannot see each other. The nodes are flattened into a sequence and the mask encodes ancestry.

A real bug: tree attention with the wrong position IDs

My packed tree evaluated differently from the same paths evaluated one at a time. The mask was correct; the position IDs were not. I was numbering nodes by their index in the flattened packing order, but a node's position for RoPE purposes is its depth in the tree, two siblings at depth 2 are both at prefix_len + 2, regardless of where they landed in the packing. With packing-order IDs, RoPE placed sibling branches at different distances from the prefix and quietly scored them wrong. The fix is a tree_position_ids() function derived from depth, and a test that compares packed-tree evaluation against per-path evaluation token for token.

9.4  What was measured

ArmTarget passesTokens / pass AcceptanceTokens/svs baseline
autoregressive5200.98, 677.41.00×
speculative γ=22452.090.590483.20.71×
speculative γ=42032.520.424463.20.68×
speculative γ=61742.940.367535.50.79×
autoregressive + GPTQ-4bit5200.98, 774.81.14×
speculative γ=6 + GPTQ-4bit1762.910.362535.80.79×

3× fewer target forward passes at γ=6, and the two levers compose; you can speculate against a quantized target and keep essentially the same acceptance rate (0.367 vs 0.362), because 4-bit quantization barely moves the target's distribution.

And it is slower in wall clock, by 21%

Read the target-pass column, not the tokens/second column. Speculation converts a memory-bandwidth problem into a compute problem, and at 5M parameters on a CPU there is no memory-bandwidth problem to convert; a forward pass is dominated by Python dispatch, and speculation adds dispatch (the draft's γ sequential passes, the accept loop, the cache surgery) to save passes that were cheap. The pass reduction is the real, hardware-independent result; the wall-clock number is honest and does not generalize, and both are reported.

10

Serving and the unified benchmark

Five variants, one table, and an explicit statement of which columns generalize beyond this hardware.

The serving layer is deliberately small: streaming generation with a KV cache, sampling transforms (temperature, top-k, top-p, repetition penalty) shared with the speculative path so the two cannot drift apart, stop-sequence handling, and a prefill/decode timing breakdown.

A real bug: the streamer swallowed stop sequences

Streaming means decoding tokens to text as they arrive, and a multi-byte UTF-8 character can straddle a token boundary, so bytes.decode() on a partial buffer throws. My first implementation wrapped it in try/except and held the bytes back until they decoded. That is correct for a split character and catastrophic for a genuinely invalid byte: the buffer never decodes, so it grows forever, and every subsequent stop-sequence check runs against text that is missing everything after that point. Generation silently ignores its stop sequences from then on.

The fix is to use the thing designed for this, codecs.getincrementaldecoder("utf-8")("replace"), which holds back genuinely incomplete sequences and emits a replacement character for genuinely invalid ones, so the buffer always drains.

The variants table

Every Arc 2 lever, measured on the same prompt, the same token count, and the same machine, with the composed variants included.

VariantParamsWeights KV @ ctxDecode tok/sLatency p50 Val pplTiny bench
base (fp32)4,952,06418.89 MB 0.47 MB729.291.3 ms 1.4764100.0%
distilled (reverse-KL)279,1681.06 MB 0.06 MB1765.821.6 ms 2.522267.9% ± 8.8
GPTQ 4-bit4,952,0644.38 MB 0.47 MB756.087.8 ms 1.4766100.0%
speculative (γ=6)5,231,23219.96 MB 0.53 MB597.3107.2 ms 1.4764,
speculative + GPTQ 4-bit5,231,2325.44 MB 0.53 MB583.5109.7 ms 1.4766,

How to read this table

The weight column is the representation size, not the tensor size
The 4-bit rows are simulated in fp32, because there is no int4 CPU kernel to run them on. Reading the footprint off the tensors would therefore report a 4-bit model as 32-bit. The figure shown is computed from the effective bit-width including the stored scales; the size the model would occupy with a real kernel.
Speculative rows include the draft's weights and cache
Speculation is not free in memory; it trades space for target passes. Reporting only the target's footprint would hide the trade.
Decode throughput does not generalize; the pass counts do
This is a CPU measurement at 5M parameters, where a forward pass is bound by Python dispatch rather than weight loading. Both compression levers therefore show their memory win and no speed win, and speculation shows a speed loss. The cost model explains exactly why, and the target-pass reduction, which is hardware-independent, is the number to carry away.
The tiny benchmark is saturated
28 hand-written questions across subject–verb agreement, coreference, schema completion and arithmetic, scored by likelihood against a stated 50% chance line. The base model gets 100%, so it is a degradation detector for Arc 2, not a quality ladder. An unchanged score means compression did not break the capabilities it probes, and nothing stronger.
The one clean composition result

Distillation gives 17.7× smaller weights and 2.4× the decode throughput, at a real capability cost (tiny bench 100% → 67.9%). GPTQ-4bit gives 4.3× smaller weights at no measurable perplexity or benchmark cost. And speculation composes with quantization: running the target 4-bit changes the acceptance rate from 0.367 to 0.362, which is to say not at all: because 4-bit quantization barely moves the distribution the draft is trying to predict.

A test bug worth confessing: the benchmark was measuring nothing

One tiny-benchmark category scored exactly 0.5: chance, which I first read as "the model has not learned agreement". It had. The probe presented a single sentence, but the model was trained on multi-sentence stories, so the prompt was structurally off-distribution and the model was effectively being asked a question in a format it had never seen. Rewriting the probe to use a full story prefix took the same model from 6/12 to 12/12. The lesson is that an evaluation returning exactly chance is more likely to be broken than informative, and that "the model is bad" is the more comfortable conclusion, which is why it needs checking first.

All results

Every number produced by a committed script, stamped with the git commit and hardware that produced it, and regenerable offline from committed data.

The natural-language model

The micro tier, 40.4M parameters, trained on TinyStories: a corpus of short stories written with the vocabulary of a small child, built by Eldan & Li precisely to test whether sub-100M models can be coherent. That capability match is the point: the same 40M parameters given general web text learn the shape of English and produce fluent nonsense, because the distribution is far wider than the capacity.

0 2 4 7 9 11 0 1,300 2,600 3,900 5,200 ln(16384) = 9.704 — uniform-guess floor 1.513 · ppl 4.54 micro · 40.4M params · TinyStories · 32.0M tokens · 3.2 h step
Starting exactly at ln(16384) = 9.7041, as the zero-init scheme predicts. The first logged loss was 9.7041: agreement to five decimals with a value derived from theory, not from a previous run. This is an in-progress run against a deliberately reduced budget; see the caveat below.

Sampled from that checkpoint at 3.07M tokens, temperature 0.7:

prompt → completion

Tom found a shiny red box in the garden. When he opened it, he found a shiny red ball. The red ball was very pretty. Tom played with the red ball all day. He made it very happy. He was so happy to have his red ball back. But then, something unexpected happened. The red ball started to move and fell down. Tom and his red ball were stuck in the tree.

Once upon a time there was a little girl named Mia who loved to play with her toys. One day, she found a big bowl of the paper in her room. She was very happy to see what was inside the box. Mia's mom said, "Sue, it is time to clean the box."

Read that carefully, because the failures are as informative as the successes. The grammar is correct throughout. Tense, agreement and pronoun reference hold across sentences. Dialogue is punctuated properly. Narrative structure: setup, complication, "But then, something unexpected happened", is clearly learned. What breaks is entity tracking: the bowl becomes paper becomes a box, and Mia's mother calls her Sue. That is exactly the signature of an under-trained model: syntax is learned from local statistics and arrives early; maintaining a consistent world state across a paragraph needs far more data.

This run is under-trained, deliberately and measurably

A Chinchilla-optimal budget for 40.4M parameters is ~808M tokens. This run has seen 3.07M , 0.4% of compute-optimal: because it is training on a laptop GPU at ~2,270 tokens/second, and the full budget would take four days. The run is continuing against a 32M-token target. Every manifest records the Chinchilla fraction precisely so an under-trained run is labelled as one rather than quietly compared against a fully-trained baseline. The honest headline is the pipeline produces coherent English from scratch on free hardware, not this is a good language model.

The CI/teaching tier

0.0 1.6 3.2 4.8 6.4 8.0 0 100 200 300 400 step cross-entropy (nats/token) ln(V) = 6.93 — uniform-guess floor 0.390 · ppl 1.48 nano · 5.0M params · toy corpus · 95 s on a laptop CPU train validation
The nano tier: 4,952,064 parameters, 819,200 tokens, 95 seconds on a laptop CPU. This is the tier the whole test suite and every ablation runs on, which is what makes a 563-test suite and six controlled A/B studies affordable. It trains on a synthetic story grammar, so it demonstrates that the pipeline learns structure: agreement, coreference, narrative shape: not that it models open-domain text.

The quantization frontier

1.470 1.491 1.512 1.534 1.555 2.5 3.5 4.5 8.5 effective bits per weight (incl. stored scales) validation perplexity fp32 = 1.4764 RTN GPTQ AWQ RTN 1.5405 GPTQ 1.4997
Plotted against effective bits, so the stored group scales are counted. All three methods are indistinguishable from fp32 from 3.5 bits upward; the predicted 4-bit GPTQ win does not happen at this scale. The separation appears at 2.5 bits, where GPTQ reaches 1.4997 against RTN's 1.5405 and AWQ's 1.5468, while having the largest weight error of the three.

Everything, in one table

ClaimMeasurementProduced by
Muon converges faster than AdamW 50 vs 105 steps to target loss; 0.3896 vs 0.4039 final scripts/ablate.py
QK-norm / zero-init / SwiGLU improve final loss Not confirmed at 5 seeds: p = 0.256 / 0.035 / 0.495, and the one that clears α=0.05 does not survive Holm–Bonferroni scripts/ablate_multiseed.py
QK-norm speeds convergence and stabilises it p < 0.0001 on steps-to-target; 18× lower run-to-run variance scripts/ablate_multiseed.py
Muon is more stable than AdamW across seeds F = 176.4, p = 0.0002, 176× smaller spread scripts/ablate_multiseed.py
Beats GPT-2 on in-domain bits per byte 0.5485 vs 0.9385 with 3.1× fewer parameters; reverses out of domain scripts/external_baseline.py
Has learned syntactic agreement Refuted, 94% simple, 44% with an attractor: linear recency, not syntax scripts/evaluate.py
DPO alone beats its SFT starting point Refuted, loses 0–7–33; likelihood collapse scripts/align_pipeline.py
DPO + NLL anchor beats SFT 3–0–37, with Δ log p(chosen) restored to positive scripts/align_pipeline.py
Reverse-KL distillation degenerates less repetition 0.0000 vs 0.0064 (fwd-KL), 0.0372 (SeqKD), 0.0383 (the teacher) scripts/distill_compare.py
Distillation shrinks the model 17.7× overall, 29.9× on non-embedding parameters scripts/distill_compare.py
GPTQ beats RTN at 4 bits "by a clear margin" Refuted; a tie (1.4766 vs 1.4767) scripts/quantize_frontier.py
GPTQ beats RTN where bits are scarce 1.4997 vs 1.5405 at 2 bits, with higher weight error scripts/quantize_frontier.py
Speculation cuts target forward passes 2.94 tokens per target pass at γ=6, vs 0.98 autoregressive scripts/specdec_bench.py
Speculation is faster in wall clock Refuted at this scale, 0.79×; see the cost model scripts/specdec_bench.py
Speculation is distribution-preserving Greedy identical token-for-token; 120,000-sample distributional test passes tests/unit/test_specdec.py
Speculation composes with quantization acceptance 0.367 → 0.362 with a 4-bit target, unchanged scripts/bench_all.py
The whole pipeline runs on a CPU tokenizer→pretrain→SFT→DPO→quantize→speculate in 45 s tests/e2e/test_smoke.py

What went wrong

Six real bugs and four wrong hypotheses. The distinction between the two categories is the one that matters.

Debugging a machine-learning pipeline is unusual in that most failures do not raise. The model trains, the loss goes down, and the number at the end is simply worse than it should be, which is indistinguishable from "this is how well the method works" unless you have something to check against. Every entry below was found by having something to check against.

Bugs in the code

What brokeSymptomCause and fix
Checkpoint resume Resumed run ended at val loss 5.28 vs 5.09 uninterrupted. No error. Model, optimizer and RNG were restored; the data iterator restarted at batch 0. Now TokenBatcher.stream() takes an offset, and the test asserts bit-identical continuation rather than "resume runs".
GPTQ activation ordering 2-bit GPTQ lost to plain round-to-nearest. Un-permuting the dequantized matrix re-quantized it, adding a second uncompensated rounding pass. The permutation now travels with the integer codes.
Medusa tree positions Packed-tree evaluation disagreed with per-path evaluation. Position IDs came from packing order, not tree depth, so RoPE placed sibling branches at wrong distances. Added tree_position_ids().
Streaming text decoder Stop sequences silently stopped working after certain bytes. try/except around bytes.decode() buffered invalid bytes forever. Replaced with an incremental UTF-8 decoder in replace mode.
Accumulation dtype fp64 reference tests could not agree past 1e−5. x.float() promotes bf16 and silently demotes float64. Added accumulation_dtype(); tolerances tightened to 1e−10.
Missing MLE warm-start Reverse-KL students reached perplexity 1028. Not a bug in the gradient: a policy gradient on a randomly-initialized student optimizes the distribution of noise. Added the warm-start MiniLLM specifies, applied identically to all arms so the comparison stays controlled.

Hypotheses that were wrong: the tests, not the code

"BPE is subadditive"

Hypothesis found "eps" (1 token) + "ep" (1 token) → "epsep" (3 tokens). Greedy merging is not monotone. The counterexample is now the regression test.

"Muon always beats AdamW"

It loses on convex single-matrix least squares; the regime where Adam's per-coordinate scaling is near-optimal. Both results are now tests.

"GPTQ's edge needs the Hessian's off-diagonals"

Error compensation helps with white activations too. Split into two narrower true claims , and while fixing it I found the test was measuring act_order as exactly zero, because I had built the synthetic data with logspace, which is already sorted.

"The model can't do agreement"

A probe scoring exactly chance is more likely broken than informative. The probe was a single sentence; the model was trained on multi-sentence stories. Same model, proper prefix: 6/12 → 12/12.

The pattern

Four of the six code bugs were found by a test comparing an optimized path against a slow obvious one: fast attention against a naive loop, packed tree against per-path, resumed run against uninterrupted, fast rotation against an fp64 reference. That is the single highest-yield testing technique in this project, and it is available for almost any performance optimization: keep the stupid version, and assert they agree.

How it is measured

Perplexity cannot compare two models with different tokenizers, and a benchmark everything scores 100% on measures nothing. Both were true here, and both were fixed.

This section exists because the project's original evaluation was its weakest part: token perplexity plus a 28-question quiz the base model saturated. Neither can support a claim. What replaced them is four metrics that each answer a question the others cannot.

Bits per byte; the only number that compares across models

Perplexity is per token, and token boundaries are a property of the tokenizer, not of the text. A model with a 50,257-token vocabulary needs fewer tokens for a sentence than one with 16,384, so each of its tokens carries more information and its per-token perplexity looks worse: even if it predicts the underlying text better. Comparing perplexity across tokenizers is meaningless, and it is done constantly.

BPB = ( Σ negative log-likelihood in nats / ln 2 ) / (UTF-8 bytes of the text) the denominator is a property of the text, which no tokenizer can change

Both models score the same held-out strings, each with its own tokenizer, and the results become directly comparable. This is what the Pile and Chinchilla papers use, and it is what makes the comparison below a measurement rather than an artefact.

0.0 0.9 1.8 2.7 3.6 0.55 0.94 1.08 tinystories-valid 3.29 0.83 0.96 out-of-domain bits per byte — lower is better NanoScale 40M GPT-2 family same held-out strings, each model with its own tokenizer, normalised by UTF-8 bytes
1.71× better than GPT-2 in domain with 3.1× fewer parameters: and 4× worse out of domain. The reversal is the point. The in-domain number measures specialization, not capability: this model was trained on TinyStories and GPT-2 was not. Reporting the favourable half alone would make the favourable half worthless.

Minimal pairs: a benchmark with headroom

The replacement for the saturated quiz is a BLiMP-style forced choice (Warstadt et al. 2020). Each item is two sentences differing in exactly one place, one grammatical and one not; the model scores both and is correct if it prefers the grammatical one.

good: The boy near the cats runs quickly. bad: The boy near the cats run quickly. chance is exactly 50%; it is a forced choice, so there is no prompt-format sensitivity

856 items, generated from templates over a lexicon rather than hand-written, so the suite scales and is reproducible from a seed. Difficulty is controllable: an agreement item with an intervening attractor noun is far harder than one without, because the model has to track the true subject across a nearer, disagreeing noun.

chance 50% agreement_simple 94% determiner_noun 86% reflexive 100% argument_structure 64% tense_consistency 98% pronoun_gender 100% negation 50% agreement_attractor 44% entity_tracking 76% 0% 25% 50% 75% 100% forced-choice accuracy, 95% Wilson interval · 856 items
7 of 9 phenomena above chance, macro-average 79.1%, and the two failures are the most informative rows in the table.
The finding this benchmark was built to expose

Simple agreement 94%. Agreement with an attractor: 44%, below chance. Put a disagreeing noun between the subject and the verb and the model systematically picks the one nearer the verb. It has not learned syntactic agreement; it has learned a linear recency heuristic that happens to coincide with agreement whenever nothing intervenes.

Below-chance is a stronger result than at-chance. At chance would mean "no signal"; below chance means there is a systematic rule and it is the wrong one. A single aggregate accuracy would have averaged 94% and 44% into a respectable 69% and shown nothing. This replicates the Linzen-style agreement-attraction finding at 40M parameters.

And a benchmark bug the numbers exposed

Entity tracking first scored 100%. That should have been implausible for a model failing negation entirely, and it was: the template introduced only the correct object into the context, so any model with a copying bias scored perfectly without tracking anything. Rewriting it so both candidate objects appear, each bound to a different character, dropped the score to 76%: still real, now honest.

Two more were caught by tests rather than by reading numbers: negation items were not length-matched (nothing inside vs a key inside), so a model could score by preferring shorter sentences, and articles were not chosen for the following vowel, producing "a apple", ungrammatical for a reason unrelated to the phenomenon under test. An item only measures what you named if the wrong answer is equally available.

Calibration, is the confidence earned?

A model can be accurate and badly miscalibrated: 99% confident on predictions that are right 70% of the time. That matters for a small model specifically, because overconfidence is the mechanism behind degenerate generation, a model certain of a wrong continuation cannot recover from it. Expected calibration error bins predictions by confidence and measures the gap between confidence and accuracy in each bin.

0.0 0.0 0.2 0.2 0.4 0.4 0.6 0.6 0.8 0.8 1.0 1.0 perfect calibration 62.1% confident 61.7% correct reliability confidence accuracy ECE 0.0074 · over-confidence +0.0037
metricvalue
expected calibration error0.0074
maximum calibration error0.0161
top-1 next-token accuracy61.7%
mean confidence62.1%
over-confidence+0.0037

The model is almost exactly calibrated: it is right 61.7% of the time and 62.1% confident. That is a genuinely good result and one perplexity cannot see, perplexity cannot distinguish a well-calibrated model from a confidently wrong one.

Diversity: catching what likelihood rewards

Likelihood metrics reward a model for putting probability where the evaluation set is, which is exactly satisfied by a model that produces the same safe thing every time. Distinct-n and self-BLEU detect that; perplexity structurally cannot.

metricvaluereading
distinct-20.86787% of bigrams across generations are unique
distinct-30.974near-total trigram novelty
self-BLEU0.027generations are almost entirely unlike each other, no mode collapse
repetition rate0.011very little within-sample looping

Ablations, with statistics

Five seeds per arm, Welch's t-test, effect size, a variance test, and a multiplicity correction. The conclusions changed.

The single-seed ablations reported earlier used a flat rule: any gap below 2% was "no measurable difference". That was an honest guess and not a measurement, with one run per arm there is no estimate of run-to-run variance, so there is nothing to compare a gap against. Running five seeds per arm makes the comparison a real two-sample test.

0.382 0.413 0.443 0.474 0.505 AdamW only sd 0.0434 106 steps → target Muon + AdamW sd 0.0033 53 steps → target Optimizer, 5 seeds per arm bar: mean · band: ±1 sd · dots: individual seeds (n=5)
Muon reaches the target in half the steps, every single seed, and never has a bad run. AdamW's five seeds span 0.400 to 0.503; Muon's span 0.384 to 0.392.
TestResultpVerdict
Mean final loss0.3882 vs 0.42630.121 Not significant; one AdamW seed failed to converge and inflated its variance
Steps to target53 vs 106<0.0001 Decisive, and the distributions do not overlap at all
Run-to-run varianceF = 176.40.0002 Muon's spread is 176× smaller
Why three tests instead of one

A single comparison of mean loss says "no significant difference" and would have been the whole report. That would have thrown away the two most striking things in the data. An optimizer that lands in the same place every time is more useful than one that is sometimes better and sometimes diverges, and a t-test on the mean is structurally unable to say so. Stability is a property worth testing directly, not an inconvenience for the mean comparison.

0.380 0.391 0.401 0.412 0.423 default (QK-norm, zer… sd 0.0033 53 steps → target − QK-norm sd 0.0139 76 steps → target − zero-init output sd 0.0018 43 steps → target ReLU² instead of SwiG… sd 0.0100 55 steps → target Architecture, 5 seeds per arm bar: mean · band: ±1 sd · dots: individual seeds (n=5)
Architecture arms at five seeds each. The differences in mean final loss are small relative to the spread; the differences in steps-to-target are not.
VariantΔ mean lossp (loss) p (steps)var FVerdict
− QK-norm+0.00830.256 <0.000118.0 No loss difference; decisively slower and 18× less stable
− zero-init output−0.00450.035 0.00043.3 Not significant after correction
ReLU² vs SwiGLU+0.00350.495 0.1789.3No difference on any test
The correction that changed a conclusion

Removing zero-init came out at p = 0.035, significant by the usual threshold, and it would have read as a finding. But it is one of three comparisons against the same baseline, and running three tests at α=0.05 gives roughly a 14% chance of at least one false positive. Holm–Bonferroni correction demotes it to not significant, which is the honest answer.

This is not a hypothetical worry that was added defensively. It is a case where the uncorrected number would have been reported as a result and the corrected one says there is nothing there.

What QK-norm actually does, now visible

Its effect on final loss is not detectable (p=0.256). Its effect on how the run gets there is unmistakable: p < 0.0001 on steps-to-target and 18× the run-to-run variance without it. That is precisely the signature of a stability aid, and the single-seed experiment, which could only compare final losses, called it "no measurable difference".

When each capability appears

A single end-of-training score cannot tell never learned from learned and then unlearned. Probing sixteen times during one run can.

The evaluation reports one number per phenomenon at the end of training. That is a photograph of a process, and the process is the more interesting object, especially given the finished model sits at 94% on simple agreement and 44% on agreement across an attractor. Was it ever better? Is 44% a floor, or a trajectory caught mid-climb?

So: one training run, the full 856-item suite evaluated every 250 steps, sixteen probes spanning 23M tokens. The cost is one training run plus a few seconds per probe.

25% 50% 75% 100% chance reflexive rho=+0.87 p=0.000 agreement_simple rho=+0.82 p=0.000 pronoun_gender rho=+0.71 p=0.002 argument_structure rho=+0.64 p=0.008 entity_tracking rho=+0.45 p=0.080 determiner_noun rho=+0.37 p=0.163 negation rho=+0.00 p=1.000 tense_consistency rho=-0.02 p=0.955 agreement_attractor rho=-0.25 p=0.345 0M 6M 12M 18M 25M training tokens Minimal-pair accuracy through one training run, 12.8M params, 16 probes Dashed amber: no positive trend with training. Validation loss over the same run: 3.106 to 1.774.
Seven of nine phenomena climb. Two never move. Curves are ordered by their rank correlation with training tokens; the two in amber are the ones with no positive trend. Validation loss over the same run fell 3.106 → 1.774, so the model was unambiguously learning something throughout.
PhenomenonFirst probeFinal Reaches 60%Spearman ρp
reflexive50%100% 3.1M+0.87<0.0001
agreement_simple60%82% 13.8M+0.820.0001
pronoun_gender53%96% 3.1M+0.710.0019
argument_structure61%66% 15.4M+0.640.0081
entity_tracking45%80% 7.7M+0.450.080
determiner_noun50%78% 4.6M+0.370.163
tense_consistency100%98% 1.5M−0.020.955
negation50%50% never+0.001.000
agreement_attractor47%42% never−0.250.345
Three results the endpoint could not have shown

1. There is an emergence order, and it is not the order of apparent difficulty. Reflexive binding (Tom washed himself) and cross-sentence pronoun gender both clear 60% at 3.1M tokens; simple subject–verb agreement takes 13.8M. Agreement looks like the more elementary rule and is learned later, plausibly because gendered names carry a strong lexical cue while agreement requires attending to a noun's number.

2. negation has a rank correlation of exactly +0.00 with p = 1.000. It starts at 50%, ends at 50%, and its largest excursion from its own peak is zero points. Across sixteen probes and 23M tokens, while validation loss fell by 43%, this capability did not budge by a single item. That is about as clean a "the model has no representation of this" as a benchmark can produce.

3. agreement_attractor is the only phenomenon that trends downward. Every other curve either rises or is already saturated; this one has ρ = −0.25. Combined with simple agreement rising to 82% over the same run, the picture is of a model getting steadily better at agreement when nothing intervenes and no better, slightly worse, when something does.

What these numbers will and will not support

The downward trend is not statistically significant (p = 0.345), and I am not claiming it is. With 100 items per phenomenon per probe the binomial standard error is about 5 points, so the visible dip to 38% at 7.7M tokens is barely one standard error of a difference and could be noise. Reporting that dip as "the model unlearns agreement" would be exactly the kind of over-claim this project is built to avoid.

What is solid is the contrast, and it does not depend on any single probe: seven phenomena show a positive correlation with training (four significantly), while these two show none at all across sixteen paired observations. negation's ρ = 0.00 with p = 1.000 is not a marginal call.

The obvious next experiment is cheap and would settle it: rerun at three seeds with 400 items per phenomenon instead of 100. That halves the standard error and turns "the only negative trend" into a testable claim about whether the recency heuristic is actively strengthened. It is roughly two hours of laptop GPU.

This is the sharpest argument for the project's small scale being a feature rather than an apology. The whole experiment, sixteen full benchmark evaluations interleaved with a complete training run, took under two hours on a laptop. The same study on a model where one training run costs thousands of GPU-hours is a research programme; here it is an afternoon, and it can be re-run with a different architecture, optimizer or data mixture by changing one config field.

What it is actually for

A language model is a compressor. Making that literal turns the "beats GPT-2 in domain, loses out of domain" result from a curiosity into a product thesis.

Everything above is a demonstration that the pipeline is correct. This section is the argument that the artifact it produces is useful, and the argument rests on an identity that is usually left as theory.

Shannon: a symbol of probability p costs −log₂(p) bits to encode. so a model's cross-entropy in bits/byte is the size of the file it can produce

Feed a model's next-token distribution to an arithmetic coder and its cross-entropy stops being a benchmark number and becomes a byte count you can check on disk. The repository does exactly this, and every figure below survived a byte-identical round trip.

Worked example: the cost of four real tokens
context "Once upon a" → p(" time") = 1.000 cost −log₂(1.000) = 0.00 bits context "Once upon a time" → p(",") = 0.62 cost = 0.69 bits p(" there") = 0.21 cost = 2.25 bits p(" in") = 0.04 cost = 4.64 bits

The model spends nothing on " time" because the phrase is fixed, and the arithmetic coder emits no bits for it. That is the whole mechanism: predictable text is free, surprising text is expensive, and the total across a passage is the file size. A generic compressor cannot do this, because "upon a" → " time" is a fact about English, not a repeated substring it can point back to.

0.0 1.1 2.3 3.4 4.6 0.78 3.86 4.32 3.90 2 KB input 10.3x compression 0.73 2.94 3.24 3.24 8 KB input 10.9x compression 0.71 2.46 2.72 2.85 24 KB input 11.3x compression bits per byte after a verified lossless round trip — lower is better NanoScale-LM bzip2 -9 xz -9 gzip -9
0.71 bits/byte against 2.46 for the best classical compressor: a 3.5× smaller file. Coder overhead against the model's own cross-entropy is 1.1%, so nearly all of the theoretical rate is realised rather than merely quoted. The gap holds at every input size tested.

The same forward pass is an anomaly detector

Per-token surprisal is the compressor's cost function, un-summed. A line the model finds expensive to encode is a line unlike its training distribution; an unsupervised anomaly score needing no labels, no rules, and one threshold expressed as a percentile of normal traffic.

alarm threshold = 95th pct of normal log line 14.1 code 13.9 wrong domain 13.0 gibberish 12.7 subtle 8.5 normal 6.9 normal 6.5 normal 6.4 normal 6.2 surprisal, bits per token
All five injected anomalies clear the 95th-percentile threshold of normal lines. The bottom row is the one worth looking at: "Tom picked up the quantum entanglement and put it in his pocket" is grammatical, in the right register, and differs only in that one noun phrase is impossible in this world. It scores 8.50 against a normal maximum of 6.86: semantic detection, not lexical.

The economics, which decide whether any of this is worth doing

The model must be stored alongside the archive, so a neural codec only pays back above a break-even volume. This is the calculation that is usually skipped, and it is the one that determines whether the idea is real.

Model precisionModel size Break-even inputVerdict
fp32154 MB704 MB Worth it for an archive, not a file
int841 MB187 MB One day of logs for a mid-sized service
int4 (GPTQ)22 MB99 MB Trivially exceeded by any log pipeline

Each input byte costs 0.708 bits with the model against 2.458 with the best classical coder, saving 0.219 bytes per byte. Below break-even, use xz. Above it, the neural codec wins and keeps winning, and this is exactly where §08's quantization stops being an exercise and starts paying rent, because shrinking the model shrinks the break-even point by 7×.

Why this job needs a small model, not a good one

A 7B model predicts text better and cannot do this. Compression requires running the model over every byte, in lockstep, at both ends. At ~30 tokens/second per stream and 14 GB of weights, break-even lands in the tens of terabytes and the archive takes longer to write than it took to generate. A 40M model at ~200 tokens/second on one laptop core, shipping as a 22 MB int4 blob, is a codec.

The out-of-domain weakness becomes the design. You compress your own logs with a model trained on your own logs; that it is useless on Wikipedia is irrelevant and is precisely why it is small enough to deploy.

Where this fits

High-volume narrow-domain text

Application logs, telemetry, sensor records, EDI and claims traffic, chat transcripts, one team's code in one internal dialect. All are enormous, repetitive, and distributionally narrow; the regime where a specialised 40M model beats a general 124M one, which §the measurement section shows it does by 1.71×.

Edge and air-gapped deployment

Runs on a CPU with no accelerator, no network and no API key. The entire pipeline : train, compress, detect, fits on a laptop, which matters for environments where sending logs to a hosted model is not an option.

Two capabilities, one model

Compression and anomaly detection come from the same forward pass, so the marginal cost of the second is zero. Archive a stream and get monitoring for free, with the anomaly threshold calibrated against the same traffic being archived.

Honest limits

Encoding at ~200 tokens/second is orders of magnitude slower than xz. This suits write-once, store-long workloads, not interactive ones. And decompression requires the exact model bytes, lose the checkpoint and the archive is unreadable, which is a real operational liability that xz does not have.

The engineering hazard this exposed, and the general lesson

The decoder has no side information: it rebuilds each probability distribution by re-running the model on what it has already decoded. If the two ends disagree about a single integer frequency anywhere in a 16,384-entry vocabulary, the arithmetic desynchronises and everything after it is corrupt. So the question is how much floating-point drift the quantisation absorbs, and that is measurable, not a matter of hope.

PathDriftMargin vs 2.0e−5 bucketDecision
One-shot teacher-forced encode1.7e−6 12× Rejected, a coin flip at 16k symbols per position
Incremental decode with a KV cache1e−9 20,000× Used, and 9× faster

The tempting optimisation is the unsafe one: the encoder knows all the tokens, so it could score them in a single batched pass. That path drifts from the stepwise one by more than a tenth of a quantisation bucket, and would corrupt archives occasionally and silently. Integer quantisation of the probability table is what makes a neural codec robust to floating-point non-determinism, and the size of that robustness is a number you should measure before shipping.

Design decisions

The choices that shaped the repository, and what each one cost.

D1  Configuration is frozen, hashed, documented

16 immutable pydantic models. Every field carries a description, enforced by a test that fails if one is missing. Configs hash to a stable digest recorded in every run manifest, and save→load→validate is an exact identity.

Cost: computed fields had to be stripped on round-trip, which needed a dedicated dump_inputs() helper.

D2  Parameter counts are reported honestly

The plan's headline figures were approximate. This repo pins the shapes and reports what they actually produce, in both total and non-embedding form, asserted as exact constants against the analytic formula and the built module.

Comparing "parameter counts" across projects is meaningless unless you say whether embeddings and an untied head are included.

D3  nano is deliberately not compute-optimal

20:1 on 5M parameters is ~99M tokens, which is hours of CPU. nano exists to be a sub-10-minute CI and teaching tier. Its manifests record what fraction of compute-optimal it covered, so the shortfall is data rather than an omission.

D4  Everything has a CPU path

An unavailable accelerator degrades to CPU rather than raising; bf16 autocast degrades to fp32. CI runs the entire pipeline on CPU runners. GPU runs are reported experiments, never gates.

Cost: absolute throughput numbers are hardware-dependent, so every one is stamped with the hardware string that produced it.

D5  Negative results are committed, not tuned away

When a measurement contradicted the plan, the measurement was recorded: as a results table, a written explanation, and where possible a test pinning the surprising behaviour so it cannot silently change.

Cost: the README is longer and less triumphant than it could be.

D6  Every number lives in exactly one place

Scripts write JSON + a figure + a Markdown fragment into results/. The docs results page is generated from those fragments, and CI fails if the committed page has drifted. There is no number in the docs that a human typed.

Cost: prose about a measurement has to be written inside the measurement script.

D7  Report the terms, not just the metric

Every preference run logs absolute log-probabilities, not only their difference; every run logs generated length before and after; the head-to-head judge is length-insensitive by construction. This is what caught DPO's likelihood collapse.

D8  Measure what transfers; disclaim what doesn't

Report target passes saved, weight footprint at effective bit-width, distributional exactness, and objective rank ordering as results. Report wall-clock throughput with an explicit statement that it does not generalize.

Cost: the "3× faster" chart this project could have shown does not exist, because it would not have been true.

Limits, and what more compute would change

Read this before quoting any number above.

What this project does not show

  • These are 5-seed results at 5–40M parameters. Five seeds is enough to measure run-to-run variance and enough to detect a large effect; it is not enough to detect a small one. The optimizer's mean-loss advantage is a real example: d = 1.24 is a large effect and p = 0.121 still fails to clear α=0.05, because n=5 with one divergent seed is underpowered. These remain directional confirmations of findings obtained at 100–1000× this scale.
  • A lever that matters at scale can be invisible here. QK-norm exists because attention logits blow up over long runs at large width. 400 steps at d=256 never gets there. "This experiment cannot see the effect" and "the effect is not real" are different statements.
  • The wall-clock inference numbers do not generalize at all. Every Arc 2 technique targets memory bandwidth, and there is no memory-bandwidth bottleneck at this scale on a CPU. This is stated everywhere the numbers appear.
  • The minimal-pair suite is templated, not naturalistic. 856 items generated from a lexicon of about 50 words, so it measures whether specific grammatical contrasts are represented: not general linguistic competence, and certainly not reasoning. Its vocabulary overlaps the training distribution by construction. The older 28-question quiz is retained only as a compression-damage detector, where saturation is the desired property.
  • There is no MMLU number here, and there should not be one. A 40M-parameter model trained on children's stories would score at chance on any knowledge benchmark. Reporting chance-level results on a famous benchmark to look thorough would be padding.
  • The alignment data is synthetic. Preference pairs are generated programmatically, and the head-to-head judge is a program that checks the properties those pairs encode. It measures did the model learn the labels, not is the model good.

What the same code does with more compute

ChangeWhat it needsWhat it would settle
Train micro to the full 20:1 budget 808M tokens: a few hours on one free T4, days on this laptop Whether entity tracking appears with data alone at 40M parameters.
Train small (126M) on FineWeb-Edu 2.5B tokens; a multi-session Kaggle P100 run Open-domain competence, and whether the ablations that showed nothing here start to separate.
More seeds on the optimizer arm ~12 seeds to reach 80% power at the observed effect size; about 25 minutes Whether Muon's mean-loss advantage is significant, not just its speed and stability. Currently p=0.121 at n=5.
A held-out minimal-pair lexicon No compute; a vocabulary split Whether the grammatical scores survive words the model never trained on, separating learned rules from memorised collocations.
Real GPU inference benchmarks Any CUDA device plus an int4 kernel Whether the wall-clock wins appear once memory bandwidth is the binding constraint. The pass-count reductions predict they should.
Multi-GPU FSDP sharding; the training loop is written so this is additive Nothing scientific; it is a throughput change, and the recipe is documented rather than built.

What would make this publishable

Being direct, because it matters for what to do next: a 40M model on TinyStories will not beat state of the art on anything anyone tracks, and the bits-per-byte win over GPT-2 is a specialization result, not a capability one. Neither is a contribution on its own.

What is closer to a contribution is the methodology, and two specific findings that fall out of it:

  • The agreement-attraction result. 94% on simple agreement, 44%: below chance : with an attractor. That is a clean, quantified demonstration that a 40M model has learned linear recency rather than syntactic agreement, at a scale where the whole training run is three hours and fully reproducible.
  • Muon's variance reduction. 176× lower run-to-run spread than AdamW (F=176.4, p=0.0002), while the mean-loss difference is not significant. Optimizer papers overwhelmingly report mean final loss; that this is the weaker signal, and stability the stronger one, is worth saying.
  • The emergence trajectories. Sixteen full benchmark evaluations interleaved with one training run, showing an ordering that is not the order of apparent difficulty, and two phenomena that never move at all, negation at ρ = +0.00, p = 1.000. The methodological point is that this study costs two laptop-hours here and is a research programme at frontier scale, which is the strongest version of the "small scale is a feature" argument.
  • The codec result. 11.3× lossless compression against xz's 3.3× on in-domain text, with the break-even economics worked out. This is the piece that makes the whole thing an artifact someone could deploy rather than only read about.

The realistic framing is a workshop paper or a reproducibility report: which small-scale replications of 2023–2026 LM findings hold, which do not, and what it costs to check , with every claim backed by a committed script and a significance test. That is a real contribution and an honest one. Claiming a state-of-the-art result would not be.

Nothing above requires a redesign. The size ladder is one architecture with different hyperparameters; the streaming data path already handles corpora far larger than disk; the checkpoint format is resumable across sessions. Scaling this is a matter of compute, which is exactly the claim the project set out to demonstrate.

Run it yourself

Ten minutes, no GPU, no API key, no downloads.

git clone https://github.com/<user>/nanoscale-lm && cd nanoscale-lm
make install
make smoke     # tokenizer → pretrain → SFT → DPO → quantize → speculate

make smoke runs the entire pipeline on CPU and asserts a sanity metric at every stage, loss starts at ln(V), SFT reduces the masked loss, DPO raises the reward margin, 4-bit stays coherent, GPTQ does not lose to RTN, greedy speculation is token-identical to greedy autoregressive decoding. Measured end to end: 45 seconds.

Repository map

src/nanoscale/
├── config/       16 frozen pydantic models + the size-ladder presets
├── tokenizer/    byte-level BPE: train, encode, decode, chat template
├── model/        attention (GQA · RoPE · QK-norm · KV cache), norms, MLPs, MTP, numerics
├── optim/        Muon (Newton–Schulz), AdamW, cautious decay, parameter router
├── train/        data pipeline, schedules, checkpointing, the loop
├── align/        SFT, DPO / SimPO, GRPO-RLVR
├── distill/      forward-KL, SeqKD, on-policy reverse KL
├── quantize/     RTN, GPTQ, AWQ, KV-cache quantization
├── specdec/      accept rule, draft–target sampling, Medusa + tree attention
├── serve/        streaming generation, stop sequences, timing breakdown
├── eval/         perplexity with error bars, tiny benchmark, preference judge
└── bench/        ablation harness, throughput / latency / memory harness

tests/{unit,property,dynamics,e2e}/   563 tests
results/                              committed figures, tables, JSON: the source of truth
notebooks/                            two Colab notebooks, CPU and GPU paths
demo/                                 Gradio app: generate, compare, inspect, race

Quality gates

ruff with docstring, annotation and complexity rules; mypy --strict over src, tests and scripts with no ignores; 563 tests including property-based tests via Hypothesis. CI additionally runs the end-to-end pipeline, builds the docs with warnings as errors, and fails if the generated results page has drifted from the committed artifacts.

Reproducing the measurements

make ablate, make align, make distill, make quantize, make specdec, make bench. Every one also accepts --replay, which re-renders its figure and write-up from committed JSON without retraining anything.


Apache-2.0. Every algorithm implemented from scratch in PyTorch; no high-level trainer library in src/nanoscale/. Primary references: Vaswani et al. 2017 (Transformer); Su et al. 2021 (RoPE); Zhang & Sennrich 2019 (RMSNorm); Shazeer 2020 (SwiGLU); Ainslie et al. 2023 (GQA); Jordan et al. 2024 (Muon); Rafailov et al. 2023 (DPO); Meng et al. 2024 (SimPO); Shao et al. 2024 (GRPO); Hinton et al. 2015 (KD); Kim & Rush 2016 (SeqKD); Gu et al. 2024 (MiniLLM); Frantar et al. 2023 (GPTQ); Lin et al. 2023 (AWQ); Leviathan et al. 2023 (speculative sampling); Cai et al. 2024 (Medusa); Hoffmann et al. 2022 (Chinchilla); Eldan & Li 2023 (TinyStories); Miller 2024 (evaluation error bars).