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

Pre-training, step by step.

The code that labs use for real pre-training runs is public now. It is also very hard to read: ten thousand lines of code for splitting work across GPUs, wrapped around a training loop that fits on a napkin. I wrote this page to learn it. It breaks pre-training into eight steps, and each step opens into three layers: the idea, the code, and the scaffolding. At every step I also show real data, exactly as it is stored 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
The output is a base model. All it can do is continue text. The next page, post-training, is where it learns to answer you.

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 comes down to the same eight lines of code. Take a batch of token ids, predict each next token, measure how wrong the predictions were with cross-entropy, backpropagate, and step the optimizer. Repeat until the compute budget runs out.

The loop is easy. The hard part is running it over the whole readable internet, on thousands of GPUs, for weeks, without one failed machine ruining the run. Almost all the code 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))
01the ideawhat it does and why. read this first+

What the loss is

Line 4 of the loop produces one number, the loss. The rest of this page is about pushing that number down: faster, on more data, and without the run crashing. Here is what the number measures, one line at a time.

Line 3 gives every token a score. At each position, the model outputs one number per token in its vocabulary. nanochat’s vocabulary has 32,768 tokens, and Llama 3’s has 128,256. These numbers are called logits. They can be any size, positive or negative. Softmax turns them into probabilities in two steps. It raises e to each score, so every value is positive. Then it divides by the total, so the values add up to 1.

FROM SCORES TO PROBABILITIES: SOFTMAX"The"" capital"" of"" France"" is"?the text so farnext tokenscore (logit)e^scorep = e^score / 189.72" Paris"5.0148.410.7823" a"3.020.090.1059" the"2.512.180.0642" located"2.07.390.0389" not"0.51.650.0087total189.721.0000, alwaysthe text continues with " Paris", so p = 0.7823 and the penalty is −log 0.7823 = 0.2455A bigger score always gives a bigger probability, but not in proportion. A score of 5.0 against 3.0 becomes 148 against 20, sothe top guess takes most of the probability. This toy vocabulary has five tokens. nanochat’s has 32,768, and the arithmetic isthe same.

Line 4 turns one probability into a penalty. The data says which token actually came next. Call the probability the model gave that token p. The penalty is −log p. If p = 1, the penalty is 0. At p = 0.5 it is 0.69, and at p = 0.1 it is 2.30. As p gets close to 0, the penalty grows without limit, so the most expensive mistake is to be confident and wrong. F.cross_entropy computes the same number without forming p at all. It takes the log of the total and subtracts the right token’s score: log 189.72 − 5.0 = 0.2455. This form is also safer on a computer, because e raised to a large score can overflow.

A PROBABILITY BECOMES A PENALTY: −LOG Ppenalty1234000.250.500.751p, the probability it gave the right token↑ keeps going: no ceiling"capital" "2" "The": no model can know these" Paris"ppenalty1.000.0000sure, and right0.900.10540.500.6931a coin flip0.102.3026one in ten0.014.6052→ 0→ ∞sure, and wrongevery token counts here
The dots are the 16 tokens of one sentence: “The capital of France is Paris, a city of 2.1 million people.” The probabilities are picked for the example, and “Paris” uses the one from the softmax above. Drag p to see the penalty for any probability.

The loss is the average penalty. Each training step averages the penalties over every token in its batch. Pre-training counts every token. The SFT step in post-training adds a mask so that only the answer counts. For the sentence above, the average is 1.3340. nanochat measures its validation loss the same way, over 80 × 524,288 tokens, which is about 42 million penalties averaged into one number.

A few tokens make up most of the loss. In that sentence, the three hardest tokens (“The”, “capital” and the “2” of 2.1 million) make up half of the total. The eight tokens the model gave 0.5 or more make up 15%. Nothing earlier in the text says the document will be about a capital, or that the population starts with 2, so no model can predict those tokens well. That is why the loss never reaches 0. Text has real uncertainty in it, and a model can only get close to that limit. The Chinchilla paper’s fit puts the limit at about 1.69 for its data.

reading itwhat a loss number means
loss    e^(-loss)
4.00    0.0183    about 1 in 55
3.28    0.0376    about 1 in 27    the modded-nanogpt speedrun target
2.00    0.1353    about 1 in 7
1.69    0.1845    about 1 in 5     the limit in the Chinchilla fit
1.00    0.3679    about 1 in 3
To read a loss, undo the log. e to the minus loss is the probability the model gives the right token on a typical token (strictly, the geometric mean). When a loss curve drops from 4 to 3, the typical right token went from about 1 in 55 to about 1 in 20.

Bits per byte is the same number in other units. The loss uses the natural log, so its unit is nats per token. Divide by ln 2 = 0.6931 to get bits per token. Then divide by the average number of bytes in a token to get bits per byte. If a token covers about 4 bytes, the speedrun’s 3.28 becomes 3.28 ÷ 0.6931 ÷ 4 ≈ 1.18 bits per byte. The tokenizer step explains why runs report it: it does not depend on the tokenizer.

The scale is what turns each of those lines into its own engineering project. Here are some published runs.

~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 use a small reading list throughout the page. Each repo is the best place to learn one thing. If you only read one, read nanochat. It holds the whole pipeline in about six thousand lines, and every step on this page has a matching file there.

