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