Skip to content

Commit a78151f

Browse files
authored
osdc/hf-cache: scripts/hf-cache-seed.py to seed models into bucket(s) (#833)
Stack from [ghstack](https://github.com/ezyang/ghstack/tree/0.15.0) (oldest at bottom): * __->__ #833 Standalone seeding tool under osdc/scripts (moved out of the justfile). Downloads each HF model once locally, then `aws s3 sync`s it to one or more clusters' per-region buckets pytorch-hf-model-cache-<cluster_id>, or --all clusters that enable the hf-cache module (synced in parallel). Writes straight to S3 with the operator's AWS creds — independent of the cluster mount. Run once per model. uv run scripts/hf-cache-seed.py -c meta-staging-aws-ue1 Qwen/Qwen2.5-7B-Instruct uv run scripts/hf-cache-seed.py --all Qwen/Qwen2.5-7B-Instruct
1 parent bce3386 commit a78151f

2 files changed

Lines changed: 335 additions & 0 deletions

File tree

osdc/scripts/hf-cache-seed.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
#!/usr/bin/env python3
2+
# /// script
3+
# requires-python = ">=3.12"
4+
# dependencies = ["huggingface_hub>=0.24", "pyyaml>=6.0"]
5+
# ///
6+
"""Seed HuggingFace models into OSDC clusters' hf-cache S3 buckets.
7+
8+
Each model is downloaded once locally, then ``aws s3 sync``ed to every target
9+
cluster's per-region bucket ``pytorch-hf-model-cache-<cluster_id>`` (the layout
10+
the runners read at ``/mnt/hf_cache/hub``). Target one or more clusters, or
11+
``--all`` (every cluster that enables the hf-cache module); clusters are synced
12+
in parallel. Writes straight to S3 — independent of the mount on the cluster.
13+
14+
Requires ``aws`` on PATH (run with mise active, e.g. from the osdc/ dir).
15+
16+
Usage:
17+
uv run scripts/hf-cache-seed.py -c meta-staging-aws-ue1 Qwen/Qwen2.5-7B-Instruct
18+
uv run scripts/hf-cache-seed.py --all Qwen/Qwen2.5-7B-Instruct meta-llama/Llama-3.1-8B
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import argparse
24+
import concurrent.futures
25+
import os
26+
import shutil
27+
import subprocess
28+
import sys
29+
import tempfile
30+
from pathlib import Path
31+
32+
import yaml
33+
34+
BUCKET_PREFIX = "pytorch-hf-model-cache-"
35+
HF_MODULE = "hf-cache"
36+
CLUSTERS_YAML = Path(os.environ.get("CLUSTERS_YAML", Path(__file__).resolve().parent.parent / "clusters.yaml"))
37+
38+
39+
def load_clusters() -> dict:
40+
with open(CLUSTERS_YAML) as f:
41+
return yaml.safe_load(f).get("clusters", {}) or {}
42+
43+
44+
def resolve_targets(clusters: dict, requested: list[str] | None, all_clusters: bool) -> list[str]:
45+
"""Return the cluster ids to seed. --all selects every hf-cache-enabled cluster."""
46+
if all_clusters:
47+
targets = [cid for cid, c in clusters.items() if HF_MODULE in (c.get("modules") or [])]
48+
if not targets:
49+
raise SystemExit(f"No clusters enable the '{HF_MODULE}' module in {CLUSTERS_YAML}")
50+
return targets
51+
targets = []
52+
for cid in requested or []:
53+
if cid not in clusters:
54+
raise SystemExit(f"Unknown cluster '{cid}'. Known: {', '.join(clusters)}")
55+
if HF_MODULE not in (clusters[cid].get("modules") or []):
56+
print(
57+
f"WARNING: cluster '{cid}' does not enable '{HF_MODULE}' — its bucket may not exist.", file=sys.stderr
58+
)
59+
targets.append(cid)
60+
return targets
61+
62+
63+
def bucket_for(cid: str) -> str:
64+
return f"{BUCKET_PREFIX}{cid}"
65+
66+
67+
# config + tokenizer only (no weight files) — enough for benchmarks that init
68+
# with random weights, e.g. vLLM's ``--load-format dummy`` runs. Excludes
69+
# *.safetensors / *.bin / *.pth / *.gguf, which for those models are huge/gated.
70+
CONFIG_ONLY_PATTERNS = ["*.json", "*.txt", "*.model", "*.jinja"]
71+
72+
73+
def download_models(models: list[str], staging: Path, config_only: bool = False) -> list[str]:
74+
"""Download each model once into staging/hub (shared across all target buckets).
75+
76+
With ``config_only`` only config + tokenizer files are fetched (no weights) —
77+
for benchmarks that random-init weights (e.g. vLLM ``--load-format dummy``).
78+
79+
Returns the models that failed to download (e.g. a gated repo without an
80+
HF_TOKEN that has access) — these are skipped so one bad model doesn't abort
81+
the whole batch; the rest still download and sync.
82+
"""
83+
from huggingface_hub import snapshot_download
84+
85+
hub = staging / "hub"
86+
hub.mkdir(parents=True, exist_ok=True)
87+
allow = CONFIG_ONLY_PATTERNS if config_only else None
88+
failed = []
89+
for i, model in enumerate(models, 1):
90+
suffix = " (config only)" if config_only else ""
91+
print(f"-> [{i}/{len(models)}] downloading {model}{suffix} ...", flush=True)
92+
try:
93+
path = snapshot_download(model, cache_dir=str(hub), allow_patterns=allow)
94+
print(f" {model} -> {path}", flush=True)
95+
except Exception as e:
96+
print(f" !! SKIP {model}: {type(e).__name__}: {str(e)[:140]}", flush=True)
97+
failed.append(model)
98+
return failed
99+
100+
101+
def sync_to_cluster(cid: str, region: str, staging: Path) -> tuple[str, bool, str]:
102+
cmd = [
103+
"aws",
104+
"s3",
105+
"sync",
106+
str(staging / "hub"),
107+
f"s3://{bucket_for(cid)}/hub",
108+
"--region",
109+
region,
110+
"--no-progress",
111+
]
112+
# Stream aws's per-file "upload: ..." lines live (prefixed with the cluster id
113+
# so parallel syncs stay readable) instead of capturing — otherwise the upload
114+
# phase looks hung with no output. --no-progress keeps it to one line per file.
115+
print(f"[{cid}] syncing -> s3://{bucket_for(cid)}/hub", flush=True)
116+
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
117+
for line in proc.stdout or []:
118+
print(f"[{cid}] {line}", end="", flush=True)
119+
rc = proc.wait()
120+
return cid, rc == 0, "" if rc == 0 else f"aws s3 sync exited {rc} (see [{cid}] output above)"
121+
122+
123+
def main(argv: list[str] | None = None) -> int:
124+
parser = argparse.ArgumentParser(description="Seed HF models into OSDC hf-cache S3 buckets.")
125+
group = parser.add_mutually_exclusive_group(required=True)
126+
group.add_argument("-c", "--cluster", action="append", metavar="CLUSTER_ID", help="target cluster (repeatable)")
127+
group.add_argument("--all", action="store_true", help="target every cluster that enables the hf-cache module")
128+
parser.add_argument("models", nargs="+", help="HF model id(s), e.g. Qwen/Qwen2.5-7B-Instruct")
129+
parser.add_argument("-j", "--jobs", type=int, default=0, help="parallel cluster syncs (default: one per cluster)")
130+
parser.add_argument(
131+
"--config-only",
132+
action="store_true",
133+
help="download only config+tokenizer files, no weights (for vLLM --load-format dummy benchmarks)",
134+
)
135+
args = parser.parse_args(argv)
136+
137+
clusters = load_clusters()
138+
targets = resolve_targets(clusters, args.cluster, args.all)
139+
print(f"Targets ({len(targets)}): {', '.join(targets)}")
140+
print(f"Models ({len(args.models)}): {', '.join(args.models)}")
141+
142+
if shutil.which("aws") is None:
143+
raise SystemExit("ERROR: 'aws' not found on PATH — run with mise active (e.g. from the osdc/ dir).")
144+
145+
staging = Path(tempfile.mkdtemp(prefix="hf-cache-seed-"))
146+
try:
147+
dl_failed = download_models(args.models, staging, config_only=args.config_only)
148+
workers = args.jobs or len(targets)
149+
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
150+
futs = [ex.submit(sync_to_cluster, cid, clusters[cid]["region"], staging) for cid in targets]
151+
results = [f.result() for f in concurrent.futures.as_completed(futs)]
152+
finally:
153+
shutil.rmtree(staging, ignore_errors=True)
154+
155+
print("\n=== results ===")
156+
if dl_failed:
157+
print(f" SKIPPED {len(dl_failed)} model(s) — download failed: {', '.join(dl_failed)}")
158+
failed = 0
159+
for cid, ok, detail in sorted(results):
160+
if ok:
161+
print(f" OK {cid} -> s3://{bucket_for(cid)}/hub")
162+
else:
163+
failed += 1
164+
tail = "\n ".join(detail.splitlines()[-5:])
165+
print(f" FAIL {cid}\n {tail}")
166+
seeded = len(args.models) - len(dl_failed)
167+
if failed or dl_failed:
168+
if failed:
169+
print(f"\n{failed}/{len(results)} cluster(s) failed.")
170+
if dl_failed:
171+
print(f"{len(dl_failed)} model(s) skipped (see above).")
172+
return 1
173+
print(f"\nSeeded {seeded} model(s) into {len(results)} cluster(s).")
174+
return 0
175+
176+
177+
if __name__ == "__main__":
178+
sys.exit(main())
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""Unit tests for scripts/hf-cache-seed.py."""
2+
3+
import importlib.util
4+
import sys
5+
import types
6+
from pathlib import Path
7+
from unittest.mock import MagicMock, patch
8+
9+
import pytest
10+
11+
# Filename has a hyphen, so load it via importlib (can't `import`).
12+
_spec = importlib.util.spec_from_file_location(
13+
"hf_cache_seed", str(Path(__file__).resolve().parent.parent / "hf-cache-seed.py")
14+
)
15+
_mod = importlib.util.module_from_spec(_spec)
16+
_spec.loader.exec_module(_mod)
17+
18+
CLUSTERS = {
19+
"meta-staging-aws-ue1": {"region": "us-east-1", "modules": ["arc", "hf-cache"]},
20+
"meta-staging-aws-uw1": {"region": "us-west-1", "modules": ["arc", "hf-cache"]},
21+
"meta-prod-aws-ue1": {"region": "us-east-1", "modules": ["arc", "nodepools"]},
22+
}
23+
24+
25+
def test_bucket_for():
26+
assert _mod.bucket_for("meta-staging-aws-ue1") == "pytorch-hf-model-cache-meta-staging-aws-ue1"
27+
28+
29+
def test_resolve_targets_all_selects_hf_cache_clusters():
30+
assert set(_mod.resolve_targets(CLUSTERS, None, True)) == {"meta-staging-aws-ue1", "meta-staging-aws-uw1"}
31+
32+
33+
def test_resolve_targets_all_none_enabled_exits():
34+
with pytest.raises(SystemExit):
35+
_mod.resolve_targets({"c": {"region": "r", "modules": []}}, None, True)
36+
37+
38+
def test_resolve_targets_explicit():
39+
assert _mod.resolve_targets(CLUSTERS, ["meta-staging-aws-ue1"], False) == ["meta-staging-aws-ue1"]
40+
41+
42+
def test_resolve_targets_unknown_exits():
43+
with pytest.raises(SystemExit):
44+
_mod.resolve_targets(CLUSTERS, ["nope"], False)
45+
46+
47+
def test_resolve_targets_warns_when_module_disabled(capsys):
48+
assert _mod.resolve_targets(CLUSTERS, ["meta-prod-aws-ue1"], False) == ["meta-prod-aws-ue1"]
49+
assert "does not enable" in capsys.readouterr().err
50+
51+
52+
def test_load_clusters(tmp_path, monkeypatch):
53+
f = tmp_path / "clusters.yaml"
54+
f.write_text("clusters:\n c1:\n region: us-east-1\n")
55+
monkeypatch.setattr(_mod, "CLUSTERS_YAML", f)
56+
assert _mod.load_clusters() == {"c1": {"region": "us-east-1"}}
57+
58+
59+
def test_download_models(monkeypatch, tmp_path):
60+
fake = types.ModuleType("huggingface_hub")
61+
fake.snapshot_download = lambda model, cache_dir, allow_patterns=None: f"{cache_dir}/{model}"
62+
monkeypatch.setitem(sys.modules, "huggingface_hub", fake)
63+
assert _mod.download_models(["org/m"], tmp_path) == []
64+
assert (tmp_path / "hub").is_dir()
65+
66+
67+
def test_download_models_config_only_passes_allow_patterns(monkeypatch, tmp_path):
68+
seen = {}
69+
70+
def cap(model, cache_dir, allow_patterns=None):
71+
seen["allow"] = allow_patterns
72+
return f"{cache_dir}/{model}"
73+
74+
fake = types.ModuleType("huggingface_hub")
75+
fake.snapshot_download = cap
76+
monkeypatch.setitem(sys.modules, "huggingface_hub", fake)
77+
_mod.download_models(["org/m"], tmp_path, config_only=True)
78+
assert seen["allow"] == _mod.CONFIG_ONLY_PATTERNS
79+
80+
81+
def test_download_models_skips_failure(monkeypatch, tmp_path):
82+
def boom(model, cache_dir, allow_patterns=None):
83+
if model == "org/bad":
84+
raise OSError("gated repo")
85+
return f"{cache_dir}/{model}"
86+
87+
fake = types.ModuleType("huggingface_hub")
88+
fake.snapshot_download = boom
89+
monkeypatch.setitem(sys.modules, "huggingface_hub", fake)
90+
assert _mod.download_models(["org/ok", "org/bad"], tmp_path) == ["org/bad"]
91+
92+
93+
def _fake_popen(lines, rc):
94+
fake = MagicMock()
95+
fake.stdout = list(lines) # iterable + truthy
96+
fake.wait.return_value = rc
97+
return fake
98+
99+
100+
def test_sync_to_cluster_ok():
101+
with patch.object(_mod.subprocess, "Popen", return_value=_fake_popen(["upload: a\n"], 0)):
102+
cid, ok, _ = _mod.sync_to_cluster("c1", "us-east-1", Path("/tmp/x"))
103+
assert ok
104+
assert cid == "c1"
105+
106+
107+
def test_sync_to_cluster_fail():
108+
with patch.object(_mod.subprocess, "Popen", return_value=_fake_popen(["AccessDenied\n"], 1)):
109+
_, ok, detail = _mod.sync_to_cluster("c1", "us-east-1", Path("/tmp/x"))
110+
assert not ok
111+
assert "exited 1" in detail
112+
113+
114+
def _run_main(argv, sync=lambda cid, region, staging: (cid, True, "ok")):
115+
with (
116+
patch.object(_mod, "load_clusters", return_value=CLUSTERS),
117+
patch.object(_mod, "download_models", return_value=[]),
118+
patch.object(_mod.shutil, "which", return_value="/usr/bin/aws"),
119+
patch.object(_mod, "sync_to_cluster", side_effect=sync),
120+
):
121+
return _mod.main(argv)
122+
123+
124+
def test_main_success(capsys):
125+
assert _run_main(["-c", "meta-staging-aws-ue1", "org/m"]) == 0
126+
assert "Seeded" in capsys.readouterr().out
127+
128+
129+
def test_main_all_success():
130+
assert _run_main(["--all", "org/m"]) == 0
131+
132+
133+
def test_main_reports_failure(capsys):
134+
rc = _run_main(["-c", "meta-staging-aws-ue1", "org/m"], sync=lambda cid, region, staging: (cid, False, "e1\ne2"))
135+
assert rc == 1
136+
assert "FAIL" in capsys.readouterr().out
137+
138+
139+
def test_main_reports_download_skip(capsys):
140+
with (
141+
patch.object(_mod, "load_clusters", return_value=CLUSTERS),
142+
patch.object(_mod, "download_models", return_value=["org/bad"]),
143+
patch.object(_mod.shutil, "which", return_value="/usr/bin/aws"),
144+
patch.object(_mod, "sync_to_cluster", side_effect=lambda cid, region, staging: (cid, True, "")),
145+
):
146+
rc = _mod.main(["-c", "meta-staging-aws-ue1", "org/ok", "org/bad"])
147+
assert rc == 1
148+
assert "SKIPPED" in capsys.readouterr().out
149+
150+
151+
def test_main_requires_aws():
152+
with (
153+
patch.object(_mod, "load_clusters", return_value=CLUSTERS),
154+
patch.object(_mod.shutil, "which", return_value=None),
155+
pytest.raises(SystemExit),
156+
):
157+
_mod.main(["-c", "meta-staging-aws-ue1", "org/m"])

0 commit comments

Comments
 (0)