Open it to see the whole pipeline, end to end, in code small enough to read. It trains on one 8×H100 node with the current techniques: Muon, QK-norm, sliding windows, FP8, best-fit packing, CORE eval. ~6,000 lines. Start with scripts/base_train.py and dev/LOG.md, a diary of which experiments helped and which did not.
Open it to see which tricks make training fast. It is the speedrun, a public race to train a small GPT-2 to a target loss in the least time. train_gpt.py is one 2,400-line file with every trick that has held up on the leaderboard, and it is the standard reference code for Muon. The record was 45 min in 2024. It is 1.23 min now.
Open it to see how a large multi-GPU training run is built, in plain PyTorch. It has every kind of parallelism (FSDP2, TP, PP, CP, EP), float8 and MXFP8 training, and DCP checkpoints. It includes model definitions for Llama 3, DeepSeek V3 and V4, Kimi K3 and Qwen3.
Open it to see every setting of one of the leading fully open training runs. It has the OLMo 3 7B and 32B scripts for pretraining, midtraining and long context, with the list of data files in each mix. Read them next to the OLMo 3 report.
Open it to read a frontier MoE model in one short file. inference/model.py is 808 lines, and it is the clearest code I know for MLA, a sigmoid-gated MoE with bias-based load balancing, and block-wise FP8. The report is the best write-up of how to train in FP8.
Open it to see how raw web pages get cleaned. It is the FineWeb pipeline as code you can run, and examples/fineweb.py has every filter and threshold that produced FineWeb and FineWeb-Edu.
Open it for a second data recipe and for the CORE benchmark. It has the DCLM-Baseline recipe (resiliparse extraction, Bloom-filter dedup, a fastText quality classifier) and the CORE metric that nanochat uses to define "GPT-2 grade".
Open it to learn how training is split across many GPUs. The Playbook is a textbook on parallelism, backed by 4,000 scaling experiments. picotron is its code companion, with DP, TP, PP and CP each written in under 300 lines.
Open it when you want the original. Tensor and pipeline parallelism come from here, and most labs' internal training code descends from it. At 275,000 lines, it is a reference to look things up in. Start somewhere smaller.
02 · step 1 · data

Turn the web into a corpus.

Pre-training data starts as Common Crawl, a nonprofit's monthly dump of a few billion web pages. It ends as a few trillion tokens of text that is mostly not spam. Every step in between is filtering, and the filtering is where open datasets differ from each other.

Here is one row of FineWeb, the example row from its dataset card, exactly as published. It is text plus a note of where the text came from. There are no labels and no questions.

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
}
FineWeb has about 25 billion rows like this one, which is a blog post about a baseball player. id, url and date are copied from the WARC headers. dump names the monthly crawl. language_score is fastText's confidence in the language label. token_count is counted with the GPT-2 tokenizer. Only the text is used for training; the other fields record where it came from.
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 together they throw away most of what comes in. 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 extracting the text yourself from the WARC files works better than using the WET files, which hold text Common Crawl already extracted. The text comes out cleaner, and models trained on it do better.

Next comes language identification. A fastText classifier scores each page, and FineWeb keeps pages that score above 0.65 for English. Then come the quality rules inherited from Gopher and C4. They drop documents with too many repeated lines, too few words, no sentence-ending punctuation, too many symbols, the word "javascript", or "lorem ipsum". FineWeb added three rules of its own after testing each one in small training runs. They drop documents with too many short lines, too many lines without punctuation, or too much duplicated text.

Then deduplication. FineWeb uses MinHash over 5-grams, with 112 hash functions in 14 buckets of 8. That setup catches pairs of documents that are about 75 percent similar. Here FineWeb found something surprising. Deduplicating across all snapshots at once made models worse than deduplicating within each snapshot. Global dedup mostly removed good text that is copied all over the web, and kept junk that appears only once. Finally, personal data (PII): emails and IP addresses are replaced with placeholders.

Then the classifier

Hand-written rules get you to FineWeb. The next big jump was a learned quality classifier. For FineWeb-Edu, Llama-3-70B-Instruct scored about 460K pages on a 0 to 5 scale of "educational value". Those labels trained a small regression head on top of an embedding model. That small model then scored all 15T tokens, and only pages scoring 3 or above were kept. The 1.3T tokens that remained 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. DCLM kept the top 10 percent. Every serious dataset since uses some version of this: a big model labels a sample, then a cheap model scores everything.

the shape of the tradeoffStricter filtering raises the quality of each token but leaves fewer tokens. Labs want both, so they run several classifiers with different thresholds and keep the resulting buckets separate. Later, the mixing step decides how many epochs (full passes) each bucket gets. Nemotron-CC went further. Instead of dropping low-scoring pages, it had an LLM rewrite them into cleaner text. That 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 takes documents in and passes documents on. The real FineWeb file is longer, but this is its 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 the crawl's segments, and 8,000 CPU tasks run in parallel on a Slurm cluster. Nothing here needs a GPU.
Trafilatura(...)
The extraction step. favour_precision means: when unsure whether some text is part of the main content, drop it. Test runs showed this beat the crawl's own WET text.
LanguageFilter(..., 0.65)
fastText's 176-language model. The threshold works as a quality knob. Raise it and you lose short pages and pages that mix languages. Lower it and you keep more noise.
Gopher*Filter
DeepMind's rules from 2021, written for its Gopher model and still in every pipeline. The repetition filter drops a document if 30 percent of its lines are duplicates. The quality filter checks word counts, mean word length, the share of symbols, and whether common words like "the" and "of" appear.
FineWebQualityFilter()
The three rules FineWeb added after testing them on 28B-token training runs. Each one on its own gave a measurable gain on the combined benchmark score.

