Text Generation
Transformers
Safetensors
English
metadiffusion
diffusion
diffusion-lm
ar-to-diffusion
custom_code
CodeSoft commited on
Commit
d6f5237
·
verified ·
1 Parent(s): f3e0e57

Upload 9 files

Browse files
Files changed (8) hide show
  1. chat.py +548 -0
  2. convert.py +191 -0
  3. eval.py +162 -0
  4. export_hf.py +188 -0
  5. model.py +296 -0
  6. prepare_data.py +388 -0
  7. requirements.txt +5 -0
  8. train.py +702 -0
chat.py ADDED
@@ -0,0 +1,548 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """chat.py: ChatML chat with MetaDiffusion-600M checkpoints.
3
+
4
+ Left-to-right block commit (semi-autoregressive): the leftmost masked
5
+ positions are filled first, so <|im_end|> cannot win the race at position 0.
6
+
7
+ Usage:
8
+ Interactive: python chat.py --model-path checkpoints/step_30000.pt
9
+ One-shot: python chat.py --model-path checkpoints/step_30000.pt \
10
+ --prompt "What is 2+2?" --watch
11
+ """
12
+
13
+ import argparse
14
+ import json
15
+ import math
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ import torch
20
+ import torch.nn.functional as F
21
+ from transformers import AutoTokenizer
22
+
23
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
24
+ from model import MetaDiffusionConfig, MetaDiffusionLM # noqa: E402
25
+
26
+ IM_START, IM_END = "<|im_start|>", "<|im_end|>"
27
+ RAINBOW_TOKENS = [f"<|r{i}|>" for i in range(1, 8)]
28
+
29
+
30
+ def build_config(config_dict):
31
+ valid = {k: v for k, v in config_dict.items()
32
+ if k in MetaDiffusionConfig.__dataclass_fields__}
33
+ return MetaDiffusionConfig(**valid)
34
+
35
+
36
+ def load_model(model_path, device):
37
+ path = Path(model_path)
38
+ if path.is_dir():
39
+ with open(path / "config.json") as f:
40
+ config = build_config(json.load(f))
41
+ model = MetaDiffusionLM(config).to(device)
42
+ from safetensors.torch import load_file
43
+ sd = load_file(path / "model.safetensors")
44
+ sd = {k[len("model."):] if k.startswith("model.") else k: v for k, v in sd.items()}
45
+ model.load_state_dict(sd, strict=True)
46
+ else:
47
+ ckpt = torch.load(model_path, map_location=device, weights_only=False)
48
+ config = build_config(ckpt["config"])
49
+ model = MetaDiffusionLM(config).to(device)
50
+ sd = {k.replace("_orig_mod.", "", 1) if k.startswith("_orig_mod.") else k: v
51
+ for k, v in ckpt["model_state_dict"].items()}
52
+ model.load_state_dict(sd, strict=True)
53
+ model.eval()
54
+ print(f" Loaded {sum(p.numel() for p in model.parameters())/1e6:.1f}M params, "
55
+ f"vocab={config.mask_vocab_size}")
56
+ return model
57
+
58
+
59
+ def ensure_special_tokens(tokenizer):
60
+ """Add [MASK] + rainbow if missing"""
61
+ added = []
62
+ if tokenizer.convert_tokens_to_ids("[MASK]") == tokenizer.unk_token_id:
63
+ added.append("[MASK]")
64
+ missing = [t for t in RAINBOW_TOKENS
65
+ if tokenizer.convert_tokens_to_ids(t) == tokenizer.unk_token_id]
66
+ if missing:
67
+ added.extend(missing)
68
+ if added:
69
+ tokenizer.add_special_tokens({"additional_special_tokens": added})
70
+ return tokenizer
71
+
72
+
73
+ def format_messages(messages):
74
+ parts = []
75
+ for m in messages:
76
+ parts.append(f"{IM_START}{m['role']}\n{m['content']}{IM_END}")
77
+ return "\n".join(parts)
78
+
79
+
80
+ def cumulative_unmask_frac(i, N):
81
+ return 0.5 * (1 - math.cos(math.pi * i / N))
82
+
83
+
84
+ @torch.no_grad()
85
+ def generate_response(model, tokenizer, prompt_ids, gen_len, num_steps,
86
+ temperature, repetition_penalty, device, watch=False,
87
+ stop_on_end=True, cfg_scale=0.0, ban_ids=None,
88
+ top_p=0.0, min_p=0.0, refine=False, refine_frac=0.3,
89
+ refine_steps=16, im_end_bias=0.0, im_end_bias_t=0.3,
90
+ smart_remask=False, smart_remask_thresh=0.5,
91
+ smart_remask_iters=2):
92
+ mask_id = model.config.mask_token_id
93
+ im_end_id = tokenizer.convert_tokens_to_ids(IM_END)
94
+ eos_id = tokenizer.eos_token_id
95
+ rainbow_ids = [tokenizer.convert_tokens_to_ids(t) for t in RAINBOW_TOKENS]
96
+ prompt_len = prompt_ids.shape[1]
97
+ total_len = prompt_len + gen_len
98
+
99
+ x = torch.full((1, total_len), mask_id, device=device, dtype=torch.long)
100
+ x[0, :prompt_len] = prompt_ids
101
+ # commit-confidence map for --smart-remask (top-1 prob at commit time);
102
+ # 1.0 for prompt/uncommitted so only real commits can fall below the bar
103
+ conf = (torch.ones((1, total_len), dtype=torch.float32, device=device)
104
+ if smart_remask else None)
105
+
106
+ terminated = False
107
+ for i in range(num_steps):
108
+ frac_now = cumulative_unmask_frac(i, num_steps)
109
+ frac_next = cumulative_unmask_frac(i + 1, num_steps)
110
+ n_masked = (x == mask_id).sum().item()
111
+ if i == num_steps - 1:
112
+ n_unmask = n_masked
113
+ else:
114
+ n_total = int((frac_next - frac_now) * gen_len + 0.5)
115
+ n_unmask = max(n_total, 1) if n_masked > 0 else 0
116
+ if n_unmask == 0:
117
+ break
118
+
119
+ t = 1.0 - frac_now
120
+ t_val = torch.full((1,), t, device=device)
121
+ logits = model(x, t_val).float() # fp32 sampling path: stable softmax
122
+ # Mid-run curriculum probes extrapolate t beyond what the model has
123
+ # seen (e.g. t=0.99 at step 8.5K when the ramp max is ~0.48). The
124
+ # timestep embedding can then blow up to NaN/inf inside the bf16
125
+ # forward. Sanitize once here so CFG, softmax and multinomial never
126
+ # see a poisoned distribution.
127
+ logits = torch.nan_to_num(logits, nan=0.0, posinf=50.0, neginf=-50.0)
128
+ if cfg_scale > 0:
129
+ # classifier-free guidance: unconditional branch sees the prompt
130
+ # region masked too; logits = cond + s*(cond - uncond).
131
+ # The all-mask input is off the training manifold, so its logits
132
+ # can be extreme; extrapolating them in bf16 overflows to inf and
133
+ # poisons softmax/multinomial. Compute in fp32 and clamp.
134
+ uncond_x = torch.full_like(x, mask_id)
135
+ uncond_logits = model(uncond_x, t_val).float()
136
+ uncond_logits = torch.nan_to_num(uncond_logits, nan=0.0,
137
+ posinf=50.0, neginf=-50.0)
138
+ logits = (logits + cfg_scale * (logits - uncond_logits)).clamp(-50.0, 50.0)
139
+ # padding placeholders (rainbow) and [MASK] are never legitimate output
140
+ logits[:, :, mask_id] = -1e9
141
+ logits[:, :, rainbow_ids] = -1e9
142
+ if ban_ids:
143
+ # partial-byte vocab entries that cannot decode to valid UTF-8:
144
+ # the literal "�" characters; never legitimate output either
145
+ logits[:, :, ban_ids] = -1e9
146
+ if im_end_bias != 0.0 and t < im_end_bias_t:
147
+ # pragmatic terminator nudge at the end of denoising: the model's
148
+ # continuation knowledge runs out before its terminator probability
149
+ # rises, so make <|im_end|> competitive in the frontier distribution
150
+ logits[:, :, im_end_id] = logits[:, :, im_end_id] + im_end_bias
151
+
152
+ if repetition_penalty != 1.0:
153
+ committed = x[0, prompt_len:]
154
+ committed = committed[committed != mask_id]
155
+ if committed.numel() > 0:
156
+ for tok in committed.unique():
157
+ ti = tok.item()
158
+ logits[0, :, ti] = torch.where(
159
+ logits[0, :, ti] < 0,
160
+ logits[0, :, ti] * repetition_penalty,
161
+ logits[0, :, ti] / repetition_penalty,
162
+ )
163
+
164
+ mask_positions = x == mask_id
165
+ sampled, probs = sample_masked(logits, mask_positions, temperature,
166
+ top_p, min_p)
167
+ p_max = probs.max(dim=-1).values if conf is not None else None
168
+ mask_flat = mask_positions.nonzero(as_tuple=False)
169
+
170
+ if n_unmask < mask_positions.sum():
171
+ # Left-to-right commit: fill the leftmost masked positions first
172
+ fill_positions = mask_flat[:n_unmask]
173
+ for idx, tok in zip(fill_positions, sampled[:n_unmask]):
174
+ x[idx[0], idx[1]] = tok
175
+ if conf is not None:
176
+ conf[fill_positions[:, 0], fill_positions[:, 1]] = p_max[:n_unmask]
177
+ else:
178
+ x[mask_positions] = sampled
179
+ if conf is not None:
180
+ conf[mask_positions] = p_max
181
+
182
+ if watch:
183
+ remaining = (x == mask_id).sum().item()
184
+ live = [t for t in x[0, prompt_len:].tolist() if t != mask_id]
185
+ partial = tokenizer.decode(cut_response(live, tokenizer),
186
+ skip_special_tokens=True).strip()[:70]
187
+ line = f"step {i+1:3d}/{num_steps} | t={t:.3f} | masks={remaining:3d} | {partial}"
188
+ if sys.stdout.isatty():
189
+ sys.stdout.write("\r" + line[:99].ljust(99))
190
+ sys.stdout.flush()
191
+ elif i % max(1, num_steps // 8) == 0:
192
+ print(line)
193
+
194
+ if stop_on_end and ((x[0, prompt_len:] == im_end_id).any() or
195
+ (x[0, prompt_len:] == eos_id).any()):
196
+ terminated = True
197
+ break
198
+
199
+ if smart_remask and conf is not None:
200
+ # confidence-gated remasking: re-mask only the low-confidence commits
201
+ # (the junk-prone tokens) and re-denoise them with the head fixed
202
+ x = smart_remask_pass(model, x, prompt_len, gen_len, conf, im_end_id,
203
+ eos_id, mask_id, rainbow_ids, ban_ids, refine_steps,
204
+ smart_remask_thresh, smart_remask_iters, temperature,
205
+ repetition_penalty, device, top_p, min_p,
206
+ im_end_bias, im_end_bias_t)
207
+ elif refine and not terminated:
208
+ x = refine_tail(model, x, prompt_len, gen_len, im_end_id, eos_id,
209
+ mask_id, rainbow_ids, ban_ids, refine_steps,
210
+ refine_frac, temperature, repetition_penalty, device,
211
+ top_p, min_p)
212
+
213
+ if watch and sys.stdout.isatty():
214
+ sys.stdout.write("\n")
215
+ return x
216
+
217
+
218
+ def sample_masked(logits, mask_positions, temperature, top_p=0.0, min_p=0.0):
219
+ """Truncation-sampled tokens for the masked positions.
220
+
221
+ top-p nucleus (Holtzman 2020) or Min-P (Nguyen 2024) truncate the
222
+ unreliable tail of the distribution, which is exactly where junk and rare
223
+ tokens live when the model runs out of budget. Min-P scales the cutoff by
224
+ the top token's probability (pbase 0.05-0.1 recommended; use ONE of them).
225
+ Also guards degenerate rows so multinomial never sees inf/nan/negatives.
226
+ Returns (sampled, probs)."""
227
+ probs = F.softmax(logits[mask_positions] / max(temperature, 1e-8), dim=-1)
228
+ probs = torch.nan_to_num(probs, nan=0.0, posinf=0.0, neginf=0.0)
229
+ if top_p > 0.0:
230
+ sorted_probs, indices = probs.sort(dim=-1, descending=True)
231
+ drop = (sorted_probs.cumsum(dim=-1) - sorted_probs) > top_p
232
+ sorted_probs = sorted_probs.masked_fill(drop, 0.0)
233
+ sorted_probs = sorted_probs / sorted_probs.sum(dim=-1, keepdim=True).clamp(min=1e-12)
234
+ probs = torch.zeros_like(probs).scatter_(-1, indices, sorted_probs)
235
+ elif min_p > 0.0:
236
+ threshold = min_p * probs.max(dim=-1, keepdim=True).values
237
+ probs = probs.masked_fill(probs < threshold, 0.0)
238
+ probs = probs / probs.sum(dim=-1, keepdim=True).clamp(min=1e-12)
239
+ # degenerate rows (all-zero after truncation/nan handling) fall back to
240
+ # uniform so multinomial never sees an invalid distribution
241
+ zero_rows = probs.sum(dim=-1, keepdim=True) <= 0
242
+ if zero_rows.any():
243
+ probs = probs + zero_rows.to(probs.dtype)
244
+ probs = probs / probs.sum(dim=-1, keepdim=True).clamp(min=1e-12)
245
+ return torch.multinomial(probs, 1).squeeze(-1), probs
246
+
247
+
248
+ def refine_tail(model, x, prompt_len, gen_len, im_end_id, eos_id, mask_id,
249
+ rainbow_ids, ban_ids, steps, frac, temperature,
250
+ repetition_penalty, device, top_p=0.0, min_p=0.0,
251
+ im_end_bias=0.0, im_end_bias_t=0.3):
252
+ """PURE/TOLERATOR-style post-hoc refinement: when a response never
253
+ committed <|im_end|>, re-mask the tail (keeping the head fixed) and
254
+ re-denoise it with a short chain. Training-free; converts leftover
255
+ compute into coherence instead of letting committed junk stick."""
256
+ cut = prompt_len + int(gen_len * (1.0 - frac))
257
+ x[0, cut:] = mask_id
258
+ tail_len = gen_len - (cut - prompt_len)
259
+ for i in range(steps):
260
+ n_masked = (x[0, cut:] == mask_id).sum().item()
261
+ if n_masked == 0:
262
+ break
263
+ if i == steps - 1:
264
+ n_unmask = n_masked
265
+ else:
266
+ n_unmask = max(int((cumulative_unmask_frac(i + 1, steps)
267
+ - cumulative_unmask_frac(i, steps)) * tail_len + 0.5), 1)
268
+ t_val = torch.full((1,), 1.0 - cumulative_unmask_frac(i, steps), device=device)
269
+ logits = model(x, t_val).float()
270
+ logits = torch.nan_to_num(logits, nan=0.0, posinf=50.0, neginf=-50.0)
271
+ logits[:, :, mask_id] = -1e9
272
+ logits[:, :, rainbow_ids] = -1e9
273
+ if ban_ids:
274
+ logits[:, :, ban_ids] = -1e9
275
+ t_now = 1.0 - cumulative_unmask_frac(i, steps)
276
+ if im_end_bias != 0.0 and t_now < im_end_bias_t:
277
+ logits[:, :, im_end_id] = logits[:, :, im_end_id] + im_end_bias
278
+ if repetition_penalty != 1.0:
279
+ committed = x[0, prompt_len:]
280
+ committed = committed[committed != mask_id]
281
+ if committed.numel() > 0:
282
+ for tok in committed.unique():
283
+ ti = tok.item()
284
+ logits[0, :, ti] = torch.where(
285
+ logits[0, :, ti] < 0,
286
+ logits[0, :, ti] * repetition_penalty,
287
+ logits[0, :, ti] / repetition_penalty)
288
+ mask_positions = x == mask_id
289
+ sampled, _ = sample_masked(logits, mask_positions, temperature, top_p, min_p)
290
+ mask_flat = mask_positions.nonzero(as_tuple=False)
291
+ if n_unmask < mask_positions.sum():
292
+ for idx, tok in zip(mask_flat[:n_unmask], sampled[:n_unmask]):
293
+ x[idx[0], idx[1]] = tok
294
+ else:
295
+ x[mask_positions] = sampled
296
+ if (x[0, prompt_len:] == im_end_id).any() or (x[0, prompt_len:] == eos_id).any():
297
+ break
298
+ return x
299
+
300
+
301
+ def smart_remask_pass(model, x, prompt_len, gen_len, conf, im_end_id, eos_id,
302
+ mask_id, rainbow_ids, ban_ids, steps, thresh, max_iters,
303
+ temperature, repetition_penalty, device, top_p=0.0,
304
+ min_p=0.0, im_end_bias=0.0, im_end_bias_t=0.3):
305
+ """Confidence-gated re-denoising (PURE-style smart remasking).
306
+
307
+ The blind --refine tail remask wastes budget on tokens the model already
308
+ committed with high confidence. Here, re-mask exactly the tokens whose
309
+ top-1 commit probability fell below `thresh` (the junk-prone ones, often
310
+ the budget-tail fillers) and re-denoise them with the head fixed. Runs
311
+ even when im_end committed: it also cleans low-confidence junk sitting
312
+ before the terminator. Repeats up to max_iters rounds and stops early
313
+ once the terminator commits or nothing is below the bar."""
314
+ lo = prompt_len
315
+ hi = prompt_len + gen_len
316
+ for _ in range(max_iters):
317
+ resp = x[0, lo:hi]
318
+ term = (resp == im_end_id) | (resp == eos_id)
319
+ if term.any():
320
+ # never touch the terminator or anything past it
321
+ hi = lo + term.nonzero(as_tuple=True)[0][0].item()
322
+ if hi <= lo:
323
+ break
324
+ low = (conf[0, lo:hi] < thresh).nonzero(as_tuple=True)[0]
325
+ if low.numel() == 0:
326
+ break
327
+ n_remask = low.numel()
328
+ x[0, lo + low] = mask_id
329
+ conf[0, lo + low] = 1.0 # re-commits below the bar get caught again
330
+ for i in range(steps):
331
+ n_masked = (x[0, lo:hi] == mask_id).sum().item()
332
+ if n_masked == 0:
333
+ break
334
+ if i == steps - 1:
335
+ n_unmask = n_masked
336
+ else:
337
+ n_unmask = max(int((cumulative_unmask_frac(i + 1, steps)
338
+ - cumulative_unmask_frac(i, steps))
339
+ * n_remask + 0.5), 1)
340
+ n_unmask = min(n_unmask, n_masked)
341
+ t_now = 1.0 - cumulative_unmask_frac(i, steps)
342
+ t_val = torch.full((1,), t_now, device=device)
343
+ logits = model(x, t_val).float()
344
+ logits = torch.nan_to_num(logits, nan=0.0, posinf=50.0, neginf=-50.0)
345
+ logits[:, :, mask_id] = -1e9
346
+ logits[:, :, rainbow_ids] = -1e9
347
+ if ban_ids:
348
+ logits[:, :, ban_ids] = -1e9
349
+ if im_end_bias != 0.0 and t_now < im_end_bias_t:
350
+ logits[:, :, im_end_id] = logits[:, :, im_end_id] + im_end_bias
351
+ if repetition_penalty != 1.0:
352
+ committed = x[0, prompt_len:]
353
+ committed = committed[committed != mask_id]
354
+ if committed.numel() > 0:
355
+ for tok in committed.unique():
356
+ ti = tok.item()
357
+ logits[0, :, ti] = torch.where(
358
+ logits[0, :, ti] < 0,
359
+ logits[0, :, ti] * repetition_penalty,
360
+ logits[0, :, ti] / repetition_penalty)
361
+ mask_positions = x == mask_id
362
+ sampled, probs = sample_masked(logits, mask_positions,
363
+ temperature, top_p, min_p)
364
+ p_max = probs.max(dim=-1).values
365
+ mask_flat = mask_positions.nonzero(as_tuple=False)
366
+ n_fill = min(n_unmask, mask_flat.shape[0])
367
+ if n_fill:
368
+ idxs = mask_flat[:n_fill]
369
+ x[idxs[:, 0], idxs[:, 1]] = sampled[:n_fill]
370
+ conf[idxs[:, 0], idxs[:, 1]] = p_max[:n_fill]
371
+ if (x[0, lo:hi] == im_end_id).any() or \
372
+ (x[0, lo:hi] == eos_id).any():
373
+ break
374
+ if (x[0, lo:hi] == im_end_id).any() or (x[0, lo:hi] == eos_id).any():
375
+ break
376
+ return x
377
+
378
+
379
+ def cut_response(tokens, tokenizer):
380
+ """Cut at <|im_end|> / eos; drop rainbow and pad tokens."""
381
+ im_end_id = tokenizer.convert_tokens_to_ids(IM_END)
382
+ eos_id = tokenizer.eos_token_id
383
+ rainbow_ids = {tokenizer.convert_tokens_to_ids(t) for t in RAINBOW_TOKENS}
384
+ out = []
385
+ for t in tokens:
386
+ if t == im_end_id or t == eos_id:
387
+ break
388
+ if t in rainbow_ids or t == tokenizer.pad_token_id:
389
+ continue
390
+ out.append(t)
391
+ return out
392
+
393
+
394
+ def invalid_utf8_ids(tokenizer):
395
+ """Ids whose decode is *only* U+FFFD. Byte-fallback tokens that merely
396
+ contain a replacement char when decoded alone stay; those are how Qwen
397
+ builds rare unicode."""
398
+ ban = []
399
+ for i in range(len(tokenizer)):
400
+ s = tokenizer.decode([i], skip_special_tokens=True)
401
+ if s and all(c == "\uFFFD" for c in s):
402
+ ban.append(i)
403
+ return ban
404
+
405
+
406
+ def trim_messages(messages, tokenizer, max_context, max_new_tokens):
407
+ """Drop oldest non-system turns until prompt + gen budget fits."""
408
+ budget = max(32, max_context - max_new_tokens)
409
+ kept = list(messages)
410
+ while kept:
411
+ prompt = format_messages(kept) + f"\n{IM_START}assistant\n"
412
+ n = len(tokenizer.encode(prompt, add_special_tokens=False))
413
+ if n <= budget:
414
+ return kept
415
+ drop_at = next((i for i, m in enumerate(kept) if m["role"] != "system"), None)
416
+ if drop_at is None:
417
+ return kept
418
+ del kept[drop_at]
419
+ return kept
420
+
421
+
422
+ def run_turn(model, tokenizer, messages, args, device):
423
+ max_ctx = getattr(args, "max_context", 4096)
424
+ cap = min(getattr(model.config, "max_position_embeddings", 40960), max_ctx)
425
+ messages = trim_messages(messages, tokenizer, cap, args.max_new_tokens)
426
+ prompt = format_messages(messages) + f"\n{IM_START}assistant\n"
427
+ prompt_ids = torch.tensor([tokenizer.encode(prompt, add_special_tokens=False)],
428
+ device=device)
429
+ for attempt in range(3):
430
+ x = generate_response(model, tokenizer, prompt_ids, args.max_new_tokens,
431
+ args.num_steps,
432
+ args.temperature * (1 + 0.15 * attempt),
433
+ args.repetition_penalty, device, watch=args.watch,
434
+ cfg_scale=args.cfg_scale,
435
+ ban_ids=getattr(args, "bad_token_ids", None),
436
+ top_p=args.top_p, min_p=args.min_p,
437
+ refine=args.refine, refine_frac=args.refine_frac,
438
+ refine_steps=args.refine_steps,
439
+ im_end_bias=args.im_end_bias,
440
+ im_end_bias_t=args.im_end_bias_t,
441
+ smart_remask=args.smart_remask,
442
+ smart_remask_thresh=args.smart_remask_thresh,
443
+ smart_remask_iters=args.smart_remask_iters)
444
+ text = tokenizer.decode(cut_response(x[0, prompt_ids.shape[1]:].tolist(),
445
+ tokenizer),
446
+ skip_special_tokens=True).strip()
447
+ if text:
448
+ return text
449
+ return "(empty response)"
450
+
451
+
452
+ def main():
453
+ p = argparse.ArgumentParser(description="MetaDiffusion-600M chat")
454
+ p.add_argument("--model-path", required=True)
455
+ p.add_argument("--tokenizer", default=None, help="Tokenizer dir (needed for .pt checkpoints)")
456
+ p.add_argument("--prompt", default=None)
457
+ p.add_argument("--system", default="You are a helpful assistant.")
458
+ p.add_argument("--max-new-tokens", type=int, default=96)
459
+ p.add_argument("--num-steps", type=int, default=128)
460
+ p.add_argument("--temperature", type=float, default=0.7)
461
+ p.add_argument("--repetition-penalty", type=float, default=1.5)
462
+ p.add_argument("--cfg-scale", type=float, default=0.0,
463
+ help="Classifier-free guidance scale (0 = off; try 0.5-1.2). "
464
+ "Unconditional branch masks the prompt too.")
465
+ p.add_argument("--top-p", type=float, default=0.0,
466
+ help="Nucleus sampling: keep tokens covering this mass (0=off; "
467
+ "use EITHER --top-p or --min-p, not both)")
468
+ p.add_argument("--min-p", type=float, default=0.1,
469
+ help="Min-P truncation: keep tokens >= min_p x top-token prob "
470
+ "(0=off; 0.05-0.1 recommended). Truncates the junk tail.")
471
+ p.add_argument("--refine", action="store_true",
472
+ help="Post-hoc tail refinement: if im_end never commits, re-mask "
473
+ "the tail and re-denoise it (PURE/TOLERATOR-style)")
474
+ p.add_argument("--refine-frac", type=float, default=0.3,
475
+ help="Fraction of the response tail to re-denoise (--refine)")
476
+ p.add_argument("--refine-steps", type=int, default=16,
477
+ help="Denoising steps for the refinement pass")
478
+ p.add_argument("--smart-remask", action="store_true",
479
+ help="Confidence-gated remasking (PURE-style): re-mask only "
480
+ "the tokens committed with low top-1 probability and "
481
+ "re-denoise them with the head fixed. Runs even when "
482
+ "im_end committed (cleans pre-terminator junk); takes "
483
+ "precedence over --refine.")
484
+ p.add_argument("--smart-remask-thresh", type=float, default=0.5,
485
+ help="Commit-confidence bar (top-1 token prob at commit "
486
+ "time); tokens below it are re-masked (--smart-remask)")
487
+ p.add_argument("--smart-remask-iters", type=int, default=2,
488
+ help="Max refinement rounds; stops early when the "
489
+ "terminator commits or nothing is below the bar")
490
+ p.add_argument("--im-end-bias", type=float, default=0.0,
491
+ help="Logit bonus on <|im_end|> when t < --im-end-bias-t "
492
+ "(pragmatic terminator nudge; try 1.5-3.0)")
493
+ p.add_argument("--im-end-bias-t", type=float, default=0.3,
494
+ help="t threshold below which --im-end-bias applies")
495
+ p.add_argument("--device", default="cuda")
496
+ p.add_argument("--max-context", type=int, default=4096,
497
+ help="Trim multi-turn history so prompt+gen fits this many tokens")
498
+ p.add_argument("--watch", action="store_true")
499
+ args = p.parse_args()
500
+
501
+ device = torch.device(args.device if torch.cuda.is_available() else "cpu")
502
+ print(f"[*] Loading model from {args.model_path}")
503
+ model = load_model(args.model_path, device)
504
+
505
+ tok_path = args.tokenizer
506
+ if tok_path is None:
507
+ model_path = Path(args.model_path)
508
+ if model_path.is_dir():
509
+ cand = model_path / "tokenizer"
510
+ if not cand.exists() and (model_path / "tokenizer.json").exists():
511
+ cand = model_path
512
+ tok_path = str(cand)
513
+ if not tok_path or not Path(tok_path).exists():
514
+ raise SystemExit("No tokenizer found; pass --tokenizer (data/tokenizer)")
515
+ tokenizer = ensure_special_tokens(AutoTokenizer.from_pretrained(str(tok_path)))
516
+ print(f"[*] Tokenizer: {tok_path} (vocab {len(tokenizer)})")
517
+ # hard-ban vocab entries that cannot decode to valid UTF-8 (partial-byte
518
+ # tokens): they surface as "�" garbage and are never legitimate output
519
+ args.bad_token_ids = invalid_utf8_ids(tokenizer)
520
+ if args.bad_token_ids:
521
+ print(f"[*] Banning {len(args.bad_token_ids)} standalone-U+FFFD tokens")
522
+
523
+ if args.prompt:
524
+ text = run_turn(model, tokenizer, [{"role": "user", "content": args.prompt}],
525
+ args, device)
526
+ print(f"\nUser: {args.prompt}\nAssistant: {text}\n")
527
+ return
528
+
529
+ print("\nMetaDiffusion-600M chat. Type 'exit' to leave.\n")
530
+ messages = [{"role": "system", "content": args.system}]
531
+ while True:
532
+ try:
533
+ user_input = input("You: ").strip()
534
+ except (EOFError, KeyboardInterrupt):
535
+ print()
536
+ break
537
+ if user_input.lower() in ("exit", "quit"):
538
+ break
539
+ if not user_input:
540
+ continue
541
+ messages.append({"role": "user", "content": user_input})
542
+ text = run_turn(model, tokenizer, messages, args, device)
543
+ print(f"Assistant: {text}\n")
544
+ messages.append({"role": "assistant", "content": text})
545
+
546
+
547
+ if __name__ == "__main__":
548
+ main()
convert.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ convert.py: convert Qwen3-0.6B (post-trained instruct) into a
4
+ MetaDiffusion-600M initialization checkpoint.
5
+
6
+ Usage:
7
+ python convert.py \
8
+ --source Qwen/Qwen3-0.6B \
9
+ --output init/metadiffusion-600M-instruct.pt \
10
+ --tokenizer-out data/tokenizer \
11
+ --device cpu
12
+ """
13
+
14
+ import argparse
15
+ import json
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ import torch
20
+ from transformers import AutoConfig, AutoModelForCausalLM, AutoTokenizer
21
+
22
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
23
+ from model import MetaDiffusionConfig, MetaDiffusionLM # noqa: E402
24
+
25
+ RAINBOW_TOKENS = [f"<|r{i}|>" for i in range(1, 8)]
26
+ NUM_NEW_TOKENS = 1 + len(RAINBOW_TOKENS) # [MASK] + rainbow
27
+
28
+ PLAIN_CHAT_TEMPLATE = (
29
+ "{% for message in messages %}"
30
+ "{{ '<|im_start|>' + message['role'] + '\\n' + message['content'] + '<|im_end|>\\n' }}"
31
+ "{% endfor %}"
32
+ )
33
+
34
+
35
+ def build_config(src_config, base_vocab: int) -> MetaDiffusionConfig:
36
+ """base_vocab = real tokenizer length (config.vocab_size may be TP-padded,
37
+ e.g. Qwen3: tokenizer 151,669 vs config 151,936)."""
38
+ c = src_config
39
+ config_vocab = int(getattr(c, "vocab_size", 0))
40
+ if base_vocab > config_vocab:
41
+ raise RuntimeError(
42
+ f"tokenizer vocab {base_vocab} exceeds model vocab {config_vocab}")
43
+ head_dim = int(getattr(c, "head_dim", 0) or (c.hidden_size // c.num_attention_heads))
44
+ cfg = MetaDiffusionConfig(
45
+ hidden_size=int(c.hidden_size),
46
+ intermediate_size=int(c.intermediate_size),
47
+ num_hidden_layers=int(c.num_hidden_layers),
48
+ num_attention_heads=int(c.num_attention_heads),
49
+ num_key_value_heads=int(c.num_key_value_heads),
50
+ head_dim=head_dim,
51
+ vocab_size=base_vocab,
52
+ mask_vocab_size=base_vocab + NUM_NEW_TOKENS,
53
+ mask_token_id=base_vocab,
54
+ pad_token_id=int(getattr(c, "pad_token_id", None) or 151643),
55
+ max_position_embeddings=int(c.max_position_embeddings),
56
+ rope_theta=float(getattr(c, "rope_theta", None) or 1000000.0),
57
+ rms_norm_eps=float(getattr(c, "rms_norm_eps", 1e-6)),
58
+ hidden_act=str(getattr(c, "hidden_act", "silu")),
59
+ qk_norm=bool(getattr(c, "qk_norm", True)),
60
+ timestep_emb_hidden=int(c.hidden_size),
61
+ tie_word_embeddings=False,
62
+ )
63
+ return cfg
64
+
65
+
66
+ def convert(source: str, output: str, tokenizer_out: str, device: str = "cpu"):
67
+ logger = print
68
+ logger(f"[*] Loading AR instruct model: {source}")
69
+
70
+ src_config = AutoConfig.from_pretrained(source)
71
+ tokenizer = AutoTokenizer.from_pretrained(source)
72
+ base_vocab = len(tokenizer)
73
+ if base_vocab != int(getattr(src_config, "vocab_size", 0)):
74
+ logger(f"[*] Config vocab is TP-padded ({src_config.vocab_size}); "
75
+ f"using real tokenizer vocab {base_vocab}")
76
+ cfg = build_config(src_config, base_vocab)
77
+ h = cfg.hidden_size
78
+ logger(f"[*] Source: {cfg.num_hidden_layers}L x {h}W, vocab={cfg.vocab_size}, "
79
+ f"heads={cfg.num_attention_heads}/{cfg.num_key_value_heads}, "
80
+ f"head_dim={cfg.head_dim}, qk_norm={cfg.qk_norm}")
81
+
82
+ ar_model = AutoModelForCausalLM.from_pretrained(source, torch_dtype=torch.float32, device_map=device)
83
+ state = ar_model.state_dict()
84
+ del ar_model
85
+ torch.cuda.empty_cache() if torch.cuda.is_available() else None
86
+
87
+ # --- Copy weights, strip the "model." prefix ---
88
+ new_state = {}
89
+ for key, value in state.items():
90
+ new_key = key.replace("model.", "", 1) if key.startswith("model.") else key
91
+ new_state[new_key] = value.clone()
92
+
93
+ embed = new_state["embed_tokens.weight"] # (V, H)
94
+
95
+ # --- Trim untrained TP-padding rows, then append [MASK] + rainbow ---
96
+ if embed.shape[0] > base_vocab:
97
+ logger(f"[*] Trimming {embed.shape[0] - base_vocab} untrained padding rows "
98
+ f"from embeddings / lm_head")
99
+ embed = embed[:base_vocab]
100
+ elif embed.shape[0] < base_vocab:
101
+ raise RuntimeError(f"embedding rows {embed.shape[0]} < tokenizer vocab {base_vocab}")
102
+
103
+ mean_row = embed.mean(dim=0, keepdim=True) # (1, H)
104
+ new_rows = mean_row.expand(NUM_NEW_TOKENS, -1).clone() # (8, H)
105
+ new_state["embed_tokens.weight"] = torch.cat([embed, new_rows], dim=0)
106
+
107
+ # --- Untied lm_head: trim + extend identically ---
108
+ if "lm_head.weight" in new_state:
109
+ head = new_state["lm_head.weight"]
110
+ head = head[:base_vocab] if head.shape[0] > base_vocab else head
111
+ if head.shape[0] != base_vocab:
112
+ raise RuntimeError(f"lm_head rows {head.shape[0]} != tokenizer vocab {base_vocab}")
113
+ new_state["lm_head.weight"] = torch.cat([head, new_rows], dim=0)
114
+ else:
115
+ new_state["lm_head.weight"] = torch.cat([embed, new_rows], dim=0)
116
+ logger(f"[*] embed_tokens / lm_head = {cfg.mask_vocab_size} rows "
117
+ f"(mask={cfg.mask_token_id}, rainbow={cfg.mask_token_id + 1}..{cfg.mask_vocab_size - 1})")
118
+
119
+ # --- New diffusion modules: adaLN modulation zero-init (identity at
120
+ # step 0). The timestep embedding MLP keeps DEFAULT random init: with
121
+ # both zero, zero output through the zero gate zeroes every t-path
122
+ # gradient (deadlock: the model never learns noise conditioning). ---
123
+ for i in range(cfg.num_hidden_layers):
124
+ new_state[f"layers.{i}.timestep_modulation.proj.weight"] = torch.zeros(2 * h, h)
125
+ new_state[f"layers.{i}.timestep_modulation.proj.bias"] = torch.zeros(2 * h)
126
+
127
+ # --- Verify load. timestep_emb MLP stays at default (random) init so
128
+ # the t-path is not deadlocked (zero MLP through a zero gate). Extra AR
129
+ # keys (rotary inv_freq) are dropped. ---
130
+ model = MetaDiffusionLM(cfg)
131
+ missing, unexpected = model.load_state_dict(new_state, strict=False)
132
+ missing = [k for k in missing if not k.startswith("timestep_emb.")]
133
+ if missing:
134
+ raise RuntimeError(f"unexpected missing keys after convert: {missing}")
135
+ if unexpected:
136
+ logger(f"[*] Dropping {len(unexpected)} unexpected AR keys "
137
+ f"(e.g. {unexpected[0]})")
138
+ n_params = sum(p.numel() for p in model.parameters())
139
+ logger(f"[*] Verified load into MetaDiffusionLM: {n_params/1e6:.1f}M params "
140
+ f"(AR transfer + {NUM_NEW_TOKENS} new token rows + timestep modules)")
141
+
142
+ # --- Save checkpoint (full state, including default-init t-MLP) ---
143
+ out = Path(output)
144
+ out.parent.mkdir(parents=True, exist_ok=True)
145
+ ckpt = {
146
+ "config": cfg.__dict__,
147
+ "model_state_dict": model.state_dict(),
148
+ "metadata": {
149
+ "source_model": source,
150
+ "conversion_script": "convert.py",
151
+ "mask_token_id": cfg.mask_token_id,
152
+ "rainbow_token_ids": list(range(cfg.mask_token_id + 1, cfg.mask_vocab_size)),
153
+ "num_new_tokens": NUM_NEW_TOKENS,
154
+ "transferred": True,
155
+ },
156
+ }
157
+ torch.save(ckpt, str(out))
158
+ logger(f"[*] Saved checkpoint: {out}")
159
+
160
+ sidecar = out.with_suffix(".json")
161
+ with open(sidecar, "w") as f:
162
+ json.dump(cfg.__dict__, f, indent=2)
163
+ logger(f"[*] Saved config sidecar: {sidecar}")
164
+
165
+ # --- Tokenizer: add [MASK] + rainbow at the expected ids ---
166
+ tokenizer.add_special_tokens({"additional_special_tokens": ["[MASK]"] + RAINBOW_TOKENS})
167
+ tokenizer.chat_template = PLAIN_CHAT_TEMPLATE
168
+ mask_id = tokenizer.convert_tokens_to_ids("[MASK]")
169
+ assert mask_id == cfg.mask_token_id, f"[MASK] landed at {mask_id}, expected {cfg.mask_token_id}"
170
+ assert len(tokenizer) == cfg.mask_vocab_size, f"tokenizer vocab {len(tokenizer)} != {cfg.mask_vocab_size}"
171
+ tok_dir = Path(tokenizer_out)
172
+ tok_dir.mkdir(parents=True, exist_ok=True)
173
+ tokenizer.save_pretrained(str(tok_dir))
174
+ logger(f"[*] Saved tokenizer ({len(tokenizer)} tokens, [MASK]={mask_id}): {tok_dir}")
175
+
176
+ logger("[*] Done. Next: python prepare_data.py && python train.py --init-checkpoint "
177
+ f"{output}")
178
+
179
+
180
+ def main():
181
+ parser = argparse.ArgumentParser(description="Convert Qwen3-0.6B (instruct) to MetaDiffusion-600M init")
182
+ parser.add_argument("--source", default="Qwen/Qwen3-0.6B")
183
+ parser.add_argument("--output", default="init/metadiffusion-600M-instruct.pt")
184
+ parser.add_argument("--tokenizer-out", default="data/tokenizer")
185
+ parser.add_argument("--device", default="cpu")
186
+ args = parser.parse_args()
187
+ convert(args.source, args.output, args.tokenizer_out, args.device)
188
+
189
+
190
+ if __name__ == "__main__":
191
+ main()
eval.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """eval.py: lm-evaluation-harness wrapper for MetaDiffusion-600M.
3
+
4
+ Scoring: single-step diffusion (mask the continuation, forward once at
5
+ t=1.0, log-prob of the true tokens at masked positions).
6
+
7
+ Usage (needs lm-eval in the environment):
8
+ python eval.py --checkpoint checkpoints/step_30000.pt \
9
+ --tasks hellaswag,arc_easy,arc_challenge,piqa \
10
+ --tokenizer data/tokenizer
11
+ """
12
+
13
+ import argparse
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ import torch
18
+ import torch.nn.functional as F
19
+ from transformers import AutoTokenizer
20
+
21
+ from lm_eval.api.model import LM
22
+ from lm_eval.api.registry import register_model
23
+
24
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
25
+ from model import MetaDiffusionLM, MetaDiffusionConfig # noqa: E402
26
+
27
+ import logging
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ @register_model("metadiffusion_600m")
32
+ class MetaDiffusion600MWrapper(LM):
33
+ def __init__(self, checkpoint: str, dtype: str = "float32",
34
+ device: str = "cuda", tokenizer_name: str = "Qwen/Qwen3-0.6B",
35
+ max_length: int = 1024, batch_size: int = 4, **kwargs):
36
+ super().__init__()
37
+ self._device = torch.device(device)
38
+ self._max_length = max_length
39
+ self._batch_size = batch_size
40
+ dtype_map = {"float32": torch.float32, "float16": torch.float16,
41
+ "bfloat16": torch.bfloat16}
42
+ self._dtype = dtype_map.get(dtype, torch.float32)
43
+
44
+ logger.info(f"Loading checkpoint: {checkpoint}")
45
+ ckpt = torch.load(checkpoint, map_location="cpu", weights_only=False)
46
+ config = MetaDiffusionConfig(
47
+ **{k: v for k, v in ckpt.get("config", ckpt).items()
48
+ if k in MetaDiffusionConfig.__dataclass_fields__})
49
+ self.model = MetaDiffusionLM(config)
50
+ sd = ckpt.get("model_state_dict", ckpt)
51
+ sd = {k.replace("_orig_mod.", "", 1) if isinstance(k, str) and k.startswith("_orig_mod.") else k: v
52
+ for k, v in sd.items()}
53
+ self.model.load_state_dict(sd, strict=True)
54
+ self.model = self.model.to(device=self._device, dtype=self._dtype).eval()
55
+
56
+ self._mask_token_id = config.mask_token_id
57
+ self._pad_token_id = config.pad_token_id
58
+ self._tokenizer = AutoTokenizer.from_pretrained(tokenizer_name)
59
+ if self._tokenizer.pad_token_id is None:
60
+ self._tokenizer.pad_token_id = self._tokenizer.eos_token_id
61
+ self._eos_token_id = self._tokenizer.eos_token_id
62
+ logger.info(f"Loaded {config.num_hidden_layers}L x {config.hidden_size}W, "
63
+ f"vocab={config.mask_vocab_size}, {self._dtype}")
64
+
65
+ def _score_pair(self, context_tokens, continuation_tokens):
66
+ full_ids = context_tokens + continuation_tokens
67
+ if len(full_ids) > self._max_length:
68
+ excess = len(full_ids) - self._max_length
69
+ context_tokens = context_tokens[excess:] if len(context_tokens) > excess else []
70
+ full_ids = full_ids[excess:]
71
+ ctx_len = len(context_tokens)
72
+ seq_len = len(full_ids)
73
+
74
+ input_ids = torch.tensor([full_ids], device=self._device)
75
+ for i in range(ctx_len, seq_len):
76
+ input_ids[0, i] = self._mask_token_id
77
+ t = torch.tensor([1.0], device=self._device)
78
+
79
+ with torch.no_grad():
80
+ logits = self.model(input_ids, t)
81
+ log_probs = F.log_softmax(logits[0], dim=-1)
82
+
83
+ total = 0.0
84
+ is_greedy = True
85
+ for pos in range(ctx_len, seq_len):
86
+ true_token = full_ids[pos]
87
+ total += log_probs[pos, true_token].item()
88
+ if log_probs[pos].argmax().item() != true_token:
89
+ is_greedy = False
90
+ return total, is_greedy
91
+
92
+ def loglikelihood(self, requests, disable_tqdm=False):
93
+ results = []
94
+ for request in requests:
95
+ context, continuation = request.arguments
96
+ ctx = self._tokenizer.encode(context, add_special_tokens=False)
97
+ cont = self._tokenizer.encode(continuation, add_special_tokens=False)
98
+ if not cont:
99
+ cont = [self._eos_token_id]
100
+ results.append(self._score_pair(ctx, cont))
101
+ return results
102
+
103
+ def loglikelihood_rolling(self, requests, disable_tqdm=False):
104
+ results = []
105
+ for request in requests:
106
+ tokens = self._tokenizer.encode(request.arguments[0], add_special_tokens=False)
107
+ if len(tokens) <= 1:
108
+ results.append(0.0)
109
+ continue
110
+ lp, _ = self._score_pair(tokens[:1], tokens[1:])
111
+ results.append(lp)
112
+ return results
113
+
114
+ def generate_until(self, requests, disable_tqdm=False):
115
+ from chat import generate_response
116
+ results = []
117
+ for request in requests:
118
+ prompt = request.arguments[0]
119
+ prompt_ids = torch.tensor(
120
+ [self._tokenizer.encode(prompt, add_special_tokens=False)],
121
+ device=self._device)
122
+ x = generate_response(self.model, self._tokenizer, prompt_ids,
123
+ gen_len=128, num_steps=32, temperature=0.2,
124
+ repetition_penalty=1.2,
125
+ device=self._device, stop_on_end=True,
126
+ min_p=0.1)
127
+ out = x[0, prompt_ids.shape[1]:].cpu().tolist()
128
+ results.append(self._tokenizer.decode(out, skip_special_tokens=True))
129
+ return results
130
+
131
+
132
+ def main():
133
+ p = argparse.ArgumentParser(description="Evaluate MetaDiffusion-600M with lm-eval")
134
+ p.add_argument("--checkpoint", required=True)
135
+ p.add_argument("--tasks", default="hellaswag,arc_easy,arc_challenge,piqa")
136
+ p.add_argument("--tokenizer", default="data/tokenizer")
137
+ p.add_argument("--device", default="cuda:0")
138
+ p.add_argument("--dtype", default="bfloat16")
139
+ p.add_argument("--limit", default=None, help="Sample limit (smoke test)")
140
+ p.add_argument("--output", default=None, help="Result JSON path")
141
+ args = p.parse_args()
142
+
143
+ from lm_eval import simple_evaluate
144
+ results = simple_evaluate(
145
+ model="metadiffusion_600m",
146
+ model_args=f"checkpoint={args.checkpoint},dtype={args.dtype},"
147
+ f"device={args.device},tokenizer_name={args.tokenizer}",
148
+ tasks=args.tasks.split(","),
149
+ limit=float(args.limit) if args.limit is not None else None,
150
+ )
151
+ for task, res in results["results"].items():
152
+ acc = res.get("acc_norm,none") or res.get("acc,none")
153
+ print(f"{task}: {acc:.4f}" if acc is not None else f"{task}: {res}")
154
+ if args.output:
155
+ import json
156
+ with open(args.output, "w") as f:
157
+ json.dump(results["results"], f, indent=2)
158
+ print(f"Wrote {args.output}")
159
+
160
+
161
+ if __name__ == "__main__":
162
+ main()
export_hf.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """export_hf.py: export a MetaDiffusion-600M checkpoint to a release dir.
3
+
4
+ Output:
5
+ model.safetensors fp16, "model."-prefixed keys
6
+ config.json no dtype key; vocab fields = weight rows;
7
+ auto_map -> hf_modeling.py custom classes
8
+ generation_config.json denoising defaults
9
+ tokenizer/ Qwen3 tokenizer + [MASK] + rainbow tokens
10
+ hf_modeling.py standalone modeling (AutoModelForCausalLM,
11
+ GenerationMixin with iterative denoising)
12
+ scripts/ self-contained pipeline copy
13
+
14
+ Usage:
15
+ python export_hf.py --checkpoint checkpoints/step_30000.pt \
16
+ --tokenizer data/tokenizer --output MetaDiffusion-600M-Instruct-v1
17
+
18
+ Then load with:
19
+ AutoModelForCausalLM.from_pretrained(dir, trust_remote_code=True)
20
+ """
21
+
22
+ import argparse
23
+ import json
24
+ import shutil
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ import torch
29
+ from safetensors.torch import save_file
30
+ from transformers import AutoTokenizer
31
+
32
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
33
+ from model import MetaDiffusionConfig # noqa: E402
34
+
35
+ GENERATION_CONFIG = {
36
+ "temperature": 0.7,
37
+ "repetition_penalty": 1.5,
38
+ "num_steps": 128,
39
+ "max_new_tokens": 96,
40
+ "top_p": 0.0, # truncation sampling: min-p is the release default
41
+ "min_p": 0.1,
42
+ "im_end_bias": 2.0,
43
+ "im_end_bias_t": 0.3,
44
+ "do_sample": True,
45
+ "transformers_version": "4.49.0",
46
+ }
47
+
48
+ PLAIN_CHAT_TEMPLATE = (
49
+ "{% for message in messages %}{{ '<|im_start|>' + message['role'] }}\n"
50
+ "{{ message['content'] }}<|im_end|>\n"
51
+ "{% endfor %}{% if add_generation_prompt %}<|im_start|>assistant\n"
52
+ "{% endif %}"
53
+ )
54
+
55
+
56
+ def remap_state_dict(state_dict, dtype="bf16"):
57
+ cast = {"bf16": torch.bfloat16, "fp16": torch.float16,
58
+ "fp32": torch.float32}[dtype]
59
+ new_dict = {}
60
+ for key, tensor in state_dict.items():
61
+ key = key.replace("_orig_mod.", "", 1) if key.startswith("_orig_mod.") else key
62
+ new_dict["model." + key] = tensor.to(cast)
63
+ return new_dict
64
+
65
+
66
+ def package_scripts(out):
67
+ src = Path(__file__).resolve().parent
68
+ scripts_dir = out / "scripts"
69
+ scripts_dir.mkdir(parents=True, exist_ok=True)
70
+ for name in ["model.py", "convert.py", "prepare_data.py", "train.py",
71
+ "chat.py", "eval.py", "export_hf.py", "hf_modeling.py"]:
72
+ cand = src / name
73
+ if cand.exists():
74
+ shutil.copy2(cand, scripts_dir / name)
75
+ req = scripts_dir / "requirements.txt"
76
+ if not req.exists():
77
+ req.write_text("torch>=2.2\ntransformers>=4.49\nsafetensors>=0.4\n"
78
+ "datasets>=2.18\nnumpy>=1.26\n")
79
+ print(f"[*] Packaged scripts -> {scripts_dir}")
80
+
81
+
82
+ def export(checkpoint_path, tokenizer_dir, output_dir, dtype="bf16"):
83
+ out = Path(output_dir)
84
+ out.mkdir(parents=True, exist_ok=True)
85
+
86
+ print(f"[*] Loading checkpoint {checkpoint_path}")
87
+ ckpt = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
88
+ config = MetaDiffusionConfig(
89
+ **{k: v for k, v in ckpt["config"].items()
90
+ if k in MetaDiffusionConfig.__dataclass_fields__})
91
+
92
+ print("[*] Remapping state dict...")
93
+ state_dict = remap_state_dict(ckpt["model_state_dict"], dtype=dtype)
94
+ save_file(state_dict, out / "model.safetensors")
95
+ print(f"[*] Saved {len(state_dict)} tensors -> {out / 'model.safetensors'} "
96
+ f"({dtype})")
97
+
98
+ # Vocab fields must match the actual weight rows
99
+ n_vocab = state_dict["model.lm_head.weight"].shape[0]
100
+ config.vocab_size = n_vocab
101
+ config.mask_vocab_size = n_vocab
102
+ print(f"[*] Vocab in config: {n_vocab} (matches weights)")
103
+
104
+ tokenizer = AutoTokenizer.from_pretrained(tokenizer_dir)
105
+ eos_ids = [tokenizer.eos_token_id] if tokenizer.eos_token_id is not None else []
106
+ im_end = tokenizer.convert_tokens_to_ids("<|im_end|>")
107
+ if im_end != tokenizer.unk_token_id and im_end not in eos_ids:
108
+ eos_ids.append(im_end)
109
+ eos_ids = [e for e in eos_ids if e is not None]
110
+ print(f"[*] eos ids: {eos_ids}")
111
+
112
+ # partial-byte vocab entries that cannot decode to valid UTF-8 (the
113
+ # literal replacement-char garbage): hard-banned at generation
114
+ bad_ids = []
115
+ for i in range(len(tokenizer)):
116
+ s = tokenizer.decode([i], skip_special_tokens=True)
117
+ if s and all(c == "\uFFFD" for c in s):
118
+ bad_ids.append(i)
119
+
120
+ config_dict = config.__dict__.copy()
121
+ config_dict.pop("dtype", None) # transformers chokes on "torch.float32" strings
122
+ config_dict["model_type"] = "metadiffusion"
123
+ config_dict["architectures"] = ["MetaDiffusion600MForCausalLM"]
124
+ config_dict["auto_map"] = {
125
+ "AutoConfig": "hf_modeling.MetaDiffusion600MConfig",
126
+ "AutoModelForCausalLM": "hf_modeling.MetaDiffusion600MForCausalLM",
127
+ }
128
+ config_dict["eos_token_id"] = eos_ids
129
+ config_dict["rainbow_token_ids"] = list(range(config.mask_token_id + 1,
130
+ config.mask_token_id + 8))
131
+ config_dict["invalid_utf8_token_ids"] = bad_ids
132
+ with open(out / "config.json", "w") as f:
133
+ json.dump(config_dict, f, indent=2)
134
+ print(f"[*] Saved config.json (mask_token_id={config.mask_token_id}, "
135
+ f"{len(bad_ids)} banned invalid-UTF8 tokens)")
136
+
137
+ gen_config = dict(GENERATION_CONFIG)
138
+ gen_config["eos_token_id"] = eos_ids
139
+ gen_config["pad_token_id"] = config.pad_token_id
140
+ gen_config["mask_token_id"] = config.mask_token_id
141
+ with open(out / "generation_config.json", "w") as f:
142
+ json.dump(gen_config, f, indent=2)
143
+
144
+ shutil.copytree(tokenizer_dir, out / "tokenizer", dirs_exist_ok=True)
145
+ print(f"[*] Copied tokenizer -> {out / 'tokenizer'}")
146
+ # pin the plain chat template in the exported tokenizer (the saved one is
147
+ # empty, which silently falls back to the think-injecting Qwen3 default)
148
+ tok_cfg_path = out / "tokenizer" / "tokenizer_config.json"
149
+ if tok_cfg_path.exists():
150
+ tc = json.loads(tok_cfg_path.read_text())
151
+ tc["chat_template"] = PLAIN_CHAT_TEMPLATE
152
+ tok_cfg_path.write_text(json.dumps(tc, indent=2, ensure_ascii=False))
153
+ print("[*] Pinned plain chat_template in exported tokenizer")
154
+
155
+ # chat_template.jinja takes precedence over the config string since
156
+ # transformers 5.x; overwrite it so both sources carry the plain
157
+ # template (the Qwen3 default jinja injects <think> blocks).
158
+ (out / "tokenizer" / "chat_template.jinja").write_text(
159
+ PLAIN_CHAT_TEMPLATE)
160
+ print("[*] Pinned plain chat_template.jinja")
161
+
162
+
163
+ src = Path(__file__).resolve().parent
164
+ shutil.copy2(src / "hf_modeling.py", out / "hf_modeling.py")
165
+ print("[*] Copied hf_modeling.py (trust_remote_code)")
166
+
167
+ package_scripts(out)
168
+ print(f"[*] Done: {out}")
169
+ print(" Load with: AutoModelForCausalLM.from_pretrained("
170
+ f"'{out}', trust_remote_code=True)")
171
+ print(" (Write the README yourself; export never touches it.)")
172
+
173
+
174
+ def main():
175
+ p = argparse.ArgumentParser(description="Export MetaDiffusion-600M release dir")
176
+ p.add_argument("--checkpoint", required=True)
177
+ p.add_argument("--tokenizer", default="data/tokenizer")
178
+ p.add_argument("--output", required=True)
179
+ p.add_argument("--dtype", default="bf16", choices=["bf16", "fp16", "fp32"],
180
+ help="Weight dtype (default bf16: matches training dtype and "
181
+ "survives timestep extrapolation; fp16 overflows to NaN "
182
+ "on curriculum-trained checkpoints)")
183
+ args = p.parse_args()
184
+ export(args.checkpoint, args.tokenizer, args.output, dtype=args.dtype)
185
+
186
+
187
+ if __name__ == "__main__":
188
+ main()
model.py ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MetaDiffusionLM: masked-diffusion LM converted from Qwen3-0.6B-Instruct.
3
+
4
+ Architecture: 28L x 1024W, GQA (16Q / 8KV, head_dim 128), QK-norm, RoPE,
5
+ timestep conditioning (sinusoidal MLP + zero-init per-block residual).
6
+ Bidirectional attention (no causal mask) is the key difference from the AR source.
7
+
8
+ Parameter init:
9
+ - Copied from AR checkpoint: token embeddings, all transformer blocks, norms,
10
+ QK-norm, RoPE buffers.
11
+ - New, zero-init (identity at step 0): timestep embedding MLP, per-block
12
+ timestep residual. The model starts as exactly the AR model; diffusion
13
+ behavior is learned on top.
14
+ - New, mean-init: [MASK] token row and the 7 rainbow padding token rows
15
+ (appended to both embed_tokens and the untied lm_head).
16
+
17
+ Training loss (train.py):
18
+ - CE on masked positions (the diffusion objective).
19
+ """
20
+
21
+ import math
22
+ from dataclasses import asdict, dataclass, field
23
+ from typing import Optional
24
+
25
+ import torch
26
+ import torch.nn as nn
27
+ import torch.nn.functional as F
28
+
29
+
30
+ @dataclass
31
+ class MetaDiffusionConfig:
32
+ hidden_size: int = 1024
33
+ intermediate_size: int = 3072
34
+ num_hidden_layers: int = 28
35
+ num_attention_heads: int = 16
36
+ num_key_value_heads: int = 8
37
+ head_dim: int = 128
38
+ vocab_size: int = 151669 # Qwen3 real tokenizer vocab (config 151936 is TP-padded)
39
+ mask_vocab_size: int = 151677 # + [MASK] + 7 rainbow tokens
40
+ mask_token_id: int = 151669
41
+ pad_token_id: int = 151643 # <|endoftext|> in Qwen3
42
+ max_position_embeddings: int = 32768
43
+ rope_theta: float = 1000000.0
44
+ rms_norm_eps: float = 1e-6
45
+ hidden_act: str = "silu"
46
+ qk_norm: bool = True
47
+ timestep_emb_hidden: int = 1024
48
+ tie_word_embeddings: bool = False
49
+ mask_ratio_min: float = 0.0
50
+ mask_ratio_max: float = 1.0
51
+ dtype: str = "float32"
52
+
53
+
54
+ class RMSNorm(nn.Module):
55
+ def __init__(self, hidden_size, eps=1e-6):
56
+ super().__init__()
57
+ self.weight = nn.Parameter(torch.ones(hidden_size))
58
+ self.eps = eps
59
+
60
+ def forward(self, x):
61
+ orig = x.dtype
62
+ x = x.float()
63
+ var = x.pow(2).mean(-1, keepdim=True)
64
+ x = x * torch.rsqrt(var + self.eps)
65
+ return (self.weight.float() * x).to(orig)
66
+
67
+
68
+ class RotaryEmbedding(nn.Module):
69
+ def __init__(self, dim, max_position_embeddings=32768, base=1000000.0):
70
+ super().__init__()
71
+ self.dim = dim
72
+ inv_freq = 1.0 / (base ** (torch.arange(0, dim, 2).float() / dim))
73
+ self.register_buffer("inv_freq", inv_freq, persistent=False)
74
+ self.max_position_embeddings = max_position_embeddings
75
+
76
+ def forward(self, x, position_ids):
77
+ inv_freq_expanded = self.inv_freq[None, :, None].float().expand(
78
+ position_ids.shape[0], -1, 1
79
+ )
80
+ position_ids_expanded = position_ids[:, None, :].float()
81
+ freqs = (inv_freq_expanded @ position_ids_expanded).transpose(1, 2)
82
+ emb = torch.cat((freqs, freqs), dim=-1)
83
+ cos = emb.cos().to(dtype=x.dtype)
84
+ sin = emb.sin().to(dtype=x.dtype)
85
+ return cos, sin
86
+
87
+
88
+ def rotate_half(x):
89
+ x1, x2 = x.chunk(2, dim=-1)
90
+ return torch.cat((-x2, x1), dim=-1)
91
+
92
+
93
+ def apply_rotary_pos_emb(q, k, cos, sin):
94
+ cos = cos.unsqueeze(1)
95
+ sin = sin.unsqueeze(1)
96
+ q_embed = (q * cos) + (rotate_half(q) * sin)
97
+ k_embed = (k * cos) + (rotate_half(k) * sin)
98
+ return q_embed, k_embed
99
+
100
+
101
+ class TimestepEmbedding(nn.Module):
102
+ """Sinusoidal timestep embedding with learned MLP projection."""
103
+
104
+ def __init__(self, hidden_size):
105
+ super().__init__()
106
+ self.hidden_size = hidden_size
107
+ self.mlp = nn.Sequential(
108
+ nn.Linear(hidden_size, hidden_size * 4),
109
+ nn.SiLU(),
110
+ nn.Linear(hidden_size * 4, hidden_size),
111
+ )
112
+
113
+ def forward(self, t):
114
+ half_dim = self.hidden_size // 2
115
+ emb = math.log(10000.0) / (half_dim - 1)
116
+ emb = torch.exp(
117
+ torch.arange(half_dim, device=t.device, dtype=torch.float32) * -emb
118
+ )
119
+ emb = t[:, None].float() * emb[None, :]
120
+ emb = torch.cat([emb.sin(), emb.cos()], dim=-1)
121
+ # cast to the MLP weight dtype: the model may be bf16 while t is fp32
122
+ return self.mlp(emb.to(self.mlp[0].weight.dtype))
123
+
124
+
125
+ class TimestepModulation(nn.Module):
126
+ """adaLN-style timestep conditioning: scale + shift the hidden state.
127
+
128
+ Zero-init scale/shift so the model is a pure copy of the AR model at
129
+ step 0. Unlike the old zero-init ADDITIVE residual (TimestepResidual),
130
+ the gradient here is dL/dscale = dL/dx * x with x nonzero, so the
131
+ t-path trains: the additive version deadlocked (zero output through a
132
+ zero weight = zero outer-product gradient forever), leaving the model
133
+ noise-schedule-agnostic."""
134
+
135
+ def __init__(self, hidden_size):
136
+ super().__init__()
137
+ self.proj = nn.Linear(hidden_size, hidden_size * 2)
138
+ nn.init.zeros_(self.proj.weight)
139
+ nn.init.zeros_(self.proj.bias)
140
+
141
+ def forward(self, x, emb):
142
+ scale, shift = self.proj(emb).chunk(2, dim=-1)
143
+ scale, shift = scale[:, None, :], shift[:, None, :]
144
+ return x * (1.0 + scale) + shift
145
+
146
+
147
+ class SelfAttention(nn.Module):
148
+ """GQA attention, bidirectional (no causal mask), optional QK-norm."""
149
+
150
+ def __init__(self, config):
151
+ super().__init__()
152
+ self.config = config
153
+ self.hidden_size = config.hidden_size
154
+ self.num_heads = config.num_attention_heads
155
+ self.num_kv_heads = config.num_key_value_heads
156
+ self.head_dim = config.head_dim
157
+ self.num_kv_groups = self.num_heads // self.num_kv_heads
158
+
159
+ self.q_proj = nn.Linear(config.hidden_size, self.num_heads * self.head_dim, bias=False)
160
+ self.k_proj = nn.Linear(config.hidden_size, self.num_kv_heads * self.head_dim, bias=False)
161
+ self.v_proj = nn.Linear(config.hidden_size, self.num_kv_heads * self.head_dim, bias=False)
162
+ self.o_proj = nn.Linear(self.num_heads * self.head_dim, config.hidden_size, bias=False)
163
+
164
+ self.q_norm = RMSNorm(self.head_dim, eps=config.rms_norm_eps) if config.qk_norm else nn.Identity()
165
+ self.k_norm = RMSNorm(self.head_dim, eps=config.rms_norm_eps) if config.qk_norm else nn.Identity()
166
+
167
+ self.rotary_emb = RotaryEmbedding(
168
+ config.head_dim,
169
+ max_position_embeddings=config.max_position_embeddings,
170
+ base=config.rope_theta,
171
+ )
172
+
173
+ def forward(self, x, attention_mask=None, position_ids=None):
174
+ batch, seq, _ = x.shape
175
+
176
+ q = self.q_proj(x).view(batch, seq, self.num_heads, self.head_dim).transpose(1, 2)
177
+ k = self.k_proj(x).view(batch, seq, self.num_kv_heads, self.head_dim).transpose(1, 2)
178
+ v = self.v_proj(x).view(batch, seq, self.num_kv_heads, self.head_dim).transpose(1, 2)
179
+
180
+ q = self.q_norm(q)
181
+ k = self.k_norm(k)
182
+
183
+ cos, sin = self.rotary_emb(x, position_ids)
184
+ q, k = apply_rotary_pos_emb(q, k, cos, sin)
185
+
186
+ if self.num_kv_groups > 1:
187
+ k = k.repeat_interleave(self.num_kv_groups, dim=1)
188
+ v = v.repeat_interleave(self.num_kv_groups, dim=1)
189
+
190
+ out = F.scaled_dot_product_attention(q, k, v, attn_mask=attention_mask)
191
+ out = out.transpose(1, 2).contiguous().view(batch, seq, -1)
192
+ return self.o_proj(out)
193
+
194
+
195
+ class MLP(nn.Module):
196
+ def __init__(self, config):
197
+ super().__init__()
198
+ self.gate_proj = nn.Linear(config.hidden_size, config.intermediate_size, bias=False)
199
+ self.up_proj = nn.Linear(config.hidden_size, config.intermediate_size, bias=False)
200
+ self.down_proj = nn.Linear(config.intermediate_size, config.hidden_size, bias=False)
201
+
202
+ def forward(self, x):
203
+ return self.down_proj(F.silu(self.gate_proj(x)) * self.up_proj(x))
204
+
205
+
206
+ class TransformerBlock(nn.Module):
207
+ def __init__(self, config):
208
+ super().__init__()
209
+ self.input_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
210
+ self.self_attn = SelfAttention(config)
211
+ self.post_attention_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
212
+ self.mlp = MLP(config)
213
+ self.timestep_modulation = TimestepModulation(config.hidden_size)
214
+
215
+ def forward(self, x, timestep_emb, attention_mask=None, position_ids=None):
216
+ residual = x
217
+ x = self.input_layernorm(x)
218
+ x = self.self_attn(x, attention_mask, position_ids)
219
+ x = residual + x
220
+ x = self.timestep_modulation(x, timestep_emb)
221
+
222
+ residual = x
223
+ x = self.post_attention_layernorm(x)
224
+ x = self.mlp(x)
225
+ x = residual + x
226
+ x = self.timestep_modulation(x, timestep_emb)
227
+ return x
228
+
229
+
230
+ class MetaDiffusionLM(nn.Module):
231
+ def __init__(self, config: MetaDiffusionConfig):
232
+ super().__init__()
233
+ self.config = config
234
+ self.embed_tokens = nn.Embedding(config.mask_vocab_size, config.hidden_size)
235
+ self.timestep_emb = TimestepEmbedding(config.timestep_emb_hidden)
236
+ self.layers = nn.ModuleList([TransformerBlock(config) for _ in range(config.num_hidden_layers)])
237
+ self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
238
+ if config.tie_word_embeddings:
239
+ self.lm_head = None
240
+ else:
241
+ self.lm_head = nn.Linear(config.hidden_size, config.mask_vocab_size, bias=False)
242
+
243
+ def forward(self, input_ids, timesteps, attention_mask=None):
244
+ batch, seq = input_ids.shape
245
+ position_ids = torch.arange(seq, device=input_ids.device).unsqueeze(0).expand(batch, -1)
246
+
247
+ x = self.embed_tokens(input_ids)
248
+ t_emb = self.timestep_emb(timesteps)
249
+
250
+ attn_mask = None
251
+ if attention_mask is not None:
252
+ attn_mask = ((1.0 - attention_mask[:, None, None, :].float()) * -1e9).to(x.dtype)
253
+
254
+ for layer in self.layers:
255
+ x = layer(x, t_emb, attn_mask, position_ids)
256
+
257
+ x = self.norm(x)
258
+ if self.lm_head is not None:
259
+ logits = self.lm_head(x)
260
+ else:
261
+ logits = F.linear(x, self.embed_tokens.weight)
262
+ return logits
263
+
264
+ def compute_loss(self, logits, labels, mask_positions, pad_token_id=None,
265
+ eos_token_id=None, eos_weight=1.0):
266
+ """CE on masked positions only (the masked-diffusion objective).
267
+
268
+ eos_token_id/eos_weight: boost the loss on the terminator token when
269
+ it is a masked target, so the model learns to emit it (EOS-weighting,
270
+ arXiv 2506.05017)."""
271
+ logits_masked = logits[mask_positions]
272
+ labels_masked = labels[mask_positions]
273
+ if pad_token_id is not None:
274
+ valid = labels_masked != pad_token_id
275
+ logits_masked = logits_masked[valid]
276
+ labels_masked = labels_masked[valid]
277
+ if labels_masked.numel() == 0:
278
+ return torch.tensor(0.0, device=logits.device), 0
279
+ ce = F.cross_entropy(logits_masked, labels_masked, reduction="none")
280
+ if eos_token_id is not None and eos_weight != 1.0:
281
+ w = torch.where(labels_masked == eos_token_id, eos_weight, 1.0)
282
+ ce = ce * w
283
+ return ce.mean(), labels_masked.numel()
284
+
285
+ @classmethod
286
+ def from_checkpoint(cls, checkpoint_path, device="cpu"):
287
+ ckpt = torch.load(checkpoint_path, map_location=device, weights_only=False)
288
+ config_dict = ckpt.get("config", ckpt)
289
+ config = MetaDiffusionConfig(
290
+ **{k: v for k, v in config_dict.items() if k in MetaDiffusionConfig.__dataclass_fields__}
291
+ )
292
+ model = cls(config)
293
+ sd = {k.replace("_orig_mod.", "", 1) if k.startswith("_orig_mod.") else k: v
294
+ for k, v in ckpt.get("model_state_dict", ckpt).items()}
295
+ model.load_state_dict(sd, strict=True)
296
+ return model, ckpt
prepare_data.py ADDED
@@ -0,0 +1,388 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ prepare_data.py: build the chat SFT corpus for MetaDiffusion-600M.
4
+
5
+ Datasets:
6
+ - HuggingFaceTB/smol-smoltalk : 460K general instruction conversations
7
+ - OpenCoder-LLM/opc-sft-stage1[lang:python] + stage2 : code instruction data
8
+ - OpenMathInstruct-2 : math (CoT solutions + answers)
9
+
10
+ Pipeline per conversation:
11
+ 1. Normalize row -> messages [{role, content}]
12
+ 2. Format with the Qwen chat template (<|im_start|>...<|im_end|>)
13
+ 3. Tokenize; mark assistant-content tokens + rainbow padding as "response"
14
+ 4. Truncate to seq_len (drop samples whose prompt alone overflows)
15
+ 5. Rainbow-pad (cyclic <|r1|>..<|r7|>) so the model never sees repeated <eos>
16
+
17
+ Output: data/ids.bin (uint32 token ids; vocab is 151677, does NOT fit uint16),
18
+ data/resp.bin (uint8: 1 = assistant content / rainbow region), data/meta.json.
19
+
20
+ train.py masks ONLY the resp==1 region by default (prompt stays clean), which
21
+ matches the proven ChatDataset convention.
22
+
23
+ Usage:
24
+ python prepare_data.py --out data --seq-len 512
25
+ python prepare_data.py --datasets smol --max-samples 50000 --out data/smol-small
26
+ """
27
+
28
+ import argparse
29
+ import itertools
30
+ import json
31
+ import logging
32
+ import random
33
+ import re
34
+ from pathlib import Path
35
+
36
+ import numpy as np
37
+ from transformers import AutoTokenizer
38
+
39
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
40
+ logger = logging.getLogger(__name__)
41
+
42
+ RAINBOW_TOKENS = [f"<|r{i}|>" for i in range(1, 8)]
43
+ NUM_NEW_TOKENS = 1 + len(RAINBOW_TOKENS) # [MASK] + rainbow
44
+
45
+ # Per-dataset default sample caps (None = everything)
46
+ DEFAULT_CAPS = {"smol": None, "opc": 150_000, "math": 100_000, "no_robots": None}
47
+
48
+
49
+ def ensure_special_tokens(tokenizer):
50
+ """Add [MASK] + rainbow tokens if missing.
51
+
52
+ Works for both a fresh tokenizer (adds all 8 at the end) and the
53
+ already-extended one saved by convert.py: [MASK] is always the first of
54
+ the 8 appended tokens, so mask_id == len(tokenizer) - 8."""
55
+ added = []
56
+ if tokenizer.convert_tokens_to_ids("[MASK]") == tokenizer.unk_token_id:
57
+ added.append("[MASK]")
58
+ missing_rainbow = [t for t in RAINBOW_TOKENS
59
+ if tokenizer.convert_tokens_to_ids(t) == tokenizer.unk_token_id]
60
+ if missing_rainbow:
61
+ added.extend(missing_rainbow)
62
+ if added:
63
+ tokenizer.add_special_tokens({"additional_special_tokens": added})
64
+ mask_id = tokenizer.convert_tokens_to_ids("[MASK]")
65
+ expected = len(tokenizer) - NUM_NEW_TOKENS
66
+ assert mask_id == expected, f"[MASK] at {mask_id}, expected {expected}"
67
+ return tokenizer
68
+
69
+
70
+ def get_messages(row) -> list | None:
71
+ """Normalize a dataset row into [{role, content}] or None."""
72
+ if isinstance(row, dict):
73
+ for key in ("messages", "conversations", "conversation"):
74
+ val = row.get(key)
75
+ if isinstance(val, list) and val:
76
+ msgs = []
77
+ for m in val:
78
+ role = str(m.get("role", "")).lower()
79
+ if role in ("human", "prompt", "user"):
80
+ role = "user"
81
+ elif role in ("gpt", "assistant", "response", "bot", "output"):
82
+ role = "assistant"
83
+ if role not in ("user", "assistant", "system"):
84
+ continue
85
+ content = m.get("content", m.get("value", ""))
86
+ if isinstance(content, list):
87
+ content = " ".join(str(c.get("text", c)) for c in content)
88
+ if content:
89
+ msgs.append({"role": role, "content": str(content).strip()})
90
+ if msgs and any(m["role"] == "assistant" for m in msgs):
91
+ return msgs
92
+ # Instruction/output shapes
93
+ inst = row.get("instruction") or row.get("prompt") or row.get("question") or row.get("problem")
94
+ out = row.get("output") or row.get("response") or row.get("answer") or row.get("solution")
95
+ if inst and out:
96
+ return [{"role": "user", "content": str(inst).strip()},
97
+ {"role": "assistant", "content": str(out).strip()}]
98
+ return None
99
+
100
+
101
+ def math_messages(row) -> list | None:
102
+ """OpenMathInstruct-2 shape: problem + generated_solution + expected_answer."""
103
+ if not isinstance(row, dict):
104
+ return None
105
+ problem = row.get("problem") or row.get("question")
106
+ solution = row.get("generated_solution") or row.get("solution")
107
+ if not problem or not solution:
108
+ return None
109
+ answer = row.get("expected_answer")
110
+ if answer and str(answer).strip():
111
+ solution = f"{solution}\n\nFinal answer: {answer}"
112
+ return [{"role": "user", "content": str(problem).strip()},
113
+ {"role": "assistant", "content": str(solution).strip()}]
114
+
115
+
116
+ _THINK_RE = re.compile(r"<think>.*?</think>", re.S)
117
+
118
+ PLAIN_CHAT_TEMPLATE = (
119
+ "{% for message in messages %}"
120
+ "{{ '<|im_start|>' + message['role'] + '\\n' + message['content'] + '<|im_end|>\\n' }}"
121
+ "{% endfor %}"
122
+ )
123
+
124
+
125
+ def is_junk_content(content):
126
+ if "\uFFFD" in content:
127
+ return True
128
+ if not content:
129
+ return False
130
+ n_ascii = sum(1 for c in content if ord(c) < 128)
131
+ return (len(content) - n_ascii) / len(content) > 0.25
132
+
133
+
134
+ def strip_think(messages):
135
+ out = []
136
+ for m in messages:
137
+ content = m["content"]
138
+ if m["role"] == "assistant" and isinstance(content, str):
139
+ content = _THINK_RE.sub("", content)
140
+ content = re.sub(r"\n{3,}", "\n\n", content).strip()
141
+ out.append({"role": m["role"], "content": content})
142
+ return out
143
+
144
+
145
+ def format_conversation(tokenizer, messages, seq_len, min_response_tokens):
146
+ """Tokenize a conversation. Returns (ids, resp_flags) truncated/padded to
147
+ seq_len, or None if the prompt alone cannot fit.
148
+
149
+ The assistant span INCLUDES the trailing <|im_end|> token: a masked
150
+ diffusion model can only learn to emit the terminator if it is a masked
151
+ training target (VoidPadding format: [prompt][response][im_end][pad]*).
152
+ """
153
+ messages = strip_think(messages)
154
+ text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
155
+ enc = tokenizer(text, return_offsets_mapping=True, add_special_tokens=False)
156
+ ids = enc["input_ids"]
157
+ offsets = enc["offset_mapping"]
158
+
159
+ resp_flags = [0] * len(ids)
160
+ pos = 0
161
+ for m in messages:
162
+ if m["role"] == "assistant":
163
+ marker = "<|im_start|>assistant\n"
164
+ idx = text.find(marker, pos)
165
+ if idx == -1:
166
+ return None
167
+ content_start = idx + len(marker)
168
+ content_end = content_start + len(m["content"])
169
+ # extend the span through the <|im_end|> token (the terminator is
170
+ # a first-class masked target; this is the im_end-hardening fix)
171
+ im_end_at = text.find("<|im_end|>", content_end)
172
+ if im_end_at != -1:
173
+ content_end = im_end_at + len("<|im_end|>")
174
+ for t_i, (cs, ce) in enumerate(offsets):
175
+ if cs >= content_start and ce <= content_end and t_i < len(resp_flags):
176
+ resp_flags[t_i] = 1
177
+ pos = content_end
178
+ if not any(resp_flags):
179
+ return None
180
+
181
+ # Find the first response token; prompt must fit with room for a response
182
+ resp_start = resp_flags.index(1)
183
+ if resp_start > seq_len - min_response_tokens:
184
+ return None
185
+
186
+ ids = ids[:seq_len]
187
+ resp_flags = resp_flags[:seq_len]
188
+
189
+ # Rainbow pad the tail (all maskable, teaches the model to stop-and-pad)
190
+ rainbow_ids = [tokenizer.convert_tokens_to_ids(t) for t in RAINBOW_TOKENS]
191
+ n_pad = seq_len - len(ids)
192
+ if n_pad > 0:
193
+ ids = ids + [rainbow_ids[i % 7] for i in range(n_pad)]
194
+ resp_flags = resp_flags + [1] * n_pad
195
+ return ids, resp_flags
196
+
197
+
198
+ def load_rows(dataset_name, max_samples):
199
+ from datasets import load_dataset
200
+
201
+ if dataset_name == "smol":
202
+ ds = load_dataset("HuggingFaceTB/smol-smoltalk", split="train")
203
+ elif dataset_name == "opc":
204
+ rows = []
205
+ for repo in ("OpenCoder-LLM/opc-sft-stage1", "OpenCoder-LLM/opc-sft-stage2"):
206
+ loaded = None
207
+ for config in ("lang:python", "lang:generic", None):
208
+ try:
209
+ loaded = load_dataset(repo, config, split="train") if config else \
210
+ load_dataset(repo, split="train")
211
+ logger.info(f" loaded {repo} config={config}: {len(loaded)} rows")
212
+ break
213
+ except Exception:
214
+ continue
215
+ if loaded is not None:
216
+ rows.append(loaded)
217
+ if not rows:
218
+ raise RuntimeError("Could not load any opc-sft config")
219
+ ds = rows[0] if len(rows) == 1 else None
220
+ elif dataset_name == "math":
221
+ ds = load_dataset("nvidia/OpenMathInstruct-2", split="train",
222
+ streaming=True)
223
+ elif dataset_name == "no_robots":
224
+ ds = load_dataset("HuggingFaceH4/no_robots", split="train")
225
+ else:
226
+ raise ValueError(f"unknown dataset: {dataset_name}")
227
+
228
+ if ds is None:
229
+ # opc multi-repo path: chain them
230
+ def gen():
231
+ for r in rows:
232
+ yield from r
233
+ return gen()
234
+ return ds
235
+
236
+
237
+ def main():
238
+ parser = argparse.ArgumentParser(description="Build MetaDiffusion-600M chat SFT corpus")
239
+ parser.add_argument("--out", default="data", help="Output dir (ids.bin, resp.bin, meta.json)")
240
+ parser.add_argument("--tokenizer", default="data/tokenizer",
241
+ help="Tokenizer dir (from convert.py) or HF id")
242
+ parser.add_argument("--seq-len", type=int, default=512)
243
+ parser.add_argument("--datasets", default="smol,opc,math",
244
+ help="Comma list of: smol, opc, math, no_robots")
245
+ parser.add_argument("--jsonl", default=None,
246
+ help="Local JSONL of {\"messages\": [...]} rows")
247
+ parser.add_argument("--max-samples", type=int, default=0,
248
+ help="Per-dataset cap (0 = dataset default)")
249
+ parser.add_argument("--math-repeat", type=int, default=1,
250
+ help="Process the math dataset N times")
251
+ parser.add_argument("--filter-junk", action=argparse.BooleanOptionalAction, default=True,
252
+ help="Drop samples whose assistant content is >25%% non-ASCII. On by default; --no-filter-junk to keep them.")
253
+ parser.add_argument("--length-balance", action="store_true",
254
+ help="Duplicate samples whose response-token count is in "
255
+ "[--length-balance-min, --length-balance-max] by "
256
+ "--length-balance-mult (targets the 60-160 gen budget).")
257
+ parser.add_argument("--length-balance-min", type=int, default=60)
258
+ parser.add_argument("--length-balance-max", type=int, default=160)
259
+ parser.add_argument("--length-balance-mult", type=int, default=3)
260
+ parser.add_argument("--min-response-tokens", type=int, default=8)
261
+ parser.add_argument("--val-fraction", type=float, default=0.05,
262
+ help="Hold out this fraction as ids_val.bin/resp_val.bin for early stopping")
263
+ parser.add_argument("--seed", type=int, default=42)
264
+ args = parser.parse_args()
265
+
266
+ out = Path(args.out)
267
+ out.mkdir(parents=True, exist_ok=True)
268
+
269
+ tokenizer = AutoTokenizer.from_pretrained(args.tokenizer)
270
+ tokenizer = ensure_special_tokens(tokenizer)
271
+ tokenizer.chat_template = PLAIN_CHAT_TEMPLATE
272
+ logger.info(f"Tokenizer: {len(tokenizer)} tokens, [MASK]={tokenizer.convert_tokens_to_ids('[MASK]')}")
273
+
274
+ rng = random.Random(args.seed)
275
+ all_ids = []
276
+ all_resp = []
277
+ n_samples = 0
278
+
279
+ def process_rows(ds_name, rows, cap):
280
+ nonlocal n_samples
281
+ ds_n = 0
282
+ ds_skipped = 0
283
+ for row in rows:
284
+ if cap is not None and ds_n >= cap:
285
+ break
286
+ msgs = math_messages(row) if ds_name == "math" else get_messages(row)
287
+ if msgs is None:
288
+ ds_skipped += 1
289
+ continue
290
+ if args.filter_junk:
291
+ ac = msgs[-1]["content"] if msgs and msgs[-1]["role"] == "assistant" else ""
292
+ if isinstance(ac, str) and is_junk_content(ac):
293
+ ds_skipped += 1
294
+ continue
295
+ result = format_conversation(tokenizer, msgs, args.seq_len, args.min_response_tokens)
296
+ if result is None:
297
+ ds_skipped += 1
298
+ continue
299
+ ids, resp = result
300
+ copies = 1
301
+ if args.length_balance:
302
+ n_resp = int(sum(resp))
303
+ if args.length_balance_min <= n_resp <= args.length_balance_max:
304
+ copies = args.length_balance_mult
305
+ for _ in range(copies):
306
+ all_ids.append(np.array(ids, dtype=np.uint32))
307
+ all_resp.append(np.array(resp, dtype=np.uint8))
308
+ ds_n += 1
309
+ if ds_n % 20000 == 0:
310
+ logger.info(f" [{ds_name}] {ds_n:,} samples")
311
+ logger.info(f"[{ds_name}] kept {ds_n:,}, skipped {ds_skipped:,}")
312
+ n_samples += ds_n
313
+
314
+ for ds_name in [d.strip() for d in args.datasets.split(",") if d.strip()]:
315
+ cap = args.max_samples if args.max_samples > 0 else DEFAULT_CAPS.get(ds_name)
316
+ logger.info(f"[{ds_name}] loading (cap={cap})...")
317
+ rows = load_rows(ds_name, cap)
318
+ repeats = args.math_repeat if ds_name == "math" else 1
319
+ if repeats > 1:
320
+ cache_path = out / f".math_cache{'_' + str(cap) if cap else ''}.jsonl"
321
+ if cache_path.exists():
322
+ logger.info(f"[math] using local cache {cache_path}")
323
+ with open(cache_path) as f:
324
+ rows = [json.loads(line) for line in f if line.strip()]
325
+ else:
326
+ rows = list(itertools.islice(rows, cap) if cap else rows)
327
+ with open(cache_path, "w") as f:
328
+ for r in rows:
329
+ f.write(json.dumps(r) + "\n")
330
+ logger.info(f"[math] cached {len(rows):,} rows to {cache_path}")
331
+ for rep in range(repeats):
332
+ if repeats > 1:
333
+ logger.info(f"[{ds_name}] pass {rep + 1}/{repeats}")
334
+ process_rows(ds_name, rows, cap)
335
+
336
+ if args.jsonl:
337
+ with open(args.jsonl) as f:
338
+ rows = [json.loads(line) for line in f if line.strip()]
339
+ logger.info(f"[jsonl] {len(rows)} rows from {args.jsonl}")
340
+ cap = args.max_samples if args.max_samples > 0 else None
341
+ process_rows("jsonl", rows, cap)
342
+
343
+ if n_samples == 0:
344
+ raise SystemExit("no samples kept; check --datasets / --jsonl / filters")
345
+ logger.info(f"Shuffling {n_samples:,} samples (seed {args.seed})...")
346
+ order = list(range(n_samples))
347
+ rng.shuffle(order)
348
+ n_val = int(n_samples * args.val_fraction)
349
+ train_order = order[n_val:]
350
+ val_order = order[:n_val]
351
+ if not train_order:
352
+ raise SystemExit(f"val-fraction {args.val_fraction} left zero train samples")
353
+ ids_flat = np.concatenate([all_ids[i] for i in train_order])
354
+ resp_flat = np.concatenate([all_resp[i] for i in train_order])
355
+
356
+ ids_flat.tofile(out / "ids.bin")
357
+ resp_flat.tofile(out / "resp.bin")
358
+
359
+ if n_val > 0:
360
+ ids_val = np.concatenate([all_ids[i] for i in val_order])
361
+ resp_val = np.concatenate([all_resp[i] for i in val_order])
362
+ ids_val.tofile(out / "ids_val.bin")
363
+ resp_val.tofile(out / "resp_val.bin")
364
+ logger.info(f"Val held out: {n_val:,} samples -> {out / 'ids_val.bin'} / {out / 'resp_val.bin'}")
365
+
366
+ meta = {
367
+ "seq_len": args.seq_len,
368
+ "n_samples": n_samples - n_val,
369
+ "n_val_samples": n_val,
370
+ "val_held_out": True,
371
+ "n_tokens": int(ids_flat.shape[0]),
372
+ "mask_token_id": tokenizer.convert_tokens_to_ids("[MASK]"),
373
+ "rainbow_token_ids": [tokenizer.convert_tokens_to_ids(t) for t in RAINBOW_TOKENS],
374
+ "vocab_size": len(tokenizer),
375
+ "datasets": args.datasets + (f",jsonl:{args.jsonl}" if args.jsonl else ""),
376
+ "tokenizer_dir": str(args.tokenizer),
377
+ "seed": args.seed,
378
+ "filter_junk": args.filter_junk,
379
+ "length_balance": args.length_balance,
380
+ }
381
+ with open(out / "meta.json", "w") as f:
382
+ json.dump(meta, f, indent=2)
383
+ logger.info(f"Wrote {out/'ids.bin'} ({ids_flat.nbytes/1e9:.2f} GB, {meta['n_tokens']:,} tokens), "
384
+ f"{out/'resp.bin'}, {out/'meta.json'}")
385
+
386
+
387
+ if __name__ == "__main__":
388
+ main()
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ torch>=2.2
2
+ transformers>=4.49
3
+ safetensors>=0.4
4
+ datasets>=2.18
5
+ numpy>=1.26
train.py ADDED
@@ -0,0 +1,702 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ train.py: masked-diffusion chat SFT for MetaDiffusion-600M.
4
+
5
+ Usage:
6
+ python train.py --init-checkpoint init/metadiffusion-600M-instruct.pt \
7
+ --data-dir data --output-dir checkpoints --max-steps 30000
8
+ python train.py ... --keep-knowledge --keep-mult 0.05
9
+ """
10
+
11
+ import argparse
12
+ import datetime
13
+ import glob
14
+ import heapq
15
+ import json
16
+ import logging
17
+ import math
18
+ import os
19
+ import re
20
+ import signal
21
+ import sys
22
+ import time
23
+ from dataclasses import asdict
24
+
25
+ import numpy as np
26
+ import torch
27
+ from torch.optim.lr_scheduler import LambdaLR
28
+
29
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ def format_bytes(b):
34
+ for unit in ["B", "KB", "MB", "GB", "TB"]:
35
+ if b < 1024:
36
+ return f"{b:.1f} {unit}"
37
+ b /= 1024
38
+ return f"{b:.1f} PB"
39
+
40
+
41
+ def format_eta(seconds):
42
+ seconds = max(0, int(seconds))
43
+ h, rem = divmod(seconds, 3600)
44
+ m, s = divmod(rem, 60)
45
+ if h > 0:
46
+ return f"{h}h{m:02d}m"
47
+ if m > 0:
48
+ return f"{m}m{s:02d}s"
49
+ return f"{s}s"
50
+
51
+
52
+ def detect_max_batch_size(model, seq_len, device, keep_free_fraction=0.1,
53
+ vocab=151677, reserve_bytes=0):
54
+ """Forward+backward probe to find the largest batch that fits.
55
+
56
+ reserve_bytes: memory the optimizer states need during real training
57
+ (2 x n_params x 4 for fp32 Adam, x 1 for 8-bit Adam). Without this the
58
+ probe over-predicts and the first real step OOMs."""
59
+ if not torch.cuda.is_available():
60
+ return 8
61
+ # mem_get_info() returns (free, total). Base the budget on ACTUAL free
62
+ # memory: other processes may hold part of the card.
63
+ free_mem, total_mem = torch.cuda.mem_get_info()
64
+ logger.info(f"GPU: {format_bytes(total_mem)} total, {format_bytes(free_mem)} free")
65
+ mem_limit = free_mem - int(total_mem * keep_free_fraction) - reserve_bytes
66
+ logger.info(f"VRAM budget: {format_bytes(mem_limit)} "
67
+ f"(based on {format_bytes(free_mem)} free, "
68
+ f"reserving {format_bytes(reserve_bytes)} for optimizer + accum grads)")
69
+ model = model.to(device)
70
+ model.train()
71
+
72
+ last_working = 1
73
+ for bs in [1, 2, 4, 8, 16, 24, 32, 48]:
74
+ torch.cuda.empty_cache()
75
+ try:
76
+ ids = torch.randint(0, vocab, (bs, seq_len), device=device)
77
+ labels = ids.clone()
78
+ mask = torch.rand(bs, seq_len, device=device) < 0.5
79
+ t = torch.rand(bs, device=device)
80
+ logits = model(ids, t)
81
+ loss, n = model.compute_loss(logits, labels, mask)
82
+ if n > 0:
83
+ (loss / 4).backward()
84
+ torch.cuda.synchronize()
85
+ peak = torch.cuda.max_memory_allocated()
86
+ logger.info(f" batch={bs:>2d}: peak {format_bytes(peak)} "
87
+ f"({peak/total_mem*100:.0f}%)")
88
+ if peak >= mem_limit:
89
+ break
90
+ last_working = bs
91
+ except RuntimeError as e:
92
+ if "out of memory" in str(e).lower():
93
+ logger.info(f" batch={bs:>2d}: OOM")
94
+ break
95
+ raise
96
+ finally:
97
+ model.zero_grad(set_to_none=True)
98
+ torch.cuda.empty_cache()
99
+ logger.info(f"Detected max batch_size={last_working}")
100
+ return last_working
101
+
102
+
103
+ def get_step_from_filename(filename):
104
+ basename = os.path.basename(filename)
105
+ m = re.match(r"step_(\d+)(?:_\w+)?\.pt$", basename)
106
+ return int(m.group(1)) if m else None
107
+
108
+ def load_best_val_steps(stats_path, max_n):
109
+ """Return the steps with the N lowest val losses from stats.jsonl.
110
+
111
+ Falls back to train loss when val never ran (patience=0)."""
112
+ if not os.path.exists(stats_path):
113
+ return set()
114
+ val_entries, loss_entries = [], []
115
+ with open(stats_path) as f:
116
+ for line in f:
117
+ line = line.strip()
118
+ if not line:
119
+ continue
120
+ try:
121
+ e = json.loads(line)
122
+ except json.JSONDecodeError:
123
+ continue
124
+ if "step" not in e:
125
+ continue
126
+ if e.get("val_loss") is not None:
127
+ val_entries.append((float(e["val_loss"]), int(e["step"])))
128
+ elif "loss" in e:
129
+ loss_entries.append((float(e["loss"]), int(e["step"])))
130
+ pool = val_entries if val_entries else loss_entries
131
+ return {s for _, s in heapq.nsmallest(max_n, pool)}
132
+
133
+
134
+ def cleanup_checkpoints(output_dir, keep_last_n, keep_best_n, stats_path):
135
+ """Keep the N latest regular checkpoints + the N best-by-val-loss
136
+ checkpoints; delete everything else to save storage."""
137
+ # sort by step NUMBER, not filename: lexicographic order breaks once
138
+ # steps hit 5 digits (step_30000.pt < step_4000.pt alphabetically)
139
+ all_ckpts = sorted(glob.glob(os.path.join(output_dir, "step_*.pt")),
140
+ key=lambda c: (get_step_from_filename(c) or -1, c))
141
+ if len(all_ckpts) <= keep_last_n + keep_best_n:
142
+ return
143
+
144
+ last_steps = {get_step_from_filename(c) for c in all_ckpts[-keep_last_n:]}
145
+ best_steps = load_best_val_steps(stats_path, keep_best_n)
146
+
147
+ kept = set()
148
+ # latest N: regular files only (no _valbest suffix)
149
+ for s in last_steps:
150
+ for c in all_ckpts:
151
+ if "valbest" not in c and get_step_from_filename(c) == s:
152
+ kept.add(c)
153
+ # best N by val: prefer the regular file at that step, else its valbest
154
+ for s in best_steps:
155
+ cands = [c for c in all_ckpts if get_step_from_filename(c) == s]
156
+ if not cands:
157
+ continue
158
+ regular = [c for c in cands if "valbest" not in c]
159
+ kept.add(regular[0] if regular else cands[0])
160
+
161
+ deleted = 0
162
+ for c in all_ckpts:
163
+ if c not in kept:
164
+ try:
165
+ os.remove(c)
166
+ deleted += 1
167
+ except OSError:
168
+ pass
169
+ if deleted:
170
+ logger.info(f"Cleaned {deleted} checkpoints "
171
+ f"(kept {len(kept)}: {keep_last_n} latest + {keep_best_n} best-val)")
172
+
173
+
174
+ def cosine_schedule_with_warmup(optimizer, warmup_steps, total_steps, min_lr_ratio=0.1):
175
+ def lr_lambda(step):
176
+ if step < warmup_steps:
177
+ return float(step) / float(max(1, warmup_steps))
178
+ progress = float(step - warmup_steps) / float(max(1, total_steps - warmup_steps))
179
+ return max(min_lr_ratio, 0.5 * (1.0 + math.cos(math.pi * progress)))
180
+ return LambdaLR(optimizer, lr_lambda)
181
+
182
+
183
+ def last_eos_mask(clean, eos_token_id, resp_bool):
184
+ """True only at the rightmost <|im_end|> in the response region per row.
185
+
186
+ Historical multi-turn terminators stay unforced; the final terminator
187
+ still gets a gradient every step under --eos-mask-always."""
188
+ is_eos = (clean == eos_token_id) & resp_bool
189
+ rev = torch.flip(is_eos, [1]).int()
190
+ last = torch.flip(rev.cumsum(1) == 1, [1]) & is_eos
191
+ return last
192
+
193
+
194
+ def no_decay_name(name, param):
195
+ return param.ndim < 2 or "norm" in name or name.endswith(".bias")
196
+
197
+
198
+ def main():
199
+ p = argparse.ArgumentParser(description="MetaDiffusion-600M chat SFT")
200
+ p.add_argument("--init-checkpoint", required=True)
201
+ p.add_argument("--data-dir", default="data")
202
+ p.add_argument("--output-dir", default="checkpoints")
203
+ p.add_argument("--max-steps", type=int, default=30000)
204
+ p.add_argument("--seq-len", type=int, default=512)
205
+ p.add_argument("--batch-size", type=int, default=0, help="0 = auto-detect")
206
+ p.add_argument("--grad-accum-steps", type=int, default=2)
207
+ p.add_argument("--lr", type=float, default=5e-5)
208
+ p.add_argument("--min-lr-ratio", type=float, default=0.1)
209
+ p.add_argument("--warmup-steps", type=int, default=200)
210
+ p.add_argument("--weight-decay", type=float, default=0.01)
211
+ p.add_argument("--transferred-lr-mult", type=float, default=0.33)
212
+ p.add_argument("--max-grad-norm", type=float, default=1.0)
213
+ p.add_argument("--optimizer", default="auto",
214
+ choices=["auto", "adamw", "adamw8bit"],
215
+ help="auto = torchao/bitsandbytes 8-bit Adam if installed, "
216
+ "else AdamW. adamw8bit forces 8-bit (warns if missing).")
217
+ p.add_argument("--dtype", default="bfloat16", choices=["float32", "bfloat16", "float16"])
218
+ p.add_argument("--mask-all", action="store_true", help="mask the whole sequence, not just responses")
219
+ p.add_argument("--mask-ratio-min", type=float, default=0.0)
220
+ p.add_argument("--mask-ratio-max", type=float, default=1.0)
221
+ p.add_argument("--curriculum", action="store_true",
222
+ help="Ramp the mask ratio from --mask-ratio-min to 1.0 over the "
223
+ "run. Val stays at fixed --val-t.")
224
+ p.add_argument("--curriculum-early-stop-gate",
225
+ action=argparse.BooleanOptionalAction, default=True,
226
+ help="While the curriculum ramp has not yet covered --val-t, "
227
+ "val is out-of-domain (extrapolation): compute + log it, "
228
+ "but do NOT update best-val / patience / valbest. Without "
229
+ "this, early stopping can fire mid-ramp on OOD noise and "
230
+ "kill the run before the model learns high-mask denoising. "
231
+ "--no-curriculum-early-stop-gate restores the old behavior.")
232
+ p.add_argument("--eos-weight", type=float, default=1.0,
233
+ help="Loss multiplier on the <|im_end|> token when it is a "
234
+ "masked target (teach termination; try 10, hammer 25)")
235
+ p.add_argument("--eos-token-id", type=int, default=151645,
236
+ help="<|im_end|> token id (Qwen3: 151645)")
237
+ p.add_argument("--eos-mask-always", action="store_true",
238
+ help="Force-mask the last <|im_end|> terminator in every window, "
239
+ "regardless of t: the terminator gets a gradient on every "
240
+ "step and the curriculum can never starve it")
241
+ p.add_argument("--keep-knowledge", action="store_true", help="RND1-style LR split")
242
+ p.add_argument("--keep-mult", type=float, default=0.05, help="LR mult for MLP/norm/embed when --keep-knowledge")
243
+ p.add_argument("--save-every", type=int, default=2000)
244
+ p.add_argument("--log-every", type=int, default=50)
245
+ p.add_argument("--val-every", type=int, default=500,
246
+ help="Val loss check interval (needs ids_val.bin/resp_val.bin)")
247
+ p.add_argument("--patience", type=int, default=0,
248
+ help="Early stop after N val checks without improvement (0 = off)")
249
+ p.add_argument("--min-delta", type=float, default=1e-4,
250
+ help="Min val loss improvement to count as improvement")
251
+ p.add_argument("--val-batches", type=int, default=50,
252
+ help="Batches averaged per val check")
253
+ p.add_argument("--val-t", type=float, default=0.5,
254
+ help="Fixed mask ratio for val checks (stable early stopping; "
255
+ "t~U(0,1) makes val swing +-0.5 and patience fires on noise)")
256
+ p.add_argument("--keep-last-n", type=int, default=3,
257
+ help="Keep the N latest regular checkpoints")
258
+ p.add_argument("--keep-best-n", type=int, default=3,
259
+ help="Keep the N lowest-val-loss checkpoints (train-loss fallback)")
260
+ p.add_argument("--resume-from", default=None)
261
+ p.add_argument("--seed", type=int, default=42)
262
+ p.add_argument("--device", default="cuda:0")
263
+ args = p.parse_args()
264
+
265
+ torch.manual_seed(args.seed)
266
+ np.random.seed(args.seed)
267
+ device = torch.device(args.device if torch.cuda.is_available() else "cpu")
268
+ dtype = {"float32": torch.float32, "bfloat16": torch.bfloat16, "float16": torch.float16}[args.dtype]
269
+
270
+ # --- Model ---
271
+ from model import MetaDiffusionConfig, MetaDiffusionLM
272
+ logger.info(f"Loading init checkpoint: {args.init_checkpoint}")
273
+ ckpt = torch.load(args.init_checkpoint, map_location="cpu", weights_only=False)
274
+ config = MetaDiffusionConfig(
275
+ **{k: v for k, v in ckpt["config"].items() if k in MetaDiffusionConfig.__dataclass_fields__}
276
+ )
277
+ model = MetaDiffusionLM(config)
278
+ model.load_state_dict(ckpt["model_state_dict"], strict=True)
279
+ model = model.to(device=device, dtype=dtype)
280
+ logger.info(f"Model: {config.num_hidden_layers}L x {config.hidden_size}W, "
281
+ f"vocab={config.mask_vocab_size}, params={sum(p.numel() for p in model.parameters())/1e6:.1f}M, {args.dtype}")
282
+
283
+ # --- Data ---
284
+ data_dir = args.data_dir
285
+ ids_arr = np.memmap(os.path.join(data_dir, "ids.bin"), dtype=np.uint32, mode="r")
286
+ resp_arr = np.memmap(os.path.join(data_dir, "resp.bin"), dtype=np.uint8, mode="r")
287
+ assert ids_arr.shape == resp_arr.shape, "ids.bin / resp.bin length mismatch"
288
+ n_tokens = len(ids_arr)
289
+ logger.info(f"Data: {n_tokens:,} tokens, seq_len={args.seq_len}")
290
+
291
+ meta_path = os.path.join(data_dir, "meta.json")
292
+ data_meta = {}
293
+ if os.path.exists(meta_path):
294
+ with open(meta_path) as f:
295
+ data_meta = json.load(f)
296
+ if not data_meta.get("val_held_out") and data_meta.get("n_val_samples", 0) > 0:
297
+ skip = int(data_meta["n_val_samples"]) * int(data_meta.get("seq_len", args.seq_len))
298
+ if skip > 0 and skip < n_tokens:
299
+ ids_arr = ids_arr[skip:]
300
+ resp_arr = resp_arr[skip:]
301
+ n_tokens = len(ids_arr)
302
+ logger.info(f"Legacy leaked val: skipped first {skip:,} tokens of "
303
+ f"ids.bin ({data_meta['n_val_samples']} samples). "
304
+ f"Train tokens now {n_tokens:,}")
305
+
306
+ # --- Validation split (for early stopping) ---
307
+ val_ids_arr = val_resp_arr = None
308
+ val_n = 0
309
+ if args.patience > 0:
310
+ v_ids = os.path.join(data_dir, "ids_val.bin")
311
+ v_resp = os.path.join(data_dir, "resp_val.bin")
312
+ if os.path.exists(v_ids) and os.path.exists(v_resp):
313
+ val_ids_arr = np.memmap(v_ids, dtype=np.uint32, mode="r")
314
+ val_resp_arr = np.memmap(v_resp, dtype=np.uint8, mode="r")
315
+ val_n = len(val_ids_arr)
316
+ logger.info(f"Val data: {val_n:,} tokens (early stopping active, "
317
+ f"patience={args.patience}, val_every={args.val_every})")
318
+ else:
319
+ logger.warning("--patience set but no ids_val.bin/resp_val.bin found; "
320
+ "re-run prepare_data.py (writes a val split by default). "
321
+ "Early stopping disabled.")
322
+ args.patience = 0
323
+
324
+ def sample_windows(batch_size, ids_src, resp_src, n_src, rng=None):
325
+ n_windows = n_src // args.seq_len
326
+ if n_windows < 1:
327
+ raise RuntimeError(
328
+ f"Need at least {args.seq_len} tokens, have {n_src}")
329
+ if rng is None:
330
+ idx = np.random.randint(0, n_windows, size=batch_size)
331
+ else:
332
+ idx = rng.randint(0, n_windows, size=batch_size)
333
+ starts = idx * args.seq_len
334
+ ids = np.stack([ids_src[s:s + args.seq_len] for s in starts])
335
+ resp = np.stack([resp_src[s:s + args.seq_len] for s in starts])
336
+ return torch.from_numpy(ids).long(), torch.from_numpy(resp).bool()
337
+
338
+ def sample_batch(batch_size, ids_arr_local, resp_arr_local, n_tokens_local,
339
+ max_ratio=None, rng=None):
340
+ """Aligned windows; mask within the response region."""
341
+ clean, resp_bool = sample_windows(
342
+ batch_size, ids_arr_local, resp_arr_local, n_tokens_local, rng=rng)
343
+ max_r = args.mask_ratio_max if max_ratio is None else max_ratio
344
+ if rng is None:
345
+ t = torch.rand(batch_size) * (max_r - args.mask_ratio_min) + args.mask_ratio_min
346
+ mask_u = torch.rand(batch_size, args.seq_len)
347
+ else:
348
+ t = torch.from_numpy(
349
+ rng.rand(batch_size) * (max_r - args.mask_ratio_min) + args.mask_ratio_min
350
+ ).float()
351
+ mask_u = torch.from_numpy(rng.rand(batch_size, args.seq_len)).float()
352
+ mask_positions = mask_u < t[:, None]
353
+ if not args.mask_all:
354
+ mask_positions = mask_positions & resp_bool
355
+ if args.eos_mask_always:
356
+ mask_positions = mask_positions | last_eos_mask(
357
+ clean, args.eos_token_id, resp_bool)
358
+ input_ids = clean.clone()
359
+ input_ids[mask_positions] = config.mask_token_id
360
+ return input_ids, clean, mask_positions, t
361
+
362
+ # --- Optimizer selection (before batch detection: it reserves memory) ---
363
+ OptimizerCls = None
364
+ if args.optimizer in ("auto", "adamw8bit"):
365
+ for mod_name, cls_name, label in (
366
+ ("torchao.optim", "AdamW8bit", "torchao"),
367
+ ("bitsandbytes.optim", "AdamW8bit", "bitsandbytes")):
368
+ try:
369
+ mod = __import__(mod_name, fromlist=[cls_name])
370
+ OptimizerCls = getattr(mod, cls_name)
371
+ logger.info(f"Optimizer: 8-bit Adam ({label})")
372
+ break
373
+ except ImportError:
374
+ continue
375
+ if OptimizerCls is None:
376
+ from torch.optim import AdamW as OptimizerCls
377
+ if args.optimizer == "adamw8bit":
378
+ logger.warning("adamw8bit requested but neither torchao nor "
379
+ "bitsandbytes is installed; using AdamW "
380
+ "(pip install torchao)")
381
+
382
+ # --- Auto batch ---
383
+ if args.batch_size <= 0 and torch.cuda.is_available():
384
+ state_bytes = 1 if OptimizerCls.__name__ == "AdamW8bit" else 4
385
+ n_params = sum(p.numel() for p in model.parameters())
386
+ reserve_bytes = 2 * n_params * state_bytes # optimizer states
387
+ # with grad accumulation, the (accum-1) earlier micro-batch grads are
388
+ # still alive when the last micro-batch's backward peaks
389
+ reserve_bytes += (args.grad_accum_steps - 1) * n_params * 2 # bf16 grads
390
+ logger.info(f"Optimizer/grad reserve: {format_bytes(reserve_bytes)} "
391
+ f"(states {state_bytes} B/param + "
392
+ f"{args.grad_accum_steps - 1} extra bf16 grads)")
393
+ logger.info("Auto-detecting max batch size...")
394
+ args.batch_size = detect_max_batch_size(
395
+ model, args.seq_len, device,
396
+ keep_free_fraction=0.1, vocab=config.mask_vocab_size,
397
+ reserve_bytes=reserve_bytes)
398
+ if args.batch_size <= 0:
399
+ args.batch_size = 8
400
+ logger.info(f"batch_size={args.batch_size} x grad_accum={args.grad_accum_steps} "
401
+ f"= effective {args.batch_size * args.grad_accum_steps}")
402
+
403
+ # --- Parameter groups ---
404
+ # Only the diffusion-new modules get full LR. embed_tokens / lm_head are
405
+ # almost entirely transferred AR rows (MASK + rainbow are 8 of 151677).
406
+ new_keys = ("timestep_emb", "timestep_modulation")
407
+ attn_key = "self_attn"
408
+ buckets = {("new", True): [], ("new", False): [],
409
+ ("attn", True): [], ("attn", False): [],
410
+ ("non_attn", True): [], ("non_attn", False): []}
411
+ for name, param in model.named_parameters():
412
+ if any(k in name for k in new_keys):
413
+ kind = "new"
414
+ elif attn_key in name:
415
+ kind = "attn"
416
+ else:
417
+ kind = "non_attn"
418
+ buckets[(kind, not no_decay_name(name, param))].append(param)
419
+
420
+ def make_group(kind, decay, lr, label):
421
+ params = buckets[(kind, decay)]
422
+ if not params:
423
+ return None
424
+ return {"params": params, "lr": lr,
425
+ "weight_decay": args.weight_decay if decay else 0.0,
426
+ "name": label}
427
+
428
+ if args.keep_knowledge:
429
+ raw = [
430
+ make_group("new", True, args.lr, "new"),
431
+ make_group("new", False, args.lr, "new_nodecay"),
432
+ make_group("attn", True, args.lr, "attention"),
433
+ make_group("attn", False, args.lr, "attention_nodecay"),
434
+ make_group("non_attn", True, args.lr * args.keep_mult, "mlp_norm_embed"),
435
+ make_group("non_attn", False, args.lr * args.keep_mult, "mlp_norm_embed_nodecay"),
436
+ ]
437
+ else:
438
+ tr_lr = args.lr * args.transferred_lr_mult
439
+ raw = [
440
+ make_group("new", True, args.lr, "new"),
441
+ make_group("new", False, args.lr, "new_nodecay"),
442
+ make_group("attn", True, tr_lr, "transferred"),
443
+ make_group("attn", False, tr_lr, "transferred_nodecay"),
444
+ make_group("non_attn", True, tr_lr, "transferred"),
445
+ make_group("non_attn", False, tr_lr, "transferred_nodecay"),
446
+ ]
447
+ param_groups = [g for g in raw if g is not None]
448
+ for g in param_groups:
449
+ n = sum(p.numel() for p in g["params"])
450
+ logger.info(f" group {g['name']}: {n:,} params, lr={g['lr']:.2e}, "
451
+ f"wd={g['weight_decay']}")
452
+
453
+ optimizer = OptimizerCls(param_groups)
454
+ scheduler = cosine_schedule_with_warmup(optimizer, args.warmup_steps, args.max_steps, args.min_lr_ratio)
455
+
456
+ start_step = 0
457
+ if args.resume_from:
458
+ ckpt_r = torch.load(args.resume_from, map_location="cpu", weights_only=False)
459
+ model.load_state_dict(ckpt_r["model_state_dict"])
460
+ start_step = int(ckpt_r.get("step", get_step_from_filename(args.resume_from) or 0))
461
+ if "optimizer_state" in ckpt_r:
462
+ try:
463
+ optimizer.load_state_dict(ckpt_r["optimizer_state"])
464
+ scheduler.load_state_dict(ckpt_r.get("scheduler_state", {}))
465
+ logger.info("Restored optimizer + scheduler state (seamless continuation)")
466
+ except Exception as e:
467
+ logger.warning(f"Could not restore optimizer state ({e}); "
468
+ "rebuilding fresh. (Changed hyperparams / param group "
469
+ "layout between segments?)")
470
+ optimizer = OptimizerCls(param_groups)
471
+ scheduler = cosine_schedule_with_warmup(
472
+ optimizer, args.warmup_steps, args.max_steps, args.min_lr_ratio)
473
+ else:
474
+ logger.warning("Checkpoint has no optimizer state (older run); "
475
+ "rebuilding fresh.")
476
+ logger.info(f"Resumed from {args.resume_from} (continuing from step {start_step})")
477
+
478
+ os.makedirs(args.output_dir, exist_ok=True)
479
+ stats_path = os.path.join(args.output_dir, "stats.jsonl")
480
+ stats_file = open(stats_path, "a")
481
+
482
+ # --- Train loop ---
483
+ @torch.no_grad()
484
+ def compute_val_loss():
485
+ """Masked CE over --val-batches of the held-out split.
486
+
487
+ Fixed mask ratio (--val-t) + deterministic aligned windows AND
488
+ seeded masks: torch.rand made the estimate depend on the training
489
+ RNG and patience fired on noise."""
490
+ model.eval()
491
+ total_ce, total_n = 0.0, 0
492
+ rng = np.random.RandomState(args.seed)
493
+ for _ in range(args.val_batches):
494
+ clean, resp_bool = sample_windows(
495
+ args.batch_size, val_ids_arr, val_resp_arr, val_n, rng=rng)
496
+ t = torch.full((args.batch_size,), args.val_t)
497
+ mask_u = torch.from_numpy(rng.rand(args.batch_size, args.seq_len)).float()
498
+ mask_positions = mask_u < t[:, None]
499
+ if not args.mask_all:
500
+ mask_positions = mask_positions & resp_bool
501
+ if args.eos_mask_always:
502
+ mask_positions = mask_positions | last_eos_mask(
503
+ clean, args.eos_token_id, resp_bool)
504
+ input_ids = clean.clone()
505
+ input_ids[mask_positions] = config.mask_token_id
506
+ input_ids = input_ids.to(device)
507
+ clean = clean.to(device)
508
+ mask_positions = mask_positions.to(device)
509
+ t = t.to(device)
510
+ logits = model(input_ids, t)
511
+ ce, n = model.compute_loss(logits, clean, mask_positions,
512
+ pad_token_id=config.pad_token_id,
513
+ eos_token_id=args.eos_token_id,
514
+ eos_weight=args.eos_weight)
515
+ total_ce += ce.item() * n
516
+ total_n += n
517
+ model.train()
518
+ return total_ce / max(1, total_n)
519
+
520
+ def save_ckpt(step, path):
521
+ torch.save({
522
+ "config": asdict(config),
523
+ "model_state_dict": model.state_dict(),
524
+ "optimizer_state": optimizer.state_dict(),
525
+ "scheduler_state": scheduler.state_dict(),
526
+ "step": step,
527
+ "metadata": {
528
+ "init_checkpoint": args.init_checkpoint,
529
+ "keep_knowledge": args.keep_knowledge,
530
+ "dtype": args.dtype,
531
+ },
532
+ }, path)
533
+ logger.info(f"Saved {path}")
534
+
535
+ model.train()
536
+ try:
537
+ scaler = torch.amp.GradScaler(
538
+ "cuda", enabled=(args.dtype == "float16" and torch.cuda.is_available()))
539
+ except AttributeError: # torch < 2.3
540
+ scaler = torch.cuda.amp.GradScaler(
541
+ enabled=(args.dtype == "float16" and torch.cuda.is_available()))
542
+ eff_bs = args.batch_size * args.grad_accum_steps
543
+ best_val, best_step, no_improve = float("inf"), 0, 0
544
+
545
+ # Ctrl+C handling: first press finishes the current step and saves it,
546
+ # second press force-quits without saving.
547
+ interrupted = False
548
+
549
+ def _handle_sigint(sig, frame):
550
+ nonlocal interrupted
551
+ if interrupted:
552
+ print("\nSecond Ctrl+C: force quitting without save.", flush=True)
553
+ os._exit(130)
554
+ interrupted = True
555
+ print("\nCtrl+C received: finishing current step, then saving...",
556
+ flush=True)
557
+
558
+ signal.signal(signal.SIGINT, _handle_sigint)
559
+
560
+ if interrupted:
561
+ logger.info("Ctrl+C before training started; nothing to save.")
562
+ stats_file.close()
563
+ sys.exit(130)
564
+
565
+ if args.curriculum:
566
+ ramp_floor = max(args.mask_ratio_min, 0.1)
567
+ logger.info(f"Curriculum: mask ratio ramps {ramp_floor:.2f} -> 1.00 "
568
+ f"over the run (val stays at fixed t={args.val_t})")
569
+ if args.curriculum_early_stop_gate and args.patience > 0:
570
+ logger.info(f"Curriculum early-stop gate ON: val at t={args.val_t} is "
571
+ f"OOD until the ramp covers it; early stopping / valbest "
572
+ f"inactive until then (--no-curriculum-early-stop-gate to "
573
+ f"disable)")
574
+ if args.eos_weight != 1.0:
575
+ logger.info(f"EOS weighting: <|im_end|> (id {args.eos_token_id}) "
576
+ f"loss x{args.eos_weight}")
577
+ if args.eos_mask_always:
578
+ logger.info("EOS mask-always: last <|im_end|> in each window is a "
579
+ "masked target (curriculum cannot starve the terminator)")
580
+ logger.info(f"Training {args.max_steps:,} steps... (Ctrl+C saves current step)")
581
+ t0 = time.time()
582
+ step = start_step
583
+ for step in range(start_step + 1, args.max_steps + 1):
584
+ optimizer.zero_grad(set_to_none=True)
585
+ total_ce = 0.0
586
+ total_n = 0
587
+ val_loss = None
588
+ cur_max_ratio = None
589
+ if args.curriculum:
590
+ # easy-to-hard: mask ratio ramps from a 0.1 floor to 1.0 across the
591
+ # run. Starting at 0.0 leaves whole micro-batches unmasked (no grad
592
+ # path); the floor keeps t ~ U(0, 0.1) at step 1 (~100 masked
593
+ # tokens per 2048-token batch). Segment-relative: any resume
594
+ # segment covers the full ramp, so a shortened segment still trains
595
+ # the high-mask regime (where im_end lives) end to end.
596
+ seg_steps = max(1, args.max_steps - start_step)
597
+ progress = (step - start_step) / seg_steps
598
+ ramp_floor = max(args.mask_ratio_min, 0.1)
599
+ cur_max_ratio = ramp_floor + (1.0 - ramp_floor) * progress
600
+ did_backward = False
601
+ for _ in range(args.grad_accum_steps):
602
+ input_ids, clean, mask_positions, t = sample_batch(
603
+ args.batch_size, ids_arr, resp_arr, n_tokens,
604
+ max_ratio=cur_max_ratio)
605
+ input_ids = input_ids.to(device)
606
+ clean = clean.to(device)
607
+ mask_positions = mask_positions.to(device)
608
+ t = t.to(device)
609
+
610
+ logits = model(input_ids, t)
611
+ ce, n = model.compute_loss(logits, clean, mask_positions, pad_token_id=config.pad_token_id,
612
+ eos_token_id=args.eos_token_id,
613
+ eos_weight=args.eos_weight)
614
+ if n == 0:
615
+ continue # no masked tokens this micro-batch (early curriculum);
616
+ # compute_loss returns a constant, not a grad path
617
+ loss = ce / args.grad_accum_steps
618
+ total_ce += ce.item() * n # weighted by masked-token count: true per-token CE
619
+ total_n += n
620
+ scaler.scale(loss).backward()
621
+ did_backward = True
622
+
623
+ if did_backward:
624
+ scaler.unscale_(optimizer)
625
+ torch.nn.utils.clip_grad_norm_(model.parameters(), args.max_grad_norm)
626
+ scaler.step(optimizer)
627
+ scaler.update()
628
+ scheduler.step()
629
+
630
+ if interrupted:
631
+ save_ckpt(step, os.path.join(args.output_dir, f"step_{step}.pt"))
632
+ logger.info(f"Ctrl+C: saved current step -> step_{step}.pt")
633
+ break
634
+
635
+ if step % args.log_every == 0:
636
+ elapsed = time.time() - t0
637
+ # segment-relative step count: cumulative `step` over segment
638
+ # elapsed would print a fake 100K+ tok/s after --resume-from
639
+ tok_per_s = eff_bs * args.seq_len * (step - start_step) / max(1.0, elapsed)
640
+ vram = torch.cuda.max_memory_allocated() / 1e9 if torch.cuda.is_available() else 0
641
+ steps_done = max(1, step - start_step)
642
+ eta_s = (args.max_steps - step) * (elapsed / steps_done)
643
+ finish = datetime.datetime.now() + datetime.timedelta(seconds=eta_s)
644
+ eta_str = (f"ETA {format_eta(eta_s)} (el {format_eta(elapsed)}, "
645
+ f"done {finish.strftime('%I:%M %p').lstrip('0')})")
646
+ val_loss = None
647
+ if args.patience > 0 and step % args.val_every == 0:
648
+ val_loss = compute_val_loss()
649
+ logger.info(f"step {step:>6d}/{args.max_steps} | loss={total_ce/max(1,total_n):.4f}"
650
+ f" | val={val_loss:.4f} | lr={scheduler.get_last_lr()[0]:.2e} | "
651
+ f"{tok_per_s:.0f} tok/s | vram={vram:.1f}GB | {eta_str}")
652
+ else:
653
+ logger.info(f"step {step:>6d}/{args.max_steps} | loss={total_ce/max(1,total_n):.4f}"
654
+ f" | lr={scheduler.get_last_lr()[0]:.2e} | "
655
+ f"{tok_per_s:.0f} tok/s | vram={vram:.1f}GB | {eta_str}")
656
+ stats_file.write(json.dumps({
657
+ "step": step, "loss": total_ce / max(1, total_n),
658
+ "lr": float(scheduler.get_last_lr()[0]), "tok_per_s": tok_per_s,
659
+ "vram_gb": vram, "val_loss": val_loss,
660
+ }) + "\n")
661
+ stats_file.flush()
662
+
663
+ if step % args.save_every == 0:
664
+ save_ckpt(step, os.path.join(args.output_dir, f"step_{step}.pt"))
665
+ cleanup_checkpoints(args.output_dir, args.keep_last_n,
666
+ args.keep_best_n, stats_path)
667
+
668
+ if args.patience > 0 and step % args.val_every == 0:
669
+ if val_loss is None:
670
+ val_loss = compute_val_loss()
671
+ # Curriculum gate: before the ramp covers --val-t the val estimate
672
+ # is extrapolation, not generalization. Keep the number for the
673
+ # curve but do not let it move best_val / patience / valbest.
674
+ gated = (args.curriculum_early_stop_gate and args.curriculum
675
+ and cur_max_ratio is not None and cur_max_ratio < args.val_t)
676
+ if gated:
677
+ logger.info(f" [val] {val_loss:.4f} gated (t_max {cur_max_ratio:.3f} "
678
+ f"< val-t {args.val_t}); early stopping inactive")
679
+ elif val_loss < best_val - args.min_delta:
680
+ best_val, best_step, no_improve = val_loss, step, 0
681
+ save_ckpt(step, os.path.join(args.output_dir, f"step_{step}_valbest.pt"))
682
+ logger.info(f" [val] new best {val_loss:.4f} -> step_{step}_valbest.pt")
683
+ else:
684
+ no_improve += 1
685
+ logger.info(f" [val] no improvement ({no_improve}/{args.patience}), "
686
+ f"best={best_val:.4f} @ step {best_step}")
687
+ if no_improve >= args.patience:
688
+ logger.info(f"Early stop at step {step}; best val {best_val:.4f} "
689
+ f"at step {best_step} (step_{best_step}_valbest.pt)")
690
+ save_ckpt(step, os.path.join(args.output_dir, f"step_{step}.pt"))
691
+ break
692
+
693
+ stats_file.close()
694
+ if interrupted:
695
+ logger.info(f"Stopped early (Ctrl+C) at step {step}. "
696
+ f"Resume with --resume-from {args.output_dir}/step_{step}.pt")
697
+ else:
698
+ logger.info(f"Done. Final checkpoint: {args.output_dir}/step_{step}.pt")
699
+
700
+
701
+ if __name__ == "__main__":
702
+ main()