< back_to_lab
LAB // LANGUAGE MODELS

LLM MAKER

What does “training a language model from scratch” actually involve when the datacenter is one RTX 3090 under a desk? This is the full pipeline — corpus to tokenizer to transformer to a 4-billion-token training run — with the real loss curves, the real crashes, and a replayable trainer console.

PyTorchbyte-level BPERoPE + SwiGLUFineWeb-Edusingle GPUresumable runs
Parameters
97,536,768
GPT-2-small class, exact count asserted
Tokens trained
4.00B
single pass, ~41 tok/param
Best val loss
2.889
perplexity 18.0 on held-out docs
Hardware
1× RTX 3090
~23h GPU time, peak 7.9 GiB VRAM
00 // SIXTY SECONDS

Watch it answer, token by token

A one-minute film of the finished 97.5M model: the architecture, the real pretraining curve, then four chat exchanges rendered one token at a time with the model's actual next-token distribution on screen beside them.

LLM-DEMO.MP4 — sft_100m_16k · top-k 40 · T 0.75

Nothing in the film is mocked. A capture script loads best.pt on the 3090 and generates with the same sampler the chat app uses, recording every step — chosen token, its probability, the top-5 alternatives, distribution entropy, wall-clock milliseconds. The video is a replay of that log. Exchange 2 is the honest one: asked what photosynthesis releases, the model says water Ox led the distribution at 19.5% and sampling drew  water at 10.9%. At this scale the right answer is often present and lost to the dice.

01 // FROM SCRATCH, LITERALLY

Bytes in, predictions out

“From scratch” here means no pretrained anything: no borrowed tokenizer, no downloaded weights, no fine-tuning of somebody else's model. The pipeline starts with 26.6 GiB of raw educational web text and ends with a checkpoint that can continue a sentence. Everything in between — bytes → BPE tokens → embeddings → stacked attention → a probability over the next token — is code in this repo, small enough to read in an afternoon.

BYTES
Raw UTF-8 text. No vocabulary exists yet — “the” is just three bytes.
TOKENS
A trained BPE merges frequent byte pairs into 16,384 subwords. Text becomes uint16 IDs.
EMBEDDINGS
Each ID indexes a 768-dim learned vector; RoPE rotates in position information.
ATTENTION ×12
12 layers of causal attention + SwiGLU MLPs mix context — each token sees only its past.
NEXT TOKEN
A softmax over the vocab. The loss is how surprised the model was by the true next token.

The single-GPU realities shape everything else. A 4B-token run is ~23 hours of computeat 47,700 tokens/sec — and this run actually died twice in earlier incarnations, once to Windows sleep and once to session teardown, both silently. The fix wasn't hope: a Scheduled Task owns the process, --resume restores model + optimizer + RNG + data cursor bit-exactly, and a STOPfile pauses the run in two seconds when the GPU is needed for something else. The finished run was deliberately paused twice mid-flight; the loss curve below doesn't show a ripple. OOM management is why the global batch is 65,536 tokens built from 8-block micro-batches — the 100M model peaks at 7.9 GiB of the 3090's 24.

02 // ANATOMY OF A RUN

Six stages, one afternoon of code

Every stage is a standalone script with deterministic outputs — the same seeds reproduce the same subset, tokenizer, and packed blocks, byte for byte.

PIPELINE.SYS — corpus → checkpoint
1
CORPUS

FineWeb-Edu sample-10BT: 14 Parquet shards, 26.6 GiB, 9.67M documents. A keyed BLAKE2b hash ranks every document ID, so the 4B-token subset is deterministic — rerun the selector with the same seed and you get the identical split, and bigger budgets are supersets of smaller ones.

2
TOKENIZER

A byte-level BPE trained on a hashed sample of the corpus itself — 16,384 merges, special tokens <bos> <eos> <unk>. Byte-level means no out-of-vocabulary failures, ever. Then every document is encoded, terminated with <eos>, concatenated, and packed into 1,024-token uint16 blocks on disk.

3
MODEL INIT

Decoder-only transformer: 12 layers, d_model 768, 12 heads (64-dim each), SwiGLU MLPs (hidden 2,048), RoPE positions, RMSNorm, tied input/output embeddings — 97,536,768 parameters, and the trainer asserts that exact count so a config typo can't silently change the model.

4
TRAINING LOOP

Fused AdamW (β 0.9/0.95, weight decay 0.1), grad-clip 1.0, bf16 autocast with fp32 loss. Global batch 65,536 tokens = 8-block micro-batches × gradient accumulation. Blocks are memory-mapped and read through a seeded permutation — 1.37 s/step, ~47,700 tok/s sustained on the 3090.

5
CHECKPOINT / RESUME

Every 250 steps last.pt captures model, optimizer, scheduler, data cursor, and all four RNG states — resume is bit-exact (verified delta: 0.0). A Windows Scheduled Task owns the process so it survives session teardown, a STOP file pauses it gracefully in ~2s, and an auto-resume loop relaunches after any crash.

6
SAMPLING

After 61,036 steps the best checkpoint serves completions (top-k 40, temperature 0.75) through a tiny local Flask app. The base model continues text; a follow-up SFT pass (Dolly + SQuAD, loss masked to responses) teaches it to answer and, crucially, to stop.

03 // THE RUN, MEASURED

Real curves from a real run

Charts drawn from runs/smoke_100m_16k_4b/metrics.jsonl — the training log of the completed 4B-token run (June 2026).

LOSS.PLT — validation loss vs. steps
3.04.05.06.07.08.09.010.0015k30k45k61,036STOP/RESUMESTOP/RESUME9.843 at init (random weights)2.889ppl 18.0OPTIMIZER STEP (65,536 TOKENS EACH → 4.0B TOTAL)CROSS-ENTROPY LOSSvalidation losstrain loss (raw samples)
REAL DATAEvery point is read from the run's metrics.jsonl. Final train/val gap is −0.035 — the model never overfit; it ran out of budget, not data. The orange dashed lines are where the run was deliberately paused (STOP file) and later resumed from last.pt— the curve doesn't even flinch.
LR.PLT — schedule
02e-44e-46e-4015k30k45k61,036← 500-step warmup to peak 6.0e-4cosine decayfloor 6.0e-5 (10% of peak)OPTIMIZER STEPLEARNING RATE
COMPUTED FROM CONFIGThe exact schedule the trainer runs (verified against the logged per-step LR in metrics.jsonl). One continuous cosine over the whole budget — no warm restarts, because bolting a fresh cosine onto already-annealed weights is a different, worse regime. Peak is 6e-4, lower than the 12M model's 1e-3: bigger models want gentler learning rates.
PARAMS.PLT — capacity budget
SWIGLU MLPS12 × (gate + up + down)56.6M58.1%ATTENTION12 × (Wq Wk Wv Wo, 768²)28.3M29.0%EMBEDDINGS16,384 × 768, tied in/out12.6M12.9%RMSNORMS25 gain vectors0.019M<0.1%Σ = 97,536,768 PARAMS EXACTLY — THE TRAINER ASSERTS THIS COUNT AT STARTUP
COMPUTED FROM CONFIGDerived exactly from smoke_100m_16k.yaml(12 layers × d_model 768, SwiGLU hidden 2,048, vocab 16,384, tied embeddings). At 10M params the embedding table ate 21% of the budget; at 97.5M it's down to 12.9% — scaling up spends the new capacity on compute (MLPs + attention), not lookup.
04 // HANDS ON

TRAINER.TTY

Replay the run yourself: 61,036 optimizer steps compressed into about fifteen seconds, following the real loss curve — including both mid-run pauses. When it finishes, ask the checkpoint for a sample.

TRAINER.TTY — smoke_100m_16k_4b (replay)
CAPYOS TRAINER.TTY v1.0 — replay of runs/smoke_100m_16k_4b
model: decoder-only · 12L × d768 · RoPE · RMSNorm · SwiGLU · tied emb
params: 97,536,768 · vocab 16,384 · ctx 1,024 · device cuda:0 (RTX 3090)
data: fineweb_edu_100m_16k · 4,000,000,000-token budget · batch 65,536 tok
 
$ press RUN to replay 61,036 steps (~23h of GPU time, compressed to ~15s)
IDLE
Honesty label: this is a replay, not live training — step numbers, loss values, pause/resume events, and the final summary come from the real run's logs (time-compressed roughly 5,500×), with per-step jitter matching the raw log's scale. The SAMPLE output is a verbatim completion from the finished checkpoint. The weights themselves are public — every run in the registry below is downloadable from the Hugging Face Hub ↗, so you can reproduce these samples locally. Running them live in the browser is a roadmap item.
05 // LEDGER

Model registry & status

Four pretraining runs and two SFT passes so far. Perplexity is only comparable within the same tokenizer — the 12M and 100M models share the 16k tokenizer, so their numbers are a fair scaling comparison.

REGISTRY.DAT — every model trained
RunParamsVocabTrain tokensVal loss (ppl)Note
smoke_10m_full_200m10.1M8,192200M3.559 (35.1)the original baseline
smoke_12m_16k_4ep12.2M16,3842.88B (4 epochs)3.433 (31.0)epoch taper confirmed ~4× is the repeat ceiling
sft_12m_16k12.2M16,38410.8k pairsresp. 2.758learned to answer and stop; extractive QA works
smoke_100m_16k_4b97.5M16,3844.00B2.889 (18.0)this page's run — beat the 12M model's final loss at 52% trained
sft_100m_16k97.5M16,38410.8k pairsresp. 2.186SFT of the 100M base, same recipe as the 12M
BUILT
  • Full pipeline — shard download, deterministic subset selection, tokenizer training, block packing, pretraining, SFT, local chat app
  • 100M pretrain complete — 4B tokens, val loss 2.889, no overfit (gap −0.035)
  • Resilience infra — bit-exact resume, STOP-file pause, Scheduled-Task auto-resume loop (survived two real crashes on the 12M run)
  • SFT recipe — masked-loss instruction tuning; the model answers, stops at <eos>, and does genuine extractive QA
IN PROGRESS
  • Hugging Face release— five model repos packaged locally as a “FineWeb Educational Models” collection; upload pending
  • Evals beyond perplexity — held-out loss says nothing about factuality or instruction-following; building small task probes
NEXT
  • Live in-browser inference— replace this page's replay with the exported weights running locally in your tab
  • RAG over the SFT model— a capacity-limited model can't store facts, but it can extract them from a provided passage; retrieval is the highest-value lever
  • Context extension & distillation — 1,024 tokens is the trained ceiling; both need dedicated experiments