File size: 18,707 Bytes
70a25ae | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 | """Pretraining loop: AdamW, warmup into cosine decay, bf16 autocast, gradient
accumulation, resumable checkpoints. Batches are random windows of the .bin files.
python train.py # train with the constants below
python train.py --check # the checks that say the loop is correct
python train.py --toy # 2 layers, 128 dim: the whole path in minutes on a laptop
python train.py --data data/vocab8192 --pos learned ... # ablation arms
"""
import argparse
import contextlib
import hashlib
import math
import os
import shutil
import subprocess
import sys
import time
import numpy as np
import torch
import tokenizer
from model import CTX, DIM, N_HEAD, N_LAYER, VOCAB_SIZE, Sprout
DATA = "data"
OUT = "checkpoints"
TOKENS_PER_STEP = 1 << 17 # 131,072 tokens per optimizer step
MICRO_BATCH = 64 # sequences per forward pass; the rest is accumulation
MAX_STEPS = 12_000 # 1.57B tokens, ~2.9 epochs of the 538M-token corpus
WARMUP = 300
PEAK_LR = 1e-3
FLOOR = 0.1 # cosine floor as a fraction of peak
WEIGHT_DECAY = 0.1
BETAS = (0.9, 0.95)
GRAD_CLIP = 1.0
EVAL_EVERY = 250
EVAL_BATCHES = 40
CKPT_EVERY = 2000 # about every 15 minutes on an A100
SEED = 1337
PROJECT = "sprout"
HUB = "" # e.g. "user/sprout-checkpoints": every checkpoint is also pushed here
TOY = dict(layers=2, dim=128, heads=4, ctx=256, micro_batch=32, accum=1, out=f"{OUT}/toy",
steps=1500, warmup=100, lr=1e-3)
PROMPT = "Once upon a time"
ACCUM = TOKENS_PER_STEP // (MICRO_BATCH * CTX)
assert MICRO_BATCH * CTX * ACCUM == TOKENS_PER_STEP
DEVICE = (
"cuda" if torch.cuda.is_available()
else "mps" if torch.backends.mps.is_available()
else "cpu"
)
def split(data, name):
return np.memmap(f"{data}/{name}.bin", dtype=np.uint16, mode="r")
def batch(data, size, ctx, gen, device):
"""Random windows, sampled with replacement rather than by epoch permutation.
Over 538M tokens the two look the same, and this way the generator state is
the whole position in the data, which is what makes a resume exact."""
i = torch.randint(len(data) - ctx, (size,), generator=gen)
window = np.stack([data[j : j + ctx + 1] for j in i.tolist()])
t = torch.from_numpy(window.astype(np.int64))
x, y = t[:, :-1], t[:, 1:]
if device == "cuda":
x, y = x.pin_memory(), y.pin_memory()
return x.to(device, non_blocking=True), y.to(device, non_blocking=True)
return x.to(device), y.to(device)
def lr_at(step, steps, warmup, peak=PEAK_LR):
"""Linear warmup, then cosine down to FLOOR of peak. Once per optimizer
step: per micro-batch it would decay ACCUM times too fast."""
if step < warmup:
return peak * (step + 1) / warmup
t = min(1.0, (step - warmup) / max(1, steps - warmup))
return peak * (FLOOR + (1 - FLOOR) * 0.5 * (1 + math.cos(math.pi * t)))
def optimizer(model, device):
"""AdamW, decaying the matrices but not the RMSNorm gains: a gain sets the
scale of a whole activation vector, so shrinking it mutes the layer."""
# The tied embedding does get decayed. Embeddings are usually spared
# because a row only gets a gradient when its token appears, but tying
# makes it the output head, where every row moves every step.
decay = [p for p in model.parameters() if p.ndim >= 2]
plain = [p for p in model.parameters() if p.ndim < 2]
groups = [
{"params": decay, "weight_decay": WEIGHT_DECAY},
{"params": plain, "weight_decay": 0.0},
]
return torch.optim.AdamW(
groups, lr=PEAK_LR, betas=BETAS, fused=(device == "cuda")
)
def autocast(device):
# No GradScaler: bf16 keeps fp32's exponent, so gradients cannot underflow
# the way fp16's can. What it gives up is mantissa bits, which no amount of
# scaling helps.
if device == "cuda":
return torch.autocast("cuda", dtype=torch.bfloat16)
return contextlib.nullcontext()
def fingerprint(data):
"""What the tokens were made of. Stored in every checkpoint so a rerun of
the tokenizer cannot silently invalidate a resume or an eval."""
with open(f"{data}/tokenizer.model", "rb") as f:
merges = hashlib.blake2b(f.read(), digest_size=8).hexdigest()
return {
"tokenizer": merges,
"train_tokens": os.path.getsize(f"{data}/train.bin") // 2,
"val_tokens": os.path.getsize(f"{data}/val.bin") // 2,
}
def token_bytes(tok):
"""Bytes of text behind each token id; 0 for the specials, which are not text."""
n = np.array([len(tok.vocab[i]) for i in range(len(tok.vocab))], dtype=np.int64)
n[list(tok.special.values())] = 0
return n
def config(args):
"""Everything a resume has to agree on."""
return dict(
steps=args.steps, warmup=args.warmup, ctx=args.ctx,
micro_batch=args.micro_batch, accum=args.accum,
tokens_per_step=args.micro_batch * args.ctx * args.accum,
peak_lr=args.lr, floor=FLOOR, weight_decay=WEIGHT_DECAY,
betas=BETAS, grad_clip=GRAD_CLIP, seed=SEED,
n_layer=args.layers, dim=args.dim, n_head=args.heads, vocab=args.vocab,
pos=args.pos,
)
@torch.no_grad()
def val_loss(model, data, args, batches=EVAL_BATCHES):
was_training = model.training
model.eval()
# A fresh generator every time, so every eval sees the same windows and the
# curve moves because the model moved.
gen = torch.Generator().manual_seed(SEED + 1)
total = 0.0
for _ in range(batches):
x, y = batch(data, args.micro_batch, args.ctx, gen, args.device)
with autocast(args.device):
total += model(x, y)[1].item()
model.train(was_training)
return total / batches
@torch.no_grad()
def val_metrics(model, data, nbytes, args):
"""Cross-entropy and bits per byte in consecutive windows, including the tail.
Every token after the first is a target; special tokens count as zero bytes."""
assert len(data) > 1
was_training = model.training
model.eval()
ntokens = len(data) - 1
nats = 0.0
start = 0
try:
while start < ntokens:
width = min(args.ctx, ntokens - start)
count = min(args.micro_batch, (ntokens - start) // width)
t = torch.from_numpy(data[start:start + count * width + 1].astype(np.int64))
x, y = t[:-1].view(count, width), t[1:].view(count, width)
with autocast(args.device):
loss = model(x.to(args.device), y.to(args.device))[1]
nats += loss.item() * y.numel()
start += y.numel()
finally:
model.train(was_training)
ntext = int(nbytes[data[1:]].sum())
assert ntext > 0
return nats / ntokens, nats / math.log(2) / ntext
def val_bpb(model, data, nbytes, args):
return val_metrics(model, data, nbytes, args)[1]
def sample(model, tok, device, n=150):
"""Continue PROMPT from a fresh story, sampling until the story ends."""
eot = tok.special["<|endoftext|>"]
idx = torch.tensor([[eot] + tok.encode(PROMPT)], device=device)
was_training = model.training
model.eval()
out = model.generate(idx, n, temperature=0.8, stop=eot)[0, 1:].tolist()
model.train(was_training)
return tok.decode(out[: out.index(eot)] if eot in out else out)
def save(path, model, opt, gen, step, args, fp):
ckpt = {
"model": model.state_dict(),
"optimizer": opt.state_dict(),
"step": step,
"config": config(args),
"fingerprint": fp,
"data_rng": gen.get_state(),
"torch_rng": torch.get_rng_state(),
"cuda_rng": torch.cuda.get_rng_state_all() if torch.cuda.is_available() else None,
}
torch.save(ckpt, path + ".tmp")
os.replace(path + ".tmp", path) # a crash mid-write leaves the old one intact
if args.hub:
# The instance disk dies with the instance; the Hub copy is the real one.
from huggingface_hub import HfApi
HfApi().upload_file(path_or_fileobj=path, path_in_repo=os.path.relpath(path, OUT),
repo_id=args.hub, commit_message=f"step {step}")
def resume(path, model, opt, gen, fp, args):
"""Restore weights, Adam state and both RNGs; return the step to go on from."""
if not os.path.exists(path) and args.hub:
from huggingface_hub import hf_hub_download
hf_hub_download(args.hub, os.path.relpath(path, OUT), local_dir=OUT)
# Onto the CPU whatever the device: load_state_dict moves tensors to where
# the parameters already live, and the RNG states have to stay CPU bytes.
ckpt = torch.load(path, map_location="cpu", weights_only=False)
assert ckpt["fingerprint"] == fp, (ckpt["fingerprint"], fp)
assert ckpt["config"] == config(args), (ckpt["config"], config(args))
model.load_state_dict(ckpt["model"])
opt.load_state_dict(ckpt["optimizer"])
gen.set_state(ckpt["data_rng"])
torch.set_rng_state(ckpt["torch_rng"])
if ckpt["cuda_rng"] is not None and torch.cuda.is_available():
torch.cuda.set_rng_state_all(ckpt["cuda_rng"])
return ckpt["step"]
def run(args):
os.makedirs(args.out, exist_ok=True)
path = f"{args.out}/ckpt.pt"
fp = fingerprint(args.data)
tok = tokenizer.load(f"{args.data}/tokenizer.model")
args.vocab = len(tok.vocab)
torch.manual_seed(SEED)
model = Sprout(args.layers, args.dim, args.heads, args.ctx, args.vocab, args.pos)
model = model.to(args.device)
opt = optimizer(model, args.device)
gen = torch.Generator().manual_seed(SEED)
step = resume(path, model, opt, gen, fp, args) if args.resume else 0
train_data, val_data = split(args.data, "train"), split(args.data, "val")
nbytes = token_bytes(tok)
bytes_per_token = nbytes[val_data].sum() / len(val_data)
tokens = args.micro_batch * args.ctx * args.accum
stop = args.stop_after or args.steps
epochs = args.steps * tokens / len(train_data)
print(f"{sum(p.numel() for p in model.parameters()):,} params on {args.device}, "
f"{tokens:,} tokens/step x {args.steps:,} steps = {epochs:.2f} epochs")
print(f"resuming at step {step}" if args.resume else f"fingerprint {fp}")
wandb = None
if not args.no_wandb:
import wandb
# WANDB_RUN_ID before a resume continues the same run instead of
# opening a second one; WANDB_MODE=offline covers headless machines.
commit = subprocess.run(["git", "rev-parse", "--short", "HEAD"],
capture_output=True, text=True).stdout.strip()
wandb.init(project=PROJECT, name=os.path.basename(args.out),
config=config(args) | fp | {"commit": commit}, resume="allow")
losses = open(f"{args.out}/losses.txt", "a")
vals = open(f"{args.out}/val.txt", "a")
model.train()
t0 = time.time()
while step < stop:
lr = lr_at(step, args.steps, args.warmup, args.lr)
for group in opt.param_groups:
group["lr"] = lr
total = 0.0
for _ in range(args.accum):
x, y = batch(train_data, args.micro_batch, args.ctx, gen, args.device)
with autocast(args.device):
loss = model(x, y)[1]
# Divided here so the accumulated gradient is the mean over the
# whole step, not the sum of ACCUM micro-batch means.
(loss / args.accum).backward()
total += loss.item() / args.accum
grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), GRAD_CLIP)
opt.step()
opt.zero_grad(set_to_none=True)
step += 1
dt = time.time() - t0
t0 = time.time()
print(f"step {step} {total:.8f}", file=losses, flush=True)
print(f"step {step:6d} loss {total:.4f} lr {lr:.2e} "
f"grad {grad_norm:.3f} {tokens / dt:,.0f} tok/s {dt * 1e3:.0f}ms")
if wandb:
wandb.log({"loss": total, "lr": lr, "grad_norm": grad_norm.item(),
"tokens_per_sec": tokens / dt, "tokens": step * tokens}, step=step)
if step % EVAL_EVERY == 0 or step == stop:
val = val_loss(model, val_data, args)
bpb = val / math.log(2) / bytes_per_token
text = sample(model, tok, args.device)
print(f"step {step} {val:.6f} {bpb:.6f}", file=vals, flush=True)
print(f"step {step:6d} val {val:.4f} bpb {bpb:.4f}\n{text}\n")
if wandb:
wandb.log({"val_loss": val, "val_bpb": bpb,
"sample": wandb.Html(f"<pre>{text}</pre>")}, step=step)
if step % CKPT_EVERY == 0 or step == stop:
save(path, model, opt, gen, step, args, fp)
if step == args.steps:
bpb = val_bpb(model, val_data, nbytes, args)
print(f"final val bpb over all {len(val_data):,} val tokens: {bpb:.4f}")
print(f"final {bpb:.6f}", file=vals, flush=True)
if wandb:
wandb.summary["val_bpb_full"] = bpb
def check_schedule():
"""Warmup rises to the peak, cosine falls to the floor."""
schedule = [lr_at(s, MAX_STEPS, WARMUP) for s in range(MAX_STEPS)]
rising = all(b > a for a, b in zip(schedule, schedule[1:WARMUP]))
falling = all(b <= a for a, b in zip(schedule[WARMUP - 1:], schedule[WARMUP:]))
print(f"lr: start {schedule[0]:.2e}, peak {max(schedule):.2e} at step "
f"{schedule.index(max(schedule)) + 1}, end {schedule[-1]:.2e} "
f"(floor {PEAK_LR * FLOOR:.2e}), rising {rising}, then falling {falling}")
assert rising and falling
assert schedule[WARMUP - 1] == PEAK_LR == max(schedule)
assert abs(schedule[-1] - PEAK_LR * FLOOR) < 1e-8
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
plt.plot(schedule)
plt.xlabel("optimizer step")
plt.ylabel("learning rate")
plt.savefig(f"{OUT}/lr_schedule.png", dpi=120, bbox_inches="tight")
print(f"wrote {OUT}/lr_schedule.png")
def check_eval(model, val_data, args):
"""Eval mode and no grad, and the model comes back the way it went in."""
args.micro_batch, args.ctx = 8, 128
model.train()
val = val_loss(model, val_data, args, batches=4)
print(f"val loss at init: {val:.4f} (ln V = {math.log(VOCAB_SIZE):.4f}), "
f"training mode restored: {model.training}, "
f"grads still empty: {all(p.grad is None for p in model.parameters())}")
assert abs(val - math.log(VOCAB_SIZE)) < 0.15
assert model.training and all(p.grad is None for p in model.parameters())
def check_accumulation(model, val_data):
"""One batch of 32 == four accumulated micro-batches of 8."""
gen = torch.Generator().manual_seed(0)
x, y = batch(val_data, 32, 128, gen, "cpu")
whole = model(x, y)[1]
whole.backward()
full = [p.grad.clone() for p in model.parameters()]
model.zero_grad(set_to_none=True)
accumulated = 0.0
for i in range(4):
loss = model(x[i * 8:(i + 1) * 8], y[i * 8:(i + 1) * 8])[1]
(loss / 4).backward()
accumulated += loss.item() / 4
scale = max(g.abs().max().item() for g in full)
worst = max((a - p.grad).abs().max().item() for a, p in zip(full, model.parameters()))
print(f"accumulation: loss {whole.item():.6f} vs {accumulated:.6f} "
f"(diff {abs(whole.item() - accumulated):.2e}), "
f"worst grad diff {worst:.2e} against grad scale {scale:.2e}")
assert abs(whole.item() - accumulated) < 1e-5
assert worst / scale < 1e-4
model.zero_grad(set_to_none=True)
def check_resume():
"""100 steps in one process against 50 + checkpoint + fresh process + 50."""
straight, interrupted = f"{OUT}/check-straight", f"{OUT}/check-interrupted"
common = [sys.executable, __file__, "--device", "cpu", "--no-wandb",
"--steps", "100", "--warmup", "10", "--ctx", "128",
"--micro-batch", "4", "--accum", "2"]
for out in (straight, interrupted):
shutil.rmtree(out, ignore_errors=True)
print(f"running {' '.join(common[2:])} straight through, then interrupted at 50")
subprocess.run(common + ["--out", straight], check=True, stdout=subprocess.DEVNULL)
subprocess.run(common + ["--out", interrupted, "--stop-after", "50"],
check=True, stdout=subprocess.DEVNULL)
subprocess.run(common + ["--out", interrupted, "--resume"],
check=True, stdout=subprocess.DEVNULL)
curves = []
for out in (straight, interrupted):
with open(f"{out}/losses.txt") as f:
curves.append([float(line.split()[2]) for line in f])
assert len(curves[0]) == len(curves[1]) == 100, [len(c) for c in curves]
gap = [abs(a - b) for a, b in zip(*curves)]
print(f"resume: loss at step 51 {curves[0][50]:.6f} vs {curves[1][50]:.6f}, "
f"worst gap over 100 steps {max(gap):.2e}, "
f"final {curves[0][-1]:.6f} vs {curves[1][-1]:.6f}")
assert max(gap) < 1e-6
def check(args):
args.device = "cpu" # fp32 and deterministic; the tolerances assume it
os.makedirs(OUT, exist_ok=True)
check_schedule()
torch.manual_seed(SEED)
model = Sprout()
val_data = split(DATA, "val")
check_eval(model, val_data, args)
check_accumulation(model, val_data)
check_resume()
def parse():
p = argparse.ArgumentParser()
p.add_argument("--steps", type=int, default=MAX_STEPS)
p.add_argument("--stop-after", type=int, default=0,
help="halt early; the LR schedule still spans --steps")
p.add_argument("--warmup", type=int, default=WARMUP)
p.add_argument("--ctx", type=int, default=CTX)
p.add_argument("--micro-batch", type=int, default=MICRO_BATCH)
p.add_argument("--accum", type=int, default=ACCUM)
p.add_argument("--layers", type=int, default=N_LAYER)
p.add_argument("--dim", type=int, default=DIM)
p.add_argument("--heads", type=int, default=N_HEAD)
p.add_argument("--lr", type=float, default=PEAK_LR)
p.add_argument("--data", default=DATA, help="directory of tokenizer.model, train.bin, val.bin")
p.add_argument("--pos", default="rope", choices=["rope", "learned"])
p.add_argument("--out", default=OUT)
p.add_argument("--hub", default=HUB)
p.add_argument("--device", default=DEVICE)
p.add_argument("--resume", action="store_true")
p.add_argument("--no-wandb", action="store_true")
p.add_argument("--check", action="store_true")
p.add_argument("--toy", action="store_true", help="the TOY shape, unless overridden")
args = p.parse_args()
if args.toy:
for k, v in TOY.items():
if p.get_default(k) == getattr(args, k):
setattr(args, k, v)
return args
if __name__ == "__main__":
args = parse()
check(args) if args.check else run(args)
|