← back
depth
00 · opening
Last updated September 17, 2026

Pre-training, step by step.

The production repos are all public now. They are also nearly unreadable: ten thousand lines of parallelism plumbing wrapped around a loop that fits on a napkin. This page is my way of learning it. Eight big steps. Each one opens into three layers: the idea, the code, and the scaffolding. And at every step, the actual data, exactly as it sits on disk.

crawl & filterweb → corpus01tokenizetext → ids02pack & mixids → batches03define the modela decoder, dense or MoE04init & optimizerlr, schedule, Muon05train on the clusterFSDP · TP · PP · EP06checkpoint & evalkeep it alive07base modela text completer08click a step to jump to it · each one opens into three layers
A base model is the output. It completes text and nothing else. The next page, post-training, is where it learns to talk back.

Click a card to open that layer on every step below, or use the depth control in the corner. The rail on the right shows where you are in the pipeline, and which concept you are reading about.

scroll
01 · the map

One loop, run for trillions of tokens.

Every pre-training run is the same eight lines. Take a batch of token ids, predict each next token, measure how wrong you were with cross-entropy, backpropagate, step the optimizer. Repeat until the budget is gone.

What makes it hard is not the loop. It is that the loop has to run over the whole readable internet, on thousands of GPUs, for weeks, without a single machine dying at the wrong moment. Almost everything in the production repos is scaffolding around those eight lines.

the whole thing
for step in range(num_steps):                     # weeks of wall clock
    x, y = next(loader)                          # (B, T) token ids and the same ids shifted left by one
    logits = model(x)                            # (B, T, vocab): a guess for every next token at once
    loss = F.cross_entropy(logits.flatten(0, 1), y.flatten())
    loss.backward()                              # nudge billions of weights
    clip_grad_norm_(model.parameters(), 1.0)
    optimizer.step(); optimizer.zero_grad()
    set_lr(optimizer, schedule(step))

The scale is what turns each line into a project. Here is what the published runs look like.

~15T
tokens · Llama 3.1 405B
30.84M H100 GPU-hours for the 405B alone
14.8T
tokens · DeepSeek-V3
671B total, 37B active · 2.79M H800-hours · $5.6M at $2/h
36T
tokens · Qwen3
119 languages · three pre-training stages
$48
nanochat speedrun
8×H100 · 1.65 h · a 1.4B model to GPT-2 grade (CORE)
1.23 min
modded-nanogpt record
124M params to 3.28 val loss · record #89, July 2026

I lean on a small reading list throughout. Each repo is best at one thing. If you only read one, read nanochat: the whole pipeline in about six thousand lines, and every step on this page has a file there.

The whole pipeline on one 8×H100 node in 2026 idiom: Muon, QK-norm, sliding windows, FP8, best-fit packing, CORE eval. ~6,000 lines. Start with scripts/base_train.py and dev/LOG.md, the ablation diary.
The speedrun. train_gpt.py is one 2,400-line file holding every trick that survived a public leaderboard, and the canonical Muon reference. 45 min in 2024, 1.23 min now.
Production-shaped pretraining in plain PyTorch: FSDP2, TP, PP, CP, EP, float8 and MXFP8, DCP checkpoints. Ships Llama 3, DeepSeek V3 and V4, Kimi K3, Qwen3 model definitions.
The exact configs of a real frontier-open run: OLMo 3 7B and 32B pretrain, midtrain and long-context scripts with data-mix manifests. Read them next to the OLMo 3 report.
inference/model.py is 808 lines and the cleanest readable MLA, sigmoid-gated MoE with bias balancing, and block-wise FP8. The report is the best FP8 systems writeup.
The FineWeb pipeline as runnable code. examples/fineweb.py has every filter and threshold that produced FineWeb and FineWeb-Edu.
The DCLM-Baseline recipe (resiliparse, Bloom-filter dedup, fastText quality classifier) and the CORE metric nanochat uses to define "GPT-2 grade".
The parallelism textbook, with 4,000 scaling experiments behind it. picotron is its code companion: DP, TP, PP and CP each in under 300 lines.
Where tensor and pipeline parallelism come from. 275,000 lines. Most internal lab stacks descend from it. The reference, not the place to start.
02 · step 1 · data

Turn the web into a corpus.

People imagine a curated library. It is closer to a sewage plant. The input is Common Crawl, a nonprofit's monthly dump of a few billion web pages. The output is a few trillion tokens of text that is mostly not spam. Everything in between is filtering, and filtering is where open datasets differ.

Here is one row of FineWeb, the example row from its dataset card, exactly as published. No labels. No question. Just text and where it came from.

pre-training dataHuggingFaceFW/fineweb · the example row from the dataset card · parquet
{
  "text": "Posted by mattsmith on 20th April 2012\nStraight from the office of the Phillies' beat writer ...",
  "id": "<urn:uuid:d853d453-196e-4488-a411-efc2b26c40d2>",
  "dump": "CC-MAIN-2013-20",
  "url": "http://nleastchatter.com/philliesphandom/tag/freddy-galvis/",
  "date": "2013-05-18T07:24:47Z",
  "file_path": "s3://commoncrawl/crawl-data/CC-MAIN-2013-20/segments/1368696381249/warc/CC-MAIN-20130516092621-00000-ip-10-60-113-184.ec2.internal.warc.gz",
  "language": "en",
  "language_score": 0.9185474514961243,
  "token_count": 594
}
About 25 billion rows like this, a blog post about a baseball player. id, url and date are copied from the WARC headers; dump is the monthly crawl; language_score is fastText's confidence; token_count is with the GPT-2 tokenizer. The text is the thing. Everything else is provenance.
the funnel · FineWeb's stage order (bar lengths illustrative)
raw crawl (WARC)one snapshot ≈ 3B pagesurl filterblocklists, adult, spamtext extractiontrafilatura: html → main textlanguage idfastText, english ≥ 0.65quality filtersgopher · c4 · fineweb rulesminhash dedupper snapshot, 5-gramspii + finalFineWeb: 15T tokens from 96 snapshots
Most of the crawl is boilerplate, non-English text and duplicates. Keeping a tenth to a fifth is normal.
01the ideawhat it does and why. read this first+

The pipeline is a sequence of filters, and each one throws away most of what it sees. A snapshot arrives as WARC files: raw HTTP responses, HTML and all. trafilatura pulls the main article out of the HTML. FineWeb's first finding was that doing your own extraction from WARC beats using the pre-extracted WET files: cleaner text, better downstream models.

Then language identification: a fastText classifier scores each page, and FineWeb keeps English above 0.65. Then the quality heuristics inherited from Gopher and C4: drop documents with too many repeated lines, too few words, no terminal punctuation, too many symbols, the word "javascript", a "lorem ipsum". FineWeb added three of its own after ablations: too many short lines, too many lines without punctuation, too much duplicated character content.

Then deduplication. MinHash over 5-grams, 112 hash functions in 14 buckets of 8, which works out to catching documents about 75 percent similar. FineWeb's counterintuitive result: deduplicating across all snapshots at once made models worse than deduplicating within each snapshot, because global dedup preferentially removed the good, widely-mirrored text and kept the unique junk. Finally, PII: emails and IP addresses are replaced with placeholders.

Then the classifier

Heuristics get you to FineWeb. The next big jump was a learned quality classifier. FineWeb-Edu asked Llama-3-70B-Instruct to score about 460K pages on a 0 to 5 "educational value" scale, trained a small regression head on top of an embedding model with those labels, ran it over all 15T tokens, and kept pages scoring 3 or above. That left 1.3T tokens that beat the full set on knowledge benchmarks. DCLM did the same job with a fastText classifier trained to tell OpenHermes instruction data and good r/ELI5 answers apart from random web pages, and kept the top 10 percent. Every serious dataset since uses some version of this: a big model labels a sample, a cheap model scores everything.

the shape of the tradeoffAggressive filtering raises quality per token and lowers total tokens. Labs want both, so they run several classifiers with different thresholds and keep the buckets separate. Then the mixing step decides how many epochs each bucket gets. Nemotron-CC pushed this further: instead of dropping low-scoring pages, rephrase them with an LLM into cleaner text, which is how it got 6.3T tokens where FineWeb-Edu got 1.3T.
02the codea readable version of what the real repos do+

datatrove is FineWeb's pipeline as code. A pipeline is a list of steps; each step is a class that consumes and yields documents. The real FineWeb file is longer, but this is the skeleton, with the real filter names and thresholds.

datatrove · the fineweb filtering pipeline
from datatrove.executor import SlurmPipelineExecutor
from datatrove.pipeline.readers import WarcReader
from datatrove.pipeline.extractors import Trafilatura
from datatrove.pipeline.filters import (URLFilter, LanguageFilter, GopherRepetitionFilter,
                                        GopherQualityFilter, C4QualityFilter, FineWebQualityFilter)
from datatrove.pipeline.writers import JsonlWriter

pipeline = [
    WarcReader("s3://commoncrawl/crawl-data/CC-MAIN-2024-10/segments/"),
    URLFilter(),                                       # blocklists + bad words in the url
    Trafilatura(favour_precision=True),                # html -> main text
    LanguageFilter(languages=["en"], language_threshold=0.65),
    GopherRepetitionFilter(),                          # dup lines / paragraphs / n-grams
    GopherQualityFilter(min_doc_words=50, max_doc_words=100_000),
    C4QualityFilter(filter_no_terminal_punct=False),   # fineweb kept most of c4, dropped this one
    FineWebQualityFilter(),                            # short-line and punctuation ratios
    JsonlWriter("s3://my-bucket/fineweb/filtered/"),
]
SlurmPipelineExecutor(pipeline, tasks=8000, time="10:00:00", partition="cpu").run()
WarcReader(...)
Streams raw HTTP responses out of the crawl. Each task handles a slice of segments; 8,000 CPU tasks run in parallel on Slurm. Nothing here needs a GPU.
Trafilatura(...)
The extraction step. favour_precision prefers dropping doubtful content over keeping boilerplate. Ablations showed this beat the crawl's own WET text.
LanguageFilter(..., 0.65)
fastText's 176-language model. The threshold is a quality knob: raise it and you lose code-mixed and short pages, lower it and you keep more noise.
Gopher*Filter
The 2021 DeepMind heuristics, still in every pipeline. Repetition: a document where 30 percent of lines are duplicates is gone. Quality: word counts, mean word length, symbol ratios, the presence of stop words.
FineWebQualityFilter()
The three rules FineWeb added after ablating on 28B-token runs. Each one alone was worth a measurable bump on the aggregate benchmark.

Deduplication is a separate pipeline with four stages, because MinHash needs a global view: compute signatures, bucket them, find connected components of near-duplicates, then remove all but one per cluster.

datatrove · minhash in four passes
from datatrove.pipeline.dedup import (MinhashDedupSignature, MinhashDedupBuckets,
                                      MinhashDedupCluster, MinhashDedupFilter, MinhashConfig)

