maxact-fast / scripts /embed_cluster_acts.py
ceselder's picture
out-of-core rework: embed_cluster_acts streams resids to disk memmap + streaming multi-GPU k-means (--kmeans-sample); build_data reads unified emb.f32 + GPU-batched Newton/IRLS probes
320b589
Raw
History Blame Contribute Delete
9.57 kB
"""Stage 1 (activation version): cluster the corpus in Qwen3's OWN space — mean-pooled layer-27
residuals — not a separate embedding model. One pass produces both the clustering features AND the
per-doc probe residuals (build_data reads emb.f32 rows straight from here), and clusters live in
the same space as the probe/injection/reward.
OUT-OF-CORE at 200M docs / k=1e6: each rank streams its corpus shard and writes residuals straight
into its contiguous row-block of a shared disk memmap (emb.f32 — 3.3TB at 200M, never in RAM);
texts stream to per-rank jsonl (rank 0 concatenates in rank order, so rows stay aligned across
emb.f32 / assign.npy / texts.jsonl). K-means holds only the [k, d] centroids on GPU (16GB at k=1M)
and streams point-chunks from the memmap for assign+accumulate, data-parallel across ranks;
centroids train on a --kmeans-sample random subset, then ALL docs get assigned by streaming.
python scripts/embed_cluster_acts.py --n-docs 50000 --clusters 300 --inspect # coherence probe
torchrun --standalone --nproc_per_node=8 scripts/embed_cluster_acts.py \
--n-docs 200000000 --clusters 1000000 --kmeans-sample 20000000
"""
import argparse
import json
import os
import shutil
import numpy as np
import torch
import torch.distributed as dist
from datasets import load_dataset
from transformers import AutoModelForCausalLM, AutoTokenizer
from mxf.config import CORPUS, D_MODEL, MODEL, READ_LAYER
from mxf.inject import read_resid
DIST_BYTES = 15e9 # [chunk, k] fp32 distance buffer budget → chunk*k*4 < 15GB (spec'd for k=1e6)
X_BYTES = 4e9 # cap on the streamed point-chunk itself (binds when k is small)
def _argmin_chunk(X, Cb, Cn):
"""Nearest centroid per row. bf16 matmul (fp32 tensor-core accum) at ~2x fp32 speed; the fp32
||c||² correction keeps the argmin stable for cluster assignment."""
return (Cn[None] - 2 * (X.to(torch.bfloat16) @ Cb.T).float()).argmin(1)
def _chunk_rows(k, d):
return max(256, min(int(DIST_BYTES / (k * 4)), int(X_BYTES / (d * 4))))
def stream_kmeans(emb, k, iters, seed, sample, device, rank, world):
"""Lloyd's with only the centroids resident on GPU; points stream from the disk memmap.
Centroids train on a random `sample`-doc subsample (0 = all docs), sharded across ranks
(all_reduce of sums/counts). Every rank runs the same rng → identical init and updates, no
broadcasts. Empty clusters keep their previous centroid. Returns [k, d] fp32 on GPU."""
n, d = emb.shape
chunk = _chunk_rows(k, d)
rng = np.random.default_rng(seed)
samp = np.sort(rng.choice(n, min(sample or n, n), replace=False))
C = torch.from_numpy(np.asarray(emb[np.sort(rng.choice(samp, k, replace=False))])).to(device)
mine = samp[rank::world]
ones = torch.ones(chunk, device=device)
for it in range(iters):
Cb, Cn = C.to(torch.bfloat16), (C * C).sum(1)
Csum = torch.zeros_like(C)
cnt = torch.zeros(k, device=device)
for s in range(0, len(mine), chunk):
X = torch.from_numpy(np.asarray(emb[mine[s : s + chunk]])).to(device)
a = _argmin_chunk(X, Cb, Cn)
Csum.index_add_(0, a, X)
cnt.index_add_(0, a, ones[: len(X)])
if world > 1:
dist.all_reduce(Csum); dist.all_reduce(cnt)
live = cnt > 0
C = torch.where(live[:, None], Csum / cnt.clamp(min=1)[:, None], C)
if rank == 0:
print(f" kmeans iter {it}: {int(live.sum())}/{k} live", flush=True)
return C
def stream_assign(emb, C, out, lo, hi, rank):
"""Assign rows [lo, hi) to their nearest centroid, streaming memmap→GPU→memmap."""
chunk = _chunk_rows(*C.shape)
Cb, Cn = C.to(torch.bfloat16), (C * C).sum(1)
for s in range(lo, hi, chunk):
X = torch.from_numpy(np.asarray(emb[s : min(s + chunk, hi)])).to(C.device)
out[s : s + len(X)] = _argmin_chunk(X, Cb, Cn).to(torch.int32).cpu().numpy()
if rank == 0 and (s - lo) % (50 * chunk) == 0:
print(f" assign {s - lo}/{hi - lo}", flush=True)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--corpus", default=CORPUS)
ap.add_argument("--n-docs", type=int, default=50000)
ap.add_argument("--clusters", type=int, default=300)
ap.add_argument("--batch", type=int, default=256)
ap.add_argument("--min-chars", type=int, default=200)
ap.add_argument("--max-tok", type=int, default=64)
ap.add_argument("--iters", type=int, default=20)
ap.add_argument("--kmeans-sample", type=int, default=0,
help="train centroids on N random docs (0 = all); ALL docs still get assigned")
ap.add_argument("--out-dir", default="data/actclusters")
ap.add_argument("--inspect", action="store_true", help="print sample docs/cluster (coherence check)")
ap.add_argument("--seed", type=int, default=0)
a = ap.parse_args()
rank = int(os.environ.get("RANK", 0)); world = int(os.environ.get("WORLD_SIZE", 1))
local = int(os.environ.get("LOCAL_RANK", 0))
if world > 1:
dist.init_process_group("nccl"); torch.cuda.set_device(local)
device = f"cuda:{local}"
os.makedirs(a.out_dir, exist_ok=True)
per_rank = a.n_docs // world; n_total = per_rank * world; lo = rank * per_rank
if rank == 0: # sparse files, instant to create; ranks write disjoint contiguous row-blocks
np.memmap(f"{a.out_dir}/emb.f32", dtype=np.float32, mode="w+", shape=(n_total, D_MODEL)).flush()
np.lib.format.open_memmap(f"{a.out_dir}/assign.npy", mode="w+", dtype=np.int32, shape=(n_total,)).flush()
if world > 1:
dist.barrier()
emb = np.memmap(f"{a.out_dir}/emb.f32", dtype=np.float32, mode="r+", shape=(n_total, D_MODEL))
assign = np.lib.format.open_memmap(f"{a.out_dir}/assign.npy", mode="r+")
tok = AutoTokenizer.from_pretrained(MODEL)
if tok.pad_token is None:
tok.pad_token = tok.eos_token
tok.padding_side = "right"
model = AutoModelForCausalLM.from_pretrained(MODEL, torch_dtype=torch.bfloat16,
attn_implementation="sdpa", device_map={"": device}).eval()
# each rank streams its own file-shard of the corpus; residuals go straight into its emb.f32
# block as batches complete (never accumulated in RAM), texts to a per-rank jsonl
ds = load_dataset(a.corpus, split="en", streaming=True)
if world > 1:
ds = ds.shard(num_shards=world, index=rank) # file-level: each rank reads 1/world of shards
tf = open(f"{a.out_dir}/texts_rank{rank}.jsonl", "w")
buf, done = [], 0
@torch.no_grad()
def flush(batch):
nonlocal done
enc = tok(batch, padding=True, truncation=True, max_length=a.max_tok,
return_tensors="pt", add_special_tokens=True).to(device)
emb[lo + done : lo + done + len(batch)] = read_resid(model, READ_LAYER, dict(enc), pool="mean").cpu().numpy()
for t in batch:
tf.write(json.dumps({"t": t}) + "\n")
done += len(batch)
for row in ds:
t = (row.get("content") or row.get("text") or "").strip()
if len(t) < a.min_chars:
continue
buf.append(t[:2000])
if len(buf) == a.batch or done + len(buf) == per_rank:
flush(buf); buf = []
if rank == 0 and done % 25600 == 0:
print(f"resid {done}/{per_rank}", flush=True)
if done >= per_rank:
break
assert done == per_rank, f"corpus exhausted: rank {rank} got {done}/{per_rank} docs"
tf.close(); emb.flush()
del model
torch.cuda.empty_cache()
if world > 1:
dist.barrier()
if rank == 0:
print(f"clustering {n_total} docs (d={D_MODEL}) into {a.clusters} "
f"(centroid sample {a.kmeans_sample or n_total})", flush=True)
C = stream_kmeans(emb, a.clusters, a.iters, a.seed, a.kmeans_sample, device, rank, world)
stream_assign(emb, C, assign, lo, lo + per_rank, rank)
assign.flush()
if world > 1:
dist.barrier()
if rank == 0:
np.save(f"{a.out_dir}/centroids.npy", C.cpu().numpy().astype(np.float32))
with open(f"{a.out_dir}/texts.jsonl", "wb") as out: # rank order == emb/assign row order
for r in range(world):
with open(f"{a.out_dir}/texts_rank{r}.jsonl", "rb") as f:
shutil.copyfileobj(f, out)
os.remove(f"{a.out_dir}/texts_rank{r}.jsonl")
json.dump({"n_docs": n_total, "d": D_MODEL, "clusters": a.clusters, "space": "qwen3_l27_mean"},
open(f"{a.out_dir}/meta.json", "w"))
print(f"CLUSTERED {n_total} -> {a.clusters} (qwen3 layer-{READ_LAYER} mean-pool)", flush=True)
if a.inspect:
A = np.asarray(assign)
rng = np.random.default_rng(1)
sizes = np.bincount(A, minlength=a.clusters)
big = np.where(sizes >= 4)[0]
picks = {int(c): np.where(A == c)[0][:4] for c in rng.choice(big, min(8, len(big)), replace=False)}
want = {int(r) for rows in picks.values() for r in rows}
txt = {i: json.loads(l)["t"] for i, l in enumerate(open(f"{a.out_dir}/texts.jsonl")) if i in want}
for c, rows in picks.items():
print(f"\n=== cluster {c} ({sizes[c]} docs) ===", flush=True)
for r in rows:
print(" -", txt[int(r)][:100].replace("\n", " "), flush=True)
if world > 1:
dist.barrier(); dist.destroy_process_group()
if __name__ == "__main__":
main()