Files
J-Wash/core/fitting.py
T
ExtraltodeusandClaude Opus 4.8 d9773394e3 Support fitting lenses on any dataset and equal-parts mixes
The fit corpus was limited to three hardcoded choices (wikitext, Semantic-Harmless, mixed), and any other id was rejected. Now any HuggingFace dataset id works, and any number of them can be ticked to fit on an equal-parts mix, shuffled.

n_prompts now counts training SEQUENCES (what the fit iterates over) instead of source rows: each dataset is packed up to its quota, so the number entered is exactly what runs, regardless of the dataset. The fixed dropdown becomes a checkable dataset library persisted in localStorage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 05:08:52 +02:00

437 lines
17 KiB
Python

import hashlib
import json
import re
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 corpus. Any HuggingFace dataset id works as-is: wikitext is the default
# and keeps a dedicated streamed path, every other id goes through the generic
# loader below. Several ids = an equal-parts mix.
DATASET_WIKITEXT = "Salesforce/wikitext-103-raw-v1"
# Seed shared by the row sampling and the mix shuffle: a continued fit that asks
# for skip+n rows deterministically extends the sequence it drew the first n
# from (sample(skip+n) then drop the head).
_SAMPLE_SEED = 1729
def _slug(dataset):
"""Short, filename-safe tag from a dataset id's last path segment, e.g.
``heretic-org/Semantic-Harmless`` -> ``semantic-harmless``."""
tail = dataset.rstrip("/").split("/")[-1].lower()
return re.sub(r"[^a-z0-9]+", "-", tail).strip("-")[:24] or "dataset"
def _text_column(features):
"""Column to fit on: prefer ``text``, else the first string-valued column."""
from datasets import Value
if "text" in features:
return "text"
for name, feat in features.items():
if isinstance(feat, Value) and feat.dtype == "string":
return name
raise ValueError(
"dataset exposes no text column to fit on "
f"(columns: {', '.join(features) or 'none'})"
)
def _pack(texts, count, target=350):
"""Pack ``texts`` into ~``target``-char sequences, stopping as soon as
``count`` sequences are ready. jlens skips the first 16 positions of every
sequence as attention sinks, so short unpacked prompts would almost all be
dropped as too short. Returns fewer than ``count`` only if ``texts`` runs
out (the caller decides whether that is an error)."""
packs, cur = [], ""
for text in texts:
text = (text or "").strip()
if not text:
continue
cur = f"{cur}\n\n{text}" if cur else text
if len(cur) >= target:
packs.append(cur)
cur = ""
if len(packs) >= count:
return packs
if cur and len(packs) < count:
# trailing remainder: keep it so a just-large-enough dataset still fills
# its quota (jlens tolerates a slightly-short final sequence)
packs.append(cur)
return packs
def _load_split(dataset):
"""``dataset``'s ``train`` split, or its first split if it has no ``train``."""
from datasets import load_dataset
try:
return load_dataset(dataset, split="train")
except ValueError:
dd = load_dataset(dataset)
return dd[next(iter(dd))]
def _load_one(dataset, n, skip):
"""``n`` training SEQUENCES from a single ``dataset`` id, skipping the first
``skip`` (continue-from: the new sequences must not overlap the base lens's).
wikitext keeps its historical path (first records >=600 chars, streamed —
one record already is one sequence). Any other HF dataset is loaded whole,
its text column shuffled with a fixed seed, then PACKED into ~350-char
sequences until skip+n are ready (median instruct prompt ~10 tokens, so
several rows per sequence). ``n``/``skip`` count OUTPUT sequences, so the
number the user asks for is exactly what the fit iterates over — not source
rows, whose count varies per dataset."""
if n <= 0:
return []
if dataset == DATASET_WIKITEXT:
from jlens.examples import load_wikitext_prompts
# load skip + n then keep the tail: the new sequences don't overlap the
# base lens's
return load_wikitext_prompts(skip + n)[skip:]
import random
ds = _load_split(dataset)
col = _text_column(ds.features)
texts = [r[col] for r in ds]
random.Random(_SAMPLE_SEED).shuffle(texts)
packs = _pack(texts, skip + n)
if len(packs) < skip + n:
raise ValueError(
f"{dataset}: {len(texts)} rows pack into only {len(packs)} sequences, "
f"{skip + n} requested — lower n_prompts"
)
return packs[skip:skip + n]
def _load_corpus(datasets, n, skip=0):
"""``n`` training SEQUENCES drawn from ``datasets`` (a list of HF dataset
ids). A single id loads that dataset; several are mixed in EQUAL parts — n
and skip are each split across them (the first datasets take the rounding
remainder) and the union is shuffled so multi-GPU slices stay mixed. Because
the count is in sequences, ``n`` is exactly what the fit iterates over."""
datasets = list(datasets)
if len(datasets) == 1:
return _load_one(datasets[0], n, skip)
import random
k = len(datasets)
prompts = []
for i, ds in enumerate(datasets):
prompts += _load_one(ds, n // k + int(i < n % k), skip // k + int(i < skip % k))
random.Random(_SAMPLE_SEED).shuffle(prompts)
return prompts
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, datasets=(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")
datasets = [d.strip() for d in datasets if d and d.strip()]
if not datasets:
raise ValueError("at least one dataset required")
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 len(datasets) > 1:
base += "_mixed"
elif datasets[0] != DATASET_WIKITEXT:
base += "_" + _slug(datasets[0])
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,
"datasets": datasets,
"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("datasets", [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": (
"mixed: " + " + ".join(params["datasets"]) + " (equal parts)"
if len(params["datasets"]) > 1
else params["datasets"][0]
),
"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()
]