ceselder Claude Fable 5 commited on
Commit
48db85f
·
1 Parent(s): 17fc9a9

maxact-fast: hardening fixes (6 bugs) + Dr.GRPO vllm-lens rl.py + sbatch; Probe = universal direction

Browse files
pyproject.toml CHANGED
@@ -6,6 +6,7 @@ requires-python = ">=3.11"
6
  dependencies = [
7
  "torch>=2.8",
8
  "transformers>=4.57,<5", # vllm-lens pin; keep <5
 
9
  "peft",
10
  "datasets",
11
  "numpy",
@@ -17,7 +18,7 @@ dependencies = [
17
  [project.optional-dependencies]
18
  # installed in the dedicated vllm-lens venv, NOT the main env (hard pins)
19
  vllm = ["vllm==0.19.0", "vllm-lens==1.1.0"]
20
- cluster = ["faiss-gpu"]
21
 
22
  [tool.setuptools.packages.find]
23
  where = ["src"]
 
6
  dependencies = [
7
  "torch>=2.8",
8
  "transformers>=4.57,<5", # vllm-lens pin; keep <5
9
+ "accelerate", # required by device_map={"": device} in from_pretrained
10
  "peft",
11
  "datasets",
12
  "numpy",
 
18
  [project.optional-dependencies]
19
  # installed in the dedicated vllm-lens venv, NOT the main env (hard pins)
20
  vllm = ["vllm==0.19.0", "vllm-lens==1.1.0"]
21
+ cluster = ["faiss-gpu-cu12"] # PyPI "faiss-gpu" is dead (py<=3.10 wheels only, conflicts with requires-python>=3.11)
22
 
23
  [tool.setuptools.packages.find]
24
  where = ["src"]
scripts/build_data.py CHANGED
@@ -77,7 +77,7 @@ def main():
77
 
78
  # centroid-closest target texts (embedding space)
79
  emb = np.memmap(f"{a.clusters_dir}/emb.f32", dtype=np.float32, mode="r",
80
- shape=(meta["n_docs"], -1))
81
  cent = np.load(f"{a.clusters_dir}/centroids.npy"); assign = np.load(f"{a.clusters_dir}/assign.npy")
82
  texts = [json.loads(l)["t"] for l in open(f"{a.clusters_dir}/texts.jsonl")]
83
  tgt_rows = {}
@@ -96,6 +96,11 @@ def main():
96
  while n < a.n_examples:
97
  A, B = (int(x) for x in rng.choice(pool, 2, replace=False))
98
  tried += 1
 
 
 
 
 
99
  sA, sB = split[A], split[B]
100
  pr = fit_probe(usable[A][:sA], usable[B][:sB], usable[A][sA:], usable[B][sB:], a.probe_c)
101
  if pr is None:
@@ -112,10 +117,6 @@ def main():
112
  "cluster": A, "val_auc": round(auc, 3),
113
  "proj_margin": round(pA - pB, 2)}) + "\n")
114
  n += 1
115
- if n % 200_000 == 0 and n:
116
- print(f"minted {n}/{a.n_examples} | kept {tried-dropped}/{tried} pairs "
117
- f"(drop {dropped/tried:.0%}) | auc med {np.median(aucs):.3f} "
118
- f"margin med {np.median(margins):.2f}", flush=True)
119
  recs.close(); vec_bank.flush()
120
  stats = {"n_examples": n, "pairs_tried": tried, "pairs_dropped": dropped,
121
  "drop_frac": dropped / max(tried, 1), "auc_median": float(np.median(aucs)),
 
77
 
78
  # centroid-closest target texts (embedding space)
79
  emb = np.memmap(f"{a.clusters_dir}/emb.f32", dtype=np.float32, mode="r",
80
+ shape=(meta["n_docs"], meta["d"])) # -1 is illegal in memmap shapes
81
  cent = np.load(f"{a.clusters_dir}/centroids.npy"); assign = np.load(f"{a.clusters_dir}/assign.npy")
82
  texts = [json.loads(l)["t"] for l in open(f"{a.clusters_dir}/texts.jsonl")]
83
  tgt_rows = {}
 
96
  while n < a.n_examples:
97
  A, B = (int(x) for x in rng.choice(pool, 2, replace=False))
98
  tried += 1
99
+ if tried % 25_000 == 0: # keyed on tried, above the continues: 100%-drop configs would otherwise hang silently
100
+ med = (f"auc med {np.median(aucs):.3f} margin med {np.median(margins):.2f}"
101
+ if aucs else "none kept yet")
102
+ print(f"minted {n}/{a.n_examples} | kept {tried-dropped}/{tried} pairs "
103
+ f"(drop {dropped/tried:.0%}) | {med}", flush=True)
104
  sA, sB = split[A], split[B]
105
  pr = fit_probe(usable[A][:sA], usable[B][:sB], usable[A][sA:], usable[B][sB:], a.probe_c)
106
  if pr is None:
 
117
  "cluster": A, "val_auc": round(auc, 3),
118
  "proj_margin": round(pA - pB, 2)}) + "\n")
