Cleanup init commit
This commit is contained in:
+374
@@ -0,0 +1,374 @@
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from jlens.lens import JacobianLens
|
||||
|
||||
import config
|
||||
from core.gpus import gpu_stats
|
||||
|
||||
FITS_DIR = config.DATA_DIR / "fits"
|
||||
WORKER = config.ROOT / "scripts" / "fit_worker.py"
|
||||
|
||||
# Fit corpora. "mixed" = both, equal parts (rounded to the nearest prompt).
|
||||
DATASET_WIKITEXT = "Salesforce/wikitext-103-raw-v1"
|
||||
DATASET_HARMLESS = "heretic-org/Semantic-Harmless"
|
||||
FIT_DATASETS = (DATASET_WIKITEXT, DATASET_HARMLESS, "mixed")
|
||||
|
||||
|
||||
def _load_corpus(dataset, n, skip=0):
|
||||
"""``n`` prompts from ``dataset``, skipping the first ``skip`` picks
|
||||
(continue-from: the new prompts must not overlap the base lens's).
|
||||
|
||||
wikitext keeps the historical behavior (first records ≥600 chars, streamed).
|
||||
Semantic-Harmless is a small instruct set (~416 one-line prompts): we draw a
|
||||
seeded random sample — sample(skip+n) then drop the head, so a continued fit
|
||||
extends the same sequence — and PACK the picks into ~350-char sequences
|
||||
(median prompt ≈ 10 tokens, and jlens skips the first 16 positions of every
|
||||
sequence as attention sinks: unpacked, almost every pick would be dropped as
|
||||
"too short"). ``n``/``skip`` count SOURCE prompts, not packs. "mixed" takes
|
||||
equal parts of both (n odd: the extra prompt goes to wikitext) and shuffles
|
||||
the union so multi-GPU slices stay mixed."""
|
||||
if dataset == "mixed":
|
||||
import random
|
||||
|
||||
n_wiki = (n + 1) // 2
|
||||
s_wiki = (skip + 1) // 2
|
||||
prompts = _load_corpus(DATASET_WIKITEXT, n_wiki, s_wiki)
|
||||
prompts += _load_corpus(DATASET_HARMLESS, n - n_wiki, skip // 2)
|
||||
random.Random(1729).shuffle(prompts)
|
||||
return prompts
|
||||
if dataset == DATASET_HARMLESS:
|
||||
import random
|
||||
|
||||
from datasets import load_dataset
|
||||
|
||||
texts = [r["text"] for r in load_dataset(DATASET_HARMLESS, split="train")]
|
||||
if skip + n > len(texts):
|
||||
raise ValueError(
|
||||
f"{DATASET_HARMLESS} has {len(texts)} prompts, "
|
||||
f"{skip + n} requested (continue included) — lower n_prompts"
|
||||
)
|
||||
picks = random.Random(1729).sample(texts, skip + n)[skip:]
|
||||
packs, cur = [], ""
|
||||
for text in picks:
|
||||
cur = f"{cur}\n\n{text}" if cur else text
|
||||
if len(cur) >= 350:
|
||||
packs.append(cur)
|
||||
cur = ""
|
||||
if cur:
|
||||
# a lone sub-16-token tail would be skipped by jlens anyway: fold it
|
||||
# into the previous pack instead of losing it
|
||||
if packs and len(cur) < 120:
|
||||
packs[-1] += "\n\n" + cur
|
||||
else:
|
||||
packs.append(cur)
|
||||
return packs
|
||||
from jlens.examples import load_wikitext_prompts
|
||||
|
||||
# load skip + n then keep the tail: the new prompts don't overlap
|
||||
# those of the base lens
|
||||
return load_wikitext_prompts(skip + n)[skip:]
|
||||
|
||||
def _default_dim_batch(device):
|
||||
"""Default dim_batch scaled to the device's VRAM.
|
||||
Measured on a 4B bf16 fit: 8 fits in 16 GB, 4 in 12 GB."""
|
||||
try:
|
||||
total = gpu_stats()[int(device.split(":")[1])]["vram_total"]
|
||||
return 8 if total >= 15 * 2**30 else 4
|
||||
except Exception:
|
||||
return 4
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
class FitManager:
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self._procs = []
|
||||
self.state = {"state": "idle"}
|
||||
self.on_progress = None
|
||||
|
||||
def _emit(self):
|
||||
if self.on_progress:
|
||||
self.on_progress(dict(self.state))
|
||||
|
||||
def start(self, *, model_id, source, n_prompts=100, dtype="bf16", quant=None,
|
||||
devices=("cuda:0",), name=None, dim_batch=None,
|
||||
max_seq_len=128, source_layers=None, model_revision=None,
|
||||
continue_from=None, dataset=DATASET_WIKITEXT):
|
||||
with self._lock:
|
||||
if self.state.get("state") == "running":
|
||||
raise ValueError("a fitting is already in progress")
|
||||
if not devices:
|
||||
raise ValueError("at least one device required")
|
||||
if dataset not in FIT_DATASETS:
|
||||
raise ValueError(f"unknown dataset: {dataset} (choices: {', '.join(FIT_DATASETS)})")
|
||||
skip_prompts = 0
|
||||
base_lens = None
|
||||
if continue_from:
|
||||
base_lens = JacobianLens.load(continue_from)
|
||||
# new prompts: skip those already seen by the base lens
|
||||
skip_prompts = base_lens.n_prompts
|
||||
if source_layers is None:
|
||||
source_layers = list(base_lens.source_layers)
|
||||
if name is None:
|
||||
base = model_id.split("/")[-1]
|
||||
if dataset == "mixed":
|
||||
base += "_mixed"
|
||||
elif dataset == DATASET_HARMLESS:
|
||||
base += "_harmless"
|
||||
total = n_prompts + skip_prompts
|
||||
name = f"{base}_n{total}" if continue_from else f"{base}_n{n_prompts}"
|
||||
params = {
|
||||
"model_id": model_id,
|
||||
"source": source,
|
||||
"model_revision": model_revision,
|
||||
"dtype": dtype,
|
||||
"quant": quant,
|
||||
"n_prompts": n_prompts,
|
||||
"dataset": dataset,
|
||||
"devices": list(devices),
|
||||
"dim_batch": dim_batch,
|
||||
"max_seq_len": max_seq_len,
|
||||
"source_layers": source_layers,
|
||||
"continue_from": continue_from,
|
||||
"skip_prompts": skip_prompts,
|
||||
}
|
||||
self.state = {
|
||||
"state": "running",
|
||||
"name": name,
|
||||
"phase": "corpus",
|
||||
"total": n_prompts,
|
||||
"done": 0,
|
||||
"workers": [],
|
||||
"eta_seconds": None,
|
||||
"started_at": _now(),
|
||||
"params": params,
|
||||
"error": None,
|
||||
}
|
||||
self._procs = []
|
||||
threading.Thread(target=self._run, args=(name, params), daemon=True).start()
|
||||
return dict(self.state)
|
||||
|
||||
def stop(self):
|
||||
with self._lock:
|
||||
for proc in self._procs:
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
if self.state.get("state") == "running":
|
||||
self.state["state"] = "stopping"
|
||||
self._emit()
|
||||
return dict(self.state)
|
||||
|
||||
def _run(self, name, params):
|
||||
try:
|
||||
job_dir = FITS_DIR / name
|
||||
job_dir.mkdir(parents=True, exist_ok=True)
|
||||
corpus_path = job_dir / "corpus.json"
|
||||
if corpus_path.exists():
|
||||
prompts = json.loads(corpus_path.read_text(encoding="utf-8"))
|
||||
else:
|
||||
prompts = _load_corpus(
|
||||
params.get("dataset", DATASET_WIKITEXT),
|
||||
params["n_prompts"],
|
||||
params.get("skip_prompts", 0),
|
||||
)
|
||||
corpus_path.write_text(
|
||||
json.dumps(prompts, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
devices = params["devices"]
|
||||
n = len(prompts)
|
||||
if len(devices) == 2:
|
||||
cut = int(n * 0.65)
|
||||
slices = [prompts[:cut], prompts[cut:]]
|
||||
else:
|
||||
slices = [prompts]
|
||||
|
||||
self.state.update(phase="fitting", total=n)
|
||||
workers = []
|
||||
started = time.perf_counter()
|
||||
for i, (device, chunk) in enumerate(zip(devices, slices)):
|
||||
slice_path = job_dir / f"slice{i}.json"
|
||||
if not slice_path.exists():
|
||||
slice_path.write_text(json.dumps(chunk, ensure_ascii=False), encoding="utf-8")
|
||||
dim_batch = params["dim_batch"] or _default_dim_batch(device)
|
||||
cmd = [
|
||||
sys.executable, "-X", "utf8", str(WORKER),
|
||||
"--model", params["source"],
|
||||
"--device", device,
|
||||
"--dtype", params["dtype"],
|
||||
"--prompts", str(slice_path),
|
||||
"--checkpoint", str(job_dir / f"ckpt{i}.pt"),
|
||||
"--out", str(job_dir / f"lens{i}.pt"),
|
||||
"--dim-batch", str(dim_batch),
|
||||
"--max-seq-len", str(params["max_seq_len"]),
|
||||
]
|
||||
if params["quant"]:
|
||||
cmd += ["--quant", params["quant"]]
|
||||
if params["source_layers"]:
|
||||
cmd += ["--source-layers", json.dumps(params["source_layers"])]
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
cwd=str(config.ROOT),
|
||||
)
|
||||
self._procs.append(proc)
|
||||
worker_state = {
|
||||
"device": device,
|
||||
"done": 0,
|
||||
"total": len(chunk),
|
||||
"dim_batch": dim_batch,
|
||||
"state": "loading",
|
||||
"elapsed": 0.0,
|
||||
# [done, elapsed] of the last 10 updates: the ETA follows the
|
||||
# RECENT pace (throughput can degrade mid-fit, e.g. VRAM
|
||||
# saturated — a global average would then freeze the ETA)
|
||||
"hist": [],
|
||||
}
|
||||
workers.append(worker_state)
|
||||
threading.Thread(
|
||||
target=self._read_worker, args=(proc, worker_state, started), daemon=True
|
||||
).start()
|
||||
self.state["workers"] = workers
|
||||
self._emit()
|
||||
|
||||
stderr_tails = [""] * len(self._procs)
|
||||
|
||||
def drain_err(index, proc):
|
||||
data = proc.stderr.read()
|
||||
stderr_tails[index] = (data or "")[-2000:]
|
||||
|
||||
drainers = [
|
||||
threading.Thread(target=drain_err, args=(i, p), daemon=True)
|
||||
for i, p in enumerate(self._procs)
|
||||
]
|
||||
for t in drainers:
|
||||
t.start()
|
||||
for proc in self._procs:
|
||||
proc.wait()
|
||||
for t in drainers:
|
||||
t.join()
|
||||
failed = [i for i, p in enumerate(self._procs) if p.returncode != 0]
|
||||
if self.state.get("state") == "stopping":
|
||||
self.state.update(state="stopped")
|
||||
self._emit()
|
||||
return
|
||||
if failed:
|
||||
detail = " | ".join(stderr_tails[i].strip().splitlines()[-1] if stderr_tails[i].strip() else "?" for i in failed)
|
||||
raise RuntimeError(f"worker(s) {failed} failed: {detail}")
|
||||
|
||||
self.state.update(phase="merge")
|
||||
self._emit()
|
||||
partials = [
|
||||
JacobianLens.load(str(job_dir / f"lens{i}.pt"))
|
||||
for i in range(len(slices))
|
||||
]
|
||||
merged = JacobianLens.merge(partials) if len(partials) > 1 else partials[0]
|
||||
if params.get("continue_from"):
|
||||
base_lens = JacobianLens.load(params["continue_from"])
|
||||
if base_lens.source_layers != merged.source_layers:
|
||||
raise RuntimeError(
|
||||
"cannot continue: the source layers differ from the base lens "
|
||||
f"({base_lens.source_layers[0]}..{base_lens.source_layers[-1]} vs "
|
||||
f"{merged.source_layers[0]}..{merged.source_layers[-1]})"
|
||||
)
|
||||
# weighted average by n_prompts = equivalent to a fit over the union
|
||||
merged = JacobianLens.merge([base_lens, merged])
|
||||
out_dir = config.LENSES_DIR / name
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
lens_path = out_dir / "lens.pt"
|
||||
merged.save(str(lens_path))
|
||||
meta = {
|
||||
"name": name,
|
||||
"model_id": params["model_id"],
|
||||
"model_revision": params["model_revision"],
|
||||
"model_source": params["source"],
|
||||
"d_model": merged.d_model,
|
||||
"source_layers": [merged.source_layers[0], merged.source_layers[-1]],
|
||||
"dtype": params["dtype"],
|
||||
"quant": params["quant"],
|
||||
"n_prompts": merged.n_prompts,
|
||||
"corpus": (
|
||||
f"mixed: {DATASET_WIKITEXT} + {DATASET_HARMLESS} (equal parts)"
|
||||
if params.get("dataset") == "mixed"
|
||||
else params.get("dataset", DATASET_WIKITEXT)
|
||||
),
|
||||
"max_seq_len": params["max_seq_len"],
|
||||
"devices": params["devices"],
|
||||
"continued_from": params.get("continue_from"),
|
||||
"config_hash": hashlib.sha1(
|
||||
json.dumps(params, sort_keys=True).encode()
|
||||
).hexdigest()[:16],
|
||||
"created_at": _now(),
|
||||
"fit_seconds": round(time.perf_counter() - started, 1),
|
||||
}
|
||||
(out_dir / "meta.json").write_text(
|
||||
json.dumps(meta, ensure_ascii=False, indent=1), encoding="utf-8"
|
||||
)
|
||||
self.state.update(
|
||||
state="done",
|
||||
phase="done",
|
||||
lens_path=str(lens_path),
|
||||
meta=meta,
|
||||
eta_seconds=0,
|
||||
)
|
||||
self._emit()
|
||||
except Exception as exc:
|
||||
self.state.update(state="error", error=str(exc))
|
||||
self._emit()
|
||||
|
||||
def _read_worker(self, proc, worker_state, started):
|
||||
for line in proc.stdout:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if event["event"] == "loading":
|
||||
worker_state["state"] = "loading"
|
||||
elif event["event"] in ("progress", "resume"):
|
||||
worker_state["state"] = "fitting"
|
||||
worker_state["done"] = event["done"]
|
||||
worker_state["total"] = event["total"]
|
||||
worker_state["elapsed"] = round(time.perf_counter() - started, 1)
|
||||
hist = worker_state.setdefault("hist", [])
|
||||
hist.append([worker_state["done"], worker_state["elapsed"]])
|
||||
del hist[:-10]
|
||||
elif event["event"] == "done":
|
||||
worker_state["state"] = "done"
|
||||
worker_state["done"] = worker_state["total"]
|
||||
self._refresh_totals(started)
|
||||
self._emit()
|
||||
|
||||
def _refresh_totals(self, started):
|
||||
workers = self.state.get("workers", [])
|
||||
self.state["done"] = sum(w["done"] for w in workers)
|
||||
etas = []
|
||||
for w in workers:
|
||||
if not (w["done"] > 0 and w["elapsed"] > 0 and w["done"] < w["total"]):
|
||||
continue
|
||||
hist = w.get("hist") or []
|
||||
if len(hist) >= 2 and hist[-1][1] > hist[0][1] and hist[-1][0] > hist[0][0]:
|
||||
# pace over the last 10 updates (sliding window)
|
||||
rate = (hist[-1][0] - hist[0][0]) / (hist[-1][1] - hist[0][1])
|
||||
else:
|
||||
rate = w["done"] / w["elapsed"]
|
||||
etas.append((w["total"] - w["done"]) / rate)
|
||||
# multi-GPU: the fit ETA = the slowest worker
|
||||
self.state["eta_seconds"] = round(max(etas), 0) if etas else None
|
||||
self.state["vram"] = [
|
||||
{"index": g["index"], "used_gb": round(g["vram_used"] / 2**30, 1)}
|
||||
for g in gpu_stats()
|
||||
]
|
||||
Reference in New Issue
Block a user