Deduplication is a separate pipeline with four stages, because MinHash needs to see all the documents at once. First compute a signature for each document. Then sort the signatures into buckets. Then group near-duplicates into clusters. Finally, keep one document per cluster and remove the rest.

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 here is the classifier stage, cut down to its core. Score everything with a cheap model and keep the top scorers. This is FineWeb-Edu's version: 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
}
This row comes from the same crawl file as the FineWeb row above. The classifier added score and int_score, and the filter kept this row 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 the signals computed from them (such as toxicity scores) are stored in separate files, one row per document, in the same order.
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 stores the text and the tagger outputs apart. Adding a new filter means writing a new attributes file, and the corpus itself stays untouched. The mixer then joins the two files by row.

What breaks at scale

  • This is CPU work. The FineWeb recipe runs as thousands of Slurm CPU tasks reading from S3. The crawl adds up to petabytes across snapshots, so you stream it through and never store it all at once.
  • Dedup is where memory runs out. MinHash signatures for 25B documents have to be shuffled across all machines so that similar ones end up together. That is why dedup takes four passes instead of one function call. DCLM's Bloom-filter approach (BFF, written in Rust, checking 13-gram overlap at the paragraph and document level) gives up exactness to do it in a single streaming pass.
  • Decontamination. Benchmark questions leak onto the web. Every lab now removes text that shares 13-grams with its eval sets before training, and this still misses some.
  • The classifier shapes what the model learns. FineWeb-Edu's labeling prompt asks for "educational" text, which tilts the corpus toward textbooks and away from fiction and dialogue. That choice shows up in the finished model, and it is why labs keep several classifiers and mix their outputs. Text extraction is the same kind of choice: trafilatura (FineWeb) and resiliparse (DCLM) produce different corpora from the same crawl.
  • Licensing and opt-out. Some pipelines honor robots.txt and publisher opt-outs at crawl time, and others do not. Code datasets filter by license. Here the limits are set by lawyers rather than engineers.
what changed since 2023Three things changed. Text is now extracted from WARC instead of WET. Model-based quality classifiers run on top of the hand-written rules. And models now generate or rewrite part of the data: Nemotron-CC's 1.9T rephrased tokens, Kimi K2's pipelines that rephrase knowledge and math text, and Qwen3's PDF extraction with a vision model plus synthetic math and code from its specialist models. The 2021 Gopher rules still run underneath all of it.
03 · step 2 · tokenizer

Decide what a token is.

The model never sees text. It sees integers. The tokenizer turns text into those integers and back. It is fixed before training starts and never changes after that, so the model is stuck with every quirk it has.

Here is Llama 3's tokenizer, loaded from the file in the llama-models repo, applied to one short piece of text. 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]
A common word is one token, with the space before it attached (" generates"). A rare word takes three tokens. Digits are split into groups of up to three, so "2024" becomes " ", "202", "4". Code punctuation gets its own pieces. All of this comes from two things: the pre-tokenizer regex and 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.

Before BPE runs, the pre-tokenizer regex splits the text into chunks, and a merge can never cross from one chunk into another. A chunk is a word with its leading space, a run of up to three digits, a run of punctuation, or whitespace. That regex is why " generates" is one token and why "3,141" is three. GPT-4 introduced the three-digit rule so that all numbers are split the same way, and Llama 3 copied it almost verbatim.

Vocabulary size

The vocab size trades sequence length against the size of the embedding table. A bigger vocabulary means fewer tokens per document, which makes attention cheaper and fits more text into the context. But it also means a bigger embedding table and output head, and more rare tokens that each get less training.

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 tokenizer also fixes the special tokens. A base model needs only one, the document separator. But the chat format taught in post-training needs more (markers for whose turn it is, brackets around tool calls), 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 starts.

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

Here are the regexes, verbatim. Each one describes what counts as a chunk, and a merge can never cross a chunk boundary.

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 a tokenizer is a short loop that any of us could write. This is the algorithm from Karpathy's minbpe. The Rust version in nanochat does the same thing with faster 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 same idea at full size. It caps the total training text and the length of each document, trains in Rust, then hands 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 its results can still be compared with a run that uses a 128K vocabulary.
tiktoken.Encoding(...)
The learned merges become mergeable_ranks. tiktoken encodes batches fast enough that the data can be tokenized on the fly during training.
SPECIAL_TOKENS
Reserved now, used in post-training. <|bos|> is the only one pre-training uses: 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, because the GPU's tensor cores multiply matrices fastest when the sizes divide evenly into their tiles. nanochat uses pad_vocab_size_to=64; OLMo pads to 128. The model never outputs the extra rows.
  • Measure in bits per byte, not loss per token. Cross-entropy per token (the loss, explained under the map) depends on the tokenizer, since bigger tokens mean fewer but harder predictions. Divide the total loss by the number of bytes the tokens cover, and runs with different vocabularies become comparable. nanochat reports val_bpb for this reason.
  • How to split digits. GPT-4 and Llama 3 split numbers into groups of three digits, nanochat into groups of two, Qwen into single digits. Each is a guess about what will help the model do arithmetic. Nobody agrees yet, and there are only experiments to go on.
  • Byte-level vs byte fallback. Llama 2 used SentencePiece with byte_fallback: its tokens are built from characters, and it falls back to raw bytes only for characters it does not know. Llama 3 switched to tiktoken's byte-level BPE, and so did nearly everyone else.
  • Tokens per word in other languages. 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 are sized to bring that number down. Apertus, trained on 1,800 languages, makes covering all of them well a main design goal of its tokenizer.
  • The tokenizer depends on its training text. nanochat trains it on the first 2B characters of the same corpus it will pre-train on. Train it on the wrong mix of text, and code or math will split into poor tokens for the life of the model.