cfg = MinhashConfig(hash_config=HashConfig(hash_fc="sha1", precision=64),
                    num_buckets=14, hashes_per_bucket=8, n_grams=5)   # 112 hashes ≈ 75% jaccard
stage1 = [JsonlReader(filtered), MinhashDedupSignature(output_folder=sigs, config=cfg)]
stage2 = [MinhashDedupBuckets(input_folder=sigs, output_folder=buckets, config=cfg)]
stage3 = [MinhashDedupCluster(input_folder=buckets, output_folder=clusters, config=cfg)]
stage4 = [JsonlReader(filtered), MinhashDedupFilter(input_folder=clusters), JsonlWriter(deduped)]

And the classifier stage, in essence. Score everything with a cheap model, keep the top of the distribution. This is FineWeb-Edu's: a regression head on a small embedding model, trained once on the Llama-70B labels.

the quality classifier as a filter (fineweb-edu)
from transformers import AutoTokenizer, AutoModelForSequenceClassification
tok = AutoTokenizer.from_pretrained("HuggingFaceFW/fineweb-edu-classifier")
clf = AutoModelForSequenceClassification.from_pretrained("HuggingFaceFW/fineweb-edu-classifier")

def keep(doc):
    score = clf(**tok(doc["text"], truncation=True, max_length=512)).logits.item()
    doc["score"] = score                            # e.g. 3.375
    doc["int_score"] = round(max(0, min(score, 5)))  # -> 3
    return doc["int_score"] >= 3                    # fineweb-edu keeps 3, 4, 5 -> 1.3T tokens
pre-training datafineweb-edu · the same row shape plus two fields
{
  "id": "<urn:uuid:673b1bf6-2c30-40ae-992b-c387d00a836a>",
  "dump": "CC-MAIN-2013-20",
  "text": "No. 24; Updated March 2011 ... Parents are usually the first to recognize ...",
  "url": "https://www.aacap.org/AACAP/Families_and_Youth/Facts_for_Families/FFF-Guide/When-to-Seek-Help-for-Your-Child-024.aspx",
  "date": null,
  "file_path": "s3://commoncrawl/crawl-data/CC-MAIN-2013-20/segments/1368696381249/warc/CC-MAIN-20130516092621-00000-ip-10-60-113-184.ec2.internal.warc.gz",
  "language": "en", "language_score": 0.927742, "token_count": 755,
  "score": 3.375, "int_score": 3
}
Same crawl, same file. The classifier added score and int_score, and the filter kept this one because 3 ≥ 3.
03the scaffoldingwhat production adds, and where it breaks+

What the other datasets add

FineWeb (2024)
15T tokens, 96 snapshots, English only. The baseline. Trafilatura extraction, fastText ≥ 0.65, Gopher + C4 + three FineWeb rules, MinHash per dump, PII scrubbed.
FineWeb-Edu
1.3T tokens (int_score ≥ 3); a looser "score-2" cut keeps 5.4T. Same rows plus score and int_score.
DCLM-Baseline (2024)
3.79T tokens, 2.94B documents, .jsonl.zst. resiliparse extraction, RefinedWeb heuristics, Bloom-filter dedup on 13-grams, fastText classifier top 10 percent. Keys include language_id_whole_page_fasttext, bff_contained_ngram_count_before_dedupe and the classifier probability field.
Dolma 3 (2025)
AI2's mix for OLMo 3: 5.93T tokens for the 7B pretrain stage, then separate 100B midtrain and 50B long-context mixes. Documents and derived signals live in separate row-aligned files.
Nemotron-CC (2025)
6.3T tokens: 4.4T deduplicated real text plus 1.9T synthetic, rephrased by an LLM from low-scoring pages. Quality tier and synthetic type are encoded in the directory path.
The Stack v2 (2024)
Code. 3.28B files from Software Heritage with license metadata. The dataset holds blob_id, repo_name, path, language, detected_licenses, star_events_count; you fetch the file contents from SWH by blob id.
pre-training datadolma · documents/ and attributes/ · from docs/data-format.md
// attributes/<tagger>/ file, same row order, holds the derived signals:// attributes/<tagger>/ file, same row order, holds the derived signals:// attributes/<tagger>/ file, same row order, holds the derived signals:als:
{"source": "...", "id": "...", "attributes": {"toxicity": 0.7,
  "olmo_mix_v1_taggers__ft_lang_id_en_paragraph_with_doc_score_v2__en": [[0, 300, 0.9], [300, 540, 0.3]]}}
Dolma keeps the text and the tagger outputs apart, so a new filter is a new attributes file, not a rewrite of the corpus. The mixer then joins them by row.

What breaks at scale

  • It is a CPU job, not a GPU job. The FineWeb recipe runs as thousands of Slurm CPU tasks over S3. The crawl is petabytes across snapshots; you stream it and never hold it.
  • Dedup is the memory problem. MinHash signatures for 25B documents need a global shuffle, which is why it is four passes and not one function. DCLM's Bloom-filter approach (BFF, in Rust, 13-gram overlap at paragraph and document level) trades exactness for a single streaming pass.
  • Decontamination. Benchmark questions leak into the web. Every lab now removes 13-gram overlaps with their eval sets before training, and it still is not perfect.
  • The classifier decides the model's taste. FineWeb-Edu's labeling prompt asks for "educational" text, which tilts the corpus toward textbooks and away from fiction and dialogue. That is a design choice with downstream consequences, and it is why labs keep several classifiers and mix. Text extraction is the same kind of choice: trafilatura (FineWeb) and resiliparse (DCLM) produce different corpora from the same crawl.
  • Licensing and opt-out. robots.txt and publisher opt-outs are honored at crawl time by some pipelines and not others. Code datasets filter by license. This is where legal, not engineering, sets the limits.
what changed since 2023Three things. Extraction from WARC instead of WET. Model-based quality classifiers on top of heuristics. And synthetic rewriting: Nemotron-CC's 1.9T rephrased tokens, Kimi K2's knowledge and math rephrasing pipelines, Qwen3's PDF extraction with a vision model and synthetic math and code from its specialist models. The Gopher heuristics from 2021 are still underneath all of it.
03 · step 2 · tokenizer

Decide what a token is.

The model never sees text. It sees integers. The tokenizer is the contract that turns one into the other, and it is frozen before training starts and never changes again. Every quirk it has, the model inherits for life.

Here is that contract applied to one sentence by Llama 3's tokenizer, loaded from the file in the llama-models repo. 103 characters become 33 integers.

tokensllama 3 tokenizer.model · 128,256 entries · run locally with tiktoken
text    "The mitochondrion generates most of the cell's ATP. In 2024, 3,141 papers cited it.\ndef f(x): return x**2"

pieces  ["The", " mitochond", "r", "ion", " generates", " most", " of", " the", " cell", "'s", " ATP", ".",
         " In", " ", "202", "4", ",", " ", "3", ",", "141", " papers", " cited", " it", ".\n",
         "def", " f", "(x", "):", " return", " x", "**", "2"]

ids     [791, 55042, 81, 290, 27983, 1455, 315, 279, 2849, 596, 67656, 13,
         763, 220, 2366, 19, 11, 220, 18, 11, 9335, 16064, 22628, 433, 627,
         755, 282, 2120, 1680, 471, 865, 334, 17]
Common words are one token with the leading space attached (" generates"). A rare word is three. Digits are chunked in groups of up to three, so "2024" is " ", "202", "4". Code punctuation gets its own pieces. All of this is the pre-tokenizer regex plus 128,000 learned merges.
01the ideawhat it does and why. read this first+

Modern tokenizers are byte-level BPE. Start with the 256 possible bytes as your vocabulary. Count every adjacent pair in the training text. Merge the most frequent pair into a new token. Repeat until you hit the vocabulary size you chose. Because you started from bytes, any string in any language can be encoded; there is no "unknown" token.

The part people miss is the pre-tokenizer regex. Before BPE runs, text is split into chunks that merges may never cross: a word with its leading space, a run of up to three digits, a run of punctuation, whitespace. That regex is why " generates" is one token and why "3,141" is three. GPT-4 introduced the three-digit rule so that numbers tokenize uniformly, and Llama 3 copied it almost verbatim.

Vocabulary size

The vocab size is a tradeoff between sequence length and embedding cost. A bigger vocabulary means fewer tokens per document (cheaper attention, longer effective context) but a bigger embedding table and output head, and rarer tokens that each get less training signal.

GPT-2 (2019)
50,257
GPT-4 cl100k · OLMo "dolma2"
100,277 · 100,278 (OLMo reuses the cl100k merges)
Llama 3
128,256 = 128,000 merges + 256 reserved special tokens
DeepSeek-V3
129,280
Qwen3
151,669
Kimi K2 / K3
160K
GPT-4o o200k · Gemma 3
200,019 · 262,144
nanochat
32,768 = 2¹⁵, trained on 2B characters of the pre-training data

The other thing decided here: special tokens. A base model needs one, the document separator. But the chat format that post-training will teach needs more (turn markers, tool-call brackets), and the tokenizer cannot change later. So they are reserved up front. Llama 3 reserves 256 of them; nanochat defines nine, from <|bos|> to <|output_end|>, before pre-training even starts.

02the codea readable version of what the real repos do+

The regexes, verbatim. Read them as "things a merge may not cross".

the pre-tokenizer patterns
# GPT-2 (tiktoken r50k)
'(?:[sdmt]|ll|ve|re)| ?\p{L}++| ?\p{N}++| ?[^\s\p{L}\p{N}]++|\s++$|\s+(?!\S)|\s

# GPT-4 (cl100k): digits capped at 3, case-insensitive contractions
'(?i:[sdmt]|ll|ve|re)|[^\r\n\p{L}\p{N}]?+\p{L}++|\p{N}{1,3}+| ?[^\s\p{L}\p{N}]++[\r\n]*+|\s++$|\s*[\r\n]|\s+(?!\S)|\s

# Llama 3 (llama-models/models/llama3/tokenizer.py, pat_str)
(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}{1,3}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+

# nanochat (nanochat/tokenizer.py, SPLIT_PATTERN): digits capped at 2
'(?i:[sdmt]|ll|ve|re)|[^\r\n\p{L}\p{N}]?+\p{L}+|\p{N}{1,2}| ?[^\s\p{L}\p{N}]++[\r\n]*|\s*[\r\n]|\s+(?!\S)|\s+

Training is a loop that any of us could write. This is the algorithm from Karpathy's minbpe, which is what the Rust version in nanochat does with better data structures.

bpe training, the algorithm
def train_bpe(text: str, vocab_size: int):
    ids = list(text.encode("utf-8"))               # start from bytes: 256 base tokens
    merges = {}
    for new_id in range(256, vocab_size):
        counts = {}
        for a, b in zip(ids, ids[1:]):             # count adjacent pairs (inside regex chunks, in practice)
            counts[(a, b)] = counts.get((a, b), 0) + 1
        pair = max(counts, key=counts.get)         # the most frequent pair becomes one token
        merges[pair] = new_id
        ids = merge(ids, pair, new_id)             # replace every occurrence
    return merges                                  # the tokenizer *is* this ordered list of merges

