Cytrex commited on
Commit
21873b3
·
verified ·
1 Parent(s): b2a103b

Add self-distill + train job script

Browse files
Files changed (1) hide show
  1. scripts/hf_job_selfdistill_train.py +610 -0
scripts/hf_job_selfdistill_train.py ADDED
@@ -0,0 +1,610 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = [
4
+ # "torch>=2.4",
5
+ # "transformers>=4.48",
6
+ # "huggingface_hub>=1.9",
7
+ # "rich",
8
+ # ]
9
+ # ///
10
+ """
11
+ FastMTP Self-Distill + Train — HuggingFace Job Script
12
+
13
+ Runs on HF A100 as a single job:
14
+ 1. Load Gemma 4 E4B-it from HF Hub
15
+ 2. Generate self-distillation responses from prompts
16
+ 3. Train MTP head (frozen base, only head updates)
17
+ 4. Upload MTP head checkpoint to HF
18
+
19
+ Usage:
20
+ hf jobs uv run experiments/hf_job_selfdistill_train.py \
21
+ --flavor a100-large --with torch transformers huggingface_hub rich \
22
+ --env HF_TOKEN=hf_xxx \
23
+ --env MODEL_ID=google/gemma-4-E4B-it \
24
+ --env MTP_CONFIG=e4b \
25
+ --env MAX_PROMPTS=2000 \
26
+ --env TRAIN_EPOCHS=3 \
27
+ --detach
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import math
33
+ import os
34
+ import sys
35
+ import time
36
+ from pathlib import Path
37
+ from typing import Optional
38
+
39
+ import torch
40
+ import torch.nn as nn
41
+ import torch.nn.functional as F
42
+ from transformers import AutoTokenizer, AutoModelForCausalLM
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Config from env
46
+ # ---------------------------------------------------------------------------
47
+ MODEL_ID = os.environ.get("MODEL_ID", "google/gemma-4-E4B-it")
48
+ MTP_CONFIG_NAME = os.environ.get("MTP_CONFIG", "e4b")
49
+ MAX_PROMPTS = int(os.environ.get("MAX_PROMPTS", "2000"))
50
+ TRAIN_EPOCHS = int(os.environ.get("TRAIN_EPOCHS", "3"))
51
+ BATCH_SIZE = int(os.environ.get("BATCH_SIZE", "4"))
52
+ MAX_SEQ_LEN = int(os.environ.get("MAX_SEQ_LEN", "2048"))
53
+ LR = float(os.environ.get("LR", "5e-5"))
54
+ K = int(os.environ.get("K", "3"))
55
+ BETA = float(os.environ.get("BETA", "0.6"))
56
+ HF_UPLOAD_REPO = os.environ.get("HF_UPLOAD_REPO", "Cytrex/fastmtp-training")
57
+ PROMPTS_REPO = os.environ.get("PROMPTS_REPO", "Cytrex/fastmtp-prompts")
58
+ LOG_EVERY = int(os.environ.get("LOG_EVERY", "25"))
59
+ GEN_BATCH = int(os.environ.get("GEN_BATCH", "8"))
60
+ GEN_MAX_TOKENS = int(os.environ.get("GEN_MAX_TOKENS", "2048"))
61
+
62
+ print(f"=== FastMTP Self-Distill + Train ===")
63
+ print(f"Model: {MODEL_ID}")
64
+ print(f"MTP config: {MTP_CONFIG_NAME}")
65
+ print(f"Max prompts: {MAX_PROMPTS}")
66
+ print(f"Epochs: {TRAIN_EPOCHS}")
67
+ print(f"Batch size: {BATCH_SIZE}")
68
+ print(f"Upload to: {HF_UPLOAD_REPO}")
69
+ print(f"Prompts from: {PROMPTS_REPO}")
70
+
71
+
72
+ # =====================================================================
73
+ # MTP HEAD (inlined from lib/mtp_head.py)
74
+ # =====================================================================
75
+
76
+ GEMMA4_CONFIGS = {
77
+ "e2b": {
78
+ "hidden_size": 1536,
79
+ "intermediate_size": 6144,
80
+ "num_attention_heads": 8,
81
+ "num_key_value_heads": 2,
82
+ "vocab_size": 262144,
83
+ },
84
+ "e4b": {
85
+ "hidden_size": 2560,
86
+ "intermediate_size": 10240,
87
+ "num_attention_heads": 8,
88
+ "num_key_value_heads": 2,
89
+ "vocab_size": 262144,
90
+ },
91
+ "27b": {
92
+ "hidden_size": 3584,
93
+ "intermediate_size": 14336,
94
+ "num_attention_heads": 32,
95
+ "num_key_value_heads": 8,
96
+ "vocab_size": 262144,
97
+ },
98
+ "31b": {
99
+ "hidden_size": 3584,
100
+ "intermediate_size": 14336,
101
+ "num_attention_heads": 32,
102
+ "num_key_value_heads": 8,
103
+ "vocab_size": 262144,
104
+ },
105
+ }
106
+
107
+
108
+ class MTPHead(nn.Module):
109
+ def __init__(
110
+ self,
111
+ hidden_size: int,
112
+ intermediate_size: int,
113
+ num_attention_heads: int,
114
+ num_key_value_heads: int,
115
+ vocab_size: int,
116
+ head_dim: Optional[int] = None,
117
+ rms_norm_eps: float = 1e-6,
118
+ ):
119
+ super().__init__()
120
+ self.hidden_size = hidden_size
121
+ self.num_heads = num_attention_heads
122
+ self.num_kv_heads = num_key_value_heads
123
+ self.head_dim = head_dim if head_dim is not None else hidden_size // num_attention_heads
124
+ self.vocab_size = vocab_size
125
+
126
+ self.embed_tokens = nn.Embedding(vocab_size, hidden_size)
127
+ self.fusion_proj = nn.Linear(hidden_size * 2, hidden_size, bias=False)
128
+ self.fusion_norm = nn.RMSNorm(hidden_size, eps=rms_norm_eps)
129
+ self.attn_norm = nn.RMSNorm(hidden_size, eps=rms_norm_eps)
130
+ self.q_proj = nn.Linear(hidden_size, self.num_heads * self.head_dim, bias=False)
131
+ self.k_proj = nn.Linear(hidden_size, self.num_kv_heads * self.head_dim, bias=False)
132
+ self.v_proj = nn.Linear(hidden_size, self.num_kv_heads * self.head_dim, bias=False)
133
+ self.o_proj = nn.Linear(self.num_heads * self.head_dim, hidden_size, bias=False)
134
+ self.ffn_norm = nn.RMSNorm(hidden_size, eps=rms_norm_eps)
135
+ self.gate_proj = nn.Linear(hidden_size, intermediate_size, bias=False)
136
+ self.up_proj = nn.Linear(hidden_size, intermediate_size, bias=False)
137
+ self.down_proj = nn.Linear(intermediate_size, hidden_size, bias=False)
138
+ self.lm_head = nn.Linear(hidden_size, vocab_size, bias=False)
139
+
140
+ def _attention(self, x: torch.Tensor) -> torch.Tensor:
141
+ B, T, _ = x.shape
142
+ residual = x
143
+ x = self.attn_norm(x)
144
+ q = self.q_proj(x).view(B, T, self.num_heads, self.head_dim).transpose(1, 2)
145
+ k = self.k_proj(x).view(B, T, self.num_kv_heads, self.head_dim).transpose(1, 2)
146
+ v = self.v_proj(x).view(B, T, self.num_kv_heads, self.head_dim).transpose(1, 2)
147
+ if self.num_kv_heads < self.num_heads:
148
+ repeat = self.num_heads // self.num_kv_heads
149
+ k = k.repeat_interleave(repeat, dim=1)
150
+ v = v.repeat_interleave(repeat, dim=1)
151
+ out = F.scaled_dot_product_attention(q, k, v, is_causal=True)
152
+ out = out.transpose(1, 2).contiguous().view(B, T, self.num_heads * self.head_dim)
153
+ out = self.o_proj(out)
154
+ return residual + out
155
+
156
+ def _ffn(self, x: torch.Tensor) -> torch.Tensor:
157
+ residual = x
158
+ x = self.ffn_norm(x)
159
+ gate = F.silu(self.gate_proj(x))
160
+ up = self.up_proj(x)
161
+ out = self.down_proj(gate * up)
162
+ return residual + out
163
+
164
+ def forward(self, hidden_states: torch.Tensor, shifted_token_ids: torch.Tensor):
165
+ token_embed = self.embed_tokens(shifted_token_ids)
166
+ fused = torch.cat([hidden_states, token_embed], dim=-1)
167
+ fused = self.fusion_proj(fused)
168
+ fused = self.fusion_norm(fused)
169
+ x = self._attention(fused)
170
+ x = self._ffn(x)
171
+ logits = self.lm_head(x)
172
+ return logits, x
173
+
174
+ @classmethod
175
+ def from_base_model(cls, base_model: nn.Module, config: dict) -> "MTPHead":
176
+ head = cls(
177
+ hidden_size=config["hidden_size"],
178
+ intermediate_size=config["intermediate_size"],
179
+ num_attention_heads=config["num_attention_heads"],
180
+ num_key_value_heads=config["num_key_value_heads"],
181
+ vocab_size=config["vocab_size"],
182
+ )
183
+ if hasattr(base_model, "model") and hasattr(base_model.model, "language_model"):
184
+ lm_body = base_model.model.language_model
185
+ elif hasattr(base_model, "model"):
186
+ lm_body = base_model.model
187
+ else:
188
+ lm_body = base_model
189
+ if hasattr(lm_body, "embed_tokens"):
190
+ head.embed_tokens = lm_body.embed_tokens
191
+ head.embed_tokens.requires_grad_(False)
192
+ if hasattr(base_model, "lm_head"):
193
+ head.lm_head = base_model.lm_head
194
+ head.lm_head.requires_grad_(False)
195
+ elif hasattr(lm_body, "lm_head"):
196
+ head.lm_head = lm_body.lm_head
197
+ head.lm_head.requires_grad_(False)
198
+ return head
199
+
200
+ def trainable_params(self) -> list[nn.Parameter]:
201
+ tied_ids = set()
202
+ for module in (self.embed_tokens, self.lm_head):
203
+ for p in module.parameters():
204
+ tied_ids.add(id(p))
205
+ return [p for p in self.parameters() if id(p) not in tied_ids and p.requires_grad]
206
+
207
+ def n_trainable(self) -> int:
208
+ return sum(p.numel() for p in self.trainable_params())
209
+
210
+
211
+ # =====================================================================
212
+ # MTP TRAINING UTILS (inlined from lib/mtp_train.py)
213
+ # =====================================================================
214
+
215
+ def compute_alpha_weights(k: int = 3, beta: float = 0.6) -> list[float]:
216
+ raw = [beta ** (i - 1) for i in range(1, k + 1)]
217
+ total = sum(raw)
218
+ return [w / total for w in raw]
219
+
220
+
221
+ def mtp_loss(draft_logits, target_ids, k=3, beta=0.6):
222
+ alphas = compute_alpha_weights(k=k, beta=beta)
223
+ total_loss = torch.tensor(0.0, device=draft_logits[0].device, requires_grad=True)
224
+ for i in range(k):
225
+ logits = draft_logits[i]
226
+ targets = target_ids[i]
227
+ B, T, V = logits.shape
228
+ ce = F.cross_entropy(logits.reshape(B * T, V), targets.reshape(B * T), ignore_index=-100)
229
+ total_loss = total_loss + alphas[i] * ce
230
+ return total_loss
231
+
232
+
233
+ def _get_last_layer(model: nn.Module) -> nn.Module:
234
+ if hasattr(model, "model") and hasattr(model.model, "language_model"):
235
+ return model.model.language_model.layers[-1]
236
+ if hasattr(model, "model") and hasattr(model.model, "layers"):
237
+ return model.model.layers[-1]
238
+ raise ValueError("Cannot find transformer layers for hook registration")
239
+
240
+
241
+ @torch.no_grad()
242
+ def extract_hidden_states(model, input_ids, attention_mask=None):
243
+ captured = {}
244
+ def hook_fn(module, args, output):
245
+ if isinstance(output, tuple):
246
+ captured["hidden"] = output[0]
247
+ else:
248
+ captured["hidden"] = output
249
+ last_layer = _get_last_layer(model)
250
+ handle = last_layer.register_forward_hook(hook_fn)
251
+ try:
252
+ kwargs = {"input_ids": input_ids}
253
+ if attention_mask is not None:
254
+ kwargs["attention_mask"] = attention_mask
255
+ model(**kwargs)
256
+ finally:
257
+ handle.remove()
258
+ return captured["hidden"]
259
+
260
+
261
+ def prepare_mtp_targets(input_ids, k=3, pad_id=-100):
262
+ if input_ids.dim() == 1:
263
+ input_ids = input_ids.unsqueeze(0)
264
+ B, S = input_ids.shape
265
+ valid_len = S - 2
266
+ targets = []
267
+ for i in range(1, k + 1):
268
+ shift = i + 1
269
+ avail = S - shift
270
+ if avail >= valid_len:
271
+ t = input_ids[:, shift: shift + valid_len]
272
+ else:
273
+ t_real = input_ids[:, shift:]
274
+ pad_len = valid_len - avail
275
+ pad = torch.full((B, pad_len), pad_id, dtype=input_ids.dtype, device=input_ids.device)
276
+ t = torch.cat([t_real, pad], dim=1)
277
+ targets.append(t)
278
+ return targets, valid_len
279
+
280
+
281
+ # =====================================================================
282
+ # SELF-DISTILL: Generate responses from the model
283
+ # =====================================================================
284
+
285
+ def generate_self_distill(model, tokenizer, prompts: list[str], batch_size: int = 8, max_tokens: int = 2048) -> list[dict]:
286
+ """Generate responses for all prompts, return list of {prompt, response}."""
287
+ print(f"\n=== Self-Distill: generating {len(prompts)} responses ===")
288
+ results = []
289
+ t0 = time.perf_counter()
290
+
291
+ for i in range(0, len(prompts), batch_size):
292
+ batch_prompts = prompts[i:i + batch_size]
293
+
294
+ # Format as chat
295
+ conversations = []
296
+ for p in batch_prompts:
297
+ conversations.append([{"role": "user", "content": p}])
298
+
299
+ # Tokenize with chat template
300
+ texts = [tokenizer.apply_chat_template(c, tokenize=False, add_generation_prompt=True) for c in conversations]
301
+ inputs = tokenizer(texts, return_tensors="pt", padding=True, truncation=True, max_length=max_tokens // 2)
302
+ inputs = {k: v.to(model.device) for k, v in inputs.items()}
303
+
304
+ with torch.no_grad():
305
+ outputs = model.generate(
306
+ **inputs,
307
+ max_new_tokens=max_tokens // 2,
308
+ temperature=0.6,
309
+ top_p=0.95,
310
+ do_sample=True,
311
+ pad_token_id=tokenizer.pad_token_id or tokenizer.eos_token_id,
312
+ )
313
+
314
+ # Decode only the generated part
315
+ for j, output_ids in enumerate(outputs):
316
+ input_len = inputs["input_ids"][j].shape[0]
317
+ response_ids = output_ids[input_len:]
318
+ response = tokenizer.decode(response_ids, skip_special_tokens=True)
319
+ if response.strip():
320
+ results.append({"prompt": batch_prompts[j], "response": response.strip()})
321
+
322
+ if (i // batch_size + 1) % 10 == 0 or i + batch_size >= len(prompts):
323
+ elapsed = time.perf_counter() - t0
324
+ rate = len(results) / elapsed if elapsed > 0 else 0
325
+ print(f" [{len(results)}/{len(prompts)}] rate={rate:.1f}/s elapsed={elapsed:.0f}s")
326
+
327
+ print(f"Self-distill complete: {len(results)} responses in {time.perf_counter() - t0:.0f}s")
328
+ return results
329
+
330
+
331
+ # =====================================================================
332
+ # LOAD PROMPTS FROM HF DATASET
333
+ # =====================================================================
334
+
335
+ def load_prompts_from_hf(repo_id: str, max_prompts: int) -> list[str]:
336
+ """Download prompts from HF dataset repo."""
337
+ from huggingface_hub import hf_hub_download, list_repo_files
338
+
339
+ print(f"\n=== Loading prompts from {repo_id} ===")
340
+ prompts = []
341
+
342
+ try:
343
+ files = list_repo_files(repo_id, repo_type="dataset")
344
+ jsonl_files = sorted([f for f in files if f.endswith(".jsonl")])
345
+ print(f"Found {len(jsonl_files)} JSONL files: {jsonl_files}")
346
+
347
+ for fname in jsonl_files:
348
+ local = hf_hub_download(repo_id, fname, repo_type="dataset")
349
+ with open(local, "r") as f:
350
+ for line in f:
351
+ line = line.strip()
352
+ if not line:
353
+ continue
354
+ try:
355
+ obj = json.loads(line)
356
+ text = obj.get("prompt") or obj.get("text") or obj.get("instruction", "")
357
+ if text and len(text) > 10:
358
+ prompts.append(text)
359
+ except json.JSONDecodeError:
360
+ pass
361
+ if len(prompts) >= max_prompts:
362
+ break
363
+ except Exception as e:
364
+ print(f"Error loading from HF: {e}")
365
+ print("Falling back to built-in sample prompts")
366
+ prompts = _fallback_prompts()
367
+
368
+ prompts = prompts[:max_prompts]
369
+ print(f"Loaded {len(prompts)} prompts")
370
+ return prompts
371
+
372
+
373
+ def _fallback_prompts() -> list[str]:
374
+ """Minimal set of diverse prompts for PoC if dataset not available."""
375
+ return [
376
+ "Explain the difference between TCP and UDP protocols.",
377
+ "Write a Python function that implements binary search on a sorted list.",
378
+ "What are the main causes of climate change and what can individuals do?",
379
+ "Erkläre den Unterschied zwischen funktionaler und objektorientierter Programmierung.",
380
+ "Solve the equation: 3x^2 + 5x - 2 = 0",
381
+ "Write a short story about a robot discovering emotions.",
382
+ "Describe the process of photosynthesis in detail.",
383
+ "Implement a simple linked list in Python with insert, delete, and search operations.",
384
+ "What are the key principles of quantum computing?",
385
+ "Explain how neural networks learn through backpropagation.",
386
+ ] * 10 # 100 prompts for minimal PoC
387
+
388
+
389
+ # =====================================================================
390
+ # TRAINING LOOP
391
+ # =====================================================================
392
+
393
+ def train_mtp(model, mtp_head, distill_data: list[dict], tokenizer, device: str):
394
+ """Train MTP head on self-distilled data."""
395
+ print(f"\n=== MTP Training: {len(distill_data)} samples, {TRAIN_EPOCHS} epochs ===")
396
+
397
+ # Freeze base model
398
+ for p in model.parameters():
399
+ p.requires_grad_(False)
400
+ model.eval()
401
+
402
+ base_dtype = next(model.parameters()).dtype
403
+ mtp_head = mtp_head.to(device=device, dtype=base_dtype)
404
+
405
+ optimizer = torch.optim.AdamW(mtp_head.trainable_params(), lr=LR, weight_decay=0.01)
406
+
407
+ # Tokenize all data
408
+ print("Tokenizing training data...")
409
+ all_tokens = []
410
+ for rec in distill_data:
411
+ text = f"<start_of_turn>user\n{rec['prompt']}<end_of_turn>\n<start_of_turn>model\n{rec['response']}<end_of_turn>"
412
+ tokens = tokenizer(text, truncation=True, max_length=MAX_SEQ_LEN, return_tensors="pt")["input_ids"].squeeze(0)
413
+ if len(tokens) >= K + 4:
414
+ all_tokens.append(tokens)
415
+ print(f" {len(all_tokens)} valid samples after tokenization")
416
+
417
+ # Simple training loop
418
+ total_steps = (len(all_tokens) // BATCH_SIZE) * TRAIN_EPOCHS
419
+ warmup_steps = max(1, int(total_steps * 0.05))
420
+
421
+ scheduler = torch.optim.lr_scheduler.OneCycleLR(
422
+ optimizer, max_lr=LR, total_steps=max(total_steps, 1),
423
+ pct_start=0.05, anneal_strategy="cos",
424
+ )
425
+
426
+ global_step = 0
427
+ best_loss = float("inf")
428
+ t_start = time.perf_counter()
429
+ checkpoint_dir = Path("/tmp/mtp_checkpoints")
430
+ checkpoint_dir.mkdir(exist_ok=True)
431
+
432
+ for epoch in range(1, TRAIN_EPOCHS + 1):
433
+ print(f"\n--- Epoch {epoch}/{TRAIN_EPOCHS} ---")
434
+ # Shuffle
435
+ import random
436
+ random.shuffle(all_tokens)
437
+
438
+ epoch_loss = 0.0
439
+ epoch_steps = 0
440
+
441
+ for batch_start in range(0, len(all_tokens), BATCH_SIZE):
442
+ batch_toks = all_tokens[batch_start:batch_start + BATCH_SIZE]
443
+ if not batch_toks:
444
+ continue
445
+
446
+ # Pad batch
447
+ max_len = max(t.shape[0] for t in batch_toks)
448
+ padded = torch.zeros(len(batch_toks), max_len, dtype=torch.long)
449
+ for i, t in enumerate(batch_toks):
450
+ padded[i, :t.shape[0]] = t
451
+ input_ids = padded.to(device)
452
+
453
+ # Extract hidden states
454
+ hidden = extract_hidden_states(model, input_ids)
455
+
456
+ # Prepare targets
457
+ targets, valid_len = prepare_mtp_targets(input_ids, k=K)
458
+ targets = [t.to(device) for t in targets]
459
+
460
+ # Run MTP head
461
+ h = hidden[:, :valid_len, :]
462
+ shifted_ids = input_ids[:, 1:1 + valid_len]
463
+
464
+ draft_logits = []
465
+ for _ in range(K):
466
+ logits, h = mtp_head(h, shifted_ids)
467
+ draft_logits.append(logits)
468
+
469
+ # Loss + backprop
470
+ optimizer.zero_grad()
471
+ loss = mtp_loss(draft_logits, targets, k=K, beta=BETA)
472
+ loss.backward()
473
+ torch.nn.utils.clip_grad_norm_(mtp_head.trainable_params(), 1.0)
474
+ optimizer.step()
475
+ scheduler.step()
476
+
477
+ loss_val = loss.item()
478
+ epoch_loss += loss_val
479
+ epoch_steps += 1
480
+ global_step += 1
481
+
482
+ if global_step % LOG_EVERY == 0:
483
+ avg = epoch_loss / epoch_steps
484
+ lr_now = scheduler.get_last_lr()[0]
485
+ elapsed = time.perf_counter() - t_start
486
+ print(f" step={global_step} loss={loss_val:.4f} avg={avg:.4f} lr={lr_now:.2e} {global_step/elapsed:.1f}step/s")
487
+
488
+ avg_epoch_loss = epoch_loss / max(epoch_steps, 1)
489
+ print(f" Epoch {epoch} done — avg_loss={avg_epoch_loss:.4f}")
490
+
491
+ # Save checkpoint
492
+ ckpt = {
493
+ "mtp_head_state_dict": mtp_head.state_dict(),
494
+ "epoch": epoch,
495
+ "step": global_step,
496
+ "loss": avg_epoch_loss,
497
+ "k": K,
498
+ "beta": BETA,
499
+ "model_id": MODEL_ID,
500
+ "mtp_config": MTP_CONFIG_NAME,
501
+ }
502
+ torch.save(ckpt, checkpoint_dir / f"checkpoint_epoch{epoch}.pt")
503
+
504
+ if avg_epoch_loss < best_loss:
505
+ best_loss = avg_epoch_loss
506
+ torch.save(ckpt, checkpoint_dir / "best.pt")
507
+
508
+ torch.save(ckpt, checkpoint_dir / "final.pt")
509
+
510
+ # Save config
511
+ with open(checkpoint_dir / "train_config.json", "w") as f:
512
+ json.dump({
513
+ "model_id": MODEL_ID,
514
+ "mtp_config": MTP_CONFIG_NAME,
515
+ "k": K, "beta": BETA,
516
+ "lr": LR, "epochs": TRAIN_EPOCHS,
517
+ "batch_size": BATCH_SIZE,
518
+ "max_seq_len": MAX_SEQ_LEN,
519
+ "num_samples": len(all_tokens),
520
+ "best_loss": best_loss,
521
+ }, f, indent=2)
522
+
523
+ elapsed = time.perf_counter() - t_start
524
+ print(f"\nTraining complete. best_loss={best_loss:.4f} time={elapsed/60:.1f}min")
525
+ return checkpoint_dir
526
+
527
+
528
+ # =====================================================================
529
+ # UPLOAD RESULTS
530
+ # =====================================================================
531
+
532
+ def upload_results(checkpoint_dir: Path, distill_data: list[dict]):
533
+ """Upload checkpoints + distill data to HF."""
534
+ from huggingface_hub import HfApi
535
+
536
+ print(f"\n=== Uploading to {HF_UPLOAD_REPO} ===")
537
+ api = HfApi()
538
+
539
+ # Save distill data
540
+ distill_path = checkpoint_dir / "distill_data.jsonl"
541
+ with open(distill_path, "w") as f:
542
+ for rec in distill_data:
543
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
544
+
545
+ # Upload entire checkpoint dir
546
+ api.upload_folder(
547
+ folder_path=str(checkpoint_dir),
548
+ repo_id=HF_UPLOAD_REPO,
549
+ path_in_repo=f"mtp_head_{MTP_CONFIG_NAME}_k{K}",
550
+ repo_type="model",
551
+ )
552
+ print(f"Uploaded to {HF_UPLOAD_REPO}/mtp_head_{MTP_CONFIG_NAME}_k{K}")
553
+
554
+
555
+ # =====================================================================
556
+ # MAIN
557
+ # =====================================================================
558
+
559
+ def main():
560
+ t_total = time.perf_counter()
561
+ device = "cuda" if torch.cuda.is_available() else "cpu"
562
+ print(f"\nDevice: {device}")
563
+ if device == "cuda":
564
+ print(f"GPU: {torch.cuda.get_device_name(0)}")
565
+ print(f"VRAM: {torch.cuda.get_device_properties(0).total_mem / 1e9:.1f} GB")
566
+
567
+ # Step 1: Load model
568
+ print(f"\n=== Loading model: {MODEL_ID} ===")
569
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
570
+ if tokenizer.pad_token is None:
571
+ tokenizer.pad_token = tokenizer.eos_token
572
+
573
+ model = AutoModelForCausalLM.from_pretrained(
574
+ MODEL_ID,
575
+ torch_dtype=torch.bfloat16,
576
+ device_map=device,
577
+ )
578
+ model.eval()
579
+ print(f"Model loaded: {sum(p.numel() for p in model.parameters()) / 1e9:.2f}B params")
580
+
581
+ # Step 2: Load prompts
582
+ prompts = load_prompts_from_hf(PROMPTS_REPO, MAX_PROMPTS)
583
+
584
+ # Step 3: Self-distill
585
+ distill_data = generate_self_distill(
586
+ model, tokenizer, prompts,
587
+ batch_size=GEN_BATCH, max_tokens=GEN_MAX_TOKENS,
588
+ )
589
+
590
+ if not distill_data:
591
+ print("ERROR: No distill data generated!")
592
+ sys.exit(1)
593
+
594
+ # Step 4: Build MTP head
595
+ config = GEMMA4_CONFIGS[MTP_CONFIG_NAME]
596
+ mtp_head = MTPHead.from_base_model(model, config)
597
+ print(f"MTP head: {mtp_head.n_trainable() / 1e6:.1f}M trainable params")
598
+
599
+ # Step 5: Train
600
+ checkpoint_dir = train_mtp(model, mtp_head, distill_data, tokenizer, device)
601
+
602
+ # Step 6: Upload
603
+ upload_results(checkpoint_dir, distill_data)
604
+
605
+ elapsed = time.perf_counter() - t_total
606
+ print(f"\n=== JOB COMPLETE in {elapsed/60:.1f}min ===")
607
+
608
+
609
+ if __name__ == "__main__":
610
+ main()