a thing that surprised meThe tokenizer locks in a lot: how numbers are split for arithmetic, how whitespace in code is handled, and how many tokens other languages cost. And it is built in an afternoon on a CPU, before a single GPU is turned on.
04 · step 3 · pack & mix

Pack the documents into fixed-length rows.

The GPU works on a rectangle of data: B rows of exactly T tokens each. Documents come in every length. A tweet is 20 tokens and a legal filing is 40,000. So documents are joined into one long stream with a separator token between them, and the stream is cut into rows. That raises two questions. Should attention in a row 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 ROWSAAAAA⏎BBBBBBBBB⏎row 0CCC⏎DDDDDDD⏎EEEErow 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, which belongs to document B, can attend to document A, which is unrelated 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 used in nanoGPT and in torchtitan's simplest loader, is "concatenate with EOS, cut every T tokens". It wastes no space, but it cuts documents mid-sentence and lets attention reach from one document into another. Two improvements are now standard.

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

Document masking stops attention from crossing between documents. The attention mask becomes block-diagonal within the row, so a token never looks back past the start of its own document. This needs an attention kernel that handles variable-length sequences: FlashAttention's varlen interface with cu_seqlens, or FlexAttention with a document mask. OLMo-core passes doc_lens (the document lengths) through the whole stack for this.

Mixing, and the three stages

The corpus is made of dozens of sources, each with a weight. The mixture says how many tokens come from each source. A source that is scarce and valuable can be repeated for more than one epoch (OLMo-core's max_repetition_ratio sets the limit). 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.

The weights also change over the run. In 2025 and 2026 the norm is three stages. First comes a long general stage at short context. Second, a shorter "midtraining" stage gives more weight to math, code and higher-quality text while the learning rate decays to zero. Third, a brief long-context stage trains 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

Here is document masking done two ways. modded-nanogpt finds the BOS positions in the stream and passes those document boundaries to FlashAttention-3. torchtitan builds the same mask 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)

The mix is set in configuration files rather than written as code. OLMo-core's source mixture is a list of sources with target ratios. The official mix manifest is 951 lines long, with one file path per line.

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 array, memory-mapped from disk. 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. It reads raw parquet shards of text and tokenizes them on the fly with four threads. The loader's position is saved as (pq_idx, rg_idx, epoch).

Where it gets hard

  • Cropping vs padding. nanochat accepts losing 35 percent of tokens to cropping because web data is plentiful. With scarce data (a specialist domain, a small language) you keep the older loader and accept that some documents get cut in the middle.
  • A simple mask is slow. A dense (T×T) mask passed to standard attention costs as much as full attention, plus the cost of the mask. The varlen and flex kernels skip the masked-out blocks entirely, which is why they exist.
  • 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, and stretch further at inference with YaRN. The cost at training time is context parallelism: OLMo 3 splits 65K-token sequences across 8 GPUs.
  • The published base model is the checkpoint after the anneal. Midtraining is where the learning rate reaches zero and math and code get more weight. So the "base model" you download is not the model from the long constant-learning-rate stage. Labs also use annealing runs to compare data: branch from a checkpoint in the stable stage, anneal on each candidate mix, and compare the results.
  • Epochs vs rephrasing. Kimi K2 reuses its most valuable text by having an LLM rephrase it instead of repeating it word for word. The evidence is that repeats stop helping after a couple of epochs.
  • Resume must be deterministic. The loader's position is saved in the checkpoint along with the model, so a restart continues from the same document. nanochat's resume is approximate (it skips ahead one row group to avoid repeating data). OLMo's is exact, because its index records where every document is.
05 · step 4 · the model

Define the decoder.

The architecture settled around 2023 and has barely changed since. It is a stack of identical blocks. Each block runs attention and then an MLP, and each of those adds its output back onto its input (a residual connection). The changes since GPT-2 are a list of small swaps, each made for training stability or to save memory. The big new idea, mixture of experts, is in the next step, and it replaces 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 from top to bottom. RMSNorm replaced LayerNorm. It only divides each vector by its root-mean-square, with no mean subtraction and no bias, and nanochat drops the learnable gain too. It is cheaper and works as well.

RoPE replaced learned position embeddings. Instead of adding a position vector to the input, it rotates the query and key vectors by an angle that grows with position. That way the dot product between two tokens depends only on how far apart they are. RoPE can generalize past the training length, which learned positions never could. It is also why extending the context comes down to changing one number, the base frequency.

GQA (grouped-query attention) shrinks the KV cache, the keys and values kept for past tokens during generation. In Llama 3 8B, 32 query heads share 8 key-value heads; in Qwen3-32B, 64 share 8. The cache memory at inference drops fourfold or more, for a tiny cost in quality. QK-norm applies RMSNorm to queries and keys before their dot product. That keeps attention logits from growing too large, and it is the most common fix for training instability in the 2025 recipes (OLMo 2 and 3, Qwen3, nanochat).

SwiGLU replaced the GELU MLP. It uses three matrices instead of two: a gate that is multiplied into an up-projection, then a down-projection. The hidden width drops from 4× to 8/3× the model width, so the parameter count stays the same. The speedrun projects use ReLU² instead, which needs one matrix fewer and works as well at their scale. Biases are gone everywhere, and so is dropout. The embedding table and the output head are usually separate matrices (untied).

