Skip to content

Commit 7cbb948

Browse files
add pinned nanochat d12 reference baseline runner
1 parent 62ed999 commit 7cbb948

1 file changed

Lines changed: 337 additions & 0 deletions

File tree

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
"""Pinned nanochat d12 reference baseline for RG optimizer experiments.
2+
3+
The goal of this module is deliberately conservative: run Andrej Karpathy's
4+
nanochat training code at the d12 reference scale with its native tuned recipe,
5+
rather than reimplementing nanochat inside rg_optimizers.
6+
7+
The upstream checkout is pinned by commit. The only source modification made
8+
at runtime is replacing nanochat's hard-coded seed=42 with the NANOCHAT_SEED
9+
environment variable so that statistically independent baseline replicates are
10+
possible. All architecture, initialization, optimizer, learning-rate,
11+
momentum, weight-decay, data, and scaling-law logic remains upstream code.
12+
"""
13+
from __future__ import annotations
14+
15+
from dataclasses import asdict, dataclass
16+
from pathlib import Path
17+
import json
18+
import os
19+
import re
20+
import shutil
21+
import subprocess
22+
import sys
23+
from typing import Iterable
24+
25+
import pandas as pd
26+
27+
NANOCHAT_REPOSITORY = "https://github.com/karpathy/nanochat.git"
28+
# Current upstream master inspected when this baseline was authored (2026-08-07).
29+
NANOCHAT_COMMIT = "92d63d4e8bb4df75c3b71618f31ddde2378b2bcd"
30+
DEFAULT_NANOCHAT_SEEDS = (17, 29, 43)
31+
32+
33+
@dataclass(frozen=True)
34+
class NanoChatD12Config:
35+
"""Strong, research-sized nanochat reference recipe.
36+
37+
d12 is nanochat's reference/tuning scale. Width, number of heads, optimal
38+
batch size, token horizon, LR transfer, and weight-decay transfer are then
39+
derived by upstream nanochat exactly as in scripts/base_train.py.
40+
"""
41+
42+
depth: int = 12
43+
max_seq_len: int = 2048
44+
target_param_data_ratio: float = 12.0
45+
device_batch_size: int = 32
46+
total_batch_size: int = -1 # upstream auto-compute; d12 reference ~= 2**19 tokens
47+
48+
# Upstream tuned base values. nanochat applies its own batch/dmodel scaling.
49+
embedding_lr: float = 0.30
50+
unembedding_lr: float = 0.008
51+
matrix_lr: float = 0.020
52+
scalar_lr: float = 0.50
53+
weight_decay: float = 0.28
54+
55+
# Upstream schedule: linear warmup -> plateau -> long linear warmdown.
56+
warmup_steps: int = 40
57+
warmdown_ratio: float = 0.65
58+
final_lr_frac: float = 0.05
59+
60+
eval_every: int = 250
61+
eval_tokens: int = 80 * 524_288
62+
save_every: int = 250
63+
core_metric_every: int = 999_999 # final step still evaluates CORE
64+
core_metric_max_per_task: int = 500
65+
66+
# Dataset/tokenizer preparation used by nanochat's miniseries script.
67+
dataset_shards: int = 1000
68+
tokenizer_max_chars: int = 2_000_000_000
69+
vocab_size: int = 32_768
70+
71+
@property
72+
def model_dim(self) -> int:
73+
# d12*64=768, already divisible by the 128 head dimension.
74+
return self.depth * 64
75+
76+
def validate(self) -> None:
77+
if self.depth < 1:
78+
raise ValueError("depth must be positive")
79+
if self.max_seq_len < 2:
80+
raise ValueError("max_seq_len must be >=2")
81+
if self.target_param_data_ratio <= 0:
82+
raise ValueError("target_param_data_ratio must be positive")
83+
if self.device_batch_size < 1:
84+
raise ValueError("device_batch_size must be positive")
85+
if not 0 < self.warmdown_ratio <= 1:
86+
raise ValueError("warmdown_ratio must be in (0,1]")
87+
if not 0 <= self.final_lr_frac <= 1:
88+
raise ValueError("final_lr_frac must be in [0,1]")
89+
90+
91+
def _run(cmd: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> None:
92+
print("+", " ".join(cmd), flush=True)
93+
subprocess.run(cmd, cwd=cwd, env=env, check=True)
94+
95+
96+
def ensure_checkout(checkout_dir: Path, *, commit: str = NANOCHAT_COMMIT) -> Path:
97+
"""Clone nanochat if necessary and hard-pin the checkout to ``commit``."""
98+
checkout_dir = Path(checkout_dir).expanduser().resolve()
99+
if not checkout_dir.exists():
100+
checkout_dir.parent.mkdir(parents=True, exist_ok=True)
101+
_run(["git", "clone", NANOCHAT_REPOSITORY, str(checkout_dir)], cwd=checkout_dir.parent)
102+
if not (checkout_dir / ".git").is_dir():
103+
raise RuntimeError(f"{checkout_dir} exists but is not a git checkout")
104+
_run(["git", "fetch", "origin"], cwd=checkout_dir)
105+
_run(["git", "checkout", "--detach", commit], cwd=checkout_dir)
106+
head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=checkout_dir, text=True).strip()
107+
if head != commit:
108+
raise RuntimeError(f"nanochat pin failed: expected {commit}, got {head}")
109+
_install_seed_patch(checkout_dir)
110+
return checkout_dir
111+
112+
113+
def _install_seed_patch(checkout_dir: Path) -> None:
114+
"""Allow replicate seeds while leaving nanochat's default seed equal to 42."""
115+
path = checkout_dir / "nanochat" / "common.py"
116+
text = path.read_text(encoding="utf-8")
117+
if 'NANOCHAT_SEED' in text:
118+
return
119+
old = """ torch.manual_seed(42)\n if device_type == \"cuda\":\n torch.cuda.manual_seed(42)\n"""
120+
new = """ seed = int(os.environ.get(\"NANOCHAT_SEED\", \"42\"))\n torch.manual_seed(seed)\n if device_type == \"cuda\":\n torch.cuda.manual_seed(seed)\n"""
121+
if old not in text:
122+
raise RuntimeError(
123+
"Pinned nanochat common.py no longer matches the audited seed patch; "
124+
"do not silently modify an unknown upstream revision."
125+
)
126+
path.write_text(text.replace(old, new, 1), encoding="utf-8")
127+
128+
129+
def ensure_environment(checkout_dir: Path, *, gpu: bool = True) -> None:
130+
"""Create nanochat's uv environment using its own dependency lock/config."""
131+
if shutil.which("uv") is None:
132+
raise RuntimeError("uv is required. Install uv before running the nanochat baseline.")
133+
extra = "gpu" if gpu else "cpu"
134+
_run(["uv", "sync", "--extra", extra, "--group", "dev"], cwd=checkout_dir)
135+
136+
137+
def _uv_python(checkout_dir: Path) -> str:
138+
candidate = checkout_dir / ".venv" / "bin" / "python"
139+
if not candidate.is_file():
140+
raise RuntimeError("nanochat .venv is missing; run ensure_environment first")
141+
return str(candidate)
142+
143+
144+
def prepare_data(checkout_dir: Path, cache_dir: Path, config: NanoChatD12Config) -> None:
145+
"""Prepare the same dataset/tokenizer family used by nanochat miniseries runs."""
146+
config.validate()
147+
cache_dir = Path(cache_dir).expanduser().resolve()
148+
cache_dir.mkdir(parents=True, exist_ok=True)
149+
env = os.environ.copy()
150+
env["NANOCHAT_BASE_DIR"] = str(cache_dir)
151+
py = _uv_python(checkout_dir)
152+
_run([py, "-m", "nanochat.dataset", "-n", str(config.dataset_shards)], cwd=checkout_dir, env=env)
153+
_run(
154+
[
155+
py, "-m", "scripts.tok_train",
156+
f"--max-chars={config.tokenizer_max_chars}",
157+
f"--vocab-size={config.vocab_size}",
158+
],
159+
cwd=checkout_dir,
160+
env=env,
161+
)
162+
163+
164+
def training_command(
165+
checkout_dir: Path,
166+
config: NanoChatD12Config,
167+
*,
168+
seed: int,
169+
nproc_per_node: int = 8,
170+
) -> list[str]:
171+
"""Return the exact command for one pinned d12 reference replicate."""
172+
config.validate()
173+
tag = f"rg_d12_seed{seed}"
174+
args = [
175+
"-m", "scripts.base_train",
176+
f"--depth={config.depth}",
177+
f"--max-seq-len={config.max_seq_len}",
178+
f"--target-param-data-ratio={config.target_param_data_ratio}",
179+
f"--device-batch-size={config.device_batch_size}",
180+
f"--total-batch-size={config.total_batch_size}",
181+
f"--embedding-lr={config.embedding_lr}",
182+
f"--unembedding-lr={config.unembedding_lr}",
183+
f"--matrix-lr={config.matrix_lr}",
184+
f"--scalar-lr={config.scalar_lr}",
185+
f"--weight-decay={config.weight_decay}",
186+
f"--warmup-steps={config.warmup_steps}",
187+
f"--warmdown-ratio={config.warmdown_ratio}",
188+
f"--final-lr-frac={config.final_lr_frac}",
189+
f"--eval-every={config.eval_every}",
190+
f"--eval-tokens={config.eval_tokens}",
191+
f"--save-every={config.save_every}",
192+
f"--core-metric-every={config.core_metric_every}",
193+
f"--core-metric-max-per-task={config.core_metric_max_per_task}",
194+
"--sample-every=-1",
195+
"--run=dummy",
196+
f"--model-tag={tag}",
197+
]
198+
py = _uv_python(checkout_dir)
199+
if nproc_per_node > 1:
200+
torchrun = checkout_dir / ".venv" / "bin" / "torchrun"
201+
return [str(torchrun), "--standalone", f"--nproc_per_node={nproc_per_node}", *args]
202+
return [py, *args]
203+
204+
205+
def run_seed(
206+
checkout_dir: Path,
207+
cache_dir: Path,
208+
output_dir: Path,
209+
config: NanoChatD12Config,
210+
*,
211+
seed: int,
212+
nproc_per_node: int = 8,
213+
) -> Path:
214+
"""Run one replicate and tee stdout/stderr to a persistent log."""
215+
output_dir = Path(output_dir).expanduser().resolve()
216+
output_dir.mkdir(parents=True, exist_ok=True)
217+
log_path = output_dir / f"nanochat_d12_seed{seed}.log"
218+
env = os.environ.copy()
219+
env["NANOCHAT_BASE_DIR"] = str(Path(cache_dir).expanduser().resolve())
220+
env["NANOCHAT_SEED"] = str(seed)
221+
env.setdefault("OMP_NUM_THREADS", "1")
222+
cmd = training_command(checkout_dir, config, seed=seed, nproc_per_node=nproc_per_node)
223+
print("+", " ".join(cmd), flush=True)
224+
with log_path.open("w", encoding="utf-8") as log:
225+
process = subprocess.Popen(
226+
cmd,
227+
cwd=checkout_dir,
228+
env=env,
229+
stdout=subprocess.PIPE,
230+
stderr=subprocess.STDOUT,
231+
text=True,
232+
bufsize=1,
233+
)
234+
assert process.stdout is not None
235+
for line in process.stdout:
236+
print(line, end="")
237+
log.write(line)
238+
return_code = process.wait()
239+
if return_code != 0:
240+
raise subprocess.CalledProcessError(return_code, cmd)
241+
(output_dir / f"config_seed{seed}.json").write_text(
242+
json.dumps({"seed": seed, "nanochat_commit": NANOCHAT_COMMIT, **asdict(config)}, indent=2),
243+
encoding="utf-8",
244+
)
245+
return log_path
246+
247+
248+
_TRAIN_RE = re.compile(
249+
r"step\s+(\d+)/(\d+).*?loss:\s+([0-9.eE+-]+).*?lrm:\s+([0-9.eE+-]+).*?tok/sec:\s+([0-9,]+).*?total time:\s+([0-9.eE+-]+)m"
250+
)
251+
_VAL_RE = re.compile(r"Step\s+(\d+)\s+\|\s+Validation bpb:\s+([0-9.eE+-]+)")
252+
_CORE_RE = re.compile(r"Step\s+(\d+)\s+\|\s+CORE metric:\s+([0-9.eE+-]+)")
253+
254+
255+
def parse_training_log(log_path: Path, *, seed: int) -> pd.DataFrame:
256+
"""Parse nanochat's native training log into tidy step-level metrics."""
257+
rows: dict[int, dict[str, float | int]] = {}
258+
for line in Path(log_path).read_text(encoding="utf-8", errors="replace").splitlines():
259+
match = _TRAIN_RE.search(line)
260+
if match:
261+
step, total, loss, lrm, tps, minutes = match.groups()
262+
row = rows.setdefault(int(step), {"seed": seed, "step": int(step)})
263+
row.update(
264+
num_iterations=int(total),
265+
train_loss=float(loss),
266+
lr_multiplier=float(lrm),
267+
tokens_per_sec=int(tps.replace(",", "")),
268+
total_training_minutes=float(minutes),
269+
)
270+
match = _VAL_RE.search(line)
271+
if match:
272+
step, value = match.groups()
273+
rows.setdefault(int(step), {"seed": seed, "step": int(step)})["val_bpb"] = float(value)
274+
match = _CORE_RE.search(line)
275+
if match:
276+
step, value = match.groups()
277+
rows.setdefault(int(step), {"seed": seed, "step": int(step)})["core_metric"] = float(value)
278+
return pd.DataFrame(rows.values()).sort_values("step").reset_index(drop=True)
279+
280+
281+
def collect_metrics(log_paths: Iterable[tuple[int, Path]], output_path: Path | None = None) -> pd.DataFrame:
282+
frames = [parse_training_log(path, seed=seed) for seed, path in log_paths]
283+
metrics = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
284+
if output_path is not None:
285+
output_path = Path(output_path)
286+
output_path.parent.mkdir(parents=True, exist_ok=True)
287+
metrics.to_csv(output_path, index=False)
288+
return metrics
289+
290+
291+
def checkpoint_dir(cache_dir: Path, *, seed: int) -> Path:
292+
return Path(cache_dir).expanduser().resolve() / "base_checkpoints" / f"rg_d12_seed{seed}"
293+
294+
295+
def analyze_weightwatcher_checkpoints(
296+
checkout_dir: Path,
297+
cache_dir: Path,
298+
*,
299+
seed: int,
300+
output_csv: Path,
301+
) -> pd.DataFrame:
302+
"""Run WeightWatcher offline on every saved nanochat checkpoint.
303+
304+
This keeps WeightWatcher out of the timed training loop. We persist every
305+
column returned by ``analyze(ERG=True, randomize=True)`` and add seed/step,
306+
including alpha, randomized-MP trap information, and ERG metrics whenever
307+
provided by the installed WeightWatcher version.
308+
"""
309+
env_base = str(Path(cache_dir).expanduser().resolve())
310+
os.environ["NANOCHAT_BASE_DIR"] = env_base
311+
if str(checkout_dir) not in sys.path:
312+
sys.path.insert(0, str(checkout_dir))
313+
import torch
314+
import weightwatcher as ww
315+
from nanochat.checkpoint_manager import build_model, find_last_step
316+
317+
cdir = checkpoint_dir(cache_dir, seed=seed)
318+
steps = sorted(
319+
int(path.stem.split("_")[-1])
320+
for path in cdir.glob("model_*.pt")
321+
)
322+
if not steps:
323+
raise FileNotFoundError(f"No nanochat checkpoints found in {cdir}")
324+
rows = []
325+
for step in steps:
326+
model, _, _ = build_model(str(cdir), step, torch.device("cpu"), phase="eval")
327+
details = ww.WeightWatcher(model=model).analyze(ERG=True, randomize=True)
328+
details = details.copy()
329+
details.insert(0, "step", step)
330+
details.insert(0, "seed", seed)
331+
rows.append(details)
332+
del model
333+
result = pd.concat(rows, ignore_index=True)
334+
output_csv = Path(output_csv)
335+
output_csv.parent.mkdir(parents=True, exist_ok=True)
336+
result.to_csv(output_csv, index=False)
337+
return result

0 commit comments

Comments
 (0)