Cion-lab commited on
Commit
52c7981
·
verified ·
1 Parent(s): e7dcd1f

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
Files changed (1) hide show
  1. 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
- try:
618
- ev = tr.evaluate()
619
- ppl = math.exp(min(20.0, ev["eval_loss"]))
620
- say(f"validation loss={ev['eval_loss']:.4f} ppl={ppl:.2f} over {n_val} held-out windows")
621
- except Exception as e:
622
- say(f"validation eval failed: {type(e).__name__}: {str(e)[:200]}")
623
-
624
- # Everything below writes files, so it is rank 0's alone (E-029). Two ranks building the same export
625
- # directory is a write race: `shutil.copy(src, out)` with `out` not yet created writes a regular FILE
626
- # called `final` where rank 0 is making a directory of the same name, and the observed result was
627
- # `training_args.bin` vanishing mid-hash -- which killed a fully trained, fully checkpointed leg on
628
- # its last line. The variable is also no longer called `out`, which until now meant both the run
629
- # directory and the export inside it.
630
- exp = os.path.join(args.out, "final")
631
- if rank == 0:
632
- tr.save_model(exp)
633
- # The tokenizer travels with the run so `final/` is loadable by AutoTokenizer on its own (§8). If
634
- # it is not in the mix root, say so in the summary instead of skipping quietly and being surprised.
635
- shipped = []
636
- import shutil as _sh
637
- os.makedirs(exp, exist_ok=True)
638
- for fn in ("tokenizer.json", "tokenizer_config.json", "special_tokens_map.json", "vocab.json",
639
- "merges.txt"):
640
- src = os.path.join(args.root, fn)
641
- if os.path.exists(src):
642
- _sh.copy(src, exp)
643
- shipped.append(fn)
644
- say(f"tokenizer files shipped with the export: {shipped or 'NONE -- Phase 5 must add them'}")
645
- SD.save_cursor(exp, SD.cursor_dict(args.seq_len, samples, args.data_seed, fingerprint,
646
- step=step, order_sha=order_sha))
647
- json.dump({"step": step, "start_step": start_step, "tokens_consumed": consumed,
648
- "tokens_this_run": consumed_this_run, "seconds": round(elapsed, 1),
649
- "params": pc, "fingerprint": fingerprint, "shuffle_perm_sha": order_sha,
650
- "final_loss": final_loss,
651
- "prior_loss": prior, "samples_consumed": samples,
652
- "steps_planned": steps_planned},
653
- open(os.path.join(exp, "run_summary.json"), "w"), indent=1, sort_keys=True)
654
- segment = bool(hub_cb and hub_cb.stopped_at and step < steps_planned)
655
- if api is not None and not segment and rank == 0:
656
- r = hubckpt.push_and_prune(args.hub_repo, exp, "final", api, repo_type=args.hub_repo_type,
657
- token=os.environ.get("HF_TOKEN"), prune=args.prune)
658
- # .get, not [k]: `pruned` and `free_after_gb` are only present on some paths through
659
- # push_and_prune, and a KeyError here would throw away a completed run (E-023).
660
- say("final push:", json.dumps({k: r.get(k) for k in
661
- ("verify", "pruned", "free_before_gb", "free_after_gb",
662
- "prune_blocked_reason")}, default=str)[:900])
663
- # The run is over and `latest.json` has to say so. `on_save` only ever rolls it at a 381-step
664
- # boundary, so without this the pointer stops at 3,810, the launcher's `start_step >= horizon`
665
- # check never fires, and every relaunch after completion trains four more steps and pushes again.
666
- if step >= steps_planned:
667
- hubckpt.write_latest_pointer(args.hub_repo, {
668
- "path_in_repo": "final", "step": step, "samples_consumed": samples,
669
- "tokens_consumed": consumed, "fingerprint": fingerprint, "seed": args.data_seed},
670
- api, repo_type=args.hub_repo_type, token=os.environ.get("HF_TOKEN"))
671
- say(f"latest.json rolled to the completed run: final at step {step}")
672
- elif segment:
673
- # The session stopped on a checkpoint interval, and HubPush already pushed AND verified that
674
- # checkpoint and rolled latest.json to it. Re-pushing it as "final" would add a 1.7 GB LFS
675
- # revision per session for a copy the Hub already holds.
676
- say(f"segment boundary reached at step {step} of {steps_planned}: the checkpoint HubPush just "
677
- "verified is the resume point, so no separate final push")
678
- say("RUN_JSON " + json.dumps({"final_step": step, "val_ppl": ppl, "tokens_consumed": consumed,
679
- "segment_stop": segment, "steps_planned": steps_planned,
680
- "tok_per_s": round(consumed_this_run / max(1, elapsed)),
681
- "final_loss": final_loss, "prior_loss": prior,
682
- "micro_batch": args.micro_batch, "seq_len": args.seq_len,
683
- "accum": args.accum, "world": world,
684
- "params": pc["sum_numel"], "attn": args.attn,
685
- "grad_ckpt": bool(args.grad_ckpt),
686
- "optim": args.optim, "gc_nonreentrant": bool(args.gc_nonreentrant),
687
- "torch_compile": bool(args.torch_compile),
688
- "effective_attn": eff_attn,
689
- "peak_gpu_gb": peak_gb()}, sort_keys=True))
690
-
691
-
692
- def peak_gb():
693
- """Peak CUDA allocation, in GB. The batch size at seq 2048 is the one thing p0c could not tell us
694
- (it measured seq 1024), and an OOM mid-main-run is a lost session, so this number is part of every
695
- preflight cell's evidence rather than something to discover at step 300 of the real run."""
696
- try:
697
- return round(torch.cuda.max_memory_allocated() / 1e9, 2) if torch.cuda.is_available() else None
698
- except Exception:
699
- return None
700
-
701
-
702
- if __name__ == "__main__":
703
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()