CodeSoft commited on
Commit
c2ca866
·
verified ·
1 Parent(s): 5f19261

Upload 12 files

Browse files
config.json ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "hidden_size": 768,
3
+ "intermediate_size": 2112,
4
+ "num_hidden_layers": 16,
5
+ "num_attention_heads": 12,
6
+ "num_key_value_heads": 6,
7
+ "head_dim": 64,
8
+ "vocab_size": 32010,
9
+ "mask_vocab_size": 32010,
10
+ "max_position_embeddings": 5120,
11
+ "rope_theta": 10000.0,
12
+ "rms_norm_eps": 1e-06,
13
+ "hidden_act": "silu",
14
+ "timestep_emb_hidden": 768,
15
+ "mask_token_id": 32000,
16
+ "pad_token_id": 1,
17
+ "mask_ratio_min": 0.0,
18
+ "mask_ratio_max": 1.0,
19
+ "tie_word_embeddings": false,
20
+ "model_type": "metadiffusion",
21
+ "architectures": [
22
+ "MetaDiffusionForCausalLM"
23
+ ]
24
+ }
generation_config.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "bos_token_id": 0,
3
+ "eos_token_id": 2,
4
+ "pad_token_id": 1,
5
+ "mask_token_id": 32000,
6
+ "temperature": 0.7,
7
+ "repetition_penalty": 1.5,
8
+ "re_mask": 0.1,
9
+ "num_steps": 128,
10
+ "max_new_tokens": 96,
11
+ "use_cache": false,
12
+ "transformers_version": "4.40.0"
13
+ }
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5d7d0ce3745a3a99ff6863849dff20f2b536f0e2d2092fe263072056715c01d5
3
+ size 339086208
scripts/chat.py ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ chat.py: Multi-turn ChatML chat with MetaDiffusion chat-SFT models.
4
+
5
+ Works with:
6
+ - an exported dir (config.json + model.safetensors + tokenizer/)
7
+ - a training checkpoint (step_*.pt) with --tokenizer
8
+
9
+ Usage:
10
+ Interactive:
11
+ python3 chat.py --model-path MetaDiffusion-150M-ChatBase/
12
+ python3 chat.py --model-path checkpoints_chat/best.pt \
13
+ --tokenizer data/tokenizer
14
+
15
+ One-shot:
16
+ python3 chat.py --model-path MetaDiffusion-150M-ChatBase/ \
17
+ --prompt "What is 2+2?" --max-new-tokens 128
18
+ """
19
+
20
+ import argparse
21
+ import json
22
+ import math
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ import torch
27
+ import torch.nn.functional as F
28
+ from safetensors.torch import load_file
29
+ from transformers import AutoTokenizer
30
+
31
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
32
+ from model import MetaDiffusionLM, MetaDiffusionConfig # noqa: E402
33
+
34
+ MASK_TOKEN_ID = 32000
35
+ CHAT_TOKENS = ["<|im_start|>", "<|im_end|>"] + [f"<|r{i}|>" for i in range(1, 8)]
36
+ IM_START, IM_END = "<|im_start|>", "<|im_end|>"
37
+
38
+
39
+ def build_config(config_dict):
40
+ valid = {k: v for k, v in config_dict.items()
41
+ if k in MetaDiffusionConfig.__dataclass_fields__}
42
+ config = MetaDiffusionConfig(**valid)
43
+ config.tie_word_embeddings = False
44
+ return config
45
+
46
+
47
+ def load_model(model_path, device):
48
+ path = Path(model_path)
49
+ if path.is_dir():
50
+ with open(path / "config.json") as f:
51
+ config = build_config(json.load(f))
52
+ model = MetaDiffusionLM(config).to(device)
53
+ sd = load_file(path / "model.safetensors")
54
+ sd = {k[len("model."):] if k.startswith("model.") else k: v
55
+ for k, v in sd.items()}
56
+ missing, unexpected = model.load_state_dict(sd, strict=False)
57
+ if missing or unexpected:
58
+ print(f" Warning: missing={missing[:3]} unexpected={unexpected[:3]}")
59
+ else:
60
+ ckpt = torch.load(model_path, map_location=device, weights_only=False)
61
+ config = build_config(ckpt["config"])
62
+ model = MetaDiffusionLM(config).to(device)
63
+ sd = {k.replace("_orig_mod.", "", 1) if k.startswith("_orig_mod.") else k: v
64
+ for k, v in ckpt["model_state_dict"].items()}
65
+ model.load_state_dict(sd)
66
+ print(f" Loaded {sum(p.numel() for p in model.parameters())/1e6:.1f}M params, "
67
+ f"vocab={config.mask_vocab_size}")
68
+ return model
69
+
70
+
71
+ def ensure_chat_tokens(tokenizer):
72
+ """Add ChatML + rainbow tokens if missing (base tokenizer case).
73
+
74
+ Id 32000 is the diffusion [MASK] id, so a reserved filler token takes it
75
+ first; chat tokens must land at 32001..32009.
76
+ """
77
+ if tokenizer.convert_tokens_to_ids(IM_START) == tokenizer.unk_token_id:
78
+ if len(tokenizer) == 32000:
79
+ tokenizer.add_special_tokens({"additional_special_tokens": ["<|reserved|>"]})
80
+ tokenizer.add_special_tokens({"additional_special_tokens": CHAT_TOKENS})
81
+ assert tokenizer.convert_tokens_to_ids(IM_END) == 32002, \
82
+ "chat token ids wrong (collide with mask id 32000)"
83
+ return tokenizer
84
+
85
+
86
+ def format_messages(messages):
87
+ parts = []
88
+ for m in messages:
89
+ parts.append(f"{IM_START}{m['role']}\n{m['content']}{IM_END}")
90
+ return "\n".join(parts)
91
+
92
+
93
+ def cumulative_unmask_frac(i, N):
94
+ return 0.5 * (1 - math.cos(math.pi * i / N))
95
+
96
+
97
+ @torch.no_grad()
98
+ def generate_response(model, tokenizer, prompt_ids, gen_len, num_steps,
99
+ temperature, repetition_penalty, device, watch=False,
100
+ stop_on_end=True):
101
+ """Denoise a block of [MASK] tokens after the prompt (inference.py schedule).
102
+
103
+ With stop_on_end=True, denoising halts as soon as <|im_end|> or </s> is
104
+ committed in the response region: committed tokens never change, so the
105
+ output is identical, but we skip filling garbage after the terminator.
106
+ """
107
+ model.eval()
108
+ total_len = prompt_ids.shape[1] + gen_len
109
+ x = torch.full((1, total_len), MASK_TOKEN_ID, device=device, dtype=torch.long)
110
+ x[0, : prompt_ids.shape[1]] = prompt_ids
111
+ mask_id = MASK_TOKEN_ID
112
+ im_end_id = tokenizer.convert_tokens_to_ids(IM_END)
113
+ eos_id = tokenizer.eos_token_id
114
+ prompt_len = prompt_ids.shape[1]
115
+
116
+ for i in range(num_steps):
117
+ frac_now = cumulative_unmask_frac(i, num_steps)
118
+ frac_next = cumulative_unmask_frac(i + 1, num_steps)
119
+
120
+ n_masked = (x == mask_id).sum().item()
121
+ n_total = int((frac_next - frac_now) * gen_len + 0.5)
122
+ if i == num_steps - 1:
123
+ n_unmask = n_masked
124
+ else:
125
+ n_unmask = max(n_total, 1) if n_masked > 0 else 0
126
+
127
+ t = 1.0 - frac_now
128
+ logits = model(x, torch.full((1,), t, device=device))
129
+
130
+ # Never predict the mask token
131
+ logits[:, :, mask_id] = -1e9
132
+
133
+ # Repetition penalty over everything already on the sequence
134
+ if repetition_penalty != 1.0:
135
+ for tok in x[0].unique():
136
+ ti = tok.item()
137
+ logits[0, :, ti] = torch.where(
138
+ logits[0, :, ti] < 0,
139
+ logits[0, :, ti] * repetition_penalty,
140
+ logits[0, :, ti] / repetition_penalty,
141
+ )
142
+
143
+ mask_positions = x == mask_id
144
+ mask_logits = logits[mask_positions]
145
+ probs = F.softmax(mask_logits / temperature, dim=-1)
146
+ sampled = torch.multinomial(probs, 1).squeeze(-1)
147
+ mask_flat = mask_positions.nonzero(as_tuple=False)
148
+
149
+ if n_unmask < mask_positions.sum():
150
+ # Left-to-right commit: fill the leftmost masked positions first
151
+ # (semi-autoregressive block generation, as in LLaDA-MoE eval).
152
+ # Confidence-based commit lets <|im_end|> win the race at ANY
153
+ # position and commits mid-block tokens before position 0, which
154
+ # produced empty responses and fragment-style output on this model.
155
+ fill_positions = mask_flat[:n_unmask]
156
+ for idx, tok in zip(fill_positions, sampled[:n_unmask]):
157
+ x[idx[0], idx[1]] = tok
158
+ else:
159
+ x[mask_positions] = sampled
160
+
161
+ if watch:
162
+ remaining = (x == mask_id).sum().item()
163
+ live_toks = [t for t in x[0, prompt_len:].tolist() if t != mask_id]
164
+ partial = tokenizer.decode(
165
+ cut_response(live_toks, tokenizer), skip_special_tokens=True
166
+ ).strip()
167
+ if len(partial) > 70:
168
+ partial = partial[:70] + "..."
169
+ line = (f"step {i+1:3d}/{num_steps} | t={t:.3f} "
170
+ f"| masks={remaining:3d} | {partial}")
171
+ if sys.stdout.isatty():
172
+ # live in-place update
173
+ sys.stdout.write("\r" + line[:99].ljust(99))
174
+ sys.stdout.flush()
175
+ elif i % max(1, num_steps // 8) == 0:
176
+ print(line)
177
+
178
+ # Stop early once the model commits a terminator in the response
179
+ if stop_on_end and ((x[0, prompt_len:] == im_end_id).any() or
180
+ (x[0, prompt_len:] == eos_id).any()):
181
+ break
182
+
183
+ if watch and sys.stdout.isatty():
184
+ sys.stdout.write("\n")
185
+ return x
186
+
187
+
188
+ def cut_response(tokens, tokenizer):
189
+ """Cut generated token list at <|im_end|> or </s>; drop rainbow/pad tokens."""
190
+ im_end_id = tokenizer.convert_tokens_to_ids(IM_END)
191
+ eos_id = tokenizer.eos_token_id
192
+ rainbow_ids = {tokenizer.convert_tokens_to_ids(f"<|r{i}|>") for i in range(1, 8)}
193
+ out = []
194
+ for t in tokens:
195
+ if t == im_end_id or t == eos_id:
196
+ break
197
+ if t in rainbow_ids or t == tokenizer.pad_token_id:
198
+ continue
199
+ out.append(t)
200
+ return out
201
+
202
+
203
+ def run_turn(model, tokenizer, messages, args, device):
204
+ prompt = format_messages(messages) + f"\n{IM_START}assistant\n"
205
+ prompt_ids = torch.tensor([tokenizer.encode(prompt, add_special_tokens=False)],
206
+ device=device)
207
+ # Retry on empty responses: small models occasionally commit <|im_end|>
208
+ # as the first token. Bump temperature per attempt
209
+ for attempt in range(3):
210
+ x = generate_response(model, tokenizer, prompt_ids, args.max_new_tokens,
211
+ args.num_steps,
212
+ args.temperature * (1 + 0.15 * attempt),
213
+ args.repetition_penalty, device, watch=args.watch)
214
+ response_tokens = x[0, prompt_ids.shape[1]:].tolist()
215
+ response_tokens = cut_response(response_tokens, tokenizer)
216
+ text = tokenizer.decode(response_tokens, skip_special_tokens=True).strip()
217
+ if text:
218
+ return text
219
+ return "(empty response)"
220
+
221
+
222
+ def main():
223
+ parser = argparse.ArgumentParser(description="MetaDiffusion chat (ChatML)")
224
+ parser.add_argument("--model-path", required=True,
225
+ help="Exported dir (config+safetensors+tokenizer) or step_*.pt")
226
+ parser.add_argument("--tokenizer", default=None,
227
+ help="Tokenizer dir (needed when --model-path is a step_*.pt)")
228
+ parser.add_argument("--prompt", default=None, help="One-shot prompt (else REPL)")
229
+ parser.add_argument("--system", default="You are a helpful assistant.",
230
+ help="System prompt for the REPL")
231
+ parser.add_argument("--max-new-tokens", type=int, default=96,
232
+ help="Response block size)")
233
+ parser.add_argument("--num-steps", type=int, default=128, help="Denoising steps")
234
+ parser.add_argument("--temperature", type=float, default=0.7)
235
+ parser.add_argument("--repetition-penalty", type=float, default=1.5,
236
+ help="Small diffusion models loop without a strong penalty")
237
+ parser.add_argument("--device", default="cuda")
238
+ parser.add_argument("--watch", action="store_true", help="Show denoising progress")
239
+ args = parser.parse_args()
240
+
241
+ device = torch.device(args.device if torch.cuda.is_available() else "cpu")
242
+ print(f"[*] Loading model from {args.model_path}")
243
+ model = load_model(args.model_path, device)
244
+
245
+ model_path = Path(args.model_path)
246
+ tok_path = args.tokenizer
247
+ if tok_path is None:
248
+ if model_path.is_dir():
249
+ cand = model_path / "tokenizer"
250
+ if not cand.exists() and (model_path / "tokenizer.json").exists():
251
+ cand = model_path # exported dirs keep the tokenizer at root
252
+ tok_path = str(cand)
253
+ if tok_path is None or not Path(tok_path).exists():
254
+ raise Exception("No tokenizer")
255
+ tokenizer = AutoTokenizer.from_pretrained(str(tok_path))
256
+ tokenizer = ensure_chat_tokens(tokenizer)
257
+ print(f"[*] Tokenizer: {tok_path} (vocab {len(tokenizer)})")
258
+
259
+ if args.prompt:
260
+ messages = [{"role": "user", "content": args.prompt}]
261
+ text = run_turn(model, tokenizer, messages, args, device)
262
+ print(f"\nUser: {args.prompt}\nAssistant: {text}\n")
263
+ return
264
+
265
+ print("\nMetaDiffusion chat, type 'exit', 'quit' or Ctrl-D to leave.\n")
266
+ messages = [{"role": "system", "content": args.system}]
267
+ while True:
268
+ try:
269
+ user_input = input("You: ").strip()
270
+ except (EOFError, KeyboardInterrupt):
271
+ print()
272
+ break
273
+ if user_input.lower() in ("exit", "quit"):
274
+ break
275
+ if not user_input:
276
+ continue
277
+ messages.append({"role": "user", "content": user_input})
278
+ text = run_turn(model, tokenizer, messages, args, device)
279
+ print(f"Assistant: {text}\n")
280
+ messages.append({"role": "assistant", "content": text})
281
+
282
+
283
+ if __name__ == "__main__":
284
+ main()
scripts/convert_data.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ convert_data.py: turn local ChatML datasets into tokenized train.pt/val.pt
4
+ for train_chat.py.
5
+
6
+ Accepts .jsonl, .json, and .parquet files (auto-detected by extension), and
7
+ these record shapes (one conversation per record):
8
+
9
+ {"messages": [{"role": "user", "content": ...}, ...]} # HF ChatML style
10
+ [{"role": ..., "content": ...}, ...] # bare message list
11
+ {"conversation": [...]} or {"chat": [...]} # aliases
12
+ {"instruction": ..., "input": ..., "output": ...} # alpaca style (converted)
13
+ {"data": [...]} / {"conversations": [...]} # JSON containers of the above
14
+
15
+ Parquet rows may store the messages column as a list of dicts or as a JSON
16
+ string (both work).
17
+
18
+ Usage:
19
+ python3 convert_data.py --model-path . --input my_data.jsonl \
20
+ --output data/my_data
21
+ python3 convert_data.py --model-path . --input a.jsonl b.jsonl \
22
+ --output data/mixed
23
+ python3 convert_data.py --model-path . --input ./folder \
24
+ --output data/folder
25
+
26
+ Then train:
27
+ python3 train_chat.py --model-path . --data-dir data/my_data \
28
+ --output-dir my_checkpoints --lr 7e-5 --epochs 3
29
+ """
30
+
31
+ import argparse
32
+ import json
33
+ import sys
34
+ from pathlib import Path
35
+
36
+ import numpy as np
37
+ from transformers import AutoTokenizer
38
+
39
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
40
+
41
+ from prepare_data import ( # noqa: E402
42
+ add_chat_tokens,
43
+ build_conv_segments,
44
+ save_dataset,
45
+ tokenize_and_split,
46
+ )
47
+
48
+ SUPPORTED_EXTS = {".jsonl", ".json", ".parquet"}
49
+ CONTAINER_KEYS = ("conversations", "data", "rows", "examples")
50
+ MESSAGE_KEYS = ("messages", "conversation", "chat")
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # File reading
55
+ # ---------------------------------------------------------------------------
56
+
57
+ def collect_files(paths):
58
+ """Expand --input args (files and/or dirs) into a sorted file list."""
59
+ files = []
60
+ for p in paths:
61
+ p = Path(p)
62
+ if p.is_dir():
63
+ files.extend(f for f in sorted(p.iterdir())
64
+ if f.suffix.lower() in SUPPORTED_EXTS)
65
+ elif p.suffix.lower() in SUPPORTED_EXTS:
66
+ files.append(p)
67
+ else:
68
+ print(f"[!] Skipping unsupported file: {p} (want .jsonl/.json/.parquet)")
69
+ return files
70
+
71
+
72
+ def iter_records(path):
73
+ """Yield one raw record (dict or list) per conversation from a file."""
74
+ ext = path.suffix.lower()
75
+ if ext == ".jsonl":
76
+ with open(path) as f:
77
+ for line in f:
78
+ line = line.strip()
79
+ if not line:
80
+ continue
81
+ yield json.loads(line)
82
+ elif ext == ".json":
83
+ with open(path) as f:
84
+ obj = json.load(f)
85
+ if isinstance(obj, list):
86
+ yield from obj
87
+ elif isinstance(obj, dict):
88
+ for key in CONTAINER_KEYS:
89
+ if isinstance(obj.get(key), list):
90
+ yield from obj[key]
91
+ return
92
+ yield obj # single-conversation file
93
+ elif ext == ".parquet":
94
+ import pandas as pd # lazy: only needed for parquet
95
+ df = pd.read_parquet(path)
96
+ for _, row in df.iterrows():
97
+ yield dict(row)
98
+ else:
99
+ raise ValueError(f"Unsupported extension: {path}")
100
+
101
+
102
+ def normalize_record(rec):
103
+ """Turn one record into a list of {role, content} messages, or None."""
104
+ if isinstance(rec, list):
105
+ msgs = [m for m in rec
106
+ if isinstance(m, dict) and m.get("content")]
107
+ return msgs or None
108
+
109
+ if not isinstance(rec, dict):
110
+ return None
111
+
112
+ # ChatML-style keys (value may be a list of dicts, a numpy array of dicts
113
+ # from parquet round-trips, or a JSON string)
114
+ for key in MESSAGE_KEYS:
115
+ v = rec.get(key)
116
+ if isinstance(v, str):
117
+ try:
118
+ v = json.loads(v)
119
+ except json.JSONDecodeError:
120
+ continue
121
+ if isinstance(v, (list, np.ndarray)):
122
+ msgs = [m for m in v
123
+ if isinstance(m, dict) and m.get("content")]
124
+ if msgs:
125
+ return msgs
126
+
127
+ # Alpaca-style record: instruction / input / output
128
+ if rec.get("instruction") and rec.get("output"):
129
+ user = rec["instruction"]
130
+ if rec.get("input"):
131
+ user += f"\n\n{rec['input']}"
132
+ return [{"role": "user", "content": user},
133
+ {"role": "assistant", "content": rec["output"]}]
134
+
135
+ return None
136
+
137
+
138
+ def load_convs_from_files(files):
139
+ """Build (roles, segs) conversations from all files."""
140
+ convs = []
141
+ for path in files:
142
+ n_before = len(convs)
143
+ for rec in iter_records(path):
144
+ msgs = normalize_record(rec)
145
+ if msgs is None:
146
+ continue
147
+ segs, roles = build_conv_segments(msgs)
148
+ if not segs:
149
+ continue
150
+ convs.append((roles, segs))
151
+ print(f"[*] {path.name}: {len(convs) - n_before} conversations")
152
+ return convs
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # Main
157
+ # ---------------------------------------------------------------------------
158
+
159
+ def main():
160
+ parser = argparse.ArgumentParser(
161
+ description="Convert local ChatML data (jsonl/json/parquet) to tokenized .pt")
162
+ parser.add_argument("--model-path", default=".",
163
+ help="Dir with tokenizer.json (the release dir works)")
164
+ parser.add_argument("--input", nargs="+", required=True,
165
+ help="File(s) or dir(s): .jsonl, .json, .parquet")
166
+ parser.add_argument("--output", default="data/converted",
167
+ help="Output dir (train.pt, val.pt, tokenizer/, stats.json)")
168
+ parser.add_argument("--val-size", type=int, default=500, help="Held-out examples")
169
+ parser.add_argument("--max-len", type=int, default=1024, help="Max tokens per example")
170
+ parser.add_argument("--max-resp-tokens", type=int, default=256,
171
+ help="Cap on target response tokens (keeps <|im_end|>)")
172
+ parser.add_argument("--seed", type=int, default=42)
173
+ args = parser.parse_args()
174
+
175
+ out = Path(args.output)
176
+ out.mkdir(parents=True, exist_ok=True)
177
+
178
+ print(f"[*] Loading tokenizer from {args.model_path}")
179
+ tokenizer = AutoTokenizer.from_pretrained(args.model_path)
180
+ add_chat_tokens(tokenizer)
181
+ tokenizer.save_pretrained(out / "tokenizer")
182
+
183
+ files = collect_files(args.input)
184
+ if not files:
185
+ print("[!] No .jsonl/.json/.parquet files found in the inputs.")
186
+ sys.exit(1)
187
+ print(f"[*] Files: {', '.join(f.name for f in files)}")
188
+
189
+ convs = load_convs_from_files(files)
190
+ if not convs:
191
+ print("[!] No conversations parsed (check the record shapes in the docstring).")
192
+ sys.exit(1)
193
+ print(f"[*] Total: {len(convs)} conversations")
194
+
195
+ # Auto-scale the val split: never take everything for small datasets
196
+ val_size = min(args.val_size, max(1, len(convs) // 10))
197
+ if val_size != args.val_size:
198
+ print(f"[*] Small dataset: using val_size={val_size}")
199
+
200
+ train_examples, val_examples, skipped, dupes = tokenize_and_split(
201
+ convs, set(), tokenizer, val_size, args.max_len,
202
+ args.max_resp_tokens, args.seed)
203
+
204
+ save_dataset(out, train_examples, val_examples, tokenizer, skipped, dupes)
205
+
206
+
207
+ if __name__ == "__main__":
208
+ main()
scripts/export_hf.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ export_hf.py: Export a chat-SFT checkpoint to a HuggingFace-style dir.
4
+
5
+ Output dir contains:
6
+ model.safetensors (weights, "model."-prefixed keys)
7
+ config.json (architecture, mask_vocab_size = 32010)
8
+ generation_config.json (sampling defaults for chat)
9
+ tokenizer.json / tokenizer_config.json (with ChatML + rainbow tokens)
10
+ README.md (minimal)
11
+
12
+ Usage:
13
+ python3 export_hf.py \
14
+ --checkpoint checkpoints_chat/step_3000.pt \
15
+ --tokenizer data/no_robots_chatml/tokenizer \
16
+ --output MetaDiffusion-150M-Chat/
17
+ """
18
+
19
+ import argparse
20
+ import json
21
+ import shutil
22
+ import sys
23
+ from dataclasses import asdict
24
+ from pathlib import Path
25
+
26
+ import torch
27
+ from safetensors.torch import save_file
28
+
29
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
30
+ from model import MetaDiffusionConfig # noqa: E402
31
+
32
+ KEY_MAP = {
33
+ "embed_tokens.weight": "model.embed_tokens.weight",
34
+ "norm.weight": "model.norm.weight",
35
+ "lm_head.weight": "model.lm_head.weight",
36
+ }
37
+
38
+ LAYER_KEY_MAP = {
39
+ "input_layernorm.weight": "input_layernorm.weight",
40
+ "self_attn.q_proj.weight": "self_attn.q_proj.weight",
41
+ "self_attn.k_proj.weight": "self_attn.k_proj.weight",
42
+ "self_attn.v_proj.weight": "self_attn.v_proj.weight",
43
+ "self_attn.o_proj.weight": "self_attn.o_proj.weight",
44
+ "post_attention_layernorm.weight": "post_attention_layernorm.weight",
45
+ "mlp.gate_proj.weight": "mlp.gate_proj.weight",
46
+ "mlp.up_proj.weight": "mlp.up_proj.weight",
47
+ "mlp.down_proj.weight": "mlp.down_proj.weight",
48
+ "timestep_residual.proj.weight": "timestep_residual.proj.weight",
49
+ "timestep_residual.proj.bias": "timestep_residual.proj.bias",
50
+ }
51
+
52
+ TIMESTEP_KEY_MAP = {
53
+ "timestep_emb.mlp.0.weight": "model.timestep_emb.mlp.0.weight",
54
+ "timestep_emb.mlp.0.bias": "model.timestep_emb.mlp.0.bias",
55
+ "timestep_emb.mlp.2.weight": "model.timestep_emb.mlp.2.weight",
56
+ "timestep_emb.mlp.2.bias": "model.timestep_emb.mlp.2.bias",
57
+ }
58
+
59
+ GENERATION_CONFIG = {
60
+ "bos_token_id": 0,
61
+ "eos_token_id": 2,
62
+ "pad_token_id": 1,
63
+ "mask_token_id": 32000,
64
+ "temperature": 0.7,
65
+ "repetition_penalty": 1.5,
66
+ "re_mask": 0.1,
67
+ "num_steps": 128,
68
+ "max_new_tokens": 96,
69
+ "use_cache": False,
70
+ "transformers_version": "4.40.0",
71
+ }
72
+
73
+
74
+ def remap_state_dict(state_dict, half=False):
75
+ # Strip torch.compile's _orig_mod. prefix (old checkpoints saved from a
76
+ # compiled model have it; training now saves clean keys)
77
+ state_dict = {k.replace("_orig_mod.", "", 1) if k.startswith("_orig_mod.") else k: v
78
+ for k, v in state_dict.items()}
79
+ new_dict = {}
80
+ for key, tensor in state_dict.items():
81
+ if key in KEY_MAP:
82
+ new_key = KEY_MAP[key]
83
+ elif key in TIMESTEP_KEY_MAP:
84
+ new_key = TIMESTEP_KEY_MAP[key]
85
+ elif key.startswith("layers."):
86
+ parts = key.split(".")
87
+ # layers.N.<component>.<rest>
88
+ layer_idx, component = parts[1], parts[2]
89
+ rest = ".".join(parts[3:])
90
+ comp_key = f"{component}.{rest}" if rest else component
91
+ new_key = f"model.layers.{layer_idx}.{comp_key}"
92
+ else:
93
+ new_key = key
94
+ new_dict[new_key] = tensor.half() if half else tensor.float()
95
+ return new_dict
96
+
97
+
98
+ def package_scripts(out):
99
+ """Copy the runnable pipeline into out/scripts/ so the release is
100
+ self-contained: chat, finetune, and re-export work from the artifact."""
101
+ src = Path(__file__).resolve().parent
102
+ root = src.parent # model.py lives in the project root
103
+ scripts_dir = out / "scripts"
104
+ scripts_dir.mkdir(parents=True, exist_ok=True)
105
+ for name in ["model.py", "chat.py", "prepare_data.py", "train_chat.py",
106
+ "export_hf.py", "convert_data.py"]:
107
+ cand = src / name if (src / name).exists() else root / name
108
+ if cand.exists():
109
+ shutil.copy2(cand, scripts_dir / name)
110
+ req = scripts_dir / "requirements.txt"
111
+ if not req.exists():
112
+ req.write_text("torch>=2.2\ntransformers>=4.40\nsafetensors>=0.4\n"
113
+ "datasets>=2.18\nnumpy>=1.26\n"
114
+ "pandas>=2.0\npyarrow>=14.0\n")
115
+ print(f"[*] Packaged scripts -> {scripts_dir}")
116
+
117
+
118
+ def export(checkpoint_path, tokenizer_dir, output_dir, half=False):
119
+ out = Path(output_dir)
120
+ out.mkdir(parents=True, exist_ok=True)
121
+
122
+ print(f"[*] Loading checkpoint {checkpoint_path}")
123
+ ckpt = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
124
+ config = MetaDiffusionConfig(
125
+ **{k: v for k, v in ckpt["config"].items()
126
+ if k in MetaDiffusionConfig.__dataclass_fields__}
127
+ )
128
+ config.tie_word_embeddings = False
129
+
130
+ print("[*] Remapping state dict...")
131
+ state_dict = remap_state_dict(ckpt["model_state_dict"], half=half)
132
+ save_file(state_dict, out / "model.safetensors")
133
+ print(f"[*] Saved {len(state_dict)} tensors -> {out / 'model.safetensors'} "
134
+ f"({'fp16' if half else 'fp32'})")
135
+
136
+ # Keep vocab fields consistent with the actual weights: the chat pipeline
137
+ # resizes embed_tokens/lm_head to 32010 (Supra 32000 + chat tokens), and
138
+ # the ckpt config still carries vocab_size=32000 from the base. Any loader
139
+ # using vocab_size to size embeddings would fail with a size mismatch.
140
+ n_vocab = state_dict["model.lm_head.weight"].shape[0]
141
+ config.vocab_size = n_vocab
142
+ config.mask_vocab_size = n_vocab
143
+ print(f"[*] Vocab in config: {n_vocab} (matches weights)")
144
+
145
+ config_dict = asdict(config)
146
+ config_dict.pop("dtype", None) # transformers chokes on "torch.float32" strings
147
+ config_dict["model_type"] = "metadiffusion"
148
+ config_dict["architectures"] = ["MetaDiffusionForCausalLM"]
149
+ with open(out / "config.json", "w") as f:
150
+ json.dump(config_dict, f, indent=2)
151
+
152
+ with open(out / "generation_config.json", "w") as f:
153
+ json.dump(GENERATION_CONFIG, f, indent=2)
154
+
155
+ # Copy tokenizer (has ChatML + rainbow tokens)
156
+ tok_dir = Path(tokenizer_dir)
157
+ for name in ["tokenizer.json", "tokenizer_config.json", "special_tokens_map.json"]:
158
+ src = tok_dir / name
159
+ if src.exists():
160
+ shutil.copy2(src, out / name)
161
+
162
+ package_scripts(out)
163
+ print(f"[*] Exported to {out}")
164
+ print(" Vocab:", len(state_dict.get("model.lm_head.weight", [])),
165
+ "| step:", ckpt.get("step"))
166
+
167
+
168
+ def main():
169
+ parser = argparse.ArgumentParser(description="Export chat-SFT checkpoint to HF-style dir")
170
+ parser.add_argument("--checkpoint", required=True, help="step_*.pt or best.pt")
171
+ parser.add_argument("--tokenizer", default="data/no_robots_chatml/tokenizer")
172
+ parser.add_argument("--output", required=True, help="Output dir")
173
+ parser.add_argument("--fp16", action="store_true",
174
+ help="Save weights as fp16 (half the size; matches the "
175
+ "base release format)")
176
+ args = parser.parse_args()
177
+ export(args.checkpoint, args.tokenizer, args.output, half=args.fp16)
178
+
179
+
180
+ if __name__ == "__main__":
181
+ main()
scripts/model.py ADDED
@@ -0,0 +1,526 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MetaDiffusion: Convert AR LLMs to Masked Diffusion LLMs
3
+ Based on Supra-1.5-50M-Base-exp architecture
4
+ """
5
+
6
+ import math
7
+ import torch
8
+ import torch.nn as nn
9
+ import torch.nn.functional as F
10
+ from dataclasses import dataclass, field
11
+ from typing import Optional, Tuple
12
+
13
+
14
+ @dataclass
15
+ class MetaDiffusionConfig:
16
+ # Architecture (matching Supra-1.5-50M)
17
+ hidden_size: int = 512
18
+ intermediate_size: int = 1408
19
+ num_hidden_layers: int = 12
20
+ num_attention_heads: int = 8
21
+ num_key_value_heads: int = 4
22
+ head_dim: int = 64
23
+ vocab_size: int = 32000 # original vocab
24
+ mask_vocab_size: int = 32001 # vocab + [MASK] token
25
+ max_position_embeddings: int = 5120
26
+ rope_theta: float = 10000.0
27
+ rms_norm_eps: float = 1e-6
28
+ hidden_act: str = "silu"
29
+
30
+ # Diffusion-specific
31
+ timestep_emb_hidden: int = 512
32
+ mask_token_id: int = 32000 # index of [MASK] in embedding table
33
+ pad_token_id: int = 1 # Supra pad token
34
+
35
+ # Masking strategy
36
+ mask_ratio_min: float = 0.0
37
+ mask_ratio_max: float = 1.0
38
+
39
+ # Training
40
+ dtype: torch.dtype = torch.float32
41
+ tie_word_embeddings: bool = True
42
+
43
+ class RotaryEmbedding(nn.Module):
44
+ """RoPE - position embeddings for the attention layers."""
45
+
46
+ def __init__(self, dim, max_position_embeddings=5120, base=10000.0, device=None):
47
+ super().__init__()
48
+ self.dim = dim
49
+ self.max_position_embeddings = max_position_embeddings
50
+ self.base = base
51
+
52
+ inv_freq = 1.0 / (base ** (torch.arange(0, dim, 2, device=device).float() / dim))
53
+ self.register_buffer("inv_freq", inv_freq, persistent=False)
54
+
55
+ @torch.no_grad()
56
+ def forward(self, x, position_ids):
57
+ # x: (batch, seq, hidden) - used for dtype/device only
58
+ # position_ids: (batch, seq)
59
+ inv_freq_expanded = self.inv_freq[None, :, None].float().expand(
60
+ position_ids.shape[0], -1, 1
61
+ )
62
+ position_ids_expanded = position_ids[:, None, :].float()
63
+ freqs = (inv_freq_expanded.float() @ position_ids_expanded.float()).transpose(1, 2)
64
+ emb = torch.cat((freqs, freqs), dim=-1)
65
+ cos = emb.cos()
66
+ sin = emb.sin()
67
+ return cos.to(dtype=x.dtype), sin.to(dtype=x.dtype)
68
+
69
+
70
+ def rotate_half(x):
71
+ x1, x2 = x.chunk(2, dim=-1)
72
+ return torch.cat((-x2, x1), dim=-1)
73
+
74
+
75
+ def apply_rotary_pos_emb(q, k, cos, sin):
76
+ cos = cos.unsqueeze(1) # (batch, 1, seq, dim)
77
+ sin = sin.unsqueeze(1)
78
+ q_embed = (q * cos) + (rotate_half(q) * sin)
79
+ k_embed = (k * cos) + (rotate_half(k) * sin)
80
+ return q_embed, k_embed
81
+
82
+
83
+ class TimestepEmbedding(nn.Module):
84
+ """Sinusoidal timestep embedding with learned projection."""
85
+
86
+ def __init__(self, hidden_size):
87
+ super().__init__()
88
+ self.hidden_size = hidden_size
89
+ self.mlp = nn.Sequential(
90
+ nn.Linear(hidden_size, hidden_size * 4),
91
+ nn.SiLU(),
92
+ nn.Linear(hidden_size * 4, hidden_size),
93
+ )
94
+
95
+ def forward(self, t):
96
+ # t: (batch,) timesteps in [0, 1]
97
+ half_dim = self.hidden_size // 2
98
+ emb = math.log(10000.0) / (half_dim - 1)
99
+ emb = torch.exp(
100
+ torch.arange(half_dim, device=t.device, dtype=torch.float32) * -emb
101
+ )
102
+ emb = t[:, None].float() * emb[None, :]
103
+ emb = torch.cat([emb.sin(), emb.cos()], dim=-1) # (batch, hidden_size)
104
+ return self.mlp(emb).to(t.dtype) # (batch, hidden_size)
105
+
106
+
107
+ class TimestepResidual(nn.Module):
108
+ """Add timestep embedding to hidden state at each block.
109
+ Initialized to zero so it starts as identity (no disruption to pretrained weights)."""
110
+
111
+ def __init__(self, hidden_size):
112
+ super().__init__()
113
+ self.proj = nn.Linear(hidden_size, hidden_size)
114
+ nn.init.zeros_(self.proj.weight)
115
+ nn.init.zeros_(self.proj.bias)
116
+
117
+ def forward(self, x, emb):
118
+ # x: (batch, seq, hidden)
119
+ # emb: (batch, hidden)
120
+ return x + self.proj(emb)[:, None, :]
121
+
122
+
123
+ class RMSNorm(nn.Module):
124
+ """Llama-style RMSNorm."""
125
+
126
+ def __init__(self, hidden_size, eps=1e-6):
127
+ super().__init__()
128
+ self.weight = nn.Parameter(torch.ones(hidden_size))
129
+ self.eps = eps
130
+
131
+ def forward(self, x):
132
+ var = x.pow(2).mean(-1, keepdim=True)
133
+ x = x * torch.rsqrt(var + self.eps)
134
+ return self.weight * x
135
+
136
+
137
+ class SelfAttention(nn.Module):
138
+ """Multi-head attention with GQA and RoPE.
139
+ BIDIRECTIONAL (no causal mask) - this is the key difference from AR."""
140
+
141
+ def __init__(self, config):
142
+ super().__init__()
143
+ self.config = config
144
+ self.hidden_size = config.hidden_size
145
+ self.num_heads = config.num_attention_heads
146
+ self.num_kv_heads = config.num_key_value_heads
147
+ self.head_dim = config.head_dim
148
+ self.num_kv_groups = self.num_heads // self.num_kv_heads
149
+
150
+ self.q_proj = nn.Linear(
151
+ config.hidden_size, self.num_heads * config.head_dim, bias=False
152
+ )
153
+ self.k_proj = nn.Linear(
154
+ config.hidden_size, self.num_kv_heads * config.head_dim, bias=False
155
+ )
156
+ self.v_proj = nn.Linear(
157
+ config.hidden_size, self.num_kv_heads * config.head_dim, bias=False
158
+ )
159
+ self.o_proj = nn.Linear(
160
+ self.num_heads * config.head_dim, config.hidden_size, bias=False
161
+ )
162
+ self.rotary_emb = RotaryEmbedding(
163
+ config.head_dim,
164
+ max_position_embeddings=config.max_position_embeddings,
165
+ base=config.rope_theta,
166
+ )
167
+
168
+ def forward(self, x, attention_mask=None, position_ids=None):
169
+ batch, seq, _ = x.shape
170
+
171
+ q = self.q_proj(x).view(batch, seq, self.num_heads, self.head_dim).transpose(1, 2)
172
+ k = self.k_proj(x).view(batch, seq, self.num_kv_heads, self.head_dim).transpose(1, 2)
173
+ v = self.v_proj(x).view(batch, seq, self.num_kv_heads, self.head_dim).transpose(1, 2)
174
+
175
+ cos, sin = self.rotary_emb(x, position_ids)
176
+ q, k = apply_rotary_pos_emb(q, k, cos, sin)
177
+
178
+ # GQA: repeat KV heads to match Q heads
179
+ if self.num_kv_groups > 1:
180
+ k = k.repeat_interleave(self.num_kv_groups, dim=1)
181
+ v = v.repeat_interleave(self.num_kv_groups, dim=1)
182
+
183
+ # Bidirectional attention - no causal mask!
184
+ out = F.scaled_dot_product_attention(q, k, v, attn_mask=attention_mask)
185
+ out = out.transpose(1, 2).contiguous().view(batch, seq, -1)
186
+ return self.o_proj(out)
187
+
188
+
189
+ class MLP(nn.Module):
190
+ """Llama-style gated FFN (SwiGLU)."""
191
+
192
+ def __init__(self, config):
193
+ super().__init__()
194
+ self.gate_proj = nn.Linear(
195
+ config.hidden_size, config.intermediate_size, bias=False
196
+ )
197
+ self.up_proj = nn.Linear(
198
+ config.hidden_size, config.intermediate_size, bias=False
199
+ )
200
+ self.down_proj = nn.Linear(
201
+ config.intermediate_size, config.hidden_size, bias=False
202
+ )
203
+
204
+ def forward(self, x):
205
+ return self.down_proj(F.silu(self.gate_proj(x)) * self.up_proj(x))
206
+
207
+
208
+ class TransformerBlock(nn.Module):
209
+ """Llama transformer block adapted for diffusion.
210
+ - Pre-norm architecture
211
+ - Bidirectional attention
212
+ - Timestep conditioning via residual addition
213
+ """
214
+
215
+ def __init__(self, config):
216
+ super().__init__()
217
+ self.input_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
218
+ self.self_attn = SelfAttention(config)
219
+ self.post_attention_layernorm = RMSNorm(
220
+ config.hidden_size, eps=config.rms_norm_eps
221
+ )
222
+ self.mlp = MLP(config)
223
+ self.timestep_residual = TimestepResidual(config.hidden_size)
224
+
225
+ def forward(self, x, timestep_emb, attention_mask=None, position_ids=None):
226
+ # Pre-norm + attention + residual + timestep
227
+ residual = x
228
+ x = self.input_layernorm(x)
229
+ x = self.self_attn(x, attention_mask, position_ids)
230
+ x = residual + x
231
+ x = self.timestep_residual(x, timestep_emb)
232
+
233
+ # Pre-norm + FFN + residual + timestep
234
+ residual = x
235
+ x = self.post_attention_layernorm(x)
236
+ x = self.mlp(x)
237
+ x = residual + x
238
+ x = self.timestep_residual(x, timestep_emb)
239
+
240
+ return x
241
+
242
+ class MetaDiffusionLM(nn.Module):
243
+ """Masked Diffusion Language Model.
244
+
245
+ Converts an AR Llama-style model to a masked-diffusion LM.
246
+ Transfers: embeddings, all transformer blocks, RoPE, norms.
247
+ New: timestep embedding, [MASK] token.
248
+ Output head is tied with embeddings by default (set tie_word_embeddings=False to untie).
249
+ """
250
+
251
+ def __init__(self, config: MetaDiffusionConfig):
252
+ super().__init__()
253
+ self.config = config
254
+
255
+ # Embeddings (vocab + 1 for [MASK])
256
+ self.embed_tokens = nn.Embedding(
257
+ config.mask_vocab_size, config.hidden_size, padding_idx=config.pad_token_id
258
+ )
259
+
260
+ # Timestep conditioning
261
+ self.timestep_emb = TimestepEmbedding(config.timestep_emb_hidden)
262
+
263
+ # Transformer stack
264
+ self.layers = nn.ModuleList(
265
+ [TransformerBlock(config) for _ in range(config.num_hidden_layers)]
266
+ )
267
+
268
+ # Final norm
269
+ self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
270
+
271
+ # Output projection (can be tied with embeddings for parameter efficiency)
272
+ if config.tie_word_embeddings:
273
+ self.lm_head = None # Will use embed_tokens.weight in forward
274
+ else:
275
+ self.lm_head = nn.Linear(
276
+ config.hidden_size, config.mask_vocab_size, bias=False
277
+ )
278
+
279
+ self.post_init()
280
+
281
+ def post_init(self):
282
+ if self.lm_head is not None:
283
+ nn.init.normal_(self.lm_head.weight, std=0.02)
284
+
285
+ def forward(self, input_ids, timesteps, attention_mask=None):
286
+ """
287
+ Forward pass for training.
288
+
289
+ Args:
290
+ input_ids: (batch, seq) - tokens with masked positions replaced by mask_token_id
291
+ timesteps: (batch,) - diffusion timestep in [0, 1]
292
+ attention_mask: optional (batch, seq) - 1 for real tokens, 0 for padding
293
+
294
+ Returns:
295
+ logits: (batch, seq, mask_vocab_size)
296
+ """
297
+ batch, seq = input_ids.shape
298
+
299
+ # Position IDs (0, 1, 2, ...)
300
+ position_ids = (
301
+ torch.arange(seq, device=input_ids.device).unsqueeze(0).expand(batch, -1)
302
+ )
303
+
304
+ # Embed tokens
305
+ x = self.embed_tokens(input_ids)
306
+
307
+ # Get timestep embedding
308
+ t_emb = self.timestep_emb(timesteps)
309
+
310
+ # Convert attention mask for SDPA (0 -> keep, -inf -> mask out)
311
+ attn_mask = None
312
+ if attention_mask is not None:
313
+ attn_mask = ((1.0 - attention_mask[:, None, None, :].float()) * -1e9).to(
314
+ x.dtype
315
+ )
316
+
317
+ # Pass through transformer blocks
318
+ for layer in self.layers:
319
+ x = layer(x, t_emb, attn_mask, position_ids)
320
+
321
+ # Final norm + project to vocab
322
+ x = self.norm(x)
323
+ if self.lm_head is not None:
324
+ logits = self.lm_head(x)
325
+ else:
326
+ # Tied embeddings: use embed_tokens.weight transposed
327
+ logits = F.linear(x, self.embed_tokens.weight)
328
+
329
+ return logits
330
+
331
+ def compute_loss(self, logits, labels, mask_positions, pad_token_id=None):
332
+ """
333
+ Compute cross-entropy loss on masked positions only.
334
+
335
+ Args:
336
+ logits: (batch, seq, vocab_size)
337
+ labels: (batch, seq) - original token IDs (before masking)
338
+ mask_positions: (batch, seq) - bool tensor, True where token was masked
339
+ pad_token_id: ignore these positions in loss
340
+
341
+ Returns:
342
+ loss: scalar
343
+ num_masked: number of positions in loss
344
+ """
345
+ logits_masked = logits[mask_positions]
346
+ labels_masked = labels[mask_positions]
347
+
348
+ # Filter out padding tokens
349
+ if pad_token_id is not None:
350
+ valid = labels_masked != pad_token_id
351
+ logits_masked = logits_masked[valid]
352
+ labels_masked = labels_masked[valid]
353
+
354
+ if labels_masked.numel() == 0:
355
+ return torch.tensor(0.0, device=logits.device), 0
356
+
357
+ loss = F.cross_entropy(logits_masked, labels_masked)
358
+ return loss, labels_masked.numel()
359
+
360
+ @torch.no_grad()
361
+ def generate(self, batch_size, seq_len, num_steps=256, device="cuda"):
362
+ """
363
+ Iterative denoising generation (LLaDA-style).
364
+
365
+ Starts from all-mask tokens and progressively unmaskes the most confident predictions.
366
+
367
+ Args:
368
+ batch_size: number of sequences to generate
369
+ seq_len: length of each sequence
370
+ num_steps: number of denoising iterations
371
+
372
+ Returns:
373
+ tokens: (batch, seq) - generated token IDs
374
+ """
375
+ mask_token_id = self.config.mask_token_id
376
+ x = torch.full(
377
+ (batch_size, seq_len), mask_token_id, device=device, dtype=torch.long
378
+ )
379
+
380
+ # Linear schedule from t=1 (all mask) to t=0 (no mask)
381
+ timesteps = torch.linspace(1.0, 0.0, num_steps + 1, device=device)
382
+
383
+ for i in range(num_steps):
384
+ t = timesteps[i]
385
+ t_next = timesteps[i + 1]
386
+ t_batch = torch.full((batch_size,), t, device=device)
387
+
388
+ # Get predictions
389
+ logits = self.forward(x, t_batch)
390
+ pred_tokens = logits.argmax(dim=-1)
391
+
392
+ # Confidence of predicted tokens
393
+ probs = F.softmax(logits, dim=-1)
394
+ confidence = probs.gather(-1, pred_tokens.unsqueeze(-1)).squeeze(-1)
395
+
396
+ # Number of tokens to unmask this step
397
+ num_unmask = max(1, int(seq_len * (t - t_next)))
398
+
399
+ # Only consider currently-masked positions
400
+ is_mask = x == mask_token_id
401
+ confidence_masked = confidence.clone()
402
+ confidence_masked[~is_mask] = -1.0
403
+
404
+ # Unmask the most confident predictions
405
+ _, top_indices = confidence_masked.topk(num_unmask, dim=-1)
406
+ batch_idx = (
407
+ torch.arange(batch_size, device=device).unsqueeze(-1).expand_as(top_indices)
408
+ )
409
+ x[batch_idx, top_indices] = pred_tokens[batch_idx, top_indices]
410
+
411
+ return x
412
+
413
+ @classmethod
414
+ def from_pretrained_ar(
415
+ cls,
416
+ model_name_or_path: str,
417
+ **kwargs,
418
+ ):
419
+ """
420
+ Initialize a MetaDiffusionLM from a pretrained AR Llama model.
421
+
422
+ Transfers all AR weights and initializes new diffusion components.
423
+ """
424
+ from transformers import LlamaForCausalLM
425
+
426
+ print(f"Loading AR model: {model_name_or_path}")
427
+ ar_model = LlamaForCausalLM.from_pretrained(model_name_or_path)
428
+ ar_config = ar_model.config
429
+
430
+ # Build diffusion config from AR config
431
+ head_dim = getattr(
432
+ ar_config,
433
+ "head_dim",
434
+ ar_config.hidden_size // ar_config.num_attention_heads,
435
+ )
436
+ config = MetaDiffusionConfig(
437
+ hidden_size=ar_config.hidden_size,
438
+ intermediate_size=ar_config.intermediate_size,
439
+ num_hidden_layers=ar_config.num_hidden_layers,
440
+ num_attention_heads=ar_config.num_attention_heads,
441
+ num_key_value_heads=ar_config.num_key_value_heads,
442
+ head_dim=head_dim,
443
+ vocab_size=ar_config.vocab_size,
444
+ mask_vocab_size=ar_config.vocab_size + 1,
445
+ max_position_embeddings=ar_config.max_position_embeddings,
446
+ rope_theta=getattr(ar_config, "rope_theta", 10000.0),
447
+ rms_norm_eps=ar_config.rms_norm_eps,
448
+ mask_token_id=ar_config.vocab_size,
449
+ pad_token_id=getattr(ar_config, "pad_token_id", 1),
450
+ **kwargs,
451
+ )
452
+
453
+ model = cls(config)
454
+
455
+ # Build state dict mapping
456
+ state_dict = ar_model.state_dict()
457
+ new_state_dict = {}
458
+
459
+ # --- Embeddings ---
460
+ # Copy original vocab embeddings
461
+ ar_embeds = state_dict["model.embed_tokens.weight"]
462
+ mask_embed = ar_embeds.mean(dim=0, keepdim=True) # [MASK] = mean of all embeds
463
+ new_state_dict["embed_tokens.weight"] = torch.cat(
464
+ [ar_embeds, mask_embed], dim=0
465
+ )
466
+
467
+ # --- Transformer blocks ---
468
+ for i in range(config.num_hidden_layers):
469
+ ar_prefix = f"model.layers.{i}"
470
+ new_prefix = f"layers.{i}"
471
+
472
+ # Attention
473
+ new_state_dict[f"{new_prefix}.self_attn.q_proj.weight"] = state_dict[
474
+ f"{ar_prefix}.self_attn.q_proj.weight"
475
+ ]
476
+ new_state_dict[f"{new_prefix}.self_attn.k_proj.weight"] = state_dict[
477
+ f"{ar_prefix}.self_attn.k_proj.weight"
478
+ ]
479
+ new_state_dict[f"{new_prefix}.self_attn.v_proj.weight"] = state_dict[
480
+ f"{ar_prefix}.self_attn.v_proj.weight"
481
+ ]
482
+ new_state_dict[f"{new_prefix}.self_attn.o_proj.weight"] = state_dict[
483
+ f"{ar_prefix}.self_attn.o_proj.weight"
484
+ ]
485
+
486
+ # MLP
487
+ new_state_dict[f"{new_prefix}.mlp.gate_proj.weight"] = state_dict[
488
+ f"{ar_prefix}.mlp.gate_proj.weight"
489
+ ]
490
+ new_state_dict[f"{new_prefix}.mlp.up_proj.weight"] = state_dict[
491
+ f"{ar_prefix}.mlp.up_proj.weight"
492
+ ]
493
+ new_state_dict[f"{new_prefix}.mlp.down_proj.weight"] = state_dict[
494
+ f"{ar_prefix}.mlp.down_proj.weight"
495
+ ]
496
+
497
+ # Layer norms
498
+ new_state_dict[f"{new_prefix}.input_layernorm.weight"] = state_dict[
499
+ f"{ar_prefix}.input_layernorm.weight"
500
+ ]
501
+ new_state_dict[
502
+ f"{new_prefix}.post_attention_layernorm.weight"
503
+ ] = state_dict[f"{ar_prefix}.post_attention_layernorm.weight"]
504
+
505
+ # --- Final norm ---
506
+ new_state_dict["norm.weight"] = state_dict["model.norm.weight"]
507
+
508
+ # --- Output head ---
509
+ # Initialize from AR embeddings (since AR used tied embeddings, E^T was the output)
510
+ if not config.tie_word_embeddings:
511
+ new_state_dict["lm_head.weight"] = torch.cat(
512
+ [ar_embeds, torch.zeros(1, config.hidden_size, device=ar_embeds.device)],
513
+ dim=0,
514
+ ).clone()
515
+
516
+ # Load with strict=False for new diffusion params
517
+ missing, unexpected = model.load_state_dict(new_state_dict, strict=False)
518
+
519
+ print(f"Weights transferred from AR model")
520
+ print(f" Missing (new diffusion params): {len(missing)}")
521
+ print(f" Unexpected: {len(unexpected)}")
522
+ if missing:
523
+ for k in missing:
524
+ print(f" NEW: {k}")
525
+
526
+ return model
scripts/prepare_data.py ADDED
@@ -0,0 +1,411 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ prepare_data.py: Download HuggingFaceH4/no_robots and pre-tokenize it as ChatML
4
+ for MetaDiffusion chat SFT.
5
+
6
+ Output (in --data-dir):
7
+ train.pt list of {"input_ids": LongTensor, "assistant_start": int,
8
+ "assistant_end": int}
9
+ val.pt same, held-out
10
+ tokenizer/ Supra tokenizer with ChatML + rainbow tokens added
11
+ stats.json counts and length stats
12
+
13
+ The assistant region (content + trailing <|im_end|>) is the only part that
14
+ will be masked during training; everything before it is visible prompt.
15
+
16
+ Usage:
17
+ python3 prepare_data.py --model-path ../hf_release --data-dir data/no_robots_chatml
18
+ """
19
+
20
+ import argparse
21
+ import hashlib
22
+ import json
23
+ import random
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ import numpy as np
28
+ import torch
29
+ from datasets import load_dataset
30
+ from transformers import AutoTokenizer
31
+
32
+ IM_START = "<|im_start|>"
33
+ IM_END = "<|im_end|>"
34
+ RESERVED = "<|reserved|>" # filler so id 32000 stays free for [MASK]
35
+ CHAT_TOKENS = [IM_START, IM_END] + [f"<|r{i}|>" for i in range(1, 8)] # rainbow pads
36
+ BASE_VOCAB = 32000 # Supra tokenizer entry count (ids 0..31999)
37
+
38
+
39
+ def add_chat_tokens(tokenizer):
40
+ """Add chat + rainbow tokens at ids 32001..32009.
41
+
42
+ The base tokenizer has 32000 entries, so the first added token would take
43
+ id 32000, which is the diffusion [MASK] id. A reserved filler token takes
44
+ that slot first; mask id 32000 must never exist in the tokenizer.
45
+ """
46
+ if len(tokenizer) == BASE_VOCAB:
47
+ tokenizer.add_special_tokens({"additional_special_tokens": [RESERVED]})
48
+ n = tokenizer.add_special_tokens(
49
+ {"additional_special_tokens": CHAT_TOKENS}
50
+ )
51
+ im_start = tokenizer.convert_tokens_to_ids(IM_START)
52
+ im_end = tokenizer.convert_tokens_to_ids(IM_END)
53
+ assert im_start == 32001, f"im_start id {im_start} != 32001 (collides with [MASK])"
54
+ assert im_end == 32002, f"im_end id {im_end} != 32002"
55
+ print(f"[*] Added {n} special tokens, vocab now {len(tokenizer)}")
56
+ print(f"[*] im_start={im_start} im_end={im_end} "
57
+ f"rainbow={[tokenizer.convert_tokens_to_ids(f'<|r{i}|>') for i in range(1, 8)]}")
58
+ return n
59
+
60
+
61
+ def format_segment(role: str, content: str) -> str:
62
+ return f"{IM_START}{role}\n{content}{IM_END}"
63
+
64
+
65
+ def load_messages(dataset_name: str):
66
+ """Return a list of message lists for one dataset name."""
67
+ if dataset_name == "no_robots":
68
+ ds = load_dataset("HuggingFaceH4/no_robots", split="train")
69
+ return [list(row["messages"]) for row in ds]
70
+ if dataset_name == "alpaca":
71
+ ds = load_dataset("yahma/alpaca-cleaned", split="train")
72
+ out = []
73
+ for row in ds:
74
+ user = row["instruction"]
75
+ if row.get("input"):
76
+ user += f"\n\n{row['input']}"
77
+ out.append([{"role": "user", "content": user},
78
+ {"role": "assistant", "content": row["output"]}])
79
+ return out
80
+ if dataset_name == "dolly":
81
+ ds = load_dataset("databricks/databricks-dolly-15k", split="train")
82
+ out = []
83
+ for row in ds:
84
+ user = row["instruction"]
85
+ if row.get("context"):
86
+ user += f"\n\n{row['context']}"
87
+ out.append([{"role": "user", "content": user},
88
+ {"role": "assistant", "content": row["response"]}])
89
+ return out
90
+ if dataset_name == "smol-smoltalk":
91
+ # The actual SFT set for SmolLM2-135M-Instruct: 484K short, high-quality
92
+ # conversations designed for <1B models (no function calling, no
93
+ # advanced math). Cap with --max-examples (e.g. 60000) for a 150M model.
94
+ ds = load_dataset("HuggingFaceTB/smol-smoltalk", split="train")
95
+ return [list(row["messages"]) for row in ds]
96
+ if dataset_name == "math":
97
+ return load_math_data()
98
+ raise ValueError(f"Unknown dataset: {dataset_name} "
99
+ f"(choose from: no_robots, alpaca, dolly, smol-smoltalk, math)")
100
+
101
+ MATH_QUESTION_TEMPLATES = {
102
+ "add": ["What is {a} + {b}?", "What is {a} plus {b}?", "Add {a} and {b}.",
103
+ "What does {a} + {b} equal?"],
104
+ "sub": ["What is {a} - {b}?", "What is {a} minus {b}?", "Subtract {b} from {a}.",
105
+ "What does {a} - {b} equal?"],
106
+ "mul": ["What is {a} × {b}?", "What is {a} times {b}?", "Multiply {a} by {b}.",
107
+ "What does {a} × {b} equal?"],
108
+ "div": ["What is {a} ÷ {b}?", "What is {a} divided by {b}?", "Divide {a} by {b}.",
109
+ "What does {a} ÷ {b} equal?"],
110
+ }
111
+ MATH_ANSWER_TEMPLATES = ["The answer is {r}.", "It is {r}.", "{r}"]
112
+
113
+
114
+ def load_math_data(seed: int = 42):
115
+ """Exhaustive basic-arithmetic QA pairs (add/sub/mul/div), ChatML messages.
116
+
117
+ A 150M model learns arithmetic by memorization, so cover EVERY pair in a
118
+ small range rather than sampling: add a<=b in 1..99, sub b<a in 1..99,
119
+ times tables 1..12, exact divisions 1..12.
120
+ """
121
+ rng = random.Random(seed)
122
+ pairs = [] # (op, a, b, result)
123
+ for a in range(1, 100):
124
+ for b in range(a, 100):
125
+ pairs.append(("add", a, b, a + b))
126
+ for a in range(2, 100):
127
+ for b in range(1, a):
128
+ pairs.append(("sub", a, b, a - b))
129
+ for a in range(1, 13):
130
+ for b in range(1, 13):
131
+ pairs.append(("mul", a, b, a * b))
132
+ for b in range(1, 13):
133
+ for q in range(1, 13):
134
+ pairs.append(("div", b * q, b, q))
135
+ rng.shuffle(pairs)
136
+
137
+ out = []
138
+ for op, a, b, r in pairs:
139
+ question = rng.choice(MATH_QUESTION_TEMPLATES[op]).format(a=a, b=b)
140
+ answer = rng.choice(MATH_ANSWER_TEMPLATES).format(r=r)
141
+ out.append([{"role": "user", "content": question},
142
+ {"role": "assistant", "content": answer}])
143
+ print(f" (synthetic arithmetic: {len(out)} pairs, "
144
+ f"add {sum(1 for p in pairs if p[0]=='add')}, "
145
+ f"sub {sum(1 for p in pairs if p[0]=='sub')}, "
146
+ f"mul {sum(1 for p in pairs if p[0]=='mul')}, "
147
+ f"div {sum(1 for p in pairs if p[0]=='div')})")
148
+ return out
149
+
150
+
151
+ def example_key(input_ids_tensor):
152
+ """Compact dedup key: SHA-256 of token ids.
153
+
154
+ Tuple keys for 484K x 600-token conversations cost ~5 GB of set memory;
155
+ digests cost ~50 MB. (Hashing was never the bottleneck; per-segment
156
+ tokenizer.encode() calls were.)
157
+ """
158
+ return hashlib.sha256(
159
+ input_ids_tensor.numpy().astype(np.uint32).tobytes()
160
+ ).digest()
161
+
162
+
163
+ def build_conv_segments(messages):
164
+ """Split a conversation into ChatML segment strings + roles."""
165
+ segs, roles = [], []
166
+ for m in messages:
167
+ role = m.get("role", "user")
168
+ content = m.get("content", "")
169
+ if not content.strip():
170
+ continue
171
+ segs.append(format_segment(role, content))
172
+ roles.append(role)
173
+ return segs, roles
174
+
175
+
176
+ def reconstruct_example(seg_ids, roles, tokenizer, max_len, max_resp):
177
+ """Rebuild a conversation from pre-encoded segments (tokenize_example
178
+ logic, but with tokenization already done in batch)."""
179
+ ids = []
180
+ assistant_ranges = []
181
+ for role, seg in zip(roles, seg_ids):
182
+ start = len(ids)
183
+ ids.extend(seg)
184
+ if role == "assistant":
185
+ assistant_ranges.append((start, len(ids)))
186
+
187
+ if not ids or not assistant_ranges:
188
+ return None
189
+ a0, a1 = assistant_ranges[-1]
190
+ if a1 <= a0:
191
+ return None
192
+
193
+ # Cap the response length, always keeping the trailing <|im_end|>
194
+ if a1 - a0 > max_resp:
195
+ im_end_id = tokenizer.convert_tokens_to_ids(IM_END)
196
+ ids = ids[:a0] + ids[a0:a0 + max_resp - 1] + [im_end_id]
197
+ a1 = a0 + max_resp
198
+
199
+ # Truncate from the front (history), keep the target response intact
200
+ resp = ids[a0:a1]
201
+ if len(resp) > max_len:
202
+ resp = resp[:max_len]
203
+ a1 = a0 + len(resp)
204
+ hist = ids[:a0]
205
+ room = max_len - len(resp)
206
+ if len(hist) > room:
207
+ hist = hist[len(hist) - room:] if room > 0 else []
208
+ ids = hist + resp
209
+ return {
210
+ "input_ids": torch.tensor(ids, dtype=torch.long),
211
+ "assistant_start": len(hist),
212
+ "assistant_end": len(ids),
213
+ }
214
+
215
+
216
+ def tokenize_and_split(convs, math_indices, tokenizer, val_size, max_len,
217
+ max_resp, seed=42):
218
+ """Batched tokenize + reconstruct + dedup + val split.
219
+
220
+ convs: list of (roles, segs) from build_conv_segments.
221
+ math_indices: conversation indices exempt from dedup (math repeats are
222
+ intentional). Returns (train_examples, val_examples, skipped, dupes).
223
+ """
224
+ all_seg_strs = [s for _, segs in convs for s in segs]
225
+ print(f"[*] Encoding {len(all_seg_strs)} segments (batched)...")
226
+ encoded = []
227
+ CHUNK = 200_000
228
+ for i in range(0, len(all_seg_strs), CHUNK):
229
+ chunk = all_seg_strs[i:i + CHUNK]
230
+ encoded.extend(tokenizer(chunk, add_special_tokens=False)["input_ids"])
231
+
232
+ rng = random.Random(seed)
233
+ idxs = list(range(len(convs)))
234
+ rng.shuffle(idxs)
235
+ val_idxs = set(idxs[:val_size])
236
+
237
+ train_examples, val_examples = [], []
238
+ skipped = dupes = 0
239
+ seen = set()
240
+ ptr = 0
241
+ for i, (roles, segs) in enumerate(convs):
242
+ seg_ids = encoded[ptr:ptr + len(segs)]
243
+ ptr += len(segs)
244
+ ex = reconstruct_example(seg_ids, roles, tokenizer, max_len, max_resp)
245
+ if ex is None:
246
+ skipped += 1
247
+ continue
248
+ if i not in math_indices:
249
+ key = example_key(ex["input_ids"])
250
+ if key in seen:
251
+ dupes += 1
252
+ continue
253
+ seen.add(key)
254
+ (val_examples if i in val_idxs else train_examples).append(ex)
255
+ return train_examples, val_examples, skipped, dupes
256
+
257
+
258
+ def save_dataset(out_dir, train_examples, val_examples, tokenizer,
259
+ skipped, dupes):
260
+ """Save train.pt / val.pt / stats.json and print the summary."""
261
+ out = Path(out_dir)
262
+ torch.save({"examples": train_examples}, out / "train.pt")
263
+ torch.save({"examples": val_examples}, out / "val.pt")
264
+
265
+ lens = [e["input_ids"].numel() for e in train_examples]
266
+ stats = {
267
+ "train": len(train_examples),
268
+ "val": len(val_examples),
269
+ "skipped": skipped,
270
+ "dupes": dupes,
271
+ "avg_tokens": sum(lens) / len(lens) if lens else 0,
272
+ "max_tokens": max(lens) if lens else 0,
273
+ "vocab_size": len(tokenizer),
274
+ "im_start_id": tokenizer.convert_tokens_to_ids(IM_START),
275
+ "im_end_id": tokenizer.convert_tokens_to_ids(IM_END),
276
+ "rainbow_ids": [tokenizer.convert_tokens_to_ids(f"<|r{i}|>") for i in range(1, 8)],
277
+ "mask_token_id": 32000,
278
+ }
279
+ with open(out / "stats.json", "w") as f:
280
+ json.dump(stats, f, indent=2)
281
+
282
+ print(f"[*] Done: {stats['train']} train / {stats['val']} val "
283
+ f"(skipped {skipped}, dupes {dupes})")
284
+ print(f"[*] Avg tokens per example: {stats['avg_tokens']:.0f} (max {stats['max_tokens']})")
285
+ print(f"[*] im_start={stats['im_start_id']} im_end={stats['im_end_id']} "
286
+ f"rainbow={stats['rainbow_ids']}")
287
+ return stats
288
+
289
+
290
+ def tokenize_example(tokenizer, messages, max_len: int, max_resp: int = 256):
291
+ """Tokenize a conversation segment-wise; return ids + assistant bounds.
292
+
293
+ The target (last assistant response) is capped at `max_resp` tokens
294
+ including its trailing <|im_end|>: long web-text targets teach rambling,
295
+ and the terminator must stay in the target so the model learns to emit it.
296
+ """
297
+ ids = []
298
+ assistant_ranges = [] # (start, end) per message, in token space
299
+ for m in messages:
300
+ role = m.get("role", "user")
301
+ content = m.get("content", "")
302
+ if not content.strip():
303
+ continue
304
+ seg = format_segment(role, content)
305
+ seg_ids = tokenizer.encode(seg, add_special_tokens=False)
306
+ start = len(ids)
307
+ ids.extend(seg_ids)
308
+ if role == "assistant":
309
+ assistant_ranges.append((start, len(ids)))
310
+
311
+ if not ids or not assistant_ranges:
312
+ return None
313
+
314
+ # Target turn = the LAST assistant response (LLaDA-MoE style)
315
+ a0, a1 = assistant_ranges[-1]
316
+ if a1 <= a0:
317
+ return None
318
+
319
+ # Cap the response length, always keeping the trailing <|im_end|>
320
+ if a1 - a0 > max_resp:
321
+ im_end_id = tokenizer.convert_tokens_to_ids(IM_END)
322
+ ids = ids[:a0] + ids[a0:a0 + max_resp - 1] + [im_end_id]
323
+ a1 = a0 + max_resp
324
+
325
+ # Truncate from the front (history), keep the target response intact
326
+ resp = ids[a0:a1]
327
+ if len(resp) > max_len:
328
+ resp = resp[:max_len]
329
+ a1 = a0 + len(resp)
330
+ hist = ids[:a0]
331
+ room = max_len - len(resp)
332
+ if len(hist) > room:
333
+ hist = hist[len(hist) - room:] if room > 0 else []
334
+ ids = hist + resp
335
+ return {
336
+ "input_ids": torch.tensor(ids, dtype=torch.long),
337
+ "assistant_start": len(hist),
338
+ "assistant_end": len(ids),
339
+ }
340
+
341
+
342
+ def main():
343
+ parser = argparse.ArgumentParser(description="Prepare no_robots as ChatML for MetaDiffusion SFT")
344
+ parser.add_argument("--model-path", default="../hf_release", help="Dir with tokenizer.json (base model)")
345
+ parser.add_argument("--data-dir", default="data/no_robots_chatml", help="Output dir")
346
+ parser.add_argument("--datasets", default="no_robots",
347
+ help="Comma list: no_robots, alpaca, dolly, smol-smoltalk, "
348
+ "math (e.g. no_robots,alpaca,dolly,smol-smoltalk,math)")
349
+ parser.add_argument("--max-examples", type=int, default=0,
350
+ help="Cap per downloaded dataset (0 = no cap). smol-smoltalk "
351
+ "is 484K; use ~60000 for a 150M model. Does not apply "
352
+ "to synthetic math (exhaustive coverage is the point).")
353
+ parser.add_argument("--val-size", type=int, default=500, help="Held-out examples")
354
+ parser.add_argument("--max-len", type=int, default=1024, help="Max tokens per example")
355
+ parser.add_argument("--max-resp-tokens", type=int, default=256,
356
+ help="Cap on target response tokens (keeps <|im_end|>)")
357
+ parser.add_argument("--math-repeat", type=int, default=1,
358
+ help="Upsample synthetic math: N passes over all pairs "
359
+ "(different phrasings per pass; 3-4 helps a 150M "
360
+ "model memorize the mapping)")
361
+ parser.add_argument("--seed", type=int, default=42)
362
+ args = parser.parse_args()
363
+
364
+ out = Path(args.data_dir)
365
+ out.mkdir(parents=True, exist_ok=True)
366
+
367
+ print(f"[*] Loading tokenizer from {args.model_path}")
368
+ tokenizer = AutoTokenizer.from_pretrained(args.model_path)
369
+ add_chat_tokens(tokenizer)
370
+ tokenizer.save_pretrained(out / "tokenizer")
371
+
372
+ convs = [] # (roles, segs) per conversation
373
+ math_indices = set()
374
+ for name in args.datasets.split(","):
375
+ name = name.strip()
376
+ print(f"[*] Loading dataset: {name}")
377
+ if name == "math":
378
+ # Upsample: several passes over the same pairs, each pass re-rolling
379
+ # phrasings (fresh seed). Math repeats are NOT deduped: identical
380
+ # copies are intentional extra gradient steps (memorization needs
381
+ # repetition, not variety).
382
+ start = len(convs)
383
+ for rep in range(args.math_repeat):
384
+ msgs = load_math_data(seed=args.seed + rep)
385
+ for messages in msgs:
386
+ segs, roles = build_conv_segments(messages)
387
+ convs.append((roles, segs))
388
+ print(f" pass {rep + 1}/{args.math_repeat}: {len(msgs)}")
389
+ math_indices.update(range(start, len(convs)))
390
+ else:
391
+ msgs = load_messages(name)
392
+ if args.max_examples > 0 and len(msgs) > args.max_examples:
393
+ rng_cap = random.Random(args.seed)
394
+ rng_cap.shuffle(msgs)
395
+ msgs = msgs[: args.max_examples]
396
+ print(f" capped to {len(msgs)}")
397
+ for messages in msgs:
398
+ segs, roles = build_conv_segments(messages)
399
+ convs.append((roles, segs))
400
+ print(f" {len(msgs)} conversations")
401
+ print(f"[*] Total: {len(convs)} conversations")
402
+
403
+ train_examples, val_examples, skipped, dupes = tokenize_and_split(
404
+ convs, math_indices, tokenizer, args.val_size, args.max_len,
405
+ args.max_resp_tokens, args.seed)
406
+
407
+ save_dataset(out, train_examples, val_examples, tokenizer, skipped, dupes)
408
+
409
+
410
+ if __name__ == "__main__":
411
+ main()
scripts/requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ torch>=2.2
2
+ transformers>=4.40
3
+ safetensors>=0.4
4
+ datasets>=2.18
5
+ numpy>=1.26
6
+ pandas>=2.0
7
+ pyarrow>=14.0
scripts/train_chat.py ADDED
@@ -0,0 +1,698 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ train_chat.py: Chat SFT for MetaDiffusion-150M-exp (LLaDA Algorithm 2 style).
4
+
5
+ Checkpoints use the same format as train.py:
6
+ {step, model_state_dict, optimizer_state_dict, scheduler_state_dict, config}
7
+
8
+ Usage:
9
+ python3 train_chat.py \
10
+ --model-path ../hf_release \
11
+ --data-dir data/no_robots_chatml \
12
+ --output-dir checkpoints_chat \
13
+ --epochs 8
14
+ """
15
+
16
+ import argparse
17
+ import glob
18
+ import heapq
19
+ import json
20
+ import logging
21
+ import math
22
+ import os
23
+ import re
24
+ import shutil
25
+ import sys
26
+ import time
27
+ from dataclasses import asdict
28
+ from pathlib import Path
29
+
30
+ import torch
31
+ import torch.nn as nn
32
+ import torch.nn.functional as F
33
+ from safetensors.torch import load_file
34
+ from torch.optim import AdamW
35
+ from torch.optim.lr_scheduler import LambdaLR
36
+ from torch.utils.data import DataLoader, Dataset
37
+
38
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
39
+ from model import MetaDiffusionLM, MetaDiffusionConfig # noqa: E402
40
+
41
+ MASK_TOKEN_ID = 32000
42
+ BASE_VOCAB = 32000 # Supra tokenizer entry count
43
+ RESERVED = "<|reserved|>" # filler so id 32000 stays free for [MASK]
44
+ BASE_VOCAB_WITH_RESERVED = BASE_VOCAB + 1
45
+ CHAT_TOKENS = ["<|im_start|>", "<|im_end|>"] + [f"<|r{i}|>" for i in range(1, 8)]
46
+ CHAT_VOCAB = BASE_VOCAB + 1 + len(CHAT_TOKENS) # 32010
47
+
48
+
49
+ def ensure_chat_tokens(tokenizer):
50
+ """Make sure chat tokens live at ids 32001..32009
51
+
52
+ Handles both a fresh base tokenizer (adds <|reserved|> at 32000 first) and
53
+ an already-prepared one (no-op).
54
+ """
55
+ if tokenizer.convert_tokens_to_ids("<|im_start|>") == tokenizer.unk_token_id:
56
+ if len(tokenizer) == BASE_VOCAB:
57
+ tokenizer.add_special_tokens({"additional_special_tokens": [RESERVED]})
58
+ tokenizer.add_special_tokens({"additional_special_tokens": CHAT_TOKENS})
59
+ return tokenizer
60
+
61
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
62
+ logger = logging.getLogger(__name__)
63
+
64
+ def format_bytes(b):
65
+ for unit in ["B", "KB", "MB", "GB", "TB"]:
66
+ if b < 1024:
67
+ return f"{b:.1f} {unit}"
68
+ b /= 1024
69
+ return f"{b:.1f} PB"
70
+
71
+
72
+ def format_duration(seconds):
73
+ seconds = max(0, int(seconds))
74
+ hours, rem = divmod(seconds, 3600)
75
+ minutes, secs = divmod(rem, 60)
76
+ if hours > 0:
77
+ return f"{hours}h{minutes:02d}m{secs:02d}s"
78
+ if minutes > 0:
79
+ return f"{minutes}m{secs:02d}s"
80
+ return f"{secs}s"
81
+
82
+
83
+ def get_gpu_memory_info():
84
+ if not torch.cuda.is_available():
85
+ return None, None
86
+ torch.cuda.synchronize()
87
+ free, total = torch.cuda.mem_get_info()
88
+ return total, free
89
+
90
+
91
+ def detect_max_batch_size(model, seq_len, device, keep_free_fraction=0.1,
92
+ amp_dtype=None):
93
+ """Largest batch size that fits in VRAM with headroom (from train.py)."""
94
+ if not torch.cuda.is_available():
95
+ return 8
96
+ total_mem, free_mem = get_gpu_memory_info()
97
+ if total_mem is None:
98
+ return 8
99
+ logger.info(f"GPU memory: {format_bytes(total_mem)} total, {format_bytes(free_mem)} free")
100
+ model = model.to(device).train()
101
+ mem_limit = total_mem - int(total_mem * keep_free_fraction)
102
+ last_working, first_oom = 1, None
103
+ for bs in [1, 2, 4, 8, 16, 32, 64, 128, 256]:
104
+ torch.cuda.synchronize()
105
+ if torch.cuda.memory_allocated() >= mem_limit:
106
+ first_oom = bs
107
+ break
108
+ try:
109
+ input_ids = torch.randint(0, 32000, (bs, seq_len), device=device)
110
+ labels = torch.randint(0, 32000, (bs, seq_len), device=device)
111
+ mask_positions = torch.rand(bs, seq_len, device=device) < 0.5
112
+ timesteps = torch.rand(bs, device=device)
113
+ with torch.autocast("cuda", dtype=amp_dtype or torch.float16):
114
+ logits = model(input_ids, timesteps)
115
+ loss, num_masked = model.compute_loss(logits, labels, mask_positions)
116
+ if num_masked > 0:
117
+ (loss / 4).backward()
118
+ torch.cuda.synchronize()
119
+ peak_mem = torch.cuda.max_memory_allocated()
120
+ logger.info(f" batch_size={bs:>3d}: peak VRAM={format_bytes(peak_mem)} "
121
+ f"(limit={format_bytes(mem_limit)})")
122
+ if peak_mem >= mem_limit:
123
+ first_oom = bs
124
+ break
125
+ last_working = bs
126
+ except RuntimeError as e:
127
+ if "out of memory" in str(e).lower():
128
+ first_oom = bs
129
+ break
130
+ raise
131
+ finally:
132
+ model.zero_grad(set_to_none=True)
133
+ torch.cuda.empty_cache()
134
+ if first_oom is not None and last_working < first_oom - 1:
135
+ lo, hi = last_working, first_oom
136
+ while lo + 1 < hi:
137
+ mid = (lo + hi) // 2
138
+ try:
139
+ input_ids = torch.randint(0, 32000, (mid, seq_len), device=device)
140
+ labels = torch.randint(0, 32000, (mid, seq_len), device=device)
141
+ mask_positions = torch.rand(mid, seq_len, device=device) < 0.5
142
+ timesteps = torch.rand(mid, device=device)
143
+ with torch.autocast("cuda", dtype=amp_dtype or torch.float16):
144
+ logits = model(input_ids, timesteps)
145
+ loss, num_masked = model.compute_loss(logits, labels, mask_positions)
146
+ if num_masked > 0:
147
+ (loss / 4).backward()
148
+ torch.cuda.synchronize()
149
+ if torch.cuda.max_memory_allocated() < mem_limit:
150
+ lo = mid
151
+ else:
152
+ hi = mid
153
+ except RuntimeError as e:
154
+ if "out of memory" in str(e).lower():
155
+ hi = mid
156
+ else:
157
+ raise
158
+ finally:
159
+ model.zero_grad(set_to_none=True)
160
+ torch.cuda.empty_cache()
161
+ last_working = lo
162
+ model.zero_grad(set_to_none=True)
163
+ torch.cuda.empty_cache()
164
+ logger.info(f"Detected max batch_size: {last_working}")
165
+ return last_working
166
+
167
+
168
+ def get_step_from_filename(filename):
169
+ basename = os.path.basename(filename)
170
+ m = re.match(r"step_(\d+)(?:_\w+)?\.pt$", basename)
171
+ return int(m.group(1)) if m else None
172
+
173
+
174
+ def load_best_steps(stats_path, max_n):
175
+ if not os.path.exists(stats_path):
176
+ return set()
177
+ entries = []
178
+ with open(stats_path) as f:
179
+ for line in f:
180
+ line = line.strip()
181
+ if not line:
182
+ continue
183
+ try:
184
+ entry = json.loads(line)
185
+ if "step" in entry and "loss" in entry:
186
+ entries.append((entry["loss"], entry["step"]))
187
+ except json.JSONDecodeError:
188
+ continue
189
+ return {step for _, step in heapq.nsmallest(max_n, entries)}
190
+
191
+
192
+ def cleanup_checkpoints(output_dir, keep_first_n, keep_last_n, keep_best_n, stats_path):
193
+ all_ckpts = sorted(glob.glob(os.path.join(output_dir, "step_*.pt")))
194
+ if len(all_ckpts) <= keep_first_n + keep_last_n + keep_best_n:
195
+ return
196
+ first_steps = {get_step_from_filename(c) for c in all_ckpts[:keep_first_n]}
197
+ last_steps = {get_step_from_filename(c) for c in all_ckpts[-keep_last_n:]}
198
+ best_steps = load_best_steps(stats_path, keep_best_n)
199
+ keep_steps = (first_steps | last_steps | best_steps) - {None}
200
+ for ckpt in all_ckpts:
201
+ s = get_step_from_filename(ckpt)
202
+ if s is not None and s not in keep_steps:
203
+ try:
204
+ os.remove(ckpt)
205
+ except OSError:
206
+ pass
207
+ logger.info(f"Cleaned old checkpoints (kept {len(keep_steps)}: "
208
+ f"{len(first_steps)} first, {len(last_steps)} last, {len(best_steps)} best)")
209
+
210
+
211
+ def get_free_disk_space(path):
212
+ return shutil.disk_usage(path).free
213
+
214
+ def build_config(config_dict):
215
+ """Build MetaDiffusionConfig, ignoring non-dataclass keys (model_type, ...)."""
216
+ valid = {k: v for k, v in config_dict.items() if k in MetaDiffusionConfig.__dataclass_fields__}
217
+ config = MetaDiffusionConfig(**valid)
218
+ config.tie_word_embeddings = False # released checkpoint has an untied lm_head
219
+ return config
220
+
221
+
222
+ def load_model(model_path, device):
223
+ """Load MetaDiffusionLM from a dir (config.json + model.safetensors) or a step_*.pt."""
224
+ path = Path(model_path)
225
+ if path.is_dir():
226
+ with open(path / "config.json") as f:
227
+ config = build_config(json.load(f))
228
+ model = MetaDiffusionLM(config).to(device)
229
+ sd = load_file(path / "model.safetensors")
230
+ sd = {k[len("model."):] if k.startswith("model.") else k: v for k, v in sd.items()}
231
+ missing, unexpected = model.load_state_dict(sd, strict=False)
232
+ if missing or unexpected:
233
+ logger.warning(f"missing={missing[:5]} unexpected={unexpected[:5]}")
234
+ else:
235
+ ckpt = torch.load(path, map_location=device, weights_only=False)
236
+ config = build_config(ckpt["config"])
237
+ model = MetaDiffusionLM(config).to(device)
238
+ model.load_state_dict(clean_state_dict(ckpt["model_state_dict"]))
239
+ return model, config
240
+
241
+
242
+ def clean_state_dict(state_dict):
243
+ """Strip torch.compile's _orig_mod. prefix from checkpoint keys."""
244
+ return {k.replace("_orig_mod.", "", 1) if k.startswith("_orig_mod.") else k: v
245
+ for k, v in state_dict.items()}
246
+
247
+
248
+ def expand_embeddings(model, new_vocab):
249
+ """Mean-init new rows (ChatML + rainbow tokens) in embed_tokens and lm_head.
250
+
251
+ New modules are created on the model's device/dtype: nn.Embedding/nn.Linear
252
+ default to CPU, which would crash the first forward ("Tensor device
253
+ mismatch") unless something else moves the model afterwards.
254
+ """
255
+ old_vocab = model.config.mask_vocab_size
256
+ if new_vocab <= old_vocab:
257
+ return
258
+ device = model.embed_tokens.weight.device
259
+ dtype = model.embed_tokens.weight.dtype
260
+ mean_emb = model.embed_tokens.weight.data.mean(dim=0, keepdim=True)
261
+ n_new = new_vocab - old_vocab
262
+
263
+ emb = torch.cat([model.embed_tokens.weight.data, mean_emb.expand(n_new, -1)], dim=0)
264
+ model.embed_tokens = nn.Embedding(new_vocab, model.config.hidden_size,
265
+ padding_idx=model.config.pad_token_id).to(device, dtype)
266
+ model.embed_tokens.weight.data.copy_(emb)
267
+
268
+ head = torch.cat([model.lm_head.weight.data, mean_emb.expand(n_new, -1)], dim=0)
269
+ model.lm_head = nn.Linear(model.config.hidden_size, new_vocab, bias=False).to(device, dtype)
270
+ model.lm_head.weight.data.copy_(head)
271
+
272
+ model.config.mask_vocab_size = new_vocab
273
+ logger.info(f"Expanded embeddings {old_vocab} -> {new_vocab} (mean init)")
274
+
275
+
276
+ class ChatDataset(Dataset):
277
+ """no_robots ChatML examples; masks ONLY the last assistant response.
278
+
279
+ Examples are stored as plain int lists (NOT tensors): with forkserver
280
+ workers (torch's default once CUDA is initialized), every tensor in the
281
+ dataset is transferred through shared memory at worker spawn, and 9000
282
+ tensors blows the open-file limit. Lists pickle as bytes.
283
+ """
284
+
285
+ def __init__(self, data_path, tokenizer, seq_len, seed=42):
286
+ raw = torch.load(data_path, weights_only=True)["examples"]
287
+ self.examples = [
288
+ {
289
+ "ids": ex["input_ids"].tolist(),
290
+ "a0": int(ex["assistant_start"]),
291
+ "a1": int(ex["assistant_end"]),
292
+ }
293
+ for ex in raw
294
+ ]
295
+ self.seq_len = seq_len
296
+ self.mask_id = MASK_TOKEN_ID
297
+ self.rainbow_ids = [
298
+ tokenizer.convert_tokens_to_ids(f"<|r{i}|>") for i in range(1, 8)
299
+ ]
300
+ self.seed = seed
301
+ logger.info(f"Loaded {len(self.examples)} examples from {data_path}")
302
+
303
+ def __len__(self):
304
+ return len(self.examples)
305
+
306
+ def __getitem__(self, idx):
307
+ ex = self.examples[idx]
308
+ ids = ex["ids"]
309
+ a0, a1 = ex["a0"], ex["a1"]
310
+
311
+ # Guard truncation
312
+ if len(ids) > self.seq_len:
313
+ resp = ids[a0:a1]
314
+ if len(resp) > self.seq_len:
315
+ resp = resp[: self.seq_len]
316
+ room = self.seq_len - len(resp)
317
+ hist = ids[:a0]
318
+ hist = hist[len(hist) - room:] if room > 0 else []
319
+ ids = hist + resp
320
+ a0, a1 = len(hist), len(ids)
321
+
322
+ # Rainbow padding (cyclic, never masked, never in loss)
323
+ n = len(ids)
324
+ pad = self.seq_len - n
325
+ full = ids + [self.rainbow_ids[j % 7] for j in range(pad)]
326
+
327
+ can_mask = torch.zeros(self.seq_len, dtype=torch.bool)
328
+ can_mask[a0:a1] = True
329
+
330
+ t = torch.rand(1).item()
331
+ rand = torch.rand(self.seq_len)
332
+ mask_pos = (rand < t) & can_mask
333
+
334
+ input_ids = torch.tensor(full, dtype=torch.long)
335
+ input_ids[mask_pos] = self.mask_id
336
+ attention = torch.ones(self.seq_len, dtype=torch.long)
337
+ attention[n:] = 0
338
+
339
+ return {
340
+ "input_ids": input_ids,
341
+ "labels": torch.tensor(full, dtype=torch.long),
342
+ "mask_positions": mask_pos,
343
+ "timesteps": torch.tensor(t, dtype=torch.float32),
344
+ "attention_mask": attention,
345
+ "resp_len": torch.tensor(max(a1 - a0, 1), dtype=torch.float32),
346
+ }
347
+
348
+
349
+ def collate_fn(batch):
350
+ return {
351
+ k: torch.stack([b[k] for b in batch]) for k in batch[0]
352
+ }
353
+
354
+
355
+ def worker_init_fn(worker_id):
356
+ torch.manual_seed(42 + worker_id)
357
+
358
+ def get_cosine_schedule_with_warmup(optimizer, num_warmup_steps, num_training_steps,
359
+ min_lr_ratio=0.1):
360
+ def lr_lambda(current_step):
361
+ if current_step < num_warmup_steps:
362
+ return float(current_step) / float(max(1, num_warmup_steps))
363
+ progress = float(current_step - num_warmup_steps) / float(
364
+ max(1, num_training_steps - num_warmup_steps)
365
+ )
366
+ return max(min_lr_ratio, 0.5 * (1.0 + math.cos(math.pi * progress)))
367
+ return LambdaLR(optimizer, lr_lambda)
368
+
369
+
370
+ @torch.no_grad()
371
+ def evaluate(model, val_dataset, batch_size, device, dtype, n_max=100):
372
+ model.eval()
373
+ losses, n_seen = [], 0
374
+ for start in range(0, min(len(val_dataset), n_max), batch_size):
375
+ idxs = list(range(start, min(start + batch_size, n_max)))
376
+ batch = collate_fn([val_dataset[i] for i in idxs])
377
+ input_ids = batch["input_ids"].to(device)
378
+ labels = batch["labels"].to(device)
379
+ mask_positions = batch["mask_positions"].to(device)
380
+ timesteps = batch["timesteps"].to(device)
381
+ attention = batch["attention_mask"].to(device)
382
+ resp_len = batch["resp_len"].to(device)
383
+ with torch.autocast("cuda", dtype=dtype):
384
+ logits = model(input_ids, timesteps, attention_mask=attention)
385
+ if mask_positions.any():
386
+ ce = F.cross_entropy(logits.float()[mask_positions],
387
+ labels[mask_positions], reduction="none")
388
+ w = (1.0 / (timesteps * resp_len)).unsqueeze(1).expand_as(labels)
389
+ loss = (ce * w[mask_positions]).sum() / input_ids.shape[0]
390
+ losses.append(loss.item())
391
+ n_seen += 1
392
+ model.train()
393
+ return sum(losses) / len(losses) if losses else float("nan"), n_seen
394
+
395
+
396
+ def main():
397
+ parser = argparse.ArgumentParser(description="Chat SFT for MetaDiffusion (LLaDA Algorithm 2)")
398
+ parser.add_argument("--model-path", default="../hf_release",
399
+ help="Dir with config.json + model.safetensors, or a step_*.pt")
400
+ parser.add_argument("--data-dir", default="data/no_robots_chatml")
401
+ parser.add_argument("--output-dir", default="checkpoints_chat")
402
+ parser.add_argument("--seq-len", type=int, default=512)
403
+ parser.add_argument("--batch-size", type=int, default=0, help="0 = auto-detect")
404
+ parser.add_argument("--grad-accum-steps", type=int, default=4)
405
+ parser.add_argument("--num-workers", type=int, default=4,
406
+ help="DataLoader workers (0 if forkserver shm issues)")
407
+ parser.add_argument("--lr", type=float, default=3e-5)
408
+ parser.add_argument("--min-lr-ratio", type=float, default=0.1)
409
+ parser.add_argument("--warmup-steps", type=int, default=100)
410
+ parser.add_argument("--weight-decay", type=float, default=0.1)
411
+ parser.add_argument("--epochs", type=int, default=8)
412
+ parser.add_argument("--max-steps", type=int, default=0, help="0 = epochs only")
413
+ parser.add_argument("--transferred-lr-mult", type=float, default=0.33)
414
+ parser.add_argument("--new-lr-mult", type=float, default=1.0)
415
+ parser.add_argument("--max-grad-norm", type=float, default=1.0)
416
+ parser.add_argument("--save-every", type=int, default=500)
417
+ parser.add_argument("--log-every", type=int, default=50)
418
+ parser.add_argument("--val-every", type=int, default=200)
419
+ parser.add_argument("--patience", type=int, default=3,
420
+ help="Early stop after N val checks without improvement (0 = off)")
421
+ parser.add_argument("--min-delta", type=float, default=0.001,
422
+ help="Relative val-loss improvement required to count as progress")
423
+ parser.add_argument("--resume-from", type=str, default=None)
424
+ parser.add_argument("--seed", type=int, default=42)
425
+ parser.add_argument("--device", default="cuda", help="cuda, cuda:1, cpu")
426
+ parser.add_argument("--bf16", action="store_true", help="Use bf16 instead of fp16")
427
+ parser.add_argument("--no-compile", action="store_true")
428
+ parser.add_argument("--keep-free-vram", type=float, default=0.1)
429
+ parser.add_argument("--keep-first-n", type=int, default=2)
430
+ parser.add_argument("--keep-last-n", type=int, default=2)
431
+ parser.add_argument("--keep-best-n", type=int, default=2)
432
+ parser.add_argument("--disk-min-gb", type=float, default=5.0)
433
+ parser.add_argument("--export-dir", type=str, default=None,
434
+ help="Export final dir (config+safetensors+tokenizer)")
435
+ args = parser.parse_args()
436
+
437
+ device = torch.device(args.device if torch.cuda.is_available() else "cpu")
438
+ torch.manual_seed(args.seed)
439
+
440
+ model, config = load_model(args.model_path, device)
441
+ logger.info(f"Loaded: {config.num_hidden_layers}L x {config.hidden_size}W, "
442
+ f"vocab={config.mask_vocab_size}")
443
+
444
+ if args.resume_from is None:
445
+ expand_embeddings(model, CHAT_VOCAB)
446
+ else:
447
+ logger.info(f"Resuming: keeping expanded vocab {config.mask_vocab_size}")
448
+
449
+ from transformers import AutoTokenizer
450
+ tokenizer = AutoTokenizer.from_pretrained(os.path.join(args.data_dir, "tokenizer"))
451
+ ensure_chat_tokens(tokenizer)
452
+ im_end = tokenizer.convert_tokens_to_ids("<|im_end|>")
453
+ assert im_end == 32002, (
454
+ f"Tokenizer has im_end={im_end}, expected 32002. "
455
+ f"Data dir is stale (pre-fix ids): re-run prepare_data.py first."
456
+ )
457
+ logger.info(f"Tokenizer vocab: {len(tokenizer)} | im_end={im_end}")
458
+
459
+ if args.bf16:
460
+ model = model.to(torch.bfloat16)
461
+ amp_dtype = torch.bfloat16
462
+ else:
463
+ # fp16 AMP: keep fp32 master weights, autocast does the fp16 compute.
464
+ # GradScaler requires fp32 gradients; fp16 weights would produce fp16
465
+ # grads and unscale_ raises "Attempting to unscale FP16 gradients".
466
+ amp_dtype = torch.float16
467
+
468
+ train_ds = ChatDataset(os.path.join(args.data_dir, "train.pt"), tokenizer,
469
+ args.seq_len, seed=args.seed)
470
+ val_ds = ChatDataset(os.path.join(args.data_dir, "val.pt"), tokenizer,
471
+ args.seq_len, seed=args.seed)
472
+
473
+ transferred_names, new_names = set(), set()
474
+ for name, p in model.named_parameters():
475
+ if any(k in name for k in ["timestep_emb", "timestep_residual", "lm_head",
476
+ "embed_tokens.weight"]):
477
+ new_names.add(name)
478
+ else:
479
+ transferred_names.add(name)
480
+ param_groups = [
481
+ {"params": [p for n, p in model.named_parameters() if n in transferred_names],
482
+ "lr": args.lr * args.transferred_lr_mult, "name": "transferred"},
483
+ {"params": [p for n, p in model.named_parameters() if n in new_names],
484
+ "lr": args.lr * args.new_lr_mult, "name": "new"},
485
+ ]
486
+ for pg in param_groups:
487
+ logger.info(f" {pg['name']}: {sum(p.numel() for p in pg['params']):,} params, "
488
+ f"lr={pg['lr']:.2e}")
489
+
490
+ optimizer = AdamW(param_groups, weight_decay=args.weight_decay)
491
+
492
+ batch_size = args.batch_size
493
+ if batch_size <= 0 and torch.cuda.is_available():
494
+ batch_size = detect_max_batch_size(model, args.seq_len, device,
495
+ args.keep_free_vram, amp_dtype)
496
+ if batch_size <= 0:
497
+ batch_size = 8
498
+ eff_batch = batch_size * args.grad_accum_steps
499
+ steps_per_epoch = max(1, math.ceil(len(train_ds) / eff_batch))
500
+ total_steps = args.max_steps if args.max_steps > 0 else steps_per_epoch * args.epochs
501
+ logger.info(f"batch={batch_size} accum={args.grad_accum_steps} "
502
+ f"eff={eff_batch} steps/epoch={steps_per_epoch} total={total_steps}")
503
+
504
+ # Compile AFTER batch detection + resume (avoids recompiles per probe
505
+ # batch size, and lets resume load clean keys into a plain nn.Module)
506
+ scheduler = get_cosine_schedule_with_warmup(
507
+ optimizer, args.warmup_steps, total_steps, args.min_lr_ratio
508
+ )
509
+
510
+ dataloader = DataLoader(train_ds, batch_size=batch_size, shuffle=True,
511
+ num_workers=args.num_workers, pin_memory=True,
512
+ drop_last=False, collate_fn=collate_fn,
513
+ worker_init_fn=worker_init_fn)
514
+
515
+ global_step = 0
516
+ if args.resume_from:
517
+ ckpt = torch.load(args.resume_from, map_location=device, weights_only=False)
518
+ model.load_state_dict(clean_state_dict(ckpt["model_state_dict"]))
519
+ optim_state = ckpt.get("optimizer_state_dict", {})
520
+ if optim_state and "param_groups" in optim_state and "state" in optim_state:
521
+ try:
522
+ optimizer.load_state_dict(optim_state)
523
+ except (ValueError, KeyError) as e:
524
+ logger.warning(f"Optimizer state not loaded: {e}")
525
+ sched_state = ckpt.get("scheduler_state_dict", {})
526
+ if sched_state and sched_state.get("last_epoch", 0) == ckpt.get("step", 0):
527
+ try:
528
+ scheduler.load_state_dict(sched_state)
529
+ except (ValueError, KeyError) as e:
530
+ logger.warning(f"Scheduler state not loaded: {e}")
531
+ global_step = ckpt.get("step", 0)
532
+ logger.info(f"Resumed from step {global_step}")
533
+
534
+ if not args.no_compile:
535
+ logger.info("Compiling model...")
536
+ model = torch.compile(model)
537
+
538
+ os.makedirs(args.output_dir, exist_ok=True)
539
+ stats_path = os.path.join(args.output_dir, "stats.jsonl")
540
+ stats_file = open(stats_path, "a")
541
+ with open(os.path.join(args.output_dir, "config.json"), "w") as f:
542
+ json.dump(asdict(model.config), f, indent=2, default=str)
543
+
544
+ scaler = torch.amp.GradScaler("cuda", enabled=not args.bf16)
545
+ free_disk = get_free_disk_space(args.output_dir)
546
+ if free_disk < args.disk_min_gb * 1e9:
547
+ stats_file.close()
548
+ raise RuntimeError(f"Insufficient disk space: {format_bytes(free_disk)}")
549
+
550
+ model.train()
551
+ optimizer.zero_grad()
552
+ loss_total, loss_count = 0.0, 0
553
+ start_time = time.time()
554
+ last_log_time = start_time
555
+ data_iter = iter(dataloader)
556
+ epoch = 0
557
+ best_val = float("inf")
558
+ no_improve = 0
559
+ early_stopped = False
560
+
561
+ while global_step < total_steps:
562
+ if global_step % steps_per_epoch == 0 and global_step > 0:
563
+ epoch += 1
564
+ try:
565
+ batch = next(data_iter)
566
+ except StopIteration:
567
+ epoch += 1
568
+ data_iter = iter(dataloader)
569
+ batch = next(data_iter)
570
+
571
+ input_ids = batch["input_ids"].to(device)
572
+ labels = batch["labels"].to(device)
573
+ mask_positions = batch["mask_positions"].to(device)
574
+ timesteps = batch["timesteps"].to(device)
575
+ attention = batch["attention_mask"].to(device)
576
+ resp_len = batch["resp_len"].to(device)
577
+
578
+ with torch.autocast("cuda", dtype=amp_dtype):
579
+ logits = model(input_ids, timesteps, attention_mask=attention)
580
+
581
+ num_masked = mask_positions.sum().item()
582
+ if num_masked > 0:
583
+ # LLaDA GUIDELINES loss: CE/(t * response_len) summed over masked
584
+ # response tokens, mean over batch. Expected value ~ per-token CE.
585
+ ce = F.cross_entropy(logits.float()[mask_positions],
586
+ labels[mask_positions], reduction="none")
587
+ w = (1.0 / (timesteps * resp_len)).unsqueeze(1).expand_as(labels)
588
+ loss = (ce * w[mask_positions]).sum() / input_ids.shape[0]
589
+ scaler.scale(loss / args.grad_accum_steps).backward()
590
+ loss_total += loss.item()
591
+ else:
592
+ loss = torch.tensor(0.0, device=device)
593
+
594
+ global_step += 1
595
+
596
+ if global_step % args.grad_accum_steps == 0:
597
+ scaler.unscale_(optimizer)
598
+ torch.nn.utils.clip_grad_norm_(model.parameters(), args.max_grad_norm)
599
+ skipped = scaler.step(optimizer) # True when grads had inf/nan
600
+ scaler.update()
601
+ if not skipped:
602
+ scheduler.step() # don't advance LR on skipped steps
603
+ optimizer.zero_grad()
604
+
605
+ loss_count += 1
606
+
607
+ if global_step % args.log_every == 0:
608
+ now = time.time()
609
+ elapsed = now - start_time
610
+ avg_loss = loss_total / max(1, loss_count)
611
+ ppl = math.exp(min(avg_loss, 20))
612
+ lr = scheduler.get_last_lr()[0]
613
+ steps_per_sec = args.log_every / max(now - last_log_time, 1e-6)
614
+ eta = format_duration((total_steps - global_step) / steps_per_sec)
615
+ logger.info(f"Step {global_step:>6d} | epoch {epoch:.1f} | loss={avg_loss:.4f} | "
616
+ f"ppl={ppl:.1f} | lr={lr:.2e} | {steps_per_sec:.1f} steps/s | "
617
+ f"elapsed={elapsed:.0f}s | eta={eta}")
618
+ loss_total, loss_count = 0.0, 0
619
+ last_log_time = now
620
+
621
+ stats_entry = {"step": global_step, "epoch": round(epoch, 2),
622
+ "loss": round(avg_loss, 4), "ppl": round(ppl, 1), "lr": lr}
623
+ stats_file.write(json.dumps(stats_entry) + "\n")
624
+ stats_file.flush()
625
+
626
+ # Validation + early stopping (independent of log cadence)
627
+ if global_step % args.val_every == 0:
628
+ val_loss, _ = evaluate(model, val_ds, batch_size, device, amp_dtype)
629
+ logger.info(f" val_loss={val_loss:.4f}")
630
+ stats_file.write(json.dumps({"step": global_step,
631
+ "val_loss": round(val_loss, 4)}) + "\n")
632
+ stats_file.flush()
633
+ if val_loss < best_val * (1.0 - args.min_delta):
634
+ best_val = val_loss
635
+ no_improve = 0
636
+ ckpt_path = os.path.join(args.output_dir, "best.pt")
637
+ torch.save({"step": global_step,
638
+ "model_state_dict": clean_state_dict(model.state_dict()),
639
+ "optimizer_state_dict": optimizer.state_dict(),
640
+ "scheduler_state_dict": scheduler.state_dict(),
641
+ "config": asdict(model.config)}, ckpt_path)
642
+ logger.info(f" Best val loss, saved {ckpt_path}")
643
+ else:
644
+ no_improve += 1
645
+ logger.info(f" No val improvement ({no_improve}/{args.patience} checks, "
646
+ f"best={best_val:.4f})")
647
+ if args.patience > 0 and no_improve >= args.patience:
648
+ logger.info(f"Early stopping at step {global_step}: no val loss "
649
+ f"improvement for {args.patience} checks "
650
+ f"(best={best_val:.4f})")
651
+ ckpt_path = os.path.join(args.output_dir, f"step_{global_step}.pt")
652
+ torch.save({"step": global_step,
653
+ "model_state_dict": clean_state_dict(model.state_dict()),
654
+ "optimizer_state_dict": optimizer.state_dict(),
655
+ "scheduler_state_dict": scheduler.state_dict(),
656
+ "config": asdict(model.config)}, ckpt_path)
657
+ logger.info(f"Saved final checkpoint: {ckpt_path}")
658
+ stats_file.write(json.dumps(
659
+ {**stats_entry, "best_val": round(best_val, 4),
660
+ "early_stopped": True}) + "\n")
661
+ stats_file.flush()
662
+ stats_file.close()
663
+ early_stopped = True
664
+ break
665
+
666
+ if global_step % args.save_every == 0:
667
+ ckpt_path = os.path.join(args.output_dir, f"step_{global_step}.pt")
668
+ torch.save({"step": global_step,
669
+ "model_state_dict": clean_state_dict(model.state_dict()),
670
+ "optimizer_state_dict": optimizer.state_dict(),
671
+ "scheduler_state_dict": scheduler.state_dict(),
672
+ "config": asdict(model.config)}, ckpt_path)
673
+ logger.info(f"Saved checkpoint: {ckpt_path}")
674
+ cleanup_checkpoints(args.output_dir, args.keep_first_n,
675
+ args.keep_last_n, args.keep_best_n, stats_path)
676
+ if get_free_disk_space(args.output_dir) < args.disk_min_gb * 1e9:
677
+ logger.warning("Low disk after save; stopping")
678
+ stats_file.close()
679
+ return
680
+
681
+ stats_file.close()
682
+ if early_stopped:
683
+ logger.info(f"Early stopping triggered; best val loss {best_val:.4f} "
684
+ f"saved as best.pt")
685
+ else:
686
+ logger.info(f"Training complete at step {global_step}")
687
+
688
+ if args.export_dir:
689
+ from export_hf import export
690
+ export(os.path.join(args.output_dir, f"step_{global_step}.pt")
691
+ if not os.path.exists(os.path.join(args.output_dir, "best.pt"))
692
+ else os.path.join(args.output_dir, "best.pt"),
693
+ os.path.join(args.data_dir, "tokenizer"),
694
+ args.export_dir)
695
+
696
+
697
+ if __name__ == "__main__":
698
+ main()
tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
tokenizer_config.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "backend": "tokenizers",
3
+ "bos_token": "<s>",
4
+ "clean_up_tokenization_spaces": false,
5
+ "eos_token": "</s>",
6
+ "extra_special_tokens": [
7
+ "<|im_start|>",
8
+ "<|im_end|>",
9
+ "<|r1|>",
10
+ "<|r2|>",
11
+ "<|r3|>",
12
+ "<|r4|>",
13
+ "<|r5|>",
14
+ "<|r6|>",
15
+ "<|r7|>"
16
+ ],
17
+ "is_local": true,
18
+ "local_files_only": false,
19
+ "model_max_length": 5120,
20
+ "pad_token": "<pad>",
21
+ "tokenizer_class": "TokenizersBackend",
22
+ "unk_token": "<unk>"
23
+ }