cd /news/artificial-intelligence/i-trained-a-113m-parameter-earthquak… Β· home β€Ί topics β€Ί artificial-intelligence β€Ί article
[ARTICLE Β· art-56584] src=github.com β†— pub= topic=artificial-intelligence verified=true sentiment=Β· neutral

I trained a 113M-parameter earthquake LLM from absolute scratch

A developer trained a 113M-parameter language model for earthquake science from scratch using open-access papers, Wikipedia, and FineWeb-Edu on two NVIDIA A30 GPUs. The model achieved a 35% reduction in bits-per-byte on general prose by mixing domain and general text, demonstrating the full pretraining lifecycle from data crawling to inference.

read54 min views1 publishedJul 12, 2026
I trained a 113M-parameter earthquake LLM from absolute scratch
Image: source

Train a small GPT for earthquake science β€” the entire LLM lifecycle, from a blank folder to a talking model, explained block by block.

Crawl β†’ Clean β†’ Tokenize β†’ Model β†’ Train β†’ Infer, on 2Γ— NVIDIA A30 (48 GB).

Six free data sources β†’ crawl β†’ clean/dedup β†’ 16k BPE β†’ 113M GQA+RoPE decoder β†’ 2-GPU DDP training β†’ streaming inference. Each stage is a section below.

nanoGPT-Seis is a teaching repository. It is not trying to be a great earthquake model β€” it is trying to make every stage of pretraining a language model legible: where the data comes from, how it is cleaned and deduplicated, how a tokenizer is built, why the Transformer looks the way it does, how it is trained across two GPUs, and how it is served. Every design decision is explained, and every number (perplexity, VRAM, tokens) is one we actually measured on this hardware.

The corpus mixes earthquake / seismology text (open-access papers via Crossref+Unpaywall, arXiv/EarthArXiv preprints, the "Earthquake Insights" Substack) with general text (Wikipedia + FineWeb-Edu) for plain-language fluency β€” about 24% domain / 76% general. A focused corpus lets a ~100M-param model become genuinely fluent on a single node, so you can see the whole loop close in a day, not a month. (Why the general mix? See Β§1.)

Status:the pretraining lifecycle is complete β€” crawl through inference.

Results at a glanceQuick startStage 1 β€” Data crawlingStage 2 β€” Processing & dedupStage 3 β€” The BPE tokenizerStage 4 β€” The model (RoPE, GQA, …)Stage 5 β€” Training (DDP, VRAM, LR)Stage 6 β€” InferenceRepository layoutScaling-law experiments