Two newer additions. First, sliding-window layers. In each group of four layers, three attend only to the last 768 or 4,096 tokens and the 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 still see the whole context. Second, a logit softcap: 15 · tanh(logits / 15) on the output, from Gemma 2. It keeps every final logit between −15 and 15, so none can grow without limit.

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

nanochat's gpt.py shows the 2026 dense block in 555 lines. Here is the attention forward pass, lightly trimmed. Notice what is missing: 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, applied after RoPE. It keeps the dot products bounded so the softmax never saturates (puts nearly all its weight on one token). Saturation is the failure behind most loss spikes.
window_size=window_size
The sliding window. (768, 0) on "S" layers, (T, 0) on "L" layers, repeated 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, with a gate per head. A speedrun trick that still helped at 1.4B in nanochat's experiments.
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.

Here is the SwiGLU version from the Llama reference code, 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 way to shrink the KV cache. It compresses each token's keys and values into one 512-dimensional vector, stores only that, and expands it again when needed. A separate 64-dimensional "rope key" carries the position, because the RoPE rotation cannot pass through the compression. Queries get their own low-rank path (rank 1,536). In absorb mode, the expansion matrix is folded into the attention-score computation, so inference never builds the full keys at all. DeepSeek-V3 and Kimi K2 both use MLA. Kimi K3 adds a gate to it and interleaves it with linear-attention layers (KDA): 69 linear layers to 24 gated-MLA layers.

MTP, multi-token prediction, adds one small extra block that predicts the token after next from the main model's hidden state. It has its own cross-entropy loss, weighted 0.3 for the first 10T tokens and 0.1 after that. It gives the model more to learn from each position, and at inference it can serve as the draft model for speculative decoding. torchtitan's models/deepseek_v3/MTP.md documents the block: enorm, hnorm, eh_proj, one transformer block, and a shared output head.

DeepSeek V4 (April 2026) cuts attention memory further: 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. They all point the same way: in the newest models, full attention over the whole sequence is the exception.

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 warns 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 help at that scale. That log is the most candid record I know of which tricks from 124M-parameter models still work at larger sizes.

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.

In a dense transformer the MLP holds two thirds of the parameters, and every token runs through all of them. A mixture of experts keeps many MLPs and sends each token through only a few. DeepSeek-V3 has 671 billion parameters and uses 37 billion of them for each token. Knowledge is stored across all the parameters, but each token only pays the compute for a small 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 each score into the range 0 to 1 with a sigmoid, and picks the top-k (8). Those experts process the token, and their outputs are added up, each weighted by its score (rescaled so the weights sum to 1). One shared expert processes every token, so common knowledge does not have to be copied into each routed expert. Kimi K2 keeps the same design with 384 experts. Kimi K3 has 896, picks the top 16, and has two shared experts. Qwen3-MoE has 128, top-8, and no shared expert.

How the router learns

MoE does not change what the model is trained to do. The input is still tokens, the output is still a probability for every possible next token, and the loss is still −log p of the token that actually came next. MoE only changes what happens inside each block. The router is trained by that same loss, from scratch, like every other weight in the model. Nobody tells it which expert should handle what.

ONE TOKEN, ONE TRAINING STEP"def"router8 scores, keep 2E2 × 0.55 + E5 × 0.45plus the shared expertmore layers−log p(next token)the ordinary lossBACKWARD: WHERE THE GRADIENT FROM THAT LOSS GOESE2, E5the two experts that ranbetter at tokens like thisrouterits scores for E2 and E5, through 0.55 and 0.45sends tokens like this there more, or lessthe other 6never ran for this tokennothing: no gradient at allPicking the top k is a choice, not a smooth function, so the gradient only reaches what was chosen. An expert that is rarelypicked rarely learns, so it gets picked even less and falls further behind. Load balancing exists to break that loop.

The router learns through the mixing weights. If the prediction would have been better with a bit more of expert 2’s output, the gradient raises expert 2’s score for tokens that look like this one. So nothing assigns topics to experts. They specialize in whatever lowers the loss. When Mistral looked at how Mixtral routes tokens, they found no clear topic experts, only patterns at the level of tokens and syntax.

The router is trained here, once, and post-training inherits it. It causes a problem there too: the engine that generates answers and the trainer can send the same token to different experts, which the GSPO fix in post-training works around.

The main way this goes wrong is load imbalance. Nothing stops the router from sending every token to the same three experts. Those three then act as a small dense model while the other 253 sit idle. The classic fix is an extra loss term that penalizes uneven use of experts, but that term pulls against the main language-modeling loss. DeepSeek-V3 avoids the extra loss. Instead it adds a per-expert bias to the scores, used only to choose the experts. After each step, an overloaded expert's bias goes down by a small constant and an underloaded expert's bias goes up. The weights used to mix the expert outputs never include 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.

Compare the total and active numbers. The compute per token grows with active parameters, so in compute V3 trains and serves like a 37B dense model. Memory grows with total parameters, so the cluster has to hold the weights of a 671B model. MoE is a bet on that trade: parameters are a cheap place to store knowledge, and compute is the scarce resource. Kimi K2's report fits a "sparsity scaling law" to pick 384/8 = 48 as the ratio of total experts to active experts.

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