nanochat's script is the production shape of the same idea: cap the training text, cap each document, train in Rust, then hand the merges to tiktoken for fast encoding.

nanochat · train, then wrap in tiktoken
# scripts/tok_train.py: defaults --max-chars 2_000_000_000 --doc-cap 10_000 --vocab-size 32768
tokenizer = RustBPETokenizer.train_from_iterator(text_iter, args.vocab_size)   # rustbpe, GPT-4-style split

# nanochat/tokenizer.py: wrap the learned merges in tiktoken for encode_ordinary_batch speed
enc = tiktoken.Encoding(name="rustbpe", pat_str=SPLIT_PATTERN,
                        mergeable_ranks=mergeable_ranks, special_tokens=SPECIAL_TOKENS)
SPECIAL_TOKENS = ["<|bos|>", "<|user_start|>", "<|user_end|>", "<|assistant_start|>", "<|assistant_end|>",
                  "<|python_start|>", "<|python_end|>", "<|output_start|>", "<|output_end|>"]
--doc-cap 10_000
Cap each document at 10K characters so a few giant pages do not dominate the pair statistics.
--vocab-size 32768
A power of two, and small. nanochat measures loss in bits per byte so this choice is comparable to a 128K vocabulary run.
tiktoken.Encoding(...)
The learned merges become mergeable_ranks; tiktoken is what makes batch encoding fast enough to tokenize on the fly during training.
SPECIAL_TOKENS
Reserved now, used in post-training. <|bos|> is the only one pre-training touches: every document starts with it.
03the scaffoldingwhat production adds, and where it breaks+

What production adds

  • Pad the vocabulary. The embedding table and output head are padded to a multiple of 64 or 128 rows so the matmuls tile onto tensor cores. nanochat: pad_vocab_size_to=64; OLMo pads to 128. The extra rows are never produced.
  • Measure in bits per byte, not loss. Cross-entropy per token depends on the tokenizer. Divide by the bytes each token covers and runs with different vocabularies become comparable. nanochat reports val_bpb for exactly this reason.
  • Digit policy. GPT-4 and Llama 3 chunk digits by three, nanochat by two, Qwen by one. Each is a bet on how the model will do arithmetic. There is no consensus, only ablations.
  • Byte-level vs byte fallback. Llama 2 used SentencePiece with byte_fallback: normal tokens over characters, bytes only for what is left. Llama 3 switched to tiktoken's byte-level BPE, and so did nearly everyone else.
  • Multilingual fertility. A vocabulary trained mostly on English spends three to five tokens per word on Thai or Hindi. Qwen3's 151K and Gemma's 262K vocabularies exist to bring that down. Apertus, trained on 1,800 languages, treats tokenizer coverage as a first-class design goal.
  • The tokenizer is data too. nanochat trains it on the first 2B characters of the same corpus it will pre-train on. Train it on the wrong distribution and code or math tokenize badly for the life of the model.
a thing that surprised meThe tokenizer is where the model's arithmetic, its handling of whitespace in code, and its multilingual cost are all fixed, and it is decided in an afternoon on a CPU before a single GPU is turned on.
04 · step 3 · pack & mix

Cut everything into rows.

A GPU wants a rectangle: B rows of exactly T tokens. Documents are nothing like that. A tweet is 20 tokens and a legal filing is 40,000. So documents are concatenated into one stream with a separator token between them, and the stream is cut into rows. Two questions follow: should a row's attention be allowed to look across the separator into an unrelated document, and how do you avoid chopping documents in half?

packing · five documents into rows of sixteen · then the mask
DOCUMENTS (TOKENS)A · 5B · 9C · 3D · 7E · 4concatenate, insert <eos>, cut into rows of T = 16PACKED ROWSAAAAABBBBBBBBBrow 0CCCDDDDDDDEEEErow 1⏎ = <eos>, the document separator. Row 0 holds A, B and the start of C; C continues in row 1.ATTENTION MASK · ROW 0 (QUERY ↓ CAN SEE KEY →)keys →qWHAT THE MASK DECIDESCausal: a token attends only to earlier positions.Packed rows add a question: may token 9 (doc B)read tokens 0-5 (doc A)? They are unrelated text.Cross-document attention: allowed (plain causal)wastes attention on noise but is simplest.Masked (block-diagonal): each document sees onlyitself. Needs cu_seqlens plumbing. Llama 3 does this.
Toggle the mask. "Allowed" is plain causal attention: token 9, the start of document B, can attend to document A, which is noise. "Masked" is block-diagonal: each document only sees itself. Llama 3, OLMo 3, SmolLM3 and modded-nanogpt all mask.
01the ideawhat it does and why. read this first+

The 2023 default, still in nanoGPT and torchtitan's simplest loader, is "concatenate with EOS, cut every T". It wastes nothing but it cuts documents mid-sentence and lets attention leak across them. Two refinements are now standard.

Best-fit packing treats the row as a bin. nanochat's loader starts every row with BOS, then repeatedly places the largest document that still fits; when nothing fits, it crops one to fill the row exactly. Result: no padding, every token can see its own document's start, and about 35 percent of tokens get cropped at T = 2048. The paper behind it is "Fewer Truncations Improve Language Modeling" (Ding et al., 2024).

Document masking fixes the leak. The attention mask becomes block-diagonal within the row, so a token never attends past its own document's start. It needs a kernel that understands variable-length sequences: FlashAttention's varlen interface with cu_seqlens, or FlexAttention with a document mask. OLMo-core passes doc_lens through the whole stack for this.

Mixing, and the three stages

