Cion-lab commited on
Commit
a067bd1
·
verified ·
1 Parent(s): 18d6af8

hub sync for staged sources + cold-resume rehearsal script

Browse files
Files changed (3) hide show
  1. build/build_mix.py +72 -0
  2. build/hubsync.py +105 -0
  3. build/smoke_hub_resume.py +100 -0
build/build_mix.py CHANGED
@@ -219,12 +219,34 @@ class State:
219
 
220
  # --------------------------------------------------------------------- stage
221
  def stage(args, st, tokenizer):
 
 
 
222
  for src in SOURCES:
223
  key = src_key(src)
224
  rec = st.d["staged"].get(key, {})
225
  if rec.get("complete"):
226
  print(f"stage: skip {key} (complete)", flush=True)
227
  continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  want = min(src["tokens"], args.cap_tokens_per_source or src["tokens"])
229
  if args.budget_tokens and st.staged_tokens_total() >= args.budget_tokens:
230
  print("stage: overall budget reached", flush=True)
@@ -328,6 +350,19 @@ def stage(args, st, tokenizer):
328
  "docs_seen": docs_seen, "dropped": dict(drops),
329
  "target": want, "seconds": round(time.monotonic() - t0, 1)}
330
  st.commit(new_h, new_k)
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  el = time.monotonic() - t0
332
  print(f"stage: {key} {got:,} tok of {want:,} from {docs_seen:,} docs; "
333
  f"dup={drops['exact_dup']} keydup={drops['key_dup']} lang={drops['non_english']} "
@@ -433,6 +468,9 @@ def main():
433
  ap.add_argument("--max-chars", type=int, default=100_000)
434
  ap.add_argument("--sec-per-source", type=int, default=0)
435
  ap.add_argument("--only", default="", help="comma list of config names to stage")
 
 
 
436
  ap.add_argument("--stage-only", action="store_true")
437
  ap.add_argument("--merge-only", action="store_true")
438
  args = ap.parse_args()
@@ -451,7 +489,41 @@ def main():
451
  print(f"tokenizer vocab={tk.get_vocab_size()} "
452
  f"sha256={hashlib.sha256(open(tok_path, 'rb').read()).hexdigest()[:12]}", flush=True)
453
 
 
 
 
 
 
 
 
 
 
 
 
 
454
  st = State(args.root)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
455
  t0 = time.monotonic()
456
  if not args.merge_only:
457
  stage(args, st, tk)
 
219
 
220
  # --------------------------------------------------------------------- stage
221
  def stage(args, st, tokenizer):
222
+ remote = HS.remote_stage_files(args.hub_repo) if (args.hub_repo and not args.merge_only) else set()
223
+ if args.hub_repo:
224
+ print(f"hub: {len(remote)} staged files already on {args.hub_repo}", flush=True)
225
  for src in SOURCES:
226
  key = src_key(src)
227
  rec = st.d["staged"].get(key, {})
228
  if rec.get("complete"):
229
  print(f"stage: skip {key} (complete)", flush=True)
230
  continue
231
+ if args.hub_repo:
232
+ # A source whose shards AND record are already on the Hub is done; pulling the record is
233
+ # cheaper than re-staging and keeps the merge's proportional weights honest.
234
+ have = [p for p in remote if p.startswith(f"stage/{key}/") and p.endswith(".bin")]
235
+ if have and f"stage/{key}/record.json" in remote:
236
+ try:
237
+ HS.download_missing(st.root, [key], args.hub_repo, remote=remote)
238
+ rec_json = json.load(open(os.path.join(st.stage_dir, key, "record.json")))
239
+ st.d["staged"][key] = {"complete": True, "shards": rec_json["shards"],
240
+ "tokens": rec_json["tokens"],
241
+ "docs_seen": rec_json["docs_seen"],
242
+ "dropped": rec_json["dropped"],
243
+ "target": rec_json["tokens"], "restored_from_hub": True}
244
+ st.commit([], [])
245
+ print(f"stage: restored {key} from Hub ({rec_json['tokens']:,} tok), skipping",
246
+ flush=True)
247
+ continue
248
+ except Exception as e:
249
+ print(f"!! restore failed for {key}: {type(e).__name__}: {str(e)[:140]}", flush=True)
250
  want = min(src["tokens"], args.cap_tokens_per_source or src["tokens"])
251
  if args.budget_tokens and st.staged_tokens_total() >= args.budget_tokens:
252
  print("stage: overall budget reached", flush=True)
 
350
  "docs_seen": docs_seen, "dropped": dict(drops),
351
  "target": want, "seconds": round(time.monotonic() - t0, 1)}
352
  st.commit(new_h, new_k)
