Add per-checkpoint eval worker (eval_watch.py + run_eval_watch.sh); ONLY_MODEL mode in eval_adapter.py
Browse files- eval_adapter.py +6 -1
- eval_watch.py +110 -0
- run_eval_watch.sh +78 -0
eval_adapter.py
CHANGED
|
@@ -17,6 +17,7 @@ Config via env:
|
|
| 17 |
N_TASKS how many test tasks (default 50)
|
| 18 |
CONCURRENCY parallel rollouts (default 8; keep <= MAX_CONCURRENT_ENVS)
|
| 19 |
OUT results JSON path (default /app/eval_results.json)
|
|
|
|
| 20 |
"""
|
| 21 |
|
| 22 |
import json
|
|
@@ -31,6 +32,7 @@ VLLM_URL = os.environ.get("VLLM_URL", "http://127.0.0.1:8001")
|
|
| 31 |
TEST_SPLIT = os.environ.get("TEST_SPLIT", "/app/prepared/datasets/test")
|
| 32 |
BASE_MODEL = os.environ.get("BASE_MODEL", "LiquidAI/LFM2.5-2.6B")
|
| 33 |
ADAPTER_NAME = os.environ.get("ADAPTER_NAME", "trained")
|
|
|
|
| 34 |
N_TASKS = int(os.environ.get("N_TASKS", "50"))
|
| 35 |
CONCURRENCY = int(os.environ.get("CONCURRENCY", "8"))
|
| 36 |
OUT = os.environ.get("OUT", "/app/eval_results.json")
|
|
@@ -69,7 +71,10 @@ def run_one(task_index, model):
|
|
| 69 |
|
| 70 |
|
| 71 |
def main():
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
| 73 |
work = [(i, m) for i in range(N_TASKS) for m in models]
|
| 74 |
cells = {} # (task_index, model) -> {"correct": bool|None, "failed": bool, "error": str|None}
|
| 75 |
with ThreadPoolExecutor(max_workers=CONCURRENCY) as pool:
|
|
|
|
| 17 |
N_TASKS how many test tasks (default 50)
|
| 18 |
CONCURRENCY parallel rollouts (default 8; keep <= MAX_CONCURRENT_ENVS)
|
| 19 |
OUT results JSON path (default /app/eval_results.json)
|
| 20 |
+
ONLY_MODEL evaluate just this one served model name (per-checkpoint eval)
|
| 21 |
"""
|
| 22 |
|
| 23 |
import json
|
|
|
|
| 32 |
TEST_SPLIT = os.environ.get("TEST_SPLIT", "/app/prepared/datasets/test")
|
| 33 |
BASE_MODEL = os.environ.get("BASE_MODEL", "LiquidAI/LFM2.5-2.6B")
|
| 34 |
ADAPTER_NAME = os.environ.get("ADAPTER_NAME", "trained")
|
| 35 |
+
ONLY_MODEL = os.environ.get("ONLY_MODEL", "")
|
| 36 |
N_TASKS = int(os.environ.get("N_TASKS", "50"))
|
| 37 |
CONCURRENCY = int(os.environ.get("CONCURRENCY", "8"))
|
| 38 |
OUT = os.environ.get("OUT", "/app/eval_results.json")
|
|
|
|
| 71 |
|
| 72 |
|
| 73 |
def main():
|
| 74 |
+
if ONLY_MODEL:
|
| 75 |
+
models = [ONLY_MODEL]
|
| 76 |
+
else:
|
| 77 |
+
models = [BASE_MODEL] + ([ADAPTER_NAME] if ADAPTER_NAME else [])
|
| 78 |
work = [(i, m) for i in range(N_TASKS) for m in models]
|
| 79 |
cells = {} # (task_index, model) -> {"correct": bool|None, "failed": bool, "error": str|None}
|
| 80 |
with ThreadPoolExecutor(max_workers=CONCURRENCY) as pool:
|
eval_watch.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-checkpoint eval worker: watch the adapter repo, eval each new checkpoint.
|
| 2 |
+
|
| 3 |
+
Loop: poll ADAPTER_REPO for checkpoint-N dirs without eval results, hot-load each
|
| 4 |
+
as a LoRA into the already-running vLLM (runtime LoRA updates — no server restart),
|
| 5 |
+
run the held-out eval on a fixed task subset, and upload per-checkpoint results
|
| 6 |
+
plus a rolling eval/summary.json (the live learning curve).
|
| 7 |
+
|
| 8 |
+
Base-model cells are computed once and reused for every checkpoint.
|
| 9 |
+
|
| 10 |
+
Env: ADAPTER_REPO (required), BASE_MODEL, N_TASKS (default 100), POLL_S (600).
|
| 11 |
+
Stops when eval/final_results.json appears (the training job's own final eval).
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import os
|
| 16 |
+
import subprocess
|
| 17 |
+
import time
|
| 18 |
+
import urllib.request
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
from huggingface_hub import HfApi, snapshot_download
|
| 22 |
+
|
| 23 |
+
REPO = os.environ["ADAPTER_REPO"]
|
| 24 |
+
BASE_MODEL = os.environ.get("BASE_MODEL", "LiquidAI/LFM2.5-2.6B")
|
| 25 |
+
POLL_S = int(os.environ.get("POLL_S", "600"))
|
| 26 |
+
VLLM = "http://127.0.0.1:8001"
|
| 27 |
+
|
| 28 |
+
api = HfApi()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def repo_files():
|
| 32 |
+
return set(api.list_repo_files(REPO))
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def pending_checkpoints(fs):
|
| 36 |
+
ckpts = sorted(
|
| 37 |
+
{f.split("/")[0] for f in fs if f.startswith("checkpoint-") and f.endswith("adapter_config.json")},
|
| 38 |
+
key=lambda s: int(s.rsplit("-", 1)[1]),
|
| 39 |
+
)
|
| 40 |
+
done = {f[len("eval/"):-len("_results.json")] for f in fs if f.startswith("eval/") and f.endswith("_results.json")}
|
| 41 |
+
return [c for c in ckpts if c not in done]
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def run_eval(model_name, out_path):
|
| 45 |
+
env = dict(os.environ, ONLY_MODEL=model_name, OUT=out_path)
|
| 46 |
+
subprocess.run(["python3", "/app/eval_adapter.py"], env=env, check=True)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def upload(local, remote):
|
| 50 |
+
api.upload_file(path_or_fileobj=str(local), path_in_repo=remote, repo_id=REPO)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def lora_rpc(route, name, path=None):
|
| 54 |
+
payload = {"lora_name": name}
|
| 55 |
+
if path is not None:
|
| 56 |
+
payload["lora_path"] = str(path)
|
| 57 |
+
req = urllib.request.Request(
|
| 58 |
+
f"{VLLM}/v1/{route}",
|
| 59 |
+
data=json.dumps(payload).encode(),
|
| 60 |
+
headers={"Content-Type": "application/json"},
|
| 61 |
+
)
|
| 62 |
+
urllib.request.urlopen(req, timeout=900)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def pass_at_1(cells_path):
|
| 66 |
+
cells = json.loads(Path(cells_path).read_text())["cells"]
|
| 67 |
+
scored = [c["correct"] for c in cells.values() if not c["failed"]]
|
| 68 |
+
return (sum(scored) / len(scored)) if scored else None, len(scored)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def main():
|
| 72 |
+
fs = repo_files()
|
| 73 |
+
# 1. Base-model cells, once. Every checkpoint compares against these.
|
| 74 |
+
if "eval/base_cells.json" not in fs:
|
| 75 |
+
print("[watch] evaluating base model first", flush=True)
|
| 76 |
+
run_eval(BASE_MODEL, "/tmp/base_cells.json")
|
| 77 |
+
upload("/tmp/base_cells.json", "eval/base_cells.json")
|
| 78 |
+
|
| 79 |
+
summary = {}
|
| 80 |
+
if "eval/summary.json" in fs:
|
| 81 |
+
summary = json.loads(Path(api.hf_hub_download(REPO, "eval/summary.json")).read_text())
|
| 82 |
+
|
| 83 |
+
loaded = []
|
| 84 |
+
while True:
|
| 85 |
+
fs = repo_files()
|
| 86 |
+
if "eval/final_results.json" in fs:
|
| 87 |
+
print("[watch] training job's final eval landed; stopping", flush=True)
|
| 88 |
+
return
|
| 89 |
+
for ckpt in pending_checkpoints(fs):
|
| 90 |
+
print(f"[watch] new checkpoint: {ckpt}", flush=True)
|
| 91 |
+
local = Path(snapshot_download(REPO, allow_patterns=[f"{ckpt}/*"])) / ckpt
|
| 92 |
+
lora_rpc("load_lora_adapter", ckpt, local)
|
| 93 |
+
loaded.append(ckpt)
|
| 94 |
+
if len(loaded) > 4: # stay under --max-loras
|
| 95 |
+
lora_rpc("unload_lora_adapter", loaded.pop(0))
|
| 96 |
+
run_eval(ckpt, f"/tmp/{ckpt}_cells.json")
|
| 97 |
+
upload(f"/tmp/{ckpt}_cells.json", f"eval/{ckpt}_results.json")
|
| 98 |
+
p1, n = pass_at_1(f"/tmp/{ckpt}_cells.json")
|
| 99 |
+
pb, nb = pass_at_1("/tmp/base_cells.json")
|
| 100 |
+
summary[ckpt] = {"pass_at_1": p1, "scorable": n,
|
| 101 |
+
"base_pass_at_1": pb,
|
| 102 |
+
"delta": (p1 - pb) if p1 is not None and pb is not None else None}
|
| 103 |
+
Path("/tmp/summary.json").write_text(json.dumps(summary, indent=2))
|
| 104 |
+
upload("/tmp/summary.json", "eval/summary.json")
|
| 105 |
+
print(f"[watch] {ckpt}: pass@1={p1} (base {pb})", flush=True)
|
| 106 |
+
time.sleep(POLL_S)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
if __name__ == "__main__":
|
| 110 |
+
main()
|
run_eval_watch.sh
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Per-checkpoint eval worker (1 GPU). Polls ADAPTER_REPO for new checkpoint-N
|
| 3 |
+
# dirs and evaluates each on a fixed N_TASKS subset of the held-out test split,
|
| 4 |
+
# writing eval/<checkpoint-N>_results.json + a rolling eval/summary.json.
|
| 5 |
+
#
|
| 6 |
+
# Expected env:
|
| 7 |
+
# HF_TOKEN (secret) hub access + sandbox Jobs
|
| 8 |
+
# ADAPTER_REPO (env) repo the training job pushes checkpoints to
|
| 9 |
+
# N_TASKS (env) test tasks per eval (default 100; same subset every time)
|
| 10 |
+
set -euo pipefail
|
| 11 |
+
cd "$(dirname "$0")"
|
| 12 |
+
|
| 13 |
+
echo "=== [setup] ==="
|
| 14 |
+
apt-get update -qq && apt-get install -y -qq --no-install-recommends git ca-certificates
|
| 15 |
+
python3 -m pip install --quiet \
|
| 16 |
+
'openenv[harbor]' \
|
| 17 |
+
'harbor[hf-sandbox]>=0.24.0' \
|
| 18 |
+
math-verify tomli-w \
|
| 19 |
+
aiohttp
|
| 20 |
+
git clone --depth 1 https://github.com/huggingface/OpenEnv.git /opt/OpenEnv
|
| 21 |
+
SITE=$(python3 -c "import site; print(site.getsitepackages()[0])")
|
| 22 |
+
echo "/opt/OpenEnv/envs" > "$SITE/openenv_examples.pth"
|
| 23 |
+
|
| 24 |
+
echo "=== [patch] opencode seam: session id as x-api-key (Jobs proxy strips Authorization) ==="
|
| 25 |
+
python3 - <<'EOF'
|
| 26 |
+
import openenv.harbor.seams as seams
|
| 27 |
+
|
| 28 |
+
path = seams.__file__
|
| 29 |
+
src = open(path).read()
|
| 30 |
+
old = '"apiKey": session,\n "timeout": 600_000,'
|
| 31 |
+
new = ('"apiKey": session,\n'
|
| 32 |
+
' "headers": {"x-api-key": session},\n'
|
| 33 |
+
' "timeout": 600_000,')
|
| 34 |
+
assert src.count(old) == 1, f"opencode seam not found in {path}"
|
| 35 |
+
open(path, "w").write(src.replace(old, new))
|
| 36 |
+
print("patched", path)
|
| 37 |
+
EOF
|
| 38 |
+
|
| 39 |
+
echo "=== [setup] pinned test tasks ==="
|
| 40 |
+
python3 prepare_tasks.py --output /app/prepared --splits test
|
| 41 |
+
|
| 42 |
+
echo "=== [start] vLLM (base; LoRAs hot-loaded per checkpoint) ==="
|
| 43 |
+
CUDA_VISIBLE_DEVICES=0 VLLM_ALLOW_RUNTIME_LORA_UPDATING=1 \
|
| 44 |
+
vllm serve "${BASE_MODEL:-LiquidAI/LFM2.5-2.6B}" \
|
| 45 |
+
--port 8001 \
|
| 46 |
+
--max-model-len 32768 \
|
| 47 |
+
--return-tokens-as-token-ids \
|
| 48 |
+
--logprobs-mode processed_logprobs \
|
| 49 |
+
--enable-auto-tool-choice \
|
| 50 |
+
--tool-call-parser lfm2 \
|
| 51 |
+
--enable-lora --max-lora-rank 32 --max-loras 8 &
|
| 52 |
+
VLLM_PID=$!
|
| 53 |
+
|
| 54 |
+
echo "=== [start] env server (test split; tunnel-free via Jobs proxy) ==="
|
| 55 |
+
export SPACE_HOST="${JOB_ID}--8000.hf.jobs"
|
| 56 |
+
PREPARED=/app/prepared OPENENV_DATASETS=/app/prepared/datasets/test bash serve_env.sh &
|
| 57 |
+
ENV_PID=$!
|
| 58 |
+
|
| 59 |
+
python3 - <<'EOF'
|
| 60 |
+
import time, urllib.request
|
| 61 |
+
for name, url in [("vllm", "http://127.0.0.1:8001/v1/models"),
|
| 62 |
+
("env", "http://127.0.0.1:8000/health")]:
|
| 63 |
+
for i in range(360):
|
| 64 |
+
try:
|
| 65 |
+
urllib.request.urlopen(url, timeout=5)
|
| 66 |
+
print(name, "ready")
|
| 67 |
+
break
|
| 68 |
+
except Exception:
|
| 69 |
+
time.sleep(5)
|
| 70 |
+
else:
|
| 71 |
+
raise SystemExit(f"{name} server never became ready")
|
| 72 |
+
EOF
|
| 73 |
+
|
| 74 |
+
echo "=== [watch] evaluating every new checkpoint in ${ADAPTER_REPO} ==="
|
| 75 |
+
python3 eval_watch.py
|
| 76 |
+
|
| 77 |
+
kill $VLLM_PID $ENV_PID 2>/dev/null || true
|
| 78 |
+
echo "=== done ==="
|