Download presentation/src/pretrain.py from BuildITSystems/phishing-detector-encoder: direct link, hf CLI and curl.
- Browser
- Download file 58.7 kB
-
https://huggingface.co/BuildITSystems/phishing-detector-encoder/resolve/main/presentation/src/pretrain.py
- Command line
-
hf download hf://BuildITSystems/phishing-detector-encoder/presentation/src/pretrain.py
-
curl -L -o pretrain.py https://huggingface.co/BuildITSystems/phishing-detector-encoder/resolve/main/presentation/src/pretrain.py
58.7 kB
| """Phase 1: masked-language-model pretraining of the from-scratch encoder (resumable, bf16, VRAM-logged). | |
| This is the long GPU run Nathan launches in tmux, so every choice below favours robustness: | |
| * **Data.** pretrain.parquet is tokenised ONCE into a cached uint16 token stream | |
| (data/processed/pretrain_tokens.npy, [SEP] appended after every document exactly like | |
| smoke_test's packing) plus a JSON sidecar holding the tokenizer sha1, row counts and the | |
| pretrain-val split. The LAST 1% of documents by parquet order is held out as | |
| pretrain_val_tokens.npy and is used only for the val MLM loss. The cache is reused when the | |
| sidecar's tokenizer sha1 and parquet row count match; otherwise it is rebuilt and the reason | |
| is logged. Training sequences are contiguous 512-token windows over the stream (RoBERTa | |
| FULL-SENTENCES: text crosses document boundaries, no padding, no attention mask, so SDPA | |
| takes the Flash path). Each epoch visits every window once in a permutation seeded with | |
| ``seed + epoch``; the data position is therefore (epoch, sequences consumed) and resume | |
| reproduces the order exactly. | |
| * **Optimisation = the measured smoke-test path.** ``mlm_mask`` and ``build_optimizer`` are | |
| imported from smoke_test.py (bf16 autocast, fused AdamW with biases/LayerNorm excluded from | |
| weight decay, dynamic 15% masking with 80/10/10 replacement, loss on masked positions only | |
| with the hidden states gathered before the MLM head, grad-norm clipping). Gradient | |
| accumulation is ``round(effective_batch_sequences / micro_batch)`` and the realised batch | |
| is logged (a warning when it is not exactly 256, e.g. 24 x 11 = 264). | |
| * **Schedule.** Linear warmup over ``min(config.warmup_steps, 6% of total steps)`` then cosine | |
| (or linear) decay to 10% of the peak over ``epochs x steps_per_epoch`` optimizer steps. The | |
| schedule is a pure function of the step and is stored in every checkpoint; a resume must | |
| reproduce it exactly (``--max-steps`` / ``--max-hours`` cap how far THIS invocation runs, | |
| they never change the plan). | |
| * **Checkpoints.** ``checkpoints/pretrain/<run>/step-NNNNNNN/{model.safetensors, | |
| trainer_state.pt, summary.json}`` written atomically (tmp dir + rename) every | |
| ``checkpoint_every_steps``, at every epoch end, on SIGINT/SIGTERM (saved at the next | |
| optimizer-step boundary, exit 0), when ``--max-hours`` is reached (exit 0) and when the | |
| VRAM hard cap is breached (exit 3: a controlled stop beats a mid-step OOM when the desktop | |
| spikes). ``model.safetensors`` is ``EncoderForMLM.state_dict_for_safetensors()`` (fp32 master | |
| weights; the tied decoder alias is dropped and restored by ``load_tied_state_dict``). | |
| ``trainer_state.pt`` carries optimizer, schedule, step/epoch/data cursor, every RNG state and | |
| a config snapshot; ``--resume`` refuses a different model shape or a different plan. | |
| * **Logging.** Every ``log_every_steps`` optimizer steps: loss, lr, grad norm, tokens/s, | |
| peak allocated/reserved VRAM for the interval (peak stats reset per interval), device memory | |
| in use, elapsed and ETA, to the logger and to ``metrics.csv``. Val MLM loss and masked-token | |
| accuracy every ``eval_every_steps`` on a fixed set of val windows with fixed masks. | |
| Hard rule honoured here: the TREC-06 holdout parquet is never opened by this script; the | |
| data pipeline's asserted-zero holdout-vs-pretrain overlap (data_report.md) is the tripwire. | |
| CLI:: | |
| python src/pretrain.py train [--run-name NAME] [--resume [PATH|latest]] [--epochs N] | |
| [--max-steps N] [--max-hours H] [--micro-batch B] | |
| [--grad-checkpointing] [--limit-docs N] [--checkpoint-every N] | |
| [--log-every N] [--eval-every N] [--keep-checkpoints K] [--fresh] | |
| python src/pretrain.py export --run-name NAME # final/ from the latest checkpoint | |
| python src/pretrain.py plot --run-name NAME # reports/pretrain_curves.png | |
| """ | |
| from __future__ import annotations | |
| import os | |
| os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") | |
| import argparse # noqa: E402 | |
| import csv # noqa: E402 | |
| import json # noqa: E402 | |
| import logging # noqa: E402 | |
| import math # noqa: E402 | |
| import random # noqa: E402 | |
| import re # noqa: E402 | |
| import shutil # noqa: E402 | |
| import signal # noqa: E402 | |
| import sys # noqa: E402 | |
| import time # noqa: E402 | |
| from dataclasses import asdict, dataclass # noqa: E402 | |
| from datetime import datetime # noqa: E402 | |
| from pathlib import Path # noqa: E402 | |
| from types import SimpleNamespace # noqa: E402 | |
| from typing import Any # noqa: E402 | |
| sys.path.insert(0, str(Path(__file__).parent)) | |
| import numpy as np # noqa: E402 | |
| import pyarrow.parquet as pq # noqa: E402 | |
| import torch # noqa: E402 | |
| import torch.nn.functional as F # noqa: E402 | |
| from safetensors.torch import load_file, save_file # noqa: E402 | |
| from torch import Tensor # noqa: E402 | |
| import common # noqa: E402 | |
| from model import EncoderConfig, EncoderForMLM, count_parameters # noqa: E402 | |
| from smoke_test import build_optimizer, mlm_mask # noqa: E402 (the measured code path) | |
| from tokenizer import load_tokenizer # noqa: E402 | |
| GIB = float(2**30) | |
| LOG = logging.getLogger("pretrain") | |
| VAL_FRACTION = 0.01 # last 1% of pretraining docs -> val MLM loss only | |
| VAL_WINDOWS = 64 # fixed windows (and fixed masks) scored at every eval | |
| VAL_MASK_SEED_OFFSET = 7919 # eval masks never share a generator with training | |
| ENCODE_CHUNK_DOCS = 4096 | |
| WARMUP_FRACTION = 0.06 # config comment: warmup = min(1000, 6% of total steps) | |
| RUN_LOG_MIN_STEPS = 100 # dev runs shorter than this never touch RUN_LOG.md | |
| STEP_DIR_RE = re.compile(r"^step-(\d{7})$") | |
| METRICS_COLUMNS = [ | |
| "step", "epoch", "epoch_frac", "loss", "loss_avg", "lr", "grad_norm", "tokens_per_s", | |
| "max_alloc_gb", "max_reserved_gb", "device_used_gb", "elapsed_s", "train_seconds_total", | |
| "val_mlm_loss", "val_mlm_acc", | |
| ] | |
| EXIT_OK, EXIT_CONFIG, EXIT_VRAM, EXIT_NAN = 0, 2, 3, 4 | |
| # ================================================================================ data cache | |
| class TokenCache: | |
| train: np.ndarray # 1-D uint16 token stream, [SEP] after every document | |
| val: np.ndarray # same, for the last 1% of documents | |
| meta: dict[str, Any] | |
| def _cache_paths(processed: Path, limit_docs: int | None) -> tuple[Path, Path, Path]: | |
| suffix = f".dev{limit_docs}" if limit_docs else "" | |
| return (processed / f"pretrain_tokens{suffix}.npy", | |
| processed / f"pretrain_val_tokens{suffix}.npy", | |
| processed / f"pretrain_tokens{suffix}.json") | |
| def _encode_ids(tok, texts: list[str]) -> list[list[int]]: | |
| fast = getattr(tok, "encode_batch_fast", None) | |
| encs = fast(texts, add_special_tokens=False) if fast else tok.encode_batch(texts, add_special_tokens=False) | |
| return [e.ids for e in encs] | |
| def load_or_build_token_cache(cfg: dict, limit_docs: int | None) -> TokenCache: | |
| """Reuse the cached token stream when its sidecar matches the tokenizer and the parquet, | |
| else tokenise the corpus once (~106M tokens; progress and ETA are logged per chunk).""" | |
| processed = Path(cfg["paths"]["data_processed"]) | |
| parquet = processed / "pretrain.parquet" | |
| tok_path = Path(cfg["paths"]["tokenizer"]) | |
| if not parquet.exists() or not tok_path.exists(): | |
| raise FileNotFoundError(f"need {parquet} and {tok_path}; run data_prep.py and tokenizer.py first") | |
| rows = pq.ParquetFile(parquet).metadata.num_rows | |
| n_docs = min(rows, limit_docs) if limit_docs else rows | |
| tok_sha1 = common.sha1_hex(tok_path.read_text(encoding="utf-8")) | |
| train_p, val_p, meta_p = _cache_paths(processed, limit_docs) | |
| reason = None | |
| if not (train_p.exists() and val_p.exists() and meta_p.exists()): | |
| reason = "cache files missing" | |
| else: | |
| meta = common.read_json(meta_p) | |
| if meta.get("tokenizer_sha1") != tok_sha1: | |
| reason = f"tokenizer sha1 changed ({meta.get('tokenizer_sha1', '?')[:12]} -> {tok_sha1[:12]})" | |
| elif meta.get("parquet_rows") != rows: | |
| reason = f"parquet row count changed ({meta.get('parquet_rows')} -> {rows})" | |
| elif meta.get("n_docs") != n_docs: | |
| reason = f"document count changed ({meta.get('n_docs')} -> {n_docs})" | |
| if reason is None: | |
| meta = common.read_json(meta_p) | |
| train = np.load(train_p) | |
| val = np.load(val_p) | |
| LOG.info("token cache reused: %s (%s tokens train / %s val, %d docs, tokenizer %s)", | |
| train_p.name, f"{len(train):,}", f"{len(val):,}", meta["n_docs"], tok_sha1[:12]) | |
| return TokenCache(train, val, meta) | |
| LOG.info("rebuilding token cache: %s", reason) | |
| tok = load_tokenizer(tok_path) | |
| sep_id, mask_id = tok.token_to_id("[SEP]"), tok.token_to_id("[MASK]") | |
| if sep_id is None or mask_id is None: | |
| raise RuntimeError("tokenizer lacks [SEP]/[MASK]") | |
| if tok.get_vocab_size() > np.iinfo(np.uint16).max + 1: | |
| raise RuntimeError("vocab exceeds uint16; widen the cache dtype") | |
| n_val_docs = max(1, int(n_docs * VAL_FRACTION)) | |
| split_at = n_docs - n_val_docs | |
| LOG.info("tokenising %s docs from %s in chunks of %d (val = last %d docs); ~106M tokens expected for the full corpus", | |
| f"{n_docs:,}", parquet.name, ENCODE_CHUNK_DOCS, n_val_docs) | |
| train_chunks: list[np.ndarray] = [] | |
| val_chunks: list[np.ndarray] = [] | |
| done = chars = 0 | |
| n_tok_train = n_tok_val = 0 | |
| t0 = common.timer() | |
| for batch in pq.ParquetFile(parquet).iter_batches(batch_size=ENCODE_CHUNK_DOCS, columns=["text"]): | |
| if done >= n_docs: | |
| break | |
| texts = [t or "" for t in batch.column(0).to_pylist()][: n_docs - done] | |
| chars += sum(map(len, texts)) | |
| for j, ids in enumerate(_encode_ids(tok, texts)): | |
| arr = np.asarray(ids + [sep_id], dtype=np.uint16) | |
| if done + j >= split_at: | |
| val_chunks.append(arr) | |
| n_tok_val += arr.size | |
| else: | |
| train_chunks.append(arr) | |
| n_tok_train += arr.size | |
| done += len(texts) | |
| elapsed = common.timer() - t0 | |
| eta = elapsed / done * (n_docs - done) if done else 0.0 | |
| LOG.info("tokenising: %s/%s docs (%.0f%%), %.1fM tokens, %.0fs elapsed, ETA %s", | |
| f"{done:,}", f"{n_docs:,}", 100.0 * done / n_docs, (n_tok_train + n_tok_val) / 1e6, elapsed, fmt_dur(eta)) | |
| train = np.concatenate(train_chunks) if train_chunks else np.zeros(0, np.uint16) | |
| val = np.concatenate(val_chunks) if val_chunks else np.zeros(0, np.uint16) | |
| encode_s = common.timer() - t0 | |
| meta = { | |
| "created": datetime.now().isoformat(timespec="seconds"), | |
| "parquet": str(parquet), "parquet_rows": rows, "n_docs": done, "limit_docs": limit_docs, | |
| "n_docs_train": min(done, split_at), "n_docs_val": max(0, done - split_at), | |
| "val_rule": f"last {VAL_FRACTION:.0%} of documents by parquet order", | |
| "tokenizer": str(tok_path), "tokenizer_sha1": tok_sha1, "vocab_size": tok.get_vocab_size(), | |
| "sep_id": sep_id, "mask_id": mask_id, "dtype": "uint16", | |
| "n_tokens_train": int(train.size), "n_tokens_val": int(val.size), "chars": chars, | |
| "tokens_per_char": round((train.size + val.size) / max(1, chars), 5), | |
| "encode_seconds": round(encode_s, 1), | |
| "packing": "documents concatenated with [SEP] after each; windows of max_seq_len cut from the stream", | |
| } | |
| np.save(train_p, train) | |
| np.save(val_p, val) | |
| common.write_json(meta, meta_p) | |
| LOG.info("token cache written: %s tokens train, %s val (%.4f tok/char) in %.0fs -> %s", | |
| f"{train.size:,}", f"{val.size:,}", meta["tokens_per_char"], encode_s, train_p) | |
| return TokenCache(train, val, meta) | |
| class WindowStore: | |
| """Contiguous windows over a token stream; a batch is gathered on the CPU | |
| (16 x 512 x 2 bytes) and moved to the device, which costs microseconds against a | |
| ~350 ms micro-step and keeps the 212 MB corpus out of VRAM. | |
| With ``prefix_id`` set (the [CLS] id) every window is ``[CLS]`` + ``seq_len - 1`` stream | |
| tokens, as RoBERTa prepends ``<s>`` to each packed sequence. MLM never targets [CLS] | |
| (specials are excluded from masking), but attention to and from position 0 is trained, | |
| so the classifier's pooled [CLS] state does not start fine-tuning from an untouched row. | |
| """ | |
| def __init__(self, stream: np.ndarray, seq_len: int, prefix_id: int | None = None) -> None: | |
| self.prefix_id = prefix_id | |
| self.body_len = seq_len - 1 if prefix_id is not None else seq_len | |
| self.n = int(stream.size // self.body_len) | |
| self.windows = stream[: self.n * self.body_len].reshape(self.n, self.body_len) | |
| def batch(self, idx: np.ndarray, device: torch.device) -> Tensor: | |
| w = torch.from_numpy(self.windows[idx].astype(np.int64)) | |
| if self.prefix_id is not None: | |
| w = torch.cat([torch.full((w.shape[0], 1), self.prefix_id, dtype=torch.int64), w], dim=1) | |
| return w.to(device, non_blocking=True) | |
| def epoch_permutation(seed: int, epoch: int, n: int) -> np.ndarray: | |
| return np.random.default_rng(seed + epoch).permutation(n) | |
| # ================================================================================= schedule | |
| class Schedule: | |
| kind: str # "cosine" | "linear" | |
| peak_lr: float | |
| warmup_steps: int | |
| total_steps: int | |
| floor_ratio: float = 0.1 # decay target as a fraction of peak | |
| def from_config(cls, pcfg: dict, total_steps: int) -> "Schedule": | |
| name = str(pcfg.get("schedule", "cosine_to_10pct")).lower() | |
| kind = "linear" if name.startswith("linear") else "cosine" | |
| m = re.search(r"to_(\d+)pct", name) | |
| floor = int(m.group(1)) / 100.0 if m else 0.1 | |
| frac = float(pcfg.get("warmup_fraction", WARMUP_FRACTION)) | |
| warmup = min(int(pcfg["warmup_steps"]), max(1, round(frac * total_steps))) | |
| return cls(kind, float(pcfg["lr"]), warmup, int(total_steps), floor) | |
| def lr_at(self, step: int) -> float: | |
| """LR for optimizer step ``step`` (1-based). Past ``total_steps`` the floor holds.""" | |
| if step <= self.warmup_steps: | |
| return self.peak_lr * step / self.warmup_steps | |
| progress = min(1.0, (step - self.warmup_steps) / max(1, self.total_steps - self.warmup_steps)) | |
| decay = 0.5 * (1.0 + math.cos(math.pi * progress)) if self.kind == "cosine" else 1.0 - progress | |
| return self.peak_lr * (self.floor_ratio + (1.0 - self.floor_ratio) * decay) | |
| # ============================================================================ run bookkeeping | |
| class TrainState: | |
| step: int = 0 # optimizer steps completed | |
| epoch: int = 0 # 0-based epoch currently being trained | |
| position: int = 0 # sequences consumed in the current epoch | |
| tokens_seen: int = 0 | |
| train_seconds_total: float = 0.0 # wall time inside the training loop, summed over resumes | |
| peak_reserved_gb: float = 0.0 | |
| peak_allocated_gb: float = 0.0 | |
| last_loss: float | None = None | |
| last_val_loss: float | None = None | |
| last_val_acc: float | None = None | |
| invocations: int = 0 | |
| class StopSignal: | |
| """SIGINT/SIGTERM set a flag; the loop saves at the next optimizer-step boundary.""" | |
| def __init__(self) -> None: | |
| self.name: str | None = None | |
| def install(self) -> None: | |
| for s in (signal.SIGINT, signal.SIGTERM): | |
| signal.signal(s, self._handle) | |
| def _handle(self, signum: int, frame: Any) -> None: | |
| self.name = signal.Signals(signum).name | |
| sys.stderr.write(f"\n[pretrain] {self.name} received: saving a checkpoint at the next optimizer step, " | |
| "then exiting 0 (kill -9 aborts without saving)\n") | |
| def fmt_dur(seconds: float) -> str: | |
| seconds = max(0.0, float(seconds)) | |
| h, rem = divmod(int(seconds), 3600) | |
| m, s = divmod(rem, 60) | |
| return f"{h}h {m:02d}m {s:02d}s" if h else f"{m}m {s:02d}s" | |
| def list_step_dirs(run_dir: Path) -> list[Path]: | |
| if not run_dir.exists(): | |
| return [] | |
| dirs = [p for p in run_dir.iterdir() if p.is_dir() and STEP_DIR_RE.match(p.name)] | |
| return sorted(dirs, key=lambda p: int(STEP_DIR_RE.match(p.name).group(1))) | |
| def latest_step_dir(run_dir: Path) -> Path | None: | |
| dirs = list_step_dirs(run_dir) | |
| return dirs[-1] if dirs else None | |
| def rng_states(mask_gen: torch.Generator) -> dict[str, Any]: | |
| return { | |
| "python": random.getstate(), | |
| "numpy": np.random.get_state(), | |
| "torch_cpu": torch.get_rng_state(), | |
| "torch_cuda": torch.cuda.get_rng_state_all(), | |
| "mask_gen": mask_gen.get_state(), | |
| } | |
| def restore_rng(states: dict[str, Any], mask_gen: torch.Generator) -> None: | |
| random.setstate(states["python"]) | |
| np.random.set_state(states["numpy"]) | |
| torch.set_rng_state(states["torch_cpu"]) | |
| torch.cuda.set_rng_state_all(states["torch_cuda"]) | |
| mask_gen.set_state(states["mask_gen"]) | |
| def config_snapshot(mcfg: EncoderConfig, pcfg: dict, seed: int, micro_batch: int, accum: int, | |
| sched: Schedule, steps_per_epoch: int, epochs: int, cache: TokenCache, | |
| n_windows: int, n_val_windows: int) -> dict[str, Any]: | |
| model = mcfg.to_dict() | |
| grad_ckpt = model.pop("grad_checkpointing") | |
| return { | |
| "model": model, | |
| "grad_checkpointing": grad_ckpt, | |
| "pretrain": {k: pcfg[k] for k in ("lr", "betas", "eps", "weight_decay", "grad_clip", | |
| "mlm_probability", "mask_replace_probs", "schedule", | |
| "effective_batch_sequences")}, | |
| "seed": seed, | |
| "micro_batch": micro_batch, | |
| "grad_accumulation": accum, | |
| "effective_batch": micro_batch * accum, | |
| "schedule": asdict(sched), | |
| "steps_per_epoch": steps_per_epoch, | |
| "epochs": epochs, | |
| "total_steps": sched.total_steps, | |
| "data": {"n_windows": n_windows, "n_val_windows": n_val_windows, | |
| "tokenizer_sha1": cache.meta["tokenizer_sha1"], "n_docs": cache.meta["n_docs"], | |
| "limit_docs": cache.meta.get("limit_docs"), | |
| "window": f"[CLS] + {mcfg.max_seq_len - 1} stream tokens"}, | |
| } | |
| def check_resume_compatible(saved: dict[str, Any], current: dict[str, Any]) -> None: | |
| """Refuse to resume into a different model shape or a different plan; micro-batch and | |
| gradient checkpointing may change (the effective batch may not, so the data cursor and | |
| the schedule stay valid).""" | |
| if saved["model"] != current["model"]: | |
| diff = {k: (saved["model"].get(k), current["model"].get(k)) | |
| for k in set(saved["model"]) | set(current["model"]) if saved["model"].get(k) != current["model"].get(k)} | |
| raise RuntimeError(f"refusing to resume: model config differs (saved, current) = {diff}") | |
| for key in ("pretrain", "seed", "effective_batch", "schedule", "steps_per_epoch", "epochs", "total_steps", "data"): | |
| if saved.get(key) != current.get(key): | |
| raise RuntimeError(f"refusing to resume: '{key}' differs from the checkpoint " | |
| f"(saved={saved.get(key)!r}, current={current.get(key)!r}); " | |
| "a resumed run must continue the same schedule and data order") | |
| for key in ("micro_batch", "grad_accumulation", "grad_checkpointing"): | |
| if saved.get(key) != current.get(key): | |
| LOG.warning("resume: %s changed %r -> %r (allowed; effective batch unchanged)", key, saved.get(key), current.get(key)) | |
| # ============================================================================== checkpoints | |
| def save_checkpoint(run_dir: Path, model: EncoderForMLM, opt: torch.optim.Optimizer, sched: Schedule, | |
| state: TrainState, snapshot: dict[str, Any], mask_gen: torch.Generator, | |
| metrics_tail: list[dict[str, Any]], keep: int, reason: str) -> Path: | |
| """Atomic: everything lands in ``step-NNNNNNN.tmp`` and is renamed at the end, so a kill | |
| during the 4 GB write never leaves a half-written directory that ``--resume latest`` would | |
| pick up. Older step dirs beyond ``keep`` are pruned afterwards.""" | |
| t0 = common.timer() | |
| final = run_dir / f"step-{state.step:07d}" | |
| tmp = run_dir / f"{final.name}.tmp" | |
| if tmp.exists(): | |
| shutil.rmtree(tmp) | |
| tmp.mkdir(parents=True) | |
| sd = {k: v.detach().to("cpu", copy=True).contiguous() for k, v in model.state_dict_for_safetensors().items()} | |
| save_file(sd, str(tmp / "model.safetensors"), | |
| metadata={"format": "pt", "step": str(state.step), "config": json.dumps(model.config.to_dict())}) | |
| del sd | |
| summary = { | |
| "step": state.step, "epoch": state.epoch, "position": state.position, "tokens_seen": state.tokens_seen, | |
| "train_seconds_total": round(state.train_seconds_total, 1), | |
| "peak_reserved_gb": round(state.peak_reserved_gb, 3), "peak_allocated_gb": round(state.peak_allocated_gb, 3), | |
| "last_loss": state.last_loss, "last_val_loss": state.last_val_loss, "last_val_acc": state.last_val_acc, | |
| "lr": sched.lr_at(max(1, state.step)), "reason": reason, "saved_at": datetime.now().isoformat(timespec="seconds"), | |
| "config": snapshot, | |
| } | |
| torch.save({ | |
| "optimizer": opt.state_dict(), | |
| "scheduler": asdict(sched), | |
| "step": state.step, | |
| "epoch": state.epoch, | |
| "micro_step_in_epoch": (state.step % max(1, snapshot["steps_per_epoch"])) * snapshot["grad_accumulation"], | |
| "data_cursor": {"seed": snapshot["seed"], "epoch": state.epoch, "position": state.position, | |
| "n_windows": snapshot["data"]["n_windows"], "rule": "numpy default_rng(seed + epoch).permutation(n_windows)"}, | |
| "rng": rng_states(mask_gen), | |
| "config": snapshot, | |
| "state": asdict(state), | |
| "metrics_tail": metrics_tail[-50:], | |
| }, tmp / "trainer_state.pt") | |
| common.write_json(summary, tmp / "summary.json") | |
| if final.exists(): | |
| shutil.rmtree(final) | |
| tmp.rename(final) | |
| pruned = [] | |
| for old in list_step_dirs(run_dir)[:-keep] if keep > 0 else []: | |
| shutil.rmtree(old) | |
| pruned.append(old.name) | |
| LOG.info("checkpoint saved: %s (%s) in %.1fs%s", final, reason, common.timer() - t0, | |
| f"; pruned {pruned}" if pruned else "") | |
| return final | |
| def load_checkpoint_into(step_dir: Path, model: EncoderForMLM, opt: torch.optim.Optimizer, | |
| mask_gen: torch.Generator, device: torch.device) -> tuple[dict[str, Any], TrainState]: | |
| t0 = common.timer() | |
| weights = load_file(str(step_dir / "model.safetensors"), device=str(device)) | |
| model.load_tied_state_dict(weights, strict=True) | |
| del weights | |
| saved = torch.load(step_dir / "trainer_state.pt", map_location="cpu", weights_only=False) | |
| opt.load_state_dict(saved["optimizer"]) | |
| restore_rng(saved["rng"], mask_gen) | |
| state = TrainState(**saved["state"]) | |
| LOG.info("resumed from %s: step %d, epoch %d, position %d sequences, %.1fs of training so far (load took %.1fs)", | |
| step_dir, state.step, state.epoch, state.position, state.train_seconds_total, common.timer() - t0) | |
| return saved, state | |
| # ================================================================================= metrics | |
| class MetricsCSV: | |
| def __init__(self, path: Path) -> None: | |
| self.path = path | |
| def truncate_after(self, step: int) -> None: | |
| """Drop rows past the resumed step so a run killed between checkpoints leaves no | |
| duplicate or orphan steps in the record.""" | |
| if not self.path.exists(): | |
| return | |
| with self.path.open("r", encoding="utf-8", newline="") as fh: | |
| rows = list(csv.DictReader(fh)) | |
| keep = [r for r in rows if r.get("step", "").isdigit() and int(r["step"]) <= step] | |
| if len(keep) != len(rows): | |
| LOG.warning("metrics.csv: dropping %d rows past step %d from an earlier, unsaved stretch", len(rows) - len(keep), step) | |
| self._write_all(keep) | |
| def _write_all(self, rows: list[dict[str, Any]]) -> None: | |
| with self.path.open("w", encoding="utf-8", newline="") as fh: | |
| w = csv.DictWriter(fh, fieldnames=METRICS_COLUMNS, extrasaction="ignore") | |
| w.writeheader() | |
| w.writerows(rows) | |
| def append(self, row: dict[str, Any]) -> None: | |
| new = not self.path.exists() or self.path.stat().st_size == 0 | |
| with self.path.open("a", encoding="utf-8", newline="") as fh: | |
| w = csv.DictWriter(fh, fieldnames=METRICS_COLUMNS, extrasaction="ignore") | |
| if new: | |
| w.writeheader() | |
| w.writerow({k: ("" if row.get(k) is None else row[k]) for k in METRICS_COLUMNS}) | |
| def read(self) -> list[dict[str, str]]: | |
| if not self.path.exists(): | |
| return [] | |
| with self.path.open("r", encoding="utf-8", newline="") as fh: | |
| return list(csv.DictReader(fh)) | |
| # ================================================================================== eval | |
| def evaluate_mlm(model: EncoderForMLM, val_store: WindowStore, val_idx: np.ndarray, micro_batch: int, | |
| device: torch.device, mask_seed: int, pool_like: Any, vocab: int, p_mask: float, | |
| replace_probs: list[float]) -> tuple[float, float]: | |
| """Mean MLM loss and masked-token accuracy on fixed val windows with fixed masks (the | |
| generator is re-seeded every call, so successive evals score identical inputs).""" | |
| was_training = model.training | |
| model.eval() | |
| gen = torch.Generator(device=device).manual_seed(mask_seed) | |
| loss_sum = torch.zeros((), device=device, dtype=torch.float64) | |
| correct = torch.zeros((), device=device, dtype=torch.int64) | |
| n = 0 | |
| for i in range(0, len(val_idx), micro_batch): | |
| ids = val_store.batch(val_idx[i:i + micro_batch], device) | |
| inputs, labels, pos = mlm_mask(ids, pool_like, vocab, p_mask, replace_probs, gen) | |
| with torch.autocast(device_type="cuda", dtype=torch.bfloat16): | |
| logits = model(inputs, attention_mask=None, masked_positions=pos) | |
| loss_sum += F.cross_entropy(logits.float(), labels, reduction="sum").double() | |
| correct += (logits.argmax(dim=-1) == labels).sum() | |
| n += int(labels.numel()) | |
| if was_training: | |
| model.train() | |
| return float(loss_sum) / max(1, n), float(correct) / max(1, n) | |
| # ============================================================================== final / log | |
| def write_final(run_dir: Path, step_dir: Path, cfg: dict, status: str) -> Path: | |
| """``final/`` per the artifact contract: model.safetensors (copied from the chosen step | |
| dir), config.json with the model config + tokenizer pointer + training summary, and a | |
| copy of tokenizer.json so the directory is self-contained for finetune.py.""" | |
| final = run_dir / "final" | |
| final.mkdir(parents=True, exist_ok=True) | |
| shutil.copy2(step_dir / "model.safetensors", final / "model.safetensors") | |
| tok_path = Path(cfg["paths"]["tokenizer"]) | |
| if tok_path.exists(): | |
| shutil.copy2(tok_path, final / "tokenizer.json") | |
| summary_p = step_dir / "summary.json" | |
| if summary_p.exists(): | |
| s = common.read_json(summary_p) | |
| else: | |
| ts = torch.load(step_dir / "trainer_state.pt", map_location="cpu", weights_only=False) | |
| s = {**ts["state"], "config": ts["config"], "lr": None, "reason": "trainer_state.pt"} | |
| snap = s["config"] | |
| run_summary_p = run_dir / "run_summary.json" | |
| run_summary = common.read_json(run_summary_p) if run_summary_p.exists() else {} | |
| training = { | |
| "run_name": run_dir.name, "status": status, "source_checkpoint": step_dir.name, | |
| "steps": s["step"], "total_steps_planned": snap["total_steps"], "steps_per_epoch": snap["steps_per_epoch"], | |
| "epochs_planned": snap["epochs"], "epochs_completed": round(s["step"] / max(1, snap["steps_per_epoch"]), 3), | |
| "tokens_seen": s["tokens_seen"], "total_training_seconds": s["train_seconds_total"], | |
| "total_training_time": fmt_dur(s["train_seconds_total"]), | |
| "final_loss": s.get("last_loss"), "val_loss": s.get("last_val_loss"), "val_masked_acc": s.get("last_val_acc"), | |
| "peak_vram_reserved_gb": s.get("peak_reserved_gb"), "peak_vram_allocated_gb": s.get("peak_allocated_gb"), | |
| "micro_batch": snap["micro_batch"], "grad_accumulation": snap["grad_accumulation"], "effective_batch": snap["effective_batch"], | |
| "precision": "bf16_autocast", "optimizer": "AdamW(fused) " + json.dumps(snap["pretrain"]), | |
| "schedule": snap["schedule"], "mean_tokens_per_s": run_summary.get("mean_tokens_per_s"), | |
| "data": snap["data"], "weights_dtype": "float32", "exported_at": datetime.now().isoformat(timespec="seconds"), | |
| } | |
| common.write_json({"model": EncoderConfig.from_dict({**snap["model"], "grad_checkpointing": False}).to_dict(), | |
| "tokenizer": "tokenizer.json", "training": training}, final / "config.json") | |
| LOG.info("final/ written from %s -> %s", step_dir.name, final) | |
| return final | |
| def next_run_log_number(path: Path) -> int: | |
| nums = [int(m.group(1)) for m in re.finditer(r"^## (\d+)\.", path.read_text(encoding="utf-8"), re.M)] if path.exists() else [] | |
| return (max(nums) + 1) if nums else 0 | |
| def run_log_block(run_dir: Path, snap: dict[str, Any], state: TrainState, status: str, mean_tok_s: float, | |
| logfile: str | None, counts: dict[str, Any]) -> str: | |
| n = next_run_log_number(common.project_root() / "RUN_LOG.md") | |
| sch = snap["schedule"] | |
| lines = [ | |
| f"## {n}. {datetime.now():%Y-%m-%d %H:%M} — MLM pretraining run `{run_dir.name}` ({status})", | |
| "", | |
| f"**Run.** `python src/pretrain.py train --run-name {run_dir.name}`; {counts['total']:,} parameters (EncoderForMLM, tied head). " | |
| f"micro-batch {snap['micro_batch']} x accumulation {snap['grad_accumulation']} = {snap['effective_batch']} sequences x " | |
| f"{snap['model']['max_seq_len']} tokens per step; bf16 autocast, fused AdamW {snap['pretrain']['betas']} eps {snap['pretrain']['eps']} " | |
| f"wd {snap['pretrain']['weight_decay']} (no decay on biases/LayerNorm), clip {snap['pretrain']['grad_clip']}; " | |
| f"{sch['kind']} schedule peak {sch['peak_lr']:.1e} -> {sch['floor_ratio']:.0%} over {sch['total_steps']} planned steps " | |
| f"({snap['epochs']} epochs x {snap['steps_per_epoch']} steps) with {sch['warmup_steps']} warmup steps; " | |
| f"dynamic 15% masking 80/10/10, loss on masked positions only; {snap['data']['n_windows']:,} packed 512-token windows " | |
| f"({snap['data']['n_docs']:,} docs), {snap['data']['n_val_windows']:,} val windows.", | |
| "", | |
| f"**Result.** {state.step} optimizer steps ({state.step / max(1, snap['steps_per_epoch']):.2f} epochs), " | |
| f"{state.tokens_seen / 1e6:.1f}M tokens seen, total training time {fmt_dur(state.train_seconds_total)} " | |
| f"({state.train_seconds_total:,.0f} s) over {state.invocations} invocation(s), mean {mean_tok_s:,.0f} tokens/s; " | |
| f"peak VRAM reserved {state.peak_reserved_gb:.2f} GiB / allocated {state.peak_allocated_gb:.2f} GiB; " | |
| f"final train MLM loss {state.last_loss if state.last_loss is None else f'{state.last_loss:.4f}'}, " | |
| f"val MLM loss {state.last_val_loss if state.last_val_loss is None else f'{state.last_val_loss:.4f}'} " | |
| f"(masked-token accuracy {state.last_val_acc if state.last_val_acc is None else f'{100 * state.last_val_acc:.1f}%'}).", | |
| "", | |
| f"**Artifacts.** {run_dir.relative_to(common.project_root()) if run_dir.is_relative_to(common.project_root()) else run_dir}/ " | |
| f"(metrics.csv, run_summary.json, step-*/ and final/); log {logfile or 'logs/pretrain-*.log'}.", | |
| ] | |
| return "\n".join(lines) | |
| def current_logfile() -> str | None: | |
| for h in LOG.handlers: | |
| if isinstance(h, logging.FileHandler): | |
| return h.baseFilename | |
| return None | |
| def smoke_test_note(cfg: dict) -> str: | |
| rep = Path(cfg["paths"]["reports"]) / "smoke_test.json" | |
| try: | |
| rows = {r["micro_batch"]: r for r in common.read_json(rep)["finder"]["no_ckpt"] if r.get("ok")} | |
| parts = [f"{b}: {rows[b]['max_reserved_gib']:.2f} GiB reserved, {rows[b]['tokens_per_s']:,} tok/s" for b in sorted(rows)] | |
| return f"smoke_test measured (no checkpointing) micro-batch {'; '.join(parts)} ({rep.name})" | |
| except Exception: # noqa: BLE001 - informational only | |
| return "smoke_test measured 13.65 GiB reserved at micro-batch 16 and 17.65 GiB at 24 (reports/smoke_test.json)" | |
| # ================================================================================== train | |
| def resolve_run_and_resume(args: argparse.Namespace, cfg: dict) -> tuple[Path, Path | None]: | |
| base = Path(cfg["paths"]["checkpoints"]) / "pretrain" | |
| if args.resume is None: | |
| return base / args.run_name, None | |
| if args.resume == "latest": | |
| run_dir = base / args.run_name | |
| step_dir = latest_step_dir(run_dir) | |
| if step_dir is None: | |
| raise FileNotFoundError(f"--resume: no step-* checkpoint under {run_dir}") | |
| return run_dir, step_dir | |
| p = Path(args.resume).expanduser().resolve() | |
| if (p / "trainer_state.pt").exists(): | |
| return p.parent, p | |
| step_dir = latest_step_dir(p) | |
| if step_dir is None: | |
| raise FileNotFoundError(f"--resume {p}: neither a step dir nor a run dir with step-* checkpoints") | |
| return p, step_dir | |
| def cmd_train(args: argparse.Namespace, cfg: dict) -> int: | |
| t_invocation = common.timer() | |
| global LOG | |
| LOG = common.setup_logging("pretrain", cfg["paths"]["logs"]) | |
| device_info = common.print_device_info() | |
| if not torch.cuda.is_available(): | |
| LOG.error("CUDA is required for pretraining") | |
| return EXIT_CONFIG | |
| LOG.info("PYTORCH_CUDA_ALLOC_CONF=%s | torch %s | %s", os.environ.get("PYTORCH_CUDA_ALLOC_CONF"), torch.__version__, device_info["device"]) | |
| seed = int(cfg["seed"]) | |
| common.set_seed(seed) | |
| device = torch.device("cuda") | |
| pcfg, vr = cfg["pretrain"], cfg["vram"] | |
| hard_cap = float(vr["hard_cap_gb"]) | |
| mcfg = EncoderConfig.from_dict(cfg["model"]) | |
| if args.grad_checkpointing: | |
| mcfg.grad_checkpointing = True | |
| L = mcfg.max_seq_len | |
| run_dir, resume_dir = resolve_run_and_resume(args, cfg) | |
| if resume_dir is None and list_step_dirs(run_dir): | |
| if args.fresh: | |
| LOG.warning("--fresh: removing existing checkpoints and metrics in %s", run_dir) | |
| shutil.rmtree(run_dir) | |
| else: | |
| LOG.error("%s already holds checkpoints %s; pass --resume to continue it, --fresh to start over, or a new --run-name", | |
| run_dir, [p.name for p in list_step_dirs(run_dir)]) | |
| return EXIT_CONFIG | |
| run_dir.mkdir(parents=True, exist_ok=True) | |
| saved_summary = common.read_json(resume_dir / "summary.json") if resume_dir and (resume_dir / "summary.json").exists() else None | |
| # -------------------------------------------------------------- batch geometry | |
| if args.micro_batch: | |
| micro_batch = int(args.micro_batch) | |
| elif saved_summary: | |
| micro_batch = int(saved_summary["config"]["micro_batch"]) | |
| else: | |
| micro_batch = int(pcfg["micro_batch_start"]) | |
| eff_target = int(pcfg["effective_batch_sequences"]) | |
| accum = max(1, round(eff_target / micro_batch)) | |
| eff_batch = accum * micro_batch | |
| LOG.info("micro-batch %d x accumulation %d = effective %d sequences (%s tokens/step); %s", | |
| micro_batch, accum, eff_batch, f"{eff_batch * L:,}", smoke_test_note(cfg)) | |
| if eff_batch != eff_target: | |
| LOG.warning("realised effective batch %d != configured %d because %d is not a divisor of %d", | |
| eff_batch, eff_target, micro_batch, eff_target) | |
| # ------------------------------------------------------------------------ data | |
| # Refuse a mismatched --limit-docs BEFORE tokenising anything (resuming a dev run without | |
| # --limit-docs would otherwise build the full 106M-token cache and then be refused). | |
| if saved_summary is not None: | |
| saved_limit = saved_summary["config"]["data"].get("limit_docs") | |
| if args.limit_docs is None and saved_limit is not None: | |
| LOG.warning("--resume: using the checkpoint's --limit-docs %s", saved_limit) | |
| args.limit_docs = saved_limit | |
| elif args.limit_docs != saved_limit: | |
| LOG.error("--resume: --limit-docs %s differs from the checkpoint's %s; a resumed run must use the same data", | |
| args.limit_docs, saved_limit) | |
| return EXIT_CONFIG | |
| cache = load_or_build_token_cache(cfg, args.limit_docs) | |
| cls_id = int(cfg["tokenizer"]["special_tokens"].index("[CLS]")) | |
| train_store, val_store = WindowStore(cache.train, L, prefix_id=cls_id), WindowStore(cache.val, L, prefix_id=cls_id) | |
| steps_per_epoch = train_store.n // eff_batch | |
| if steps_per_epoch < 1: | |
| LOG.error("only %d windows of %d tokens; need at least %d for one optimizer step", train_store.n, L, eff_batch) | |
| return EXIT_CONFIG | |
| epochs = int(args.epochs) if args.epochs else (int(saved_summary["config"]["epochs"]) if saved_summary else int(pcfg["epochs"])) | |
| total_steps = epochs * steps_per_epoch | |
| sched = Schedule.from_config(pcfg, total_steps) | |
| LOG.info("data: %s train windows (%s tokens) -> %d steps/epoch x %d epochs = %d planned steps; %s val windows " | |
| "(%d scored per eval); %d leftover windows per epoch are skipped", | |
| f"{train_store.n:,}", f"{train_store.n * L:,}", steps_per_epoch, epochs, total_steps, | |
| f"{val_store.n:,}", min(VAL_WINDOWS, val_store.n), train_store.n - steps_per_epoch * eff_batch) | |
| LOG.info("schedule: %s warmup %d steps (= min(config %d, %.0f%% of %d)) -> peak %.2e -> floor %.0f%% at step %d", | |
| sched.kind, sched.warmup_steps, int(pcfg["warmup_steps"]), 100 * float(pcfg.get("warmup_fraction", WARMUP_FRACTION)), total_steps, | |
| sched.peak_lr, 100 * sched.floor_ratio, sched.total_steps) | |
| val_idx = np.sort(np.random.default_rng(seed + VAL_MASK_SEED_OFFSET).choice(val_store.n, size=min(VAL_WINDOWS, val_store.n), replace=False)) if val_store.n else np.zeros(0, np.int64) | |
| tok = load_tokenizer(cfg["paths"]["tokenizer"]) | |
| special_ids = [i for i in (tok.token_to_id(s) for s in cfg["tokenizer"]["special_tokens"]) if i is not None] | |
| pool_like = SimpleNamespace(special_ids=special_ids, mask_id=tok.token_to_id("[MASK]")) | |
| assert pool_like.mask_id == cache.meta["mask_id"], "tokenizer [MASK] id differs from the token cache" | |
| p_mask, replace_probs = float(pcfg["mlm_probability"]), [float(x) for x in pcfg["mask_replace_probs"]] | |
| # ----------------------------------------------------------------------- model | |
| common.set_seed(seed) | |
| with torch.device(device): | |
| model = EncoderForMLM(mcfg) | |
| model.train() | |
| counts = count_parameters(model) | |
| print(f"EncoderForMLM parameters: {counts['total']:,} total | embeddings {counts['embeddings']:,} | " | |
| f"encoder layers {counts['encoder_layers']:,} | final norm {counts['final_norm']:,} | MLM head {counts['head']:,}" | |
| f" | grad checkpointing {'ON' if mcfg.grad_checkpointing else 'off'}") | |
| LOG.info("model: %s params %s", f"{counts['total']:,}", {k: v for k, v in mcfg.to_dict().items()}) | |
| opt = build_optimizer(model, sched.peak_lr, pcfg) | |
| LOG.info("optimizer: %s fused=%s groups(decay=%d tensors, no_decay=%d tensors) betas=%s eps=%s wd=%s clip=%s", | |
| type(opt).__name__, opt.defaults.get("fused"), len(opt.param_groups[0]["params"]), len(opt.param_groups[1]["params"]), | |
| pcfg["betas"], pcfg["eps"], pcfg["weight_decay"], pcfg["grad_clip"]) | |
| mask_gen = torch.Generator(device=device).manual_seed(seed) | |
| snapshot = config_snapshot(mcfg, pcfg, seed, micro_batch, accum, sched, steps_per_epoch, epochs, cache, train_store.n, val_store.n) | |
| metrics = MetricsCSV(run_dir / "metrics.csv") | |
| state = TrainState() | |
| if resume_dir is not None: | |
| saved, state = load_checkpoint_into(resume_dir, model, opt, mask_gen, device) | |
| check_resume_compatible(saved["config"], snapshot) | |
| metrics.truncate_after(state.step) | |
| run_end = min(total_steps, int(args.max_steps)) if args.max_steps else total_steps | |
| if state.step >= run_end: | |
| LOG.warning("nothing to do: step %d already >= %d (planned %d, --max-steps %s); run_summary.json left untouched", | |
| state.step, run_end, total_steps, args.max_steps) | |
| return EXIT_OK | |
| state.invocations += 1 | |
| common.write_json({**asdict(state), "config": snapshot, "status": "running", "started": datetime.now().isoformat(timespec="seconds")}, | |
| run_dir / "run_summary.json") | |
| max_seconds = float(args.max_hours) * 3600.0 if args.max_hours else None | |
| log_every = int(args.log_every or pcfg["log_every_steps"]) | |
| eval_every = int(pcfg["eval_every_steps"] if args.eval_every is None else args.eval_every) | |
| ckpt_every = int(args.checkpoint_every or pcfg["checkpoint_every_steps"]) | |
| keep = int(args.keep_checkpoints) | |
| LOG.info("run %s: steps %d -> %d of %d planned%s; log every %d, eval every %s, checkpoint every %d (keep %d), VRAM hard cap %.0f GiB", | |
| run_dir.name, state.step, run_end, total_steps, f", --max-hours {args.max_hours}" if max_seconds else "", | |
| log_every, eval_every or "never", ckpt_every, keep, hard_cap) | |
| stop = StopSignal() | |
| stop.install() | |
| perm = epoch_permutation(seed, state.epoch, train_store.n) | |
| LOG.info("data cursor: epoch %d, position %d, next window index %s (permutation seed %d)", | |
| state.epoch, state.position, int(perm[state.position]) if state.position < train_store.n else "n/a", seed + state.epoch) | |
| # ------------------------------------------------------------------- the loop | |
| tokens_per_step = eff_batch * L | |
| metrics_tail: list[dict[str, Any]] = [] | |
| last_saved_step = state.step if resume_dir is not None else -1 | |
| status, exit_code = "running", EXIT_OK | |
| nonfinite_streak = 0 | |
| interval_losses: list[float] = [] | |
| interval_steps = 0 | |
| pause_s = 0.0 # eval + checkpoint time, excluded from tokens/s | |
| run_tokens = 0 | |
| run_train_s = 0.0 | |
| torch.cuda.synchronize() | |
| torch.cuda.reset_peak_memory_stats() | |
| t_loop0 = common.timer() # starts AFTER checkpoint load: first tokens/s is clean | |
| t_row = t_loop0 | |
| LOG.info("training loop started (setup took %.1fs)", t_loop0 - t_invocation) | |
| def write_row(val: tuple[float, float] | None, lr: float, grad_norm: float, loss: float) -> None: | |
| nonlocal t_row, interval_losses, interval_steps, pause_s, run_tokens, run_train_s | |
| torch.cuda.synchronize() | |
| now = common.timer() | |
| wall = now - t_row | |
| active = max(1e-6, wall - pause_s) | |
| tok_s = interval_steps * tokens_per_step / active | |
| run_tokens += interval_steps * tokens_per_step | |
| run_train_s += active | |
| vram = common.vram_stats() | |
| free, total = torch.cuda.mem_get_info() | |
| state.peak_reserved_gb = max(state.peak_reserved_gb, vram["max_reserved_gb"]) | |
| state.peak_allocated_gb = max(state.peak_allocated_gb, vram["max_allocated_gb"]) | |
| elapsed = now - t_loop0 | |
| state.train_seconds_total += wall | |
| row = { | |
| "step": state.step, "epoch": (state.step - 1) // steps_per_epoch + 1, | |
| "epoch_frac": round(state.step / steps_per_epoch, 4), | |
| "loss": round(loss, 5), "loss_avg": round(sum(interval_losses) / max(1, len(interval_losses)), 5), | |
| "lr": f"{lr:.6e}", "grad_norm": round(grad_norm, 4), "tokens_per_s": round(tok_s), | |
| "max_alloc_gb": round(vram["max_allocated_gb"], 3), "max_reserved_gb": round(vram["max_reserved_gb"], 3), | |
| "device_used_gb": round((total - free) / GIB, 3), "elapsed_s": round(elapsed, 1), | |
| "train_seconds_total": round(state.train_seconds_total, 1), | |
| "val_mlm_loss": None if val is None else round(val[0], 5), "val_mlm_acc": None if val is None else round(val[1], 5), | |
| } | |
| metrics.append(row) | |
| metrics_tail.append(row) | |
| del metrics_tail[:-50] | |
| remaining = run_end - state.step | |
| eta = remaining * (active / max(1, interval_steps)) | |
| LOG.info("step %d/%d ep %d | loss %.4f (avg %.4f) | lr %.2e | gnorm %.2f | %s tok/s | alloc %.2f GiB res %.2f GiB used %.2f GiB" | |
| " | elapsed %s | ETA %s%s", | |
| state.step, run_end, row["epoch"], loss, row["loss_avg"], lr, grad_norm, f"{tok_s:,.0f}", | |
| vram["max_allocated_gb"], vram["max_reserved_gb"], row["device_used_gb"], fmt_dur(elapsed), fmt_dur(eta), | |
| "" if val is None else f" | val_mlm_loss {val[0]:.4f} val_acc {100 * val[1]:.2f}%") | |
| t_row = now | |
| interval_losses, interval_steps, pause_s = [], 0, 0.0 | |
| torch.cuda.reset_peak_memory_stats() | |
| def checkpoint(reason: str) -> None: | |
| nonlocal last_saved_step, pause_s | |
| if last_saved_step == state.step: | |
| return | |
| t0 = common.timer() | |
| save_checkpoint(run_dir, model, opt, sched, state, snapshot, mask_gen, metrics_tail, keep, reason) | |
| last_saved_step = state.step | |
| pause_s += common.timer() - t0 | |
| while state.step < run_end: | |
| lr = sched.lr_at(state.step + 1) | |
| for g in opt.param_groups: | |
| g["lr"] = lr | |
| micro_losses = [] | |
| phase = "forward/backward" | |
| try: | |
| for _ in range(accum): | |
| idx = perm[state.position:state.position + micro_batch] | |
| state.position += micro_batch | |
| ids = train_store.batch(idx, device) | |
| inputs, labels, pos = mlm_mask(ids, pool_like, mcfg.vocab_size, p_mask, replace_probs, mask_gen) | |
| with torch.autocast(device_type="cuda", dtype=torch.bfloat16): | |
| logits = model(inputs, attention_mask=None, masked_positions=pos) | |
| loss = F.cross_entropy(logits, labels) | |
| (loss / accum).backward() | |
| micro_losses.append(float(loss.detach())) | |
| step_loss = sum(micro_losses) / accum | |
| if not math.isfinite(step_loss): | |
| nonfinite_streak += 1 | |
| opt.zero_grad(set_to_none=True) | |
| LOG.error("non-finite loss %r at step %d (streak %d); optimizer step skipped", step_loss, state.step + 1, nonfinite_streak) | |
| if nonfinite_streak >= 5: | |
| status, exit_code = "diverged", EXIT_NAN | |
| break | |
| state.step += 1 | |
| else: | |
| nonfinite_streak = 0 | |
| phase = "optimizer step" | |
| grad_norm = float(torch.nn.utils.clip_grad_norm_(model.parameters(), float(pcfg["grad_clip"]))) | |
| opt.step() | |
| opt.zero_grad(set_to_none=True) | |
| state.step += 1 | |
| state.last_loss = step_loss | |
| state.tokens_seen += tokens_per_step | |
| interval_losses.append(step_loss) | |
| interval_steps += 1 | |
| epoch_end = state.step % steps_per_epoch == 0 | |
| if epoch_end: | |
| LOG.info("epoch %d/%d complete at step %d", state.epoch + 1, epochs, state.step) | |
| state.epoch += 1 | |
| state.position = 0 | |
| perm = epoch_permutation(seed, state.epoch, train_store.n) | |
| val = None | |
| finished = state.step >= run_end | |
| if eval_every and (state.step % eval_every == 0 or finished) and len(val_idx): | |
| phase = "eval" | |
| t0 = common.timer() | |
| val = evaluate_mlm(model, val_store, val_idx, micro_batch, device, seed + VAL_MASK_SEED_OFFSET, pool_like, | |
| mcfg.vocab_size, p_mask, replace_probs) | |
| state.last_val_loss, state.last_val_acc = val | |
| pause_s += common.timer() - t0 | |
| except torch.OutOfMemoryError as e: | |
| # Another process grabbing VRAM (desktop spike, a second job) OOMs this one even | |
| # though its own reserved peak is under the cap. In the forward/backward phase the | |
| # weights and optimizer state are intact, so the partial step is discarded; after a | |
| # completed optimizer step (eval phase) the step counts. Either way the data cursor | |
| # is rewound to the last completed step, a checkpoint is saved and the resume | |
| # continues from exactly there. An OOM inside opt.step itself is the one case where | |
| # weights may be partially updated; it is logged as such. | |
| ids = inputs = labels = pos = logits = loss = None # drop the half-built graph before saving | |
| opt.zero_grad(set_to_none=True) | |
| torch.cuda.empty_cache() | |
| model.train() | |
| state.position = (state.step % steps_per_epoch) * eff_batch | |
| free, total = torch.cuda.mem_get_info() | |
| LOG.error("CUDA OOM during the %s of step %d (%s); device has %.2f of %.2f GiB in use across all processes, this process " | |
| "reserved %.2f GiB. Saving a checkpoint at step %d and stopping (exit %d).%s", phase, state.step + (phase != "eval"), | |
| str(e).splitlines()[0][:160], (total - free) / GIB, total / GIB, torch.cuda.memory_reserved() / GIB, state.step, | |
| EXIT_VRAM, " WARNING: the OOM hit inside opt.step; weights may be partially updated." if phase == "optimizer step" else "") | |
| status, exit_code = "oom", EXIT_VRAM | |
| break | |
| if state.step % log_every == 0 or val is not None or finished or stop.name or epoch_end: | |
| write_row(val, lr, grad_norm if math.isfinite(step_loss) else float("nan"), step_loss) | |
| reserved_peak = torch.cuda.max_memory_reserved() / GIB | |
| if reserved_peak > hard_cap: | |
| state.peak_reserved_gb = max(state.peak_reserved_gb, reserved_peak) | |
| LOG.error("VRAM guard: reserved peak %.2f GiB exceeds the %.0f GiB hard cap; saving and stopping (exit %d). " | |
| "Resume with a smaller --micro-batch or --grad-checkpointing.", reserved_peak, hard_cap, EXIT_VRAM) | |
| status, exit_code = "vram_guard", EXIT_VRAM | |
| break | |
| if finished: | |
| status = "complete" if state.step >= total_steps else "max_steps" | |
| break | |
| if stop.name: | |
| status = f"signal:{stop.name}" | |
| break | |
| if max_seconds is not None and (common.timer() - t_loop0) >= max_seconds: | |
| LOG.warning("--max-hours %.2f reached at step %d; saving a checkpoint and exiting 0 (resume with --resume)", args.max_hours, state.step) | |
| status = "max_hours" | |
| break | |
| if epoch_end: | |
| checkpoint(f"epoch {state.epoch} end") | |
| elif state.step % ckpt_every == 0: | |
| checkpoint("periodic") | |
| # -------------------------------------------------------------------- wrap up | |
| if interval_steps: | |
| write_row(None, lr, float("nan"), state.last_loss if state.last_loss is not None else float("nan")) | |
| checkpoint(status) | |
| run_wall = common.timer() - t_loop0 | |
| mean_tok_s = run_tokens / max(1e-6, run_train_s) | |
| summary = { | |
| **asdict(state), "status": status, "exit_code": exit_code, "run_end_step": run_end, | |
| "this_invocation": {"steps": run_tokens // tokens_per_step, "tokens": run_tokens, "loop_seconds": round(run_wall, 1), | |
| "mean_tokens_per_s": round(mean_tok_s), "setup_seconds": round(t_loop0 - t_invocation, 1)}, | |
| "mean_tokens_per_s": round(mean_tok_s), "config": snapshot, "params": counts, | |
| "finished": datetime.now().isoformat(timespec="seconds"), "log": current_logfile(), | |
| } | |
| common.write_json(summary, run_dir / "run_summary.json") | |
| print(f"\nTotal training time (this invocation): {fmt_dur(run_wall)} ({run_wall:.1f} s) for {run_tokens // tokens_per_step} steps, " | |
| f"{mean_tok_s:,.0f} tokens/s mean") | |
| print(f"Total training time (all invocations): {fmt_dur(state.train_seconds_total)} ({state.train_seconds_total:.1f} s), " | |
| f"{state.step} steps, {state.tokens_seen / 1e6:.1f}M tokens, peak VRAM reserved {state.peak_reserved_gb:.2f} GiB") | |
| LOG.info("run %s stopped: %s at step %d/%d (exit %d)", run_dir.name, status, state.step, total_steps, exit_code) | |
| if status == "complete": | |
| write_final(run_dir, run_dir / f"step-{state.step:07d}", cfg, status) | |
| if status in ("complete", "max_steps", "max_hours") and state.step >= RUN_LOG_MIN_STEPS: | |
| common.append_run_log(run_log_block(run_dir, snapshot, state, status, mean_tok_s, current_logfile(), counts)) | |
| LOG.info("RUN_LOG.md updated") | |
| else: | |
| LOG.info("RUN_LOG.md not updated (status %s, step %d; needs a normal stop at >= %d steps)", status, state.step, RUN_LOG_MIN_STEPS) | |
| return exit_code | |
| # ============================================================================ export / plot | |
| def cmd_export(args: argparse.Namespace, cfg: dict) -> int: | |
| global LOG | |
| LOG = common.setup_logging("pretrain", cfg["paths"]["logs"]) | |
| run_dir = Path(cfg["paths"]["checkpoints"]) / "pretrain" / args.run_name | |
| step_dir = latest_step_dir(run_dir) | |
| if step_dir is None: | |
| LOG.error("no step-* checkpoint under %s", run_dir) | |
| return EXIT_CONFIG | |
| summary_p = run_dir / "run_summary.json" | |
| status = common.read_json(summary_p).get("status", "exported") if summary_p.exists() else "exported" | |
| final = write_final(run_dir, step_dir, cfg, status) | |
| cfgj = common.read_json(final / "config.json") | |
| print(json.dumps(cfgj["training"], indent=2)) | |
| print(f"final/: {sorted(p.name for p in final.iterdir())} -> {final}") | |
| return EXIT_OK | |
| def cmd_plot(args: argparse.Namespace, cfg: dict) -> int: | |
| global LOG | |
| LOG = common.setup_logging("pretrain", cfg["paths"]["logs"]) | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt | |
| run_dir = Path(cfg["paths"]["checkpoints"]) / "pretrain" / args.run_name | |
| rows = MetricsCSV(run_dir / "metrics.csv").read() | |
| if not rows: | |
| LOG.error("no metrics.csv under %s", run_dir) | |
| return EXIT_CONFIG | |
| steps = np.array([int(r["step"]) for r in rows]) | |
| loss = np.array([float(r["loss"]) for r in rows]) | |
| lr = np.array([float(r["lr"]) for r in rows]) | |
| val_rows = [r for r in rows if r.get("val_mlm_loss")] | |
| fig, ax = plt.subplots(figsize=(9, 5)) | |
| ax.plot(steps, loss, color="tab:blue", lw=1.2, label="train MLM loss (per logged step)") | |
| if val_rows: | |
| ax.plot([int(r["step"]) for r in val_rows], [float(r["val_mlm_loss"]) for r in val_rows], | |
| "o-", color="tab:orange", lw=1.5, ms=4, label="val MLM loss") | |
| ax.axhline(math.log(int(cfg["model"]["vocab_size"])), color="grey", ls=":", lw=1, label=f"ln(V) = {math.log(int(cfg['model']['vocab_size'])):.2f}") | |
| ax.set_xlabel("optimizer step") | |
| ax.set_ylabel("MLM cross-entropy") | |
| ax.set_title(f"Pretraining {args.run_name}: {len(rows)} logged steps, last train loss {loss[-1]:.3f}" | |
| + (f", last val loss {float(val_rows[-1]['val_mlm_loss']):.3f}" if val_rows else "")) | |
| ax.grid(alpha=0.3) | |
| ax2 = ax.twinx() | |
| ax2.plot(steps, lr, color="tab:green", lw=1, alpha=0.7, label="learning rate") | |
| ax2.set_ylabel("learning rate") | |
| ax2.ticklabel_format(axis="y", style="sci", scilimits=(0, 0)) | |
| h1, l1 = ax.get_legend_handles_labels() | |
| h2, l2 = ax2.get_legend_handles_labels() | |
| ax.legend(h1 + h2, l1 + l2, loc="upper right", fontsize=9) | |
| out = Path(cfg["paths"]["reports"]) / "pretrain_curves.png" | |
| out.parent.mkdir(parents=True, exist_ok=True) | |
| fig.tight_layout() | |
| fig.savefig(out, dpi=150) | |
| LOG.info("wrote %s (%d rows, %d val points)", out, len(rows), len(val_rows)) | |
| print(out) | |
| return EXIT_OK | |
| # ==================================================================================== CLI | |
| def parse_args(argv: list[str] | None = None) -> argparse.Namespace: | |
| ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) | |
| ap.add_argument("--config", default="config.yaml") | |
| sub = ap.add_subparsers(dest="cmd", required=True) | |
| tr = sub.add_parser("train", help="MLM pretraining (resumable)") | |
| tr.add_argument("--run-name", default="large-24L") | |
| tr.add_argument("--resume", nargs="?", const="latest", default=None, metavar="PATH", | |
| help="resume from a step dir / run dir, or 'latest' under the run (default when the flag has no value)") | |
| tr.add_argument("--epochs", type=int, default=None, help="planned epochs (config.pretrain.epochs); fixes the schedule length") | |
| tr.add_argument("--max-steps", type=int, default=None, help="stop this invocation once the GLOBAL step reaches N (schedule unchanged)") | |
| tr.add_argument("--max-hours", type=float, default=None, help="stop this invocation after H hours of training: save, exit 0") | |
| tr.add_argument("--micro-batch", type=int, default=None, help="default config.pretrain.micro_batch_start (16), or the checkpoint's on resume") | |
| tr.add_argument("--grad-checkpointing", action="store_true", help="activation checkpointing (config.model.grad_checkpointing)") | |
| tr.add_argument("--limit-docs", type=int, default=None, help="dev: tokenise only the first N documents (separate cache files)") | |
| tr.add_argument("--checkpoint-every", type=int, default=None) | |
| tr.add_argument("--log-every", type=int, default=None) | |
| tr.add_argument("--eval-every", type=int, default=None, help="0 disables periodic eval") | |
| tr.add_argument("--keep-checkpoints", type=int, default=3) | |
| tr.add_argument("--fresh", action="store_true", help="start over in a run dir that already holds checkpoints (deletes them)") | |
| ex = sub.add_parser("export", help="write final/ (model.safetensors + config.json + tokenizer.json) from the latest checkpoint") | |
| ex.add_argument("--run-name", required=True) | |
| pl = sub.add_parser("plot", help="reports/pretrain_curves.png from metrics.csv") | |
| pl.add_argument("--run-name", required=True) | |
| return ap.parse_args(argv) | |
| def main(argv: list[str] | None = None) -> int: | |
| args = parse_args(argv) | |
| cfg = common.load_config(args.config) | |
| if args.cmd == "train": | |
| return cmd_train(args, cfg) | |
| if args.cmd == "export": | |
| return cmd_export(args, cfg) | |
| return cmd_plot(args, cfg) | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |