review round 3 (probe/model-card/eval): rank-visible eval failure + cross-rank peak memory + complete run_summary; card derived-and-asserted from config; harness install --no-deps and task ids confirmed; torchrun redirects 3 + tee 3
Browse files- train/train_ounce100m.py +760 -703
train/train_ounce100m.py
CHANGED
|
@@ -1,703 +1,760 @@
|
|
| 1 |
-
# The training run in one script: frozen architecture, map-style packed data, fp16 on T4, trapezoid LR,
|
| 2 |
-
# and a checkpoint cycle that satisfies §3.13 by construction.
|
| 3 |
-
|
| 4 |
-
# Deliberately thin glue over `transformers.Trainer` (docs/01-plan.md §5): data loading, DDP, gradient
|
| 5 |
-
# accumulation, scaler, RNG capture and resumption are the framework's job. What is ours is the shard
|
| 6 |
-
# reader, the LR shape, and the upload/verify/prune discipline -- the three things a generic stack cannot
|
| 7 |
-
# know about this project.
|
| 8 |
-
|
| 9 |
-
# Two resume facts that are easy to get wrong and are asserted here rather than assumed:
|
| 10 |
-
# 1. `ignore_data_skip=True`. Trainer's own resume would replay discarded batches; we advance the data
|
| 11 |
-
# position by slicing the permutation in shard_dataset instead. Leaving the flag at False would
|
| 12 |
-
# double-skip and silently LOSE training data -- the exact §3.1 violation.
|
| 13 |
-
# 2. The cursor is checked against the checkpoint, not trusted. A resumed run whose cursor, seed,
|
| 14 |
-
# sequence length or corpus fingerprint disagrees with what is on disk stops before step 1.
|
| 15 |
-
|
| 16 |
-
import argparse
|
| 17 |
-
import json
|
| 18 |
-
import math
|
| 19 |
-
import os
|
| 20 |
-
import time
|
| 21 |
-
|
| 22 |
-
import torch
|
| 23 |
-
from torch.optim.lr_scheduler import LambdaLR
|
| 24 |
-
from transformers import (LlamaConfig, LlamaForCausalLM, Trainer, TrainerCallback,
|
| 25 |
-
TrainingArguments, default_data_collator, set_seed)
|
| 26 |
-
|
| 27 |
-
import hubckpt
|
| 28 |
-
import shard_dataset as SD
|
| 29 |
-
|
| 30 |
-
# ---- frozen at Gate 1 (docs/01-plan.md §2.3, §4). Only --layers/--hidden change, for test T1.
|
| 31 |
-
BASE = dict(hidden=576, layers=22, heads=9, kv_heads=3, ffn=1536, vocab=49152,
|
| 32 |
-
rope_theta=10000.0, rms_eps=1e-5, tie=True)
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
def _kv_dividing(heads, hidden):
|
| 36 |
-
"""GQA requires num_key_value_heads to divide num_attention_heads; SDPA raises otherwise
|
| 37 |
-
("Number of heads in key and value must divide the number of heads in Q"). Variant shapes are built
|
| 38 |
-
from hidden/64, which need not divide 9 -- take the largest divisor of `heads` that fits."""
|
| 39 |
-
per_head = max(1, hidden // 64)
|
| 40 |
-
return max(d for d in range(1, min(per_head, heads) + 1) if heads % d == 0)
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
def build_config(seq_len, layers=None, hidden=None, attn="eager"):
|
| 44 |
-
"""The frozen shape, and the two variants test T1 needs (20L at the same width, and a wider/shallower
|
| 45 |
-
alternative). SwiGLU width is int(2*4h/3) rounded to a 64-multiple -- 1536 at h=576, which is the
|
| 46 |
-
value the frozen 106,194,240 count was derived from. Getting this factor wrong silently changes the
|
| 47 |
-
parameter count and makes T1 compare two different models.
|
| 48 |
-
|
| 49 |
-
`attn` is explicit because it is a measured choice, not a default: probe p0c found eager attention
|
| 50 |
-
beating memory-efficient SDPA at seq 1024 on Turing (docs/00-platform-notes.md §9), and Gate 3 P2
|
| 51 |
-
then clocked 4,071 tok/s with the transformers default -- half the rate p0c saw in a raw loop. Leaving
|
| 52 |
-
the attention kernel to whatever the library defaults to is how a 68-hour run becomes a 27-hour run.
|
| 53 |
-
"""
|
| 54 |
-
h = hidden or BASE["hidden"]
|
| 55 |
-
L = layers or BASE["layers"]
|
| 56 |
-
# "variant" means a different WIDTH, not merely a different layer count: the frozen GQA ratio (9 Q /
|
| 57 |
-
# 3 KV) belongs to the frozen width, and passing --hidden 576 explicitly must not quietly turn the
|
| 58 |
-
# model into MHA-9 and make test T1 measure something else.
|
| 59 |
-
frozen_width = (h == BASE["hidden"])
|
| 60 |
-
inter = BASE["ffn"] if frozen_width else (int(round(8 * h / 3)) // 64) * 64
|
| 61 |
-
kv = BASE["kv_heads"] if frozen_width else _kv_dividing(BASE["heads"], h)
|
| 62 |
-
cfg = LlamaConfig(vocab_size=BASE["vocab"], hidden_size=h, intermediate_size=inter,
|
| 63 |
-
num_hidden_layers=L, num_attention_heads=BASE["heads"],
|
| 64 |
-
num_key_value_heads=kv,
|
| 65 |
-
max_position_embeddings=seq_len, rope_theta=BASE["rope_theta"],
|
| 66 |
-
rms_norm_eps=BASE["rms_eps"], tie_word_embeddings=BASE["tie"],
|
| 67 |
-
attention_dropout=0.0, hidden_act="silu", mlp_bias=False,
|
| 68 |
-
attn_implementation=attn,
|
| 69 |
-
pad_token_id=BASE["vocab"] - 1, eos_token_id=0)
|
| 70 |
-
return cfg
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
def count_params(model):
|
| 74 |
-
"""Total, embeddings included -- the number §2 budgets against. Reported both ways: numel sum and
|
| 75 |
-
the unique-parameter count, which differ when embeddings are tied."""
|
| 76 |
-
total = sum(p.numel() for p in model.parameters())
|
| 77 |
-
uniq = sum(p.numel() for p in set(model.parameters()))
|
| 78 |
-
return {"sum_numel": total, "unique": uniq,
|
| 79 |
-
"trainable": sum(p.numel() for p in model.parameters() if p.requires_grad)}
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
# The cursor arithmetic in one place, because three separate bugs live here:
|
| 83 |
-
# - a step consumes micro_batch * world * accum sequences, not micro_batch (DDP shards the batch);
|
| 84 |
-
# - Trainer restores `global_step` from the checkpoint, so on resume it is ALREADY absolute. Adding the
|
| 85 |
-
# resume point a second time inflates the cursor, and an inflated cursor makes the next run skip data
|
| 86 |
-
# it never read -- silent data loss with no error anywhere, which is §3.1's worst case. Writing this
|
| 87 |
-
# as a pure function of the absolute step removes the whole class of bug.
|
| 88 |
-
# - max_steps is the TOTAL horizon (it is what the LR schedule decays against), so resuming mid-run
|
| 89 |
-
# must keep the same steps_planned or the trapezoid changes shape.
|
| 90 |
-
def samples_at(absolute_step, seqs_per_step):
|
| 91 |
-
return int(absolute_step) * seqs_per_step
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
class TrapezoidTrainer(Trainer):
|
| 95 |
-
"""Trainer with (a) a hand-rolled trapezoid schedule and (b) a data position we own."""
|
| 96 |
-
|
| 97 |
-
def __init__(self, *a, lr_shape=None, **kw):
|
| 98 |
-
self.lr_shape = lr_shape
|
| 99 |
-
super().__init__(*a, **kw)
|
| 100 |
-
|
| 101 |
-
def create_scheduler(self, num_training_steps, optimizer=None):
|
| 102 |
-
warm = self.lr_shape["warmup_steps"]
|
| 103 |
-
decay_at = self.lr_shape["decay_start"]
|
| 104 |
-
total = self.lr_shape["total_steps"]
|
| 105 |
-
|
| 106 |
-
def fn(cur):
|
| 107 |
-
if cur < warm:
|
| 108 |
-
return (cur + 1) / max(1, warm)
|
| 109 |
-
if cur < decay_at:
|
| 110 |
-
return 1.0
|
| 111 |
-
# linear decay to exactly zero at the end: the trapezoid in docs/01-plan.md §4.3
|
| 112 |
-
return max(0.0, 1.0 - (cur - decay_at) / max(1, total - decay_at))
|
| 113 |
-
|
| 114 |
-
if self.lr_scheduler is None:
|
| 115 |
-
self.lr_scheduler = LambdaLR(self.optimizer if optimizer is None else optimizer, fn)
|
| 116 |
-
return self.lr_scheduler
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
class HubPush(TrainerCallback):
|
| 120 |
-
"""§3.13 after every checkpoint: write the cursor in, upload, verify from the Hub, prune locally.
|
| 121 |
-
|
| 122 |
-
Only rank 0 acts -- the ranks share the checkpoint directory and would race. A failed verification
|
| 123 |
-
raises inside the callback, so the run stops AT that checkpoint rather than continuing with its only
|
| 124 |
-
copy on a disk that can be taken away at any moment.
|
| 125 |
-
"""
|
| 126 |
-
|
| 127 |
-
def __init__(self, repo, api, token, seq_len, seed, fingerprint, seqs_per_step,
|
| 128 |
-
prune=True, min_free_gb=6.0, repo_type="dataset", stop_after_steps=0, order_sha=None):
|
| 129 |
-
self.repo, self.api, self.token = repo, api, token
|
| 130 |
-
self.seq_len, self.seed, self.fingerprint = seq_len, seed, fingerprint
|
| 131 |
-
self.seqs_per_step, self.repo_type = seqs_per_step, repo_type
|
| 132 |
-
self.order_sha = order_sha
|
| 133 |
-
self.prune, self.min_free_gb = prune, min_free_gb
|
| 134 |
-
self.stop_after = int(stop_after_steps or 0)
|
| 135 |
-
self.stopped_at = None
|
| 136 |
-
self.n = 0
|
| 137 |
-
self.steps = []
|
| 138 |
-
self.results = []
|
| 139 |
-
|
| 140 |
-
def on_step_end(self, args, state, control, **kw):
|
| 141 |
-
# A session that ends ON a verified checkpoint loses nothing when the platform takes the disk.
|
| 142 |
-
# That is how the 10 % cadence is made compatible with an unknown session cap: instead of pushing
|
| 143 |
-
# extra intermediate copies (which multiplies Hub LFS storage by the number of sessions), the run
|
| 144 |
-
# is cut into segments whose length is a whole number of checkpoint intervals.
|
| 145 |
-
if self.stop_after and state.global_step >= self.stop_after:
|
| 146 |
-
self.stopped_at = state.global_step
|
| 147 |
-
control.should_training_stop = True
|
| 148 |
-
return control
|
| 149 |
-
|
| 150 |
-
def on_save(self, args, state, control, **kw):
|
| 151 |
-
if not state.is_world_process_zero:
|
| 152 |
-
return control
|
| 153 |
-
step = state.global_step
|
| 154 |
-
d = os.path.join(args.output_dir, f"checkpoint-{step}")
|
| 155 |
-
if not os.path.isdir(d):
|
| 156 |
-
raise RuntimeError(f"Trainer reported a save but {d} does not exist")
|
| 157 |
-
samples = samples_at(step, self.seqs_per_step)
|
| 158 |
-
SD.save_cursor(d, SD.cursor_dict(self.seq_len, samples, self.seed, self.fingerprint,
|
| 159 |
-
step=step, order_sha=self.order_sha))
|
| 160 |
-
t0 = time.time()
|
| 161 |
-
# Upload and verify *without* pruning, roll the pointer and read it back, and only then delete the
|
| 162 |
-
# local copy. The old order (prune inside push_and_prune, pointer written afterwards) meant one
|
| 163 |
-
# failed pointer commit left a Hub-verified checkpoint that nothing pointed at and no local copy to
|
| 164 |
-
# recover it with -- and every guard a resume runs would still pass, because weights, cursor,
|
| 165 |
-
# fingerprint and seed are all self-consistent. Only the pointer would be wrong, and the next
|
| 166 |
-
# session would quietly re-read 100 M tokens.
|
| 167 |
-
res = hubckpt.push_and_prune(self.repo, d, f"ckpt/checkpoint-{step}", self.api,
|
| 168 |
-
repo_type=self.repo_type, token=self.token, prune=False)
|
| 169 |
-
ptr_body = {"path_in_repo": f"ckpt/checkpoint-{step}", "step": step,
|
| 170 |
-
"samples_consumed": samples, "tokens_consumed": samples * self.seq_len,
|
| 171 |
-
"fingerprint": self.fingerprint, "seed": self.seed}
|
| 172 |
-
hubckpt.write_latest_pointer(self.repo, ptr_body, self.api, repo_type=self.repo_type,
|
| 173 |
-
token=self.token)
|
| 174 |
-
if self.prune:
|
| 175 |
-
hubckpt.prune_local(d, res, self.min_free_gb)
|
| 176 |
-
loss = next((h["loss"] for h in reversed(state.log_history) if "loss" in h), None)
|
| 177 |
-
print(f"CKPT {step} samples={samples:,} push+verify={round(time.time()-t0,1)}s "
|
| 178 |
-
f"ok={res['verify']['ok']} pruned={res.get('pruned')} free={res['free_after_gb']}GB "
|
| 179 |
-
f"loss={loss}", flush=True)
|
| 180 |
-
self.n += 1
|
| 181 |
-
self.steps.append(step)
|
| 182 |
-
self.results.append(res)
|
| 183 |
-
return control
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
def run_benchmark(args, model, dataset, steps, world, seqs_per_step):
|
| 187 |
-
"""The same data path, optimizer and fp16 recipe as the real run, with no Trainer in the loop.
|
| 188 |
-
|
| 189 |
-
Why this exists: P2 measured 5,105 tok/s for 22L/seq-1024 where probe p0c measured 11,062 tok/s for a
|
| 190 |
-
near-identical shape with a hand-rolled loop. The gap is either (a) the attention kernel, (b) per-step
|
| 191 |
-
framework overhead, or (c) a measurement artefact, and which one it is decides whether the main run
|
| 192 |
-
needs 27 h or 68 h. Guessing wrong in either direction is expensive, so measure both paths.
|
| 193 |
-
"""
|
| 194 |
-
import torch.distributed as dist
|
| 195 |
-
from torch.nn.parallel import DistributedDataParallel as DDP
|
| 196 |
-
from torch.utils.data import DataLoader
|
| 197 |
-
|
| 198 |
-
local_rank = int(os.environ.get("LOCAL_RANK", "0"))
|
| 199 |
-
device = torch.device("cuda", local_rank)
|
| 200 |
-
torch.cuda.set_device(device)
|
| 201 |
-
if world > 1 and not dist.is_initialized():
|
| 202 |
-
dist.init_process_group("nccl")
|
| 203 |
-
model = model.to(device)
|
| 204 |
-
if args.grad_ckpt:
|
| 205 |
-
model.gradient_checkpointing_enable()
|
| 206 |
-
model.config.use_cache = False
|
| 207 |
-
net = DDP(model, device_ids=[local_rank]) if world > 1 else model
|
| 208 |
-
|
| 209 |
-
opt = torch.optim.AdamW(net.parameters(), lr=args.lr, betas=(0.9, 0.95),
|
| 210 |
-
weight_decay=0.1, eps=1e-8)
|
| 211 |
-
try:
|
| 212 |
-
scaler = torch.amp.GradScaler("cuda", enabled=True) # torch >= 2.4
|
| 213 |
-
except TypeError:
|
| 214 |
-
scaler = torch.cuda.amp.GradScaler(enabled=True) # older signature
|
| 215 |
-
# Same reader, same shuffle, no accumulation: the comparison against the Trainer cells is then
|
| 216 |
-
# per-step cost with the framework removed, and nothing else differs.
|
| 217 |
-
dl = DataLoader(dataset, batch_size=args.micro_batch, shuffle=False, num_workers=2,
|
| 218 |
-
pin_memory=False, drop_last=True)
|
| 219 |
-
t0 = time.time()
|
| 220 |
-
toks = 0
|
| 221 |
-
for i, batch in enumerate(dl):
|
| 222 |
-
if i >= steps:
|
| 223 |
-
break
|
| 224 |
-
batch = {k: v.to(device, non_blocking=True) for k, v in batch.items()}
|
| 225 |
-
opt.zero_grad(set_to_none=True)
|
| 226 |
-
with torch.autocast("cuda", dtype=torch.float16):
|
| 227 |
-
out = net(**batch)
|
| 228 |
-
scaler.scale(out.loss).backward()
|
| 229 |
-
scaler.unscale_(opt)
|
| 230 |
-
torch.nn.utils.clip_grad_norm_(net.parameters(), 1.0)
|
| 231 |
-
scaler.step(opt)
|
| 232 |
-
scaler.update()
|
| 233 |
-
toks += batch["input_ids"].numel()
|
| 234 |
-
if i == 0:
|
| 235 |
-
torch.cuda.synchronize()
|
| 236 |
-
t0 = time.time() # exclude CUDA context + first-trace cost from the rate
|
| 237 |
-
toks = 0
|
| 238 |
-
sec = time.time() - t0
|
| 239 |
-
res = {"benchmark_steps": i + 1 if steps else 0, "tok_per_s": round(toks / max(1e-6, sec)),
|
| 240 |
-
"seconds": round(sec, 1), "tokens": toks,
|
| 241 |
-
"peak_gpu_gb": round(torch.cuda.max_memory_allocated() / 1e9, 2),
|
| 242 |
-
"world": world, "seq_len": args.seq_len, "micro_batch": args.micro_batch,
|
| 243 |
-
"attn": args.attn, "grad_ckpt": bool(args.grad_ckpt),
|
| 244 |
-
"params": sum(p.numel() for p in model.parameters())}
|
| 245 |
-
print("BENCH_JSON " + json.dumps(res, sort_keys=True), flush=True)
|
| 246 |
-
if world > 1:
|
| 247 |
-
dist.barrier()
|
| 248 |
-
dist.destroy_process_group()
|
| 249 |
-
return res
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
def main():
|
| 253 |
-
ap = argparse.ArgumentParser()
|
| 254 |
-
ap.add_argument("--root", default="/kaggle/working/mixroot")
|
| 255 |
-
ap.add_argument("--out", default="/kaggle/working/run")
|
| 256 |
-
ap.add_argument("--hub-repo", default="", help="dataset repo for checkpoints; empty = no upload")
|
| 257 |
-
ap.add_argument("--hub-repo-type", default="dataset", choices=["dataset", "model"])
|
| 258 |
-
# D-011: these ARE the frozen main-run values (measured 7,732-7,828 tok/s at 1024/eager on 2xT4), so
|
| 259 |
-
# that launching the run does not depend on remembering a flag string.
|
| 260 |
-
ap.add_argument("--seq-len", type=int, default=1024)
|
| 261 |
-
ap.add_argument("--attn", default="eager", choices=["eager", "sdpa", "flash_attention_2"],
|
| 262 |
-
help="p0c measured eager beating SDPA on Turing; Gate 3 P2/A-B measured +52 %% for "
|
| 263 |
-
"eager at seq 1024 and an OOM at 2048. flash_attention_2 needs sm80+")
|
| 264 |
-
ap.add_argument("--grad-ckpt", default=True, action=argparse.BooleanOptionalAction,
|
| 265 |
-
help="on by default: it is what keeps peak memory at ~7 GB instead of 12 GB, and the "
|
| 266 |
-
"measured rate cost at 1024 was nil (7,828 with GC at m8 vs 7,732 without at m4)")
|
| 267 |
-
ap.add_argument("--layers", type=int, default=0)
|
| 268 |
-
ap.add_argument("--hidden", type=int, default=0)
|
| 269 |
-
ap.add_argument("--micro-batch", type=int, default=4,
|
| 270 |
-
help="4 x 1024 x 32 accum x 2 cards = 262,144 tokens/step, the frozen batch size")
|
| 271 |
-
ap.add_argument("--accum", type=int, default=32)
|
| 272 |
-
ap.add_argument("--lr", type=float, default=6e-4)
|
| 273 |
-
ap.add_argument("--optim", default="adamw_torch",
|
| 274 |
-
help="only mathematically-equivalent implementations are fair game at freeze time "
|
| 275 |
-
"(adamw_torch / adamw_torch_fused / adamw_apex_fused); 8-bit variants change the "
|
| 276 |
-
"update, so they are excluded by rule, not by preference")
|
| 277 |
-
ap.add_argument("--gc-nonreentrant", default=False, action=argparse.BooleanOptionalAction,
|
| 278 |
-
help="pass use_reentrant=False to gradient checkpointing: same recomputation, "
|
| 279 |
-
"different autograd path, and reports on whether it is faster under DDP conflict")
|
| 280 |
-
ap.add_argument("--torch-compile", default=False, action=argparse.BooleanOptionalAction,
|
| 281 |
-
help="Trainer's torch_compile hook; fused kernels, no change to the update rule")
|
| 282 |
-
ap.add_argument("--tokens", type=int, default=1_000_000_000)
|
| 283 |
-
ap.add_argument("--max-steps", type=int, default=0, help="0 = derive from --tokens; >0 overrides")
|
| 284 |
-
ap.add_argument("--warmup-frac", type=float, default=0.02)
|
| 285 |
-
ap.add_argument("--decay-frac", type=float, default=0.80)
|
| 286 |
-
ap.add_argument("--seed", type=int, default=20260919)
|
| 287 |
-
ap.add_argument("--data-seed", type=int, default=20260919)
|
| 288 |
-
ap.add_argument("--resume", default="", help="checkpoint dir; empty = auto from Hub latest")
|
| 289 |
-
ap.add_argument("--val-tokens", type=int, default=2_000_000)
|
| 290 |
-
ap.add_argument("--log-every", type=int, default=10)
|
| 291 |
-
ap.add_argument("--push-every-steps", type=int, default=0, help="0 = only at 10%% boundaries")
|
| 292 |
-
ap.add_argument("--stop-after-steps", type=int, default=0,
|
| 293 |
-
help="absolute global step at which THIS session stops training, so it ends on a "
|
| 294 |
-
"verified checkpoint instead of being killed between two of them. Must be a "
|
| 295 |
-
"whole multiple of the checkpoint interval; 0 = run to the horizon.")
|
| 296 |
-
ap.add_argument("--prune", action="store_true", help="delete local checkpoint after hub verify")
|
| 297 |
-
ap.add_argument("--dry-run", action="store_true", help="build everything, train 0 steps")
|
| 298 |
-
ap.add_argument("--benchmark", type=int, default=0,
|
| 299 |
-
help="N steps with no Trainer in the loop (isolates framework per-step cost)")
|
| 300 |
-
args = ap.parse_args()
|
| 301 |
-
|
| 302 |
-
rank = int(os.environ.get("RANK", "0"))
|
| 303 |
-
world = int(os.environ.get("WORLD_SIZE", "1"))
|
| 304 |
-
say = (lambda *m: print(*m, flush=True)) if rank == 0 else (lambda *m: None)
|
| 305 |
-
|
| 306 |
-
man_path = os.path.join(args.root, "manifest.json")
|
| 307 |
-
if not os.path.exists(man_path):
|
| 308 |
-
raise SystemExit(f"no manifest at {man_path}: build the mix first (Phase 2 is not optional)")
|
| 309 |
-
man = json.load(open(man_path))
|
| 310 |
-
if man["total_tokens"] < args.tokens:
|
| 311 |
-
say(f"WARNING mix has {man['total_tokens']:,} tokens < target {args.tokens:,}")
|
| 312 |
-
|
| 313 |
-
store = SD.PackedTokenStore(args.root, man)
|
| 314 |
-
fingerprint = SD.mix_fingerprint(args.root, man)
|
| 315 |
-
# One definition of the step, used by the cursor, the schedule and the token count: a step consumes
|
| 316 |
-
# micro_batch * world sequences (DDP shards the batch), so tokens/step follows from it.
|
| 317 |
-
seqs_per_step = args.micro_batch * world * args.accum
|
| 318 |
-
tokens_per_step = seqs_per_step * args.seq_len
|
| 319 |
-
steps_planned = args.max_steps or int(args.tokens / tokens_per_step)
|
| 320 |
-
say(f"mix: {store.total_tokens:,} tokens in {len(store.shards)} shards fp={fingerprint} "
|
| 321 |
-
f"| {tokens_per_step:,} tok/step x {steps_planned} steps = "
|
| 322 |
-
f"{tokens_per_step * steps_planned:,} tokens")
|
| 323 |
-
|
| 324 |
-
# ---- resume point: the cursor is the run's identity as much as the weights are.
|
| 325 |
-
# `--resume auto` is what a fresh instance with an empty disk passes: it asks the Hub where the run
|
| 326 |
-
# stopped and pulls that checkpoint down. §3.1 forbids recovering from anything left on an instance.
|
| 327 |
-
if args.resume == "auto":
|
| 328 |
-
if not args.hub_repo:
|
| 329 |
-
raise SystemExit("--resume auto needs --hub-repo")
|
| 330 |
-
import ounce100m_credentials
|
| 331 |
-
ounce100m_credentials.install()
|
| 332 |
-
from huggingface_hub import HfApi
|
| 333 |
-
tk = os.environ.get("HF_TOKEN")
|
| 334 |
-
ptr = hubckpt.latest_pointer(args.hub_repo, repo_type=args.hub_repo_type, token=tk)
|
| 335 |
-
if ptr.get("not_found"):
|
| 336 |
-
# A 404 is normally a fact -- "this run has never checkpointed" -- and step 0 is then right.
|
| 337 |
-
# But a missing pointer beside existing checkpoints means the pointer upload failed after a
|
| 338 |
-
# good checkpoint, and starting from zero would silently retrain the corpus from the top,
|
| 339 |
-
# which §3.1 forbids and E-018 is about. So list the repo before believing the 404.
|
| 340 |
-
try:
|
| 341 |
-
listed = sorted(k for k in hubckpt.hub_listing(args.hub_repo, args.hub_repo_type,
|
| 342 |
-
token=tk)
|
| 343 |
-
if k.startswith("ckpt/checkpoint-"))
|
| 344 |
-
except hubckpt.RepoMissing:
|
| 345 |
-
# The checkpoint repo has never been created: nothing has ever been pushed, so step 0 is
|
| 346 |
-
# the only correct answer. The first session of the run always takes this branch, and
|
| 347 |
-
# treating a 404 as "listing failed" would refuse it (a generic `except Exception` below
|
| 348 |
-
# makes the list non-empty and the refusal fires).
|
| 349 |
-
say(f"{args.hub_repo} does not exist yet -- nothing has ever been pushed")
|
| 350 |
-
listed = []
|
| 351 |
-
except Exception as e:
|
| 352 |
-
listed = [f"<listing failed: {type(e).__name__}>"]
|
| 353 |
-
if listed:
|
| 354 |
-
raise SystemExit(f"--resume auto found no latest.json on {args.hub_repo}, but the repo "
|
| 355 |
-
f"holds {len(listed)} checkpoint path(s) ({listed[:3]}). The pointer is "
|
| 356 |
-
"missing, not the run -- refusing to start from step 0.")
|
| 357 |
-
say(f"no latest.json and no checkpoints on {args.hub_repo} -> starting from step 0")
|
| 358 |
-
args.resume = ""
|
| 359 |
-
elif "error" in ptr:
|
| 360 |
-
# Anything else (401, timeout, DNS) means the lookup failed, not that there is nothing there.
|
| 361 |
-
# Falling through to step 0 here would silently retrain the first stretch of the run, which is
|
| 362 |
-
# exactly the §3.1 violation this whole branch exists to prevent (E-018's shape).
|
| 363 |
-
raise SystemExit(f"--resume auto could not read the hub pointer: {ptr['error']}")
|
| 364 |
-
else:
|
| 365 |
-
# A pointer is a resume point only if nothing newer was ever pushed. This scan catches the one
|
| 366 |
-
# case write_latest_pointer's own read-back cannot see: a pointer left behind by a rollback, a
|
| 367 |
-
# deletion, or a session that died between its checkpoint push and its pointer roll. Resuming
|
| 368 |
-
# from the stale pointer would re-train an interval with weights and a cursor that agree with
|
| 369 |
-
# each other and disagree with the Hub.
|
| 370 |
-
import re as _re
|
| 371 |
-
steps_on_hub = set()
|
| 372 |
-
for attempt in (1, 2):
|
| 373 |
-
try:
|
| 374 |
-
steps_on_hub = {int(m.group(1)) for k in
|
| 375 |
-
hubckpt.hub_listing(args.hub_repo, args.hub_repo_type, token=tk)
|
| 376 |
-
if (m := _re.match(r"ckpt/checkpoint-(\d+)/", k))}
|
| 377 |
-
break
|
| 378 |
-
except Exception as e:
|
| 379 |
-
say(f" checkpoint scan attempt {attempt}: {type(e).__name__}: {str(e)[:140]}")
|
| 380 |
-
time.sleep(15)
|
| 381 |
-
else:
|
| 382 |
-
raise SystemExit("--resume auto could not enumerate the checkpoint repo, so it cannot "
|
| 383 |
-
f"prove latest.json (step {ptr.get('step')}) is the newest state")
|
| 384 |
-
if steps_on_hub and max(steps_on_hub) > int(ptr.get("step") or 0):
|
| 385 |
-
raise SystemExit(f"latest.json points at step {ptr.get('step')} but {args.hub_repo} also "
|
| 386 |
-
f"holds checkpoint-{max(steps_on_hub)}: the pointer is behind the run. "
|
| 387 |
-
"Resuming here would re-train that interval, so refusing.")
|
| 388 |
-
# Both ranks need the checkpoint on disk, and this block runs before TrainingArguments has
|
| 389 |
-
# created a process group -- so there is no barrier to synchronise on. Downloading into a
|
| 390 |
-
# per-rank directory instead removes the race entirely (two writers, one path), at the cost of
|
| 391 |
-
# a second 1.7 GB pull that happens in parallel. Each rank then reads its own verified copy and
|
| 392 |
-
# derives the same start_sample/start_step from the same cursor.json bytes.
|
| 393 |
-
dest = os.path.join(args.out, f"resume_r{rank}",
|
| 394 |
-
os.path.basename(ptr["path_in_repo"]))
|
| 395 |
-
say(f"auto-resume: hub says step {ptr.get('step')} at {ptr['path_in_repo']}")
|
| 396 |
-
got = hubckpt.download_checkpoint(args.hub_repo, ptr["path_in_repo"], dest, HfApi(),
|
| 397 |
-
repo_type=args.hub_repo_type, token=tk)
|
| 398 |
-
say(f"downloaded {got['files']} files, {got['bytes']/1e6:.0f} MB in {got['seconds']}s")
|
| 399 |
-
if got["bytes"] < 1_000_000:
|
| 400 |
-
raise SystemExit("auto-resume downloaded a checkpoint that is not real weights")
|
| 401 |
-
args.resume = got["dir"]
|
| 402 |
-
say(f"resuming from {args.resume}")
|
| 403 |
-
cur = SD.load_cursor(args.resume) if args.resume else None
|
| 404 |
-
if args.resume and cur is None:
|
| 405 |
-
# Weights at step N with the data stream back at sample 0 would retrain the opening of the corpus
|
| 406 |
-
# and report a continuous loss curve. Refuse instead.
|
| 407 |
-
raise SystemExit(f"resume refused: {args.resume} has no cursor.json -- a checkpoint without a "
|
| 408 |
-
"recorded data position cannot be resumed, only restarted (§3.1)")
|
| 409 |
-
if args.resume:
|
| 410 |
-
say(f"resume from {args.resume} cursor={cur}")
|
| 411 |
-
start_sample = (cur or {}).get("samples_consumed", 0)
|
| 412 |
-
start_step = (cur or {}).get("step") or 0
|
| 413 |
-
# Identity of the visit order, derived from the corpus size and the seed rather than trusted to the
|
| 414 |
-
# seed: numpy's permutation is only stable within a version, and Kaggle updates its image underneath
|
| 415 |
-
# long runs. ~50 ms once per session, and it is what makes "resumed onto a different order" loud.
|
| 416 |
-
order_sha = SD.perm_sha(store.total_tokens // args.seq_len, args.data_seed)
|
| 417 |
-
if cur:
|
| 418 |
-
for k, v in (("seq_len", args.seq_len), ("shuffle_seed", args.data_seed),
|
| 419 |
-
("shuffle_perm_sha", order_sha), ("dataset_files_sha", fingerprint)):
|
| 420 |
-
if cur.get(k) != v:
|
| 421 |
-
raise SystemExit(f"resume refused: cursor {k}={cur.get(k)} != current {v}. "
|
| 422 |
-
"A resumed run must read the same data in the same order (§3.1).")
|
| 423 |
-
if start_sample % seqs_per_step:
|
| 424 |
-
raise SystemExit(f"resume refused: cursor samples_consumed={start_sample} is not a whole "
|
| 425 |
-
f"number of steps (seqs/step={seqs_per_step}) -- batch geometry changed")
|
| 426 |
-
if cur.get("step") is not None and cur["step"] * seqs_per_step != start_sample:
|
| 427 |
-
raise SystemExit(f"resume refused: cursor step={cur['step']} and samples_consumed="
|
| 428 |
-
f"{start_sample} disagree at seqs/step={seqs_per_step}")
|
| 429 |
-
if start_step * seqs_per_step != start_sample:
|
| 430 |
-
start_step = start_sample // seqs_per_step
|
| 431 |
-
|
| 432 |
-
train_ds = SD.make_dataset(store, args.seq_len, args.data_seed, start_sample=start_sample)
|
| 433 |
-
assert getattr(train_ds, "order_sha", None) == order_sha, "two derivations of the order disagree"
|
| 434 |
-
# Validation comes from the mix's held-out val/ shards, never from the training stream: scoring on
|
| 435 |
-
# tokens the model already consumed would be a number nobody could reproduce (and §3.12).
|
| 436 |
-
vs = [s["file"] for s in man.get("val_shards", [])]
|
| 437 |
-
if not vs:
|
| 438 |
-
raise SystemExit("manifest lists no val_shards -- Gate 2 requires a held-out split")
|
| 439 |
-
vstore = SD.PackedTokenStore(args.root, man, files=vs)
|
| 440 |
-
n_val = min(max(1, args.val_tokens // args.seq_len), vstore.total_tokens // args.seq_len)
|
| 441 |
-
val_ds = SD.make_dataset(vstore, args.seq_len, args.data_seed, shuffle=False, count=n_val)
|
| 442 |
-
need = (steps_planned - start_step) * seqs_per_step
|
| 443 |
-
if len(train_ds) < need:
|
| 444 |
-
# Reading past the end of the stream makes the Trainer begin epoch 2 at the top of the same
|
| 445 |
-
# permutation: the run would then report the full token target while quietly re-training on the
|
| 446 |
-
# first windows. The mix is built with ~12 % headroom over the target for exactly this reason, so
|
| 447 |
-
# arriving here means that assumption stopped being true (review finding), and the honest response
|
| 448 |
-
# is to stop before step 1 rather than to discover it in the final report.
|
| 449 |
-
raise SystemExit(
|
| 450 |
-
f"dataset too small for the plan: {len(train_ds):,} windows available from sample "
|
| 451 |
-
f"{start_sample:,}, {need:,} needed for {steps_planned} steps x {seqs_per_step} sequences "
|
| 452 |
-
f"({need * args.seq_len:,} tokens). Lower --tokens to the 0.9 B fallback (D-012) or rebuild "
|
| 453 |
-
"the mix larger -- never let the epoch wrap.")
|
| 454 |
-
say(f"data: {len(train_ds):,} training windows available, {need:,} needed "
|
| 455 |
-
f"({100.0 * len(train_ds) / need - 100:.1f} % margin), {n_val:,} held-out val windows")
|
| 456 |
-
|
| 457 |
-
cfg = build_config(args.seq_len, args.layers or None, args.hidden or None, attn=args.attn)
|
| 458 |
-
set_seed(args.seed) # before init: both ranks must start from identical weights, not two draws
|
| 459 |
-
model = LlamaForCausalLM(cfg).to(dtype=torch.float32)
|
| 460 |
-
pc = count_params(model)
|
| 461 |
-
say("params:", json.dumps(pc))
|
| 462 |
-
if not (90_000_000 <= pc["sum_numel"] <= 110_000_000) and not args.layers and not args.hidden:
|
| 463 |
-
raise SystemExit(f"parameter count {pc['sum_numel']:,} is outside §2's 90-110M budget")
|
| 464 |
-
|
| 465 |
-
if args.benchmark:
|
| 466 |
-
# Both ranks iterate the same samples here (no DistributedSampler): this path measures step cost,
|
| 467 |
-
# not a training run, and it must never be used to produce a checkpoint.
|
| 468 |
-
say(f"benchmark mode: {args.benchmark} steps, no Trainer, micro={args.micro_batch} "
|
| 469 |
-
f"seq={args.seq_len} attn={args.attn} world={world}")
|
| 470 |
-
run_benchmark(args, model, train_ds, args.benchmark, world, seqs_per_step)
|
| 471 |
-
return
|
| 472 |
-
|
| 473 |
-
# 10 checkpoints per run is §2's cadence; --push-every-steps overrides for the preflight.
|
| 474 |
-
ckpt_every = args.push_every_steps or max(1, steps_planned // 10)
|
| 475 |
-
if args.stop_after_steps:
|
| 476 |
-
if args.stop_after_steps % ckpt_every:
|
| 477 |
-
raise SystemExit(f"--stop-after-steps {args.stop_after_steps} is not a whole multiple of the "
|
| 478 |
-
f"checkpoint interval {ckpt_every}: the session would end between two "
|
| 479 |
-
f"checkpoints, which is exactly what this flag exists to prevent")
|
| 480 |
-
if args.stop_after_steps > steps_planned:
|
| 481 |
-
raise SystemExit(f"--stop-after-steps {args.stop_after_steps} exceeds the horizon "
|
| 482 |
-
f"{steps_planned}; use 0 to run to the end")
|
| 483 |
-
|
| 484 |
-
api = None
|
| 485 |
-
if args.hub_repo:
|
| 486 |
-
import ounce100m_credentials
|
| 487 |
-
ounce100m_credentials.install()
|
| 488 |
-
from huggingface_hub import HfApi
|
| 489 |
-
api = HfApi()
|
| 490 |
-
# rank 0 only: two ranks committing the same .gitattributes to a brand-new repo is a write race
|
| 491 |
-
# on the one path that must not fail (only rank 0 ever uploads checkpoints, so rank 1 needs none)
|
| 492 |
-
if rank == 0:
|
| 493 |
-
say("ckpt repo:", json.dumps(hubckpt.ensure_repo(args.hub_repo, api,
|
| 494 |
-
os.environ.get("HF_TOKEN"),
|
| 495 |
-
args.hub_repo_type)))
|
| 496 |
-
say(f"checkpoint hub target {args.hub_repo} (prune={args.prune}, every {ckpt_every} steps)")
|
| 497 |
-
|
| 498 |
-
tacc = TrainingArguments(
|
| 499 |
-
output_dir=args.out,
|
| 500 |
-
per_device_train_batch_size=args.micro_batch,
|
| 501 |
-
# Same geometry for the validation pass: with the library default (8) the end-of-run eval measures
|
| 502 |
-
# a peak that has nothing to do with the training configuration, which is how an OOM in the no-
|
| 503 |
-
# checkpointing cell could be mistaken for a passing one (review finding).
|
| 504 |
-
per_device_eval_batch_size=args.micro_batch,
|
| 505 |
-
gradient_accumulation_steps=args.accum,
|
| 506 |
-
learning_rate=args.lr,
|
| 507 |
-
weight_decay=0.1,
|
| 508 |
-
adam_beta1=0.9, adam_beta2=0.95, adam_epsilon=1e-8,
|
| 509 |
-
max_grad_norm=1.0,
|
| 510 |
-
lr_scheduler_type="constant", # replaced by TrapezoidTrainer.create_scheduler
|
| 511 |
-
warmup_ratio=0.0, # the trapezoid owns warmup
|
| 512 |
-
num_train_epochs=1,
|
| 513 |
-
max_steps=0 if args.dry_run else steps_planned,
|
| 514 |
-
fp16=True, bf16=False,
|
| 515 |
-
# measured decision, not a default: A-B found eager attention 52 % faster at seq 1024 and OOM at
|
| 516 |
-
# 2048; checkpointing is what makes eager at 2048 possible at all, at the cost of a recompute pass
|
| 517 |
-
gradient_checkpointing=args.grad_ckpt,
|
| 518 |
-
# Same recomputation, different autograd path -- offered as a cells-vs-baseline question, not a
|
| 519 |
-
# belief. Reports on use_reentrant=False under DDP contradict each other across versions.
|
| 520 |
-
gradient_checkpointing_kwargs={"use_reentrant": not args.gc_nonreentrant}
|
| 521 |
-
if args.grad_ckpt else None,
|
| 522 |
-
optim=args.optim,
|
| 523 |
-
torch_compile=args.torch_compile,
|
| 524 |
-
ddp_find_unused_parameters=False,
|
| 525 |
-
# Rank 0 spends minutes inside on_save uploading ~1.7 GB, listing the repo and downloading one file
|
| 526 |
-
# back to hash it, while rank 1 waits at the next collective. The library's 1,800 s default would
|
| 527 |
-
# abort the run on a slow-egress Kaggle box -- precisely during the checkpoint that the other rank
|
| 528 |
-
# was protecting. Four hours makes the collective patient without hiding a real deadlock.
|
| 529 |
-
ddp_timeout=14400,
|
| 530 |
-
dataloader_num_workers=2,
|
| 531 |
-
dataloader_pin_memory=False,
|
| 532 |
-
remove_unused_columns=False, # our dict keys must reach the model untouched
|
| 533 |
-
ignore_data_skip=True, # see header note 1 -- we advance position ourselves
|
| 534 |
-
save_strategy="no" if args.dry_run else "steps",
|
| 535 |
-
save_steps=ckpt_every,
|
| 536 |
-
save_total_limit=1, # §3.13: the Hub is the store, the instance is scratch
|
| 537 |
-
logging_steps=args.log_every, report_to=[],
|
| 538 |
-
seed=args.seed, data_seed=args.seed,
|
| 539 |
-
accelerator_config={"dispatch_batches": False},
|
| 540 |
-
average_tokens_across_devices=False,
|
| 541 |
-
)
|
| 542 |
-
|
| 543 |
-
tr = TrapezoidTrainer(
|
| 544 |
-
model=model, args=tacc, train_dataset=train_ds, eval_dataset=val_ds,
|
| 545 |
-
data_collator=default_data_collator, lr_shape={
|
| 546 |
-
"warmup_steps": max(100, int(steps_planned * args.warmup_frac)),
|
| 547 |
-
"decay_start": int(steps_planned * args.decay_frac),
|
| 548 |
-
"total_steps": steps_planned},
|
| 549 |
-
)
|
| 550 |
-
hub_cb = None
|
| 551 |
-
if api is not None:
|
| 552 |
-
hub_cb = HubPush(args.hub_repo, api, os.environ.get("HF_TOKEN"), args.seq_len,
|
| 553 |
-
args.data_seed, fingerprint, seqs_per_step,
|
| 554 |
-
prune=args.prune, repo_type=args.hub_repo_type,
|
| 555 |
-
stop_after_steps=args.stop_after_steps, order_sha=order_sha)
|
| 556 |
-
tr.add_callback(hub_cb)
|
| 557 |
-
|
| 558 |
-
# Precision must be ASSERTED, not configured: bf16 "works" silently on Turing and fp32 master
|
| 559 |
-
# weights can be lost in a refactor (docs/00-platform-notes.md §8, memory/ERRORS.md E-006). The
|
| 560 |
-
# scaler's home moved between transformers versions, so look in both places instead of trusting one
|
| 561 |
-
# attribute path (E-010: prefer version-free surfaces).
|
| 562 |
-
scaler = getattr(tr, "scaler", None) or getattr(getattr(tr, "accelerator", None), "scaler", None)
|
| 563 |
-
eff_attn = (getattr(model.config, "_attn_implementation", None)
|
| 564 |
-
or getattr(model.config, "attn_implementation", None))
|
| 565 |
-
say(f"precision: fp16={tacc.fp16} bf16={tacc.bf16} scaler={type(scaler).__name__} "
|
| 566 |
-
f"master_dtype={next(model.parameters()).dtype} requested_attn={args.attn} "
|
| 567 |
-
f"effective_attn={eff_attn}")
|
| 568 |
-
assert tacc.fp16 and not tacc.bf16, "not the frozen T4 fp16 recipe"
|
| 569 |
-
assert next(model.parameters()).dtype == torch.float32, "master weights are not fp32"
|
| 570 |
-
|
| 571 |
-
prior = None
|
| 572 |
-
if args.resume:
|
| 573 |
-
tsp = os.path.join(args.resume, "trainer_state.json")
|
| 574 |
-
if os.path.exists(tsp):
|
| 575 |
-
h0 = [h for h in json.load(open(tsp)).get("log_history", []) if "loss" in h]
|
| 576 |
-
prior = h0[-1]["loss"] if h0 else None
|
| 577 |
-
say(f"prior loss at resume: {prior} -- loss continuity is judged against this number")
|
| 578 |
-
|
| 579 |
-
if args.dry_run:
|
| 580 |
-
say(json.dumps({"dry_run": True, "train_samples": len(train_ds), "val_samples": len(val_ds),
|
| 581 |
-
"steps_planned": steps_planned, "ckpt_every": ckpt_every,
|
| 582 |
-
"params": pc, "fingerprint": fingerprint, "scaler": type(scaler).__name__}))
|
| 583 |
-
return
|
| 584 |
-
|
| 585 |
-
t0 = time.time()
|
| 586 |
-
tr.train(resume_from_checkpoint=args.resume or None)
|
| 587 |
-
|
| 588 |
-
elapsed = time.time() - t0
|
| 589 |
-
step = tr.state.global_step
|
| 590 |
-
# Did this segment really end on a pushed checkpoint? `stopped_at` is set when the step counter reaches
|
| 591 |
-
# the target, but performing the save at that step is the library's decision. If it ever ordered the
|
| 592 |
-
# stop ahead of the save, the session would exit 0 having pushed nothing, and every relaunch would
|
| 593 |
-
# redo the same interval while `latest` stayed put -- which looks like a stuck run, not a broken one.
|
| 594 |
-
if hub_cb is not None and args.stop_after_steps:
|
| 595 |
-
if args.stop_after_steps <= start_step:
|
| 596 |
-
raise SystemExit(f"--stop-after-steps {args.stop_after_steps} is not beyond the resume point "
|
| 597 |
-
f"step {start_step}: this session would train nothing")
|
| 598 |
-
if step < args.stop_after_steps:
|
| 599 |
-
raise SystemExit(f"asked to stop at step {args.stop_after_steps}, stopped at {step}")
|
| 600 |
-
if args.stop_after_steps < steps_planned and args.stop_after_steps not in hub_cb.steps:
|
| 601 |
-
raise SystemExit(f"segment ended at step {step} but no checkpoint was pushed and verified "
|
| 602 |
-
f"for it (pushed: {sorted(hub_cb.steps)}). The next session would resume "
|
| 603 |
-
"from the previous pointer and re-read this interval.")
|
| 604 |
-
samples = samples_at(step, seqs_per_step)
|
| 605 |
-
consumed = samples * args.seq_len
|
| 606 |
-
consumed_this_run = max(0, step - start_step) * tokens_per_step
|
| 607 |
-
hist = [h for h in tr.state.log_history if "loss" in h]
|
| 608 |
-
final_loss = hist[-1]["loss"] if hist else None
|
| 609 |
-
say(f"TRAIN DONE step={step}/{steps_planned} elapsed={elapsed/60:.1f} min "
|
| 610 |
-
f"tokens_consumed={consumed:,} last_loss={final_loss}")
|
| 611 |
-
# E-006, again: a run that produced NaN still reports a confident tokens/sec. Never let that pass.
|
| 612 |
-
if final_loss is None or not math.isfinite(final_loss):
|
| 613 |
-
raise SystemExit(f"final loss is not finite ({final_loss}) -- not exporting anything")
|
| 614 |
-
|
| 615 |
-
# Validation first, and on every rank: `evaluate()` is collective, so gating it would deadlock.
|
| 616 |
-
ppl = None
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
if rank == 0:
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
"
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# The training run in one script: frozen architecture, map-style packed data, fp16 on T4, trapezoid LR,
|
| 2 |
+
# and a checkpoint cycle that satisfies §3.13 by construction.
|
| 3 |
+
|
| 4 |
+
# Deliberately thin glue over `transformers.Trainer` (docs/01-plan.md §5): data loading, DDP, gradient
|
| 5 |
+
# accumulation, scaler, RNG capture and resumption are the framework's job. What is ours is the shard
|
| 6 |
+
# reader, the LR shape, and the upload/verify/prune discipline -- the three things a generic stack cannot
|
| 7 |
+
# know about this project.
|
| 8 |
+
|
| 9 |
+
# Two resume facts that are easy to get wrong and are asserted here rather than assumed:
|
| 10 |
+
# 1. `ignore_data_skip=True`. Trainer's own resume would replay discarded batches; we advance the data
|
| 11 |
+
# position by slicing the permutation in shard_dataset instead. Leaving the flag at False would
|
| 12 |
+
# double-skip and silently LOSE training data -- the exact §3.1 violation.
|
| 13 |
+
# 2. The cursor is checked against the checkpoint, not trusted. A resumed run whose cursor, seed,
|
| 14 |
+
# sequence length or corpus fingerprint disagrees with what is on disk stops before step 1.
|
| 15 |
+
|
| 16 |
+
import argparse
|
| 17 |
+
import json
|
| 18 |
+
import math
|
| 19 |
+
import os
|
| 20 |
+
import time
|
| 21 |
+
|
| 22 |
+
import torch
|
| 23 |
+
from torch.optim.lr_scheduler import LambdaLR
|
| 24 |
+
from transformers import (LlamaConfig, LlamaForCausalLM, Trainer, TrainerCallback,
|
| 25 |
+
TrainingArguments, default_data_collator, set_seed)
|
| 26 |
+
|
| 27 |
+
import hubckpt
|
| 28 |
+
import shard_dataset as SD
|
| 29 |
+
|
| 30 |
+
# ---- frozen at Gate 1 (docs/01-plan.md §2.3, §4). Only --layers/--hidden change, for test T1.
|
| 31 |
+
BASE = dict(hidden=576, layers=22, heads=9, kv_heads=3, ffn=1536, vocab=49152,
|
| 32 |
+
rope_theta=10000.0, rms_eps=1e-5, tie=True)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _kv_dividing(heads, hidden):
|
| 36 |
+
"""GQA requires num_key_value_heads to divide num_attention_heads; SDPA raises otherwise
|
| 37 |
+
("Number of heads in key and value must divide the number of heads in Q"). Variant shapes are built
|
| 38 |
+
from hidden/64, which need not divide 9 -- take the largest divisor of `heads` that fits."""
|
| 39 |
+
per_head = max(1, hidden // 64)
|
| 40 |
+
return max(d for d in range(1, min(per_head, heads) + 1) if heads % d == 0)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def build_config(seq_len, layers=None, hidden=None, attn="eager"):
|
| 44 |
+
"""The frozen shape, and the two variants test T1 needs (20L at the same width, and a wider/shallower
|
| 45 |
+
alternative). SwiGLU width is int(2*4h/3) rounded to a 64-multiple -- 1536 at h=576, which is the
|
| 46 |
+
value the frozen 106,194,240 count was derived from. Getting this factor wrong silently changes the
|
| 47 |
+
parameter count and makes T1 compare two different models.
|
| 48 |
+
|
| 49 |
+
`attn` is explicit because it is a measured choice, not a default: probe p0c found eager attention
|
| 50 |
+
beating memory-efficient SDPA at seq 1024 on Turing (docs/00-platform-notes.md §9), and Gate 3 P2
|
| 51 |
+
then clocked 4,071 tok/s with the transformers default -- half the rate p0c saw in a raw loop. Leaving
|
| 52 |
+
the attention kernel to whatever the library defaults to is how a 68-hour run becomes a 27-hour run.
|
| 53 |
+
"""
|
| 54 |
+
h = hidden or BASE["hidden"]
|
| 55 |
+
L = layers or BASE["layers"]
|
| 56 |
+
# "variant" means a different WIDTH, not merely a different layer count: the frozen GQA ratio (9 Q /
|
| 57 |
+
# 3 KV) belongs to the frozen width, and passing --hidden 576 explicitly must not quietly turn the
|
| 58 |
+
# model into MHA-9 and make test T1 measure something else.
|
| 59 |
+
frozen_width = (h == BASE["hidden"])
|
| 60 |
+
inter = BASE["ffn"] if frozen_width else (int(round(8 * h / 3)) // 64) * 64
|
| 61 |
+
kv = BASE["kv_heads"] if frozen_width else _kv_dividing(BASE["heads"], h)
|
| 62 |
+
cfg = LlamaConfig(vocab_size=BASE["vocab"], hidden_size=h, intermediate_size=inter,
|
| 63 |
+
num_hidden_layers=L, num_attention_heads=BASE["heads"],
|
| 64 |
+
num_key_value_heads=kv,
|
| 65 |
+
max_position_embeddings=seq_len, rope_theta=BASE["rope_theta"],
|
| 66 |
+
rms_norm_eps=BASE["rms_eps"], tie_word_embeddings=BASE["tie"],
|
| 67 |
+
attention_dropout=0.0, hidden_act="silu", mlp_bias=False,
|
| 68 |
+
attn_implementation=attn,
|
| 69 |
+
pad_token_id=BASE["vocab"] - 1, eos_token_id=0)
|
| 70 |
+
return cfg
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def count_params(model):
|
| 74 |
+
"""Total, embeddings included -- the number §2 budgets against. Reported both ways: numel sum and
|
| 75 |
+
the unique-parameter count, which differ when embeddings are tied."""
|
| 76 |
+
total = sum(p.numel() for p in model.parameters())
|
| 77 |
+
uniq = sum(p.numel() for p in set(model.parameters()))
|
| 78 |
+
return {"sum_numel": total, "unique": uniq,
|
| 79 |
+
"trainable": sum(p.numel() for p in model.parameters() if p.requires_grad)}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# The cursor arithmetic in one place, because three separate bugs live here:
|
| 83 |
+
# - a step consumes micro_batch * world * accum sequences, not micro_batch (DDP shards the batch);
|
| 84 |
+
# - Trainer restores `global_step` from the checkpoint, so on resume it is ALREADY absolute. Adding the
|
| 85 |
+
# resume point a second time inflates the cursor, and an inflated cursor makes the next run skip data
|
| 86 |
+
# it never read -- silent data loss with no error anywhere, which is §3.1's worst case. Writing this
|
| 87 |
+
# as a pure function of the absolute step removes the whole class of bug.
|
| 88 |
+
# - max_steps is the TOTAL horizon (it is what the LR schedule decays against), so resuming mid-run
|
| 89 |
+
# must keep the same steps_planned or the trapezoid changes shape.
|
| 90 |
+
def samples_at(absolute_step, seqs_per_step):
|
| 91 |
+
return int(absolute_step) * seqs_per_step
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class TrapezoidTrainer(Trainer):
|
| 95 |
+
"""Trainer with (a) a hand-rolled trapezoid schedule and (b) a data position we own."""
|
| 96 |
+
|
| 97 |
+
def __init__(self, *a, lr_shape=None, **kw):
|
| 98 |
+
self.lr_shape = lr_shape
|
| 99 |
+
super().__init__(*a, **kw)
|
| 100 |
+
|
| 101 |
+
def create_scheduler(self, num_training_steps, optimizer=None):
|
| 102 |
+
warm = self.lr_shape["warmup_steps"]
|
| 103 |
+
decay_at = self.lr_shape["decay_start"]
|
| 104 |
+
total = self.lr_shape["total_steps"]
|
| 105 |
+
|
| 106 |
+
def fn(cur):
|
| 107 |
+
if cur < warm:
|
| 108 |
+
return (cur + 1) / max(1, warm)
|
| 109 |
+
if cur < decay_at:
|
| 110 |
+
return 1.0
|
| 111 |
+
# linear decay to exactly zero at the end: the trapezoid in docs/01-plan.md §4.3
|
| 112 |
+
return max(0.0, 1.0 - (cur - decay_at) / max(1, total - decay_at))
|
| 113 |
+
|
| 114 |
+
if self.lr_scheduler is None:
|
| 115 |
+
self.lr_scheduler = LambdaLR(self.optimizer if optimizer is None else optimizer, fn)
|
| 116 |
+
return self.lr_scheduler
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
class HubPush(TrainerCallback):
|
| 120 |
+
"""§3.13 after every checkpoint: write the cursor in, upload, verify from the Hub, prune locally.
|
| 121 |
+
|
| 122 |
+
Only rank 0 acts -- the ranks share the checkpoint directory and would race. A failed verification
|
| 123 |
+
raises inside the callback, so the run stops AT that checkpoint rather than continuing with its only
|
| 124 |
+
copy on a disk that can be taken away at any moment.
|
| 125 |
+
"""
|
| 126 |
+
|
| 127 |
+
def __init__(self, repo, api, token, seq_len, seed, fingerprint, seqs_per_step,
|
| 128 |
+
prune=True, min_free_gb=6.0, repo_type="dataset", stop_after_steps=0, order_sha=None):
|
| 129 |
+
self.repo, self.api, self.token = repo, api, token
|
| 130 |
+
self.seq_len, self.seed, self.fingerprint = seq_len, seed, fingerprint
|
| 131 |
+
self.seqs_per_step, self.repo_type = seqs_per_step, repo_type
|
| 132 |
+
self.order_sha = order_sha
|
| 133 |
+
self.prune, self.min_free_gb = prune, min_free_gb
|
| 134 |
+
self.stop_after = int(stop_after_steps or 0)
|
| 135 |
+
self.stopped_at = None
|
| 136 |
+
self.n = 0
|
| 137 |
+
self.steps = []
|
| 138 |
+
self.results = []
|
| 139 |
+
|
| 140 |
+
def on_step_end(self, args, state, control, **kw):
|
| 141 |
+
# A session that ends ON a verified checkpoint loses nothing when the platform takes the disk.
|
| 142 |
+
# That is how the 10 % cadence is made compatible with an unknown session cap: instead of pushing
|
| 143 |
+
# extra intermediate copies (which multiplies Hub LFS storage by the number of sessions), the run
|
| 144 |
+
# is cut into segments whose length is a whole number of checkpoint intervals.
|
| 145 |
+
if self.stop_after and state.global_step >= self.stop_after:
|
| 146 |
+
self.stopped_at = state.global_step
|
| 147 |
+
control.should_training_stop = True
|
| 148 |
+
return control
|
| 149 |
+
|
| 150 |
+
def on_save(self, args, state, control, **kw):
|
| 151 |
+
if not state.is_world_process_zero:
|
| 152 |
+
return control
|
| 153 |
+
step = state.global_step
|
| 154 |
+
d = os.path.join(args.output_dir, f"checkpoint-{step}")
|
| 155 |
+
if not os.path.isdir(d):
|
| 156 |
+
raise RuntimeError(f"Trainer reported a save but {d} does not exist")
|
| 157 |
+
samples = samples_at(step, self.seqs_per_step)
|
| 158 |
+
SD.save_cursor(d, SD.cursor_dict(self.seq_len, samples, self.seed, self.fingerprint,
|
| 159 |
+
step=step, order_sha=self.order_sha))
|
| 160 |
+
t0 = time.time()
|
| 161 |
+
# Upload and verify *without* pruning, roll the pointer and read it back, and only then delete the
|
| 162 |
+
# local copy. The old order (prune inside push_and_prune, pointer written afterwards) meant one
|
| 163 |
+
# failed pointer commit left a Hub-verified checkpoint that nothing pointed at and no local copy to
|
| 164 |
+
# recover it with -- and every guard a resume runs would still pass, because weights, cursor,
|
| 165 |
+
# fingerprint and seed are all self-consistent. Only the pointer would be wrong, and the next
|
| 166 |
+
# session would quietly re-read 100 M tokens.
|
| 167 |
+
res = hubckpt.push_and_prune(self.repo, d, f"ckpt/checkpoint-{step}", self.api,
|
| 168 |
+
repo_type=self.repo_type, token=self.token, prune=False)
|
| 169 |
+
ptr_body = {"path_in_repo": f"ckpt/checkpoint-{step}", "step": step,
|
| 170 |
+
"samples_consumed": samples, "tokens_consumed": samples * self.seq_len,
|
| 171 |
+
"fingerprint": self.fingerprint, "seed": self.seed}
|
| 172 |
+
hubckpt.write_latest_pointer(self.repo, ptr_body, self.api, repo_type=self.repo_type,
|
| 173 |
+
token=self.token)
|
| 174 |
+
if self.prune:
|
| 175 |
+
hubckpt.prune_local(d, res, self.min_free_gb)
|
| 176 |
+
loss = next((h["loss"] for h in reversed(state.log_history) if "loss" in h), None)
|
| 177 |
+
print(f"CKPT {step} samples={samples:,} push+verify={round(time.time()-t0,1)}s "
|
| 178 |
+
f"ok={res['verify']['ok']} pruned={res.get('pruned')} free={res['free_after_gb']}GB "
|
| 179 |
+
f"loss={loss}", flush=True)
|
| 180 |
+
self.n += 1
|
| 181 |
+
self.steps.append(step)
|
| 182 |
+
self.results.append(res)
|
| 183 |
+
return control
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def run_benchmark(args, model, dataset, steps, world, seqs_per_step):
|
| 187 |
+
"""The same data path, optimizer and fp16 recipe as the real run, with no Trainer in the loop.
|
| 188 |
+
|
| 189 |
+
Why this exists: P2 measured 5,105 tok/s for 22L/seq-1024 where probe p0c measured 11,062 tok/s for a
|
| 190 |
+
near-identical shape with a hand-rolled loop. The gap is either (a) the attention kernel, (b) per-step
|
| 191 |
+
framework overhead, or (c) a measurement artefact, and which one it is decides whether the main run
|
| 192 |
+
needs 27 h or 68 h. Guessing wrong in either direction is expensive, so measure both paths.
|
| 193 |
+
"""
|
| 194 |
+
import torch.distributed as dist
|
| 195 |
+
from torch.nn.parallel import DistributedDataParallel as DDP
|
| 196 |
+
from torch.utils.data import DataLoader
|
| 197 |
+
|
| 198 |
+
local_rank = int(os.environ.get("LOCAL_RANK", "0"))
|
| 199 |
+
device = torch.device("cuda", local_rank)
|
| 200 |
+
torch.cuda.set_device(device)
|
| 201 |
+
if world > 1 and not dist.is_initialized():
|
| 202 |
+
dist.init_process_group("nccl")
|
| 203 |
+
model = model.to(device)
|
| 204 |
+
if args.grad_ckpt:
|
| 205 |
+
model.gradient_checkpointing_enable()
|
| 206 |
+
model.config.use_cache = False
|
| 207 |
+
net = DDP(model, device_ids=[local_rank]) if world > 1 else model
|
| 208 |
+
|
| 209 |
+
opt = torch.optim.AdamW(net.parameters(), lr=args.lr, betas=(0.9, 0.95),
|
| 210 |
+
weight_decay=0.1, eps=1e-8)
|
| 211 |
+
try:
|
| 212 |
+
scaler = torch.amp.GradScaler("cuda", enabled=True) # torch >= 2.4
|
| 213 |
+
except TypeError:
|
| 214 |
+
scaler = torch.cuda.amp.GradScaler(enabled=True) # older signature
|
| 215 |
+
# Same reader, same shuffle, no accumulation: the comparison against the Trainer cells is then
|
| 216 |
+
# per-step cost with the framework removed, and nothing else differs.
|
| 217 |
+
dl = DataLoader(dataset, batch_size=args.micro_batch, shuffle=False, num_workers=2,
|
| 218 |
+
pin_memory=False, drop_last=True)
|
| 219 |
+
t0 = time.time()
|
| 220 |
+
toks = 0
|
| 221 |
+
for i, batch in enumerate(dl):
|
| 222 |
+
if i >= steps:
|
| 223 |
+
break
|
| 224 |
+
batch = {k: v.to(device, non_blocking=True) for k, v in batch.items()}
|
| 225 |
+
opt.zero_grad(set_to_none=True)
|
| 226 |
+
with torch.autocast("cuda", dtype=torch.float16):
|
| 227 |
+
out = net(**batch)
|
| 228 |
+
scaler.scale(out.loss).backward()
|
| 229 |
+
scaler.unscale_(opt)
|
| 230 |
+
torch.nn.utils.clip_grad_norm_(net.parameters(), 1.0)
|
| 231 |
+
scaler.step(opt)
|
| 232 |
+
scaler.update()
|
| 233 |
+
toks += batch["input_ids"].numel()
|
| 234 |
+
if i == 0:
|
| 235 |
+
torch.cuda.synchronize()
|
| 236 |
+
t0 = time.time() # exclude CUDA context + first-trace cost from the rate
|
| 237 |
+
toks = 0
|
| 238 |
+
sec = time.time() - t0
|
| 239 |
+
res = {"benchmark_steps": i + 1 if steps else 0, "tok_per_s": round(toks / max(1e-6, sec)),
|
| 240 |
+
"seconds": round(sec, 1), "tokens": toks,
|
| 241 |
+
"peak_gpu_gb": round(torch.cuda.max_memory_allocated() / 1e9, 2),
|
| 242 |
+
"world": world, "seq_len": args.seq_len, "micro_batch": args.micro_batch,
|
| 243 |
+
"attn": args.attn, "grad_ckpt": bool(args.grad_ckpt),
|
| 244 |
+
"params": sum(p.numel() for p in model.parameters())}
|
| 245 |
+
print("BENCH_JSON " + json.dumps(res, sort_keys=True), flush=True)
|
| 246 |
+
if world > 1:
|
| 247 |
+
dist.barrier()
|
| 248 |
+
dist.destroy_process_group()
|
| 249 |
+
return res
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def main():
|
| 253 |
+
ap = argparse.ArgumentParser()
|
| 254 |
+
ap.add_argument("--root", default="/kaggle/working/mixroot")
|
| 255 |
+
ap.add_argument("--out", default="/kaggle/working/run")
|
| 256 |
+
ap.add_argument("--hub-repo", default="", help="dataset repo for checkpoints; empty = no upload")
|
| 257 |
+
ap.add_argument("--hub-repo-type", default="dataset", choices=["dataset", "model"])
|
| 258 |
+
# D-011: these ARE the frozen main-run values (measured 7,732-7,828 tok/s at 1024/eager on 2xT4), so
|
| 259 |
+
# that launching the run does not depend on remembering a flag string.
|
| 260 |
+
ap.add_argument("--seq-len", type=int, default=1024)
|
| 261 |
+
ap.add_argument("--attn", default="eager", choices=["eager", "sdpa", "flash_attention_2"],
|
| 262 |
+
help="p0c measured eager beating SDPA on Turing; Gate 3 P2/A-B measured +52 %% for "
|
| 263 |
+
"eager at seq 1024 and an OOM at 2048. flash_attention_2 needs sm80+")
|
| 264 |
+
ap.add_argument("--grad-ckpt", default=True, action=argparse.BooleanOptionalAction,
|
| 265 |
+
help="on by default: it is what keeps peak memory at ~7 GB instead of 12 GB, and the "
|
| 266 |
+
"measured rate cost at 1024 was nil (7,828 with GC at m8 vs 7,732 without at m4)")
|
| 267 |
+
ap.add_argument("--layers", type=int, default=0)
|
| 268 |
+
ap.add_argument("--hidden", type=int, default=0)
|
| 269 |
+
ap.add_argument("--micro-batch", type=int, default=4,
|
| 270 |
+
help="4 x 1024 x 32 accum x 2 cards = 262,144 tokens/step, the frozen batch size")
|
| 271 |
+
ap.add_argument("--accum", type=int, default=32)
|
| 272 |
+
ap.add_argument("--lr", type=float, default=6e-4)
|
| 273 |
+
ap.add_argument("--optim", default="adamw_torch",
|
| 274 |
+
help="only mathematically-equivalent implementations are fair game at freeze time "
|
| 275 |
+
"(adamw_torch / adamw_torch_fused / adamw_apex_fused); 8-bit variants change the "
|
| 276 |
+
"update, so they are excluded by rule, not by preference")
|
| 277 |
+
ap.add_argument("--gc-nonreentrant", default=False, action=argparse.BooleanOptionalAction,
|
| 278 |
+
help="pass use_reentrant=False to gradient checkpointing: same recomputation, "
|
| 279 |
+
"different autograd path, and reports on whether it is faster under DDP conflict")
|
| 280 |
+
ap.add_argument("--torch-compile", default=False, action=argparse.BooleanOptionalAction,
|
| 281 |
+
help="Trainer's torch_compile hook; fused kernels, no change to the update rule")
|
| 282 |
+
ap.add_argument("--tokens", type=int, default=1_000_000_000)
|
| 283 |
+
ap.add_argument("--max-steps", type=int, default=0, help="0 = derive from --tokens; >0 overrides")
|
| 284 |
+
ap.add_argument("--warmup-frac", type=float, default=0.02)
|
| 285 |
+
ap.add_argument("--decay-frac", type=float, default=0.80)
|
| 286 |
+
ap.add_argument("--seed", type=int, default=20260919)
|
| 287 |
+
ap.add_argument("--data-seed", type=int, default=20260919)
|
| 288 |
+
ap.add_argument("--resume", default="", help="checkpoint dir; empty = auto from Hub latest")
|
| 289 |
+
ap.add_argument("--val-tokens", type=int, default=2_000_000)
|
| 290 |
+
ap.add_argument("--log-every", type=int, default=10)
|
| 291 |
+
ap.add_argument("--push-every-steps", type=int, default=0, help="0 = only at 10%% boundaries")
|
| 292 |
+
ap.add_argument("--stop-after-steps", type=int, default=0,
|
| 293 |
+
help="absolute global step at which THIS session stops training, so it ends on a "
|
| 294 |
+
"verified checkpoint instead of being killed between two of them. Must be a "
|
| 295 |
+
"whole multiple of the checkpoint interval; 0 = run to the horizon.")
|
| 296 |
+
ap.add_argument("--prune", action="store_true", help="delete local checkpoint after hub verify")
|
| 297 |
+
ap.add_argument("--dry-run", action="store_true", help="build everything, train 0 steps")
|
| 298 |
+
ap.add_argument("--benchmark", type=int, default=0,
|
| 299 |
+
help="N steps with no Trainer in the loop (isolates framework per-step cost)")
|
| 300 |
+
args = ap.parse_args()
|
| 301 |
+
|
| 302 |
+
rank = int(os.environ.get("RANK", "0"))
|
| 303 |
+
world = int(os.environ.get("WORLD_SIZE", "1"))
|
| 304 |
+
say = (lambda *m: print(*m, flush=True)) if rank == 0 else (lambda *m: None)
|
| 305 |
+
|
| 306 |
+
man_path = os.path.join(args.root, "manifest.json")
|
| 307 |
+
if not os.path.exists(man_path):
|
| 308 |
+
raise SystemExit(f"no manifest at {man_path}: build the mix first (Phase 2 is not optional)")
|
| 309 |
+
man = json.load(open(man_path))
|
| 310 |
+
if man["total_tokens"] < args.tokens:
|
| 311 |
+
say(f"WARNING mix has {man['total_tokens']:,} tokens < target {args.tokens:,}")
|
| 312 |
+
|
| 313 |
+
store = SD.PackedTokenStore(args.root, man)
|
| 314 |
+
fingerprint = SD.mix_fingerprint(args.root, man)
|
| 315 |
+
# One definition of the step, used by the cursor, the schedule and the token count: a step consumes
|
| 316 |
+
# micro_batch * world sequences (DDP shards the batch), so tokens/step follows from it.
|
| 317 |
+
seqs_per_step = args.micro_batch * world * args.accum
|
| 318 |
+
tokens_per_step = seqs_per_step * args.seq_len
|
| 319 |
+
steps_planned = args.max_steps or int(args.tokens / tokens_per_step)
|
| 320 |
+
say(f"mix: {store.total_tokens:,} tokens in {len(store.shards)} shards fp={fingerprint} "
|
| 321 |
+
f"| {tokens_per_step:,} tok/step x {steps_planned} steps = "
|
| 322 |
+
f"{tokens_per_step * steps_planned:,} tokens")
|
| 323 |
+
|
| 324 |
+
# ---- resume point: the cursor is the run's identity as much as the weights are.
|
| 325 |
+
# `--resume auto` is what a fresh instance with an empty disk passes: it asks the Hub where the run
|
| 326 |
+
# stopped and pulls that checkpoint down. §3.1 forbids recovering from anything left on an instance.
|
| 327 |
+
if args.resume == "auto":
|
| 328 |
+
if not args.hub_repo:
|
| 329 |
+
raise SystemExit("--resume auto needs --hub-repo")
|
| 330 |
+
import ounce100m_credentials
|
| 331 |
+
ounce100m_credentials.install()
|
| 332 |
+
from huggingface_hub import HfApi
|
| 333 |
+
tk = os.environ.get("HF_TOKEN")
|
| 334 |
+
ptr = hubckpt.latest_pointer(args.hub_repo, repo_type=args.hub_repo_type, token=tk)
|
| 335 |
+
if ptr.get("not_found"):
|
| 336 |
+
# A 404 is normally a fact -- "this run has never checkpointed" -- and step 0 is then right.
|
| 337 |
+
# But a missing pointer beside existing checkpoints means the pointer upload failed after a
|
| 338 |
+
# good checkpoint, and starting from zero would silently retrain the corpus from the top,
|
| 339 |
+
# which §3.1 forbids and E-018 is about. So list the repo before believing the 404.
|
| 340 |
+
try:
|
| 341 |
+
listed = sorted(k for k in hubckpt.hub_listing(args.hub_repo, args.hub_repo_type,
|
| 342 |
+
token=tk)
|
| 343 |
+
if k.startswith("ckpt/checkpoint-"))
|
| 344 |
+
except hubckpt.RepoMissing:
|
| 345 |
+
# The checkpoint repo has never been created: nothing has ever been pushed, so step 0 is
|
| 346 |
+
# the only correct answer. The first session of the run always takes this branch, and
|
| 347 |
+
# treating a 404 as "listing failed" would refuse it (a generic `except Exception` below
|
| 348 |
+
# makes the list non-empty and the refusal fires).
|
| 349 |
+
say(f"{args.hub_repo} does not exist yet -- nothing has ever been pushed")
|
| 350 |
+
listed = []
|
| 351 |
+
except Exception as e:
|
| 352 |
+
listed = [f"<listing failed: {type(e).__name__}>"]
|
| 353 |
+
if listed:
|
| 354 |
+
raise SystemExit(f"--resume auto found no latest.json on {args.hub_repo}, but the repo "
|
| 355 |
+
f"holds {len(listed)} checkpoint path(s) ({listed[:3]}). The pointer is "
|
| 356 |
+
"missing, not the run -- refusing to start from step 0.")
|
| 357 |
+
say(f"no latest.json and no checkpoints on {args.hub_repo} -> starting from step 0")
|
| 358 |
+
args.resume = ""
|
| 359 |
+
elif "error" in ptr:
|
| 360 |
+
# Anything else (401, timeout, DNS) means the lookup failed, not that there is nothing there.
|
| 361 |
+
# Falling through to step 0 here would silently retrain the first stretch of the run, which is
|
| 362 |
+
# exactly the §3.1 violation this whole branch exists to prevent (E-018's shape).
|
| 363 |
+
raise SystemExit(f"--resume auto could not read the hub pointer: {ptr['error']}")
|
| 364 |
+
else:
|
| 365 |
+
# A pointer is a resume point only if nothing newer was ever pushed. This scan catches the one
|
| 366 |
+
# case write_latest_pointer's own read-back cannot see: a pointer left behind by a rollback, a
|
| 367 |
+
# deletion, or a session that died between its checkpoint push and its pointer roll. Resuming
|
| 368 |
+
# from the stale pointer would re-train an interval with weights and a cursor that agree with
|
| 369 |
+
# each other and disagree with the Hub.
|
| 370 |
+
import re as _re
|
| 371 |
+
steps_on_hub = set()
|
| 372 |
+
for attempt in (1, 2):
|
| 373 |
+
try:
|
| 374 |
+
steps_on_hub = {int(m.group(1)) for k in
|
| 375 |
+
hubckpt.hub_listing(args.hub_repo, args.hub_repo_type, token=tk)
|
| 376 |
+
if (m := _re.match(r"ckpt/checkpoint-(\d+)/", k))}
|
| 377 |
+
break
|
| 378 |
+
except Exception as e:
|
| 379 |
+
say(f" checkpoint scan attempt {attempt}: {type(e).__name__}: {str(e)[:140]}")
|
| 380 |
+
time.sleep(15)
|
| 381 |
+
else:
|
| 382 |
+
raise SystemExit("--resume auto could not enumerate the checkpoint repo, so it cannot "
|
| 383 |
+
f"prove latest.json (step {ptr.get('step')}) is the newest state")
|
| 384 |
+
if steps_on_hub and max(steps_on_hub) > int(ptr.get("step") or 0):
|
| 385 |
+
raise SystemExit(f"latest.json points at step {ptr.get('step')} but {args.hub_repo} also "
|
| 386 |
+
f"holds checkpoint-{max(steps_on_hub)}: the pointer is behind the run. "
|
| 387 |
+
"Resuming here would re-train that interval, so refusing.")
|
| 388 |
+
# Both ranks need the checkpoint on disk, and this block runs before TrainingArguments has
|
| 389 |
+
# created a process group -- so there is no barrier to synchronise on. Downloading into a
|
| 390 |
+
# per-rank directory instead removes the race entirely (two writers, one path), at the cost of
|
| 391 |
+
# a second 1.7 GB pull that happens in parallel. Each rank then reads its own verified copy and
|
| 392 |
+
# derives the same start_sample/start_step from the same cursor.json bytes.
|
| 393 |
+
dest = os.path.join(args.out, f"resume_r{rank}",
|
| 394 |
+
os.path.basename(ptr["path_in_repo"]))
|
| 395 |
+
say(f"auto-resume: hub says step {ptr.get('step')} at {ptr['path_in_repo']}")
|
| 396 |
+
got = hubckpt.download_checkpoint(args.hub_repo, ptr["path_in_repo"], dest, HfApi(),
|
| 397 |
+
repo_type=args.hub_repo_type, token=tk)
|
| 398 |
+
say(f"downloaded {got['files']} files, {got['bytes']/1e6:.0f} MB in {got['seconds']}s")
|
| 399 |
+
if got["bytes"] < 1_000_000:
|
| 400 |
+
raise SystemExit("auto-resume downloaded a checkpoint that is not real weights")
|
| 401 |
+
args.resume = got["dir"]
|
| 402 |
+
say(f"resuming from {args.resume}")
|
| 403 |
+
cur = SD.load_cursor(args.resume) if args.resume else None
|
| 404 |
+
if args.resume and cur is None:
|
| 405 |
+
# Weights at step N with the data stream back at sample 0 would retrain the opening of the corpus
|
| 406 |
+
# and report a continuous loss curve. Refuse instead.
|
| 407 |
+
raise SystemExit(f"resume refused: {args.resume} has no cursor.json -- a checkpoint without a "
|
| 408 |
+
"recorded data position cannot be resumed, only restarted (§3.1)")
|
| 409 |
+
if args.resume:
|
| 410 |
+
say(f"resume from {args.resume} cursor={cur}")
|
| 411 |
+
start_sample = (cur or {}).get("samples_consumed", 0)
|
| 412 |
+
start_step = (cur or {}).get("step") or 0
|
| 413 |
+
# Identity of the visit order, derived from the corpus size and the seed rather than trusted to the
|
| 414 |
+
# seed: numpy's permutation is only stable within a version, and Kaggle updates its image underneath
|
| 415 |
+
# long runs. ~50 ms once per session, and it is what makes "resumed onto a different order" loud.
|
| 416 |
+
order_sha = SD.perm_sha(store.total_tokens // args.seq_len, args.data_seed)
|
| 417 |
+
if cur:
|
| 418 |
+
for k, v in (("seq_len", args.seq_len), ("shuffle_seed", args.data_seed),
|
| 419 |
+
("shuffle_perm_sha", order_sha), ("dataset_files_sha", fingerprint)):
|
| 420 |
+
if cur.get(k) != v:
|
| 421 |
+
raise SystemExit(f"resume refused: cursor {k}={cur.get(k)} != current {v}. "
|
| 422 |
+
"A resumed run must read the same data in the same order (§3.1).")
|
| 423 |
+
if start_sample % seqs_per_step:
|
| 424 |
+
raise SystemExit(f"resume refused: cursor samples_consumed={start_sample} is not a whole "
|
| 425 |
+
f"number of steps (seqs/step={seqs_per_step}) -- batch geometry changed")
|
| 426 |
+
if cur.get("step") is not None and cur["step"] * seqs_per_step != start_sample:
|
| 427 |
+
raise SystemExit(f"resume refused: cursor step={cur['step']} and samples_consumed="
|
| 428 |
+
f"{start_sample} disagree at seqs/step={seqs_per_step}")
|
| 429 |
+
if start_step * seqs_per_step != start_sample:
|
| 430 |
+
start_step = start_sample // seqs_per_step
|
| 431 |
+
|
| 432 |
+
train_ds = SD.make_dataset(store, args.seq_len, args.data_seed, start_sample=start_sample)
|
| 433 |
+
assert getattr(train_ds, "order_sha", None) == order_sha, "two derivations of the order disagree"
|
| 434 |
+
# Validation comes from the mix's held-out val/ shards, never from the training stream: scoring on
|
| 435 |
+
# tokens the model already consumed would be a number nobody could reproduce (and §3.12).
|
| 436 |
+
vs = [s["file"] for s in man.get("val_shards", [])]
|
| 437 |
+
if not vs:
|
| 438 |
+
raise SystemExit("manifest lists no val_shards -- Gate 2 requires a held-out split")
|
| 439 |
+
vstore = SD.PackedTokenStore(args.root, man, files=vs)
|
| 440 |
+
n_val = min(max(1, args.val_tokens // args.seq_len), vstore.total_tokens // args.seq_len)
|
| 441 |
+
val_ds = SD.make_dataset(vstore, args.seq_len, args.data_seed, shuffle=False, count=n_val)
|
| 442 |
+
need = (steps_planned - start_step) * seqs_per_step
|
| 443 |
+
if len(train_ds) < need:
|
| 444 |
+
# Reading past the end of the stream makes the Trainer begin epoch 2 at the top of the same
|
| 445 |
+
# permutation: the run would then report the full token target while quietly re-training on the
|
| 446 |
+
# first windows. The mix is built with ~12 % headroom over the target for exactly this reason, so
|
| 447 |
+
# arriving here means that assumption stopped being true (review finding), and the honest response
|
| 448 |
+
# is to stop before step 1 rather than to discover it in the final report.
|
| 449 |
+
raise SystemExit(
|
| 450 |
+
f"dataset too small for the plan: {len(train_ds):,} windows available from sample "
|
| 451 |
+
f"{start_sample:,}, {need:,} needed for {steps_planned} steps x {seqs_per_step} sequences "
|
| 452 |
+
f"({need * args.seq_len:,} tokens). Lower --tokens to the 0.9 B fallback (D-012) or rebuild "
|
| 453 |
+
"the mix larger -- never let the epoch wrap.")
|
| 454 |
+
say(f"data: {len(train_ds):,} training windows available, {need:,} needed "
|
| 455 |
+
f"({100.0 * len(train_ds) / need - 100:.1f} % margin), {n_val:,} held-out val windows")
|
| 456 |
+
|
| 457 |
+
cfg = build_config(args.seq_len, args.layers or None, args.hidden or None, attn=args.attn)
|
| 458 |
+
set_seed(args.seed) # before init: both ranks must start from identical weights, not two draws
|
| 459 |
+
model = LlamaForCausalLM(cfg).to(dtype=torch.float32)
|
| 460 |
+
pc = count_params(model)
|
| 461 |
+
say("params:", json.dumps(pc))
|
| 462 |
+
if not (90_000_000 <= pc["sum_numel"] <= 110_000_000) and not args.layers and not args.hidden:
|
| 463 |
+
raise SystemExit(f"parameter count {pc['sum_numel']:,} is outside §2's 90-110M budget")
|
| 464 |
+
|
| 465 |
+
if args.benchmark:
|
| 466 |
+
# Both ranks iterate the same samples here (no DistributedSampler): this path measures step cost,
|
| 467 |
+
# not a training run, and it must never be used to produce a checkpoint.
|
| 468 |
+
say(f"benchmark mode: {args.benchmark} steps, no Trainer, micro={args.micro_batch} "
|
| 469 |
+
f"seq={args.seq_len} attn={args.attn} world={world}")
|
| 470 |
+
run_benchmark(args, model, train_ds, args.benchmark, world, seqs_per_step)
|
| 471 |
+
return
|
| 472 |
+
|
| 473 |
+
# 10 checkpoints per run is §2's cadence; --push-every-steps overrides for the preflight.
|
| 474 |
+
ckpt_every = args.push_every_steps or max(1, steps_planned // 10)
|
| 475 |
+
if args.stop_after_steps:
|
| 476 |
+
if args.stop_after_steps % ckpt_every:
|
| 477 |
+
raise SystemExit(f"--stop-after-steps {args.stop_after_steps} is not a whole multiple of the "
|
| 478 |
+
f"checkpoint interval {ckpt_every}: the session would end between two "
|
| 479 |
+
f"checkpoints, which is exactly what this flag exists to prevent")
|
| 480 |
+
if args.stop_after_steps > steps_planned:
|
| 481 |
+
raise SystemExit(f"--stop-after-steps {args.stop_after_steps} exceeds the horizon "
|
| 482 |
+
f"{steps_planned}; use 0 to run to the end")
|
| 483 |
+
|
| 484 |
+
api = None
|
| 485 |
+
if args.hub_repo:
|
| 486 |
+
import ounce100m_credentials
|
| 487 |
+
ounce100m_credentials.install()
|
| 488 |
+
from huggingface_hub import HfApi
|
| 489 |
+
api = HfApi()
|
| 490 |
+
# rank 0 only: two ranks committing the same .gitattributes to a brand-new repo is a write race
|
| 491 |
+
# on the one path that must not fail (only rank 0 ever uploads checkpoints, so rank 1 needs none)
|
| 492 |
+
if rank == 0:
|
| 493 |
+
say("ckpt repo:", json.dumps(hubckpt.ensure_repo(args.hub_repo, api,
|
| 494 |
+
os.environ.get("HF_TOKEN"),
|
| 495 |
+
args.hub_repo_type)))
|
| 496 |
+
say(f"checkpoint hub target {args.hub_repo} (prune={args.prune}, every {ckpt_every} steps)")
|
| 497 |
+
|
| 498 |
+
tacc = TrainingArguments(
|
| 499 |
+
output_dir=args.out,
|
| 500 |
+
per_device_train_batch_size=args.micro_batch,
|
| 501 |
+
# Same geometry for the validation pass: with the library default (8) the end-of-run eval measures
|
| 502 |
+
# a peak that has nothing to do with the training configuration, which is how an OOM in the no-
|
| 503 |
+
# checkpointing cell could be mistaken for a passing one (review finding).
|
| 504 |
+
per_device_eval_batch_size=args.micro_batch,
|
| 505 |
+
gradient_accumulation_steps=args.accum,
|
| 506 |
+
learning_rate=args.lr,
|
| 507 |
+
weight_decay=0.1,
|
| 508 |
+
adam_beta1=0.9, adam_beta2=0.95, adam_epsilon=1e-8,
|
| 509 |
+
max_grad_norm=1.0,
|
| 510 |
+
lr_scheduler_type="constant", # replaced by TrapezoidTrainer.create_scheduler
|
| 511 |
+
warmup_ratio=0.0, # the trapezoid owns warmup
|
| 512 |
+
num_train_epochs=1,
|
| 513 |
+
max_steps=0 if args.dry_run else steps_planned,
|
| 514 |
+
fp16=True, bf16=False,
|
| 515 |
+
# measured decision, not a default: A-B found eager attention 52 % faster at seq 1024 and OOM at
|
| 516 |
+
# 2048; checkpointing is what makes eager at 2048 possible at all, at the cost of a recompute pass
|
| 517 |
+
gradient_checkpointing=args.grad_ckpt,
|
| 518 |
+
# Same recomputation, different autograd path -- offered as a cells-vs-baseline question, not a
|
| 519 |
+
# belief. Reports on use_reentrant=False under DDP contradict each other across versions.
|
| 520 |
+
gradient_checkpointing_kwargs={"use_reentrant": not args.gc_nonreentrant}
|
| 521 |
+
if args.grad_ckpt else None,
|
| 522 |
+
optim=args.optim,
|
| 523 |
+
torch_compile=args.torch_compile,
|
| 524 |
+
ddp_find_unused_parameters=False,
|
| 525 |
+
# Rank 0 spends minutes inside on_save uploading ~1.7 GB, listing the repo and downloading one file
|
| 526 |
+
# back to hash it, while rank 1 waits at the next collective. The library's 1,800 s default would
|
| 527 |
+
# abort the run on a slow-egress Kaggle box -- precisely during the checkpoint that the other rank
|
| 528 |
+
# was protecting. Four hours makes the collective patient without hiding a real deadlock.
|
| 529 |
+
ddp_timeout=14400,
|
| 530 |
+
dataloader_num_workers=2,
|
| 531 |
+
dataloader_pin_memory=False,
|
| 532 |
+
remove_unused_columns=False, # our dict keys must reach the model untouched
|
| 533 |
+
ignore_data_skip=True, # see header note 1 -- we advance position ourselves
|
| 534 |
+
save_strategy="no" if args.dry_run else "steps",
|
| 535 |
+
save_steps=ckpt_every,
|
| 536 |
+
save_total_limit=1, # §3.13: the Hub is the store, the instance is scratch
|
| 537 |
+
logging_steps=args.log_every, report_to=[],
|
| 538 |
+
seed=args.seed, data_seed=args.seed,
|
| 539 |
+
accelerator_config={"dispatch_batches": False},
|
| 540 |
+
average_tokens_across_devices=False,
|
| 541 |
+
)
|
| 542 |
+
|
| 543 |
+
tr = TrapezoidTrainer(
|
| 544 |
+
model=model, args=tacc, train_dataset=train_ds, eval_dataset=val_ds,
|
| 545 |
+
data_collator=default_data_collator, lr_shape={
|
| 546 |
+
"warmup_steps": max(100, int(steps_planned * args.warmup_frac)),
|
| 547 |
+
"decay_start": int(steps_planned * args.decay_frac),
|
| 548 |
+
"total_steps": steps_planned},
|
| 549 |
+
)
|
| 550 |
+
hub_cb = None
|
| 551 |
+
if api is not None:
|
| 552 |
+
hub_cb = HubPush(args.hub_repo, api, os.environ.get("HF_TOKEN"), args.seq_len,
|
| 553 |
+
args.data_seed, fingerprint, seqs_per_step,
|
| 554 |
+
prune=args.prune, repo_type=args.hub_repo_type,
|
| 555 |
+
stop_after_steps=args.stop_after_steps, order_sha=order_sha)
|
| 556 |
+
tr.add_callback(hub_cb)
|
| 557 |
+
|
| 558 |
+
# Precision must be ASSERTED, not configured: bf16 "works" silently on Turing and fp32 master
|
| 559 |
+
# weights can be lost in a refactor (docs/00-platform-notes.md §8, memory/ERRORS.md E-006). The
|
| 560 |
+
# scaler's home moved between transformers versions, so look in both places instead of trusting one
|
| 561 |
+
# attribute path (E-010: prefer version-free surfaces).
|
| 562 |
+
scaler = getattr(tr, "scaler", None) or getattr(getattr(tr, "accelerator", None), "scaler", None)
|
| 563 |
+
eff_attn = (getattr(model.config, "_attn_implementation", None)
|
| 564 |
+
or getattr(model.config, "attn_implementation", None))
|
| 565 |
+
say(f"precision: fp16={tacc.fp16} bf16={tacc.bf16} scaler={type(scaler).__name__} "
|
| 566 |
+
f"master_dtype={next(model.parameters()).dtype} requested_attn={args.attn} "
|
| 567 |
+
f"effective_attn={eff_attn}")
|
| 568 |
+
assert tacc.fp16 and not tacc.bf16, "not the frozen T4 fp16 recipe"
|
| 569 |
+
assert next(model.parameters()).dtype == torch.float32, "master weights are not fp32"
|
| 570 |
+
|
| 571 |
+
prior = None
|
| 572 |
+
if args.resume:
|
| 573 |
+
tsp = os.path.join(args.resume, "trainer_state.json")
|
| 574 |
+
if os.path.exists(tsp):
|
| 575 |
+
h0 = [h for h in json.load(open(tsp)).get("log_history", []) if "loss" in h]
|
| 576 |
+
prior = h0[-1]["loss"] if h0 else None
|
| 577 |
+
say(f"prior loss at resume: {prior} -- loss continuity is judged against this number")
|
| 578 |
+
|
| 579 |
+
if args.dry_run:
|
| 580 |
+
say(json.dumps({"dry_run": True, "train_samples": len(train_ds), "val_samples": len(val_ds),
|
| 581 |
+
"steps_planned": steps_planned, "ckpt_every": ckpt_every,
|
| 582 |
+
"params": pc, "fingerprint": fingerprint, "scaler": type(scaler).__name__}))
|
| 583 |
+
return
|
| 584 |
+
|
| 585 |
+
t0 = time.time()
|
| 586 |
+
tr.train(resume_from_checkpoint=args.resume or None)
|
| 587 |
+
|
| 588 |
+
elapsed = time.time() - t0
|
| 589 |
+
step = tr.state.global_step
|
| 590 |
+
# Did this segment really end on a pushed checkpoint? `stopped_at` is set when the step counter reaches
|
| 591 |
+
# the target, but performing the save at that step is the library's decision. If it ever ordered the
|
| 592 |
+
# stop ahead of the save, the session would exit 0 having pushed nothing, and every relaunch would
|
| 593 |
+
# redo the same interval while `latest` stayed put -- which looks like a stuck run, not a broken one.
|
| 594 |
+
if hub_cb is not None and args.stop_after_steps:
|
| 595 |
+
if args.stop_after_steps <= start_step:
|
| 596 |
+
raise SystemExit(f"--stop-after-steps {args.stop_after_steps} is not beyond the resume point "
|
| 597 |
+
f"step {start_step}: this session would train nothing")
|
| 598 |
+
if step < args.stop_after_steps:
|
| 599 |
+
raise SystemExit(f"asked to stop at step {args.stop_after_steps}, stopped at {step}")
|
| 600 |
+
if args.stop_after_steps < steps_planned and args.stop_after_steps not in hub_cb.steps:
|
| 601 |
+
raise SystemExit(f"segment ended at step {step} but no checkpoint was pushed and verified "
|
| 602 |
+
f"for it (pushed: {sorted(hub_cb.steps)}). The next session would resume "
|
| 603 |
+
"from the previous pointer and re-read this interval.")
|
| 604 |
+
samples = samples_at(step, seqs_per_step)
|
| 605 |
+
consumed = samples * args.seq_len
|
| 606 |
+
consumed_this_run = max(0, step - start_step) * tokens_per_step
|
| 607 |
+
hist = [h for h in tr.state.log_history if "loss" in h]
|
| 608 |
+
final_loss = hist[-1]["loss"] if hist else None
|
| 609 |
+
say(f"TRAIN DONE step={step}/{steps_planned} elapsed={elapsed/60:.1f} min "
|
| 610 |
+
f"tokens_consumed={consumed:,} last_loss={final_loss}")
|
| 611 |
+
# E-006, again: a run that produced NaN still reports a confident tokens/sec. Never let that pass.
|
| 612 |
+
if final_loss is None or not math.isfinite(final_loss):
|
| 613 |
+
raise SystemExit(f"final loss is not finite ({final_loss}) -- not exporting anything")
|
| 614 |
+
|
| 615 |
+
# Validation first, and on every rank: `evaluate()` is collective, so gating it would deadlock.
|
| 616 |
+
ppl = None
|
| 617 |
+
val_err = None
|
| 618 |
+
try:
|
| 619 |
+
ev = tr.evaluate()
|
| 620 |
+
ppl = math.exp(min(20.0, ev["eval_loss"]))
|
| 621 |
+
say(f"validation loss={ev['eval_loss']:.4f} ppl={ppl:.2f} over {n_val} held-out windows")
|
| 622 |
+
except Exception as e:
|
| 623 |
+
# `say()` is a no-op on rank 1, and that silence is what made a rank-1 evaluation crash unreadable
|
| 624 |
+
# in probe 1 v1: the handler ran on both ranks, only rank 0 spoke, and the divergence looked like an
|
| 625 |
+
# unexplained exit 1 with `error_file: <N/A>`. Report on every rank, and carry the failure into the
|
| 626 |
+
# record so a missing PPL can never be read as a passing validation pass.
|
| 627 |
+
val_err = f"{type(e).__name__}: {str(e)[:300]}"
|
| 628 |
+
print(f"[rank {rank}] validation eval failed: {val_err}", flush=True)
|
| 629 |
+
|
| 630 |
+
# Sampled here, on every rank, before the rank-0-only export block: a collective reduction inside
|
| 631 |
+
# `if rank == 0:` would hang, and reading rank 0's allocator alone is what made the probe's memory
|
| 632 |
+
# verdict systematically optimistic -- it hides the other card's peak, the two CUDA contexts, and the
|
| 633 |
+
# reserved-but-unallocated blocks that are the real ceiling on a 14.56 GiB T4 (review finding B4).
|
| 634 |
+
mem = peak_stats()
|
| 635 |
+
say("memory:", json.dumps(mem, sort_keys=True))
|
| 636 |
+
|
| 637 |
+
# Everything below writes files, so it is rank 0's alone (E-029). Two ranks building the same export
|
| 638 |
+
# directory is a write race: `shutil.copy(src, out)` with `out` not yet created writes a regular FILE
|
| 639 |
+
# called `final` where rank 0 is making a directory of the same name, and the observed result was
|
| 640 |
+
# `training_args.bin` vanishing mid-hash -- which killed a fully trained, fully checkpointed leg on
|
| 641 |
+
# its last line. The variable is also no longer called `out`, which until now meant both the run
|
| 642 |
+
# directory and the export inside it.
|
| 643 |
+
exp = os.path.join(args.out, "final")
|
| 644 |
+
if rank == 0:
|
| 645 |
+
tr.save_model(exp)
|
| 646 |
+
# The tokenizer travels with the run so `final/` is loadable by AutoTokenizer on its own (§8). If
|
| 647 |
+
# it is not in the mix root, say so in the summary instead of skipping quietly and being surprised.
|
| 648 |
+
shipped = []
|
| 649 |
+
import shutil as _sh
|
| 650 |
+
os.makedirs(exp, exist_ok=True)
|
| 651 |
+
for fn in ("tokenizer.json", "tokenizer_config.json", "special_tokens_map.json", "vocab.json",
|
| 652 |
+
"merges.txt"):
|
| 653 |
+
src = os.path.join(args.root, fn)
|
| 654 |
+
if os.path.exists(src):
|
| 655 |
+
_sh.copy(src, exp)
|
| 656 |
+
shipped.append(fn)
|
| 657 |
+
say(f"tokenizer files shipped with the export: {shipped or 'NONE -- Phase 5 must add them'}")
|
| 658 |
+
SD.save_cursor(exp, SD.cursor_dict(args.seq_len, samples, args.data_seed, fingerprint,
|
| 659 |
+
step=step, order_sha=order_sha))
|
| 660 |
+
json.dump({"step": step, "start_step": start_step, "tokens_consumed": consumed,
|
| 661 |
+
"tokens_this_run": consumed_this_run, "session_seconds": round(elapsed, 1),
|
| 662 |
+
"params": pc, "params_total": pc["sum_numel"], "fingerprint": fingerprint,
|
| 663 |
+
"shuffle_perm_sha": order_sha,
|
| 664 |
+
"final_loss": final_loss, "prior_loss": prior, "samples_consumed": samples,
|
| 665 |
+
"steps_planned": steps_planned,
|
| 666 |
+
# The recipe, written down. The Phase 5 card reads this file to state hyperparameters,
|
| 667 |
+
# and it had none of them: every claim about sequence length, batch shape, throughput or
|
| 668 |
+
# attention would have rendered as `None` because those lived only in the stdout RUN_JSON
|
| 669 |
+
# line, a different schema (review finding A2). `seconds` was also renamed: it is THIS
|
| 670 |
+
# session's elapsed, and a five-session run published as one number is wrong by ~5x (A3).
|
| 671 |
+
"seq_len": args.seq_len, "micro_batch": args.micro_batch, "accum": args.accum,
|
| 672 |
+
"world": world, "tokens_per_step": tokens_per_step,
|
| 673 |
+
"tok_per_s": round(consumed_this_run / max(1, elapsed)),
|
| 674 |
+
"attn": args.attn, "effective_attn": eff_attn, "grad_ckpt": bool(args.grad_ckpt),
|
| 675 |
+
"optim": args.optim, "lr": args.lr, "warmup_frac": args.warmup_frac,
|
| 676 |
+
"decay_frac": args.decay_frac, "seed": args.seed, "data_seed": args.data_seed,
|
| 677 |
+
"val_ppl": ppl, "val_loss_error": val_err, "val_windows": n_val,
|
| 678 |
+
"peak_gpu_gb": mem["rank0_allocated_gb"],
|
| 679 |
+
"peak_reserved_gb": mem["rank0_reserved_gb"],
|
| 680 |
+
"peak_gpu_gb_max_rank": mem["max_allocated_gb"],
|
| 681 |
+
"peak_reserved_gb_max_rank": mem["max_reserved_gb"]},
|
| 682 |
+
open(os.path.join(exp, "run_summary.json"), "w"), indent=1, sort_keys=True)
|
| 683 |
+
segment = bool(hub_cb and hub_cb.stopped_at and step < steps_planned)
|
| 684 |
+
if api is not None and not segment and rank == 0:
|
| 685 |
+
r = hubckpt.push_and_prune(args.hub_repo, exp, "final", api, repo_type=args.hub_repo_type,
|
| 686 |
+
token=os.environ.get("HF_TOKEN"), prune=args.prune)
|
| 687 |
+
# .get, not [k]: `pruned` and `free_after_gb` are only present on some paths through
|
| 688 |
+
# push_and_prune, and a KeyError here would throw away a completed run (E-023).
|
| 689 |
+
say("final push:", json.dumps({k: r.get(k) for k in
|
| 690 |
+
("verify", "pruned", "free_before_gb", "free_after_gb",
|
| 691 |
+
"prune_blocked_reason")}, default=str)[:900])
|
| 692 |
+
# The run is over and `latest.json` has to say so. `on_save` only ever rolls it at a 381-step
|
| 693 |
+
# boundary, so without this the pointer stops at 3,810, the launcher's `start_step >= horizon`
|
| 694 |
+
# check never fires, and every relaunch after completion trains four more steps and pushes again.
|
| 695 |
+
if step >= steps_planned:
|
| 696 |
+
hubckpt.write_latest_pointer(args.hub_repo, {
|
| 697 |
+
"path_in_repo": "final", "step": step, "samples_consumed": samples,
|
| 698 |
+
"tokens_consumed": consumed, "fingerprint": fingerprint, "seed": args.data_seed},
|
| 699 |
+
api, repo_type=args.hub_repo_type, token=os.environ.get("HF_TOKEN"))
|
| 700 |
+
say(f"latest.json rolled to the completed run: final at step {step}")
|
| 701 |
+
elif segment:
|
| 702 |
+
# The session stopped on a checkpoint interval, and HubPush already pushed AND verified that
|
| 703 |
+
# checkpoint and rolled latest.json to it. Re-pushing it as "final" would add a 1.7 GB LFS
|
| 704 |
+
# revision per session for a copy the Hub already holds.
|
| 705 |
+
say(f"segment boundary reached at step {step} of {steps_planned}: the checkpoint HubPush just "
|
| 706 |
+
"verified is the resume point, so no separate final push")
|
| 707 |
+
say("RUN_JSON " + json.dumps({"final_step": step, "val_ppl": ppl, "tokens_consumed": consumed,
|
| 708 |
+
"segment_stop": segment, "steps_planned": steps_planned,
|
| 709 |
+
"tok_per_s": round(consumed_this_run / max(1, elapsed)),
|
| 710 |
+
"final_loss": final_loss, "prior_loss": prior,
|
| 711 |
+
"micro_batch": args.micro_batch, "seq_len": args.seq_len,
|
| 712 |
+
"accum": args.accum, "world": world,
|
| 713 |
+
"params": pc["sum_numel"], "attn": args.attn,
|
| 714 |
+
"grad_ckpt": bool(args.grad_ckpt),
|
| 715 |
+
"optim": args.optim, "gc_nonreentrant": bool(args.gc_nonreentrant),
|
| 716 |
+
"torch_compile": bool(args.torch_compile),
|
| 717 |
+
"effective_attn": eff_attn,
|
| 718 |
+
"peak_gpu_gb": mem["rank0_allocated_gb"],
|
| 719 |
+
"peak_reserved_gb": mem["rank0_reserved_gb"],
|
| 720 |
+
"peak_gpu_gb_max_rank": mem["max_allocated_gb"],
|
| 721 |
+
"peak_reserved_gb_max_rank": mem["max_reserved_gb"],
|
| 722 |
+
# So a caller cannot report success for a validation pass that never
|
| 723 |
+
# happened: a null val_ppl with no error here would be a bug, and a null
|
| 724 |
+
# with an error is a measured failure.
|
| 725 |
+
"val_error": val_err, "val_windows": n_val}, sort_keys=True))
|
| 726 |
+
|
| 727 |
+
|
| 728 |
+
def peak_stats():
|
| 729 |
+
"""Peak CUDA memory, from every rank, with reserved bytes and the cross-rank maximum.
|
| 730 |
+
|
| 731 |
+
An OOM mid-run is a lost session, so this is part of every preflight cell's evidence rather than
|
| 732 |
+
something to discover at step 300. `max_memory_allocated` on rank 0 alone understates what the card is
|
| 733 |
+
actually holding: the allocator reserves more than it hands out, and the other rank's peak is a separate
|
| 734 |
+
number on a separate GPU that the run's own limit has to respect.
|
| 735 |
+
"""
|
| 736 |
+
out = {"cuda": torch.cuda.is_available(), "rank0_allocated_gb": None, "rank0_reserved_gb": None,
|
| 737 |
+
"max_allocated_gb": None, "max_reserved_gb": None, "device": None}
|
| 738 |
+
if not out["cuda"]:
|
| 739 |
+
return out
|
| 740 |
+
try:
|
| 741 |
+
alloc = torch.cuda.max_memory_allocated()
|
| 742 |
+
res = torch.cuda.max_memory_reserved()
|
| 743 |
+
out["rank0_allocated_gb"] = round(alloc / 1e9, 2)
|
| 744 |
+
out["rank0_reserved_gb"] = round(res / 1e9, 2)
|
| 745 |
+
out["device"] = torch.cuda.get_device_name(0)
|
| 746 |
+
t = torch.tensor([float(alloc), float(res)], device=alloc and "cuda" or "cpu")
|
| 747 |
+
if torch.distributed.is_available() and torch.distributed.is_initialized():
|
| 748 |
+
torch.distributed.all_reduce(t, op=torch.distributed.ReduceOp.MAX)
|
| 749 |
+
out["max_allocated_gb"] = round(float(t[0]) / 1e9, 2)
|
| 750 |
+
out["max_reserved_gb"] = round(float(t[1]) / 1e9, 2)
|
| 751 |
+
else:
|
| 752 |
+
out["max_allocated_gb"] = out["rank0_allocated_gb"]
|
| 753 |
+
out["max_reserved_gb"] = out["rank0_reserved_gb"]
|
| 754 |
+
except Exception as e:
|
| 755 |
+
out["error"] = f"{type(e).__name__}: {str(e)[:120]}"
|
| 756 |
+
return out
|
| 757 |
+
|
| 758 |
+
|
| 759 |
+
if __name__ == "__main__":
|
| 760 |
+
main()
|