This guide is the deep technical reference for nanochat: its architecture, the GPT model internals, the training/inference pipeline, configuration, deployment of the chat server, security posture, code quality, and improvement opportunities. It is intended for engineers, researchers, and operators who need to understand, deploy, extend, or fork the codebase — not just run it.
nanochat is a single-node LLM training and inference harness. There is no distributed control plane, no database, no service mesh — it is a set of Python scripts that read and write files under one base directory, optionally launched across multiple GPUs on one machine via torchrun. The design philosophy is deliberate minimalism: a "strong baseline" codebase that is cohesive, readable, and maximally forkable, with one dial of complexity (--depth) that determines all other model hyperparameters.
Important
Everything in this guide is derived directly from the source at commit
dc54a1a. Where the code carries an explicit caveat (e.g. "not a true security sandbox", "blindly following AdamW theory for Muon"), this guide reproduces it faithfully rather than papering over it.
Table of Contents
- Executive Summary
- Architecture Overview
- The GPT Model (Internals)
- The Optimizer: MuonAdamW
- Precision: Explicit dtype & FP8
- Attention: FA3 with SDPA Fallback
- The Inference Engine & Tool Use
- Data Pipeline
- Checkpoints & the Base Directory
- Module Reference
- Requirements & Dependencies
- Configuration Reference
- Deploying the Chat Server
- Distributed Training Operations
- Security Analysis
- Code Quality & Conventions
- Testing Strategy
- Potential Improvements
- Contributing
- Quick Reference
1. Executive Summary
nanochat covers the entire LLM lifecycle in one repository:
| Concern | nanochat's answer |
|---|---|
| Scope | Tokenization → pretraining → SFT → RL → evaluation → inference → chat UI. |
| Scale target | A single GPU node (1–8 GPUs). GPT-2 capability in ~3 hours / ~$48 on 8×H100. |
| Configurability | One dial (--depth); all other hyperparameters auto-derived via scaling laws. |
| State | The filesystem. One base directory (~/.cache/nanochat) holds data, tokenizer, checkpoints, eval output, and reports. No DB, no message broker. |
| Distribution | PyTorch DDP via torchrun. Single-GPU runs use gradient accumulation. |
| Inference | A custom KV-cache Engine with batched generation and a calculator tool-use state machine. |
| Serving | A FastAPI web server (chat_web) with a multi-GPU worker pool, streaming SSE responses, and a static HTML UI. |
| License | MIT. |
The codebase is intentionally small (~14 core modules, 8 task modules, 9 scripts) and reads top-to-bottom. There are no abstraction layers you must learn — the training loop, the model, and the optimizer are each a single readable file.
2. Architecture Overview
┌───────────────────────────────────────────────────────────┐
│ scripts/ (entry points) │
│ tok_train base_train chat_sft chat_rl chat_eval ... │
└───────┬───────────────┬───────────────┬───────────────┬────┘
│ imports │ imports │ imports │
┌──────────────────▼────────────────▼────────────────▼────────────────▼─────────────────┐
│ nanochat/ (core library) │
│ │
│ common.py gpt.py optim.py engine.py tokenizer.py │
│ (device, ddp, (GPT nn.Module: (MuonAdamW, (KV-cache infer, (BPE: rustbpe │
│ dtype, dirs, attn, MLP, DistMuonAdamW) tool-use FSM) train / tiktoken │
│ flops table) scaling-law math) infer) │
│ │
│ dataloader.py dataset.py checkpoint_ core_eval.py loss_eval.py │
│ (bos-bestfit (parquet shards, manager.py (CORE / DCLM) (bits per byte) │
│ packing) HF download) (save/load) │
│ │
│ flash_attention.py fp8.py execution.py report.py ui.html / logo.svg │
│ (FA3 | SDPA) (Float8Linear) (sandbox exec) (markdown report) (web frontend) │
└──────────────────────────────────────────────────────────────────────────────────────┬─┘
│ used by │
┌──────────────────▼──────────────────┐ ┌──────────────────────────▼──┐
│ tasks/ (eval/SFT) │ │ runs/ (orchestration .sh) │
│ Task base + ARC, MMLU, GSM8K, │ │ speedrun, runcpu, │
│ HumanEval, SpellingBee, SmolTalk, │ │ miniseries, scaling_laws │
│ CustomJSON, TaskMixture/Sequence │ └──────────────────────────────┘
└───────────────────────────────────────┘
Layering. scripts/ are thin orchestrators that parse arguments and drive a loop; all reusable logic lives in nanochat/. tasks/ defines evaluation and SFT datasets behind a common Task interface. runs/ are bash scripts that chain the Python scripts into full pipelines.
Data flow. dataset.py downloads parquet shards → dataloader.py tokenizes and packs them into (B, T) batches → gpt.py forwards them → optim.py updates weights → checkpoint_manager.py persists state → engine.py loads a checkpoint and generates → chat_web.py/chat_cli.py expose it.
No hidden state. Every artifact is a file under the base directory. To reset, delete the relevant subdirectory; to resume, point a script at the saved step. This is the same "filesystem is the database" simplicity throughout.
3. The GPT Model (Internals)
The model lives entirely in nanochat/gpt.py. It is a decoder-only transformer with a set of deliberate, modern design choices (the docstring enumerates them):
- Rotary positional embeddings (RoPE), no learned position embeddings. Base theta = 100000; the cache is over-computed 10× the sequence length.
- QK-norm — queries and keys are RMS-normalized before attention, then scaled by
1.2(split between Q and K) for sharper attention. - ReLU² MLP —
c_fc → relu(x).square() → c_proj, hidden dim 4× model dim. - Untied weights for token embedding (
wte) and output head (lm_head). - No biases in linear layers; no learnable params in RMSNorm.
- Group-Query Attention (GQA) support (
n_kv_head ≤ n_head). - Sliding-window attention via a per-layer window pattern (see below).
- Pre-norm residual blocks:
x = x + attn(norm(x)),x = x + mlp(norm(x)).
Notable architectural additions
The model goes beyond a vanilla GPT with several learnable mechanisms (inspired by modded-nanoGPT and ResFormer):
| Mechanism | Parameter | What it does |
|---|---|---|
| Residual scaling | resid_lambdas (per layer) |
Scales the residual stream per layer. Init decays 1.15 → 1.05 across depth. |
| x0 blending | x0_lambdas (per layer) |
Blends the initial normalized embedding back in at each layer. Init decays 0.20 → 0.05. |
| Token smear | smear_lambda, smear_gate |
Mixes the previous token's embedding into the current position (cheap bigram-like signal), gated by a small learned network on the first 24 channels. |
| Backout | backout_lambda |
Subtracts the mid-layer (halfway) residual before the final norm, to remove low-level features before logit projection. Init 0.2. |
| Value embeddings | value_embeds (alternating layers) |
ResFormer-style value residual: per-head input-dependent gate (range 0–3) mixes a learned value embedding into v. Present on alternating layers; the last layer always included. |
Sliding-window attention
window_pattern (default SSSL) is a string tiled across layers: L = full context, S = quarter context (ceil-rounded to a 128 tile, e.g. 2048 → 768). The final layer is always L regardless of the pattern. The per-layer window sizes are precomputed in _compute_window_sizes() and passed to Flash Attention as (left, right) tuples.
Note
Under the PyTorch SDPA fallback, sliding-window attention is emulated with explicit masks and is much slower. On non-Hopper GPUs, prefer
--window-pattern Lto avoid this penalty.
Meta-device initialization (a footgun to know)
GPT.__init__ runs under torch.device("meta") — it only establishes shapes/dtypes, no real data. All actual parameter initialization happens in init_weights(). The standard build sequence is:
with torch.device("meta"):
model = GPT(config) # shapes only
model.to_empty(device=device) # allocate storage (uninitialized)
model.init_weights() # real initialization
Warning
Any computation that needs real tensor values must NOT live in
__init__. This is a common source of bugs when extending the model — put real initialization ininit_weights().
Forward pass shape
The forward computes logits as (B, T, padded_vocab_size), with the vocab padded up to a multiple of 64 for tensor-core efficiency (outputs are cropped logically). Logits are soft-capped to [-15, 15]. Loss uses cross-entropy with ignore_index=-1 (the masking convention used throughout for padding and non-target tokens).
FLOPs and scaling-parameter accounting
estimate_flops() returns FLOPs/token (forward+backward) as 6 × (matmul params) + attention FLOPs, accounting for the reduced effective sequence length of sliding-window layers. num_scaling_params() returns a breakdown (wte, value_embeds, lm_head, transformer_matrices, scalars, total) so scaling-law analysis can pick a convention. base_train uses transformer_matrices + lm_head as the "scaling params" for the cleanest scaling laws.
4. The Optimizer: MuonAdamW
nanochat/optim.py defines a combined optimizer with two flavors selected per parameter group via a kind field:
- Muon (
kind='muon') — used for the 2D matrix parameters of the transformer blocks. Muon orthogonalizes the momentum-smoothed gradient (Newton-Schulz iteration,ns_steps=5) before the update. Matrix params are grouped by shape so they can be stacked and processed together. - AdamW (
kind='adamw') — used for everything else: embeddings, the LM head, value embeddings, and the per-layer scalars (resid_lambdas,x0_lambdas, smear/backout params).
DistMuonAdamW is the distributed variant used under DDP; MuonAdamW is the single-process version. GPT.setup_optimizer() builds the param groups with all hyperparameters explicit (per-group lr, betas, eps, weight_decay), including a 1/√(model_dim/768) learning-rate scale for the AdamW groups (tuned at a 768-dim reference model).
Note
The codebase is candid that Muon's scaling rules are extrapolated from AdamW theory: "Note that these papers study AdamW, not Muon. We are blindly following AdamW theory for scaling hoping it ~works for Muon too." Treat the LR/weight-decay scaling as empirical, not proven, for Muon.
Hyperparameter auto-derivation (in base_train)
From --depth alone, base_train derives:
- Token horizon =
target_param_data_ratio × scaling_params. - Batch size ∝
D^0.383(Power Lines paper), clamped to a power of two. - LR correction η ∝
√(B/B_ref)(AdamW sqrt scaling, reused for Muon). - Weight-decay scaling to keep the
T_epoch = B/(η·λ·D)constant.
Schedules: linear LR warmup → constant → linear warmdown; Muon momentum warms 0.85→0.97 then down to 0.90; Muon weight decay follows a cosine decay to zero over training.
5. Precision: Explicit dtype & FP8
nanochat does not use torch.amp.autocast. Precision is managed explicitly through a single global COMPUTE_DTYPE (in common.py), auto-detected per hardware (_detect_compute_dtype):
| Hardware | dtype | Reason |
|---|---|---|
| CUDA SM ≥ 80 | bfloat16 |
Native bf16 tensor cores. |
| CUDA SM < 80 | float32 |
No bf16; fp16 requires a GradScaler (available via NANOCHAT_DTYPE=float16). |
| CPU / MPS | float32 |
No reduced-precision tensor cores. |
Override with NANOCHAT_DTYPE ∈ {bfloat16, float16, float32}.
How mixed precision works without autocast: master weights stay fp32 (for optimizer precision); the custom Linear layer casts weights to the activation dtype in forward. Embeddings are stored directly in COMPUTE_DTYPE to save memory (except under fp16, which needs fp32 embeddings because GradScaler can't unscale fp16 grads). fp16 training enables a torch.amp.GradScaler in the training loops (SFT supports it; RL does not).
FP8 training (fp8.py)
--fp8 swaps eligible nn.Linear layers for a custom Float8Linear (a ~150-line replacement for torchao's ~2000-line implementation). It supports only the tensorwise dynamic-scaling recipe. Mechanics:
- A single
autograd.Function(_Float8Matmul, marked@torch._dynamo.allow_in_graph) quantizes operands to FP8, callstorch._scaled_mm(cuBLAS FP8 kernel, ~2× bf16), and dequantizes. - Forward and weight use
float8_e4m3fn(more mantissa); gradients usefloat8_e5m2(wider range). - Only layers with both dims divisible by 16 and
min(in,out) ≥ 128are converted (fp8_module_filter). - Evaluation temporarily swaps FP8 layers back to bf16
Linear(disable_fp8context manager) for consistent metrics.
Caution
FP8 training requires CUDA + an H100-class GPU. On non-CUDA devices
--fp8is ignored with a warning. Therowwiserecipe is not implemented — requesting it raisesValueError: Only 'tensorwise' recipe is supported.
6. Attention: FA3 with SDPA Fallback
nanochat/flash_attention.py exports a flash_attn namespace that matches the Flash Attention 3 API exactly but transparently falls back to PyTorch SDPA. Resolution happens once at import:
- FA3 kernels are loaded only on Hopper (SM 90) GPUs, via the
kernelslibrary (varunneal/flash-attention-3). Ada (SM 89) and Blackwell (SM 100) fall back until FA3 is recompiled for them. - Even on Hopper, FA3 is used only in bf16 (FA3 supports bf16/fp8; fp16/fp32 use SDPA).
- The SDPA fallback (
_sdpa_attention) handles three cases: full-context equal-length, single-token decode (slices the KV window), and chunked/sliding-window inference (builds an explicit boolean mask). It supports GQA viaenable_gqa.
USE_FA3 and HAS_FA3 are module-level booleans the training scripts read to print warnings. A test override (_override_impl) can force 'fa3' or 'sdpa' for the fallback test.
7. The Inference Engine & Tool Use
nanochat/engine.py provides efficient generation, decoupled from tokenization (it operates purely on token-id lists).
KV cache
KVCache is built for FA3's flash_attn_with_kvcache API: tensors are (n_layers, B, T, H, D), position is tracked per batch element via an int32 cache_seqlens tensor, and FA3 updates the cache in place. It also stores prev_embedding for the token-smear mechanism during decode. prefill() copies a batch-1 prefill cache into a batch-N decode cache so the prompt is processed once and then expanded across samples.
Generation flow
Engine.generate():
- Batch-1 prefill of the prompt; take the last-position logits.
- Replicate the KV cache to
num_samplesrows. - Per-row
RowStatetracks the token sequence, a forced-token deque, and tool-block state. - Loop: sample next token per row (
sample_next_tokenwith temperature/top-k; temperature 0 = argmax), update state, run tool logic, yield(token_column, token_masks).
generate_batch() wraps this into non-streaming batched generation, returning final sequences with terminal tokens stripped. Termination tokens are <|assistant_end|> and <|bos|>.
Tool use: the calculator state machine
The chat special tokens coordinate tool use:
| Token | Role |
|---|---|
| `< | bos |
| `< | user_start |
| `< | assistant_start |
| `< | python_start |
| `< | output_start |
When the model emits <|python_end|> closing a block, the Engine decodes the captured expression and runs use_calculator(), which:
- Strips commas; for pure-math expressions, evaluates with a 3-second timeout via
eval(formula, {"__builtins__": {}}, {}). The**power operator is disallowed. - For string operations, allows only
.count(...)and blocks dangerous patterns (__,import,exec,eval,open,globals, etc.). - On success, the result tokens are force-injected wrapped in
<|output_start|>/<|output_end|>(masked as non-sampled, so they aren't trained on in RL).
Note
use_calculatoris a constrained arithmetic/counting tool, not a general Python interpreter. It is what lets a tiny model answer "how many r's in strawberry" by counting rather than guessing.
Separately: the HumanEval sandbox (execution.py)
execution.py provides execute_code() for evaluating model-generated Python (used by the HumanEval task). Each execution runs in its own process with a timeout, a 256 MB memory limit (RLIMIT_AS/DATA/STACK), captured stdout/stderr, a temp working directory, and a reliability_guard() that nulls out destructive functions (os.system, os.kill, shutil.rmtree, subprocess.Popen, etc.).
Caution
The module's own docstring is explicit: this is not a true security sandbox. Network access is not blocked,
ctypes/dynamic features can bypass restrictions, and there is no kernel-level isolation (no seccomp, containers, or VMs). It protects against accidental destructive behavior during evaluation, not against malicious adversarial code. Run evaluations on untrusted models inside a real sandbox (container/VM).
8. Data Pipeline
Dataset (dataset.py)
The pretraining corpus is ClimbMix-400B (huggingface.co/datasets/karpathy/climbmix-400b-shuffle), distributed as 6543 parquet shards (shard_00000.parquet … shard_06542.parquet), each ~250M chars / ~100 MB compressed. Stored under <base_dir>/base_data_climbmix. The last shard (6542) is always the validation split; everything else is train. Downloads use a multiprocessing Pool, write to a .tmp file then atomically rename, and retry up to 5 times with exponential backoff.
Note
Legacy support: if the ClimbMix directory is absent, the code warns about the FinewebEdu-100B → ClimbMix migration (March 4 2026) and falls back to the old
base_datadirectory. This fallback path is marked for eventual deletion.
Dataloader (dataloader.py)
tokenizing_distributed_data_loader_with_state_bos_bestfit implements BOS-aligned best-fit packing:
- Every row starts with
<|bos|>. - Documents are tokenized in batches (multithreaded) into a buffer; for each row, the largest document that fits the remaining space is placed; when nothing fits, the shortest buffered doc is cropped to fill exactly.
- Result: 100% utilization (no padding), but ~35% of tokens are discarded to cropping. The trade-off buys cleaner contexts (every token can attend back to its document's BOS).
- DDP sharding is by row group; an approximate resume state (
pq_idx,rg_idx,epoch) is yielded for checkpointing.
A pre-allocated CPU pinned buffer and a single host-to-device copy minimize transfer overhead.
Note
SFT uses a different, conversation-aware loader (
sft_data_generator_bos_bestfitinsidechat_sft.py) that packs whole conversations and pads rather than crops, masking targets to-1for padding, user prompts, and tool outputs (only assistant completions are trained on).
Tokenizer (tokenizer.py)
Two implementations behind a shared interface:
RustBPETokenizer— trains via the Rustrustbpelibrary and serves viatiktokenat inference (GPT-4 style). This is the default (get_tokenizer()).HuggingFaceTokenizer— wraps a HFtokenizersmodel, used for evaluating HF baselines.
Both expose encode, decode, encode_special, get_bos_token_id, render_conversation (produces token ids + loss mask for SFT), and render_for_completion (primes the assistant for generation during eval/RL).
9. Checkpoints & the Base Directory
get_base_dir() returns $NANOCHAT_BASE_DIR if set, else ~/.cache/nanochat (created on demand). Layout:
$NANOCHAT_BASE_DIR/
├── base_data_climbmix/ # downloaded pretraining shards (parquet)
├── tokenizer/ # trained tokenizer + token_bytes.pt
├── base_checkpoints/<tag>/ # pretrained base models (source="base")
├── chatsft_checkpoints/<tag>/ # SFT models (source="sft")
├── chatrl_checkpoints/<tag>/ # RL models (source="rl")
├── base_eval/ # CORE eval CSVs
├── eval_bundle/ # downloaded CORE eval data (eval_bundle.zip)
├── identity_conversations.jsonl # synthetic identity data for SFT
└── report/ # markdown run reports
Checkpoint format (checkpoint_manager.py)
Per step <step> in a <tag> directory:
| File | Contents |
|---|---|
model_<step>.pt |
Model state_dict (rank 0 only). |
meta_<step>.json |
Model config, user config (CLI args), val bpb, dataloader state, loop state. |
optim_<step>_rank<r>.pt |
Optimizer state, sharded per rank (each rank saves its own). |
load_model(source, ...) maps base|sft|rl to the directory, guesses the model tag (largest d<N>, else most recent), guesses the step (last), and rebuilds the model. Two compatibility shims patch old checkpoints: _patch_missing_config_keys (defaults window_pattern="L") and _patch_missing_keys (defaults resid_lambdas=1.0, x0_lambdas=0.0). The _orig_mod. prefix added by torch.compile is stripped on load.
Important
Optimizer state is per-rank-sharded. To resume distributed training you must restart with the same world size so each rank finds its
optim_*_rank<r>.ptshard.
10. Module Reference
nanochat/ core library
| Module | Responsibility | Key symbols |
|---|---|---|
common.py |
Device/DDP/dtype init, base dir, file download with lock, GPU peak-FLOPS table, logging. | compute_init, get_base_dir, COMPUTE_DTYPE, get_peak_flops, print0, download_file_with_lock |
gpt.py |
The GPT model and scaling-law math. | GPT, GPTConfig, Block, CausalSelfAttention, MLP, Linear |
optim.py |
Combined Muon + AdamW optimizers (single + distributed). | MuonAdamW, DistMuonAdamW, fused step kernels |
engine.py |
KV-cache inference + tool-use state machine. | Engine, KVCache, RowState, use_calculator, sample_next_token |
tokenizer.py |
BPE tokenizers (train via rustbpe, infer via tiktoken; HF wrapper). | RustBPETokenizer, HuggingFaceTokenizer, get_tokenizer, render_conversation |
dataloader.py |
BOS-aligned best-fit distributed pretraining loader. | tokenizing_distributed_data_loader_*_bos_bestfit |
dataset.py |
Parquet shard download/iteration. | download_single_file, parquets_iter_batched, list_parquet_files |
checkpoint_manager.py |
Save/load model, optimizer, metadata. | save_checkpoint, load_checkpoint, load_model, build_model |
core_eval.py |
CORE / DCLM evaluation (MC, schema, LM tasks). | evaluate_task, evaluate_example, prompt renderers |
loss_eval.py |
Bits-per-byte evaluation. | evaluate_bpb |
flash_attention.py |
FA3 ↔ SDPA unified attention. | flash_attn, HAS_FA3, USE_FA3 |
fp8.py |
Minimal tensorwise FP8 training. | Float8Linear, _Float8Matmul, convert_to_float8_training |
execution.py |
Sandboxed Python execution for HumanEval. | execute_code, reliability_guard, ExecutionResult |
report.py |
Assemble the markdown run report (system info, configs, metrics). | Report, get_report, estimate_cost |
tasks/ evaluation & SFT datasets
All tasks subclass Task (tasks/common.py) with eval_type (generative or categorical), num_examples, get_example, evaluate, and optionally reward (for RL). TaskMixture and TaskSequence compose tasks.
| Task | Type | Purpose |
|---|---|---|
ARC |
categorical | Multiple-choice science questions (Easy/Challenge). |
MMLU |
categorical | Broad-topic multiple choice. |
GSM8K |
generative | Grade-school math (has reward for RL). |
HumanEval |
generative | Simple Python coding (runs in the sandbox). |
SpellingBee / SimpleSpelling |
generative | Letter spelling/counting (has reward). |
SmolTalk |
— | HuggingFace conversation dataset (SFT mixture). |
CustomJSON |
— | Arbitrary JSONL conversations (e.g. identity data). |
dev/ developer utilities
gen_synthetic_data.py (generate identity/synthetic conversations, with --num/--workers/--output/--append/--save-metadata flags and an HF_TOKEN env var), repackage_data_reference.py (reference for how pretraining shards were prepared), notebooks for scaling analysis, and assets.
11. Requirements & Dependencies
From pyproject.toml (requires-python >= 3.10, torch==2.9.1):
| Dependency | Role |
|---|---|
torch==2.9.1 |
Core tensor/NN/distributed framework (CUDA 12.8 or CPU index). |
datasets |
HuggingFace datasets (SmolTalk, MMLU, GSM8K, etc.). |
tiktoken |
Fast BPE inference tokenization. |
tokenizers |
HuggingFace tokenizer backend. |
rustbpe |
Rust BPE training. |
fastapi + uvicorn |
The chat web server. |
wandb |
Experiment logging (optional; --run=dummy disables). |
kernels |
Loads the FA3 kernel on Hopper. |
psutil |
System info for the report. |
Dev group (--group dev): pytest, matplotlib, ipykernel, python-dotenv, transformers (the last required to evaluate HF baselines).
torch is resolved from mutually-exclusive cpu/gpu extras pointing at different PyTorch wheel indexes. The transitive dependency filelock is used by download_file_with_lock to coordinate concurrent downloads across ranks. jinja2 is used by core_eval.py to render eval prompts, and pyarrow to read parquet.
12. Configuration Reference
nanochat has no config files — all configuration is via CLI flags and a few environment variables. There is no config.yaml, no .env schema, no settings object.
Environment variables
| Variable | Used in | Effect |
|---|---|---|
NANOCHAT_BASE_DIR |
common.get_base_dir |
Base directory for all artifacts (default ~/.cache/nanochat). |
NANOCHAT_DTYPE |
common._detect_compute_dtype |
Force compute dtype (bfloat16/float16/float32). |
RANK, LOCAL_RANK, WORLD_SIZE |
common.get_dist_info |
Set by torchrun; control DDP. |
OMP_NUM_THREADS |
run scripts | Set to 1 to avoid CPU oversubscription. |
HF_TOKEN |
dev/gen_synthetic_data.py |
HuggingFace API token for synthetic data generation. |
WANDB_RUN |
run scripts | Default wandb run name passed to --run. |
PYTORCH_ALLOC_CONF |
training scripts | Set to expandable_segments:True to reduce fragmentation. |
Note
The training scripts set
PYTORCH_ALLOC_CONF="expandable_segments:True"programmatically at import — you don't need to export it yourself.
Model config (GPTConfig)
| Field | Default | Meaning |
|---|---|---|
sequence_len |
2048 |
Max context length. |
vocab_size |
32768 |
Vocabulary size (padded to ×64 internally). |
n_layer |
12 |
Transformer depth. |
n_head |
6 |
Query heads. |
n_kv_head |
6 |
Key/value heads (GQA when < n_head). |
n_embd |
768 |
Model dimension. |
window_pattern |
SSSL |
Sliding-window pattern (final layer forced to L). |
In practice these are computed from --depth in base_train rather than set directly.
For per-script CLI flags, see the User Guide's Command & Flag Reference and the per-script sections there.
13. Deploying the Chat Server
scripts/chat_web.py is a FastAPI app serving the model. Architecture:
WorkerPoolloads one full model replica per GPU (--num-gpus). EachWorkerholds a device, anEngine, and a tokenizer. Available workers sit in anasyncio.Queue; requests acquire a worker (waiting if all busy) and release it after streaming completes.- Lifespan: models load on startup; the server prints
Server ready at http://localhost:<port>. - CORS is wide open (
allow_origins=["*"]).
Endpoints
| Method | Path | Description |
|---|---|---|
GET |
/ |
Serves nanochat/ui.html (with the API URL rewritten to same-origin). |
GET |
/logo.svg |
The nanochat logo. |
POST |
/chat/completions |
Streaming chat (Server-Sent Events). Accepts messages, optional temperature, max_tokens, top_k. |
GET |
/health |
{status, ready, num_gpus, available_workers}. |
GET |
/stats |
Worker pool stats (total/available/busy workers, per-worker device). |
Built-in abuse limits
Enforced in validate_chat_request:
| Limit | Value |
|---|---|
| Max messages per request | 500 |
| Max characters per message | 8000 |
| Max total conversation characters | 32000 |
| Temperature range | 0.0–2.0 |
| Top-k range | 0–200 (0 = full vocab) |
| Max tokens range | 1–4096 |
Roles must be user or assistant. Streaming carefully accumulates tokens to avoid emitting partial multi-byte UTF-8 (emoji) sequences.
Recommended production hardening
Caution
Out of the box the server has no authentication, binds to
0.0.0.0, and allows all CORS origins. Before exposing it beyond a trusted network:
- Put it behind a reverse proxy (nginx/Caddy/Traefik) terminating TLS.
- Add authentication at the proxy (basic auth, OAuth, or an API gateway).
- Restrict CORS and bind to
127.0.0.1, letting the proxy handle external traffic.- Add rate limiting at the proxy in addition to the built-in per-request caps.
- Run inside a container with resource limits.
- [ ] TLS termination configured
- [ ] Authentication in front of
/chat/completions - [ ] CORS tightened to known origins
- [ ] Rate limiting at the edge
- [ ] Server bound to localhost behind the proxy
- [ ] Resource/GPU memory limits set
Multi-GPU serving scales throughput linearly with replicas (--num-gpus), since each request is handled by one worker end to end. Note that multi-GPU serving requires CUDA — CPU/MPS support only a single worker.
14. Distributed Training Operations
- Launch:
torchrun --standalone --nproc_per_node=<N> -m scripts.<name> -- <args>. The--standaloneflag runs a single-node rendezvous.--nproc_per_node= number of GPUs. - The
--separator divides torchrun args from script args and is mandatory. - Init/cleanup:
compute_init()sets seeds (42), enables TF32 matmul precision, initializes the NCCL process group, and pins each rank to its local GPU.compute_cleanup()destroys the group. Always paired around a run. - Rank-0 conventions: only rank 0 logs (
print0), saves model/meta files, and writes reports. All ranks save their own optimizer shard. - Gradient accumulation:
grad_accum_steps = total_batch_size / (device_batch_size × max_seq_len × world_size). Single-GPU runs get a large accumulation count automatically. - Determinism: full determinism is intentionally not enabled (
torch.use_deterministic_algorithmsis commented out to avoid slowdowns); global seeds are set but most code uses explicit RNG objects.
Monitoring
The training loop logs per step: loss (debiased EMA), LR multiplier, step time, tokens/sec, and MFU (model FLOPS utilization, computed against the hardcoded GPU peak-FLOPS table in common.py). With wandb enabled (--run=<name>), watch val/bpb, core_metric, train/mfu, and train/tok_per_sec.
Note
If MFU shows
0%, your GPU isn't in theget_peak_flopstable (it returnsinffor unknowns). The metric is cosmetic; training is unaffected.
Garbage-collection tuning
The training loops manually manage Python GC: after the first step they gc.collect(); gc.freeze(); gc.disable(), then gc.collect() every 5000 steps. This avoids ~500 ms GC pauses that otherwise recur during long runs.
15. Security Analysis
nanochat is research/training software, not a hardened production service. Key findings from the source:
Code execution surfaces
execution.pysandbox — explicitly not a security boundary (see §7). Network is open,ctypescan escape, no kernel isolation. Mitigation: run model-code evaluation (HumanEval) inside a container or VM with no network.use_calculatorinengine.py— useseval()but with{"__builtins__": {}}, a character allowlist, a dangerous-pattern denylist, disallowed**, a 3-second timeout, and acceptance of only arithmetic or.count(...). This is a much tighter surface than the HumanEval sandbox, but it is stilleval. Mitigation: keep the allowlist conservative; do not broaden it without review.
Web server (chat_web.py)
| Concern | Status |
|---|---|
| Authentication | None. Anyone reaching the port can use the model. |
| CORS | allow_origins=["*"] — fully open. |
| Bind address | 0.0.0.0 by default. |
| Input validation | Present (message/length/temperature/token caps). |
| Rate limiting | Only the per-request size caps; no per-client rate limit. |
| Secrets | None handled by the server. |
Mitigation: front with an authenticating TLS proxy; see §13.
Data & supply chain
- Pretraining data and the CORE eval bundle are downloaded over HTTPS from S3 and HuggingFace with retry/backoff; there is no checksum verification of downloaded shards or the eval bundle. Mitigation: if integrity matters, verify hashes out of band.
torch.loadis used to load checkpoints. Only load checkpoints you trust — pickle deserialization can execute code. nanochat loads only locally produced checkpoints by default.- The FA3 kernel is fetched at runtime from the
kernelshub (varunneal/flash-attention-3) on Hopper GPUs — a runtime code-download dependency to be aware of in locked-down environments.
Secrets
The only secret-like value is HF_TOKEN (used by dev/gen_synthetic_data.py) and optional wandb credentials (via wandb login). Neither is stored in the repo.
Important
There is no malicious code in this repository. The security notes above describe operational risks of running training/research software (untrusted code execution, an unauthenticated dev server), not vulnerabilities in nanochat itself. The codebase is candid about these limits in its own docstrings.
16. Code Quality & Conventions
Strengths.
- Radical readability. Each major component (model, optimizer, training loop, engine) is one self-contained file you can read top to bottom. Heavy inline commentary explains why, not just what (e.g. the FP8 module's comparison to torchao, the scaling-law derivations in
base_train). - Explicit over implicit. No autocast, no hidden config objects, explicit param groups, explicit dtype management. The "one dial" philosophy keeps the surface tiny.
- Honest comments. The code flags its own approximations and footguns ("blindly following AdamW theory for Muon", "this is dumb, but we need to init the rotary embeddings", "not a true security sandbox").
Conventions.
- Scripts parse argparse at module top level and run a
while/forloop; reusable logic is imported fromnanochat/. - Hyphenated CLI flags (
--device-batch-size) map to underscored attrs. print0gates console output to rank 0;DummyWandbprovides a no-op logging interface.- Masking convention:
-1is the cross-entropy ignore index for padding, prompts, and tool outputs throughout. - The
d<depth>model-tag convention threads through checkpoints and run scripts.
Rough edges (acknowledged in-code).
- Device/dtype tracking is partly hardcoded ("cuda → bf16, everything else → fp32") and the Engine inherits this assumption; the KVCache allocates eagerly. The author notes a larger refactor is warranted.
GPT.__init__running on meta device is a documented footgun.- Some logging-extraction in run scripts relies on
grep-ing training log lines (fragile to format changes).
17. Testing Strategy
Tests live in tests/ and run via pytest (config in pyproject.toml: testpaths=["tests"], slow marker for slow tests, python_files=["test_*.py"]).
| Test file | Covers |
|---|---|
tests/test_engine.py |
The inference Engine (KV-cache correctness, generation). |
tests/test_attention_fallback.py |
FA3 ↔ SDPA equivalence (using the _override_impl hook). |
The engine module also contains an inline __main__ self-test that verifies Engine.generate() matches the naive model.generate() token-for-token. Coverage is intentionally light — this is a research codebase where correctness is largely validated by end-to-end metrics (CORE, bpb) rather than exhaustive unit tests.
Tip
Run
pytest -m "not slow"for a fast check, or the full suite before submitting changes. The attention-fallback test is especially valuable when modifying the model or attention path.
18. Potential Improvements
Drawn from in-code TODOs, community issues, and the documented rough edges:
| Area | Opportunity | Source |
|---|---|---|
| Device/dtype | Refactor the hardcoded "cuda→bf16" assumption out of Engine/KVCache; allocate KV cache lazily. |
engine.py comment |
| Attention | Adopt official PyTorch FA3 builds; extend fast attention to Ada/Blackwell. | Issue #737, flash_attention.py |
| Chunked inference | Fix token-smear for chunked prefill (first token of a chunk misses its cross-chunk predecessor). | Issue #756 |
| Flash Attention | Performance/error-handling improvements to the FA module. | Issue #752 |
| MPS | Add BF16 support on Apple Silicon. | Issue #685 |
| Memory leak | base_eval HellaSwag gets progressively slower / leaks memory on small models (Mac Studio). |
Issue #427 |
| CORE parity | SQuAD task under-scores vs. reference (31% vs 37%) — investigate. | core_eval.py TODO |
| RoPE | Consider bumping the rotary base theta above 100K (more common recently). | gpt.py TODO |
| Model re-init | build_model calls init_weights() only to initialize rotary buffers — wasteful; refactor. |
checkpoint_manager.py TODO |
| Run scripts | Replace fragile grep-based log parsing with structured metric output. |
runs/*.sh |
| Legacy code | Remove the FinewebEdu→ClimbMix dataset fallback once migration is complete. | dataset.py |
| RL fp16 | SFT supports fp16 GradScaler; RL does not — add it for consistency. | README |
19. Contributing
The project's stated goal: improve the state of the art in micro models trainable end to end on budgets under $1000, optimizing for both cost and cognitive simplicity. Contributions must keep the codebase a single, cohesive, minimal, hackable "strong baseline" — no giant config objects, model factories, or if-then-else monsters.
- Principled across depth. Any change must work for all settings of
--depth, since that single dial drives every derived hyperparameter. The reference quality target is improving the time-to-GPT-2 (CORE > 0.256525). - AI disclosure policy. When submitting a PR, declare any parts with substantial LLM contribution that you did not write or do not fully understand.
- Validate with the speedrun.
runs/speedrun.shis the canonical recipe; the leaderboard (dev/LEADERBOARD.md) tracks wall-clock-to-GPT-2 on 8×H100. - License: MIT. Cite as the
@misc{nanochat}entry in the README if used in research.
Community channels: DeepWiki, GitHub Discussions, and the #nanochat Discord.
20. Quick Reference
Pipeline order
nanochat.dataset → tok_train → tok_eval → base_train → base_eval
│
▼
chat_sft → chat_eval → [chat_rl] → chat_cli / chat_web
Model sources
| Source | Directory | Produced by |
|---|---|---|
base |
base_checkpoints/ |
base_train |
sft |
chatsft_checkpoints/ |
chat_sft |
rl |
chatrl_checkpoints/ |
chat_rl |
Key files
| Path | What |
|---|---|
nanochat/gpt.py |
The model. |
nanochat/optim.py |
MuonAdamW optimizer. |
nanochat/engine.py |
Inference + tool use. |
scripts/base_train.py |
Pretraining loop + scaling-law derivation. |
scripts/chat_web.py |
FastAPI chat server. |
runs/speedrun.sh |
Reference full pipeline. |
pyproject.toml |
Dependencies, Python/torch versions. |
Critical knobs
| Knob | Where | Effect |
|---|---|---|
--depth |
training scripts | The one dial — sets model size and all derived hyperparameters. |
--device-batch-size |
training scripts | VRAM fit (reduce on OOM; results unchanged). |
--fp8 |
base_train |
Faster pretraining on H100+. |
NANOCHAT_DTYPE |
env | Force compute precision. |
NANOCHAT_BASE_DIR |
env | Relocate all artifacts. |
--num-gpus |
chat_web |
Serving throughput (replicas). |