value
Corpus 533,248 docs Β· 485.7M words Β· 822.7M training tokens (β‰ˆ2.4:1 general:domain)
Model 113M params β€” decoder-only, GQA + RoPE + RMSNorm + SwiGLU
Hardware 2Γ— NVIDIA A30 (24 GB each), bf16, DDP β€” also runs on a single RTX 3090/4090, or 12–16 GB with a smaller batch (

4096 tokens0.997β€” vs 1.527 for a domain-only base (βˆ’35%)Three findings worth pausing on:

Longer context helped. A controlled A/B (data held fixed) β€” retraining at 4096 vs 1024 dropped perplexity ~11% (9.74 β†’ 10.93 domain-only) for only ~26% more compute per step β€” papers have long-range structure a 1024-token window can't see across.The model uses that context. In a 4096-token window, loss on tokens at positions 2048–4096 is 25% lower than at 0–64 β€” it conditions on thousands of preceding tokens (seeΒ§8).A general-text mix restores fluency. Adding Wikipedia + FineWeb-Edu (~2.4:1 general:domain) cut bits/byte on general prose by 35% vs a paper-only base β€” see the data-mix comparison below.

A paper-only base is fluent in paper-register but repetitive/incoherent in plain prose. Adding ~540M tokens of Wikipedia + FineWeb-Edu (β†’ ~823M train tokens, ~2.4:1 general:domain, ~3.8 epochs β€” within the ~4-epoch repeat budget shown to be near-lossless by Muennighoff et al., 2023) gives v2.

Measured with bits-per-byte (tokenizer-independent, so v1↔v2 is fair; src/compare_models.py

):

v2 is far more fluent on general prose (βˆ’35% bits/byte) and generates coherent non-earthquake text where v1 emits gibberish, at the cost of some domain sharpness (+22%) β€” the classic fluency↔specialization trade-off. This fluent base is the right starting point for SFT; base pretraining alone never yields chat. Domain-only weights are kept as checkpoints/ckpt_v1_domain.pt

.

The pretrained 113M checkpoint is hosted on the Hugging Face Hub: jiazhe868/nanogpt_seis.

conda activate nanogpt_seis
pip install -r requirements.txt

huggingface-cli download jiazhe868/nanogpt_seis \
    checkpoints/ckpt.pt \
    data/tokenized/tokenizer.json \
    data/tokenized/meta.json \
    configs/gpt120m_ctx4k.yaml \
    --local-dir .

python -m src.inference --prompt "The 2011 Tohoku earthquake"
conda activate nanogpt_seis
pip install -r requirements.txt

python -m src.crawl.wikipedia        --max-pages 500                              # earthquake-titled pages
python -m src.crawl.fulltext         --per-journal 3000 --broad 30000 --workers 64
python -m src.crawl.preprints        --arxiv 3000 --eartharxiv 2000
python -m src.crawl.substack         --max 500
python -m src.crawl.general          --wiki-tokens 300000000 --fineweb-tokens 240000000

python -m src.process.build_corpus   --val-frac 0.005      # clean Β· dedup Β· split
python -m src.tokenizer.train_bpe    --vocab-size 16384    # train the tokenizer
python -m src.tokenizer.encode                             # β†’ uint16 shards

torchrun --standalone --nproc_per_node=2 \
    -m src.train --config configs/gpt120m_ctx4k.yaml       # train on 2 GPUs

python -m src.inference --prompt "The 2011 Tohoku earthquake"   # streams live

A PyTorch built for a newer CUDA than your driver will silently report⚠️ Environment gotcha.cuda.is_available() == False

and fall back to CPU. Verify withpython -c "import torch; print(torch.cuda.is_available())"

β€” it must printTrue

. This project usestorch 2.6.0+cu124

to match a CUDA-12.5 driver.

Goal: assemble a large, legal, full-text earthquake corpus from free sources. Code: src/crawl/

.

source module what we pull mechanism
Research papers fulltext.py
OA full-text PDFs of earthquake papers Crossref (DOIs) β†’ Unpaywall (OA PDF) β†’ download β†’ extract
Preprints preprints.py
arXiv + EarthArXiv full text arXiv API + OSF/DOI β†’ PDF
Wikipedia wikipedia.py
pages titled "earthquake" MediaWiki API plaintext extracts
Substack substack.py
"Earthquake Insights" articles archive API + HTML body parse
General text general.py
Wikipedia + FineWeb-Edu (fluency mix) HF datasets streaming to a token budget

Why a general-text mix?A ~113M model trained only on research papers becomes fluent in paper-register but repetitive and incoherent in plain prose, and 240M tokens is far below compute-optimal ([Hoffmann et al., 2022]). So we add ~240M tokens ofWikipedia +[FineWeb-Edu]([Penedo et al., 2024]; quality-filtered educational web) for a ~1:1 general:domain mix β€” a fluent base that the planned SFT stage can then make conversational. (Base pretraining alone never yields chat; that's SFT.)

Every document is normalized to one schema (src/crawl/common.py

):

@dataclass
class Doc:
    source: str          # "fulltext" | "arxiv" | "wikipedia" | ...
    id: str              # stable per-source id (used for dedup)
    title: str
    text: str            # the cleaned body we will tokenize
    url: str = ""
    date: str = ""
    extra: dict = field(default_factory=dict)   # venue, cited_by, full_text, ...

Finding the papers (Crossref). Crossref indexes ~150M scholarly works with a free, generous API. We page through earthquake journal-articles with a deep cursor (no offset limit), filtered by journal ISSN:

params = {"rows": 1000, "cursor": cursor,
          "filter": "type:journal-article,issn:0094-8276",   # e.g. GRL
          "query.bibliographic": "earthquake", "mailto": EMAIL}

Finding the open PDF (Unpaywall). A DOI is not a PDF. Unpaywall (free, ~100k/day) maps a DOI to its legal open-access copies. We try repository (green OA) locations first β€” publisher links are frequently bot-blocked stubs:

prio = 0 if loc.get("host_type") == "repository" else 1

Down in parallel, politely. PDFs come from many hosts, so we use a thread pool but throttle per host β€” different servers download concurrently while any single server stays rate-limited:

class HostThrottle:                 # src/crawl/fulltext.py
    def wait(self, host):
        with self._guard:                                  # get/create this host's lock
            host_lock = self._locks.setdefault(host, threading.Lock())
            self._last.setdefault(host, 0.0)
        with host_lock:                                    # serialize only this host
            delta = self.min - (time.monotonic() - self._last[host])
            if delta > 0: time.sleep(delta)
            self._last[host] = time.monotonic()

Validating every download. Not every "OA PDF" is real β€” many are 5 KB anti-bot landing pages. We accept a download only if it is a real PDF with enough text:

if not pdf_bytes.startswith(b"%PDF"): return None   # HTML / stub
if doc.page_count < 2:                return None    # cover page
if len(text) < min_chars:             return None    # too little extracted

Not wasting the budget. Some journals (Science, Nature) are almost entirely paywalled β€” scanning thousands of their DOIs would burn the Unpaywall budget for zero full text. A low-yield abort gate skips a journal once its hit-rate stays under a threshold:

if scanned >= abort_after and got_ft / max(1, scanned) < min_hit:
    break        # this venue isn't worth more API calls

Resumable. Output is appended line-by-line and already-fetched ids are skipped on restart, so a multi-hour crawl survives interruption:

done = _load_done_ids(out)          # ids already in the JSONL
...
if iid in done: continue            # skip work we already have

War story (why Crossref + Unpaywall).This project originally enumerated papers via OpenAlex, which changed to a paid credit model mid-build β€” the free daily budget ran out after ~100 requests. Crossref + Unpaywall is the free, robust replacement, and it is what the code ships with. The lesson β€” pin your data source assumptions and make the crawler resumable β€” is baked into the design.

Full-text yield varies a lot by source: arXiv ~99% (open by design), the broad OA pool ~15%, paywalled journals ~0% (abstract fallback). Net corpus: ~20k full-text papers + ~26k abstracts.

The crawler above is API-driven, but the shape underneath is the classic one: a breadth-first traversal of a link graph, run across many threads. It is worth writing that general pattern out in full, because getting the concurrency right is the hard part. The crux is shared state that every worker touches at once β€” a frontier of URLs still to visit, and a set of URLs already seen.

Three rules make it correct:

  • The frontier is a queue.Queue

, which is internally synchronised:get()

andput()

are atomic, andget()

blocks until work exists, so workers never busy-wait. - The seen-set gets its own lock. "Is this URL new? if so, add it" is a read-modify-write; without the lock two threads can both judge the same URL new and enqueue it twice.

  • Termination uses Queue.join()

/task_done()

. The hard question in a concurrent BFS is knowing when you are done: a worker finding the frontier momentarily empty does not mean the crawl is finished, because another worker may be one instruction away from enqueuing more links.join()

counts outstanding tasks and only releases once every enqueued URL has been fully processed.

import re, threading, queue, requests
from urllib.parse import urljoin, urldefrag, urlparse

class BFSCrawler:
    """Breadth-first web crawler: one shared frontier, N worker threads."""

    def __init__(self, seeds, max_pages=5000, n_workers=16, min_interval=1.0):
        self.frontier   = queue.Queue()          # thread-safe BFS queue of (url, depth)
        self.seen       = set(seeds)             # every URL ever enqueued
        self.seen_lock  = threading.Lock()       # guards `seen` (check-then-add races)
        self.pages      = []                     # kept (url, html) pairs
        self.pages_lock = threading.Lock()       # guards `pages` + the page counter
        self.max_pages  = max_pages
        self.n_workers  = n_workers
        self.throttle   = HostThrottle(min_interval)   # Β§3.2 β€” politeness, per host
        for s in seeds:
            self.frontier.put((s, 0))            # seed the frontier at depth 0

    def _links(self, base, html):                # extract absolute, de-fragmented links
        for m in re.finditer(r'href=["\'](.*?)["\']', html):
            url, _ = urldefrag(urljoin(base, m.group(1)))   # relative β†’ absolute, drop #frag
            if url.startswith(("http://", "https://")):
                yield url

    def _enqueue(self, url, depth):
        with self.seen_lock:              # the check AND the add are ONE critical section,
            if url in self.seen:          # else two threads both see `url` as new and
                return                    # enqueue it twice
            self.seen.add(url)
        self.frontier.put((url, depth))

    def _worker(self):
        while True:
            url, depth = self.frontier.get()      # blocks; (None, _) is the stop sentinel
            if url is None:
                self.frontier.task_done()
                return
            try:
                with self.pages_lock:
                    if len(self.pages) >= self.max_pages:
                        continue                  # budget spent β€” drain the rest quietly
                self.throttle.wait(urlparse(url).netloc)      # rate-limit this host
                html = requests.get(url, timeout=10).text
                with self.pages_lock:
                    if len(self.pages) >= self.max_pages:
                        continue
                    self.pages.append((url, html))
                for link in self._links(url, html):           # BFS: expand the frontier
                    self._enqueue(link, depth + 1)
            except Exception:
                pass                              # one dead link must not kill a worker
            finally:
                self.frontier.task_done()         # pairs with the get() above

    def run(self):
        workers = [threading.Thread(target=self._worker, daemon=True)
                   for _ in range(self.n_workers)]
        for t in workers:
            t.start()
        self.frontier.join()                      # wait until the frontier is truly drained
        for _ in workers:                         # then release the idle, blocked workers
            self.frontier.put((None, 0))
        for t in workers:
            t.join()
        return self.pages

ThreadPoolExecutor

reaches the same result with less bookkeeping β€” submit _worker

n_workers

times and let the pool own the threads β€” but spelling out the Queue.join()

handshake is what makes the termination logic visible.

This repo's fulltext.py

is a one-hop specialisation of exactly this skeleton: the frontier is the list of DOIs Crossref returns, each is "expanded" not into page out-links but into its Unpaywall PDF locations, and the shared guarded state is the resume-set of already-fetched ids rather than a seen

set of URLs. The thread pool and the per-host throttle are identical. A full multi-hop BFS would instead follow <a href>

links, which is the version above.

Code: src/process/

. Turns messy data/raw/*.jsonl

into clean train/val splits.

def normalize(text):
    text = html.unescape(text)                 # &#13; &amp; β†’ real chars
    text = unicodedata.normalize("NFKC", text) # fi ligatures, full-width, …
    text = _CTRL_RE.sub("", text)              # strip control bytes
    text = _MULTISPACE_RE.sub(" ", text)       # collapse whitespace
    ...

PDFs get extra care: line-break de-hyphenation (earth-\nquake β†’ earthquake

) and reference-section stripping (truncate at the last "References"/"Bibliography" heading β€” the bibliography is noise for a language model).

Three cheap, effective filters (passes_filters

):

lengthβ€” drop < 200 chars;** English ratio**β€” fraction of tokens that are common English stopwords; drops the Spanish-translated Substack posts without a language-ID dependency;alpha ratioβ€” fraction of letters/spaces; drops garbled PDF tables and math dumps.

Duplicate training data measurably hurts language models (Lee et al., 2021) β€” repeated documents get over-memorised and waste the token budget. But comparing every document against every other is O(NΒ²) and reads every byte, so good dedup is an exercise in doing the cheap test first: eliminate non-candidates with progressively more expensive checks, and pay the expensive one only on what survives.

The cascade, in its classic form. The same idea drives the textbook "find duplicate files on disk" problem. Two files can only be identical if they have the same size; of the same-size files, only those whose first kilobyte also matches can be identical; and only those earn a full-content hash. Each tier is strictly more expensive than the last and runs on strictly fewer inputs:

import os, hashlib
from collections import defaultdict

def find_duplicate_files(root: str) -> list[list[str]]:
    def file_hash(path: str, first_chunk_only=False, chunk=8192) -> str:
        h = hashlib.md5()
        try:
            with open(path, "rb") as f:
                if first_chunk_only:
                    h.update(f.read(1024))                    # cheap: one small read
                else:
                    for block in iter(lambda: f.read(chunk), b""):
                        h.update(block)                       # full read of the file
        except OSError:
            return ""
        return h.hexdigest()

    by_size: dict[int, list[str]] = defaultdict(list)
    for dirpath, _, names in os.walk(root):
        for name in names:
            path = os.path.join(dirpath, name)
            if os.path.islink(path):
                continue
            try:
                size = os.path.getsize(path)
            except OSError:
                continue
            if size > 0:
                by_size[size].append(path)

    by_partial: dict[str, list[str]] = defaultdict(list)
    for paths in by_size.values():
        if len(paths) > 1:                                    # a lone file can't be a dup
            for p in paths:
                if (ph := file_hash(p, first_chunk_only=True)):
                    by_partial[ph].append(p)

    by_full: dict[str, list[str]] = defaultdict(list)
    for paths in by_partial.values():
        if len(paths) > 1:
            for p in paths:
                if (fh := file_hash(p, first_chunk_only=False)):
                    by_full[fh].append(p)

    return [paths for paths in by_full.values() if len(paths) > 1]

Why it is fast: N files cost N cheap stat()

calls; a partial read happens only for files that share a size; a full read happens only for files that also share a first kilobyte. Almost everything is filtered out before Tier 3, so the expensive full-content pass touches a tiny fraction of the data β€” the O(NΒ²)-looking problem collapses to roughly O(N). The design rule generalises: cheapest discriminator first, narrow to candidates, confirm last.

Exact pass, applied to documents. Text documents are small enough that a single hash is the whole cascade β€” the trick is what you hash. We normalise first (lowercase, collapse whitespace) so copies that differ only in formatting collide, then key on one SHA-1. Normalisation is the moral equivalent of the size/partial tiers: it forces "same content, different wrapping" into the same bucket before the hash even runs.

def exact_key(text: str) -> str:                 # src/process/dedup.py
    normalized = " ".join(text.lower().split())  # canonicalise whitespace + case
    return hashlib.sha1(normalized.encode("utf-8")).hexdigest()

seen, unique = set(), []
for d in docs:
    k = exact_key(d["text"])
    if k in seen:            # O(1) set membership β€” the whole exact pass is O(N)
        continue
    seen.add(k); unique.append(d)

Near-duplicate pass (MinHash + LSH). Exact hashing misses documents that are almost identical β€” e.g. an abstract that also appears verbatim inside its full-text PDF, plus one extra sentence: one byte differs, so the SHA-1s diverge completely. For those we need a similarity measure, and LSH is the same cascade idea applied to fuzzy matching. Each document becomes a set of word 5-grams (shingles); a MinHash (Broder, 1997) signature (128 permutations) estimates the Jaccard similarity of two shingle sets in constant space; and an LSH index buckets signatures so only likely-similar documents are ever compared β€” the cheap candidate filter that replaces the O(NΒ²) all-pairs comparison, exactly like Tier 1 above. Documents are processed longest-first, so when a near-dup is found we keep the fullest version:

m = MinHash(num_perm=128)
for shingle in word_5grams(text): m.update(shingle.encode())
if lsh.query(m):          # LSH returns only bucket-mates β€” the candidate set
    near_dups += 1        # a β‰₯70%-similar doc is already kept β†’ drop this one
else:
    lsh.insert(id, m); kept.append(doc)

The MinHash step is the expensive tier (it reads every surviving document), so dedup.py

computes signatures across a process pool β€” worker processes inherit the document list by copy-on-write and return just the signatures β€” while the stateful LSH insert/query stays serial but cheap (dict ops on precomputed signatures). On the real corpus this removed 1,126 exact + 304 near duplicates. Finally the docs are shuffled with a fixed seed and split into train / val, preserving each doc's metadata.

Code: src/tokenizer/

. We train our own tokenizer rather than reuse GPT-2's β€” owning this stage is the point, and a domain vocabulary encodes seismology text more efficiently.

BPE (Byte-Pair Encoding β€” Sennrich et al., 2015, adapting a 1994 compression scheme) starts from single characters and greedily merges the most frequent adjacent pair, over and over, until it reaches the target vocab size. Frequent sequences (earthquake

, sub

, duction

) become single tokens; rare ones stay in pieces. Byte-level (Wang et al., 2019) means the base alphabet is the 256 raw bytes, so any input is representable β€” there are no <unk>

tokens, ever.

tokenizer = Tokenizer(BPE(unk_token=None))
tokenizer.pre_tokenizer = ByteLevel(add_prefix_space=False)
trainer = BpeTrainer(vocab_size=16384, special_tokens=["<|endoftext|>"],
                     initial_alphabet=ByteLevel.alphabet())   # all 256 bytes
tokenizer.train_from_iterator(_iter_texts(train_jsonl), trainer)

We use 16,384 tokens (vs GPT-2's 50,257): smaller vocab β†’ smaller embedding matrix (a big fraction of a small model's params) and the domain is narrow. Measured compression on our corpus is ~3.9 chars/token β€” healthy. The single special token <|endoftext|>

marks document boundaries.

tokenizers

does this for us in fast Rust, but the algorithm is small enough to write by hand β€” and understanding it is the point. A BPE tokenizer is just a base alphabet plus an ordered list of merge rules, learned in two phases.

The whole thing fits in one small class β€” training learns the merges, encoding replays them, decoding concatenates the bytes back:

from collections import defaultdict

class BPETokenizer:
    def __init__(self, vocab_size: int):
        assert vocab_size >= 256
        self.vocab_size = vocab_size
        self.vocab = {}      # token id -> merged bytes
        self.merges = {}     # (id1, id2) -> new id

    def _get_stats(self, ids: list) -> dict:            # count adjacent id pairs
        count = defaultdict(int)
        for i in range(len(ids) - 1):
            count[(ids[i], ids[i + 1])] += 1
        return count

    def _merge(self, ids: list, pair: tuple, newidx: int) -> list:
        output, i = [], 0
        while i < len(ids):
            if i < len(ids) - 1 and pair == (ids[i], ids[i + 1]):
                output.append(newidx); i += 2
            else:
                output.append(ids[i]); i += 1
        return output

    def train(self, text: str):
        for i in range(256):                            # base vocab = the 256 bytes
            self.vocab[i] = bytes([i])
        ids = list(text.encode("utf-8"))
        for i in range(self.vocab_size - 256):          # each merge adds one token
            stats = self._get_stats(ids)
            if not stats:
                break
            top_pair = max(stats, key=stats.get)        # most frequent adjacent pair...
            newidx = 256 + i
            ids = self._merge(ids, top_pair, newidx)    # ...becomes one new token
            self.merges[top_pair] = newidx
            self.vocab[newidx] = self.vocab[top_pair[0]] + self.vocab[top_pair[1]]

    def encode(self, text: str) -> list:                # text -> BPE token ids
        ids = list(text.encode("utf-8"))
        while len(ids) >= 2:
            stats = self._get_stats(ids)
            pair = min(stats, key=lambda p: self.merges.get(p, float("inf")))
            if pair not in self.merges:
                break
            ids = self._merge(ids, pair, self.merges[pair])
        return ids

    def decode(self, ids: list) -> str:                 # token ids -> text
        raw = b"".join(self.vocab[i] for i in ids)
        return raw.decode("utf-8", errors="replace")

train

starts from the 256 byte values, then repeatedly counts adjacent id pairs, merges the most frequent pair into a fresh id (256 + i

), and records both the merge rule and its byte expansion β€” one new token per iteration, so it stops after vocab_size βˆ’ 256

merges. encode

replays those merges greedily, always applying the one learned earliest (min

by new id = highest priority) until no learned pair is left. decode

concatenates each id's bytes and UTF-8-decodes. Because the base alphabet is all 256 bytes, nothing is ever out-of-vocabulary. (For a longer from-scratch treatment of this exact algorithm, see Karpathy's minbpe and the "Let's build the GPT Tokenizer" video.)

On our real 16k tokenizer (verified) the same mechanism gives earthquake

and subduction

one token each (they're common in the corpus), newest β†’ [new, est]

, seismogram β†’ [seism, ogram]

, and a genuinely rare word splits far more β€” antidisestablishmentarianism β†’ [ant, id, is, establish, ment, arian, ism]

.

This minimal version merges over the raw byte stream; production tokenizers β€” including our tokenizers

-based train_bpe.py

β€” add a byte-level pre-tokenizer that first splits text into word-ish chunks so merges never cross word boundaries. But the core loop is exactly the above, just in fast Rust so it handles the 3 GB corpus.

encode.py

streams the corpus through the tokenizer and writes one flat array of token ids per split, documents separated by the eot id:

for enc in tok.encode_batch(batch):
    buf.extend(enc.ids); buf.append(eot_id)       # eot between docs
    if len(buf) >= FLUSH_EVERY:
        np.asarray(buf, dtype=np.uint16).tofile(fout)   # 2 bytes/token

uint16

(0–65535) fits our 16k vocab in 2 bytes/token β†’ 822.7M tokens β‰ˆ 1.6 GB, and the training np.memmap

s it (no RAM blow-up). Crucially, this file is context-length-agnostic β€” the window width is chosen at training time, which is why switching 1024β†’4096 needed no re-tokenization.

Code: src/model/gqa_gpt.py

. A modern, Llama-style decoder-only Transformer.

Bottom-to-top: token ids β†’ embedding β†’ Γ—16 pre-norm blocks (RMSNorm β†’ GQA+RoPE β†’ residual, then RMSNorm β†’ SwiGLU β†’ residual) β†’ final RMSNorm β†’ weight-tied LM head β†’ logits.

choice over the "classic GPT" alternative why
RMSNorm (
LayerNorm fewer ops, no mean-subtraction, same quality β€” the Llama default
RoPE (
learned position embeddings relative, extrapolates, no position table to learn
GQA (
full multi-head attention 3Γ— smaller KV cache for long-context inference, ~no quality loss
SwiGLU (
GELU MLP gated activation, better quality per parameter
weight tying (
separate input/output embeddings saves 12.6M params on a small model, regularizes
no biases
linear layers with bias biases add little; simpler and slightly faster

Attention is the operation that lets tokens exchange information. Each token's vector is linearly projected into three roles:

queryq

β€” what this token is looking for;keyk

β€” what this token offers to others;valuev

β€” the information it passes on when attended to.

For one head of width d_k

, stack these over the sequence into Q, K, V

of shape T Γ— d_k

(one row per token). Attention scores every query against every key, turns the scores into weights, and returns each query's weighted average of the values:

Attention(Q, K, V) = softmax( Q Kα΅€ / √d_k + M ) Β· V

Q Kα΅€

is theT Γ— T

matrix of queryΒ·key dot products β€” how strongly token i's query matches token j's key.softmax

over each row makes weights that sum to 1, and multiplying byV

averages the values under those weights.M

is the causal mask:0

on and below the diagonal,βˆ’βˆž

above, so a token attends only to itself and earlier tokens β€” it can't peek at the future it is trained to predict.

Why divide by √d_k. q·k

is a sum of d_k

products; for roughly unit-variance, independent entries that sum has variance d_k

, so raw scores grow like √d_k

. Feed large scores into softmax and it saturates β€” one weight β‰ˆ 1, the rest β‰ˆ 0 β€” and its gradient vanishes, so learning stalls. Dividing by √d_k

rescales scores back to ~unit variance and keeps softmax in a responsive range. (RoPE in Β§6.3 is applied to q

and k

just before this product; GQA in Β§6.4 shares K

/V

across heads.)

Why several heads, not one. Instead of one attention over the full d_model

width, we split into h

heads of d_k = d_model / h

(here 12 Γ— 64), attend independently, and concatenate. A single head must collapse every kind of relationship β€” local adjacency, syntax, long-range coreference β€” into one weight distribution; separate heads let each specialise in a different pattern and a different subspace at the same time. Total compute is unchanged (h Β· d_k = d_model

), and the specialisation is visible in the trained model (Β§8.4: some heads are previous-token, some broad, some attention sinks).

Cost. Both Q Kα΅€

and the weighting of V

are T Γ— T Γ— d_k

per head, so attention is O(TΒ² Β· d_model) in time and O(TΒ²) in memory for the score matrix β€” quadratic in sequence length T

. That quadratic is why long context is expensive, and why the next two subsections matter: GQA shrinks the per-token state that has to be cached, and FlashAttention removes the O(TΒ²) memory term entirely.

A Transformer is permutation-invariant; it needs to be told token order. RoPE encodes position by rotating each query/key vector by an angle proportional to its position. The dot product of a rotated query at position m

and key at position n

then depends only on their relative distance m βˆ’ n

β€” which is exactly what attention should care about, and it lets the model handle positions it can partly extrapolate to.

The cleanest way to see it: treat each pair of channels as a complex number β€” rotating a 2-D vector by angle ΞΈ is just multiplying by the unit complex number e^{iΞΈ}

. Precompute e^{iΒ·mΒ·ΞΈ_j}

for every position m

and frequency ΞΈ_j

, then encoding position is a single complex multiply:

def precompute_mtheta(dim: int, seqlen: int, base: float = 1e4) -> torch.Tensor:
    theta = 1 / (base ** (torch.arange(0, dim, 2) / dim))    # D/2 frequencies
    m = torch.arange(0, seqlen)                              # S positions
    mtheta = torch.outer(m, theta)                           # S Γ— D/2 angles
    return torch.polar(torch.ones_like(mtheta), mtheta)      # unit complex e^{iΒ·mΞΈ}

def apply_rope(x: torch.Tensor, mtheta: torch.Tensor) -> torch.Tensor:
    x_complex = torch.view_as_complex(x.float().unflatten(-1, (-1, 2)))   # B S H D/2
    mtheta = mtheta[:x.shape[1]].reshape(1, x.shape[1], 1, -1)            # 1 S 1 D/2
    x_rotated = torch.view_as_real(x_complex * mtheta).flatten(3)         # B S H D
    return x_rotated.type_as(x)

Each channel pair (xβ‚‚β±Ό, xβ‚‚β±Όβ‚Šβ‚)

becomes the complex number xβ‚‚β±Ό + iΒ·xβ‚‚β±Όβ‚Šβ‚

and is rotated by mΒ·ΞΈ_j

; low-index pairs use large ΞΈ_j

(rotate fast), high-index pairs small ΞΈ_j

(slow). There are no learned parameters, and the rotation preserves norm. The dot product of a rotated query at m

and key at n

then depends only on the relative offset m βˆ’ n

β€” the whole point.

Our repo implements the algebraically-equivalent real-valued rotate_half

form (same rotation, no complex tensors β€” friendlier to the compiled / FlashAttention path); cos

/sin

are precomputed once and applied to Q and K just before attention:

def _apply_rope(x, cos, sin):          # x: (B, n_head, T, head_dim)
    return x * cos + _rotate_half(x) * sin

(One convention pairs adjacent channels, the other pairs channel j

with j + D/2

; both give the same relative-position behavior β€” just a different weight layout.)

Two consequences (both computed from this model's head_dim=64, ΞΈ=10000

): the qΒ·k score depends only on the relative distance β€” curves for queries at positions 200/800/2000 lie exactly on top of each other (left) β€” and the channel pairs rotate at a geometric spread of frequencies, fast to slow (right), so the model can resolve both nearby and far-apart positions.

Standard multi-head attention gives every one of the 12 query heads its own key/value head β€” so at inference the KV cache (the stored keys/values for every past token) holds 12 heads' worth per token. GQA (Ainslie et al., 2023) lets several query heads share a KV head β€” interpolating between full MHA and multi-query attention (Shazeer, 2019). We use 12 query : 4 KV heads, so the KV cache is 3Γ— smaller β€” cheaper long-context inference β€” at nearly no quality cost, because attention quality is dominated by the number of query heads (still 12).

q = torch.einsum("btd,hid->bhti", x, self.wq.weight.view(n_head, hd, d))  # 12 heads
k = torch.einsum("btd,hid->bhti", x, self.wk.weight.view(n_kv,   hd, d))  #  4 heads  ← fewer
v = torch.einsum("btd,hid->bhti", x, self.wv.weight.view(n_kv,   hd, d))  #  4 heads
if n_kv != n_head:                       # broadcast KV heads to match Q for SDPA
    k = k.repeat_interleave(rep, dim=1)  # (the cache itself still stores only n_kv heads)
    v = v.repeat_interleave(rep, dim=1)
y = F.scaled_dot_product_attention(q, k, v, is_causal=True)   # FlashAttention kernel

That one scaled_dot_product_attention

call is just the standard attention math β€” worth spelling out once. Here is the explicit, non-Flash equivalent (also how src/figures/attention_map.py

recovers the weights, since the fused kernel doesn't expose them):

import math
scores = torch.einsum("bhqd,bhkd->bhqk", q, k) / math.sqrt(head_dim)  # (B,n_head,T,T)
mask = torch.triu(torch.ones(T, T, dtype=torch.bool), diagonal=1)
scores = scores.masked_fill(mask, float("-inf"))                      # causal
attn = torch.softmax(scores, dim=-1)                                  # each row sums to 1
y = torch.einsum("bhqk,bhkd->bhqd", attn, v)                          # (B,n_head,T,head_dim)

The two einsums are the queryΒ·key similarity (bhqd,bhkd->bhqk

) and the value-weighted sum (bhqk,bhkd->bhqd

) β€” identical to q @ kα΅€

and attn @ v

, just with the contracted axis named.

Why FlashAttention (Dao et al., 2022; FlashAttention-2, 2023). F.scaled_dot_product_attention

computes exactly the above (verified identical to ~1e-7), but attention written this way is memory-bandwidth bound, not compute bound: the naive version writes the TΓ—T

scores

and attn

matrices out to the GPU's large-but-slow main memory (HBM) and reads them back, and shuttling those O(TΒ²) intermediates dominates the wall-clock. FlashAttention never builds them. It splits Q, K, V into blocks small enough to fit in on-chip SRAM β€” the fast per-SM scratchpad (tens of KB, ~10–20Γ— the bandwidth of HBM) β€” and for each query block streams over the key/value blocks, accumulating the result tile by tile so the O(TΒ²) intermediates never leave SRAM. Only the O(T) output is written back to HBM.

The online softmax, in code. The one hard part is the softmax. An ordinary softmax needs the whole row at once β€” subtract the row max for numerical stability, exponentiate, divide by the sum β€” but a query block only ever sees one key block at a time. The fix is an online softmax that keeps three running quantities per query row and updates them block by block: the running max m

, the running normaliser l = Ξ£ exp(Β·)

, and the running un-normalised output o

. When a new block raises the max, the earlier accumulators are rescaled by exp(m_old βˆ’ m_new)

to rebase them onto the new max:

import torch, math

class FlashAttention:
    """Tiled attention with an online softmax β€” the FlashAttention forward pass in
    plain PyTorch. Never materialises the full SΓ—S score matrix, only BrΓ—Bc tiles."""

    def __init__(self, block_q: int, block_kv: int):
        self.Br = block_q        # query-block size (rows of the score matrix)
        self.Bc = block_kv       # key/value-block size (columns)

    def __call__(self, q, k, v):
        B, H, S, D = q.shape
        scale = 1.0 / math.sqrt(D)
        out = torch.zeros_like(q)

        for i in range(0, S, self.Br):                    # outer loop: query blocks
            q_i = q[..., i:i + self.Br, :]                # (B,H,Br,D) β€” stays in SRAM
            o_i = torch.zeros_like(q_i)                   # running un-normalised output
            l_i = torch.zeros(*q_i.shape[:-1], 1, device=q.device, dtype=q.dtype)  # running Ξ£exp
            m_i = torch.full_like(l_i, float("-inf"))     # running row-max

            for j in range(0, S, self.Bc):                # inner loop: stream K/V blocks
                k_j = k[..., j:j + self.Bc, :]
                v_j = v[..., j:j + self.Bc, :]

                s_ij  = torch.matmul(q_i, k_j.transpose(-2, -1)) * scale   # (B,H,Br,Bc) scores
                m_new = torch.maximum(m_i, s_ij.max(dim=-1, keepdim=True).values)   # new row-max
                p_ij  = torch.exp(s_ij - m_new)           # tile probabilities vs the new max
                corr  = torch.exp(m_i - m_new)            # rescale earlier accumulators (≀ 1)

                l_i = l_i * corr + p_ij.sum(dim=-1, keepdim=True)   # rebase Ξ£exp, add this tile
                o_i = o_i * corr + torch.matmul(p_ij, v_j)          # rebase output, add pΒ·V
                m_i = m_new                               # advance the running max

            out[..., i:i + self.Br, :] = o_i / l_i        # normalise once, write O(T) to HBM

        return out

One inner step reads a BrΓ—Bc

score tile s_ij

, folds its row-max into the running max (m_new

), forms that tile's probabilities p_ij = exp(s_ij βˆ’ m_new)

against the new max, and β€” before adding them in β€” multiplies the earlier l

and o

by the correction corr = exp(m_i βˆ’ m_new) ≀ 1

. That correction is the whole trick: it retroactively puts the running sum and output on the latest max's scale, so after the final block o / l

is bit-for-bit a full softmax (max abs diff ~1e-15 vs scaled_dot_product_attention

) while nothing larger than one BrΓ—Bc

tile is ever held. The production CUDA kernel is this exact recurrence with the tiles living in registers/SRAM. For a causal decoder like ours the inner loop simply skips future key blocks (j > i

) and triangular-masks only the diagonal block, so causality roughly halves the work as well.

Far fewer HBM round-trips β†’ both O(T) memory and a big speedup β€” which is exactly what makes 4096-token context affordable on a 24 GB card.

Every sublayer is wrapped in normalisation. We use RMSNorm: scale each token vector to unit root-mean-square, then apply a learned per-channel gain g

β€” no mean subtraction, no bias:

RMSNorm(x) = x / sqrt( mean(xΒ²) + Ξ΅ ) Β· g

LayerNorm additionally centres the vector ((x βˆ’ ΞΌ)/Οƒ Β· Ξ³ + Ξ²

); RMSNorm drops the mean and the bias, which is cheaper and works as well in practice β€” the Llama default.

Where the norm sits matters as much as which one. The original Transformer is post-norm β€” normalise after the residual add; modern decoders (GPT-2, Llama, us) are pre-norm β€” normalise inside the residual branch, before the sublayer:

post-norm:  x  ←  Norm( x + Sublayer(x) )
pre-norm:   x  ←  x + Sublayer( Norm(x) )

Why pre-norm. In pre-norm the residual path x + …

is an unbroken identity highway: gradients flow from the loss straight back to every layer without passing through a normaliser, so deep stacks train stably from scratch. Post-norm puts a norm on that main path, so the gradient is rescaled at every layer on the way down β€” deep post-norm Transformers need careful learning-rate warmup and initialisation just to converge. The price of pre-norm is that the residual stream's variance grows with depth (each block adds to it without renormalising the sum); we offset that in the weight init (Β§6.7).

Between attention layers, each token is transformed on its own by a feed-forward network. The classic version is Wβ‚‚ Β· GELU(W₁ x)

: project up, apply a pointwise activation, project down. We use SwiGLU, a gated variant with three matrices:

SwiGLU(x) = Wβ‚‚ ( SiLU(W₁ x) βŠ™ (W₃ x) ),     SiLU(z) = z Β· sigmoid(z)

The up-projection W₃ x

is multiplied element-wise (βŠ™

) by a data-dependent gate SiLU(W₁ x)

: the network learns, per token and per hidden unit, how much signal to let through. That multiplicative interaction is more expressive than a single fixed activation and gives better quality per parameter. Because SwiGLU uses three matrices instead of two, its hidden width is set to β‰ˆ 8/3 Β· d_model

(rounded to a multiple of 256) rather than 4 Β· d_model

, so the parameter count matches a plain MLP β€” the ⌈8/3Β·dβŒ‰

rule. Code: src/model/gqa_gpt.py

's SwiGLU

(w1

gate, w3

up, w2

down).

Initialisation decides whether the first steps are stable. Three rules:

Linear and embedding weights: A small standard deviation keeps activations and the initial logits in a sane range. At init the model should be maximally unsure, so the loss should beNormal(0, 0.02)

.ln(vocab_size) = ln(16384) β‰ˆ 9.70

; ours starts at9.85βœ“ β€” the quickest check that the model and loss are wired correctly.** Residual output projections scaled by**(the GPT-2 trick): the attention1/√(2Β·n_layer)

Wo

and the SwiGLUWβ‚‚

β€” the two matrices that write back into the residual stream β€” are initialised withstd = 0.02/√(2Β·n_layer)

. In a pre-norm net (Β§6.5) each of the2Β·n_layer

sublayers adds to the residual stream without renormalising the sum, so without this its variance would grow with depth and the logits would blow up; shrinking those writes keeps the residual variance roughly constant from the first block to the last.No biases anywhere, and RMSNorm gains start at1

.

(For the scaling experiments, muP rescales both this init and the learning rate by width; Β§10.2.)

configs/gpt120m_ctx4k.yaml

:

n_layer: 16      d_model: 768      n_head: 12   n_kv_head: 4     # GQA 12:4
block_size: 4096   vocab_size: 16384   ffn hidden: 2048 (SwiGLU)

d_model 768 / 12 heads / 16 layersβ‰ˆ GPT-2-small shape β†’ ~113M params, a size that trains comfortably on one node and is big enough to be fluent.** head_dim 64**(768/12) β€” the sweet spot FlashAttention is tuned for.** SwiGLU hidden 2048**β€” the Llama⌈8/3Β·dβŒ‰

rule rounded to a multiple of 256.

Code: src/train.py

. Data-parallel across both A30s.

We launch with torchrun --nproc_per_node=2

, which starts one process per GPU. DistributedDataParallel keeps a replica of the model on each GPU; each processes a different micro-batch, and gradients are all-reduced (averaged) across GPUs before the optimizer step β€” so both replicas stay identical. The effective batch is the sum across GPUs.

init_process_group(backend="nccl")
model = torch.compile(model)                       # fuse kernels
model = DDP(model, device_ids=[local_rank])        # replicate + all-reduce grads

Gradient accumulation multiplies the batch further: we run several micro-batches before stepping, and β€” importantly β€” only sync gradients on the last one, so the in-between backward passes don't pay the all-reduce cost:

for micro in range(grad_accum):
    model.require_backward_grad_sync = (micro == grad_accum - 1)
    with autocast(bfloat16):
        _, loss = model(x, y)
    (loss / grad_accum).backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)   # clip exploding grads
optimizer.step()

Global batch = batch(4) Γ— grad_accum(12) Γ— gpus(2) Γ— ctx(4096)

= 393,216 tokens/iter. bf16 autocast (mixed-precision training) halves memory and doubles throughput vs fp32 with no loss-scaling needed (bf16 has fp32's exponent range).

DDP has a hard ceiling: it replicates the whole model and its training state on every GPU, so the biggest model it can train is the one that fits on one card. Mixed-precision Adam costs about 16 bytes per parameter β€” 2 (bf16 weights) + 2 (bf16 grads) + 12 (fp32 optimizer: master weights 4, momentum 4, variance 4) β€” so a 7B model needs ~112 GB of state before a single activation, far past one GPU. Beyond that you have to shard the model itself. The strategies below are orthogonal axes β€” large runs combine several β€” differing mainly in what they split and how much they must communicate (N = number of GPUs sharing a shard).

strategy splits across GPUs what it cuts communication wants
DDP
nothing (full replica) throughput only all-reduce grads any link
ZeRO-1
optimizer states opt memory β†’ 12/N B/param β‰ˆ DDP any
ZeRO-2
  • gradients | + grad memory β†’ 2/N | β‰ˆ DDP | any | ZeRO-3 / FSDP |
  • parameters | all state β†’ ~16/N | ~1.5Γ— DDP | fast interconnect | Tensor (Megatron) | weight matrices, within a layer | params + activations + layer compute | 2 all-reduce per layer, each pass | NVLink, intra-node | Pipeline | layers (by depth) | params + activations by depth | point-to-point activations | tolerates inter-node | Sequence / context | the token dimension | activation memory; enables long context | K/V exchange in attention | medium | Expert (MoE) | experts (MoE only) | grows params at fixed FLOPs | 2Γ— all-to-all | NVLink, intra-node |

ZeRO / FSDP β€” shard the data-parallel redundancy. Still data parallelism (every rank runs the whole model on its own micro-batch), but instead of replicating the training state N times it shards it across the N ranks (Rajbhandari et al. 2019). ZeRO-1 shards the optimizer states (the 12 bytes/param) at essentially DDP communication β€” the cheapest, near-free win. ZeRO-2 also shards gradients (reduce-scatter instead of all-reduce, same volume). ZeRO-3 also shards the parameters: each rank keeps 1/N of the weights and all-gathers a layer's shard just-in-time in the forward and the backward, then frees it β€” per-GPU state falls to ~16/N bytes/param (near-linear memory scaling) at about 1.5Γ— DDP's communication. PyTorch FSDP is ZeRO-3 (Zhao et al. 2023). What ZeRO does not do: shard activations, or make any layer faster β€” per-rank compute is unchanged. Caveat: ZeRO-3's extra parameter all-gathers need a fast interconnect or throughput sags.

Tensor parallelism (Megatron) β€” split inside a layer. Shard individual weight matrices so each GPU holds a slice and computes part of every layer, recombining with a collective (Shoeybi et al. 2019). The MLP splits its up-projection column-wise and its down-projection row-wise (one all-reduce); attention splits across heads, which are independent (one all-reduce on the output projection). It cuts parameters, activations, and per-layer compute together, and speeds up the layer itself. Caveat: two all-reduces per layer in the forward and two in the backward sit on the critical path β€” very heavy communication β€” so tensor parallelism is only efficient within a node over NVLink, and its degree is usually capped at the GPUs in one node (≀ 8). It also needs Megatron-style layer code, not a transparent wrapper.

Pipeline parallelism (GPipe, 1F1B) β€” split by depth. Consecutive layers live on different GPUs; only the activations at stage boundaries are passed, as small point-to-point messages that tolerate slower inter-node links (Huang et al. 2018). To stop GPUs idling, the batch is chopped into micro-batches and pipelined. Caveat: the pipeline bubble β€” GPUs sit idle while the pipeline fills and drains, a fraction β‰ˆ (Pβˆ’1)/(m + Pβˆ’1) for P stages and m micro-batches, so you need many micro-batches to amortize it β€” and balancing the per-stage load is fiddly.

Sequence / context parallelism β€” split the token dimension. In Megatron form it complements tensor parallelism by also sharding the norm/dropout regions TP leaves replicated, cutting activation memory further (Korthikanti et al. 2022). In the ring-attention / Ulysses form it shards the sequence itself, so each GPU holds part of the tokens and exchanges K/V across ranks during attention (Liu et al. 2023) β€” the route to very long contexts (hundreds of thousands of tokens) whose O(T) activations or O(TΒ²) attention would never fit otherwise. Caveat: attention now carries cross-rank communication.

Expert parallelism β€” for Mixture-of-Experts only. In an MoE the feed-forward is replaced by many expert FFNs and each token is routed to a few (top-k). Expert parallelism puts different experts on different GPUs: an all-to-all dispatches each token to its expert's GPU, and another all-to-all brings the result back (Fedus et al. 2021). This decouples parameter count from per-token compute β€” scale to trillions of parameters at fixed FLOPs per token. Caveats: all-to-all is bandwidth-hungry; token loads across experts are uneven (stragglers), needing a load-balancing loss and per-expert capacity caps; and it applies only if you chose an MoE architecture.

Combining them, and choosing by size. The axes multiply β€” total GPUs = DP Γ— TP Γ— PP (Γ— expert Γ— context) β€” and the rule that matters is to put the most communication-hungry axis on the fastest link (Narayanan et al. 2021): tensor parallelism (and MoE all-to-all) stay inside a node on NVLink; pipeline spans nodes, since its point-to-point transfers tolerate slower links; data parallelism with ZeRO wraps the outside to scale throughput and shard whatever state is left. Activation recomputation (gradient checkpointing) trades recompute for activation memory and composes with all of them β€” usually the first lever to pull. Rational defaults for a dense model:

≀ ~1B (fits one GPU with state): plain DDP. Our 113M lives here.Fits, but state is tight or you want a bigger batch: ZeRO-1/2 β€” near-free memory at DDP-level communication.Doesn't fit one GPU, single node (~a few B–13B): ZeRO-3 / FSDP to shard everything across the node (simplest), or tensor parallelism ≀ 8 if you are compute-bound.~10B–100B, multi-node: 3D β€” tensor parallel within each node, pipeline across nodes, data parallel / ZeRO on top; FSDP alone starts to choke on inter-node all-gathers here.100B+ or very long context: full 3D plus sequence/context parallelism, and expert parallelism if the model is MoE.

Linear warmup (400 iters) then cosine decay (Loshchilov & Hutter, 2016) to a floor. Warmup avoids early divergence while Adam's statistics are cold; cosine decay squeezes out the last improvements at low LR. Plotting both against the training step makes the schedule and its effect legible on one figure:

The LR (orange) ramps up over the first 400 steps then follows a cosine to its floor, while the loss (blue) crashes during warmup and keeps refining through the long low-LR tail β€” which is where the final best val loss came from.

Training memory has a fixed part and a batch-dependent part.

Fixed (independent of batch): parameters + gradients + Adam's two moments. With fp32 master weights and fused AdamW (Loshchilov & Hutter, 2017):

params (fp32)   113M Γ— 4 B = 0.45 GB
grads  (fp32)   113M Γ— 4 B = 0.45 GB
Adam m,v (fp32) 113M Γ— 8 B = 0.90 GB
                            ─────────
fixed β‰ˆ 1.8 GB

Batch-dependent (activations): grows with batch Γ— seq_len Γ— n_layer Γ— d_model

. This is the hard-to-predict part (FlashAttention, autocast and torch.compile

all change it), so we measure it empirically with a one-shot probe β€” run a single forward+backward and read the peak:

torch.cuda.reset_peak_memory_stats()
loss = model(x, y)[1]; loss.backward(); opt.step()
print(torch.cuda.max_memory_allocated() / 1e9, "GB")

That's how we picked the batch size: batch 4 @ 4096 β†’ 15.2 GB (fits 24 GB with headroom); batch 8 @ 4096 OOMs. This probe-first habit beats guessing every time.

Measured at run time: ~12 GB/GPU, both at 100% utilization, ~2.9 s/iter.

Nothing here is A30-specific. Because DDP replicates the whole model on each GPU, a single 24 GB card already holds a full training copy β€” so this runs unchanged on a single RTX 3090 or RTX 4090 (drop torchrun

; train.py

takes the single-process path when no RANK

is set). Memory is dominated by activations, which scale with batch Γ— context, so a smaller card just uses a smaller micro-batch; gradient accumulation then restores the same 393,216-token global batch (it runs micro-batches sequentially, so it costs time, not memory). Measured peaks:

card setting peak VRAM command
24 GB (RTX 3090 / 4090 / A5000) batch 4 @ 4096 (shipped) ~16 GB python -m src.train --config configs/gpt120m_ctx4k.yaml
16 GB batch 2 @ 4096 8.9 GB … --batch-size 2 --grad-accum 48
12 GB batch 1 @ 4096 5.3 GB … --batch-size 1 --grad-accum 96

Ampere (3090, A5000) and Ada (4090) run on the pinned torch 2.6.0+cu124

as-is; the newest Blackwell cards need a CUDA-12.8 / PyTorch β‰₯2.7 build. Rough wall-clock for the full 8,000-iter run (measured on A30, spec-scaled for the rest): ~6.5 h on 2Γ— A30, ~13 h on one A30, ~10–13 h on a 4090, and ~20–30 h on a 3090 or A5000 β€” throughput scales with the card, but the recipe is identical.

Code: src/inference.py

(engine + tests), src/sample.py

(simple sampler).

Naive generation recomputes the whole sequence every step β€” O(TΒ²). With a KV-cache we keep each layer's past keys/values and feed only the new token each step β€” O(T), so throughput stays flat (~83 tok/s) as the sequence grows toward 4096, where the naive path degrades to ~63 tok/s. Correctness is verified against the naive path (greedy output is byte-identical).

Generation streams token-by-token (generate_stream

yields one token per step; the CLI prints with flush=True

), so you see text ~176 ms after hitting enter instead of waiting for the whole block. Decoding uses a "decode-the-running-list, emit-the-suffix" trick so multi-byte characters that span BPE tokens render correctly.

The sampler also supports anti-repetition controls (on by default in the CLIs): --repetition-penalty

(downweights already-seen tokens) and --no-repeat-ngram

(hard-bans repeating n-grams). At 0.1B params the base model occasionally falls into degenerate loops (e.g. inside a paper table); these decisively fix it β€” Mj Mj Mj…

β†’ a coherent citation β€” without changing correctness (both default to off inside the model, on in the CLI).

generate_annotated()

records, for every emitted token, the raw probability the model assigned it and the top-8 alternatives. Shading a real sample by that confidence makes the model's behavior visible β€” and shows what temperature sampling does:

Dark = confident (Mw 9.0

, the

, earthquake

), light = uncertain. The bottom panel explains one light token: the model's top choice was area

(p 0.59), but with temperature 0.8 it sampled the rarer plane

(p 0.01) β€” "rupture plane", still perfectly valid seismology. This is exactly the exploration/quality trade-off that --temperature

controls.

The decisive test (--test

, context_utilization

): measure loss by position within a full window. If long context helps, later positions β€” which have more preceding tokens to condition on β€” should be predicted better:

position in window mean loss
0–64 (little context) 2.89
1024–2048 2.22
2048–4096 (full context) 2.16

βˆ’25% from early to late β€” the model demonstrably exploits the long context.

python -m src.inference --test           # generation + perplexity + context-utilization
python -m src.inference --interactive    # streaming REPL
python -m src.inference --perplexity-text "..."   # score any text

The token embeddings encode real domain structure β€” a 2-D PCA cleanly separates seismology terms from everyday words, and nearest neighbours are sensible (tsunami β†’ liquefaction, landslide, evacuation

):

And different attention heads specialize β€” some are local (previous-token), some are "attention-sink" heads, some spread broadly β€” all causal (lower-triangular):

A nice sanity check of specialization: perplexity on a seismology sentence is ~13–16, but on generic English it is ~600–900 β€” the model is sharply tuned to its domain.

nanogpt_seis/
β”œβ”€β”€ README.md                  ← you are here
β”œβ”€β”€ configs/                   ← YAML model+training configs
β”‚   β”œβ”€β”€ gpt120m.yaml           (1024-ctx baseline)
β”‚   β”œβ”€β”€ gpt120m_ctx4k.yaml     (4096-ctx, the main model)
β”‚   └── gpt_small.yaml         (~32M, for fast pipeline iteration)
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ crawl/                 ← Stage 1: data crawlers
β”‚   β”‚   β”œβ”€β”€ common.py          (Doc schema, cached polite HTTP)
β”‚   β”‚   β”œβ”€β”€ fulltext.py        (Crossref + Unpaywall, concurrent, resumable)
β”‚   β”‚   β”œβ”€β”€ preprints.py       (arXiv + EarthArXiv)
β”‚   β”‚   β”œβ”€β”€ general.py         (Wikipedia + FineWeb-Edu general-text mix)
β”‚   β”‚   β”œβ”€β”€ wikipedia.py  substack.py  arxiv_openalex.py
β”‚   β”œβ”€β”€ process/               ← Stage 2: clean / filter / dedup / split
β”‚   β”‚   β”œβ”€β”€ clean.py  dedup.py  build_corpus.py
β”‚   β”œβ”€β”€ tokenizer/             ← Stage 3: BPE
β”‚   β”‚   β”œβ”€β”€ train_bpe.py  encode.py
β”‚   β”œβ”€β”€ model/gqa_gpt.py       ← Stage 4: the Transformer
β”‚   β”œβ”€β”€ train.py               ← Stage 5: DDP training loop
β”‚   β”œβ”€β”€ inference.py sample.py ← Stage 6: serving (streaming, KV-cache, anti-repeat)
β”‚   β”œβ”€β”€ compare_models.py      ← v1-vs-v2 A/B (bits-per-byte)
β”‚   β”œβ”€β”€ plot_training.py       ← training-curve plots
β”‚   β”œβ”€β”€ scaling/               ← Stage 10: IsoFLOP scaling-law sweep (muP)
β”‚   └── figures/               ← the diagrams in this README
β”œβ”€β”€ assets/                    ← generated figures
β”œβ”€β”€ data/                      ← raw / processed / tokenized (git-ignored)
└── checkpoints/               ← weights, logs, plots (weights git-ignored)

Regenerate every figure in this README (each writes .png

  • .pdf

):

python -m src.figures.workflow
python -m src.figures.architecture
python -m src.figures.gqa_vs_mha
python -m src.figures.training_curves
python -m src.figures.corpus_composition
python -m src.figures.rope
CUDA_VISIBLE_DEVICES=0 python -m src.figures.generation_example
CUDA_VISIBLE_DEVICES=0 python -m src.figures.embedding_space
CUDA_VISIBLE_DEVICES=0 python -m src.figures.bpb_comparison
CUDA_VISIBLE_DEVICES=0 python -m src.figures.context_utilization
CUDA_VISIBLE_DEVICES=0 python -m src.figures.attention_map

Code: src/scaling/

. The 113M model is a single point; the interesting question is the shape of the curve. This is a small, self-contained harness that measures the compute-optimal frontier at 3M–85M scale, the IsoFLOP way (Hoffmann et al. 2022): train a family of sizes under fixed compute budgets and see which size wins at each budget.

Vary only N and D. Context length (1024) and the global batch (131,072 tokens/iter) are fixed across every run, so the only moving parts are model size N and data D. Each run then costs exactlyC β‰ˆ 6Β·NΒ·D

FLOPs β€” that is what makes a budget an "IsoFLOP" set of points.N is non-embedding parameters. With a fixed 16k tied embedding, the vocab matrix would dominate tiny models and distort the power law, so N excludes it (Kaplan et al. convention). The tokenizer and embedding size are identical in every run.One profile per budget. For each C, every model whose impliedD = C/(6N)

is trainable and under ~4 epochs of the corpus is run; the size with the lowest loss on that profile is the compute-optimal N_opt(C). Connecting the minima across budgets gives the frontier, and its slope is the scaling exponent.

The family spans a ~165Γ— range in size (head_dim fixed at 64, so d_model = 64Β·n_head):

model n_layer d_model ~non-emb N
xxs 3 128 ~0.7M
xs 4 256 ~3.1M
s 6 384 ~9.4M
m 8 512 ~24M
l 10 640 ~44M
xl 12 768 ~76M
2xl 14 896 ~122M

Default budgets span 6e15

–5.4e17

(five, a 90Γ— range in compute) over these seven sizes β†’ 25 cells after the data-window gate. The sweep is resumable (a DONE

marker per finished run), so widening the grid only trains the new cells.

The confound in any size sweep is the learning rate: the best LR shifts with width, so reusing one LR across a 24Γ— range would penalise some sizes and pollute the fit. Maximal-Update Parametrization (muP, Yang et al. 2022) fixes this β€” tune the LR once at a base width and it transfers to the rest. For Adam the core rule is that hidden-matrix weights (whose fan-in grows with width) get a learning rate scaled by base_width / d_model

, while embeddings and norms keep the base LR; the same weights are initialised smaller by sqrt(base_width / d_model)

, and the output logits are scaled by base_width / d_model

to keep their magnitude width-stable.

mult    = d_model / mup_base_width
lr_mult = 1 / mult if name.endswith(_HIDDEN_SUFFIXES) else 1.0  # embeds/norms: 1.0

Because head_dim is fixed across the sweep, muP's 1/head_dim

attention-scale change is unnecessary β€” the usual 1/sqrt(head_dim)

is already width-independent here. muP is opt-in (mup: true

in the config) and an exact no-op at the base width, so the 113M model and every earlier result are unchanged. One caveat: with tied input/output embeddings the shared matrix takes the embedding parametrization (constant LR), so the output multiplier β€” not a separate readout LR β€” carries muP's width stabilisation.

python -m src.scaling.run_sweep --generate

python -m src.scaling.run_sweep --run --nproc 2      # --budgets 2e16 6e16 to subset

python -m src.scaling.fit                            # β†’ checkpoints/scaling/scaling_fit.json
python -m src.figures.scaling_laws                   # β†’ assets/scaling_laws.png

fit

fits a parabola to each budget's loss-vs-N profile, takes the vertex as the compute-optimal (N_opt, D_opt, L_opt), and then fits N_opt ∝ C^a

and D_opt ∝ C^b

across budgets β€” Chinchilla predicts a β‰ˆ b β‰ˆ 0.5

. The figure shows the IsoFLOP profiles (loss vs N, one valley per budget) and the fitted frontier. With the exponents in hand you can check whether the actual 113M / 823M-token run sits on the frontier you measured, or off it.

Across five budgets over a 90Γ— compute range (left), each profile traces a loss valley: at fixed compute a too-small model underfits and a too-large one is undertrained, and the minimum marches to larger N as compute grows. From the parabola vertices:

budget C (FLOPs) N_opt (non-emb) D_opt (tokens) L_opt tokens/param
6e15 2.0M 511M 3.55 261
2e16 5.7M 587M 3.29 104
6e16 10.8M 928M 3.00 86
1.8e17 25.0M 1.20B 2.94 48
5.4e17 44.3M 2.03B 2.75 46

Fitting the frontier gives N_opt ∝ C^0.69 and D_opt ∝ C^0.31 (they sum to 1, as they must). Two readings:

  • The frontier is real and tight β€” five points on a clean power law (right panel) β€” but its slope is steeper than Chinchilla's β‰ˆ 0.5: over this 0.7M–122M, 6e15–5.4e17 range the marginal FLOP buys more by growing the model than by training it longer. A fixed 1024-token context (which does not grow with the model), the small scale, short runs, and a single muP-transferred base LR all plausibly steepen it, so the exact exponent isn't universal.
  • tokens/param at the optimum falls from ~260 at the smallest budget toward ~46 at the largest β€” the more compute, the closer to Chinchilla-like ratios, as expected if the frontier only approaches its asymptotic slope at larger scale.

The practical implication at this scale: given a compute budget, under-sizing the model costs more than under-training it β€” spend the marginal FLOP on parameters before extra epochs. That is the opposite emphasis from the deliberately over-trained "inference-optimal" small models often shipped today, which leave the compute-optimal frontier on purpose to cut serving cost β€” a reminder that "optimal" depends on whether you are optimising training compute or lifetime inference cost.

Where the real 113M model lands. Overlaying the actual run (red star) β€” N = 100.7M non-embedding, D = 3.15B tokens processed, C β‰ˆ 1.9e18 FLOPs β€” the extrapolated frontier predicts a compute-optimal size N_opt β‰ˆ 117M there, so the real model is 0.86Γ— compute-optimal: slightly smaller than ideal, having seen a bit more data per parameter than the frontier prefers (31 vs ~46 tokens/param). It sat close to the measured frontier, erring marginally toward too-small / over-trained β€” reassuring for a model sized by hand.

Caveats, plainly: the top budget's minimum is a left-boundary argmin (the smaller models that would pin it fall outside the under-4-epoch data window at 5.4e17), so it may over-state N_opt there; and the real-model point is a 3.5Γ— extrapolation beyond the largest budget, at a different context length (4096) on the mixed corpus, so only its (C, N) position is comparable, not its loss. Read the 0.86Γ— as "near-optimal, slightly small," not a precise verdict. Widening the range further (more budgets, an even bigger model) would firm it up.

Architecture & attention

  • Vaswani et al., "Attention Is All You Need" (2017) β€” the Transformer. arXiv:1706.03762 - Su et al., "RoFormer: Enhanced Transformer with Rotary Position Embedding" (2021) β€” RoPE. arXiv:2104.09864 - Shazeer, "Fast Transformer Decoding: One Write-Head Is All You Need" (2019) β€” MQA. arXiv:1911.02150 - Ainslie et al., "GQA: Training Generalized Multi-Query Transformer Models…" (2023). arXiv:2305.13245 - Dao et al., "FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness" (2022). arXiv:2205.14135Β· Dao, "FlashAttention-2" (2023).arXiv:2307.08691 - Zhang & Sennrich, "Root Mean Square Layer Normalization" (2019) β€” RMSNorm. arXiv:1910.07467 - Shazeer, "GLU Variants Improve Transformer" (2020) β€” SwiGLU. arXiv:2002.05202 - Press & Wolf, "Using the Output Embedding to Improve Language Models" (2016) β€” weight tying. arXiv:1608.05859

Tokenization

  • Sennrich et al., "Neural Machine Translation of Rare Words with Subword Units" (2015) β€” BPE for NMT. arXiv:1508.07909 - Wang et al., "Neural Machine Translation with Byte-Level Subwords" (2019) β€” byte-level BPE (popularized by GPT-2). arXiv:1909.03341 - P. Gage, "A New Algorithm for Data Compression" (1994) β€” the original BPE.

Language models

  • Radford et al., "Improving Language Understanding by Generative Pre-Training" (2018) β€” GPT. OpenAI - Radford et al., "Language Models are Unsupervised Multitask Learners" (2019) β€” GPT-2. OpenAI - Brown et al., "Language Models are Few-Shot Learners" (2020) β€” GPT-3. arXiv:2005.14165 - Touvron et al., "LLaMA: Open and Efficient Foundation Language Models" (2023). arXiv:2302.13971 - Touvron et al., "Llama 2: Open Foundation and Fine-Tuned Chat Models" (2023). arXiv:2307.09288 - Meta AI, "The Llama 3 Herd of Models" (2024). arXiv:2407.21783

Training & scaling

  • Loshchilov & Hutter, "Decoupled Weight Decay Regularization" (2017) β€” AdamW. arXiv:1711.05101 - Loshchilov & Hutter, "SGDR: Stochastic Gradient Descent with Warm Restarts" (2016) β€” cosine schedule. arXiv:1608.03983 - Micikevicius et al., "Mixed Precision Training" (2017). arXiv:1710.03740 - Yang et al., "Tensor Programs V: Tuning Large Neural Networks via Zero-Shot Hyperparameter Transfer" (2022) β€” muP / muTransfer. arXiv:2203.03466 - Hoffmann et al., "Training Compute-Optimal Large Language Models" (2022) β€” Chinchilla. arXiv:2203.15556 - Muennighoff et al., "Scaling Data-Constrained Language Models" (2023) β€” how far to repeat data. arXiv:2305.16264 - Lee et al., "Deduplicating Training Data Makes Language Models Better" (2021). arXiv:2107.06499 - Broder, "On the Resemblance and Containment of Documents" (1997) β€” MinHash.

Parallelism & systems

  • Rajbhandari et al., "ZeRO: Memory Optimizations Toward Training Trillion Parameter Models" (2019). arXiv:1910.02054 - Shoeybi et al., "Megatron-LM: Training Multi-Billion Parameter Language Models Using Model Parallelism" (2019). arXiv:1909.08053 - Huang et al., "GPipe: Efficient Training of Giant Neural Networks using Pipeline Parallelism" (2018). arXiv:1811.06965 - Narayanan et al., "Efficient Large-Scale Language Model Training on GPU Clusters Using Megatron-LM" (2021) β€” 3D parallelism. arXiv:2104.04473 - Korthikanti et al., "Reducing Activation Recomputation in Large Transformer Models" (2022) β€” sequence parallelism + selective checkpointing. arXiv:2205.05198 - Zhao et al., "PyTorch FSDP: Experiences on Scaling Fully Sharded Data Parallel" (2023). arXiv:2304.11277 - Liu et al., "Ring Attention with Blockwise Transformers for Near-Infinite Context" (2023). arXiv:2310.01889 - Jacobs et al., "DeepSpeed-Ulysses: System Optimizations for Extreme Long Sequence Transformer Models" (2023). arXiv:2309.14509 - Fedus et al., "Switch Transformers: Scaling to Trillion Parameter Models with Simple and Efficient Sparsity" (2021) β€” MoE. arXiv:2101.03961 - Lepikhin et al., "GShard: Scaling Giant Models with Conditional Computation and Automatic Sharding" (2020) β€” MoE. arXiv:2006.16668

Data & datasets

Tools & learning resources

Β·datasets

(MinHash/LSH) Β·datasketch

PyTorch(SDPA, DDP)

Inspired by Andrej Karpathy's nanoGPT and the minimind project's teach-by-building philosophy. Data courtesy of Crossref, Unpaywall, arXiv, EarthArXiv/OSF, Wikipedia, and the Earthquake Insights Substack β€” thanks to the open-science infrastructure that makes a project like this possible.

Code is MIT β€” seeLICENSE.The corpus is not redistributed.data/

is git-ignored; you regenerate it by running the crawlers. Each source keeps its own terms β€” arXiv/EarthArXiv (per-paper licenses), Crossref/Unpaywall (open-access PDFs only), Wikipedia (CC BY-SA), FineWeb-Edu (ODC-By), and the Earthquake Insights Substack (author's content). Respect each source's license and robots/rate-limit etiquette; the crawlers send a polite identifyingUser-Agent

/mailto

.Model weights are a derived artifact, distributed (if at all) via a GitHub Release or the Hugging Face Hub, not committed to git.

MIT β€” see LICENSE.

── more in #artificial-intelligence 4 stories Β· sorted by recency
── more on @nanogpt-seis 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/i-trained-a-113m-par…] indexed:0 read:54min 2026-07-12 Β· β€”