| """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 |
| X_BYTES = 4e9 |
|
|
|
|
| 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: |
| 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() |
|
|
| |
| |
| ds = load_dataset(a.corpus, split="en", streaming=True) |
| if world > 1: |
| ds = ds.shard(num_shards=world, index=rank) |
| 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: |
| 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() |
|
|