353
+ # /kaggle/working is destroyed when the session ends, so a source that took 40 minutes to stage
354
+ # must not exist only there. Published per source, because that is the unit a resume can skip.
355
+ if complete and args.hub_repo:
356
+ with open(os.path.join(st.stage_dir, key, "record.json"), "w") as f:
357
+ json.dump({"key": key, "src": {k: v for k, v in src.items()},
358
+ "shards": shards, "tokens": got, "docs_seen": docs_seen,
359
+ "dropped": dict(drops)}, f, indent=1)
360
+ try:
361
+ n = HS.upload_source(st.root, key, args.hub_repo)
362
+ print(f" published {key} -> {args.hub_repo}/stage/{key} ({n} files)", flush=True)
363
+ except Exception as e:
364
+ print(f"!! publish FAILED for {key}: {type(e).__name__}: {str(e)[:160]} -- "
365
+ f"this source will have to be re-staged if the session dies", flush=True)
366
  el = time.monotonic() - t0
367
  print(f"stage: {key} {got:,} tok of {want:,} from {docs_seen:,} docs; "
368
  f"dup={drops['exact_dup']} keydup={drops['key_dup']} lang={drops['non_english']} "
 
468
  ap.add_argument("--max-chars", type=int, default=100_000)
469
  ap.add_argument("--sec-per-source", type=int, default=0)
470
  ap.add_argument("--only", default="", help="comma list of config names to stage")
471
+ ap.add_argument("--hub-repo", default="Cion-lab/ounce100m-mix-stage",
472
+ help="staged sources are published here so a session kill cannot lose them; "
473
+ "empty string disables sync")
474
  ap.add_argument("--stage-only", action="store_true")
475
  ap.add_argument("--merge-only", action="store_true")
476
  args = ap.parse_args()
 
489
  print(f"tokenizer vocab={tk.get_vocab_size()} "
490
  f"sha256={hashlib.sha256(open(tok_path, 'rb').read()).hexdigest()[:12]}", flush=True)
491
 
492
+ global HS
493
+ HS = None
494
+ if args.hub_repo:
495
+ try:
496
+ import hubsync as HS_mod
497
+ HS = HS_mod
498
+ HS.ensure_repo(args.hub_repo)
499
+ except Exception as e:
500
+ print(f"!! hub sync unavailable: {type(e).__name__}: {str(e)[:160]}; "
501
+ f"build proceeds but is NOT interruption-safe", flush=True)
502
+ HS = None
503
+
504
  st = State(args.root)
505
+ if args.hub_repo and not st.d["staged"]:
506
+ # The state file lives on /kaggle/working too, so in a fresh session it is empty even when the
507
+ # Hub holds a complete stage. Rebuild the stage bookkeeping from the Hub's record.json files --
508
+ # without this, --merge-only on a new instance would find nothing and quietly emit an empty mix.
509
+ try:
510
+ remote = HS.remote_stage_files(args.hub_repo)
511
+ keys = sorted({p.split("/")[1] for p in remote if p.startswith("stage/") and "/" in p})
512
+ pulled = HS.download_missing(args.root, keys, args.hub_repo, remote=remote)
513
+ for k in keys:
514
+ rp = os.path.join(args.root, "stage", k, "record.json")
515
+ if not os.path.exists(rp):
516
+ continue
517
+ j = json.load(open(rp))
518
+ st.d["staged"][k] = {"complete": True, "shards": j["shards"], "tokens": j["tokens"],
519
+ "docs_seen": j["docs_seen"], "dropped": j["dropped"],
520
+ "target": j["tokens"], "restored_from_hub": True}
521
+ st.commit([], [])
522
+ print(f"hub: restored {len(st.d['staged'])} staged source(s) "
523
+ f"({st.staged_tokens_total():,} tokens)", flush=True)
524
+ except Exception as e:
525
+ print(f"!! hub restore failed: {type(e).__name__}: {str(e)[:160]}", flush=True)
526
+
527
  t0 = time.monotonic()
528
  if not args.merge_only:
529
  stage(args, st, tk)
build/hubsync.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hub sync for the mix build: the instance is scratch, so nothing that took an hour to produce may live
2
+ # only there (§3.13 "never let the instance be the only copy", applied to a CPU job rather than a
3
+ # checkpoint).
4
+ #
5
+ # Granularity is one *source*: staging a source takes 5-40 minutes, so publishing its shards the moment
6
+ # it finishes bounds the loss from any interruption to the one source in flight, and a resume can skip
7
+ # every source already on the Hub without re-reading it.
8
+ #
9
+ # The stage repo is private-in-practice but public by project rule; it holds no credentials and no
10
+ # benchmark material -- only token ids from the approved mix.
11
+
12
+ import json
13
+ import os
14
+
15
+
16
+ def repo_id_from_env(default=None):
17
+ return os.environ.get("OUNCE100M_STAGE_REPO") or default
18
+
19
+
20
+ def api():
21
+ import os
22
+
23
+ import ounce100m_credentials
24
+
25
+ ounce100m_credentials.install()
26
+ from huggingface_hub import HfApi
27
+
28
+ return HfApi(token=os.environ["HF_TOKEN"])
29
+
30
+
31
+ def ensure_repo(repo):
32
+ from huggingface_hub import create_repo
33
+
34
+ # hub 1.11 (the image's version) takes the owner inside repo_id and has no `namespace` kwarg (E-013)
35
+ create_repo(repo_id=repo, repo_type="dataset", private=False, exist_ok=True)
36
+ return repo
37
+
38
+
39
+ def remote_stage_files(repo):
40
+ """Set of relative paths under stage/ that already exist on the Hub."""
41
+ import urllib.request
42
+
43
+ url = f"https://huggingface.co/api/datasets/{repo}/tree/main/stage?recursive=true"
44
+ try:
45
+ with urllib.request.urlopen(url, timeout=90) as r:
46
+ entries = json.loads(r.read().decode())
47
+ except Exception:
48
+ return set()
49
+ out = set()
50
+ for e in entries:
51
+ if e.get("type") != "file":
52
+ continue
53
+ out.add(e["path"])
54
+ # a directory in the tree listing means we must recurse; recursive=true should cover it, but
55
+ # if the server ignored the flag we get dirs and no files, which the caller detects as empty
56
+ return out
57
+
58
+
59
+ def upload_source(root, key, repo):
60
+ """Publish stage/<key>/* in one commit. Returns the file names pushed."""
61
+ a = api()
62
+ local = os.path.join(root, "stage", key)
63
+ files = sorted(f for f in os.listdir(local) if f.endswith((".bin", ".json")))
64
+ if not files:
65
+ return []
66
+ a.upload_folder(folder_path=local, path_in_repo=f"stage/{key}", repo_id=repo,
67
+ repo_type="dataset", commit_message=f"stage {key}: {len(files)} shard(s)",
68
+ allow_patterns=["*.bin", "*.json"])
69
+ return files
70
+
71
+
72
+ def download_missing(root, keys_wanted, repo, remote=None):
73
+ """Fetch stage/<key>/*.bin for every wanted key not already on disk. Returns {key: [filenames]}."""
74
+ from huggingface_hub import hf_hub_download
75
+
76
+ remote = remote if remote is not None else remote_stage_files(repo)
77
+ got = {}
78
+ for key in keys_wanted:
79
+ want = sorted(p for p in remote if p.startswith(f"stage/{key}/") and p.endswith(".bin"))
80
+ if not want:
81
+ got[key] = []
82
+ continue
83
+ local_dir = os.path.join(root, "stage", key)
84
+ os.makedirs(local_dir, exist_ok=True)
85
+ have = {f"stage/{key}/{f}" for f in os.listdir(local_dir)}
86
+ for rp in want:
87
+ if rp in have:
88
+ continue
89
+ hf_hub_download(repo_id=repo, repo_type="dataset", filename=rp,
90
+ local_dir=root, force_download=False)
91
+ got[key] = sorted(os.listdir(local_dir))
92
+ return got
93
+
94
+
95
+ def status(repo):
96
+ import urllib.request
97
+
98
+ url = f"https://huggingface.co/api/datasets/{repo}"
99
+ try:
100
+ with urllib.request.urlopen(url, timeout=60) as r:
101
+ d = json.loads(r.read().decode())
102
+ return {"exists": True, "gated": d.get("gated"), "private": d.get("private"),
103
+ "lastModified": d.get("lastModified"), "id": d.get("id")}
104
+ except Exception as e:
105
+ return {"exists": False, "error": f"{type(e).__name__}: {str(e)[:120]}"}
build/smoke_hub_resume.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Cold-resume rehearsal for the mix builder, in one session.
2
+ #
3
+ # What it proves and why it matters: /kaggle/working is destroyed when a Kaggle session ends, so the
4
+ # staged shards AND build_state.json are gone even though the Hub still holds the stage. The full build is
5
+ # hours of streaming; if a session kill meant starting that over, the build would never finish. This
6
+ # script therefore:
7
+ # 1. stages two small sources and publishes them to a throwaway stage repo,
8
+ # 2. WIPES the local tree -- a faithful simulation of the instance dying, not a soft restart,
9
+ # 3. runs --merge-only against the Hub alone, which must restore the stage bookkeeping from the Hub's
10
+ # record.json files and emit a mixed shard containing both sources,
11
+ # 4. runs verify_mix.py over the result, and
12
+ # 5. deletes the throwaway repo so no test artifact is mistaken for the real stage later.
13
+ #
14
+ # Step 3 is the one that would catch a silent failure: with an empty state and no restore path, merge
15
+ # would find nothing and emit an empty mix while still exiting 0.
16
+ #
17
+ # CPU session, zero GPU quota. No credentials in this file; they come from the private store (D-006).
18
+
19
+ import json
20
+ import os
21
+ import shutil
22
+ import subprocess
23
+ import sys
24
+ import time
25
+
26
+ ROOT = "/kaggle/working/mixroot"
27
+ REPO = "Cion-lab/ounce100m-mix-stagetest"
28
+ TWO = "cosmopedia__khanacademy,SimpleStories__default"
29
+
30
+
31
+ def sh(argv, label):
32
+ print(f"=== {label}", flush=True)
33
+ p = subprocess.run([sys.executable] + argv, cwd="/kaggle/working", capture_output=True, text=True,
34
+ timeout=3600)
35
+ out = p.stdout
36
+ for line in out.splitlines():
37
+ print(" |", line[:220], flush=True)
38
+ if p.returncode != 0:
39
+ print(" STDERR:", (p.stderr or "")[-1500:], flush=True)
40
+ return {"rc": p.returncode, "out": out[-6000:], "err": (p.stderr or "")[-1500:]}
41
+
42
+
43
+ def grab_json(text, begin, end):
44
+ if begin in text and end in text:
45
+ try:
46
+ return json.loads(text.split(begin, 1)[1].split(end, 1)[0])
47
+ except Exception:
48
+ return None
49
+ return None
50
+
51
+
52
+ def main():
53
+ R = {"repo": REPO, "sources": TWO}
54
+ base = ["build_mix.py", "--root", ROOT, "--only", TWO, "--cap-tokens-per-source", "1200000",
55
+ "--hub-repo", REPO]
56
+
57
+ shutil.rmtree(ROOT, ignore_errors=True)
58
+ r1 = sh(base + ["--stage-only"], "1. stage two sources and publish")
59
+ R["stage"] = {"rc": r1["rc"], "json": grab_json(r1["out"], "BUILD_JSON_BEGIN", "BUILD_JSON_END")}
60
+
61
+ # the wipe IS the test: after this, nothing local survives except this script and the code
62
+ shutil.rmtree(ROOT, ignore_errors=True)
63
+ R["wiped"] = {"root_exists": os.path.exists(ROOT),
64
+ "stage_dir_exists": os.path.exists(os.path.join(ROOT, "stage"))}
65
+
66
+ r2 = sh(base + ["--merge-only"], "2. cold resume: merge from the Hub with an empty local tree")
67
+ R["merge"] = {"rc": r2["rc"], "json": grab_json(r2["out"], "BUILD_JSON_BEGIN", "BUILD_JSON_END")}
68
+
69
+ r3 = sh(["verify_mix.py", "--root", ROOT, "--min-sources-per-shard", "2",
70
+ "--sample-decodes", "3"], "3. verify the restored mix")
71
+ R["verify"] = grab_json(r3["out"], "VERIFY_JSON_BEGIN", "VERIFY_JSON_END")
72
+
73
+ staged = (R["merge"].get("json") or {}).get("total_tokens", 0)
74
+ R["COLD_RESUME_PASSED"] = bool(
75
+ r1["rc"] == 0 and r2["rc"] == 0 and staged > 1_000_000
76
+ and (R["verify"] or {}).get("PASS") is True
77
+ and (R["verify"] or {}).get("distinct_sources_in_mix") == 2)
78
+
79
+ # cleanup: the throwaway stage repo must not outlive the test, or a later real build could restore
80
+ # capped 1.2M-token sources and believe they were complete
81
+ def cleanup():
82
+ import ounce100m_credentials
83
+ ounce100m_credentials.install()
84
+ from huggingface_hub import delete_repo
85
+ delete_repo(repo_id=REPO, repo_type="dataset", token=os.environ["HF_TOKEN"])
86
+ return "deleted"
87
+
88
+ try:
89
+ R["cleanup"] = cleanup()
90
+ except Exception as e:
91
+ R["cleanup"] = {"error": f"{type(e).__name__}: {str(e)[:200]}",
92
+ "action_needed": f"delete the throwaway dataset {REPO} manually"}
93
+
94
+ print("SMOKE_JSON_BEGIN")
95
+ print(json.dumps(R, indent=1, default=str)[:6000])
96
+ print("SMOKE_JSON_END")
97
+
98
+
99
+ if __name__ == "__main__":
100
+ main()