diff --git a/baseline/experiments/nanogpt_muonclip_large_2026_08_30/README.md b/baseline/experiments/nanogpt_muonclip_large_2026_08_30/README.md new file mode 100644 index 0000000..6055a9e --- /dev/null +++ b/baseline/experiments/nanogpt_muonclip_large_2026_08_30/README.md @@ -0,0 +1,145 @@ +# Large MuonClip nanoGPT long run — 2026-08-30 + +This is a single-seed, MuonClip-only scaling run intended for Charles's +16-GiB Apple M2 Pro. It does **not** run AdamW or any other comparison arm. + +## Scale point + +| Quantity | Value | +|---|---:| +| Transformer blocks | 6 | +| Attention heads per block | 8 | +| Embedding width | 384 | +| Context length | 512 | +| Trainable parameters | 30,117,120 | +| Unique training tokens | 512,000,000 | +| Validation / test tokens | 4,000,000 / 4,000,000 | +| Processed training tokens | 512,000,000 | +| Tokens per parameter | 17.0 | +| Effective optimizer batch | 8,192 tokens | +| Optimizer updates | 62,500 | +| Warmup updates | 1,000 | +| Permanent WeightWatcher states | 26 | +| WeightWatcher matrices per state | 36 | + +This is near a Chinchilla-style token budget in ratio, but it is not presented +as a scaling-law measurement: there is only one model size, one optimizer, and +one seed. The purpose is to obtain a serious long trajectory without repeatedly +cycling over the same small corpus. + +The peak MuonClip learning rate remains the empirically exercised `2e-4`. +Warmup is lengthened to 1,000 updates and cosine decay spans the full fresh-data +pass, ending at `1e-5`. Weight decay is `0.1`, the RMS update scale is `0.2`, +gradient clipping is `1.0`, and QK-Clip retains the tested threshold of `100`. + +Expected M2 Pro wall time is roughly **4–6 days**, based on the measured +four-head run and the increase in block count, width, and context. The first +few evaluation rows provide a machine-specific ETA; `status` reports it. + +## Exact protocol + +The frozen YAML is [`configs/muonclip_long_mps.yaml`](configs/muonclip_long_mps.yaml). +FineWeb-Edu is streamed from pinned revision +`593b3a867298afb8ce42625a270ef20ddcad28f9`. Train, validation, and test are +document-disjoint. The test split remains untouched until the final and +validation-selected checkpoint audit. + +## Mac setup and preflight + +Use the Conda Python that already passed the earlier campaign's dependency +check: + +```bash +cd /tmp/rg_optimizers + +git switch main +git pull --ff-only origin main + +CONDA_PY="/Users/charleshmartin/opt/anaconda3/envs/ww_prod310/bin/python" + +"$CONDA_PY" -m pip install -e baseline/nanogpt_one_head + +cd baseline/experiments/nanogpt_muonclip_large_2026_08_30 + +export RG_NANOGPT_LARGE_EXPERIMENT_ROOT="/Users/charleshmartin/rg_runs/nanogpt_muonclip_large_20260830" +export PYTORCH_ENABLE_MPS_FALLBACK=1 +export PYTHONUNBUFFERED=1 + +"$CONDA_PY" scripts/run_experiment.py doctor --device auto --smoke-step +``` + +The smoke step instantiates the full 30.1M-parameter model on MPS and performs +one real MuonClip forward/backward/update before any 512M-token download. + +## Start the detached long run + +The following creates one detached `tmux` session. It prepares the larger +dataset and then starts or resumes the single MuonClip seed. No AdamW command is +present. + +```bash +tmux new-session -d -s muonclip-large \ + "/usr/bin/caffeinate -dimsu '$CONDA_PY' scripts/run_experiment.py prepare && /usr/bin/caffeinate -dimsu '$CONDA_PY' scripts/run_experiment.py run --device auto --mps-retries 20" +``` + +Detach/closing the Cloud or Terminal window does not stop a process inside +`tmux`. To watch the live terminal and detach again, use: + +```bash +tmux attach -t muonclip-large +``` + +Press `Control-b`, release both keys, then press `d`. + +## Check progress at any time + +Run these from the experiment directory with the same three exported variables +shown above: + +```bash +"$CONDA_PY" scripts/run_experiment.py status +``` + +For the last terminal output: + +```bash +tmux capture-pane -p -t muonclip-large -S -40 +``` + +For the durable combined training log: + +```bash +tail -n 40 "$RG_NANOGPT_LARGE_EXPERIMENT_ROOT/logs/train.log" +``` + +## Generate a live report without stopping training + +```bash +"$CONDA_PY" scripts/run_experiment.py report --open +``` + +The report is regenerated atomically at: + +```text +$RG_NANOGPT_LARGE_EXPERIMENT_ROOT/live_report/report.html +``` + +It includes train/validation loss, perplexity, optimizer diagnostics, +throughput, MPS memory, QK-Clip activity, and per-block curves for all six +matrix types for raw alpha, clip_xmax alpha, ERG gap, random distance, and trap +count. It uses only already-completed CSV rows and does not touch the model or +checkpoint. + +## Resume after interruption or reboot + +Re-export the variables, return to this directory, and run the same command: + +```bash +/usr/bin/caffeinate -dimsu "$CONDA_PY" scripts/run_experiment.py run \ + --device auto \ + --mps-retries 20 +``` + +The verified `checkpoint_latest.pt` includes the model, both optimizer states, +RNG state, training generator, and resume diagnostics. The launcher resumes by +default. Do not pass `--overwrite` unless intentionally discarding the run. diff --git a/baseline/experiments/nanogpt_muonclip_large_2026_08_30/configs/muonclip_long_mps.yaml b/baseline/experiments/nanogpt_muonclip_large_2026_08_30/configs/muonclip_long_mps.yaml new file mode 100644 index 0000000..f589853 --- /dev/null +++ b/baseline/experiments/nanogpt_muonclip_large_2026_08_30/configs/muonclip_long_mps.yaml @@ -0,0 +1,141 @@ +protocol: + name: nanogpt_muonclip_large_2026_08_30_long_mps + version: 1 + description: > + Single-seed, MuonClip-only long run on Apple MPS. The model has six + transformer blocks, eight attention heads, width 384, and context length + 512 (30,117,120 trainable parameters). It sees one pass over 512 million + pinned FineWeb-Edu tokens: 62,500 optimizer updates and approximately + 17.0 training tokens per parameter. Rolling finite full-state checkpoints + make the run resumable, while 26 permanent states retain per-block + WeightWatcher raw and clip_xmax diagnostics. + +dataset: + name: HuggingFaceFW/fineweb-edu + config: sample-10BT + split: train + revision: 593b3a867298afb8ce42625a270ef20ddcad28f9 + tokenizer: gpt2 + train_tokens: 512000000 + val_tokens: 4000000 + test_tokens: 4000000 + +model: + vocab_size: 50257 + block_size: 512 + n_layer: 6 + n_head: 8 + n_embd: 384 + dropout: 0.0 + bias: false + tie_weights: true + +training: + seeds: [20260830] + batch_size: 1 + grad_accum_steps: 16 + target_epochs: 1.0 + epoch_interval: 0.04 + eval_interval_steps: 500 + eval_batches: 64 + checkpoint_interval_steps: 250 + grad_clip: 1.0 + +optimizer_profiles: + # Compatibility profiles required by the shared trainer. The launcher in + # this experiment hard-codes muon_clip and never executes these three arms. + sgd_momentum: + display_name: SGD + Nesterov momentum (inactive compatibility profile) + family: sgd + learning_rate: 0.05 + min_learning_rate: 0.005 + warmup_fraction: 0.10 + lr_schedule_epochs: 1.0 + schedule: warmup_cosine + momentum: 0.90 + dampening: 0.0 + nesterov: true + weight_decay: 0.01 + + adamw: + display_name: AdamW (inactive compatibility profile) + family: adamw + learning_rate: 0.0006 + min_learning_rate: 0.00006 + warmup_fraction: 0.01 + lr_schedule_epochs: 1.0 + schedule: warmup_cosine + beta1: 0.90 + beta2: 0.95 + epsilon: 1.0e-8 + weight_decay: 0.10 + + muon: + display_name: Muon + auxiliary AdamW (inactive compatibility profile) + family: muon + matrix_learning_rate: 0.02 + matrix_min_learning_rate: 0.002 + aux_learning_rate: 0.0003 + aux_min_learning_rate: 0.00003 + warmup_fraction: 0.05 + lr_schedule_epochs: 1.0 + schedule: warmup_cosine + momentum: 0.95 + nesterov: true + newton_schulz_steps: 5 + muon_epsilon: 1.0e-7 + matrix_weight_decay: 0.01 + beta1: 0.90 + beta2: 0.95 + epsilon: 1.0e-8 + aux_weight_decay: 0.01 + + muon_clip: + display_name: MuonClip + RMS-matched updates + auxiliary AdamW + family: muon_clip + learning_rate: 0.0002 + min_learning_rate: 0.00001 + warmup_fraction: 0.016 + lr_schedule_epochs: 1.0 + schedule: warmup_cosine + momentum: 0.95 + nesterov: false + newton_schulz_steps: 5 + muon_epsilon: 1.0e-7 + weight_decay: 0.10 + update_rms_scale: 0.20 + qk_clip_threshold: 100.0 + qk_clip_balance: 0.50 + qk_diagnostics_interval: 250 + beta1: 0.90 + beta2: 0.95 + epsilon: 1.0e-8 + +evaluation: + train_probe_seed: 31001 + validation_probe_seed: 32001 + test_probe_seed: 33001 + bleu_probe_seed: 34001 + bleu_examples: 64 + bleu_prompt_tokens: 128 + bleu_continuation_tokens: 64 + bleu_batch_size: 1 + +weightwatcher: + enabled: true + ERG: true + randomize: true + strict: true + min_evals: 20 + fix_fingers: clip_xmax + max_fingers: 10 + require_raw_alpha: true + +runtime: + matmul_precision: high + allow_tf32: false + cudnn_benchmark: false + mps_fallback: true + deterministic_algorithms: false + deterministic_warn_only: true + empty_mps_cache_after_weightwatcher: true diff --git a/baseline/experiments/nanogpt_muonclip_large_2026_08_30/scripts/build_live_report.py b/baseline/experiments/nanogpt_muonclip_large_2026_08_30/scripts/build_live_report.py new file mode 100644 index 0000000..f98fd01 --- /dev/null +++ b/baseline/experiments/nanogpt_muonclip_large_2026_08_30/scripts/build_live_report.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Build a live HTML report from an incomplete or completed long run.""" + +from __future__ import annotations + +import argparse +import html +from pathlib import Path +import time + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd + + +MATRIX_TYPES = ("W_Q", "W_K", "W_V", "W_O", "W_MLP_IN", "W_MLP_OUT") + + +def read_csv(path: Path) -> pd.DataFrame: + if not path.is_file() or path.stat().st_size == 0: + return pd.DataFrame() + for attempt in range(2): + try: + return pd.read_csv(path) + except (pd.errors.EmptyDataError, pd.errors.ParserError): + if attempt: + raise + time.sleep(0.1) + return pd.DataFrame() + + +def save_training_plot(metrics: pd.DataFrame, output: Path) -> None: + frame = metrics.sort_values("step").copy() + figure, axes = plt.subplots(2, 2, figsize=(13, 8)) + axis = axes[0, 0] + axis.plot(frame["epoch"], frame["train_loss"], label="train") + axis.plot(frame["epoch"], frame["val_loss"], label="validation") + axis.set(title="Loss", xlabel="Corpus-equivalent epoch", ylabel="NLL") + axis.legend(frameon=False) + + axis = axes[0, 1] + axis.plot(frame["epoch"], frame["val_perplexity"], color="#D55E00") + axis.set(title="Validation perplexity", xlabel="Corpus-equivalent epoch") + + axis = axes[1, 0] + for column, label in ( + ("primary_lr", "learning rate"), + ("grad_norm_pre_clip", "gradient norm"), + ("update_to_weight_ratio", "update / weight"), + ): + if column in frame: + values = pd.to_numeric(frame[column], errors="coerce") + axis.plot(frame["epoch"], values, label=label) + axis.set_yscale("log") + axis.set(title="Optimization", xlabel="Corpus-equivalent epoch") + axis.legend(frameon=False) + + axis = axes[1, 1] + axis.plot(frame["epoch"], frame["tokens_per_sec"], label="tokens / sec") + memory = pd.to_numeric( + frame.get("mps_driver_allocated_mb", pd.Series(dtype=float)), + errors="coerce", + ) + if len(memory) == len(frame) and np.isfinite(memory).any(): + twin = axis.twinx() + twin.plot(frame["epoch"], memory, color="#CC79A7", label="MPS MiB") + twin.set_ylabel("MPS driver MiB") + axis.set(title="Throughput and memory", xlabel="Corpus-equivalent epoch") + axis.set_ylabel("Tokens / sec") + + for axis in axes.flat: + axis.grid(alpha=0.25) + figure.tight_layout() + figure.savefig(output, dpi=170, bbox_inches="tight") + plt.close(figure) + + +def save_spectral_plot(layers: pd.DataFrame, metric: str, output: Path) -> None: + frame = layers.copy() + frame[metric] = pd.to_numeric(frame[metric], errors="coerce") + frame["block"] = pd.to_numeric(frame["block"], errors="coerce") + frame["epoch"] = pd.to_numeric(frame["epoch"], errors="coerce") + figure, axes = plt.subplots(2, 3, figsize=(15, 8), sharex=True) + colors = plt.cm.viridis(np.linspace(0.05, 0.95, 6)) + for axis, matrix_type in zip(axes.flat, MATRIX_TYPES, strict=True): + subset = frame[frame["matrix_type"] == matrix_type] + for block, block_frame in subset.groupby("block", sort=True): + block_index = int(block) + axis.plot( + block_frame["epoch"], + block_frame[metric], + marker="o", + markersize=2.5, + linewidth=1.4, + color=colors[block_index % len(colors)], + label=f"block {block_index}", + ) + if metric in {"alpha_raw", "alpha_clip_xmax"}: + axis.axhline(2.0, color="black", linestyle="--", linewidth=1) + axis.set_title(matrix_type) + axis.grid(alpha=0.25) + axis.set_xlabel("Epoch") + axis.set_ylabel(metric) + handles, labels = axes[0, 0].get_legend_handles_labels() + if handles: + figure.legend( + handles, + labels, + loc="upper center", + bbox_to_anchor=(0.5, 0.975), + ncol=6, + frameon=False, + ) + figure.suptitle(f"{metric}: each transformer block", y=0.998) + figure.tight_layout(rect=(0.0, 0.0, 1.0, 0.91)) + figure.savefig(output, dpi=170, bbox_inches="tight") + plt.close(figure) + + +def save_qk_plot(frame: pd.DataFrame, output: Path) -> None: + data = frame.sort_values("step").copy() + figure, axes = plt.subplots(1, 2, figsize=(12, 4.5)) + axes[0].plot(data["step"], data["mean_max_logit"], label="mean max logit") + axes[0].plot(data["step"], data["max_logit"], label="maximum logit") + axes[0].axhline( + float(data["threshold"].iloc[-1]), + color="black", + linestyle="--", + label="clip threshold", + ) + axes[0].legend(frameon=False) + axes[0].set(title="QK logits", xlabel="Optimizer step") + axes[1].plot(data["step"], data["active_fraction"], label="active fraction") + axes[1].plot(data["step"], data["min_gamma"], label="minimum gamma") + axes[1].set(title="QK clipping", xlabel="Optimizer step") + axes[1].legend(frameon=False) + for axis in axes: + axis.grid(alpha=0.25) + figure.tight_layout() + figure.savefig(output, dpi=170, bbox_inches="tight") + plt.close(figure) + + +def build_report(experiment_root: Path, output: Path) -> Path: + run_dir = experiment_root / "results" / "muon_clip" / "seed_20260830" + metrics = read_csv(run_dir / "metrics.csv") + layers = read_csv(run_dir / "spectral" / "layers.csv") + qk = read_csv(run_dir / "muonclip_qk.csv") + plots = output.parent / "plots" + plots.mkdir(parents=True, exist_ok=True) + + images: list[tuple[str, str]] = [] + if not metrics.empty: + save_training_plot(metrics, plots / "training.png") + images.append(("Training and validation", "plots/training.png")) + if not layers.empty: + for metric in ( + "alpha_raw", + "alpha_clip_xmax", + "ERG_gap", + "rand_distance", + "num_traps", + ): + if metric in layers: + filename = f"{metric}.png" + save_spectral_plot(layers, metric, plots / filename) + images.append((f"WeightWatcher {metric}", f"plots/{filename}")) + if not qk.empty: + save_qk_plot(qk, plots / "qk_clip.png") + images.append(("MuonClip QK diagnostics", "plots/qk_clip.png")) + + summary = "No evaluation row has been written yet." + if not metrics.empty: + row = metrics.sort_values("step").iloc[-1] + summary = ( + f"Latest step: {int(row['step']):,}; epoch: {float(row['epoch']):.4f}; " + f"train loss: {float(row['train_loss']):.4f}; " + f"validation loss: {float(row['val_loss']):.4f}; " + f"validation perplexity: {float(row['val_perplexity']):.2f}." + ) + sections = "\n".join( + f"
Single seed 20260830; 6 blocks; 8 heads per block; width 384; context 512; 512M training tokens. This report is a non-destructive snapshot and may be regenerated while training continues.
+{html.escape(summary)}
+{sections if sections else 'No plot-ready rows are available yet.
'} +""" + output.parent.mkdir(parents=True, exist_ok=True) + temporary = output.with_suffix(".html.tmp") + temporary.write_text(document, encoding="utf-8") + temporary.replace(output) + return output + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--experiment-root", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + args = parser.parse_args() + print(build_report(args.experiment_root, args.output)) + + +if __name__ == "__main__": + main() diff --git a/baseline/experiments/nanogpt_muonclip_large_2026_08_30/scripts/run_experiment.py b/baseline/experiments/nanogpt_muonclip_large_2026_08_30/scripts/run_experiment.py new file mode 100644 index 0000000..e4c130a --- /dev/null +++ b/baseline/experiments/nanogpt_muonclip_large_2026_08_30/scripts/run_experiment.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +"""Run, resume, inspect, and report the large MuonClip Apple-MPS experiment.""" + +from __future__ import annotations + +import argparse +import csv +import json +import math +import os +from pathlib import Path +import signal +import subprocess +import sys +from typing import Any + + +SCRIPT_PATH = Path(__file__).resolve() +EXPERIMENT_DIR = SCRIPT_PATH.parents[1] +REPOSITORY_ROOT = SCRIPT_PATH.parents[4] +PACKAGE_ROOT = REPOSITORY_ROOT / "baseline" / "nanogpt_one_head" +CONFIG_PATH = EXPERIMENT_DIR / "configs" / "muonclip_long_mps.yaml" +ROOT_ENV = "RG_NANOGPT_LARGE_EXPERIMENT_ROOT" +DEFAULT_ROOT = Path("/tmp/rg-nanogpt-muonclip-large-20260830") +OPTIMIZER = "muon_clip" +SEED = 20260830 + + +def experiment_root() -> Path: + value = Path(os.environ.get(ROOT_ENV, str(DEFAULT_ROOT))).expanduser() + if not value.is_absolute(): + raise ValueError(f"{ROOT_ENV} must be an absolute path") + return value.resolve(strict=False) + + +def paths() -> dict[str, Path]: + root = experiment_root() + result = { + "root": root, + "data": root / "data", + "results": root / "results", + "logs": root / "logs", + "report": root / "live_report", + } + for path in result.values(): + path.mkdir(parents=True, exist_ok=True) + return result + + +def run_dir() -> Path: + return paths()["results"] / OPTIMIZER / f"seed_{SEED}" + + +def _install_and_load() -> tuple[dict[str, Any], Any, Any, Any]: + from rg_nanogpt_one_head.muonclip import install_muonclip_extension + + install_muonclip_extension() + from rg_nanogpt_one_head.config import load_config, max_steps, tokens_per_step + from rg_nanogpt_one_head.model import GPT, GPTConfig + + cfg = load_config(CONFIG_PATH) + return cfg, GPT, GPTConfig, (max_steps, tokens_per_step) + + +def doctor(device_request: str, smoke_step: bool) -> None: + import datasets # noqa: F401 + import matplotlib # noqa: F401 + import pandas # noqa: F401 + import torch + import weightwatcher + from rg_nanogpt_one_head.runtime import choose_device + + cfg, GPT, GPTConfig, helpers = _install_and_load() + max_steps, tokens_per_step = helpers + model = GPT(GPTConfig(**cfg["model"])) + parameter_count = model.parameter_count() + step_tokens = tokens_per_step(cfg) + total_steps = max_steps(cfg) + processed_tokens = total_steps * step_tokens + resolved = choose_device(device_request) + payload = { + "config": str(CONFIG_PATH), + "experiment_root": str(experiment_root()), + "device_request": device_request, + "resolved_device": str(resolved), + "torch_version": torch.__version__, + "weightwatcher_version": weightwatcher.__version__, + "parameter_count": parameter_count, + "transformer_blocks": int(cfg["model"]["n_layer"]), + "attention_heads_per_block": int(cfg["model"]["n_head"]), + "transformer_matrix_count": 6 * int(cfg["model"]["n_layer"]), + "train_tokens": int(cfg["dataset"]["train_tokens"]), + "processed_tokens": processed_tokens, + "tokens_per_parameter": processed_tokens / parameter_count, + "tokens_per_optimizer_step": step_tokens, + "optimizer_steps": total_steps, + "warmup_steps": round( + total_steps + * float(cfg["optimizer_profiles"][OPTIMIZER]["warmup_fraction"]) + ), + "optimizer": OPTIMIZER, + "seed": SEED, + "estimated_dataset_gib": ( + 2 + * sum( + int(cfg["dataset"][name]) + for name in ("train_tokens", "val_tokens", "test_tokens") + ) + / 1024**3 + ), + } + if smoke_step: + from rg_nanogpt_one_head.config import optimizer_profile + from rg_nanogpt_one_head.optimizers import ( + make_optimizer_handles, + optimizer_step, + zero_grad, + ) + from rg_nanogpt_one_head.runtime import synchronize + + model.to(resolved) + model.train() + handles = make_optimizer_handles( + model, + optimizer_profile(cfg, OPTIMIZER), + ) + zero_grad(handles) + x = torch.randint( + 0, + int(cfg["model"]["vocab_size"]), + ( + int(cfg["training"]["batch_size"]), + int(cfg["model"]["block_size"]), + ), + device=resolved, + ) + _, loss = model(x, x) + if loss is None: + raise RuntimeError("large-model smoke step did not return a loss") + loss.backward() + torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0, foreach=False) + optimizer_step(handles) + synchronize(resolved) + loss_value = float(loss.detach().cpu()) + if not math.isfinite(loss_value): + raise FloatingPointError("large-model smoke loss is non-finite") + payload["smoke_optimizer_step"] = "passed" + payload["smoke_loss"] = loss_value + print(json.dumps(payload, indent=2, sort_keys=True)) + + +def prepare(force: bool) -> None: + cfg, _, _, _ = _install_and_load() + from rg_nanogpt_one_head.data import prepare_fineweb_edu + + prepare_fineweb_edu(cfg, paths()["data"], force=force) + + +def run_training(device: str, mps_retries: int, overwrite: bool) -> int: + resolved = paths() + command = [ + sys.executable, + "-u", + "-m", + "rg_nanogpt_one_head.muonclip", + "--config", + str(CONFIG_PATH), + "--optimizer", + OPTIMIZER, + "--seeds", + str(SEED), + "--data-root", + str(resolved["data"]), + "--results-root", + str(resolved["results"]), + "--device", + device, + "--mps-retries", + str(int(mps_retries)), + "--fail-fast", + ] + if overwrite: + command.append("--overwrite") + + log_path = resolved["logs"] / "train.log" + print("[large-muonclip] command:", " ".join(command), flush=True) + print(f"[large-muonclip] log: {log_path}", flush=True) + with log_path.open("a", encoding="utf-8") as log: + process = subprocess.Popen( + command, + cwd=EXPERIMENT_DIR, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + try: + assert process.stdout is not None + for line in process.stdout: + print(line, end="", flush=True) + log.write(line) + log.flush() + except KeyboardInterrupt: + process.send_signal(signal.SIGINT) + process.wait() + raise + return int(process.wait()) + + +def _last_csv_row(path: Path) -> dict[str, str] | None: + if not path.is_file() or path.stat().st_size == 0: + return None + for _ in range(2): + try: + with path.open("r", newline="", encoding="utf-8") as handle: + rows = list(csv.DictReader(handle)) + return rows[-1] if rows else None + except (csv.Error, OSError): + continue + return None + + +def _number(row: dict[str, str], name: str) -> float: + try: + return float(row[name]) + except (KeyError, TypeError, ValueError): + return float("nan") + + +def status() -> None: + cfg, _, _, helpers = _install_and_load() + total_steps = helpers[0](cfg) + directory = run_dir() + print(f"RUN: {directory}") + + completion = directory / "run_complete.json" + if completion.is_file(): + payload = json.loads(completion.read_text(encoding="utf-8")) + print("STATE: COMPLETE") + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print("STATE: INCOMPLETE OR RUNNING") + + row = _last_csv_row(directory / "metrics.csv") + if row is None: + print("TRAINING: waiting for the first evaluation row") + else: + step = int(_number(row, "step")) + elapsed = _number(row, "elapsed_sec") + remaining = max(0, total_steps - step) + eta = remaining * elapsed / step if step > 0 else float("nan") + print( + "TRAINING: " + f"step={step:,}/{total_steps:,} ({100 * step / total_steps:.2f}%) " + f"epoch={_number(row, 'epoch'):.4f} " + f"train_loss={_number(row, 'train_loss'):.4f} " + f"val_loss={_number(row, 'val_loss'):.4f} " + f"val_ppl={_number(row, 'val_perplexity'):.2f} " + f"val_acc={100 * _number(row, 'val_accuracy'):.2f}% " + f"tokens_per_sec={_number(row, 'tokens_per_sec'):,.0f} " + f"eta_hours={eta / 3600:.1f}" + ) + + spectral = _last_csv_row(directory / "spectral" / "summary.csv") + if spectral is None: + print("WEIGHTWATCHER: waiting for the first permanent state") + else: + print( + "WEIGHTWATCHER: " + f"step={int(_number(spectral, 'step')):,} " + f"epoch={_number(spectral, 'epoch'):.4f} " + f"matrices={int(_number(spectral, 'n_matrices'))} " + f"alpha_raw_median={_number(spectral, 'alpha_raw_median'):.3f} " + "alpha_clip_median=" + f"{_number(spectral, 'alpha_clip_xmax_median'):.3f} " + f"ERG_gap_median={_number(spectral, 'ERG_gap_median'):.3f} " + f"num_traps_mean={_number(spectral, 'num_traps_mean'):.2f}" + ) + + checkpoint = directory / "checkpoint_latest.pt" + print( + "CHECKPOINT: " + + ( + f"present ({checkpoint.stat().st_size / 1024**2:.1f} MiB)" + if checkpoint.is_file() + else "not written yet" + ) + ) + process = subprocess.run( + ["pgrep", "-fl", "rg_nanogpt_one_head.muonclip"], + text=True, + capture_output=True, + check=False, + ) + print("PROCESS:") + print(process.stdout.strip() or "no MuonClip process found") + + +def report(open_report: bool) -> None: + output = paths()["report"] / "report.html" + command = [ + sys.executable, + str(EXPERIMENT_DIR / "scripts" / "build_live_report.py"), + "--experiment-root", + str(experiment_root()), + "--output", + str(output), + ] + subprocess.run(command, check=True) + print(f"REPORT: {output}") + if open_report: + subprocess.run(["open", str(output)], check=True) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + doctor_parser = subparsers.add_parser("doctor") + doctor_parser.add_argument("--device", default="auto") + doctor_parser.add_argument("--smoke-step", action="store_true") + + prepare_parser = subparsers.add_parser("prepare") + prepare_parser.add_argument("--force", action="store_true") + + run_parser = subparsers.add_parser("run") + run_parser.add_argument("--device", default="auto") + run_parser.add_argument("--mps-retries", type=int, default=20) + run_parser.add_argument("--overwrite", action="store_true") + + subparsers.add_parser("status") + + report_parser = subparsers.add_parser("report") + report_parser.add_argument("--open", action="store_true") + + args = parser.parse_args() + if args.command == "doctor": + doctor(args.device, args.smoke_step) + elif args.command == "prepare": + prepare(args.force) + elif args.command == "run": + if args.mps_retries < 0: + parser.error("--mps-retries must be nonnegative") + raise SystemExit( + run_training(args.device, args.mps_retries, args.overwrite) + ) + elif args.command == "status": + status() + elif args.command == "report": + report(args.open) + + +if __name__ == "__main__": + main() diff --git a/baseline/nanogpt_one_head/src/rg_nanogpt_one_head/config.py b/baseline/nanogpt_one_head/src/rg_nanogpt_one_head/config.py index 2499d20..4359ded 100644 --- a/baseline/nanogpt_one_head/src/rg_nanogpt_one_head/config.py +++ b/baseline/nanogpt_one_head/src/rg_nanogpt_one_head/config.py @@ -225,8 +225,6 @@ def validate_config(cfg: dict[str, Any]) -> None: for key in ("vocab_size", "block_size", "n_layer", "n_head", "n_embd"): if int(model[key]) < 1: raise ValueError(f"model.{key} must be positive") - if int(model["n_layer"]) != 1: - raise ValueError("this experiment is fixed to one transformer block") if int(model["n_embd"]) % int(model["n_head"]) != 0: raise ValueError("model.n_embd must be divisible by model.n_head") if not 0.0 <= float(model.get("dropout", 0.0)) < 1.0: diff --git a/baseline/nanogpt_one_head/src/rg_nanogpt_one_head/model.py b/baseline/nanogpt_one_head/src/rg_nanogpt_one_head/model.py index d40fd38..029b426 100644 --- a/baseline/nanogpt_one_head/src/rg_nanogpt_one_head/model.py +++ b/baseline/nanogpt_one_head/src/rg_nanogpt_one_head/model.py @@ -20,8 +20,8 @@ class GPTConfig: tie_weights: bool = True def __post_init__(self) -> None: - if self.n_layer != 1: - raise ValueError("the reference architecture is fixed to one block") + if self.n_layer < 1: + raise ValueError("n_layer must be positive") if self.n_head < 1: raise ValueError("n_head must be positive") if self.n_embd % self.n_head != 0: diff --git a/baseline/nanogpt_one_head/src/rg_nanogpt_one_head/spectral.py b/baseline/nanogpt_one_head/src/rg_nanogpt_one_head/spectral.py index 1260d56..856a12e 100644 --- a/baseline/nanogpt_one_head/src/rg_nanogpt_one_head/spectral.py +++ b/baseline/nanogpt_one_head/src/rg_nanogpt_one_head/spectral.py @@ -4,7 +4,7 @@ import json from pathlib import Path import random -from typing import Any +from typing import Any, Sequence import numpy as np import pandas as pd @@ -44,7 +44,7 @@ class WeightMatrixHolder(nn.Module): - """CPU-only Linear view of the six one-block transformer matrices.""" + """CPU-only Linear views of every declared transformer matrix.""" def __init__(self, model: GPT) -> None: super().__init__() @@ -173,6 +173,7 @@ def _validate_weightwatcher_frame( frame: pd.DataFrame, *, finger_policy: str | bool, + expected_matrix_names: Sequence[str] | None = None, ) -> None: """Reject incomplete results and stale pre-finger-policy caches.""" @@ -207,10 +208,26 @@ def _validate_weightwatcher_frame( + ", ".join(missing) + ". Remove the stale run directory before restarting." ) - if len(frame) != 6 or frame["matrix_name"].nunique() != 6: + expected_names = ( + tuple(str(value) for value in expected_matrix_names) + if expected_matrix_names is not None + else () + ) + expected_count = len(expected_names) if expected_names else 6 + observed_names = tuple(frame["matrix_name"].astype(str)) + inventory_matches = ( + set(observed_names) == set(expected_names) + if expected_names + else len(set(observed_names)) == expected_count + ) + if ( + len(frame) != expected_count + or frame["matrix_name"].nunique() != expected_count + or not inventory_matches + ): raise RuntimeError( - "WeightWatcher must return exactly the six declared transformer " - "matrices" + "WeightWatcher must return exactly the declared transformer " + f"matrix inventory ({expected_count} matrices)" ) numeric = [ @@ -455,6 +472,9 @@ def run_weightwatcher( device = model_device(model) synchronize(device) current_model_hash = model_state_sha256(model.state_dict()) + expected_matrix_names = tuple( + name for name, _, _, _ in transformer_matrix_items(model) + ) diagnostic_seed = int(seed) + 1_000_003 + int(step) if raw_path.is_file(): frame = pd.read_csv(raw_path) @@ -462,6 +482,7 @@ def run_weightwatcher( _validate_weightwatcher_frame( frame, finger_policy=finger_policy, + expected_matrix_names=expected_matrix_names, ) expected_identities = { "run_seed": int(seed), @@ -519,8 +540,8 @@ def run_weightwatcher( try: # WeightWatcher remains deliberately CPU/NumPy based. For TPU/XLA, - # WeightMatrixHolder materializes only the six hidden matrices on the - # host and never exposes the live accelerator model to NumPy. + # WeightMatrixHolder materializes only the declared hidden matrices on + # the host and never exposes the live accelerator model to NumPy. holder = WeightMatrixHolder(model) watcher = ww.WeightWatcher(model=holder) analysis_kwargs: dict[str, Any] = { @@ -592,6 +613,7 @@ def run_weightwatcher( _validate_weightwatcher_frame( frame, finger_policy=finger_policy, + expected_matrix_names=expected_matrix_names, ) _atomic_csv(raw_path, frame) return _record_successful_frame( diff --git a/baseline/nanogpt_one_head/tests/test_muonclip_large_20260830.py b/baseline/nanogpt_one_head/tests/test_muonclip_large_20260830.py new file mode 100644 index 0000000..aa11fb9 --- /dev/null +++ b/baseline/nanogpt_one_head/tests/test_muonclip_large_20260830.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys + +import pandas as pd +import pytest + + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +REPOSITORY_ROOT = PACKAGE_ROOT.parents[1] +EXPERIMENT_ROOT = ( + REPOSITORY_ROOT + / "baseline" + / "experiments" + / "nanogpt_muonclip_large_2026_08_30" +) +CONFIG_PATH = EXPERIMENT_ROOT / "configs" / "muonclip_long_mps.yaml" +RUNNER_PATH = EXPERIMENT_ROOT / "scripts" / "run_experiment.py" +REPORT_PATH = EXPERIMENT_ROOT / "scripts" / "build_live_report.py" + +sys.path.insert(0, str(PACKAGE_ROOT / "src")) + +from rg_nanogpt_one_head.config import epoch_step_map, max_steps, tokens_per_step +from rg_nanogpt_one_head.model import GPT, GPTConfig, transformer_matrix_items +from rg_nanogpt_one_head.muonclip import install_muonclip_extension +from rg_nanogpt_one_head.spectral import _validate_weightwatcher_frame + + +def _load_config() -> dict: + install_muonclip_extension() + from rg_nanogpt_one_head.config import load_config + + return load_config(CONFIG_PATH) + + +def test_large_muonclip_protocol_has_the_declared_scale_and_schedule() -> None: + cfg = _load_config() + assert cfg["model"] == { + "vocab_size": 50_257, + "block_size": 512, + "n_layer": 6, + "n_head": 8, + "n_embd": 384, + "dropout": 0.0, + "bias": False, + "tie_weights": True, + } + assert cfg["dataset"]["train_tokens"] == 512_000_000 + assert cfg["training"]["seeds"] == [20260830] + assert tokens_per_step(cfg) == 8_192 + assert max_steps(cfg) == 62_500 + assert len(epoch_step_map(cfg)) == 26 + profile = cfg["optimizer_profiles"]["muon_clip"] + assert profile["learning_rate"] == pytest.approx(2e-4) + assert profile["min_learning_rate"] == pytest.approx(1e-5) + assert profile["warmup_fraction"] == pytest.approx(0.016) + + +def test_gpt_and_matrix_inventory_support_multiple_blocks() -> None: + model = GPT( + GPTConfig( + vocab_size=64, + block_size=8, + n_layer=3, + n_head=4, + n_embd=32, + ) + ) + matrices = transformer_matrix_items(model) + assert len(matrices) == 18 + assert {block for _, _, block, _ in matrices} == {0, 1, 2} + assert len({name for name, _, _, _ in matrices}) == 18 + + +def test_weightwatcher_validator_accepts_full_multiblock_inventory() -> None: + names = [f"L{block:02d}_{kind}" for block in range(2) for kind in ( + "W_Q", "W_K", "W_V", "W_O", "W_MLP_IN", "W_MLP_OUT" + )] + frame = pd.DataFrame( + { + "matrix_name": names, + "alpha": 3.0, + "alpha_raw": 3.0, + "ERG_gap": 1.0, + "num_traps": 0.0, + "rand_distance": 0.2, + "finger_policy": "none", + "primary_alpha_variant": "raw", + "weightwatcher_analysis_calls": 1, + "run_seed": 1, + "diagnostic_seed": 2, + "protocol_fingerprint": "fingerprint", + "model_state_sha256": "state", + } + ) + _validate_weightwatcher_frame( + frame, + finger_policy=False, + expected_matrix_names=names, + ) + with pytest.raises(RuntimeError, match="12 matrices"): + _validate_weightwatcher_frame( + frame.iloc[:-1], + finger_policy=False, + expected_matrix_names=names, + ) + + +def test_experiment_entrypoints_are_importable() -> None: + assert (EXPERIMENT_ROOT / "README.md").is_file() + assert CONFIG_PATH.is_file() + for path in (RUNNER_PATH, REPORT_PATH): + spec = importlib.util.spec_from_file_location(path.stem, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module)