Cion-lab commited on
Commit
6ddf1d2
·
verified ·
1 Parent(s): 2297a33

train: add train_ounce100m (Phase 3/4 glue)

Browse files
Files changed (1) hide show
  1. train/train_ounce100m.py +351 -0
train/train_ounce100m.py ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 build_config(seq_len, layers=None, hidden=None):
36
+ h = hidden or BASE["hidden"]
37
+ L = layers or BASE["layers"]
38
+ inter = BASE["ffn"] if hidden is None and layers is None else int(round(8 / 3 * h)) * 2 // 64 * 64
39
+ cfg = LlamaConfig(vocab_size=BASE["vocab"], hidden_size=h, intermediate_size=inter,
40
+ num_hidden_layers=L, num_attention_heads=BASE["heads"],
41
+ num_key_value_heads=BASE["kv_heads"] if hidden is None else max(1, h // 64),
42
+ max_position_embeddings=seq_len, rope_theta=BASE["rope_theta"],
43
+ rms_norm_eps=BASE["rms_eps"], tie_word_embeddings=BASE["tie"],
44
+ attention_dropout=0.0, hidden_act="silu", mlp_bias=False,
45
+ pad_token_id=BASE["vocab"] - 1, eos_token_id=0)
46
+ # GQA ratio is part of the frozen shape; 9 heads with 3 kv heads, never MHA at this budget.
47
+ if hidden is None:
48
+ cfg.num_key_value_heads = BASE["kv_heads"]
49
+ return cfg
50
+
51
+
52
+ def count_params(model):
53
+ """Total, embeddings included -- the number §2 budgets against. Reported both ways: numel sum and
54
+ the unique-parameter count, which differ when embeddings are tied."""
55
+ total = sum(p.numel() for p in model.parameters())
56
+ uniq = sum(p.numel() for p in set(model.parameters()))
57
+ return {"sum_numel": total, "unique": uniq,
58
+ "trainable": sum(p.numel() for p in model.parameters() if p.requires_grad)}
59
+
60
+
61
+ # The cursor arithmetic in one place, because three separate bugs live here:
62
+ # - a step consumes micro_batch * world sequences, not micro_batch (DDP shards the batch);
63
+ # - on resume, Trainer restores global_step from the checkpoint but our dataset was ALREADY sliced to
64
+ # start there, so the total is start_sample + step_within_this_run * seqs_per_step;
65
+ # - max_steps is the TOTAL horizon (it is what the LR schedule decays against), so resuming mid-run
66
+ # must keep the same steps_planned or the trapezoid changes shape.
67
+ def samples_at(step_in_run, start_sample, seqs_per_step):
68
+ return start_sample + int(step_in_run) * seqs_per_step
69
+
70
+
71
+ class TrapezoidTrainer(Trainer):
72
+ """Trainer with (a) a hand-rolled trapezoid schedule and (b) a data position we own."""
73
+
74
+ def __init__(self, *a, lr_shape=None, **kw):
75
+ self.lr_shape = lr_shape
76
+ super().__init__(*a, **kw)
77
+
78
+ def create_scheduler(self, num_training_steps, optimizer=None):
79
+ warm = self.lr_shape["warmup_steps"]
80
+ decay_at = self.lr_shape["decay_start"]
81
+ total = self.lr_shape["total_steps"]
82
+
83
+ def fn(cur):
84
+ if cur < warm:
85
+ return (cur + 1) / max(1, warm)
86
+ if cur < decay_at:
87
+ return 1.0
88
+ # linear decay to exactly zero at the end: the trapezoid in docs/01-plan.md §4.3
89
+ return max(0.0, 1.0 - (cur - decay_at) / max(1, total - decay_at))
90
+
91
+ if self.lr_scheduler is None:
92
+ self.lr_scheduler = LambdaLR(self.optimizer if optimizer is None else optimizer, fn)
93
+ return self.lr_scheduler
94
+
95
+
96
+ class HubPush(TrainerCallback):
97
+ """§3.13 after every checkpoint: write the cursor in, upload, verify from the Hub, prune locally.
98
+
99
+ Only rank 0 acts -- the ranks share the checkpoint directory and would race. A failed verification
100
+ raises inside the callback, so the run stops AT that checkpoint rather than continuing with its only
101
+ copy on a disk that can be taken away at any moment.
102
+ """
103
+
104
+ def __init__(self, repo, api, token, seq_len, seed, fingerprint, start_sample, seqs_per_step,
105
+ prune=True, min_free_gb=6.0):
106
+ self.repo, self.api, self.token = repo, api, token
107
+ self.seq_len, self.seed, self.fingerprint = seq_len, seed, fingerprint
108
+ self.start_sample, self.seqs_per_step = start_sample, seqs_per_step
109
+ self.prune, self.min_free_gb = prune, min_free_gb
110
+ self.n = 0
111
+ self.results = []
112
+
113
+ def on_save(self, args, state, control, **kw):
114
+ if not state.is_world_process_zero:
115
+ return control
116
+ step = state.global_step
117
+ d = os.path.join(args.output_dir, f"checkpoint-{step}")
118
+ if not os.path.isdir(d):
119
+ raise RuntimeError(f"Trainer reported a save but {d} does not exist")
120
+ samples = samples_at(step, self.start_sample, self.seqs_per_step)
121
+ SD.save_cursor(d, SD.cursor_dict(self.seq_len, samples, self.seed, self.fingerprint))
122
+ t0 = time.time()
123
+ res = hubckpt.push_and_prune(self.repo, d, f"ckpt/checkpoint-{step}", self.api,
124
+ token=self.token, prune=self.prune,
125
+ min_free_gb=self.min_free_gb)
126
+ hubckpt.write_latest_pointer(self.repo, {
127
+ "path_in_repo": f"ckpt/checkpoint-{step}", "step": step, "samples_consumed": samples,
128
+ "tokens_consumed": samples * self.seq_len, "fingerprint": self.fingerprint,
129
+ "seed": self.seed}, self.api, token=self.token)
130
+ loss = next((h["loss"] for h in reversed(state.log_history) if "loss" in h), None)
131
+ print(f"CKPT {step} samples={samples:,} push+verify={round(time.time()-t0,1)}s "
132
+ f"ok={res['verify']['ok']} pruned={res.get('pruned')} free={res['free_after_gb']}GB "
133
+ f"loss={loss}", flush=True)
134
+ self.n += 1
135
+ self.results.append(res)
136
+ return control
137
+
138
+
139
+ def main():
140
+ ap = argparse.ArgumentParser()
141
+ ap.add_argument("--root", default="/kaggle/working/mixroot")
142
+ ap.add_argument("--out", default="/kaggle/working/run")
143
+ ap.add_argument("--hub-repo", default="", help="dataset repo for checkpoints; empty = no upload")
144
+ ap.add_argument("--hub-repo-type", default="dataset", choices=["dataset", "model"])
145
+ ap.add_argument("--seq-len", type=int, default=2048)
146
+ ap.add_argument("--layers", type=int, default=0)
147
+ ap.add_argument("--hidden", type=int, default=0)
148
+ ap.add_argument("--micro-batch", type=int, default=2)
149
+ ap.add_argument("--accum", type=int, default=32)
150
+ ap.add_argument("--lr", type=float, default=6e-4)
151
+ ap.add_argument("--tokens", type=int, default=1_000_000_000)
152
+ ap.add_argument("--max-steps", type=int, default=0, help="0 = derive from --tokens; >0 overrides")
153
+ ap.add_argument("--warmup-frac", type=float, default=0.02)
154
+ ap.add_argument("--decay-frac", type=float, default=0.80)
155
+ ap.add_argument("--seed", type=int, default=20260919)
156
+ ap.add_argument("--data-seed", type=int, default=20260919)
157
+ ap.add_argument("--resume", default="", help="checkpoint dir; empty = auto from Hub latest")
158
+ ap.add_argument("--val-tokens", type=int, default=2_000_000)
159
+ ap.add_argument("--log-every", type=int, default=10)
160
+ ap.add_argument("--push-every-steps", type=int, default=0, help="0 = only at 10% boundaries")
161
+ ap.add_argument("--prune", action="store_true", help="delete local checkpoint after hub verify")
162
+ ap.add_argument("--dry-run", action="store_true", help="build everything, train 0 steps")
163
+ args = ap.parse_args()
164
+
165
+ rank = int(os.environ.get("RANK", "0"))
166
+ world = int(os.environ.get("WORLD_SIZE", "1"))
167
+ say = (lambda *m: print(*m, flush=True)) if rank == 0 else (lambda *m: None)
168
+
169
+ man_path = os.path.join(args.root, "manifest.json")
170
+ if not os.path.exists(man_path):
171
+ raise SystemExit(f"no manifest at {man_path}: build the mix first (Phase 2 is not optional)")
172
+ man = json.load(open(man_path))
173
+ if man["total_tokens"] < args.tokens:
174
+ say(f"WARNING mix has {man['total_tokens']:,} tokens < target {args.tokens:,}")
175
+
176
+ store = SD.PackedTokenStore(args.root, man)
177
+ fingerprint = SD.mix_fingerprint(args.root, man)
178
+ tokens_per_step = args.micro_batch * args.seq_len * args.accum * world
179
+ steps_planned = args.max_steps or int(args.tokens / tokens_per_step)
180
+ say(f"mix: {store.total_tokens:,} tokens in {len(store.shards)} shards fp={fingerprint} "
181
+ f"| {tokens_per_step:,} tok/step x {steps_planned} steps = "
182
+ f"{tokens_per_step * steps_planned:,} tokens")
183
+
184
+ # ---- resume point: the cursor is the run's identity as much as the weights are.
185
+ # `--resume auto` is what a fresh instance with an empty disk passes: it asks the Hub where the run
186
+ # stopped and pulls that checkpoint down. §3.1 forbids recovering from anything left on an instance.
187
+ if args.resume == "auto":
188
+ if not args.hub_repo:
189
+ raise SystemExit("--resume auto needs --hub-repo")
190
+ import ounce100m_credentials
191
+ ounce100m_credentials.install()
192
+ from huggingface_hub import HfApi
193
+ ptr = hubckpt.latest_pointer(args.hub_repo, repo_type=args.hub_repo_type)
194
+ if "error" in ptr:
195
+ say(f"no latest pointer on {args.hub_repo} ({ptr['error']}) -> starting from step 0")
196
+ args.resume = ""
197
+ else:
198
+ dest = os.path.join(args.out, os.path.basename(ptr["path_in_repo"]))
199
+ say(f"auto-resume: hub says step {ptr.get('step')} at {ptr['path_in_repo']}")
200
+ got = hubckpt.download_checkpoint(args.hub_repo, ptr["path_in_repo"], dest,
201
+ HfApi(), repo_type=args.hub_repo_type)
202
+ say(f"downloaded {got['files']} files, {got['bytes']/1e6:.0f} MB in {got['seconds']}s")
203
+ if got["bytes"] < 1_000_000:
204
+ raise SystemExit("auto-resume downloaded a checkpoint that is not real weights")
205
+ args.resume = dest
206
+ cur = SD.load_cursor(args.resume) if args.resume else None
207
+ if args.resume:
208
+ say(f"resume from {args.resume} cursor={cur}")
209
+ start_sample = (cur or {}).get("samples_consumed", 0)
210
+ if cur:
211
+ for k, v in (("seq_len", args.seq_len), ("shuffle_seed", args.data_seed),
212
+ ("dataset_files_sha", fingerprint)):
213
+ if cur.get(k) != v:
214
+ raise SystemExit(f"resume refused: cursor {k}={cur.get(k)} != current {v}. "
215
+ "A resumed run must read the same data in the same order (§3.1).")
216
+
217
+ train_ds = SD.make_dataset(store, args.seq_len, args.data_seed, start_sample=start_sample)
218
+ # Validation comes from the mix's held-out val/ shards, never from the training stream: scoring on
219
+ # tokens the model already consumed would be a number nobody could reproduce (and §3.12).
220
+ vs = [s["file"] for s in man.get("val_shards", [])]
221
+ if not vs:
222
+ raise SystemExit("manifest lists no val_shards -- Gate 2 requires a held-out split")
223
+ vstore = SD.PackedTokenStore(args.root, man, files=vs)
224
+ n_val = min(max(1, args.val_tokens // args.seq_len), vstore.total_tokens // args.seq_len)
225
+ val_ds = SD.make_dataset(vstore, args.seq_len, args.data_seed, shuffle=False, count=n_val)
226
+
227
+ cfg = build_config(args.seq_len, args.layers or None, args.hidden or None)
228
+ set_seed(args.seed) # before init: both ranks must start from identical weights, not two draws
229
+ model = LlamaForCausalLM(cfg).to(dtype=torch.float32)
230
+ pc = count_params(model)
231
+ say("params:", json.dumps(pc))
232
+ if not (90_000_000 <= pc["sum_numel"] <= 110_000_000) and not args.layers and not args.hidden:
233
+ raise SystemExit(f"parameter count {pc['sum_numel']:,} is outside §2's 90-110M budget")
234
+
235
+ seqs_per_step = args.micro_batch * world * args.accum
236
+ if seqs_per_step * args.seq_len != tokens_per_step:
237
+ raise SystemExit("internal: batch arithmetic disagrees, token/step accounting is unreliable")
238
+ ckpt_every = args.push_every_steps or max(1, steps_planned // 10)
239
+
240
+ api = None
241
+ if args.hub_repo:
242
+ import ounce100m_credentials
243
+ ounce100m_credentials.install()
244
+ from huggingface_hub import HfApi
245
+ api = HfApi()
246
+ say(f"checkpoint hub target {args.hub_repo} (prune={args.prune}, every {ckpt_every} steps)")
247
+
248
+ tacc = TrainingArguments(
249
+ output_dir=args.out,
250
+ per_device_train_batch_size=args.micro_batch,
251
+ gradient_accumulation_steps=args.accum,
252
+ learning_rate=args.lr,
253
+ weight_decay=0.1,
254
+ adam_beta1=0.9, adam_beta2=0.95, adam_epsilon=1e-8,
255
+ max_grad_norm=1.0,
256
+ lr_scheduler_type="constant", # replaced by TrapezoidTrainer.create_scheduler
257
+ warmup_ratio=0.0, # the trapezoid owns warmup
258
+ num_train_epochs=1,
259
+ max_steps=0 if args.dry_run else steps_planned,
260
+ fp16=True, bf16=False,
261
+ gradient_checkpointing=False, # measure before needing it; docs/01-plan.md §6
262
+ ddp_find_unused_parameters=False,
263
+ dataloader_num_workers=2,
264
+ dataloader_pin_memory=False,
265
+ remove_unused_columns=False, # our dict keys must reach the model untouched
266
+ ignore_data_skip=True, # see header note 1 -- we advance position ourselves
267
+ save_strategy="no" if args.dry_run else "steps",
268
+ save_steps=ckpt_every,
269
+ save_total_limit=1, # §3.13: the Hub is the store, the instance is scratch
270
+ logging_steps=args.log_every, report_to=[],
271
+ seed=args.seed, data_seed=args.seed,
272
+ accelerator_config={"dispatch_batches": False},
273
+ average_tokens_across_devices=False,
274
+ )
275
+
276
+ tr = TrapezoidTrainer(
277
+ model=model, args=tacc, train_dataset=train_ds, eval_dataset=val_ds,
278
+ data_collator=default_data_collator, lr_shape={
279
+ "warmup_steps": max(100, int(steps_planned * args.warmup_frac)),
280
+ "decay_start": int(steps_planned * args.decay_frac),
281
+ "total_steps": steps_planned},
282
+ )
283
+ if api is not None:
284
+ tr.add_callback(HubPush(args.hub_repo, api, os.environ.get("HF_TOKEN"), args.seq_len,
285
+ args.data_seed, fingerprint, start_sample, seqs_per_step,
286
+ prune=args.prune))
287
+
288
+ # Precision must be ASSERTED, not configured: bf16 "works" silently on Turing and fp32 master
289
+ # weights can be lost in a refactor (docs/00-platform-notes.md §8, memory/ERRORS.md E-006). The
290
+ # scaler's home moved between transformers versions, so look in both places instead of trusting one
291
+ # attribute path (E-010: prefer version-free surfaces).
292
+ scaler = getattr(tr, "scaler", None) or getattr(getattr(tr, "accelerator", None), "scaler", None)
293
+ say(f"precision: fp16={tacc.fp16} bf16={tacc.bf16} scaler={type(scaler).__name__} "
294
+ f"master_dtype={next(model.parameters()).dtype}")
295
+ assert tacc.fp16 and not tacc.bf16, "not the frozen T4 fp16 recipe"
296
+ assert next(model.parameters()).dtype == torch.float32, "master weights are not fp32"
297
+
298
+ prior = None
299
+ if args.resume:
300
+ tsp = os.path.join(args.resume, "trainer_state.json")
301
+ if os.path.exists(tsp):
302
+ h0 = [h for h in json.load(open(tsp)).get("log_history", []) if "loss" in h]
303
+ prior = h0[-1]["loss"] if h0 else None
304
+ say(f"prior loss at resume: {prior} -- loss continuity is judged against this number")
305
+
306
+ if args.dry_run:
307
+ say(json.dumps({"dry_run": True, "train_samples": len(train_ds), "val_samples": len(val_ds),
308
+ "steps_planned": steps_planned, "ckpt_every": ckpt_every,
309
+ "params": pc, "fingerprint": fingerprint, "scaler": type(scaler).__name__}))
310
+ return
311
+
312
+ t0 = time.time()
313
+ tr.train(resume_from_checkpoint=args.resume or None)
314
+
315
+ elapsed = time.time() - t0
316
+ step = tr.state.global_step
317
+ samples = samples_at(step, start_sample, seqs_per_step)
318
+ consumed = samples * args.seq_len
319
+ hist = [h for h in tr.state.log_history if "loss" in h]
320
+ final_loss = hist[-1]["loss"] if hist else None
321
+ say(f"TRAIN DONE step={step}/{steps_planned} elapsed={elapsed/60:.1f} min "
322
+ f"tokens_consumed={consumed:,} last_loss={final_loss}")
323
+ # E-006, again: a run that produced NaN still reports a confident tokens/sec. Never let that pass.
324
+ if final_loss is None or not math.isfinite(final_loss):
325
+ raise SystemExit(f"final loss is not finite ({final_loss}) -- not exporting anything")
326
+
327
+ out = os.path.join(args.out, "final")
328
+ tr.save_model(out)
329
+ SD.save_cursor(out, SD.cursor_dict(args.seq_len, samples, args.data_seed, fingerprint))
330
+ json.dump({"step": step, "tokens_consumed": consumed, "seconds": round(elapsed, 1),
331
+ "params": pc, "fingerprint": fingerprint, "final_loss": final_loss,
332
+ "prior_loss": prior, "samples_consumed": samples, "steps_planned": steps_planned},
333
+ open(os.path.join(out, "run_summary.json"), "w"), indent=1, sort_keys=True)
334
+ ppl = None
335
+ try:
336
+ ev = tr.evaluate()
337
+ ppl = math.exp(min(20.0, ev["eval_loss"]))
338
+ say(f"validation loss={ev['eval_loss']:.4f} ppl={ppl:.2f} over {n_val} held-out windows")
339
+ except Exception as e:
340
+ say(f"validation eval failed: {type(e).__name__}: {str(e)[:200]}")
341
+ if api is not None:
342
+ r = hubckpt.push_and_prune(args.hub_repo, out, "final", api,
343
+ token=os.environ.get("HF_TOKEN"), prune=args.prune)
344
+ say("final push:", json.dumps({k: r[k] for k in ("verify", "pruned", "free_after_gb")})[:900])
345
+ say("RUN_JSON " + json.dumps({"final_step": step, "val_ppl": ppl, "tokens_consumed": consumed,
346
+ "tok_per_s": round(consumed / max(1, elapsed)),
347
+ "final_loss": final_loss, "prior_loss": prior}, sort_keys=True))
348
+
349
+
350
+ if __name__ == "__main__":
351
+ main()