The corpus is not one pile; it is dozens of sources with weights. The mixture says how many tokens come from each, and a source can be up-sampled past one epoch if it is scarce and valuable (OLMo-core's max_repetition_ratio). SmolLM3's config lists about 45 sources: FineWeb-Edu, DCLM, papers, Wikipedia, StackExchange, 14 languages of FineWeb2, two math sets, 15 languages of The Stack, pull requests, Kaggle notebooks, GitHub issues.

And the weights change over the run. The 2025 to 2026 norm is three stages: a long general stage at short context, a shorter "midtraining" stage where math, code and higher-quality text are up-weighted while the learning rate decays to zero, then a brief long-context stage at 32K to 64K tokens.

Qwen3
S1: over 30T tokens at 4,096 context, 119 languages. S2: about 5T higher-quality STEM, code and synthetic tokens with faster LR decay. S3: hundreds of billions at 32,768 with RoPE base raised to 1M.
OLMo 3 7B
Pretrain 5.93T on 512 H100s. Midtrain 100B on 128 H100s, LR linearly to 0. Long-context 50B on 256 H100s at 65,536 with YaRN and context parallel of 8.
SmolLM3 3B
11.2T total. Web/code/math at 85/12/3 for 8T, then 75/15/10 to 10T, then 63/24/13 to 11.1T. Then 100B of long-context and 140B of reasoning midtraining.
Kimi K2
15.5T at 4,096, then a 400B annealing phase with LR 2e-5 to 7e-6, then 60B at 32K, YaRN to 128K.
DeepSeek-V3
14.8T at 4K, then two phases of 1,000 steps each to reach 32K and 128K.
02the codea readable version of what the real repos do+

nanochat's loader tokenizes parquet shards on the fly and packs with best-fit. This is the core of tokenizing_distributed_data_loader_with_state_bos_bestfit, trimmed.

nanochat · bos-aligned best-fit packing
row_capacity = T + 1                                  # +1 so inputs and targets can both be T long
token_lists = tokenizer.encode(doc_batch, prepend=bos_token, num_threads=tokenizer_threads)
doc_buffer.extend(token_lists)
for row_idx in range(B):
    pos = 0
    while pos < row_capacity:
        remaining = row_capacity - pos
        best_idx, best_len = -1, 0
        for i, doc in enumerate(doc_buffer):          # largest document that fits entirely
            if len(doc) <= remaining and len(doc) > best_len:
                best_idx, best_len = i, len(doc)
        if best_idx >= 0:
            doc = doc_buffer.pop(best_idx)
            row_buffer[row_idx, pos:pos + len(doc)] = torch.tensor(doc); pos += len(doc)
        else:                                         # nothing fits: crop the shortest to fill exactly
            doc = doc_buffer.pop(shortest_idx)
            row_buffer[row_idx, pos:pos + remaining] = torch.tensor(doc[:remaining]); pos += remaining
inputs, targets = row_buffer[:, :-1], row_buffer[:, 1:]   # (B, T) each; targets are inputs shifted left

Document masking, two ways. modded-nanogpt cuts the stream at BOS positions and hands FlashAttention-3 the boundaries; torchtitan builds the same thing as a FlexAttention block mask.

document masking · varlen and flex
# modded-nanogpt: boundaries as cumulative sequence lengths, batch size 1
# seqlens = [0, 5, 14, 16, ...]  cumulative document starts within the packed stream
y = flash_attn_varlen_func(q[0], k[0], v[0], cu_seqlens_q=seqlens, cu_seqlens_k=seqlens,
                           max_seqlen_q=max_len, max_seqlen_k=max_len,
                           causal=True, window_size=(bm_size, 0))          # sliding window too

# torchtitan: the same mask as a predicate for FlexAttention
def document_causal(b, h, q_idx, kv_idx):
    return (q_idx >= kv_idx) & (doc_id[b, q_idx] == doc_id[b, kv_idx])
block_mask = create_block_mask(document_causal, B, None, T, T)
y = flex_attention(q, k, v, block_mask=block_mask)

Mixing is configuration, not code. OLMo-core's source mixture is a list of sources with target ratios; the official mix manifest is 951 lines of one file path each.

olmo-core · a source mixture (ratios illustrative, fields real)
SourceMixtureConfig(source_name="dclm",      target_ratio=0.55, paths=[...], max_repetition_ratio=1.0)
SourceMixtureConfig(source_name="stack_edu", target_ratio=0.15, paths=[...], max_repetition_ratio=2.0)  # up-sample: two epochs
SourceMixtureConfig(source_name="math",      target_ratio=0.05, paths=[...])

# data/mixes/OLMo-mix-0925-official.txt · one line per tokenized shard
common_crawl_adult_content,preprocessed/dolma3-0625/v0.1-official/{TOKENIZER}/common_crawl/adult_content/000000.npy
03the scaffoldingwhat production adds, and where it breaks+

What the tokens look like on disk

llm.c / modded-nanogpt .bin
A 256-entry int32 header [20240520, 1, num_tokens], then uint16 token ids, each document prefixed with <|endoftext|> (50256). The 10B FineWeb sample is 103 shards of 100M tokens.
nanoGPT train.bin
One uint16 memmap. OpenWebText is 9,035,582,198 training tokens with EOT appended per document. uint16 works because GPT-2's vocabulary fits in 65,536.
OLMo / dolma .npy + .csv.gz
dolma tokens writes uint16 ids with EOS between documents, plus a csv of start,end,id,src,loc per document so the loader can find boundaries.
nanochat
No token files at all. Raw parquet shards of text, tokenized on the fly by four threads; the loader state is (pq_idx, rg_idx, epoch).

Where it gets hard

  • Cropping vs padding. nanochat accepts 35 percent cropped tokens because data is abundant. With scarce data (a specialist domain, a small language) you keep the older loader and live with mid-document cuts.
  • Naive masking is slow. A dense (T×T) mask through standard attention costs the same as full attention and then some. The varlen and flex kernels skip the zero blocks entirely; that is the whole point of them.
  • Long context is mostly a RoPE change. Raise the rotary base (10K to 500K for Llama 3 and OLMo, 1M for Qwen3, 1.5M then 5M for SmolLM3), train briefly at the longer length, extrapolate further with YaRN at inference. The training-time cost is context parallelism: OLMo 3 splits 65K-token sequences across 8 GPUs.
  • The published base model is the post-anneal checkpoint. Midtraining is where the learning rate reaches zero and where math and code get up-weighted, so "the base model" you download is not the constant-LR model. Annealing runs are also how labs A/B test data: branch from the stable checkpoint, anneal on candidate mixes, compare.
  • Epochs vs rephrasing. Kimi K2 reuses high-value tokens by rephrasing them with an LLM rather than repeating them, on the evidence that repeats past a couple of epochs stop helping.
  • Resume must be deterministic. The loader state is checkpointed with the model so a restart continues from the same document. nanochat's is approximate (it advances one row group to avoid repeats); OLMo's is exact by construction of the index.
05 · step 4 · the model

Define the decoder.

The architecture converged around 2023 and has barely moved since: a stack of identical blocks, each one attention then an MLP, each wrapped in a residual connection. What changed from GPT-2 is a list of small substitutions, every one of them made for stability or memory. The big new ideas live in the next step, in the MLP.

one block · gpt-2 · modern dense · modern moe
GPT-2 (2019)token emb + learned pos embLayerNormmulti-head attentionLayerNormMLP · GELU · 4×tied lm_headMODERN DENSE (LLAMA 3 · QWEN3)token emb · RoPE inside attentionRMSNormGQA + RoPE + QK-normRMSNormSwiGLU · ~8/3× · no biasuntied lm_headMODERN MOE (DEEPSEEK-V3 · KIMI K2)token emb · RoPE inside attentionRMSNormMLA (compressed KV) + RoPERMSNormrouter → top-k experts + shareduntied lm_head · + MTP headThe bracket marks the residual stream: each sub-block adds its output back onto x. The recipe converged around 2023; whatchanged since is mostly attention memory (GQA → MLA) and the MLP (dense → MoE).
01the ideawhat it does and why. read this first+

Read the middle column top to bottom. RMSNorm replaced LayerNorm: it only divides by the root-mean-square, no mean subtraction, no bias, and nanochat drops the learnable gain too. Cheaper and just as good.

RoPE replaced learned position embeddings. Instead of adding a position vector to the input, rotate the query and key vectors by an angle that grows with position, so the dot product between two tokens depends only on their distance. It generalizes past the training length, which learned positions never did, and it is what makes long-context extension a matter of changing one number, the base frequency.

GQA (grouped-query attention) shrinks the KV cache: 32 query heads share 8 key-value heads in Llama 3 8B, 64 share 8 in Qwen3-32B. Inference memory drops fourfold or more for a tiny quality cost. QK-norm applies RMSNorm to queries and keys before the dot product, which caps attention logits and is the single most common fix for training instability in the 2025 recipes (OLMo 2 and 3, Qwen3, nanochat).

SwiGLU replaced the GELU MLP: three matrices instead of two, a gate multiplied into an up-projection, hidden width cut to 8/3 of the old 4× so the parameter count stays the same. The speedrun world uses ReLU² instead, one matrix fewer and just as good at that scale. Biases are gone everywhere, and so is dropout. Embeddings are usually untied from the output head.

Newer: sliding-window layers. Three layers attend only to the last 768 or 4,096 tokens and every fourth attends to everything (nanochat's pattern is "SSSL", OLMo 3's is [4096, 4096, 4096, -1]). Most of the cost of attention goes away and the full layers keep long-range access. And a logit softcap: 15 · tanh(logits / 15) on the output, from Gemma 2, which stops the final logits from running away.

02the codea readable version of what the real repos do+

nanochat's gpt.py is the 2026 dense block in 555 lines. The attention forward, lightly trimmed. Note what is not there: no positional embedding table, no bias, no dropout.

nanochat/gpt.py · attention, mlp, block
def forward(self, x, ve, cos_sin, window_size, kv_cache):
    B, T, C = x.size()
    q = self.c_q(x).view(B, T, self.n_head, self.head_dim)
    k = self.c_k(x).view(B, T, self.n_kv_head, self.head_dim)      # n_kv_head < n_head: GQA
    v = self.c_v(x).view(B, T, self.n_kv_head, self.head_dim)
    if ve is not None:                                              # value embeddings on alternate layers
        gate = 3 * torch.sigmoid(self.ve_gate(x[..., :self.ve_gate_channels]))
        v = v + gate.unsqueeze(-1) * ve
    cos, sin = cos_sin
    q, k = apply_rotary_emb(q, cos, sin), apply_rotary_emb(k, cos, sin)   # RoPE
    q, k = norm(q), norm(k)                                         # QK-norm
    y = flash_attn.flash_attn_func(q, k, v, causal=True, window_size=window_size)  # (768, 0) or (T, 0)
    return self.c_proj(y.contiguous().view(B, T, -1))

class MLP(nn.Module):
    def forward(self, x):
        return self.c_proj(F.relu(self.c_fc(x)).square())           # relu²: one matrix fewer than SwiGLU

class Block(nn.Module):
    def forward(self, x, ve, cos_sin, window_size, kv_cache):
        x = x + self.attn(norm(x), ve, cos_sin, window_size, kv_cache)   # pre-norm, residual
        x = x + self.mlp(norm(x))
        return x

def norm(x):
    return F.rms_norm(x, (x.size(-1),))                             # no learnable gain
n_kv_head < n_head
Grouped-query attention. Query heads are grouped and each group shares one K and one V projection. The KV cache at inference shrinks by the group size.
apply_rotary_emb(q, cos, sin)
RoPE. cos and sin are precomputed for every position with base 100,000 here (500,000 in Llama 3 and OLMo). Half the head dimension is rotated against the other half.
norm(q), norm(k)
QK-norm, after RoPE. It bounds the dot products so softmax never saturates, which is the failure behind most loss spikes.
window_size=window_size
The sliding window. (768, 0) on "S" layers, (T, 0) on "L" layers, tiled as SSSL with the last layer always long.
ve, ve_gate
Value embeddings: a second embedding table whose vectors are added to V on alternate layers, gated per head. A speedrun trick that transferred to 1.4B in nanochat's ablations.
F.relu(x).square()
ReLU². Llama and Qwen use SwiGLU: w2(silu(w1(x)) * w3(x)) with hidden width int(2·4d/3) rounded to a multiple of 256 or 1,024.

The SwiGLU version, from the Llama reference, so you can see where the 8/3 comes from.

llama-models/models/llama3/model.py · swiglu
class FeedForward(nn.Module):
    def __init__(self, dim, hidden_dim, multiple_of, ffn_dim_multiplier):
        hidden_dim = int(2 * hidden_dim / 3)                          # 4d -> 8d/3: same params as a 2-matrix mlp
        if ffn_dim_multiplier is not None:
            hidden_dim = int(ffn_dim_multiplier * hidden_dim)         # llama 3.1 8b: 1.3 -> 14,336
        hidden_dim = multiple_of * ((hidden_dim + multiple_of - 1) // multiple_of)
        self.w1 = Linear(dim, hidden_dim, bias=False)                 # gate
        self.w2 = Linear(hidden_dim, dim, bias=False)                 # down
        self.w3 = Linear(dim, hidden_dim, bias=False)                 # up
    def forward(self, x):
        return self.w2(F.silu(self.w1(x)) * self.w3(x))
Llama 3 8B (torchtitan _8b)
dim 4096 · 32 layers · 32 heads / 8 kv heads · vocab 128,256 · rope θ 500,000 · ffn 14,336
OLMo 3 7B
olmo2_7B block with reordered norm, qk_norm=True, rope θ 500,000, sliding windows [4096, 4096, 4096, −1], full attention forced on the last layer
nanochat d24
depth 24 → width 24×64 = 1,536 → 1.38B params; every other hyperparameter derived from depth
03the scaffoldingwhat production adds, and where it breaks+

What the frontier models add

MLA, multi-head latent attention, is DeepSeek's answer to the KV cache: compress keys and values into a 512-dimensional latent per token, store only that, and decompress on the fly. A separate 64-dimensional "rope key" carries position, because the rotation cannot pass through the compression. Queries get their own low-rank path (rank 1,536). In absorb mode the decompression matrix folds into the score computation, so inference never materializes full keys at all. DeepSeek-V3 and Kimi K2 both use it; Kimi K3 gates it and interleaves it with a linear-attention layer (KDA), 69 linear to 24 gated-MLA layers.

MTP, multi-token prediction: one extra small block that predicts the token after next from the main model's hidden state, trained with its own cross-entropy at weight 0.3 for the first 10T tokens and 0.1 after. It densifies the training signal and doubles as a draft model for speculative decoding. torchtitan's models/deepseek_v3/MTP.md documents the block: enorm, hnorm, eh_proj, one transformer block, a shared output head.

DeepSeek V4 (April 2026) goes further on attention memory: sliding windows plus heavily compressed attention and compressed sparse attention, with a 1M context. Kimi K3 is a hybrid with linear attention. Nemotron 3 is a hybrid Mamba-Transformer MoE. The direction is consistent: attention over the full sequence is now the exception, not the rule.

What the speedrun adds, and a warning

modded-nanogpt's file has value embeddings, U-net style skip connections between early and late layers (now "MUDD" skips), a bigram hash embedding, paired-head attention, learnable cross-sequence attention, and an FP8 output head. Its README says plainly that "some methods used in the speedrun are unlikely to scale, particularly those imposing additional network structure, such as logit softcapping."

what transferred to 1.4B · nanochat dev/LOG.mdValue embeddings, QK-norm, ReLU², per-layer residual scalars and sliding windows helped. Multi-token prediction, varlen attention and an FP8 lm_head did not, at that scale. That log is the most honest document I know of about which 124M-scale tricks survive.

Stability choices, side by side

OLMo 2 / 3
Reordered norm (norm on the sublayer output, inside the residual branch), QK-norm, z-loss 1e-5, init std 0.02 everywhere, no weight decay on embeddings.
Qwen3
QK-norm added explicitly "to ensure stable training". Global-batch load balancing for the MoE variants.
nanochat / modded-nanogpt
Logit softcap 15·tanh(x/15) instead of z-loss. Zero-initialized output projections. Per-layer residual and x0 blending scalars.
Gemma 2 / 3
Softcapping on attention and final logits (memory), post-norm and pre-norm together.
06 · step 4b · mixture of experts

Replace the MLP with experts.

The MLP is two thirds of the parameters in a dense transformer, and every token pays for all of it. A mixture of experts keeps many MLPs and lets each token use a few. DeepSeek-V3 has 671 billion parameters and touches 37 billion per token. The knowledge is stored in the whole thing; the compute is spent on a slice. Every frontier open model since 2025 is built this way.

routing · one token · eight experts · top-2 (deepseek-v3 uses 256 and top-8)
token xhidden staterouterW_g · x → scoresROUTER SCORES (SIGMOID)E0E1E2E3E4E5E6E7ROUTED EXPERTS · 8 HERE, 256 IN DEEPSEEK-V3shared expertalways onΣ wᵢ·Eᵢ(x)+ sharedout
01the ideawhat it does and why. read this first+

The router is a single linear layer. It maps the token's hidden state to one score per expert (256 of them in V3), squashes the scores with a sigmoid, and picks the top-k (8). Those experts run on the token; their outputs are summed, weighted by the renormalized scores. One shared expert runs on every token regardless, so common knowledge does not have to be duplicated across the routed ones. Kimi K2 keeps the same shape at 384 experts; Kimi K3 has 896 with top-16 and two shared; Qwen3-MoE has 128, top-8, and no shared expert.

The failure mode is load imbalance. Nothing stops the router from sending every token to the same three experts, which then become a small dense model while the other 253 sit idle. The classic fix is an auxiliary loss that penalizes uneven expert usage, but that loss fights the language-modeling loss. DeepSeek-V3's fix is auxiliary-loss-free: a per-expert bias added to the scores only for the purpose of selection. After each step, the bias of an overloaded expert goes down by a small constant and an underloaded one goes up. The weights the model uses to mix outputs never see the bias. Toggle it in the diagram above.

The memory-versus-compute math

DeepSeek-V3
671B total · 37B active · 256 routed + 1 shared · top-8 · 61 layers, first 3 dense · expert width 2,048 · routed to at most 4 nodes
Kimi K2
1.04T total · 32.6B active · 384 routed + 1 shared · top-8 · 61 layers, 1 dense · 64 attention heads · no expert grouping
Kimi K3 (2026)
2.8T total · 104B active · 896 experts in a compressed latent space · top-16 + 2 shared · 93 layers
Qwen3-235B-A22B
235B total · 22B active · 128 experts · top-8 · no shared expert · global-batch balancing loss
DeepSeek V4 (2026)
Flash: 284B total / 13B active. Pro: 1.6T total / 49B active.
Llama 4 Maverick
17B active with 128 experts, one shared.

Read the first column against the second. Per-token FLOPs scale with active parameters, so V3 trains and serves like a 37B dense model. Total parameters scale memory, so it needs the weights of a 671B model resident across the cluster. That asymmetry is the whole bet: parameters are cheap storage for knowledge, compute is the scarce thing. Kimi K2's report fits a "sparsity scaling law" to pick 384/8 = 48 as the ratio.

02the codea readable version of what the real repos do+

DeepSeek-V3's inference code is the clearest reference I have found. The gate, verbatim except for comments. Everything is in it: sigmoid scores, the balancing bias, node-limited routing, top-k, renormalization.

deepseek-v3 · Gate.forward
def forward(self, x):                                    # class Gate, inference/model.py
    scores = linear(x, self.weight)                                  # (tokens, n_experts)
    scores = scores.softmax(-1, dtype=torch.float32) if self.score_func == "softmax" else scores.sigmoid()
    original_scores = scores
    if self.bias is not None:
        scores = scores + self.bias                                  # balancing bias: affects *which*, not *how much*
    if self.n_groups > 1:                                            # group-limited routing: pick 4 of 8 groups first
        scores = scores.view(x.size(0), self.n_groups, -1)
        group_scores = scores.amax(-1) if self.bias is None else scores.topk(2, -1)[0].sum(-1)
        indices = group_scores.topk(self.topk_groups, -1)[1]
        mask = scores.new_ones(x.size(0), self.n_groups, dtype=bool).scatter_(1, indices, False)
        scores = scores.masked_fill_(mask.unsqueeze(-1), float("-inf")).flatten(1)
    indices = torch.topk(scores, self.topk, dim=-1)[1]               # the 8 experts for each token
    weights = original_scores.gather(1, indices)                     # mixing weights come from the *unbiased* scores
    if self.score_func == "sigmoid":
        weights /= weights.sum(dim=-1, keepdim=True)                 # renormalize to 1
    weights *= self.route_scale                                      # 2.5 in v3
    return weights.type_as(x), indices

The MoE layer itself is a loop over experts. Simple, correct, and the version to understand before the fast one.

deepseek-v3 · MoE.forward · the loop version
def forward(self, x):                                    # class MoE
    shape = x.size()
    x = x.view(-1, self.dim)
    weights, indices = self.gate(x)                      # (N, k), (N, k)
    y = torch.zeros_like(x)
    counts = torch.bincount(indices.flatten(), minlength=self.n_routed_experts).tolist()
    for i in range(self.experts_start_idx, self.experts_end_idx):    # only this rank's experts
        if counts[i] == 0:
            continue
        expert = self.experts[i]
        idx, top = torch.where(indices == i)              # which tokens chose expert i, and in which slot
        y[idx] += expert(x[idx]) * weights[idx, top, None]
    z = self.shared_experts(x)                            # every token, always
    if world_size > 1:
        dist.all_reduce(y)                                # sum the partial results from every rank's experts
    return (y + z).view(shape)

The bias update, which lives in the training loop rather than the model, is one line in essence. γ was 0.001 for the first 14.3T tokens of V3 and 0 for the rest.

aux-loss-free balancing · the update
load = torch.bincount(indices.flatten(), minlength=n_experts).float()   # tokens per expert this step
gate.bias += gamma * torch.sign(load.mean() - load)                        # overloaded: down, underloaded: up

Production replaces the Python loop with a permutation and a grouped GEMM: sort tokens by expert, run one batched matmul over the variable-sized groups, unsort. torchtitan's GroupedExperts does this with torch._grouped_mm; MegaBlocks did it first with block-sparse kernels.

sort by expert, one grouped matmul
order = indices.flatten().argsort()                       # tokens grouped by expert id
x_sorted = x.repeat_interleave(k, 0)[order]               # each token appears k times, once per chosen expert
offsets = counts.cumsum(0)                                # where each expert's group ends
h = torch._grouped_mm(x_sorted, w1, offs=offsets)         # one kernel, all experts, no padding
h = F.silu(torch._grouped_mm(x_sorted, w_gate, offs=offsets)) * h
out_sorted = torch._grouped_mm(h, w2, offs=offsets)
y = torch.zeros_like(x).index_add_(0, token_of[order], out_sorted * weights.flatten()[order, None])
03the scaffoldingwhat production adds, and where it breaks+

Expert parallelism

256 experts do not fit on one GPU, so they are spread across an expert-parallel group and tokens travel to their experts: an all-to-all before the experts (dispatch) and another after (combine). DeepSeek-V3 trained with 64-way EP across 8 nodes, and its node-limited routing exists precisely so a token's 8 experts sit on at most 4 nodes, bounding the cross-node traffic. DeepEP is their open kernel for the all-to-all. Kimi K2 used 16-way EP with 16-way pipeline parallelism.

EP composes with FSDP awkwardly: the experts are sharded one way (by expert) and everything else another (by parameter). torchtitan keeps a separate "edp" mesh for the expert weights and prefetches expert all-gathers around the all-to-all.

Where MoE training breaks

  • Router collapse. Without balancing, a few experts win early and the rest never learn. The Switch Transformer loss is α · N · Σᵢ fᵢ · Pᵢ (fraction of tokens routed to i, times mean router probability for i). DeepSeek-V3 keeps a tiny sequence-wise version at α = 0.0001 alongside the bias. Qwen3 computes the balance over the global batch, not per micro-batch, so that specialization within a domain is not penalized.
  • Router in fp32. The scores are computed in float32 even in a bf16 or FP8 run; tiny score differences decide routing and low precision makes them noisy. DeepSeek keeps the gating modules out of FP8 entirely.
  • Capacity factor vs dropless. Old MoEs capped tokens per expert and dropped the overflow. Dropless MoE (MegaBlocks) processes everything with variable-sized groups, which is what grouped GEMM makes possible.
  • Upcycling. Copy a dense model's MLP into every expert and continue training. Qwen and MiniCPM did this; it avoids a cold start but the experts begin identical and take a while to diverge.
  • Fine-tuning fragility. Small post-training sets barely touch most experts. Kimi K2's advice is to fine-tune a Muon-pretrained MoE with Muon; LoRA on MoE layers is still an open question in the open literature.
  • Serving is a different problem. The 37B-active model still needs 671B parameters resident, so batching many requests is what makes it economical; single-user latency does not benefit.
the trendFiner experts and more of them: 8 experts of 14K width in Mixtral (2023) became 256 of 2K width in V3 (2024), 384 in K2 (2025), 896 in K3 (2026). K3 also runs them in a compressed latent space, halving expert FLOPs, the same trick MLA plays on attention.
07 · step 5 · init & optimizer

Pick the numbers that cannot be tuned later.

Initialization, optimizer, learning-rate schedule, batch size, and how many tokens to train on. None of these can be changed once the run is a week in, and a wrong one costs the whole run. This is where the scaling-law sweeps get spent, and where the one genuinely new thing of the last two years, the Muon optimizer, lives.

learning rate over the run · cosine vs warmup-stable-decay
tokens →lrwarmupstable · constant lr (WSD)decaycosine to 10% (llama 2/3)branch here for anneal runs
WSD holds the rate flat and decays late. Its advantage is operational: you can branch from the flat part to anneal on different data, or extend the run, without retraining from scratch. Kimi K3 went back to cosine after finding it wins once each schedule is tuned separately.
01the ideawhat it does and why. read this first+

Initialization

Nearly every lab initializes every matrix from a truncated normal with standard deviation 0.02 (OLMo's InitMethod.normal, torchtitan's default), and scales the output projections down with depth so that the residual stream does not grow layer by layer. The speedrun world goes further and zero-initializes the output projections of attention and the MLP, so every block starts as the identity. nanochat's table is worth reading in full, because every line is a deliberate choice.

nanochat GPT.init_weights · the docstring
wte (embedding):     normal, std=0.8   (then RMS-normed, so the scale is free)
lm_head:             normal, std=0.001
attn.c_q, c_k, c_v:  uniform, std=1/sqrt(n_embd)   (uniform avoids outliers)
attn.c_proj:         zeros
mlp.c_fc:            uniform, std=0.4/sqrt(n_embd)
mlp.c_proj:          zeros
resid_lambdas:       1.15 -> 1.05 across depth     (per-layer residual scale)
x0_lambdas:          0.20 -> 0.05 across depth     (blend the input embedding back in)

AdamW, the boring consensus

β₁ = 0.9, β₂ = 0.95, weight decay 0.1, gradient clipping at 1.0, and no weight decay on the embeddings. That line describes Llama 3, DeepSeek-V3, OLMo 3, SmolLM3 and Qwen3. Peak learning rates: 3e-4 for OLMo 3 7B, 2e-4 for SmolLM3 and Kimi K2, 2.2e-4 for DeepSeek-V3. Warmup of 2,000 steps or so. The differences between labs are in the schedule and the batch, not the optimizer.

Muon

Muon is the exception. For the 2-D weight matrices inside the blocks, it takes the momentum-averaged gradient and orthogonalizes it: replace the update matrix with the nearest matrix whose singular values are all one, computed with five Newton-Schulz iterations in bf16. The effect is that every direction in the update gets the same step size, instead of a few dominant directions taking all of it. Embeddings, the output head and scalars stay on AdamW. It won the speedrun in 2024, Moonshot showed it scales ("Muon is Scalable", 16B on 5.7T tokens, about half the FLOPs of AdamW for equal loss), and Kimi K2 trained a trillion-parameter model with it. MuonClip is K2's addition: after each step, if any attention head's maximum logit exceeds a threshold τ, rescale that head's query and key weights down. K2 reports 15.5T tokens without a single loss spike.

The schedule, the batch, the budget

Two schedule families coexist. Cosine decay to about 10 percent of peak (Llama 2 and 3, OLMo 3, Kimi K3). WSD, warmup then stable then decay (nanochat, OLMo-core's option, SmolLM3, DeepSeek-V3 and Kimi K2 in a multi-phase form). WSD's flat middle is what makes annealing experiments and run extensions possible. A batch-size ramp is standard too: DeepSeek-V3 grows from 3,072 to 15,360 sequences over the first 469B tokens; nanochat settled on 1M tokens per step.

And the budget. Chinchilla's 20 tokens per parameter is now a floor, not a target, because inference cost depends on parameters and not on training tokens.

Llama 3.1 405B
15T / 405B ≈ 37 tokens per parameter
Llama 3.1 8B
≈ 1,900
DeepSeek-V3
14.8T / 671B ≈ 22 per total parameter, ≈ 400 per active parameter
Kimi K2
15.5T / 1.04T ≈ 15 total, ≈ 475 per active
OLMo 3 7B
5.93T / 7B ≈ 850
Qwen3-0.6B
36T / 0.6B = 60,000
nanochat speedrun
8 to 12 per "scaling parameter"; compute-optimal there is about 10.5
02the codea readable version of what the real repos do+

Muon, verbatim from the modded-nanogpt README. The three constants are a quintic polynomial tuned so the iteration converges fast in bf16.

muon
def zeroth_power_via_newtonschulz5(G, steps=5, eps=1e-7):
    a, b, c = (3.4445, -4.7750, 2.0315)
    X = G.bfloat16() / (G.norm() + eps)                  # normalize so the iteration converges
    if G.size(0) > G.size(1): X = X.T
    for _ in range(steps):
        A = X @ X.T
        B = b * A + c * A @ A
        X = a * X + B @ X                                # X -> polynomial in X X^T, pushes singular values to 1
    if G.size(0) > G.size(1): X = X.T
    return X.to(G.dtype)

# the step, per matrix parameter
buf.mul_(momentum).add_(grad)                           # nesterov momentum
g = grad.add(buf, alpha=momentum)
g = zeroth_power_via_newtonschulz5(g)                    # orthogonalize
p.add_(g, alpha=-lr * max(1, p.size(0) / p.size(1)) ** 0.5)   # scale by aspect ratio

nanochat's optimizer setup shows the split: which parameters get Muon, which get AdamW, and how the AdamW learning rates scale with width so that one config works for every depth.

nanochat GPT.setup_optimizer · trimmed
dmodel_lr_scale = (model_dim / 768) ** -0.5              # AdamW lr ∝ 1/√d_model, tuned at 768
param_groups = [
    dict(kind='adamw', params=lm_head_params,   lr=0.004 * dmodel_lr_scale, betas=(0.8, 0.96),  weight_decay=0.01),
    dict(kind='adamw', params=embedding_params, lr=0.2   * dmodel_lr_scale, betas=(0.8, 0.995), weight_decay=0.001),
    dict(kind='adamw', params=[resid_lambdas],  lr=0.005, betas=(0.8, 0.95), weight_decay=0.05),
    dict(kind='adamw', params=[x0_lambdas],     lr=0.5,   betas=(0.96, 0.95), weight_decay=0.0),
]
for shape in sorted({p.shape for p in matrix_params}):   # every 2-D weight inside the blocks
    param_groups.append(dict(kind='muon', params=[p for p in matrix_params if p.shape == shape],
                             lr=0.02, momentum=0.95, ns_steps=5, beta2=0.9, weight_decay=weight_decay))
optimizer = MuonAdamW(param_groups)

The schedule is twenty lines. This is nanochat's, a WSD with a long linear warmdown; the Muon momentum has its own schedule alongside it.

the schedule and the budget
def get_lr_multiplier(it):                               # scripts/base_train.py
    warmup_iters = args.warmup_steps                     # 40
    warmdown_iters = round(args.warmdown_ratio * num_iterations)   # 0.65 of the run
    if it < warmup_iters:
        return (it + 1) / warmup_iters
    elif it <= num_iterations - warmdown_iters:
        return 1.0
    else:
        progress = (num_iterations - it) / warmdown_iters
        return progress * 1.0 + (1 - progress) * args.final_lr_frac   # down to 0.05

def get_muon_momentum(it):                               # 0.85 -> 0.97 over 400 steps, back to 0.90 in the warmdown
    ...

num_iterations = (target_param_data_ratio * num_scaling_params) // total_batch_size   # the budget, from one dial

MuonClip, in essence, from the Kimi K2 report. It runs after the optimizer step and leaves the forward and backward of the current step untouched.

muonclip · qk-clip after each step
for h in heads:
    S_max = (1 / sqrt(d)) * max_over_batch(Q_h @ K_h.T)          # this head's largest attention logit
    if S_max > tau:                                              # tau = 100
        gamma = tau / S_max
        W_q[h] *= gamma ** alpha                                 # alpha = 0.5: split the shrink between q and k
        W_k[h] *= gamma ** (1 - alpha)
03the scaffoldingwhat production adds, and where it breaks+

What the ablation diaries say

  • Weight decay scales with width. nanochat's log found the optimum goes as 1/width² (0.22 at depth 12, 0.08 at depth 20), and schedules it linearly to zero over the run.
  • Gradient clipping can go. With Muon and QK-norm, nanochat deleted clipping: "grad norm never exceeds 1.0 naturally", and modded-nanogpt does not clip either. Every production run with AdamW still clips at 1.0.
  • Muon keeps evolving. nanochat replaced Newton-Schulz with Polar Express coefficients, added row equilibration, renormalizes updates to a target Frobenius norm, and uses NorMuon's factored second moments and "cautious" weight decay (decay only where the update and the weight agree in sign). Kimi K3 orthogonalizes per attention head. Megatron-Core added Muon support in 2026.
  • z-loss vs softcap. OLMo 3 adds 1e-5 · log²(Σ exp logits) to keep the output logits from drifting; DCLM used 5e-6. The speedrun family uses a tanh softcap instead. Both solve the same instability.
  • Nobody uses muP in production. Every verified recipe (Llama 3, DeepSeek-V3, OLMo 3, Kimi K2 and K3, Qwen3, SmolLM3) fits its learning rate and batch size from scaling-law sweeps on small models rather than from muP. Qwen3's report says it "sets the predicted optimal learning rate and batch size for each dense or MoE model". nanochat derives everything from --depth.
  • The full DeepSeek-V3 schedule, for the record: 2K warmup steps to 2.2e-4, constant until 10T tokens, cosine to 2.2e-5 over the next 4.3T, constant at 2.2e-5 for 333B, then 7.3e-6 for the final 167B.
Kimi K3 on WSD"Cosine decay consistently achieves a lower final loss than WSD" once each schedule gets its own tuned learning rate and batch size. The convenience of WSD is real; the loss advantage people claimed for it may not be.
08 · step 6 · the cluster

Run the loop on thousands of GPUs.

One H100 holds 80GB. A 405B model in bf16 is 810GB before you add gradients and optimizer state, which triple it. So the model, the batch, and even the sequence get split across GPUs, and the loop grows a layer of collective communication around every matmul. This step is the reason the production repos are unreadable, and the reason they exist.

sixteen gpus · data × tensor × pipeline parallel
node 0gpu 0L0-15gpu 1L0-15gpu 2L0-15gpu 3L0-15gpu 4L0-15gpu 5L0-15gpu 6L0-15gpu 7L0-15node 1gpu 8L16-31gpu 9L16-31gpu 10L16-31gpu 11L16-31gpu 12L16-31gpu 13L16-31gpu 14L16-31gpu 15L16-31TP=2 · shard 0TP=2 · shard 1TP=2 · shard 2TP=2 · shard 3PP=2stage 0stage 1DP=4 · each TP pair holds a full replica of its stage · params sharded across the four with FSDP16 GPUs = DP 4 × TP 2 × PP 2. A 32-layer model: PP splits layers across nodes, TP splits each matrix across a pair, DP feeds thefour pairs different batches and all-reduces gradients. Add EP for MoE: the experts of a layer spread across the DP group andtokens shuffle with all-to-all. Add CP for 128k contexts: the sequence itself is split across GPUs.
01the ideawhat it does and why. read this first+

The loop, with accumulation

On one node the loop is nanochat's: a few forward-backward passes per optimizer step (gradient accumulation, so a 1M-token batch fits in memory), then one step. bf16 activations, fp32 master weights and optimizer state, torch.compile for fusion. The multi-node version has the same shape with four kinds of splitting layered on.

Data parallel, then FSDP

Plain data parallelism copies the whole model to every GPU and averages gradients. It stops working when the model does not fit on one GPU. FSDP (fully sharded data parallel, ZeRO-3 in DeepSpeed's naming) keeps only a slice of every parameter on each GPU, all-gathers a layer's weights right before it runs, and reduce-scatters its gradients right after. Memory per GPU drops by the number of GPUs; the cost is communication that has to be overlapped with compute. FSDP2 in PyTorch is one function, fully_shard, called once per block and once on the root. OLMo 3 trained its 7B on 512 H100s with just this, in hybrid form (replicate across nodes, shard within).

Tensor parallel

Split each matrix across the GPUs of one node: column-wise for the first matmul of a block, row-wise for the second, so only one all-reduce is needed per sub-block. Sequence parallel extends the split to the norms and dropout between blocks. It lives inside a node because it needs NVLink bandwidth every layer. Llama 3 405B uses 8-way TP.

Pipeline parallel

Put layers 0 to 15 on one set of GPUs and 16 to 31 on another. The batch is cut into micro-batches that flow through the stages, and the schedule (1F1B, interleaved, zero-bubble, DeepSeek's DualPipe) decides how much of the time a stage sits idle waiting for its neighbor. Kimi K2 used 16-way pipeline with virtual stages; DeepSeek-V3, 16-way with DualPipe.

Context and expert parallel

Context parallel splits the sequence itself across GPUs and passes keys and values around a ring, which is how OLMo 3 trains 65K-token sequences with a degree of 8. Expert parallel puts different experts on different GPUs and moves tokens to them with all-to-all; DeepSeek-V3 ran 64-way EP across 8 nodes. torchtitan's rule for composing them: the dense part of the model is sharded over (dp × cp × tp) and the sparse part over (efsdp × ep), covering the same ranks.

Llama 3 405B
16,384 H100s. TP 8 · CP · PP · FSDP, in that order of nesting. bf16 compute.
DeepSeek-V3
2,048 H800s. PP 16 (DualPipe) · EP 64 across 8 nodes · ZeRO-1 data parallel. FP8 for the big matmuls.
Kimi K2
H800 cluster. PP 16 with virtual stages · EP 16 · ZeRO-1. Parameters in bf16, gradient accumulation in fp32. 67M-token batches.
OLMo 3 7B
512 H100s. HSDP with param bf16 / reduce fp32, compile on, 4M-token global batch, clip 1.0. Long-context stage adds CP 8.
SmolLM3 3B
384 H100s for 24 days on nanotron.
nanochat
8 H100s, no DDP at all: the optimizer owns the gradient sync.

Precision

Default: bf16 for activations and the matmuls, fp32 for the master weights, the optimizer moments and the gradient reduction. FP8 goes one step further on the big linear layers only. DeepSeek-V3's recipe quantizes activations per 1×128 tile and weights per 128×128 block in E4M3, and promotes to fp32 accumulation every 128 elements. Embeddings, the output head, the MoE router, norms and attention stay in bf16 or fp32. Llama 4 trained in FP8; Nemotron 3 went to NVFP4; Kimi K3 uses MXFP4 weights with quantization-aware training from SFT onward, so the served model matches the trained one.

02the codea readable version of what the real repos do+

nanochat's loop, verbatim and trimmed. The next batch is fetched while the GPU is busy with backward.

nanochat scripts/base_train.py · the loop
for micro_step in range(grad_accum_steps):
    loss = model(x, y)
    train_loss = loss.detach()
    loss = loss / grad_accum_steps
    loss.backward()
    x, y, dataloader_state_dict = next(train_loader)      # prefetch next batch while GPU is busy
lrm = get_lr_multiplier(step)
muon_momentum = get_muon_momentum(step)
for group in optimizer.param_groups:
    group["lr"] = group["initial_lr"] * lrm
    if group["kind"] == "muon":
        group["momentum"] = muon_momentum
optimizer.step()                                           # gradient sync happens inside, no DDP
model.zero_grad(set_to_none=True)

# grad_accum_steps = total_batch_size // (device_batch_size * max_seq_len * world_size)

FSDP2 is this. Each call makes one unit that all-gathers its bf16 parameters before forward and backward and reduce-scatters bf16 gradients into fp32 sharded gradients afterward.

torchtitan/distributed/fsdp.py · fsdp2
from torch.distributed.fsdp import fully_shard, MixedPrecisionPolicy

mp_policy = MixedPrecisionPolicy(param_dtype=torch.bfloat16, reduce_dtype=torch.float32)
fsdp_config = {"mesh": dp_mesh, "mp_policy": mp_policy}
for layer_id, transformer_block in model.layers.items():
    fully_shard(transformer_block, **fsdp_config, reshard_after_forward=reshard_after_forward)
fully_shard(model, **fsdp_config)                          # the root: embeddings, norm, head

torchtitan's step adds what a cluster needs: a gradient norm that is correct across pipeline stages and expert meshes, and a hard stop on a non-finite loss before the optimizer can apply it.

torchtitan/trainer.py · Trainer.train_step · trimmed
grad_norm = dist_utils.clip_grad_norm_(
    [p for m in self.model_parts for p in m.parameters()],
    self.config.training.max_norm, foreach=True,
    pp_mesh=parallel_dims.get_optional_mesh("pp"), ep_enabled=parallel_dims.ep_enabled)
step_is_finite = loss_is_finite.logical_and(torch.isfinite(grad_norm).all())
torch._assert_async(step_is_finite,
    "Loss or gradient norm is not finite on at least one rank at "
    f"step {self.step}. Stopping training before the optimizer update.")
self.checkpointer.maybe_wait_for_staging()               # async checkpoint copy must finish before params change
self.optimizers.step()
self.lr_schedulers.step()

The parallel layout is configuration. torchtitan dropped its TOML files in 2026; configs are Python now. This is the registered DeepSeek-V3 671B entry, trimmed.

torchtitan · a parallelism config
# torchtitan/models/deepseek_v3/config_registry.py · deepseek_v3_671b
optimizer=default_adamw(lr=2.2e-4),
lr_scheduler=LRSchedulersContainer.Config(warmup_steps=2000, decay_ratio=0.8, decay_type="cosine", min_lr_factor=0.1),
training=TrainingConfig(num_tokens_per_microbatch_per_dp_rank=4 * model_spec.max_context_length, steps=10000),
parallelism=ParallelismConfig(pipeline_parallel_schedule="Interleaved1F1B", expert_parallel_degree=2),
checkpoint=CheckpointManager.Config(interval=500),
activation_checkpoint=SelectiveAC.Config(),
compile=CompileConfig(enable=True, components=["loss"]),

# ParallelismConfig fields (defaults): data_parallel_replicate_degree=1, data_parallel_shard_degree=-1,
#   tensor_parallel_degree=1, pipeline_parallel_degree=1, pipeline_parallel_schedule="1F1B",
#   context_parallel_degree=1, expert_parallel_degree=1
# on the command line:  --parallelism.data_parallel_shard_degree 2 --parallelism.tensor_parallel_degree 2

And FP8 in essence, from DeepSeek-V3's inference kernel: per-tile scaling with the E4M3 maximum of 448.

deepseek-v3 inference/kernel.py · act_quant
@triton.jit
def act_quant_kernel(x_ptr, y_ptr, s_ptr, BLOCK_SIZE: tl.constexpr, scale_fmt: tl.constexpr):
    pid = tl.program_id(axis=0)
    offs = pid * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)      # one 1x128 tile of activations
    x = tl.load(x_ptr + offs).to(tl.float32)
    amax = tl.max(tl.abs(x))
    amax = tl.maximum(amax, 1e-4)
    s = amax / 448.                                          # 448 = the largest e4m3 value
    y = x / s
    tl.store(y_ptr + offs, y.to(y_ptr.dtype.element_ty))     # float8_e4m3fn
    tl.store(s_ptr + pid, s)                                 # one fp32 scale per tile
03the scaffoldingwhat production adds, and where it breaks+

The memory arithmetic

Adam mixed precision
16 bytes per parameter: 2 (bf16 weight) + 2 (bf16 grad) + 4 (fp32 master) + 8 (two fp32 moments). A 7B model is 112GB before activations, which is why even a 7B needs FSDP or ZeRO on 80GB cards.
ZeRO stages
1 shards optimizer state, 2 adds gradients, 3 adds parameters. FSDP2 is stage 3. HSDP replicates across nodes and shards within.
Activations
Scale with batch × sequence × layers. Activation checkpointing recomputes them in backward instead of storing them. torchtitan's selective mode saves only the expensive ops (attention outputs, matmuls, MoE top-k, the collectives) and recomputes the rest.
MoE weights
DeepSeek-V3's 671B in FP8 is 671GB of weights alone, across the cluster. Per token you touch 8 routed plus 1 shared expert of 44M parameters each in 58 layers, about 23B, plus attention: the 37B active.

What the communication looks like

  • FSDP: one all-gather per layer forward, one all-gather and one reduce-scatter per layer backward, overlapped with the next layer's compute. reshard_after_forward frees the gathered weights between forward and backward; torchtitan keeps them for the last block and under pipeline parallel.
  • TP: two all-reduces per block (after attention, after the MLP), or reduce-scatter and all-gather pairs with sequence parallel. Needs NVLink; never crosses a node. Async TP overlaps these with the matmuls.
  • PP: point-to-point activations between stages. The bubble is the idle fraction: (stages − 1) / micro-batches for 1F1B, which is why micro-batch counts are large and why zero-bubble and DualPipe schedules exist.
  • EP: two all-to-alls per MoE layer, dispatch and combine. DeepEP is DeepSeek's kernel for it, with FP8 dispatch; node-limited routing caps how many nodes a token's experts span.
  • CP: keys and values pass around a ring. torchtitan balances the causal work with a head-and-tail split per rank.

Where it breaks

  • Order of wrapping matters. torchtitan applies declarative sharding (TP), then activation checkpointing, then compile, then FSDP. Get it wrong and compile graphs break or hooks fire twice.
  • Clipping needs a global norm. The stock clip_grad_norm_ only sees one rank's slice under PP; torchtitan reduces norm^p across stages and takes the p-th root. nanochat once clipped per rank instead of globally, and the ablation log records the bug.
  • The router must not be quantized. torchtitan's float8 converter refuses to touch the router gate, and the 671B config filters lm_head and router.gate out of FP8. Small matmuls lose to quantization overhead too, so there is an auto-filter by shape.
  • FP8 needs compile. Without it the scale-and-cast kernels are not fused and FP8 is slower than bf16. Rowwise scaling keeps all communication in high precision; only the matmuls run in e4m3.
  • MFU is what you tune for. Picotron reports 38 percent on Llama 2 7B across 64 H100s and about 50 percent on a 1.7B model on 8. Anything above 40 at scale is good; MoE and long-context runs land lower.
09 · step 7 · checkpoint & eval

Keep it alive for a month.

A run that lasts weeks on thousands of GPUs will be interrupted. Hardware dies, a network link flaps, a loss spike wrecks the weights. The unglamorous half of pre-training is making sure that when that happens you lose an hour and not a week, and that you can tell, from a handful of numbers, whether the run is healthy.

reading the curve · what a healthy run looks like
tokens →losssteep early drop:unigram stats, then syntaxspikeskip batch · or roll backanneal: lr → 0,high-quality mix,loss drops again• = eval + checkpoint every N steps (HellaSwag, MMLU, CORE, held-out perplexity)
01the ideawhat it does and why. read this first+

Checkpoints

Every 500 to 1,000 steps, write the model, the optimizer state and the dataloader position to storage. PyTorch's distributed checkpoint (DCP) has every rank write its own shard in parallel, and can load the result onto a different number of GPUs, which is what lets you resume a 512-GPU run on 480 after a node dies. Saving is asynchronous: the GPU-to-host copy is staged, the write happens in a background process, and the next optimizer step waits only for the staging to finish. OLMo 3 saved every 1,000 steps and each stage of its run starts from a checkpoint URL of the previous one; every intermediate checkpoint is published.

Loss spikes

The loss suddenly jumps and sometimes never comes back. The cause is almost always attention logits growing until the softmax saturates. Three defenses, in order of preference. Prevent them in the architecture: QK-norm, reordered norm, z-loss, no weight decay on embeddings. Prevent them in the optimizer: MuonClip, which is how Kimi K2 got through 15.5T tokens with zero spikes. Or skip the bad step: OLMo-core's SkipStepOptimizer skips any update whose loss or gradient norm is more than 6 standard deviations above a rolling 128-step window. DeepSeek-V3 reports no irrecoverable spikes and no rollbacks; torchtitan refuses to continue on a non-finite loss and makes you restart from a checkpoint.

What to watch

Loss, smoothed. Gradient norm, because it rises before a spike. Tokens per second and MFU, model FLOPs utilization: the fraction of the hardware's peak you are actually using, computed from a formula rather than measured. Held-out loss every few hundred steps, in bits per byte. And downstream evals every few thousand steps: HellaSwag, ARC, MMLU subsets, a code and a math task, scored by the log-likelihood of the right continuation, since a base model cannot follow instructions.

CORE

nanochat's definition of "GPT-2 grade" is the DCLM CORE score: 22 few-shot tasks, each scored as centered accuracy, (accuracy − random baseline) / (1 − random baseline), then averaged. GPT-2 1.6B scores 0.2565; the current speedrun beats it in 1.65 hours. One number that moves smoothly over a run is worth more than twenty that do not.

02the codea readable version of what the real repos do+

DCP, from torchtitan's checkpointer. The to_hf branch writes safetensors that Hugging Face tooling can load.

torchtitan components/checkpointer/dcp.py · dcp_save
if to_hf:
    state_dict = self.sd_adapter.to_hf(state_dict)                    # rename keys into the HF layout
    storage_writer = HuggingFaceStorageWriter(path=save_path, save_distributed=True, ...)
if async_mode == AsyncMode.ASYNC:
    ret = dcp.async_save(state_dict, storage_writer=storage_writer,
                         checkpoint_id=checkpoint_save_id, process_group=self.pg)
elif async_mode == AsyncMode.ASYNC_WITH_PINNED_MEM:                     # stage to pinned host memory first
    ret = dcp.async_save(state_dict, storage_writer=storage_writer, checkpoint_id=checkpoint_save_id,
                         process_group=self.pg, async_checkpointer_type=AsyncCheckpointerType.PROCESS,
                         async_stager=self.stager)
else:
    ret = dcp.save(state_dict, storage_writer=storage_writer, checkpoint_id=checkpoint_save_id)

nanochat saves less, but saves the thing people forget: where the dataloader was.

nanochat · what a checkpoint holds
checkpoint = {
    "model": orig_model.state_dict(),
    "optimizer": optimizer.state_dict(),
    "dataloader": dataloader_state_dict,            # {"pq_idx": ..., "rg_idx": ..., "epoch": ...}
    "loop": {"step": step, "min_val_bpb": min_val_bpb, "smooth_train_loss": smooth_train_loss,
             "total_training_time": total_training_time},
}

MFU is arithmetic. Six FLOPs per parameter per token for the matmuls (forward plus backward), plus the attention term, over the peak the vendor publishes for dense bf16.

model flops utilization
# nanochat GPT.estimate_flops, generalized for sliding windows
num_flops_per_token = 6 * self.num_matmul_params()                    # embeddings excluded
for layer in layers:
    num_flops_per_token += 12 * n_head * head_dim * min(window, T)      # attention: q·k and softmax·v
mfu = 100 * (num_flops_per_token * tokens_per_sec) / (peak_flops * world_size)
# peak_flops: H100 SXM 989e12 · H100 PCIe 756e12 · A100 312e12   (dense bf16, no sparsity)

And CORE, in essence.

centered accuracy
def core_score(results):                                   # nanochat/core_eval.py, the idea
    centered = []
    for task, acc in results.items():
        baseline = RANDOM_BASELINE[task]                    # e.g. 0.25 for a 4-way multiple choice
        centered.append((acc - baseline) / (1 - baseline))  # 0 = random, 1 = perfect
    return sum(centered) / len(centered)                    # mean over 22 tasks
03the scaffoldingwhat production adds, and where it breaks+

What a month on a cluster is like

  • Failures are constant. Llama 3's paper counted 466 job interruptions over 54 days of the 405B run, 419 of them unexpected, most from GPU faults. The training loop is a small part of the job; the automation that detects a dead node, restarts from the last checkpoint and gets back to full speed is the rest.
  • Checkpoint cost is a budget. Pick the interval so that the expected lost work (half an interval times the failure rate) balances the save overhead. Async staging makes a save nearly free in wall-clock; storage bandwidth per rank sets the floor.
  • A seed checkpoint makes runs reproducible. torchtitan writes the initialized weights once so that a run can be restarted from identical numbers on any GPU count.
  • Resume is approximate unless you make it exact. nanochat advances one row group past the saved position to avoid repeating data; OLMo's index makes the position exact. RNG state is rarely saved, so dropout-free architectures help here too.
  • Metrics can lie. DCLM re-based CORE from v1 to v2 in September 2025, so scores across that line are not comparable. Held-out loss depends on the tokenizer, hence bits per byte. Benchmark contamination inflates everything.
  • Souping is normal. OLMo 3 32B averages two midtraining runs and its last three long-context checkpoints. The published "base" is often an average, not a single step.
the health check I would build firstGradient norm on a log scale, loss with and without smoothing, tokens per second, and one downstream number. If the first two look fine and the third dropped, a node is sick. If the fourth stalls while loss keeps falling, look at the data mix.
10 · step 8 · what comes out

A model that only completes.

After all of that, the artifact is a base model: a next-token predictor with no idea what a turn is. Ask it a question and it may answer, or it may write three more questions in the same style, because that is what the internet looks like. It is not a chatbot. It is the raw material for one.

probing a base modelnanochat · how the base model is sampled after training
prompts = [                                  # scripts/base_eval.py --eval sample
    "The capital of France is",
    "The chemical symbol of gold is",
    "If yesterday was Friday, then tomorrow will be",
    "The opposite of hot is",
    "The planets of the solar system are:",
    "My favorite color is",
    "If 5*x + 3 = 13, then x is",
]
# each prompt: prepend <|bos|>, decode 16 tokens greedily at temperature 0
No chat template, no instruction. You hand it the start of a sentence and see whether the continuation is right. This is the only interface a base model has.
01the ideawhat it does and why. read this first+

What a base model does is completion. Given "The capital of France is", a good one writes " Paris." and then, because it is still predicting the most likely continuation, keeps going: " The capital of Germany is Berlin." Given a question formatted like a forum post, it writes a forum answer, then another post. Nothing in it knows when to stop, except the document separator it learned to predict at the end of documents.

The trick people used before post-training existed was few-shot prompting: show the model three examples of the format you want and it completes the fourth. That is still how base models are evaluated. HellaSwag, MMLU and the CORE tasks are all rendered as a few examples plus a candidate continuation, scored by the probability the model assigns to the right one. A base model can score well on knowledge benchmarks and still be useless in a chat.

nanochat's README describes the speedrun model as "a bit like talking to a kindergartener": it knows the sky is blue and will invent the reason. That is what 1.65 hours of H100 time buys. The frontier base models are the same object, just trained a few million times longer.

02the codea readable version of what the real repos do+

Sampling from a base model is the inference loop with nothing around it: one forward pass per token, take the argmax or sample, append, repeat.

greedy completion
ids = [tokenizer.get_bos_token_id()] + tokenizer.encode("The capital of France is")
for _ in range(16):
    logits = model(torch.tensor([ids]))[0, -1]                  # only the last position matters
    next_id = int(logits.argmax())                               # temperature 0: greedy
    ids.append(next_id)
    if next_id == tokenizer.get_bos_token_id():                  # the document separator: "this text is over"
        break
print(tokenizer.decode(ids[1:]))

The evaluation harness renders benchmarks the same way. A multiple-choice question becomes several candidate strings, and the score is which candidate the model finds most probable.

log-likelihood scoring · how base models are benchmarked
context = few_shot_examples + question                          # 5 solved examples, then the real one
scores = []
for choice in choices:                                          # "A) ...", "B) ...", ...
    ids = tokenizer.encode(context + choice)
    logp = model.log_prob_of_continuation(ids, start=len(tokenizer.encode(context)))
    scores.append(logp)                                         # sum of log-probs of the choice tokens
correct = scores.index(max(scores)) == answer_idx
03the scaffoldingwhat production adds, and where it breaks+

The handoff

Which checkpoint becomes "the base model" is a choice. Usually it is the post-anneal one, after the learning rate has decayed to zero on the high-quality mix, and sometimes it is an average of several. OLMo 3 publishes the whole trail: Base, then SFT, then DPO, then RLVR, for both an Instruct track and a Think track, with every intermediate checkpoint downloadable. Kimi K2's report recommends fine-tuning a Muon-pretrained checkpoint with Muon. Kimi K3 does quantization-aware training and tunes its multi-token-prediction draft head during post-training so the served model matches the trained numerics.

The base checkpoint goes into supervised fine-tuning first, where it learns the chat format and the special tokens the tokenizer reserved back in step 2. That is the next page.

Base
Completes text. Evaluated by few-shot log-likelihood. What this page produces.
Instruct
Base + SFT + preference tuning. Follows instructions, uses tools, knows when to stop.
Thinking
Instruct + long chain-of-thought SFT + reinforcement learning with verifiable rewards.
a proportion worth rememberingDeepSeek-V3's report puts post-training at about 5,000 of its 2.79M GPU-hours. Nearly everything the model knows, and nearly everything it cost, is decided on this page. What it is like to use is decided on the next one.
11 · closing

Eight lines, and everything around them.

The loop has not changed since GPT-2. What changed is the data (filtered by classifiers, rephrased by models), the block (RMSNorm, RoPE, GQA, QK-norm, experts), the optimizer (Muon), the precision (FP8), and the scaffolding that keeps a month-long run alive. None of it is magic. All of it is in public repos now, and the smallest of them fits in an afternoon.

If you read one thing after this page, read nanochat's dev/LOG.md. It is the diary of which ideas survived contact with a real run, and it is more honest than any paper.

Next: post-training, step by step (coming). Related: pre-training vs post-training, the overview; GPT-2 to Kimi K3, the architecture lineage.

Numbers for DeepSeek V4, GLM-5, Nemotron 3 and Gemma 4 come from their model cards and announcements rather than reports I read in full. The Llama 3 interruption count is from the Llama 3 paper. Everything quoted from a repo was read from a clone on September 17, 2026.