Cion-lab commited on
Commit
7570b7b
·
verified ·
1 Parent(s): b6a124b

phase4_session: repin the trainer

Browse files
Files changed (1) hide show
  1. kernels/phase4_session.py +311 -311
kernels/phase4_session.py CHANGED
@@ -1,311 +1,311 @@
1
- """One Phase 4 session of the ounce100m main run: resume, train to a checkpoint boundary, stop.
2
-
3
- Why this file exists as a *launcher with a planner* rather than a plain torchrun line. The run needs
4
- 35.9 GPU-hours and Kaggle gives 30 h/week on sessions whose cap I have not measured, so it will be
5
- interrupted -- and an interruption that lands between two checkpoints throws away everything since the
6
- last one. §2 fixes the checkpoint cadence at one per 10 % of tokens (381 steps, ~3.6 h at the measured
7
- rate), and §3.1 forbids restarting the run once checkpoint 1 exists. The only lever left is *when a
8
- session chooses to stop*, so this script computes the largest whole number of checkpoint intervals that
9
- fits the session's remaining GPU hours and passes that as --stop-after-steps. Every session then ends
10
- sitting on a pushed, Hub-verified, locally-pruned checkpoint, and no billed work is ever discarded.
11
-
12
- Failure modes are deliberately loud: if the budget cannot cover one checkpoint interval it refuses to
13
- start rather than burning up to 3.6 hours of quota on work that cannot be recovered, and if the Hub
14
- pointer is unreadable for any reason other than "no checkpoint yet" it aborts (a resume that silently
15
- starts from step 0 would train the same data twice and destroy the run's honesty).
16
-
17
- No credentials in this file: they are fetched at run time by ounce100m_credentials (D-006).
18
- """
19
-
20
- import json
21
- import os
22
- import signal
23
- import subprocess
24
- import sys
25
- import time
26
-
27
- WORK = "/kaggle/working"
28
- T0 = time.time() # session start, so every step log line can say how far into the run it is
29
- MIX_REPO = "Cion-lab/ounce100m-mix-v1"
30
- CKPT_REPO = "Cion-lab/ounce100m-ckpt"
31
-
32
- # The frozen recipe (D-011, D-012, D-015). TOKENS_PER_STEP and HORIZON_STEPS below are re-derived here
33
- # with the same integer arithmetic the trainer uses (`seqs_per_step = micro*world*accum`,
34
- # `steps_planned = int(tokens/tokens_per_step)` in train_ounce100m.py), so the two agree by construction
35
- # rather than by copy -- if that formula ever changes in the trainer, change it here too and the mix-size
36
- # check below will disagree.
37
- SEQ_LEN = 1024
38
- MICRO_BATCH = 4
39
- ACCUM = 32
40
- WORLD = 2
41
- TOKENS = 1_000_000_000
42
- TOKENS_PER_STEP = MICRO_BATCH * SEQ_LEN * ACCUM * WORLD # 262,144
43
- HORIZON_STEPS = int(TOKENS / TOKENS_PER_STEP) # 3,814 -- the trainer's own int() division
44
- CKPT_EVERY = max(1, HORIZON_STEPS // 10) # 381 -- §2's one-per-10 % cadence
45
-
46
- # Planning rate: the defaults below are P3's measured frozen-geometry figures, so a session launched with
47
- # no environment set still plans at the rate the gate measured rather than the pre-Gate 3 estimate.
48
- PLANNING_TOK_PER_S = float(os.environ.get("PLANNING_TOK_PER_S", "9358"))
49
- SESSION_GPU_HOURS = float(os.environ.get("SESSION_GPU_HOURS", "6.9"))
50
- QUOTA_LEFT_HOURS = float(os.environ.get("QUOTA_LEFT_HOURS", "30.0"))
51
- RESERVE_HOURS = float(os.environ.get("RESERVE_HOURS", "0.6")) # startup, val eval, the last push
52
- MIN_FREE_GB = float(os.environ.get("MIN_FREE_GB", "8")) # mix 2.3 GB + two checkpoints + slack
53
-
54
-
55
- def sh(argv, label, timeout=None, env=None):
56
- print(f"=== {label}", flush=True)
57
- e = dict(os.environ)
58
- e["PYTHONPATH"] = WORK
59
- e["PYTHONUNBUFFERED"] = "1" # the -u torchrun cannot carry anymore
60
- e.update(env or {})
61
- t0 = time.time()
62
- # Streamed, not buffered: capture_output=True would hold an eleven-hour session's loss curve until
63
- # the child exited, so nobody could see the run going wrong while it was still running (a review
64
- # finding; §5 requires monitoring loss on every wake). stderr is merged into stdout so a torchrun
65
- # child's traceback arrives in the same stream instead of a separate tail that hides it (E-023).
66
- import threading
67
- p = subprocess.Popen(argv, cwd=WORK, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
68
- text=True, bufsize=1, env=e, start_new_session=True)
69
- state = {"killed": False}
70
-
71
- def _kill():
72
- # torchrun is not the process tree. SIGKILLing only the child leaves both ranks alive on the two
73
- # T4s, still stepping and still rolling latest.json, while the next session is launched into the
74
- # same container -- so the whole group goes, politely first (review finding).
75
- state["killed"] = True
76
- try:
77
- os.killpg(os.getpgid(p.pid), signal.SIGTERM)
78
- except Exception:
79
- p.kill()
80
-
81
- def _hardkill():
82
- try:
83
- os.killpg(os.getpgid(p.pid), signal.SIGKILL)
84
- except Exception:
85
- p.kill()
86
- p.kill()
87
-
88
- timer = threading.Timer(timeout, _kill) if timeout else None
89
- if timer:
90
- timer.daemon = True
91
- timer.start()
92
- hard = threading.Timer(timeout + 120, _hardkill)
93
- hard.daemon = True
94
- hard.start()
95
- lines = []
96
- for line in (p.stdout or []):
97
- lines.append(line.rstrip("\n"))
98
- print(" |", line[:300], flush=True)
99
- rc = p.wait()
100
- if timer:
101
- timer.cancel()
102
- hard.cancel()
103
- if state["killed"]:
104
- rc = -9
105
- print(f"{label}_RC {rc}{' TIMEOUT' if state['killed'] else ''} seconds "
106
- f"{round(time.time() - t0, 1)} elapsed {round(time.time() - T0)}", flush=True)
107
- return rc, "\n".join(lines)[-400000:]
108
-
109
-
110
- def fetch(rev, want):
111
- """Download the run's code at a pinned revision and assert every sha256 before executing any of it."""
112
- base = f"https://huggingface.co/Cion-lab/ounce100m-code/resolve/{rev}"
113
- import hashlib
114
- for local, (repo_path, sha) in sorted(want.items()):
115
- rc = subprocess.run(["curl", "-sfL", f"{base}/{repo_path}", "-o", local],
116
- capture_output=True, text=True).returncode
117
- assert rc == 0, f"fetch failed: {repo_path} at {rev}"
118
- got = hashlib.sha256(open(os.path.join(WORK, local), "rb").read()).hexdigest()
119
- assert got == sha, f"SHA MISMATCH {repo_path}: hub {got[:16]} != expected {sha[:16]}"
120
- print("OK", local, got[:12], flush=True)
121
- print("REV_OK", rev, flush=True)
122
-
123
-
124
- def plan(start_step, free_gb):
125
- """How far this session can train and still end on a checkpoint the Hub has verified.
126
-
127
- The segment has to stop where a checkpoint is pushed: the 381-step grid, or the horizon. Running to the
128
- horizon is preferred whenever it fits, because 3,814 is not a multiple of 381 -- stopping at the last
129
- grid point leaves four steps orphaned and demands one more session whose only budget test is "can you
130
- afford a whole 2.97 h interval", which the run could fail forever while its last seven minutes of work
131
- stayed unstarted (review finding).
132
- """
133
- sec_per_step = TOKENS_PER_STEP / PLANNING_TOK_PER_S
134
- # The reserve comes off the total, not off one term: `min(session, quota - reserve)` let an 11 h
135
- # session plan 11 h of training and leave nothing for the code fetch, the 2.3 GB mix download, the
136
- # push/verify cycles and the end-of-run evaluation (review finding).
137
- usable = max(0.0, min(SESSION_GPU_HOURS, QUOTA_LEFT_HOURS) - RESERVE_HOURS)
138
- steps_that_fit = int(usable * 3600.0 // sec_per_step)
139
- remaining = HORIZON_STEPS - start_step
140
- if steps_that_fit >= remaining:
141
- stop, planned = 0, remaining # the trainer runs to its own horizon
142
- else:
143
- n = max(0, steps_that_fit // CKPT_EVERY)
144
- stop, planned = start_step + n * CKPT_EVERY, n * CKPT_EVERY
145
- end = HORIZON_STEPS if stop == 0 else stop
146
- return {"start_step": start_step, "horizon_steps": HORIZON_STEPS, "ckpt_every": CKPT_EVERY,
147
- "tokens_per_step": TOKENS_PER_STEP, "planning_tok_per_s": PLANNING_TOK_PER_S,
148
- "hours_per_checkpoint_interval": round(CKPT_EVERY * sec_per_step / 3600.0, 3),
149
- "session_gpu_hours": SESSION_GPU_HOURS, "quota_left_hours": QUOTA_LEFT_HOURS,
150
- "reserve_hours": RESERVE_HOURS, "usable_hours": round(usable, 3),
151
- "intervals_this_session": max(1, planned // CKPT_EVERY) if planned else 0,
152
- "planned_steps": planned, "stop_after_steps": stop,
153
- "runs_to_horizon_this_session": stop == 0 and planned > 0,
154
- "tokens_at_stop": end * TOKENS_PER_STEP,
155
- "pct_of_run_at_stop": round(100.0 * end * TOKENS_PER_STEP / TOKENS, 2),
156
- "free_gb_before_start": free_gb}
157
-
158
-
159
- def df_free_gb(path=WORK):
160
- """Free space on the working volume, in GB, or None if `df` could not be read.
161
-
162
- `df -k`, not `-h`: the human-readable form prints "318G"/"500M"/"1.5T" and float() on those raises,
163
- which would take the session down before training started.
164
- """
165
- rc, out = sh(["df", "-k", path], "df", timeout=60)
166
- for line in out.splitlines():
167
- parts = line.split()
168
- if len(parts) >= 4 and ("overlay" in line or line.startswith("/dev/")):
169
- try:
170
- return float(parts[3]) / 1048576.0
171
- except ValueError:
172
- return None
173
- return None
174
-
175
-
176
- def main():
177
- os.chdir(WORK)
178
- sys.path.insert(0, WORK)
179
- rev = os.environ.get("OUNCE100M_REV") or sys.argv[1]
180
- fetch(rev, {
181
- "ounce100m_credentials.py": ("ounce100m_credentials.py",
182
- "6525f62f03f2d73650a1eb4f70fcb52d1194caad4ca88b2d8bd8fd54f88339b6"),
183
- "shard_dataset.py": ("train/shard_dataset.py",
184
- "f35653bf4c8f2cfe7bb0c2c7835e308505fb5a84b4eb002d0f82f2cada768ca6"),
185
- "hubckpt.py": ("train/hubckpt.py",
186
- "568c31b300906cb8d78a59ef890baa9731b18e064e3d69b0eaea4b45c546f23e"),
187
- "train_ounce100m.py": ("train/train_ounce100m.py",
188
- "98f1402ace0f76dfff89bc50bbdb8f49a270e165d711b8ceef71cd6030ba2665"),
189
- })
190
-
191
- import ounce100m_credentials
192
- print("creds:", json.dumps(ounce100m_credentials.install(verify=True)), flush=True)
193
- sh(["bash", "-c", "nvidia-smi --query-gpu=name,memory.used,memory.total "
194
- "--format=csv,noheader; free -g | head -2"], "env")
195
- # The mix is 2.3 GB and it lands during this session, so the pre-download floor is the run's floor plus
196
- # the pull. Measuring again after it, rather than trusting the arithmetic, is the point.
197
- free_gb = df_free_gb()
198
- if free_gb is None:
199
- print("VERDICT REFUSED_TO_START: could not read the free space on /kaggle/working from `df` -- "
200
- "refusing to guess whether the mix and two checkpoints will fit", flush=True)
201
- raise SystemExit(10)
202
- if free_gb < MIN_FREE_GB + 2.3:
203
- print(f"VERDICT REFUSED_TO_START: {free_gb:.1f} GB free on /kaggle/working before the pull; this "
204
- f"run needs {MIN_FREE_GB} GB free once the 2.3 GB mix has landed (~1.7 GB per checkpoint and "
205
- "the two unpacked resume directories, on top of it). A Kaggle instance starts near 19.5 GB, "
206
- "so a low number here means something is left over from a previous session on this box.",
207
- flush=True)
208
- raise SystemExit(10)
209
- print(f"disk: {free_gb:.1f} GB free before the mix lands", flush=True)
210
-
211
- # The Hub pointer is the only truth about where the run is (never local state -- §3.13).
212
- import hubckpt
213
- tok = os.environ["HF_TOKEN"]
214
- ptr = hubckpt.latest_pointer(CKPT_REPO, repo_type="dataset", token=tok)
215
- if ptr.get("error"):
216
- print(f"FATAL: cannot read latest.json from {CKPT_REPO}: {ptr['error'][:300]}\n"
217
- "Refusing to guess a start step: the alternative is training data that was already read.",
218
- flush=True)
219
- raise SystemExit(6)
220
- start_step = int(ptr.get("step", 0)) if not ptr.get("not_found") else 0
221
- print(f"hub says: {'no checkpoint yet -- starting from step 0' if start_step == 0 else ptr}",
222
- flush=True)
223
- if start_step >= HORIZON_STEPS:
224
- print("VERDICT RUN_ALREADY_COMPLETE at step", start_step, flush=True)
225
- return
226
-
227
- # Plan before downloading anything: an unusable budget should refuse in seconds, not after pulling
228
- # 2.3 GB onto a billed GPU session.
229
- pl = plan(start_step, free_gb)
230
- print("PLAN_JSON", json.dumps(pl, indent=1), flush=True)
231
- if not pl["planned_steps"]:
232
- print(f"VERDICT REFUSED_TO_START: {pl['usable_hours']} usable hours will not reach one "
233
- f"{pl['hours_per_checkpoint_interval']} h checkpoint interval ({pl['start_step']} → "
234
- f"{HORIZON_STEPS} left). Launching anyway would burn quota on work that cannot be resumed "
235
- "from. Raise SESSION_GPU_HOURS or wait for quota.", flush=True)
236
- raise SystemExit(8)
237
-
238
- # Data: the published mix, fetched by anonymous read so this works on any instance. The checks run
239
- # here rather than inside the downloader so a quoting mistake cannot turn into a silent pass.
240
- rc, _ = sh([sys.executable, "-c",
241
- "import sys; sys.path.insert(0, '/kaggle/working');\n"
242
- "from huggingface_hub import snapshot_download\n"
243
- f"p = snapshot_download(repo_id='{MIX_REPO}', repo_type='dataset',\n"
244
- " local_dir='/kaggle/working/mixroot', max_workers=4)\n"
245
- "print('downloaded to', p)\n"],
246
- "fetch_mix", 3600)
247
- if rc != 0:
248
- raise SystemExit(7)
249
- free_gb = df_free_gb()
250
- if free_gb is not None and free_gb < MIN_FREE_GB:
251
- print(f"VERDICT REFUSED_TO_START: only {free_gb:.1f} GB free after the mix landed, below the "
252
- f"{MIN_FREE_GB} GB the checkpoints and the two resume directories need", flush=True)
253
- raise SystemExit(10)
254
- man = json.load(open(os.path.join(WORK, "mixroot", "manifest.json")))
255
- windows = int(man["total_tokens"]) // SEQ_LEN
256
- needed_windows = HORIZON_STEPS * MICRO_BATCH * ACCUM * WORLD
257
- if len(man.get("shards") or []) != int(man.get("n_shards") or -1):
258
- print(f"VERDICT REFUSED_TO_START: the published manifest lists {len(man.get('shards') or [])} "
259
- f"shard records but claims n_shards={man.get('n_shards')}", flush=True)
260
- raise SystemExit(9)
261
- print(f"mix: {man['total_tokens']:,} train tokens in {man['n_shards']} shards, "
262
- f">= {man['distinct_sources_per_shard_min']} sources per shard, "
263
- f"{windows:,} windows of {SEQ_LEN}; the horizon needs {needed_windows:,} "
264
- f"({100.0 * windows / needed_windows - 100:.1f} % margin)", flush=True)
265
- if int(man["total_tokens"]) < TOKENS or windows < needed_windows:
266
- # Training past the end of the dataset would end the epoch early and report a token count that
267
- # was never reached. The token target is a claim in the final report, so it is checked here.
268
- print(f"VERDICT REFUSED_TO_START: mix holds {man['total_tokens']:,} tokens / {windows:,} "
269
- f"windows but the run needs {TOKENS:,} / {needed_windows:,}. Either the target drops to "
270
- "0.9 B (D-012's fallback) or more sources are staged -- not a decision to make "
271
- "implicitly by launching anyway.", flush=True)
272
- raise SystemExit(9)
273
-
274
- if os.environ.get("PHASE4_PREP_ONLY") == "1":
275
- # Everything a session can get wrong before it bills GPU time has now been checked: the pinned
276
- # code hashes, the credentials resolve, the checkpoint pointer is readable, the published mix is
277
- # downloadable and large enough, and the plan lands on a checkpoint boundary. A CPU instance can
278
- # run all of that for free (E-031's arithmetic is exactly the kind of thing to find there).
279
- print("VERDICT PREP_ONLY_OK stop_after_steps", pl["stop_after_steps"],
280
- "planned_steps", pl["planned_steps"], flush=True)
281
- return
282
-
283
- argv = ["train_ounce100m.py",
284
- "--root", f"{WORK}/mixroot", "--out", f"{WORK}/run",
285
- "--hub-repo", CKPT_REPO, "--prune",
286
- "--seq-len", str(SEQ_LEN), "--attn", "eager", "--grad-ckpt",
287
- "--micro-batch", str(MICRO_BATCH), "--accum", str(ACCUM),
288
- "--tokens", str(TOKENS), "--lr", "6e-4", "--warmup-frac", "0.02", "--decay-frac", "0.80",
289
- "--log-every", "20", "--val-tokens", "2000000",
290
- "--resume", "auto", "--stop-after-steps", str(pl["stop_after_steps"])]
291
- rc, out = sh(["torchrun", "--nproc_per_node=2"] + argv, "TRAIN",
292
- # Counted from the session's own start, not the child's: the ceiling is on the container,
293
- # and by now the code fetch and the 2.3 GB pull have already spent part of the reserve.
294
- # Killing at ceiling-minus-15-min takes the whole process group with it (see sh) instead
295
- # of being caught mid-checkpoint by a platform kill that leaves both ranks running.
296
- timeout=max(600, int(SESSION_GPU_HOURS * 3600 - (time.time() - T0) - 900)))
297
- for line in out.splitlines():
298
- if line.startswith(("CKPT ", "RUN_JSON ", "resume from", "auto-resume", "precision:",
299
- "params:", "validation loss", "segment boundary")):
300
- print("KEY>", line[:400], flush=True)
301
- end_step = HORIZON_STEPS if pl["stop_after_steps"] == 0 else pl["stop_after_steps"]
302
- print("VERDICT PHASE4_SESSION rc", rc, "steps", start_step, "->", end_step,
303
- "tokens_at_stop", pl["tokens_at_stop"], "session_seconds",
304
- round(time.time() - T0, 1), flush=True)
305
- print(f"disk at exit: {df_free_gb()} GB free", flush=True)
306
- # The session's own wall clock is the measurement that sharpens every later plan.
307
- raise SystemExit(rc)
308
-
309
-
310
- if __name__ == "__main__":
311
- main()
 
1
+ """One Phase 4 session of the ounce100m main run: resume, train to a checkpoint boundary, stop.
2
+
3
+ Why this file exists as a *launcher with a planner* rather than a plain torchrun line. The run needs
4
+ 35.9 GPU-hours and Kaggle gives 30 h/week on sessions whose cap I have not measured, so it will be
5
+ interrupted -- and an interruption that lands between two checkpoints throws away everything since the
6
+ last one. §2 fixes the checkpoint cadence at one per 10 % of tokens (381 steps, ~3.6 h at the measured
7
+ rate), and §3.1 forbids restarting the run once checkpoint 1 exists. The only lever left is *when a
8
+ session chooses to stop*, so this script computes the largest whole number of checkpoint intervals that
9
+ fits the session's remaining GPU hours and passes that as --stop-after-steps. Every session then ends
10
+ sitting on a pushed, Hub-verified, locally-pruned checkpoint, and no billed work is ever discarded.
11
+
12
+ Failure modes are deliberately loud: if the budget cannot cover one checkpoint interval it refuses to
13
+ start rather than burning up to 3.6 hours of quota on work that cannot be recovered, and if the Hub
14
+ pointer is unreadable for any reason other than "no checkpoint yet" it aborts (a resume that silently
15
+ starts from step 0 would train the same data twice and destroy the run's honesty).
16
+
17
+ No credentials in this file: they are fetched at run time by ounce100m_credentials (D-006).
18
+ """
19
+
20
+ import json
21
+ import os
22
+ import signal
23
+ import subprocess
24
+ import sys
25
+ import time
26
+
27
+ WORK = "/kaggle/working"
28
+ T0 = time.time() # session start, so every step log line can say how far into the run it is
29
+ MIX_REPO = "Cion-lab/ounce100m-mix-v1"
30
+ CKPT_REPO = "Cion-lab/ounce100m-ckpt"
31
+
32
+ # The frozen recipe (D-011, D-012, D-015). TOKENS_PER_STEP and HORIZON_STEPS below are re-derived here
33
+ # with the same integer arithmetic the trainer uses (`seqs_per_step = micro*world*accum`,
34
+ # `steps_planned = int(tokens/tokens_per_step)` in train_ounce100m.py), so the two agree by construction
35
+ # rather than by copy -- if that formula ever changes in the trainer, change it here too and the mix-size
36
+ # check below will disagree.
37
+ SEQ_LEN = 1024
38
+ MICRO_BATCH = 4
39
+ ACCUM = 32
40
+ WORLD = 2
41
+ TOKENS = 1_000_000_000
42
+ TOKENS_PER_STEP = MICRO_BATCH * SEQ_LEN * ACCUM * WORLD # 262,144
43
+ HORIZON_STEPS = int(TOKENS / TOKENS_PER_STEP) # 3,814 -- the trainer's own int() division
44
+ CKPT_EVERY = max(1, HORIZON_STEPS // 10) # 381 -- §2's one-per-10 % cadence
45
+
46
+ # Planning rate: the defaults below are P3's measured frozen-geometry figures, so a session launched with
47
+ # no environment set still plans at the rate the gate measured rather than the pre-Gate 3 estimate.
48
+ PLANNING_TOK_PER_S = float(os.environ.get("PLANNING_TOK_PER_S", "9358"))
49
+ SESSION_GPU_HOURS = float(os.environ.get("SESSION_GPU_HOURS", "6.9"))
50
+ QUOTA_LEFT_HOURS = float(os.environ.get("QUOTA_LEFT_HOURS", "30.0"))
51
+ RESERVE_HOURS = float(os.environ.get("RESERVE_HOURS", "0.6")) # startup, val eval, the last push
52
+ MIN_FREE_GB = float(os.environ.get("MIN_FREE_GB", "8")) # mix 2.3 GB + two checkpoints + slack
53
+
54
+
55
+ def sh(argv, label, timeout=None, env=None):
56
+ print(f"=== {label}", flush=True)
57
+ e = dict(os.environ)
58
+ e["PYTHONPATH"] = WORK
59
+ e["PYTHONUNBUFFERED"] = "1" # the -u torchrun cannot carry anymore
60
+ e.update(env or {})
61
+ t0 = time.time()
62
+ # Streamed, not buffered: capture_output=True would hold an eleven-hour session's loss curve until
63
+ # the child exited, so nobody could see the run going wrong while it was still running (a review
64
+ # finding; §5 requires monitoring loss on every wake). stderr is merged into stdout so a torchrun
65
+ # child's traceback arrives in the same stream instead of a separate tail that hides it (E-023).
66
+ import threading
67
+ p = subprocess.Popen(argv, cwd=WORK, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
68
+ text=True, bufsize=1, env=e, start_new_session=True)
69
+ state = {"killed": False}
70
+
71
+ def _kill():
72
+ # torchrun is not the process tree. SIGKILLing only the child leaves both ranks alive on the two
73
+ # T4s, still stepping and still rolling latest.json, while the next session is launched into the
74
+ # same container -- so the whole group goes, politely first (review finding).
75
+ state["killed"] = True
76
+ try:
77
+ os.killpg(os.getpgid(p.pid), signal.SIGTERM)
78
+ except Exception:
79
+ p.kill()
80
+
81
+ def _hardkill():
82
+ try:
83
+ os.killpg(os.getpgid(p.pid), signal.SIGKILL)
84
+ except Exception:
85
+ p.kill()
86
+ p.kill()
87
+
88
+ timer = threading.Timer(timeout, _kill) if timeout else None
89
+ if timer:
90
+ timer.daemon = True
91
+ timer.start()
92
+ hard = threading.Timer(timeout + 120, _hardkill)
93
+ hard.daemon = True
94
+ hard.start()
95
+ lines = []
96
+ for line in (p.stdout or []):
97
+ lines.append(line.rstrip("\n"))
98
+ print(" |", line[:300], flush=True)
99
+ rc = p.wait()
100
+ if timer:
101
+ timer.cancel()
102
+ hard.cancel()
103
+ if state["killed"]:
104
+ rc = -9
105
+ print(f"{label}_RC {rc}{' TIMEOUT' if state['killed'] else ''} seconds "
106
+ f"{round(time.time() - t0, 1)} elapsed {round(time.time() - T0)}", flush=True)
107
+ return rc, "\n".join(lines)[-400000:]
108
+
109
+
110
+ def fetch(rev, want):
111
+ """Download the run's code at a pinned revision and assert every sha256 before executing any of it."""
112
+ base = f"https://huggingface.co/Cion-lab/ounce100m-code/resolve/{rev}"
113
+ import hashlib
114
+ for local, (repo_path, sha) in sorted(want.items()):
115
+ rc = subprocess.run(["curl", "-sfL", f"{base}/{repo_path}", "-o", local],
116
+ capture_output=True, text=True).returncode
117
+ assert rc == 0, f"fetch failed: {repo_path} at {rev}"
118
+ got = hashlib.sha256(open(os.path.join(WORK, local), "rb").read()).hexdigest()
119
+ assert got == sha, f"SHA MISMATCH {repo_path}: hub {got[:16]} != expected {sha[:16]}"
120
+ print("OK", local, got[:12], flush=True)
121
+ print("REV_OK", rev, flush=True)
122
+
123
+
124
+ def plan(start_step, free_gb):
125
+ """How far this session can train and still end on a checkpoint the Hub has verified.
126
+
127
+ The segment has to stop where a checkpoint is pushed: the 381-step grid, or the horizon. Running to the
128
+ horizon is preferred whenever it fits, because 3,814 is not a multiple of 381 -- stopping at the last
129
+ grid point leaves four steps orphaned and demands one more session whose only budget test is "can you
130
+ afford a whole 2.97 h interval", which the run could fail forever while its last seven minutes of work
131
+ stayed unstarted (review finding).
132
+ """
133
+ sec_per_step = TOKENS_PER_STEP / PLANNING_TOK_PER_S
134
+ # The reserve comes off the total, not off one term: `min(session, quota - reserve)` let an 11 h
135
+ # session plan 11 h of training and leave nothing for the code fetch, the 2.3 GB mix download, the
136
+ # push/verify cycles and the end-of-run evaluation (review finding).
137
+ usable = max(0.0, min(SESSION_GPU_HOURS, QUOTA_LEFT_HOURS) - RESERVE_HOURS)
138
+ steps_that_fit = int(usable * 3600.0 // sec_per_step)
139
+ remaining = HORIZON_STEPS - start_step
140
+ if steps_that_fit >= remaining:
141
+ stop, planned = 0, remaining # the trainer runs to its own horizon
142
+ else:
143
+ n = max(0, steps_that_fit // CKPT_EVERY)
144
+ stop, planned = start_step + n * CKPT_EVERY, n * CKPT_EVERY
145
+ end = HORIZON_STEPS if stop == 0 else stop
146
+ return {"start_step": start_step, "horizon_steps": HORIZON_STEPS, "ckpt_every": CKPT_EVERY,
147
+ "tokens_per_step": TOKENS_PER_STEP, "planning_tok_per_s": PLANNING_TOK_PER_S,
148
+ "hours_per_checkpoint_interval": round(CKPT_EVERY * sec_per_step / 3600.0, 3),
149
+ "session_gpu_hours": SESSION_GPU_HOURS, "quota_left_hours": QUOTA_LEFT_HOURS,
150
+ "reserve_hours": RESERVE_HOURS, "usable_hours": round(usable, 3),
151
+ "intervals_this_session": max(1, planned // CKPT_EVERY) if planned else 0,
152
+ "planned_steps": planned, "stop_after_steps": stop,
153
+ "runs_to_horizon_this_session": stop == 0 and planned > 0,
154
+ "tokens_at_stop": end * TOKENS_PER_STEP,
155
+ "pct_of_run_at_stop": round(100.0 * end * TOKENS_PER_STEP / TOKENS, 2),
156
+ "free_gb_before_start": free_gb}
157
+
158
+
159
+ def df_free_gb(path=WORK):
160
+ """Free space on the working volume, in GB, or None if `df` could not be read.
161
+
162
+ `df -k`, not `-h`: the human-readable form prints "318G"/"500M"/"1.5T" and float() on those raises,
163
+ which would take the session down before training started.
164
+ """
165
+ rc, out = sh(["df", "-k", path], "df", timeout=60)
166
+ for line in out.splitlines():
167
+ parts = line.split()
168
+ if len(parts) >= 4 and ("overlay" in line or line.startswith("/dev/")):
169
+ try:
170
+ return float(parts[3]) / 1048576.0
171
+ except ValueError:
172
+ return None
173
+ return None
174
+
175
+
176
+ def main():
177
+ os.chdir(WORK)
178
+ sys.path.insert(0, WORK)
179
+ rev = os.environ.get("OUNCE100M_REV") or sys.argv[1]
180
+ fetch(rev, {
181
+ "ounce100m_credentials.py": ("ounce100m_credentials.py",
182
+ "6525f62f03f2d73650a1eb4f70fcb52d1194caad4ca88b2d8bd8fd54f88339b6"),
183
+ "shard_dataset.py": ("train/shard_dataset.py",
184
+ "f35653bf4c8f2cfe7bb0c2c7835e308505fb5a84b4eb002d0f82f2cada768ca6"),
185
+ "hubckpt.py": ("train/hubckpt.py",
186
+ "568c31b300906cb8d78a59ef890baa9731b18e064e3d69b0eaea4b45c546f23e"),
187
+ "train_ounce100m.py": ("train/train_ounce100m.py",
188
+ "744259c7519e5a12c2d2bf4dca534fa39f7ddb7e6689e1a5a9e0925bfaa640aa"),
189
+ })
190
+
191
+ import ounce100m_credentials
192
+ print("creds:", json.dumps(ounce100m_credentials.install(verify=True)), flush=True)
193
+ sh(["bash", "-c", "nvidia-smi --query-gpu=name,memory.used,memory.total "
194
+ "--format=csv,noheader; free -g | head -2"], "env")
195
+ # The mix is 2.3 GB and it lands during this session, so the pre-download floor is the run's floor plus
196
+ # the pull. Measuring again after it, rather than trusting the arithmetic, is the point.
197
+ free_gb = df_free_gb()
198
+ if free_gb is None:
199
+ print("VERDICT REFUSED_TO_START: could not read the free space on /kaggle/working from `df` -- "
200
+ "refusing to guess whether the mix and two checkpoints will fit", flush=True)
201
+ raise SystemExit(10)
202
+ if free_gb < MIN_FREE_GB + 2.3:
203
+ print(f"VERDICT REFUSED_TO_START: {free_gb:.1f} GB free on /kaggle/working before the pull; this "
204
+ f"run needs {MIN_FREE_GB} GB free once the 2.3 GB mix has landed (~1.7 GB per checkpoint and "
205
+ "the two unpacked resume directories, on top of it). A Kaggle instance starts near 19.5 GB, "
206
+ "so a low number here means something is left over from a previous session on this box.",
207
+ flush=True)
208
+ raise SystemExit(10)
209
+ print(f"disk: {free_gb:.1f} GB free before the mix lands", flush=True)
210
+
211
+ # The Hub pointer is the only truth about where the run is (never local state -- §3.13).
212
+ import hubckpt
213
+ tok = os.environ["HF_TOKEN"]
214
+ ptr = hubckpt.latest_pointer(CKPT_REPO, repo_type="dataset", token=tok)
215
+ if ptr.get("error"):
216
+ print(f"FATAL: cannot read latest.json from {CKPT_REPO}: {ptr['error'][:300]}\n"
217
+ "Refusing to guess a start step: the alternative is training data that was already read.",
218
+ flush=True)
219
+ raise SystemExit(6)
220
+ start_step = int(ptr.get("step", 0)) if not ptr.get("not_found") else 0
221
+ print(f"hub says: {'no checkpoint yet -- starting from step 0' if start_step == 0 else ptr}",
222
+ flush=True)
223
+ if start_step >= HORIZON_STEPS:
224
+ print("VERDICT RUN_ALREADY_COMPLETE at step", start_step, flush=True)
225
+ return
226
+
227
+ # Plan before downloading anything: an unusable budget should refuse in seconds, not after pulling
228
+ # 2.3 GB onto a billed GPU session.
229
+ pl = plan(start_step, free_gb)
230
+ print("PLAN_JSON", json.dumps(pl, indent=1), flush=True)
231
+ if not pl["planned_steps"]:
232
+ print(f"VERDICT REFUSED_TO_START: {pl['usable_hours']} usable hours will not reach one "
233
+ f"{pl['hours_per_checkpoint_interval']} h checkpoint interval ({pl['start_step']} → "
234
+ f"{HORIZON_STEPS} left). Launching anyway would burn quota on work that cannot be resumed "
235
+ "from. Raise SESSION_GPU_HOURS or wait for quota.", flush=True)
236
+ raise SystemExit(8)
237
+
238
+ # Data: the published mix, fetched by anonymous read so this works on any instance. The checks run
239
+ # here rather than inside the downloader so a quoting mistake cannot turn into a silent pass.
240
+ rc, _ = sh([sys.executable, "-c",
241
+ "import sys; sys.path.insert(0, '/kaggle/working');\n"
242
+ "from huggingface_hub import snapshot_download\n"
243
+ f"p = snapshot_download(repo_id='{MIX_REPO}', repo_type='dataset',\n"
244
+ " local_dir='/kaggle/working/mixroot', max_workers=4)\n"
245
+ "print('downloaded to', p)\n"],
246
+ "fetch_mix", 3600)
247
+ if rc != 0:
248
+ raise SystemExit(7)
249
+ free_gb = df_free_gb()
250
+ if free_gb is not None and free_gb < MIN_FREE_GB:
251
+ print(f"VERDICT REFUSED_TO_START: only {free_gb:.1f} GB free after the mix landed, below the "
252
+ f"{MIN_FREE_GB} GB the checkpoints and the two resume directories need", flush=True)
253
+ raise SystemExit(10)
254
+ man = json.load(open(os.path.join(WORK, "mixroot", "manifest.json")))
255
+ windows = int(man["total_tokens"]) // SEQ_LEN
256
+ needed_windows = HORIZON_STEPS * MICRO_BATCH * ACCUM * WORLD
257
+ if len(man.get("shards") or []) != int(man.get("n_shards") or -1):
258
+ print(f"VERDICT REFUSED_TO_START: the published manifest lists {len(man.get('shards') or [])} "
259
+ f"shard records but claims n_shards={man.get('n_shards')}", flush=True)
260
+ raise SystemExit(9)
261
+ print(f"mix: {man['total_tokens']:,} train tokens in {man['n_shards']} shards, "
262
+ f">= {man['distinct_sources_per_shard_min']} sources per shard, "
263
+ f"{windows:,} windows of {SEQ_LEN}; the horizon needs {needed_windows:,} "
264
+ f"({100.0 * windows / needed_windows - 100:.1f} % margin)", flush=True)
265
+ if int(man["total_tokens"]) < TOKENS or windows < needed_windows:
266
+ # Training past the end of the dataset would end the epoch early and report a token count that
267
+ # was never reached. The token target is a claim in the final report, so it is checked here.
268
+ print(f"VERDICT REFUSED_TO_START: mix holds {man['total_tokens']:,} tokens / {windows:,} "
269
+ f"windows but the run needs {TOKENS:,} / {needed_windows:,}. Either the target drops to "
270
+ "0.9 B (D-012's fallback) or more sources are staged -- not a decision to make "
271
+ "implicitly by launching anyway.", flush=True)
272
+ raise SystemExit(9)
273
+
274
+ if os.environ.get("PHASE4_PREP_ONLY") == "1":
275
+ # Everything a session can get wrong before it bills GPU time has now been checked: the pinned
276
+ # code hashes, the credentials resolve, the checkpoint pointer is readable, the published mix is
277
+ # downloadable and large enough, and the plan lands on a checkpoint boundary. A CPU instance can
278
+ # run all of that for free (E-031's arithmetic is exactly the kind of thing to find there).
279
+ print("VERDICT PREP_ONLY_OK stop_after_steps", pl["stop_after_steps"],
280
+ "planned_steps", pl["planned_steps"], flush=True)
281
+ return
282
+
283
+ argv = ["train_ounce100m.py",
284
+ "--root", f"{WORK}/mixroot", "--out", f"{WORK}/run",
285
+ "--hub-repo", CKPT_REPO, "--prune",
286
+ "--seq-len", str(SEQ_LEN), "--attn", "eager", "--grad-ckpt",
287
+ "--micro-batch", str(MICRO_BATCH), "--accum", str(ACCUM),
288
+ "--tokens", str(TOKENS), "--lr", "6e-4", "--warmup-frac", "0.02", "--decay-frac", "0.80",
289
+ "--log-every", "20", "--val-tokens", "2000000",
290
+ "--resume", "auto", "--stop-after-steps", str(pl["stop_after_steps"])]
291
+ rc, out = sh(["torchrun", "--nproc_per_node=2"] + argv, "TRAIN",
292
+ # Counted from the session's own start, not the child's: the ceiling is on the container,
293
+ # and by now the code fetch and the 2.3 GB pull have already spent part of the reserve.
294
+ # Killing at ceiling-minus-15-min takes the whole process group with it (see sh) instead
295
+ # of being caught mid-checkpoint by a platform kill that leaves both ranks running.
296
+ timeout=max(600, int(SESSION_GPU_HOURS * 3600 - (time.time() - T0) - 900)))
297
+ for line in out.splitlines():
298
+ if line.startswith(("CKPT ", "RUN_JSON ", "resume from", "auto-resume", "precision:",
299
+ "params:", "validation loss", "segment boundary")):
300
+ print("KEY>", line[:400], flush=True)
301
+ end_step = HORIZON_STEPS if pl["stop_after_steps"] == 0 else pl["stop_after_steps"]
302
+ print("VERDICT PHASE4_SESSION rc", rc, "steps", start_step, "->", end_step,
303
+ "tokens_at_stop", pl["tokens_at_stop"], "session_seconds",
304
+ round(time.time() - T0, 1), flush=True)
305
+ print(f"disk at exit: {df_free_gb()} GB free", flush=True)
306
+ # The session's own wall clock is the measurement that sharpens every later plan.
307
+ raise SystemExit(rc)
308
+
309
+
310
+ if __name__ == "__main__":
311
+ main()