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.
▶ Try the live demo All results Source on GitHub
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.
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.
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.
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
ygiven promptx. Used from the alignment section onward. - p, q
- In Arc 2,
pis always the big/target/teacher distribution andqthe 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.
| Problem | Why the 2017 design suffers | Answered 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 |
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.
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+
"The boy near the cats runs quickly." becomes 8 tokens:
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.
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:
| Claim | Result |
|---|---|
| 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.
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.
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:
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:
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.
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.
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:
Now the payoff. Rotation matrices compose by adding angles, and a rotation is orthogonal, so
RmTRn = Rn−m. Therefore:
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.
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.
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.
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:
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.
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.
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.
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:
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.
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:
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.
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 usesc = 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 maint+1head, 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.
| Tier | Params | Non-embedding | L × d × H | n_kv | Context | Trains on |
|---|---|---|---|---|---|---|
nano | 4,952,064 | 4,427,776 | 6 × 256 × 4 | 2 | 256 | laptop CPU, 95 s |
micro | 40,379,904 | 23,602,688 | 8 × 512 × 8 | 4 | 512 | free Colab T4 |
small | 125,849,856 | 75,518,208 | 12 × 768 × 12 | 4 | 1024 | scale-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.
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:
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.
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:
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.
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
| Optimizer | Final val loss | Val ppl | Steps to target | Seconds to target |
|---|---|---|---|---|
| AdamW only | 0.4039 | 1.498 | 105 | 33.5 |
| Muon + AdamW | 0.3896 | 1.476 | 50 | 21.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.
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.
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.
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.
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.
| Variant | Val loss | Val ppl | Steps to target | Verdict |
|---|---|---|---|---|
| default (QK-norm, zero-init, SwiGLU) | 0.3896 | 1.476 | 50 | baseline |
| − QK-norm | 0.3893 | 1.476 | 75 | no loss difference; 1.5× more steps |
| − zero-init output | 0.3842 | 1.468 | 45 | no measurable difference |
| ReLU² instead of SwiGLU | 0.3862 | 1.471 | 55 | no 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.
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.
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.
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.
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:
Step 3. Now invert it. Instead of solving for the policy given the reward, solve for the reward given the policy:
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:
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:
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:
| Method | Δ log p(chosen) | Δ log p(rejected) | Δ margin | Wins–losses–ties vs SFT |
|---|---|---|---|---|
| DPO | −0.0454 | −4.2385 | +4.1931 | 0–7–33 |
| DPO + NLL anchor | +0.0104 | −3.9170 | +3.9274 | 3–0–37 |
| SimPO | −0.0223 | −2.2134 | +2.1911 | 0–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.
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
πrefentirely, 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
Gcompletions 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.
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.
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.
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.
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.
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
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 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.
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
| Objective | Student val ppl | Repetition rate | Gen length | Wall clock |
|---|---|---|---|---|
| forward KL | 2.0536 | 0.0064 | 45.5 | 18.2 s |
| SeqKD | 4.4499 | 0.0372 | 39.2 | 33.5 s |
| reverse KL, on-policy | 2.4974 | 0.0000 | 30.2 | 18.1 s |
| teacher, for reference | , | 0.0383 | 46.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.
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:
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:
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:
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:
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:
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.
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.
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:
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
| Method | Nominal bits | Effective bits | Val perplexity | vs fp32 | Mean layer weight error |
|---|---|---|---|---|---|
| FP32 | 32 | 32.0 | 1.4764 | 1.000× | 0.0 |
| RTN | 4 | 4.5 | 1.4767 | 1.000× | 0.0917 |
| GPTQ | 4 | 4.5 | 1.4766 | 1.000× | 0.1351 |
| AWQ | 4 | 4.5 | 1.4765 | 1.000× | 0.0737 |
| RTN | 3 | 3.5 | 1.4783 | 1.001× | 0.1962 |
| GPTQ | 3 | 3.5 | 1.4764 | 1.000× | 0.2919 |
| RTN | 2 | 2.5 | 1.5405 | 1.043× | 0.4590 |
| GPTQ | 2 | 2.5 | 1.4997 | 1.016× | 0.7141 |
| AWQ | 2 | 2.5 | 1.5468 | 1.048× | 0.3695 |
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.
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.
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.
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:
The claim is that the token you end up with is distributed exactly as p.
Here is the whole proof; it is four lines.
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.
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:
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.
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
| Arm | Target passes | Tokens / pass | Acceptance | Tokens/s | vs baseline |
|---|---|---|---|---|---|
| autoregressive | 520 | 0.98 | , | 677.4 | 1.00× |
| speculative γ=2 | 245 | 2.09 | 0.590 | 483.2 | 0.71× |
| speculative γ=4 | 203 | 2.52 | 0.424 | 463.2 | 0.68× |
| speculative γ=6 | 174 | 2.94 | 0.367 | 535.5 | 0.79× |
| autoregressive + GPTQ-4bit | 520 | 0.98 | , | 774.8 | 1.14× |
| speculative γ=6 + GPTQ-4bit | 176 | 2.91 | 0.362 | 535.8 | 0.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.
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.
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.
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.
| Variant | Params | Weights | KV @ ctx | Decode tok/s | Latency p50 | Val ppl | Tiny bench |
|---|---|---|---|---|---|---|---|
| base (fp32) | 4,952,064 | 18.89 MB | 0.47 MB | 729.2 | 91.3 ms | 1.4764 | 100.0% |
| distilled (reverse-KL) | 279,168 | 1.06 MB | 0.06 MB | 1765.8 | 21.6 ms | 2.5222 | 67.9% ± 8.8 |
| GPTQ 4-bit | 4,952,064 | 4.38 MB | 0.47 MB | 756.0 | 87.8 ms | 1.4766 | 100.0% |
| speculative (γ=6) | 5,231,232 | 19.96 MB | 0.53 MB | 597.3 | 107.2 ms | 1.4764 | , |
| speculative + GPTQ 4-bit | 5,231,232 | 5.44 MB | 0.53 MB | 583.5 | 109.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.
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.
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.
Sampled from that checkpoint at 3.07M tokens, temperature 0.7:
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.
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
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
Everything, in one table
| Claim | Measurement | Produced 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 broke | Symptom | Cause 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.
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.
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.
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.
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.
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.
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.
| metric | value |
|---|---|
| expected calibration error | 0.0074 |
| maximum calibration error | 0.0161 |
| top-1 next-token accuracy | 61.7% |
| mean confidence | 62.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.
| metric | value | reading |
|---|---|---|
| distinct-2 | 0.867 | 87% of bigrams across generations are unique |
| distinct-3 | 0.974 | near-total trigram novelty |
| self-BLEU | 0.027 | generations are almost entirely unlike each other, no mode collapse |
| repetition rate | 0.011 | very 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.
| Test | Result | p | Verdict |
|---|---|---|---|
| Mean final loss | 0.3882 vs 0.4263 | 0.121 | Not significant; one AdamW seed failed to converge and inflated its variance |
| Steps to target | 53 vs 106 | <0.0001 | Decisive, and the distributions do not overlap at all |
| Run-to-run variance | F = 176.4 | 0.0002 | Muon's spread is 176× smaller |
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.
| Variant | Δ mean loss | p (loss) | p (steps) | var F | Verdict |
|---|---|---|---|---|---|
| − QK-norm | +0.0083 | 0.256 | <0.0001 | 18.0 | No loss difference; decisively slower and 18× less stable |
| − zero-init output | −0.0045 | 0.035 | 0.0004 | 3.3 | Not significant after correction |
| ReLU² vs SwiGLU | +0.0035 | 0.495 | 0.178 | 9.3 | No difference on any test |
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.
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.
| Phenomenon | First probe | Final | Reaches 60% | Spearman ρ | p |
|---|---|---|---|---|---|
| reflexive | 50% | 100% | 3.1M | +0.87 | <0.0001 |
| agreement_simple | 60% | 82% | 13.8M | +0.82 | 0.0001 |
| pronoun_gender | 53% | 96% | 3.1M | +0.71 | 0.0019 |
| argument_structure | 61% | 66% | 15.4M | +0.64 | 0.0081 |
| entity_tracking | 45% | 80% | 7.7M | +0.45 | 0.080 |
| determiner_noun | 50% | 78% | 4.6M | +0.37 | 0.163 |
| tense_consistency | 100% | 98% | 1.5M | −0.02 | 0.955 |
| negation | 50% | 50% | never | +0.00 | 1.000 |
| agreement_attractor | 47% | 42% | never | −0.25 | 0.345 |
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.
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.
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.
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.
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.
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 precision | Model size | Break-even input | Verdict |
|---|---|---|---|
| fp32 | 154 MB | 704 MB | Worth it for an archive, not a file |
| int8 | 41 MB | 187 MB | One day of logs for a mid-sized service |
| int4 (GPTQ) | 22 MB | 99 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×.
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 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.
| Path | Drift | Margin vs 2.0e−5 bucket | Decision |
|---|---|---|---|
| One-shot teacher-forced encode | 1.7e−6 | 12× | Rejected, a coin flip at 16k symbols per position |
| Incremental decode with a KV cache | 1e−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=256never 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
| Change | What it needs | What 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,
negationat ρ = +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.
▶ Live demo All results Source on GitHub Notebooks
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).