DeepSeek-V3's inference code is the clearest reference I have found. Here is the gate, verbatim except for the comments. It has every piece: sigmoid scores, the balancing bias, node-limited routing, top-k, and 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. It is simple and correct, and it is 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 lives in the training loop, not in the model, and it comes down to one line. γ 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 code replaces the Python loop with a sort and a grouped GEMM, one matrix multiply over many groups of different sizes. Sort the tokens by expert, run one batched matmul over all the groups, then put the tokens back in their original order. 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 of GPUs, and each token is sent to the GPUs that hold its experts. That takes one all-to-all exchange before the experts (dispatch) and another after (combine). DeepSeek-V3 trained with 64-way EP across 8 nodes. Its node-limited routing exists so that a token's 8 experts sit on at most 4 nodes, which limits the traffic between nodes. DeepEP is DeepSeek's open kernel for the all-to-all. Kimi K2 used 16-way EP with 16-way pipeline parallelism.

EP and FSDP do not fit together neatly. The experts are split one way (by expert) and everything else another way (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 get chosen early and keep getting chosen, and the rest never learn. The Switch Transformer loss is α · N · Σᵢ fᵢ · Pᵢ (the fraction of tokens routed to expert i, times the mean router probability for i). DeepSeek-V3 keeps a tiny per-sequence version at α = 0.0001 alongside the bias. Qwen3 measures balance over the global batch instead of each micro-batch, so an expert that specializes in one domain is not penalized.
  • Router in fp32. The scores are computed in float32 even in a bf16 or FP8 run. Tiny differences in score decide which experts are chosen, and low precision makes those differences noisy. DeepSeek keeps the gating modules out of FP8 entirely.
  • Capacity factor vs dropless. Older MoEs capped how many tokens each expert could take and dropped the overflow. Dropless MoE (MegaBlocks) processes every token, using groups of different sizes, which grouped GEMM makes possible.
  • Upcycling. Copy a dense model's MLP into every expert and continue training. Qwen and MiniCPM did this. It saves starting from scratch, but the experts begin identical and take a while to become different from each other.
  • Fine-tuning fragility. A small post-training dataset barely reaches most experts. Kimi K2's advice is to fine-tune a MoE that was pretrained with Muon using Muon again. How well LoRA works on MoE layers is still unsettled in published work.
  • Serving is a different problem. A model with 37B active parameters still needs all 671B held in memory. Batching many requests together is what makes it economical to serve, and a single user's latency gets no benefit from it.
the trendExperts keep getting smaller and more numerous. Mixtral (2023) had 8 experts of width 14K. V3 (2024) had 256 of width 2K, K2 (2025) had 384, and K3 (2026) has 896. K3 also runs its experts in a compressed latent space, which halves their FLOPs. It is the same compression trick MLA uses for attention.
07 · step 5 · init & optimizer

Pick the numbers that cannot be tuned later.

The choices here are the initialization, the optimizer, the learning-rate schedule, the batch size, and how many tokens to train on. None of them can be changed once the run is a week in, and one wrong choice can cost the whole run. Deciding them is what the small scaling-law experiments are for. This step is also where the one new idea of the last two years lives: the Muon optimizer.

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 learning rate flat and decays it only near the end. Its advantage is practical. You can branch off 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 that cosine 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 projects go further and start the output projections of attention and the MLP at zero, so every block begins by passing its input through unchanged. 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 is about 2,000 steps. Labs differ in the schedule and the batch size, and hardly at all in the optimizer.

Muon

Muon is the exception. It is used for the 2-D weight matrices inside the blocks. It takes the gradient, averaged with momentum, and orthogonalizes it: it replaces the update matrix with the nearest matrix whose singular values are all one. Five Newton-Schulz iterations in bf16 compute this. As a result, every direction in the update gets the same step size, instead of a few dominant directions taking almost all of it. Embeddings, the output head and scalar parameters stay on AdamW. Muon won the speedrun in 2024. Moonshot showed it scales ("Muon is Scalable": 16B parameters on 5.7T tokens, with about half the FLOPs of AdamW for the same loss), and Kimi K2 trained a trillion-parameter model with it. MuonClip is K2's addition. After each step, if any attention head's largest logit is above a threshold τ, that head's query and key weights are scaled down. K2 reports 15.5T tokens without a single loss spike.

The schedule, the batch, the budget

Two kinds of schedule are in use. One is cosine decay to about 10 percent of the peak (Llama 2 and 3, OLMo 3, Kimi K3). The other is WSD: warmup, then stable, then decay (nanochat, an option in OLMo-core, SmolLM3, and 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 its batch from 3,072 to 15,360 sequences over the first 469B tokens. nanochat settled on 1M tokens per step.

Then there is the budget. Chinchilla's rule of 20 tokens per parameter is now treated as a minimum. Labs train far past it, because inference cost depends on the number of parameters and not on how many tokens went into training. A smaller model trained longer is cheaper to serve.

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+

Here is Muon, verbatim from the modded-nanogpt README. The three constants are the coefficients of a quintic polynomial, tuned so the iteration converges quickly 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 takes about 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

Here is the core of MuonClip, from the Kimi K2 report. It runs after the optimizer step, so the forward and backward passes of the current step are unchanged.

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 experiment logs say

  • Weight decay should shrink as the model gets wider. nanochat's log found the best value goes as 1/width² (0.22 at depth 12, 0.08 at depth 20). nanochat also lowers it linearly to zero over the run.
  • Gradient clipping can be dropped. With Muon and QK-norm, nanochat removed 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 changing. nanochat replaced Newton-Schulz with Polar Express coefficients, added row equilibration, rescales each update to a target Frobenius norm, and uses NorMuon's factored second moments and "cautious" weight decay (decay only where the update and the weight have the same 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 projects use a tanh softcap instead. Both fix the same instability.
  • Nobody uses muP in production. muP is a way of scaling a model so that settings tuned on a small version carry over to a big one. Every recipe I verified (Llama 3, DeepSeek-V3, OLMo 3, Kimi K2 and K3, Qwen3, SmolLM3) instead picks its learning rate and batch size by fitting scaling laws to runs of small models. 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. WSD is still more convenient, but the loss advantage people claimed for it may not exist.
08 · step 6 · the cluster

Run the loop on thousands of GPUs.

One H100 holds 80GB. A 405B model in bf16 is 810GB, and gradients and optimizer state triple that. So the model, the batch, and even each sequence get split across GPUs, and the GPUs have to exchange data around every matrix multiply. This step is why the production repos are so hard to read, and why 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 looks like nanochat's. It runs a few forward-backward passes and adds up their gradients (gradient accumulation, so a 1M-token batch fits in memory), then takes one optimizer step. Activations are in bf16, the master weights and optimizer state are in fp32, and torch.compile fuses operations together. The multi-node version has the same shape, with four kinds of splitting added on top.

Data parallel, then FSDP

Plain data parallelism copies the whole model to every GPU, gives each GPU different data, and averages the gradients. It stops working when the model does not fit on one GPU. FSDP (fully sharded data parallel, called ZeRO-3 in DeepSpeed) keeps only a slice of every parameter on each GPU. Right before a layer runs, the GPUs send each other their slices so each has the full weights (an all-gather). Right after, they sum the gradients and each keeps only its own slice (a reduce-scatter). Memory per GPU drops by a factor equal to the number of GPUs. The cost is extra communication, which has to happen while the GPUs compute so it does not slow them down. FSDP2 in PyTorch is one function, fully_shard, called once per block and once on the root model. OLMo 3 trained its 7B on 512 H100s with only this, in a hybrid form (full copies across nodes, sharded within each node).

Tensor parallel

Split each weight matrix across the GPUs of one node. The first matmul of a block is split by columns and the second by rows, so the GPUs only need to combine their results once per sub-block (one all-reduce). Sequence parallel extends the split to the norms and dropout between blocks. Tensor parallel stays inside one node because every layer needs the fast NVLink connection between the GPUs. 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. That is how OLMo 3 trains on 65K-token sequences, split 8 ways. Expert parallel puts different experts on different GPUs and moves tokens to them with an all-to-all; DeepSeek-V3 ran 64-way EP across 8 nodes. torchtitan's rule for combining them: the dense part of the model is sharded over (dp × cp × tp) and the sparse part over (efsdp × ep), and both cover the same set of GPUs.

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 and no DDP wrapper at all: the optimizer does the gradient sync itself.

Precision

The default is bf16 for activations and the matmuls, and fp32 for the master weights, the optimizer moments and the gradient reduction. FP8 goes one step further, but only on the big linear layers. DeepSeek-V3's recipe quantizes to E4M3 with one scale for each 1×128 tile of activations and each 128×128 block of weights. Every 128 elements, it adds the partial sums into an fp32 accumulator. 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+

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

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)

This is all FSDP2 takes. Each call wraps one unit. Before the forward and backward passes, the unit all-gathers its bf16 parameters. Afterward, it reduce-scatters the bf16 gradients into fp32 sharded gradients.

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 two things a cluster needs. It computes a gradient norm that is correct across pipeline stages and expert groups. And if the loss or gradient norm is NaN or infinite, it stops training before the optimizer can apply the bad update.

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 set in configuration. torchtitan dropped its TOML files in 2026, and 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 here is the core of FP8, from DeepSeek-V3's inference kernel. Each tile is scaled so its largest value maps to 448, the largest number E4M3 can hold.

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
Stage 1 shards the optimizer state, stage 2 also shards gradients, and stage 3 also shards parameters. FSDP2 is stage 3. HSDP keeps full copies across nodes and shards within each node.
Activations
They grow with batch × sequence × layers. Activation checkpointing recomputes them during the backward pass instead of storing them. torchtitan's selective mode saves the results of 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, spread across the cluster. Each token uses 8 routed experts plus 1 shared expert, 44M parameters each, in 58 layers. That is about 23B. Add attention and you get 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: activations pass directly from each stage to the next. The "bubble" is the fraction of time stages sit idle: (stages − 1) / micro-batches for 1F1B. That is why runs use many micro-batches, 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. Under causal attention, later tokens do more work than early ones, so torchtitan gives each GPU one chunk from the start of the sequence and one from the end to even out the load.

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 sums norm^p across stages and then takes the p-th root. nanochat once clipped each rank separately instead of globally, and its log records the bug.
  • The router must not be quantized. torchtitan's float8 converter refuses to touch the router gate, and the 671B config leaves lm_head and router.gate out of FP8. Small matmuls also get slower in FP8, because the cost of quantizing outweighs the gain, so there is an automatic filter by shape.
  • FP8 needs compile. Without it, the scale-and-cast steps run as separate kernels and FP8 ends up slower than bf16. Rowwise scaling keeps all communication in high precision; only the matmuls run in e4m3.
  • MFU is what you tune for. MFU is the share of the GPUs' peak speed you are using. 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 percent at scale is good; MoE and long-context runs come in lower.
09 · step 7 · checkpoint & eval

Keep the run going for a month.

A run that lasts weeks on thousands of GPUs will be interrupted. Hardware fails, a network link keeps dropping and reconnecting, a loss spike ruins the weights. The unglamorous half of pre-training is making sure that when this happens you lose an hour instead of a week, and that a handful of numbers can tell you 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, save the model, the optimizer state and the dataloader position to storage. PyTorch's distributed checkpoint (DCP) has every GPU write its own shard in parallel. It can also load the result onto a different number of GPUs, which lets you resume a 512-GPU run on 480 after a node dies. Saving happens in the background. The weights are first copied from GPU memory to host memory (staging), a background process writes them out, and the next optimizer step waits only for the copy to finish. OLMo 3 saved every 1,000 steps. Each stage of its run starts from the URL of the previous stage's checkpoint, and every intermediate checkpoint is published.

Loss spikes

In a loss spike, the loss suddenly jumps and sometimes never comes back down. The cause is almost always attention logits growing until the softmax saturates. There are three defenses, in order of preference. First, prevent spikes in the architecture: QK-norm, reordered norm, z-loss, no weight decay on embeddings. Second, prevent them in the optimizer: MuonClip, which is how Kimi K2 got through 15.5T tokens with zero spikes. Third, skip the bad step: OLMo-core's SkipStepOptimizer skips any update whose loss or gradient norm is more than 6 standard deviations above the mean of the last 128 steps. DeepSeek-V3 reports no spikes it could not recover from and no rollbacks. torchtitan stops on a NaN or infinite loss and makes you restart from a checkpoint.

What to watch

Watch the loss, smoothed. Watch the gradient norm, because it rises before a spike. Watch tokens per second and MFU (model FLOPs utilization): the fraction of the hardware's peak speed you are using, computed from a formula rather than measured. Check held-out loss every few hundred steps, in bits per byte. And run downstream evals every few thousand steps: HellaSwag, ARC, MMLU subsets, a code task and a math task. Each is scored by how likely the model finds the right continuation, because a base model cannot follow instructions.

CORE

nanochat defines "GPT-2 grade" by the DCLM CORE score. CORE has 22 few-shot tasks. Each task is scored as centered accuracy, (accuracy − random baseline) / (1 − random baseline), and the task scores are averaged. GPT-2 1.6B scores 0.2565, and the current speedrun beats it in 1.65 hours. A single number that moves smoothly over a run is more useful than twenty numbers that jump around.

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 simple arithmetic. Count six FLOPs per parameter per token for the matmuls (forward plus backward), add the attention term, multiply by tokens per second, and divide by the peak speed 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 here is the CORE calculation, simplified.

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 happen all the time. 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 work. The rest is the automation that notices a dead node, restarts from the last checkpoint and gets back to full speed.
  • Checkpoints cost time, so budget them. Pick the interval so that the work you expect to lose to failures (half an interval, times the failure rate) balances the time spent saving. Async staging makes a save nearly free in wall-clock time; the storage bandwidth per GPU sets the lower limit.
  • 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. The random number generator state is rarely saved, so having no dropout helps here too.
  • Metrics can mislead. DCLM changed CORE from v1 to v2 in September 2025, so scores from before and after that change cannot be compared. Held-out loss depends on the tokenizer, which is why people use bits per byte. Benchmark questions that leak into the training data inflate every score.
  • Souping (averaging checkpoints) is normal. OLMo 3 32B averages the weights of two midtraining runs, and of its last three long-context checkpoints. The published "base" is often an average of several checkpoints rather than 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 drops, a node is probably faulty. If the fourth stalls while loss keeps falling, look at the data mix.
10 · step 8 · what comes out

A model that only completes text.

After all of that, what you have is a base model: a next-token predictor that has no idea what a turn in a conversation 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 the raw material that post-training turns into a chatbot.

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
There is no chat template and no instruction. You give it the start of a sentence and check whether the continuation is right. That is the only way to use a base model.
01the ideawhat it does and why. read this first+

A base model does completion. Given "The capital of France is", a good one writes " Paris." Then it keeps going, because it is still predicting the most likely continuation: " The capital of Germany is Berlin." Given a question formatted like a forum post, it writes a forum answer, then another post. The only thing that makes it stop is the document separator, which it learned to predict at the end of documents.

Before post-training existed, people used few-shot prompting: show the model three examples of the format you want, and it completes a fourth. That is still how base models are evaluated. HellaSwag, MMLU and the CORE tasks are all written out as a few examples plus candidate continuations, and scored by the probability the model gives 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 make up the reason. That is what 1.65 hours of H100 time buys. The frontier base models are the same kind of model, 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 one from after the anneal, when the learning rate has decayed to zero on the high-quality mix. Sometimes it is an average of several. OLMo 3 publishes every stage: Base, then SFT, then DPO, then RLVR, for both an Instruct track and a Think track, and every intermediate checkpoint can be downloaded. Kimi K2's report recommends fine-tuning a checkpoint pretrained with Muon using Muon again. Kimi K3 does quantization-aware training and tunes its multi-token-prediction draft head during post-training, so the served model computes with the same number formats it was trained with.

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 all of what the model knows, and nearly all of what it cost, comes from the steps on this page. How it behaves when you use it is decided in post-training, on the next page.
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 going. All of it is in public repos now, and you can read the smallest of them in an afternoon.

If you read one thing after this page, read nanochat's dev/LOG.md. It records which ideas still helped in a real run and which did not, and it is more candid than any paper.

Next: post-training, step by step. 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.