119
  n += 1
 
 
 
 
120
  recs.close(); vec_bank.flush()
121
  stats = {"n_examples": n, "pairs_tried": tried, "pairs_dropped": dropped,
122
  "drop_frac": dropped / max(tried, 1), "auc_median": float(np.median(aucs)),
scripts/embed_cluster.py CHANGED
@@ -16,7 +16,7 @@ from mxf.config import ClusterConfig
16
 
17
  def embed_stream(cfg, model, tok, device):
18
  """Yield (texts, np.float32[n,d]) batches from the streamed corpus."""
19
- ds = load_dataset(cfg.corpus, split="train", streaming=True)
20
  buf, seen = [], 0
21
  for row in ds:
22
  t = (row.get("content") or row.get("text") or "").strip()
@@ -69,7 +69,7 @@ def main():
69
  for t in texts[:k]:
70
  txt.write(json.dumps({"t": t}) + "\n")
71
  n += k
72
- if n % 100_000 == 0:
73
  print(f"embedded {n}/{cfg.n_docs}", flush=True)
74
  if n >= cfg.n_docs:
75
  break
 
16
 
17
  def embed_stream(cfg, model, tok, device):
18
  """Yield (texts, np.float32[n,d]) batches from the streamed corpus."""
19
+ ds = load_dataset(cfg.corpus, split="en", streaming=True) # Ultra-FineWeb splits are en/zh (no "train"); field is "content"
20
  buf, seen = [], 0
21
  for row in ds:
22
  t = (row.get("content") or row.get("text") or "").strip()
 
69
  for t in texts[:k]:
70
  txt.write(json.dumps({"t": t}) + "\n")
71
  n += k
72
+ if n // 100_000 != (n - k) // 100_000: # batch-sized steps almost never land on exact multiples
73
  print(f"embedded {n}/{cfg.n_docs}", flush=True)
74
  if n >= cfg.n_docs:
75
  break
scripts/pretrain.py CHANGED
@@ -49,7 +49,7 @@ def main():
49
  records = [json.loads(l) for l in open(f"{a.data_dir}/records.jsonl")]
50
  n_vecs = max(r["vec_idx"] for r in records) + 1
51
  vecs = np.memmap(f"{a.data_dir}/vecs.f32", dtype=np.float32, mode="r", shape=(n_vecs, D_MODEL))
52
- records = records[rank::world]
53
  if is_main:
54
  print(f"{len(records)*world} records, {n_vecs} vectors, world={world}", flush=True)
55
 
 
49
  records = [json.loads(l) for l in open(f"{a.data_dir}/records.jsonl")]
50
  n_vecs = max(r["vec_idx"] for r in records) + 1
51
  vecs = np.memmap(f"{a.data_dir}/vecs.f32", dtype=np.float32, mode="r", shape=(n_vecs, D_MODEL))
52
+ records = records[rank::world][: len(records) // world] # equal shards: unequal lengths deadlock DDP on the last batch
53
  if is_main:
54
  print(f"{len(records)*world} records, {n_vecs} vectors, world={world}", flush=True)
55
 
scripts/rl.py ADDED
@@ -0,0 +1,358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 6: Dr. GRPO RL — vllm-lens rollouts, no KL, no /std, global-token normalizer.
2
+
3
+ Rollouts: ONE llm.generate() per step; per-request SteeringVector(norm_match=True) == our
4
+ norm-matched inject@INJECT_LAYER at the marker. old_logp comes from vLLM's generation logprobs
5
+ (valid behavior-policy logps at temperature 1.0 ONLY). new_logp is recomputed HF-side with the
6
+ same inject hook; TIS (ratio capped at cfg.tis_cap, upper only) absorbs the residual vLLM/HF
7
+ kernel mismatch; the LoRA-merged actor is pushed back into vLLM every --sync-every steps.
8
+
9
+ Reward: each generation re-tokenized STANDALONE through the CLEAN base model (adapter disabled,
10
+ no injection); reward = max over kept positions of x_t · unit(v) at READ_LAYER, position 0
11
+ skipped (attention-sink guard). No μ-centering: v is shared within a group, so μ·v is a constant
12
+ that cancels exactly in the Dr. GRPO advantage (r − group_mean).
13
+
14
+ python scripts/rl.py --tp 8 # full box (sbatch_rl.sh)
15
+ python scripts/rl.py --groups-per-step 8 --group-size 4 --total-steps 3 --no-wandb # 1-GPU smoke
16
+ """
17
+ import argparse
18
+ import functools
19
+ import json
20
+ import math
21
+ import os
22
+ import time
23
+ from collections import defaultdict
24
+
25
+ os.environ.setdefault("VLLM_ALLOW_INSECURE_SERIALIZATION", "1") # pickle for apply_model(partial)
26
+
27
+ import numpy as np
28
+ import torch
29
+ from peft import LoraConfig, PeftModel, get_peft_model
30
+ from transformers import AutoModelForCausalLM, AutoTokenizer
31
+
32
+ import wandb
33
+ from mxf.config import D_MODEL, INJECT_LAYER, MODEL, READ_LAYER, STEER_COEFF, RLConfig, TrainConfig
34
+ from mxf.inject import get_layer, hooked, make_inject_hook, read_resid
35
+ from mxf.prompts import build_prompt_ids
36
+
37
+
38
+ def _load_chunk(model, chunk):
39
+ """Module-level (picklable) target for llm.apply_model — runs on every TP worker."""
40
+ model.load_weights(iter(chunk))
41
+
42
+
43
+ def sync_weights(actor, llm):
44
+ """LoRA→vLLM colocate sync (TRL pattern): merge adapter, push HF-name/cpu-tensor pairs in
45
+ per-layer chunks (msgspec caps one encode at 4GB), reset prefix cache, unmerge."""
46
+ t0 = time.time()
47
+ actor.merge_adapter()
48
+ try:
49
+ buckets = defaultdict(list)
50
+ for k, v in actor.state_dict().items():
51
+ if "lora_" in k or "modules_to_save" in k:
52
+ continue
53
+ k = k.removeprefix("base_model.model.")
54
+ k = k.replace(".base_layer.weight", ".weight").replace(".base_layer.bias", ".bias")
55
+ grp = f"layer_{int(k.split('.', 3)[2]):03d}" if k.startswith("model.layers.") else "_other"
56
+ buckets[grp].append((k, v.detach().cpu()))
57
+ for name in sorted(buckets): # "_other" (embed/norm/lm_head) first, then layers in order
58
+ llm.apply_model(functools.partial(_load_chunk, chunk=buckets[name]))
59
+ try:
60
+ llm.llm_engine.reset_prefix_cache() # weights changed → cached prefixes are stale
61
+ except AttributeError:
62
+ pass # TODO(verify): vLLM 0.19 exposes reset_prefix_cache on llm_engine (0.19 should)
63
+ finally:
64
+ actor.unmerge_adapter()
65
+ return time.time() - t0
66
+
67
+
68
+ @torch.no_grad()
69
+ def rollout(llm, prompt_ids, marker, dirs, a):
70
+ """B groups × G rollouts in ONE generate(). dirs: [B, d]. Returns flat group-major lists
71
+ (texts, gen_ids, old_logps) — rollout i belongs to group i // group_size."""
72
+ from vllm import SamplingParams
73
+ from vllm_lens import SteeringVector
74
+
75
+ reqs, params = [], []
76
+ for v in dirs:
77
+ # activations MUST be 3-D [1 layer, 1 pos, d]: a 2-D tensor hits vllm-lens's broadcast
78
+ # branch and gets ADDed at EVERY token, silently ignoring position_indices.
79
+ sv = SteeringVector(activations=v.view(1, 1, -1).cpu().float(), layer_indices=[INJECT_LAYER],
80
+ scale=STEER_COEFF, norm_match=True, position_indices=[marker])
81
+ for _ in range(a.group_size):
82
+ # TODO(verify): TokensPrompt dict form on vLLM 0.19 — reference passed text prompts;
83
+ # we pass the exact chat-template ids so marker position is guaranteed.
84
+ reqs.append({"prompt_token_ids": list(prompt_ids)})
85
+ params.append(SamplingParams(temperature=a.temperature, top_p=1.0, top_k=-1, logprobs=1,
86
+ max_tokens=a.max_new_tokens, min_tokens=a.min_new_tokens,
87
+ extra_args={"apply_steering_vectors": [sv]}))
88
+ # TODO(verify): vLLM 0.19 reads Qwen3's generation_config for EOS (<|im_end|>) by default;
89
+ # if smoke rollouts never stop early, pass stop_token_ids explicitly in SamplingParams.
90
+ outs = llm.generate(reqs, params)
91
+ assert len(outs) == len(reqs)
92
+ texts, gen_ids, old_lps = [], [], []
93
+ for out in outs:
94
+ o = out.outputs[0]
95
+ ids = list(o.token_ids)
96
+ # old_logp MUST come from vLLM (the behavior policy). Crash on absence — any substituted
97
+ # value silently corrupts the importance ratio.
98
+ assert o.logprobs is not None and len(o.logprobs) == len(ids), (
99
+ f"vLLM logprobs missing/short ({None if o.logprobs is None else len(o.logprobs)} vs "
100
+ f"{len(ids)} tokens) — vLLM API drift?")
101
+ lp = []
102
+ for t, tid in enumerate(ids):
103
+ assert tid in o.logprobs[t], f"sampled token {tid} absent from logprobs at step {t}"
104
+ lp.append(o.logprobs[t][tid].logprob)
105
+ texts.append(o.text)
106
+ gen_ids.append(ids)
107
+ old_lps.append(torch.tensor(lp, dtype=torch.float32))
108
+ return texts, gen_ids, old_lps
109
+
110
+
111
+ @torch.no_grad()
112
+ def score(texts, dirs_rep, actor, tok, device, a):
113
+ """reward[i] = max_t x_t·unit(v_i) at READ_LAYER — standalone re-tokenization, CLEAN base
114
+ (adapter off, no injection), position 0 skipped. Rows with no kept token score 0."""
115
+ r = torch.zeros(len(texts))
116
+ valid = [i for i, t in enumerate(texts) if t.strip()]
117
+ prev = tok.padding_side
118
+ tok.padding_side = "right" # position 0 must be the first real token
119
+ try:
120
+ for s in range(0, len(valid), a.score_batch):
121
+ idxs = valid[s : s + a.score_batch]
122
+ enc = tok([texts[i] for i in idxs], return_tensors="pt", padding=True, truncation=True,
123
+ max_length=a.max_new_tokens + 32, add_special_tokens=True).to(device)
124
+ with actor.disable_adapter():
125
+ h, mask = read_resid(actor, READ_LAYER, dict(enc), pool="all") # [b,T,d] fp32, [b,T]
126
+ keep = mask.clone()
127
+ keep[:, 0] = False # attention-sink guard (old repo also norm-filtered; keep it simple)
128
+ proj = torch.einsum("btd,bd->bt", h, dirs_rep[idxs])
129
+ best = proj.masked_fill(~keep, torch.finfo(proj.dtype).min).max(1).values
130
+ has = keep.any(1)
131
+ for row, i in enumerate(idxs):
132
+ if has[row]:
133
+ r[i] = best[row].item()
134
+ finally:
135
+ tok.padding_side = prev
136
+ return r
137
+
138
+
139
+ @torch.no_grad()
140
+ def fluency(texts, actor, tok, device, a):
141
+ """(mean clean-base logprob/token, distinct-token fraction) per standalone text — gate inputs.
142
+ Adapter disabled so the policy can't inflate its own fluency score."""
143
+ logp, dis = torch.full((len(texts),), -20.0), torch.zeros(len(texts))
144
+ valid = [i for i, t in enumerate(texts) if t.strip()]
145
+ prev = tok.padding_side
146
+ tok.padding_side = "right"
147
+ try:
148
+ for s in range(0, len(valid), a.score_batch):
149
+ idxs = valid[s : s + a.score_batch]
150
+ enc = tok([texts[i] for i in idxs], return_tensors="pt", padding=True, truncation=True,
151
+ max_length=a.max_new_tokens + 32, add_special_tokens=True).to(device)
152
+ if enc["input_ids"].shape[1] < 2:
153
+ continue
154
+ with actor.disable_adapter():
155
+ logits = actor(**enc).logits[:, :-1].float()
156
+ lp = torch.log_softmax(logits, -1).gather(-1, enc["input_ids"][:, 1:, None]).squeeze(-1)
157
+ m = enc["attention_mask"][:, 1:].bool()
158
+ for row, i in enumerate(idxs):
159
+ n = int(m[row].sum())
160
+ if n:
161
+ logp[i] = (lp[row][m[row]].sum() / n).item()
162
+ ids = enc["input_ids"][row][enc["attention_mask"][row].bool()]
163
+ dis[i] = len(set(ids.tolist())) / max(len(ids), 1)
164
+ finally:
165
+ tok.padding_side = prev
166
+ return logp, dis
167
+
168
+
169
+ def update(actor, opt, submodule, ids, attn, p_len, marker, old_lp, adv, dirs_rep, a, device):
170
+ """ONE Dr. GRPO optimizer update. loss = Σ_tokens −min(ratio·A, clip(ratio)·A)·mask / TOTAL
171
+ completion tokens in batch (GLOBAL constant normalizer — no per-sequence mean, no /std, no KL).
172
+ ratio TIS-capped (upper only). new_logp forward runs with the SAME inject hook as rollout."""
173
+ n = ids.shape[0]
174
+ gen_mask = attn[:, p_len:].bool()
175
+ total_tok = max(int(gen_mask.sum()), 1)
176
+ lo, hi = 1 - a.clip_eps, 1 + a.clip_eps
177
+ loss_sum, clipped_tok = 0.0, 0
178
+ opt.zero_grad(set_to_none=True)
179
+ for s in range(0, n, a.micro_batch):
180
+ e = min(s + a.micro_batch, n)
181
+ b_ids, b_attn = ids[s:e].to(device), attn[s:e].to(device)
182
+ hook = make_inject_hook([dirs_rep[i : i + 1] for i in range(s, e)], [[marker]] * (e - s),
183
+ STEER_COEFF, device, torch.bfloat16)
184
+ with hooked(submodule, hook):
185
+ logits = actor(input_ids=b_ids, attention_mask=b_attn).logits[:, p_len - 1 : -1]
186
+ new_lp = torch.log_softmax(logits.float(), -1).gather(-1, b_ids[:, p_len:, None]).squeeze(-1)
187
+ del logits
188
+ m = gen_mask[s:e].to(device)
189
+ ratio = torch.exp(new_lp - old_lp[s:e].to(device)).clamp(max=a.tis_cap) # TIS, upper only
190
+ A = adv[s:e, None].to(device)
191
+ loss = (-torch.minimum(ratio * A, ratio.clamp(lo, hi) * A) * m).sum() / total_tok
192
+ loss.backward() # micro-losses share the global normalizer → grads sum correctly
193
+ loss_sum += loss.item()
194
+ clipped_tok += int((((ratio < lo) | (ratio > hi)) & m).sum())
195
+ gn = float(torch.nn.utils.clip_grad_norm_(
196
+ [p for p in actor.parameters() if p.requires_grad], a.max_grad_norm))
197
+ if math.isfinite(gn):
198
+ opt.step()
199
+ else: # stepping Adam on nan/inf grads corrupts moments AND weights
200
+ opt.zero_grad(set_to_none=True)
201
+ print(f"[update] non-finite grad norm ({gn}) — skipping step", flush=True)
202
+ return {"loss": loss_sum, "grad_norm": gn, "clipfrac": clipped_tok / total_tok}
203
+
204
+
205
+ def main():
206
+ cfg, tr = RLConfig(), TrainConfig()
207
+ ap = argparse.ArgumentParser()
208
+ ap.add_argument("--data-dir", default="data/pretrain")
209
+ ap.add_argument("--init-adapter", default=cfg.init_adapter)
210
+ ap.add_argument("--save-dir", default=cfg.save_dir)
211
+ ap.add_argument("--run-name", default=cfg.run_name)
212
+ ap.add_argument("--direction-source", default=cfg.direction_source)
213
+ ap.add_argument("--groups-per-step", type=int, default=cfg.groups_per_step)
214
+ ap.add_argument("--group-size", type=int, default=cfg.group_size)
215
+ ap.add_argument("--lr", type=float, default=cfg.lr)
216
+ ap.add_argument("--clip-eps", type=float, default=cfg.clip_eps)
217
+ ap.add_argument("--tis-cap", type=float, default=cfg.tis_cap)
218
+ ap.add_argument("--max-new-tokens", type=int, default=cfg.max_new_tokens)
219
+ ap.add_argument("--min-new-tokens", type=int, default=cfg.min_new_tokens)
220
+ ap.add_argument("--temperature", type=float, default=cfg.temperature)
221
+ ap.add_argument("--total-steps", type=int, default=cfg.total_steps)
222
+ ap.add_argument("--sync-every", type=int, default=cfg.sync_every)
223
+ ap.add_argument("--fluency-floor", type=float, default=cfg.fluency_floor)
224
+ ap.add_argument("--distinct-floor", type=float, default=cfg.distinct_floor)
225
+ ap.add_argument("--gate-penalty", type=float, default=cfg.gate_penalty)
226
+ ap.add_argument("--len-penalty-start", type=int, default=cfg.len_penalty_start)
227
+ ap.add_argument("--len-penalty-per-tok", type=float, default=cfg.len_penalty_per_tok)
228
+ ap.add_argument("--no-gates", action="store_true", help="disable fluency/distinct/len shaping")
229
+ ap.add_argument("--tp", type=int, default=int(os.environ.get("WORLD_SIZE", "1")))
230
+ ap.add_argument("--vllm-gpu-mem", type=float, default=0.35)
231
+ ap.add_argument("--vllm-max-len", type=int, default=1024)
232
+ ap.add_argument("--micro-batch", type=int, default=8)
233
+ ap.add_argument("--score-batch", type=int, default=64)
234
+ ap.add_argument("--max-grad-norm", type=float, default=1.0)
235
+ ap.add_argument("--save-every", type=int, default=500)
236
+ ap.add_argument("--no-wandb", action="store_true")
237
+ ap.add_argument("--seed", type=int, default=0)
238
+ a = ap.parse_args()
239
+ if a.no_gates:
240
+ a.fluency_floor = a.distinct_floor = a.len_penalty_start = None
241
+ # vLLM generation logprobs equal the sampling distribution's ONLY at T=1 (raw_logprobs).
242
+ assert a.temperature == 1.0, "old_logp from vLLM is only valid at temperature 1.0"
243
+ torch.manual_seed(a.seed)
244
+ rng = np.random.default_rng(a.seed)
245
+ device = "cuda:0" # HF actor lives here; vLLM TP shares all GPUs at gpu_memory_utilization
246
+
247
+ tok = AutoTokenizer.from_pretrained(MODEL)
248
+ if tok.pad_token is None:
249
+ tok.pad_token = tok.eos_token
250
+ prompt_ids, mpos = build_prompt_ids(tok)
251
+ marker, p_len = mpos[0], len(prompt_ids)
252
+ assert p_len + a.max_new_tokens <= a.vllm_max_len
253
+
254
+ # ---- direction bank ----
255
+ if a.direction_source == "cluster":
256
+ stats_p = f"{a.data_dir}/build_stats.json"
257
+ n_vecs = (json.load(open(stats_p))["n_examples"] if os.path.exists(stats_p)
258
+ else os.path.getsize(f"{a.data_dir}/vecs.f32") // (4 * D_MODEL))
259
+ bank = np.memmap(f"{a.data_dir}/vecs.f32", dtype=np.float32, mode="r", shape=(n_vecs, D_MODEL))
260
+ assert n_vecs >= a.groups_per_step
261
+ else:
262
+ # TODO: "sae" = unit encoder columns of the L27 SAE, "mix" = interleave cluster+sae.
263
+ # The SAE loader isn't in this repo yet — port from max-activating-examples/src/maxact/sae.py.
264
+ raise NotImplementedError(f"direction_source={a.direction_source!r}: only 'cluster' in the pilot")
265
+
266
+ # ---- actor (HF + LoRA, cuda:0). NO gradient checkpointing EVER: recompute happens after the
267
+ # inject-hook context exits → silently wrong grads. ----
268
+ actor = AutoModelForCausalLM.from_pretrained(MODEL, torch_dtype=torch.bfloat16,
269
+ attn_implementation="sdpa", device_map={"": device})
270
+ if a.init_adapter:
271
+ actor = PeftModel.from_pretrained(actor, a.init_adapter, is_trainable=True)
272
+ else:
273
+ actor = get_peft_model(actor, LoraConfig(
274
+ r=tr.lora_r, lora_alpha=tr.lora_alpha, lora_dropout=0.0, use_rslora=True,
275
+ target_modules="all-linear", bias="none", task_type="CAUSAL_LM"))
276
+ actor.train()
277
+ opt = torch.optim.AdamW([p for p in actor.parameters() if p.requires_grad], lr=a.lr, weight_decay=0.0)
278
+ submodule = get_layer(actor, INJECT_LAYER)
279
+
280
+ # ---- vLLM rollout engine (colocated, TP across all visible GPUs) ----
281
+ from vllm import LLM
282
+ llm = LLM(model=MODEL, dtype="bfloat16", gpu_memory_utilization=a.vllm_gpu_mem,
283
+ max_model_len=a.vllm_max_len, tensor_parallel_size=a.tp,
284
+ enforce_eager=True) # MANDATORY — vllm-lens hooks don't fire under compiled graphs
285
+ print(f"[vllm] up tp={a.tp} | {n_vecs} directions | prompt {p_len} toks, marker @{marker}", flush=True)
286
+ print(f"[sync] initial {sync_weights(actor, llm):.1f}s", flush=True) # vLLM == actor at step 0
287
+
288
+ if not a.no_wandb:
289
+ wandb.init(project="maxact-fast", name=a.run_name, config=vars(a))
290
+ os.makedirs(a.save_dir, exist_ok=True)
291
+ B, G = a.groups_per_step, a.group_size
292
+
293
+ for step in range(a.total_steps):
294
+ t0 = time.time()
295
+ idx = np.sort(rng.choice(n_vecs, size=B, replace=False)) # B distinct vec_idx (sorted: memmap-friendly)
296
+ dirs = torch.nn.functional.normalize(
297
+ torch.from_numpy(np.asarray(bank[idx], dtype=np.float32)), dim=-1)
298
+ texts, gen_ids, old_lps = rollout(llm, prompt_ids, marker, dirs, a)
299
+ t_roll = time.time() - t0
300
+ dirs_rep = dirs.repeat_interleave(G, 0).to(device) # [B*G, d] rollout i's group direction
301
+
302
+ r = score(texts, dirs_rep, actor, tok, device, a)
303
+ raw_r, gate_frac = r.clone(), 1.0
304
+ if a.fluency_floor is not None or a.distinct_floor is not None:
305
+ flu, dis = fluency(texts, actor, tok, device, a)
306
+ gate = torch.ones(B * G, dtype=torch.bool)
307
+ if a.fluency_floor is not None:
308
+ gate &= flu >= a.fluency_floor
309
+ if a.distinct_floor is not None:
310
+ gate &= dis >= a.distinct_floor
311
+ # sign-safe subtract, NOT zero: zeroing would rank gated garbage above coherent
312
+ # negative-dot rollouts
313
+ r = r - a.gate_penalty * (~gate).float()
314
+ gate_frac = gate.float().mean().item()
315
+ if a.len_penalty_start is not None:
316
+ over = torch.tensor([max(0, len(g) - a.len_penalty_start) for g in gen_ids],
317
+ dtype=torch.float32)
318
+ r = r - a.len_penalty_per_tok * over
319
+ adv = (r.view(B, G) - r.view(B, G).mean(1, keepdim=True)).flatten().detach() # NO /std
320
+
321
+ # pad the batch — prompt is shared, so p_len is constant across rows
322
+ L = p_len + max(len(g) for g in gen_ids)
323
+ ids = torch.full((B * G, L), tok.pad_token_id, dtype=torch.long)
324
+ attn = torch.zeros((B * G, L), dtype=torch.long)
325
+ old_lp = torch.zeros((B * G, L - p_len))
326
+ pt = torch.tensor(prompt_ids, dtype=torch.long)
327
+ for i, (g, lp) in enumerate(zip(gen_ids, old_lps)):
328
+ ids[i, :p_len] = pt
329
+ ids[i, p_len : p_len + len(g)] = torch.tensor(g)
330
+ attn[i, : p_len + len(g)] = 1
331
+ old_lp[i, : len(g)] = lp
332
+ stats = update(actor, opt, submodule, ids, attn, p_len, marker, old_lp, adv, dirs_rep, a, device)
333
+
334
+ sync_s = sync_weights(actor, llm) if (step + 1) % a.sync_every == 0 else 0.0
335
+ secs = time.time() - t0
336
+ n_gen = float(sum(len(g) for g in gen_ids))
337
+ log = {"reward/mean": raw_r.mean().item(), "reward/std": raw_r.std().item(),
338
+ "reward/max": raw_r.max().item(), "reward/shaped_mean": r.mean().item(),
339
+ "reward/gate_frac": gate_frac, "ratio/clipfrac": stats["clipfrac"],
340
+ "loss": stats["loss"], "grad_norm": stats["grad_norm"],
341
+ "rollout/mean_logp": torch.cat(old_lps).mean().item(),
342
+ "rollout/len_mean": n_gen / (B * G), "tokens_per_sec": n_gen / secs,
343
+ "time/rollout_s": t_roll, "time/sync_s": sync_s, "time/step_s": secs}
344
+ print(f"step {step:05d} | r {log['reward/mean']:.2f} (max {log['reward/max']:.1f}) | "
345
+ f"gate {gate_frac:.0%} | clip {log['ratio/clipfrac']:.2%} | len {log['rollout/len_mean']:.0f} "
346
+ f"| {log['tokens_per_sec']:.0f} tok/s | {secs:.0f}s", flush=True)
347
+ if step % 10 == 0:
348
+ print(f" sample r={raw_r[0]:.2f}: {texts[0][:110]!r}", flush=True)
349
+ if not a.no_wandb:
350
+ wandb.log(log, step=step)
351
+ if a.save_every and step and step % a.save_every == 0:
352
+ actor.save_pretrained(f"{a.save_dir}/step_{step}")
353
+ actor.save_pretrained(f"{a.save_dir}/final")
354
+ print("RL_DONE", flush=True)
355
+
356
+
357
+ if __name__ == "__main__":
358
+ main()
scripts/sbatch_rl.sh ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ #SBATCH --job-name=mxf_rl_drgrpo
3
+ #SBATCH --partition=general
4
+ #SBATCH --qos=high
5
+ #SBATCH --gres=gpu:8
6
+ #SBATCH --cpus-per-task=8
7
+ #SBATCH --mem=256G
8
+ #SBATCH --time=48:00:00
9
+ #SBATCH --no-requeue
10
+ #SBATCH --output=/workspace-vast/celeste/maxact-fast/logs/%x_%j.out
11
+
12
+ # Stage 6: Dr. GRPO RL (scripts/rl.py) — colocated single task: vLLM TP across all GPUs at
13
+ # gpu_memory_utilization=0.35, HF actor+LoRA on cuda:0. `mkdir -p logs` once before first submit.
14
+ #
15
+ # sbatch scripts/sbatch_rl.sh # RLConfig defaults
16
+ # sbatch scripts/sbatch_rl.sh --groups-per-step 64 --sync-every 4 # extra args pass through
17
+ #
18
+ # Venv (dedicated — vllm pins are hard, see pyproject [vllm] extra). Build once with:
19
+ # uv venv /workspace-vast/celeste/envs/mxf-vllm --python 3.12
20
+ # uv pip install --python /workspace-vast/celeste/envs/mxf-vllm/bin/python \
21
+ # "vllm==0.19.0" "vllm-lens==1.1.0" "transformers==4.57.1" peft wandb --torch-backend=cu128
22
+ # Pins are load-bearing: vllm-lens 1.1.0 is built against vLLM 0.19.0 — on vLLM>=0.22 the hook
23
+ # crashes then SILENTLY skips injection; cu128 matches the cluster's driver 570 (cu130 wheels
24
+ # fail at import). transformers must stay <5 (apply_chat_template API break).
25
+ # /workspace-vast/celeste/envs/vllm-lens (nla-experiments) is a known-good fallback with the same pins.
26
+
27
+ set -euo pipefail
28
+ ROOT=/workspace-vast/celeste/maxact-fast
29
+ VENV=${VENV:-/workspace-vast/celeste/envs/mxf-vllm}
30
+ source "$VENV/bin/activate"
31
+
32
+ export VLLM_ALLOW_INSECURE_SERIALIZATION=1 # pickle for apply_model(partial) weight sync
33
+ export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
34
+ export HF_HOME=/workspace-vast/pretrained_ckpts
35
+ export HF_TOKEN_PATH=/workspace-vast/celeste/.cache/huggingface/token
36
+ : "${WANDB_API_KEY:?set WANDB_API_KEY in your shell}"
37
+ export PYTHONUNBUFFERED=1
38
+ export PYTHONPATH=$ROOT/src:${PYTHONPATH:-}
39
+ # NEVER set CUDA_VISIBLE_DEVICES here — SLURM does it.
40
+
41
+ cd "$ROOT"
42
+ python scripts/rl.py --tp "${SLURM_GPUS_ON_NODE:-1}" "$@"
src/mxf/config.py CHANGED
@@ -67,10 +67,12 @@ class RLConfig:
67
  group_size: int = 8
68
  lr: float = 1e-6
69
  clip_eps: float = 0.2
 
70
  max_new_tokens: int = 96
71
  min_new_tokens: int = 16
72
  temperature: float = 1.0
73
  total_steps: int = 30_000
 
74
  fluency_floor: float | None = -4.5 # optional gates (stability without KL)
75
  distinct_floor: float | None = 0.5
76
  gate_penalty: float = 25.0
 
67
  group_size: int = 8
68
  lr: float = 1e-6
69
  clip_eps: float = 0.2
70
+ tis_cap: float = 2.0 # TIS upper ratio cap — absorbs residual vLLM/HF kernel mismatch
71
  max_new_tokens: int = 96
72
  min_new_tokens: int = 16
73
  temperature: float = 1.0
74
  total_steps: int = 30_000
75
+ sync_every: int = 10 # push LoRA-merged actor weights into vLLM every N steps
76
  fluency_floor: float | None = -4.5 # optional gates (stability without KL)
77
  distinct_floor: float | None = 0.5
78
  gate_penalty: float = 25.0