ayh015 commited on
Commit
d0900e2
·
verified ·
1 Parent(s): f150ebf

Upload folder using huggingface_hub

Browse files
tools/backup/train_poe_distill_lora_linear/cos_beta.py ADDED
@@ -0,0 +1,783 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """LoRA training with an online product-of-experts distillation target.
4
+
5
+ This script trains on fixed pi_ref rollouts, but computes full-vocabulary
6
+ teacher/ref distributions online:
7
+
8
+ pi_star(. | s) proportional to pi_T(. | s)^beta * pi_ref(. | s)^(1-beta)
9
+ beta = alpha / (alpha + 1)
10
+
11
+ The trainable model is pi_ref plus LoRA adapters. The frozen pi_ref
12
+ distribution is obtained by disabling the adapter on the same model, avoiding a
13
+ second copy of the 4B reference model.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import contextlib
20
+ import os
21
+ from dataclasses import dataclass
22
+ from typing import Any
23
+
24
+ import torch
25
+ import torch.nn.functional as F
26
+ from datasets import load_dataset
27
+ from peft import LoraConfig, TaskType, get_peft_model
28
+ from torch.nn.utils.rnn import pad_sequence
29
+ from transformers import (
30
+ AutoModelForCausalLM,
31
+ AutoTokenizer,
32
+ TrainerCallback,
33
+ TrainerControl,
34
+ TrainerState,
35
+ Trainer,
36
+ TrainingArguments,
37
+ set_seed,
38
+ )
39
+
40
+
41
+ def parse_args() -> argparse.Namespace:
42
+ parser = argparse.ArgumentParser(description="Product-of-experts LoRA distillation on fixed rollouts.")
43
+
44
+ parser.add_argument("--student-model", default=os.environ.get("SFT_CHECKPOINT"), required=False)
45
+ parser.add_argument("--teacher-model", default=os.environ.get("TEACHER_MODEL", "Qwen/Qwen3-8B"))
46
+ parser.add_argument("--train-data", default="data/rollouts/dapo-math-17k-qwen3-4b-sft-rollouts.parquet")
47
+ parser.add_argument("--output-dir", default="checkpoints/qwen3-4b-poe-distill-lora")
48
+
49
+ parser.add_argument("--alpha", type=float, default=1.0)
50
+ parser.add_argument(
51
+ "--beta-start",
52
+ type=float,
53
+ default=None,
54
+ help="Initial beta. If unset, uses alpha / (alpha + 1) as a fixed beta.",
55
+ )
56
+ parser.add_argument(
57
+ "--beta-end",
58
+ type=float,
59
+ default=None,
60
+ help="Final beta. If unset, uses alpha / (alpha + 1) as a fixed beta.",
61
+ )
62
+ parser.add_argument(
63
+ "--beta-schedule-steps",
64
+ type=int,
65
+ default=None,
66
+ help="Number of optimizer steps used to ramp beta from beta-start to beta-end.",
67
+ )
68
+ parser.add_argument("--beta-schedule", choices=["linear", "cosine"], default="linear")
69
+ parser.add_argument(
70
+ "--beta-hold-steps",
71
+ type=int,
72
+ default=0,
73
+ help=(
74
+ "Keep beta fixed at beta-start for this many optimizer steps before "
75
+ "transitioning to beta-end. This enables schedules such as: hold "
76
+ "beta=1.0 for 100 steps, then decay to 0.5 over 10 steps."
77
+ ),
78
+ )
79
+ parser.add_argument(
80
+ "--beta-transition-steps",
81
+ type=int,
82
+ default=None,
83
+ help=(
84
+ "Number of optimizer steps used to transition beta from beta-start to "
85
+ "beta-end after beta-hold-steps. If unset, falls back to "
86
+ "beta-schedule-steps / max_steps for backward compatibility."
87
+ ),
88
+ )
89
+ parser.add_argument(
90
+ "--hold-transition-schedule",
91
+ choices=["linear", "cosine"],
92
+ default="linear",
93
+ help="Schedule shape used by the hold-then-transition beta/LR schedules.",
94
+ )
95
+ parser.add_argument(
96
+ "--lr-start",
97
+ type=float,
98
+ default=None,
99
+ help="Initial LR for custom hold-then-transition scheduling. If unset, uses --learning-rate.",
100
+ )
101
+ parser.add_argument(
102
+ "--lr-end",
103
+ type=float,
104
+ default=None,
105
+ help="Final LR after the custom transition. If unset, custom LR scheduling is disabled.",
106
+ )
107
+ parser.add_argument(
108
+ "--lr-hold-steps",
109
+ type=int,
110
+ default=None,
111
+ help="Keep LR fixed at lr-start for this many optimizer steps. If unset, uses beta-hold-steps.",
112
+ )
113
+ parser.add_argument(
114
+ "--lr-transition-steps",
115
+ type=int,
116
+ default=None,
117
+ help=(
118
+ "Number of optimizer steps used to transition LR from lr-start to lr-end. "
119
+ "If unset, uses beta-transition-steps."
120
+ ),
121
+ )
122
+ parser.add_argument(
123
+ "--loss-type",
124
+ choices=["full_vocab", "sampled_token"],
125
+ default="full_vocab",
126
+ help=(
127
+ "full_vocab matches the normalized PoE distribution over the whole vocab. "
128
+ "sampled_token uses an OPD-style sampled-token surrogate with a PoE advantage."
129
+ ),
130
+ )
131
+ parser.add_argument(
132
+ "--advantage-normalization",
133
+ choices=["none", "batch", "sequence"],
134
+ default="batch",
135
+ help="Only used by --loss-type sampled_token.",
136
+ )
137
+ parser.add_argument(
138
+ "--advantage-clip",
139
+ type=float,
140
+ default=None,
141
+ help="Symmetric clamp for sampled-token advantages. Example: 5.0.",
142
+ )
143
+ parser.add_argument(
144
+ "--use-ppo-clip",
145
+ action="store_true",
146
+ default=False,
147
+ help=(
148
+ "Only used by --loss-type sampled_token. Use PPO-style ratio clipping "
149
+ "with the frozen reference log-prob as the old rollout log-prob."
150
+ ),
151
+ )
152
+ parser.add_argument(
153
+ "--ppo-clip-low",
154
+ type=float,
155
+ default=0.2,
156
+ help="Only used when --use-ppo-clip is set. Lower PPO clip epsilon.",
157
+ )
158
+ parser.add_argument(
159
+ "--ppo-clip-high",
160
+ type=float,
161
+ default=0.2,
162
+ help="Only used when --use-ppo-clip is set. Upper PPO clip epsilon.",
163
+ )
164
+ parser.add_argument(
165
+ "--sampled-loss-reduction",
166
+ choices=["per_sample", "per_token"],
167
+ default="per_sample",
168
+ help=(
169
+ "Only used by --loss-type sampled_token. per_sample averages each response "
170
+ "first, then averages across batch; per_token averages over all response tokens."
171
+ ),
172
+ )
173
+ parser.add_argument(
174
+ "--positive-advantages-only",
175
+ action="store_true",
176
+ default=False,
177
+ help="Only reinforce sampled tokens with positive PoE advantages.",
178
+ )
179
+ parser.add_argument("--max-length", type=int, default=4096)
180
+ parser.add_argument("--distill-chunk-size", type=int, default=128)
181
+ parser.add_argument("--max-train-samples", type=int, default=None)
182
+ parser.add_argument("--seed", type=int, default=42)
183
+
184
+ parser.add_argument("--num-train-epochs", type=float, default=1.0)
185
+ parser.add_argument("--max-steps", type=int, default=-1)
186
+ parser.add_argument("--per-device-train-batch-size", type=int, default=1)
187
+ parser.add_argument("--gradient-accumulation-steps", type=int, default=16)
188
+ parser.add_argument("--learning-rate", type=float, default=2e-5)
189
+ parser.add_argument("--weight-decay", type=float, default=0.0)
190
+ parser.add_argument("--adam-beta1", type=float, default=0.9)
191
+ parser.add_argument("--adam-beta2", type=float, default=0.999)
192
+ parser.add_argument("--adam-epsilon", type=float, default=1e-8)
193
+ parser.add_argument("--warmup-ratio", type=float, default=0.03)
194
+ parser.add_argument("--lr-scheduler-type", default="cosine")
195
+ parser.add_argument("--logging-steps", type=int, default=1)
196
+ parser.add_argument("--save-steps", type=int, default=100)
197
+ parser.add_argument("--save-total-limit", type=int, default=0)
198
+ parser.add_argument("--bf16", action=argparse.BooleanOptionalAction, default=True)
199
+ parser.add_argument("--fp16", action="store_true", default=False)
200
+ parser.add_argument("--gradient-checkpointing", action=argparse.BooleanOptionalAction, default=True)
201
+ parser.add_argument("--report-to", default="none")
202
+
203
+ parser.add_argument("--lora-r", type=int, default=64)
204
+ parser.add_argument("--lora-alpha", type=int, default=128)
205
+ parser.add_argument("--lora-dropout", type=float, default=0.05)
206
+ parser.add_argument(
207
+ "--freeze-lora-b-after-step",
208
+ type=int,
209
+ default=None,
210
+ help="Freeze all LoRA B matrices once global_step reaches this value. Example: 20.",
211
+ )
212
+ parser.add_argument(
213
+ "--lora-target-modules",
214
+ default="q_proj,k_proj,v_proj,o_proj,gate_proj,up_proj,down_proj",
215
+ help="Comma-separated LoRA target modules.",
216
+ )
217
+
218
+ parser.add_argument("--trust-remote-code", action="store_true", default=True)
219
+ parser.add_argument(
220
+ "--attn-implementation",
221
+ default=None,
222
+ choices=[None, "eager", "sdpa", "flash_attention_2"],
223
+ help="Forwarded to from_pretrained when set.",
224
+ )
225
+
226
+ args = parser.parse_args()
227
+ if not args.student_model:
228
+ raise ValueError("Pass --student-model or set SFT_CHECKPOINT to the Qwen3-4B SFT checkpoint.")
229
+ if args.alpha <= 0:
230
+ raise ValueError("--alpha must be positive.")
231
+ fixed_beta = args.alpha / (args.alpha + 1.0)
232
+ if args.beta_start is None:
233
+ args.beta_start = fixed_beta
234
+ if args.beta_end is None:
235
+ args.beta_end = fixed_beta
236
+ if not 0.0 <= args.beta_start <= 1.0:
237
+ raise ValueError("--beta-start must be in [0, 1].")
238
+ if not 0.0 <= args.beta_end <= 1.0:
239
+ raise ValueError("--beta-end must be in [0, 1].")
240
+ if args.beta_schedule_steps is not None and args.beta_schedule_steps <= 0:
241
+ raise ValueError("--beta-schedule-steps must be positive when set.")
242
+ if args.beta_hold_steps < 0:
243
+ raise ValueError("--beta-hold-steps must be non-negative.")
244
+ if args.beta_transition_steps is not None and args.beta_transition_steps <= 0:
245
+ raise ValueError("--beta-transition-steps must be positive when set.")
246
+ if args.lr_start is None:
247
+ args.lr_start = args.learning_rate
248
+ if args.lr_hold_steps is None:
249
+ args.lr_hold_steps = args.beta_hold_steps
250
+ if args.lr_transition_steps is None:
251
+ args.lr_transition_steps = args.beta_transition_steps
252
+ if args.lr_hold_steps is not None and args.lr_hold_steps < 0:
253
+ raise ValueError("--lr-hold-steps must be non-negative when set.")
254
+ if args.lr_end is not None:
255
+ if args.lr_start <= 0.0 or args.lr_end <= 0.0:
256
+ raise ValueError("--lr-start and --lr-end must be positive when using custom LR scheduling.")
257
+ if args.lr_transition_steps is None or args.lr_transition_steps <= 0:
258
+ raise ValueError("--lr-transition-steps must be positive when using --lr-end.")
259
+ if args.advantage_clip is not None and args.advantage_clip <= 0:
260
+ raise ValueError("--advantage-clip must be positive when set.")
261
+ if args.ppo_clip_low < 0 or args.ppo_clip_high < 0:
262
+ raise ValueError("--ppo-clip-low and --ppo-clip-high must be non-negative.")
263
+ if args.freeze_lora_b_after_step is not None and args.freeze_lora_b_after_step < 0:
264
+ raise ValueError("--freeze-lora-b-after-step must be non-negative when set.")
265
+ if args.fp16 and args.bf16:
266
+ args.bf16 = False
267
+ return args
268
+
269
+
270
+ def first_assistant_index(messages: list[dict[str, str]]) -> int:
271
+ for idx, message in enumerate(messages):
272
+ if message.get("role") == "assistant":
273
+ return idx
274
+ raise ValueError("Rollout row has no assistant message.")
275
+
276
+
277
+ def tokenize_rollout(example: dict[str, Any], tokenizer: AutoTokenizer, max_length: int) -> dict[str, Any]:
278
+ messages = example["messages"]
279
+ assistant_idx = first_assistant_index(messages)
280
+ prompt_messages = messages[:assistant_idx]
281
+ full_messages = messages[: assistant_idx + 1]
282
+
283
+ prompt_text = tokenizer.apply_chat_template(
284
+ prompt_messages,
285
+ tokenize=False,
286
+ add_generation_prompt=True,
287
+ enable_thinking=True,
288
+ )
289
+ full_text = tokenizer.apply_chat_template(
290
+ full_messages,
291
+ tokenize=False,
292
+ add_generation_prompt=False,
293
+ enable_thinking=True,
294
+ )
295
+
296
+ prompt_ids = tokenizer.encode(prompt_text, add_special_tokens=False)
297
+ input_ids = tokenizer.encode(full_text, add_special_tokens=False)
298
+
299
+ if len(input_ids) > max_length:
300
+ input_ids = input_ids[:max_length]
301
+
302
+ # Mask is aligned to labels=input_ids[1:]. A label predicts token position
303
+ # j=i+1, so it belongs to the response when j >= len(prompt_ids).
304
+ label_len = max(len(input_ids) - 1, 0)
305
+ loss_mask = [1 if i + 1 >= len(prompt_ids) else 0 for i in range(label_len)]
306
+
307
+ if sum(loss_mask) == 0:
308
+ # Drop examples where truncation removed the assistant response.
309
+ return {"input_ids": [], "loss_mask": []}
310
+
311
+ return {"input_ids": input_ids, "loss_mask": loss_mask}
312
+
313
+
314
+ @dataclass
315
+ class DistillCollator:
316
+ pad_token_id: int
317
+
318
+ def __call__(self, features: list[dict[str, Any]]) -> dict[str, torch.Tensor]:
319
+ input_ids = [torch.tensor(f["input_ids"], dtype=torch.long) for f in features]
320
+ loss_masks = [torch.tensor(f["loss_mask"], dtype=torch.float32) for f in features]
321
+ lengths = torch.tensor([x.size(0) for x in input_ids], dtype=torch.long)
322
+
323
+ padded_input_ids = pad_sequence(input_ids, batch_first=True, padding_value=self.pad_token_id)
324
+ # loss_mask is one shorter than input_ids because it aligns to shifted labels.
325
+ padded_loss_masks = pad_sequence(loss_masks, batch_first=True, padding_value=0.0)
326
+ positions = torch.arange(padded_input_ids.size(1)).unsqueeze(0)
327
+ attention_mask = (positions < lengths.unsqueeze(1)).long()
328
+ return {
329
+ "input_ids": padded_input_ids,
330
+ "attention_mask": attention_mask,
331
+ "loss_mask": padded_loss_masks,
332
+ }
333
+
334
+
335
+ def hold_then_transition_value(
336
+ *,
337
+ step: int,
338
+ start: float,
339
+ end: float,
340
+ hold_steps: int,
341
+ transition_steps: int | None,
342
+ schedule: str,
343
+ ) -> float:
344
+ """Return start during hold, then interpolate start -> end.
345
+
346
+ Step is an optimizer global_step, not a micro-batch step. With gradient
347
+ accumulation, global_step advances only after one optimizer update.
348
+ """
349
+ if step < hold_steps:
350
+ return start
351
+ if transition_steps is None or transition_steps <= 0:
352
+ return end
353
+
354
+ local_step = step - hold_steps
355
+ progress = min(max(local_step / transition_steps, 0.0), 1.0)
356
+ if schedule == "cosine":
357
+ progress = 0.5 - 0.5 * torch.cos(torch.tensor(progress * torch.pi)).item()
358
+ elif schedule != "linear":
359
+ raise ValueError(f"Unknown schedule: {schedule}")
360
+ return start + (end - start) * progress
361
+
362
+
363
+ class PoEDistillTrainer(Trainer):
364
+ def __init__(
365
+ self,
366
+ *args: Any,
367
+ teacher_model: torch.nn.Module,
368
+ beta_start: float,
369
+ beta_end: float,
370
+ beta_schedule_steps: int | None,
371
+ beta_schedule: str,
372
+ beta_hold_steps: int,
373
+ beta_transition_steps: int | None,
374
+ hold_transition_schedule: str,
375
+ loss_type: str,
376
+ advantage_normalization: str,
377
+ advantage_clip: float | None,
378
+ positive_advantages_only: bool,
379
+ use_ppo_clip: bool,
380
+ ppo_clip_low: float,
381
+ ppo_clip_high: float,
382
+ sampled_loss_reduction: str,
383
+ distill_chunk_size: int,
384
+ **kwargs: Any,
385
+ ) -> None:
386
+ super().__init__(*args, **kwargs)
387
+ self.teacher_model = teacher_model
388
+ self.teacher_model.to(self.args.device)
389
+ self.teacher_model.eval()
390
+ self.beta_start = beta_start
391
+ self.beta_end = beta_end
392
+ self.beta_schedule_steps = beta_schedule_steps
393
+ self.beta_schedule = beta_schedule
394
+ self.beta_hold_steps = beta_hold_steps
395
+ self.beta_transition_steps = beta_transition_steps
396
+ self.hold_transition_schedule = hold_transition_schedule
397
+ self.loss_type = loss_type
398
+ self.advantage_normalization = advantage_normalization
399
+ self.advantage_clip = advantage_clip
400
+ self.positive_advantages_only = positive_advantages_only
401
+ self.use_ppo_clip = use_ppo_clip
402
+ self.ppo_clip_low = ppo_clip_low
403
+ self.ppo_clip_high = ppo_clip_high
404
+ self.sampled_loss_reduction = sampled_loss_reduction
405
+ self.distill_chunk_size = distill_chunk_size
406
+
407
+ def current_beta(self) -> float:
408
+ # New mode: hold beta_start for beta_hold_steps, then transition to beta_end.
409
+ if self.beta_hold_steps > 0 or self.beta_transition_steps is not None:
410
+ return hold_then_transition_value(
411
+ step=self.state.global_step,
412
+ start=self.beta_start,
413
+ end=self.beta_end,
414
+ hold_steps=self.beta_hold_steps,
415
+ transition_steps=self.beta_transition_steps,
416
+ schedule=self.hold_transition_schedule,
417
+ )
418
+
419
+ # Backward-compatible old behavior: directly schedule beta_start -> beta_end.
420
+ schedule_steps = self.beta_schedule_steps
421
+ if schedule_steps is None:
422
+ schedule_steps = self.state.max_steps if self.state.max_steps > 0 else None
423
+ if schedule_steps is None or schedule_steps == 0:
424
+ return self.beta_end
425
+
426
+ progress = min(max(self.state.global_step / schedule_steps, 0.0), 1.0)
427
+ if self.beta_schedule == "cosine":
428
+ progress = 0.5 - 0.5 * torch.cos(torch.tensor(progress * torch.pi)).item()
429
+ return self.beta_start + (self.beta_end - self.beta_start) * progress
430
+
431
+ @staticmethod
432
+ def gather_token_logprobs(logits: torch.Tensor, labels: torch.Tensor) -> torch.Tensor:
433
+ logits = logits.float()
434
+ token_logits = logits.gather(dim=-1, index=labels.unsqueeze(-1)).squeeze(-1)
435
+ return token_logits - logits.logsumexp(dim=-1)
436
+
437
+ def normalize_advantages(self, advantages: torch.Tensor, loss_mask: torch.Tensor) -> torch.Tensor:
438
+ if self.advantage_normalization == "none":
439
+ return advantages
440
+
441
+ if self.advantage_normalization == "batch":
442
+ denom = loss_mask.sum().clamp_min(1.0)
443
+ mean = (advantages * loss_mask).sum() / denom
444
+ var = (((advantages - mean) * loss_mask) ** 2).sum() / denom
445
+ return (advantages - mean) / torch.sqrt(var + 1e-6)
446
+
447
+ denom = loss_mask.sum(dim=1, keepdim=True).clamp_min(1.0)
448
+ mean = (advantages * loss_mask).sum(dim=1, keepdim=True) / denom
449
+ var = (((advantages - mean) * loss_mask) ** 2).sum(dim=1, keepdim=True) / denom
450
+ return (advantages - mean) / torch.sqrt(var + 1e-6)
451
+
452
+ def compute_loss(
453
+ self,
454
+ model: torch.nn.Module,
455
+ inputs: dict[str, torch.Tensor],
456
+ return_outputs: bool = False,
457
+ **_: Any,
458
+ ):
459
+ loss_mask = inputs.pop("loss_mask")
460
+ input_ids = inputs["input_ids"]
461
+ attention_mask = inputs["attention_mask"]
462
+ labels = input_ids[:, 1:]
463
+
464
+ with torch.no_grad():
465
+ teacher_logits = self.teacher_model(
466
+ input_ids=input_ids,
467
+ attention_mask=attention_mask,
468
+ use_cache=False,
469
+ ).logits[:, :-1, :].detach()
470
+
471
+ adapter_owner = model.module if hasattr(model, "module") else model
472
+ disable_adapter = getattr(adapter_owner, "disable_adapter", None)
473
+ ref_context = disable_adapter() if disable_adapter is not None else contextlib.nullcontext()
474
+ with ref_context:
475
+ ref_logits = adapter_owner(
476
+ input_ids=input_ids,
477
+ attention_mask=attention_mask,
478
+ use_cache=False,
479
+ ).logits[:, :-1, :].detach()
480
+
481
+ student_outputs = model(input_ids=input_ids, attention_mask=attention_mask, use_cache=False)
482
+ student_logits = student_outputs.logits[:, :-1, :]
483
+ if teacher_logits.size(-1) != student_logits.size(-1) or ref_logits.size(-1) != student_logits.size(-1):
484
+ raise ValueError(
485
+ "Teacher, reference, and student vocab sizes must match for full-vocab PoE distillation. "
486
+ f"Got teacher={teacher_logits.size(-1)}, ref={ref_logits.size(-1)}, "
487
+ f"student={student_logits.size(-1)}."
488
+ )
489
+
490
+ total_loss = student_logits.new_zeros(())
491
+ total_tokens = loss_mask.sum().clamp_min(1.0)
492
+ beta = self.current_beta()
493
+
494
+ if self.loss_type == "sampled_token":
495
+ teacher_logp = self.gather_token_logprobs(teacher_logits, labels)
496
+ ref_logp = self.gather_token_logprobs(ref_logits, labels)
497
+ student_logp = self.gather_token_logprobs(student_logits, labels)
498
+
499
+ poe_score = beta * teacher_logp + (1.0 - beta) * ref_logp
500
+ advantages = poe_score - student_logp.detach()
501
+ advantages = self.normalize_advantages(advantages, loss_mask)
502
+ if self.advantage_clip is not None:
503
+ advantages = advantages.clamp(min=-self.advantage_clip, max=self.advantage_clip)
504
+ if self.positive_advantages_only:
505
+ advantages = advantages.clamp_min(0.0)
506
+
507
+ advantages = advantages.detach()
508
+
509
+ if self.use_ppo_clip:
510
+ # The rollouts are generated by the frozen reference/SFT policy, so ref_logp
511
+ # is used as the old rollout log-prob. This mirrors the PPO-style clipped
512
+ # policy loss used in RL frameworks such as slime.
513
+ ratio = torch.exp(student_logp - ref_logp.detach())
514
+ ratio_clipped = ratio.clamp(1.0 - self.ppo_clip_low, 1.0 + self.ppo_clip_high)
515
+ pg_loss_unclipped = -ratio * advantages
516
+ pg_loss_clipped = -ratio_clipped * advantages
517
+ token_loss = torch.maximum(pg_loss_unclipped, pg_loss_clipped)
518
+ else:
519
+ # Direct OPD-style sampled-token surrogate.
520
+ token_loss = -advantages * student_logp
521
+
522
+ if self.sampled_loss_reduction == "per_token":
523
+ loss = (token_loss * loss_mask).sum() / total_tokens
524
+ else:
525
+ # Per-sample mean: each response contributes equally regardless of length.
526
+ seq_loss = (token_loss * loss_mask).sum(dim=1) / loss_mask.sum(dim=1).clamp_min(1.0)
527
+ loss = seq_loss.mean()
528
+
529
+ return (loss, student_outputs) if return_outputs else loss
530
+
531
+ seq_len = student_logits.size(1)
532
+ for start in range(0, seq_len, self.distill_chunk_size):
533
+ end = min(start + self.distill_chunk_size, seq_len)
534
+ mask = loss_mask[:, start:end]
535
+ if mask.sum() == 0:
536
+ continue
537
+
538
+ teacher_logp = F.log_softmax(teacher_logits[:, start:end, :].float(), dim=-1)
539
+ ref_logp = F.log_softmax(ref_logits[:, start:end, :].float(), dim=-1)
540
+ student_logp = F.log_softmax(student_logits[:, start:end, :].float(), dim=-1)
541
+
542
+ poe_logits = beta * teacher_logp + (1.0 - beta) * ref_logp
543
+ target_probs = F.softmax(poe_logits, dim=-1)
544
+ token_ce = -(target_probs * student_logp).sum(dim=-1)
545
+ total_loss = total_loss + (token_ce * mask).sum()
546
+
547
+ loss = total_loss / total_tokens
548
+ return (loss, student_outputs) if return_outputs else loss
549
+
550
+
551
+ class FreezeLoRABCallback(TrainerCallback):
552
+ def __init__(self, freeze_after_step: int | None) -> None:
553
+ self.freeze_after_step = freeze_after_step
554
+ self.frozen = False
555
+
556
+ def on_step_begin(
557
+ self,
558
+ args: TrainingArguments,
559
+ state: TrainerState,
560
+ control: TrainerControl,
561
+ model: torch.nn.Module | None = None,
562
+ **kwargs: Any,
563
+ ) -> TrainerControl:
564
+ if self.freeze_after_step is None or self.frozen or model is None:
565
+ return control
566
+ if state.global_step < self.freeze_after_step:
567
+ return control
568
+
569
+ frozen_params = 0
570
+ module = model.module if hasattr(model, "module") else model
571
+ for name, param in module.named_parameters():
572
+ if ".lora_B." in name or "lora_B." in name:
573
+ param.requires_grad_(False)
574
+ frozen_params += param.numel()
575
+
576
+ self.frozen = True
577
+ if args.process_index == 0:
578
+ print(f"[PoE Distill] Froze LoRA B at global_step={state.global_step} ({frozen_params} params).")
579
+ return control
580
+
581
+
582
+ class HoldThenTransitionLRCallback(TrainerCallback):
583
+ """Custom LR schedule: hold lr_start, transition to lr_end, then keep lr_end.
584
+
585
+ Disable the Hugging Face scheduler interaction by setting
586
+ --lr-scheduler-type constant and --warmup-ratio 0.0 in the launcher when
587
+ using --lr-end. This callback sets optimizer param-group LRs directly at
588
+ each optimizer step.
589
+ """
590
+
591
+ def __init__(
592
+ self,
593
+ lr_start: float,
594
+ lr_end: float | None,
595
+ lr_hold_steps: int,
596
+ lr_transition_steps: int | None,
597
+ schedule: str,
598
+ ) -> None:
599
+ self.lr_start = lr_start
600
+ self.lr_end = lr_end
601
+ self.lr_hold_steps = lr_hold_steps
602
+ self.lr_transition_steps = lr_transition_steps
603
+ self.schedule = schedule
604
+
605
+ def current_lr(self, step: int) -> float:
606
+ if self.lr_end is None:
607
+ return self.lr_start
608
+ return hold_then_transition_value(
609
+ step=step,
610
+ start=self.lr_start,
611
+ end=self.lr_end,
612
+ hold_steps=self.lr_hold_steps,
613
+ transition_steps=self.lr_transition_steps,
614
+ schedule=self.schedule,
615
+ )
616
+
617
+ def on_train_begin(
618
+ self,
619
+ args: TrainingArguments,
620
+ state: TrainerState,
621
+ control: TrainerControl,
622
+ optimizer: torch.optim.Optimizer | None = None,
623
+ **kwargs: Any,
624
+ ) -> TrainerControl:
625
+ return self._set_lr(args, state, control, optimizer)
626
+
627
+ def on_step_begin(
628
+ self,
629
+ args: TrainingArguments,
630
+ state: TrainerState,
631
+ control: TrainerControl,
632
+ optimizer: torch.optim.Optimizer | None = None,
633
+ **kwargs: Any,
634
+ ) -> TrainerControl:
635
+ return self._set_lr(args, state, control, optimizer)
636
+
637
+ def _set_lr(
638
+ self,
639
+ args: TrainingArguments,
640
+ state: TrainerState,
641
+ control: TrainerControl,
642
+ optimizer: torch.optim.Optimizer | None,
643
+ ) -> TrainerControl:
644
+ if optimizer is None or self.lr_end is None:
645
+ return control
646
+
647
+ lr = self.current_lr(state.global_step)
648
+ for group in optimizer.param_groups:
649
+ group["lr"] = lr
650
+
651
+ if args.process_index == 0 and state.global_step % max(args.logging_steps, 1) == 0:
652
+ print(f"[PoE Distill] global_step={state.global_step}, custom_lr={lr:.3e}")
653
+ return control
654
+
655
+
656
+ class BetaLoggingCallback(TrainerCallback):
657
+ """Log beta occasionally without changing training behavior."""
658
+
659
+ def __init__(self, trainer_ref_getter) -> None:
660
+ self.trainer_ref_getter = trainer_ref_getter
661
+
662
+ def on_step_begin(
663
+ self,
664
+ args: TrainingArguments,
665
+ state: TrainerState,
666
+ control: TrainerControl,
667
+ **kwargs: Any,
668
+ ) -> TrainerControl:
669
+ trainer = self.trainer_ref_getter()
670
+ if trainer is not None and args.process_index == 0 and state.global_step % max(args.logging_steps, 1) == 0:
671
+ print(f"[PoE Distill] global_step={state.global_step}, beta={trainer.current_beta():.6f}")
672
+ return control
673
+
674
+
675
+ def main() -> None:
676
+ args = parse_args()
677
+ set_seed(args.seed)
678
+
679
+ tokenizer = AutoTokenizer.from_pretrained(args.student_model, trust_remote_code=args.trust_remote_code)
680
+ if tokenizer.pad_token_id is None:
681
+ tokenizer.pad_token = tokenizer.eos_token
682
+
683
+ raw_dataset = load_dataset("parquet", data_files=args.train_data, split="train")
684
+ if args.max_train_samples is not None:
685
+ raw_dataset = raw_dataset.select(range(min(args.max_train_samples, len(raw_dataset))))
686
+
687
+ train_dataset = raw_dataset.map(
688
+ lambda ex: tokenize_rollout(ex, tokenizer, args.max_length),
689
+ remove_columns=raw_dataset.column_names,
690
+ desc="Tokenizing pi_ref rollouts",
691
+ ).filter(lambda ex: len(ex["input_ids"]) > 0, desc="Dropping empty responses")
692
+
693
+ model_kwargs = {
694
+ "torch_dtype": torch.bfloat16 if args.bf16 else (torch.float16 if args.fp16 else torch.float32),
695
+ "trust_remote_code": args.trust_remote_code,
696
+ }
697
+ if args.attn_implementation is not None:
698
+ model_kwargs["attn_implementation"] = args.attn_implementation
699
+
700
+ student = AutoModelForCausalLM.from_pretrained(args.student_model, **model_kwargs)
701
+ teacher = AutoModelForCausalLM.from_pretrained(args.teacher_model, **model_kwargs)
702
+ teacher.eval()
703
+ teacher.requires_grad_(False)
704
+
705
+ if args.gradient_checkpointing:
706
+ student.gradient_checkpointing_enable()
707
+ student.config.use_cache = False
708
+ teacher.config.use_cache = False
709
+
710
+ lora_config = LoraConfig(
711
+ task_type=TaskType.CAUSAL_LM,
712
+ r=args.lora_r,
713
+ lora_alpha=args.lora_alpha,
714
+ lora_dropout=args.lora_dropout,
715
+ target_modules=[m.strip() for m in args.lora_target_modules.split(",") if m.strip()],
716
+ )
717
+ student = get_peft_model(student, lora_config)
718
+ student.print_trainable_parameters()
719
+
720
+ training_args = TrainingArguments(
721
+ output_dir=args.output_dir,
722
+ num_train_epochs=args.num_train_epochs,
723
+ max_steps=args.max_steps,
724
+ per_device_train_batch_size=args.per_device_train_batch_size,
725
+ gradient_accumulation_steps=args.gradient_accumulation_steps,
726
+ learning_rate=args.learning_rate,
727
+ weight_decay=args.weight_decay,
728
+ adam_beta1=args.adam_beta1,
729
+ adam_beta2=args.adam_beta2,
730
+ adam_epsilon=args.adam_epsilon,
731
+ warmup_ratio=args.warmup_ratio,
732
+ lr_scheduler_type=args.lr_scheduler_type,
733
+ logging_steps=args.logging_steps,
734
+ save_steps=args.save_steps,
735
+ save_total_limit=args.save_total_limit,
736
+ bf16=args.bf16,
737
+ fp16=args.fp16,
738
+ gradient_checkpointing=args.gradient_checkpointing,
739
+ remove_unused_columns=False,
740
+ report_to=[] if args.report_to == "none" else args.report_to.split(","),
741
+ )
742
+
743
+ trainer = PoEDistillTrainer(
744
+ model=student,
745
+ args=training_args,
746
+ train_dataset=train_dataset,
747
+ data_collator=DistillCollator(pad_token_id=tokenizer.pad_token_id),
748
+ tokenizer=tokenizer,
749
+ teacher_model=teacher,
750
+ beta_start=args.beta_start,
751
+ beta_end=args.beta_end,
752
+ beta_schedule_steps=args.beta_schedule_steps,
753
+ beta_schedule=args.beta_schedule,
754
+ beta_hold_steps=args.beta_hold_steps,
755
+ beta_transition_steps=args.beta_transition_steps,
756
+ hold_transition_schedule=args.hold_transition_schedule,
757
+ loss_type=args.loss_type,
758
+ advantage_normalization=args.advantage_normalization,
759
+ advantage_clip=args.advantage_clip,
760
+ positive_advantages_only=args.positive_advantages_only,
761
+ use_ppo_clip=args.use_ppo_clip,
762
+ ppo_clip_low=args.ppo_clip_low,
763
+ ppo_clip_high=args.ppo_clip_high,
764
+ sampled_loss_reduction=args.sampled_loss_reduction,
765
+ distill_chunk_size=args.distill_chunk_size,
766
+ callbacks=[
767
+ FreezeLoRABCallback(args.freeze_lora_b_after_step),
768
+ HoldThenTransitionLRCallback(
769
+ lr_start=args.lr_start,
770
+ lr_end=args.lr_end,
771
+ lr_hold_steps=args.lr_hold_steps,
772
+ lr_transition_steps=args.lr_transition_steps,
773
+ schedule=args.hold_transition_schedule,
774
+ ),
775
+ ],
776
+ )
777
+ trainer.train()
778
+ trainer.save_model(args.output_dir)
779
+ tokenizer.save_pretrained(args.output_dir)
780
+
781
+
782
+ if __name__ == "__main__":
783
+ main()
tools/convert_fsdp_to_hf.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ import argparse
5
+ import os
6
+ import pickle
7
+ import shutil
8
+ import time
9
+
10
+ import torch
11
+ import torch.distributed.checkpoint as dist_cp
12
+ from transformers import AutoConfig, AutoModelForCausalLM
13
+ from typing_extensions import override
14
+
15
+
16
+ class UnpicklerWrapper(pickle.Unpickler):
17
+ @override
18
+ def find_class(self, mod_name, name):
19
+ class DummyClass:
20
+ def __init__(self, *args, **kwargs):
21
+ pass
22
+
23
+ if mod_name.startswith("megatron") or mod_name.startswith("glm"):
24
+ return DummyClass
25
+ return super().find_class(mod_name, name)
26
+
27
+
28
+ class WrappedStorageReader(dist_cp.FileSystemReader):
29
+ @override
30
+ def read_metadata(self):
31
+ path = self.fs.concat_path(self.path, ".metadata")
32
+ with self.fs.create_stream(path, "rb") as metadata_file:
33
+ metadata = UnpicklerWrapper(metadata_file).load()
34
+ if getattr(metadata, "storage_meta", None) is None:
35
+ metadata.storage_meta = dist_cp.StorageMeta()
36
+ metadata.storage_meta.load_id = self.load_id
37
+ if metadata.planner_data is None:
38
+ metadata.planner_data = {}
39
+ return metadata
40
+
41
+
42
+ class EmptyStateDictLoadPlanner(dist_cp.default_planner.DefaultLoadPlanner):
43
+ @override
44
+ def set_up_planner(
45
+ self,
46
+ state_dict: dist_cp.metadata.STATE_DICT_TYPE,
47
+ metadata: dist_cp.metadata.Metadata | None = None,
48
+ is_coordinator: bool = False,
49
+ ) -> None:
50
+ for k, v in metadata.state_dict_metadata.items():
51
+ if "optimizer" in k:
52
+ continue
53
+ print(f"find {k} in torch_dist ckpt")
54
+ if isinstance(v, dist_cp.metadata.TensorStorageMetadata):
55
+ v = torch.empty(v.size, dtype=v.properties.dtype) # type: ignore[assignment]
56
+ state_dict[k] = v
57
+ super().set_up_planner(state_dict, metadata, is_coordinator)
58
+
59
+
60
+ def _detect_model_dir(input_dir: str) -> str:
61
+ model_dir = os.path.join(input_dir, "model")
62
+ return model_dir if os.path.isdir(model_dir) else input_dir
63
+
64
+
65
+ def _load_fsdp_state_dict(input_dir: str) -> dict[str, torch.Tensor]:
66
+ state_dict: dict[str, torch.Tensor] = {}
67
+ dist_cp.state_dict_loader._load_state_dict(
68
+ state_dict,
69
+ storage_reader=WrappedStorageReader(input_dir),
70
+ planner=EmptyStateDictLoadPlanner(),
71
+ no_dist=True,
72
+ )
73
+ return state_dict
74
+
75
+
76
+ def _get_candidate_prefixes(keys: list[str]) -> list[str]:
77
+ predefined = [
78
+ "model_state.model.",
79
+ "model_state.",
80
+ "model.",
81
+ "module.",
82
+ "",
83
+ ]
84
+
85
+ detected: set[str] = set()
86
+ for key in keys:
87
+ for prefix in predefined:
88
+ if prefix and key.startswith(prefix):
89
+ detected.add(prefix)
90
+
91
+ # Always keep empty string as a fall back option for exact match.
92
+ detected.add("")
93
+ # Preserve predefined order while keeping only detected prefixes.
94
+ return [p for p in predefined if p in detected]
95
+
96
+
97
+ def _strip_best_prefix(keys: list[str], target_keys: set[str]) -> tuple[str, int]:
98
+ best_prefix = ""
99
+ best_match = -1
100
+
101
+ for prefix in _get_candidate_prefixes(keys):
102
+ mapped_keys = {k.removeprefix(prefix) for k in keys}
103
+ match_count = len(mapped_keys & target_keys)
104
+ if match_count > best_match:
105
+ best_match = match_count
106
+ best_prefix = prefix
107
+
108
+ return best_prefix, best_match
109
+
110
+
111
+ def _convert_fsdp_to_hf(
112
+ origin_hf_dir: str,
113
+ input_dir: str,
114
+ output_dir: str,
115
+ ) -> None:
116
+ print(f"loading FSDP model from {input_dir}")
117
+ t = time.time()
118
+ state_dict = _load_fsdp_state_dict(input_dir)
119
+ print(f"FSDP model loaded in {time.time()-t:.2f} sec.")
120
+
121
+ tensor_items = {k: v for k, v in state_dict.items() if isinstance(v, torch.Tensor)}
122
+
123
+ config = AutoConfig.from_pretrained(origin_hf_dir, trust_remote_code=True)
124
+ hf_model = AutoModelForCausalLM.from_config(config)
125
+ target_keys = set(hf_model.state_dict().keys())
126
+
127
+ best_prefix, best_match = _strip_best_prefix(list(tensor_items.keys()), target_keys)
128
+ total_keys = len(tensor_items)
129
+
130
+ print(f"Using prefix '{best_prefix}' for key mapping. " f"Matched {best_match}/{total_keys} parameter keys.")
131
+
132
+ model_state = {k.removeprefix(best_prefix): v for k, v in tensor_items.items()}
133
+
134
+ if not model_state:
135
+ raise ValueError(
136
+ "No model weights found in checkpoint. "
137
+ "Please pass the checkpoint directory (e.g. iter_xxx or iter_xxx/model)."
138
+ )
139
+
140
+ missing, unexpected = hf_model.load_state_dict(model_state, strict=False)
141
+ print(f"Missing keys: {missing}\nUnexpected keys: {unexpected}")
142
+
143
+ os.makedirs(output_dir, exist_ok=True)
144
+ hf_model.save_pretrained(output_dir, safe_serialization=True)
145
+ print(f"Model weights saved to {output_dir}")
146
+
147
+
148
+ def copy_assets(origin_hf_dir: str, output_dir: str) -> None:
149
+ for filename in os.listdir(origin_hf_dir):
150
+ if filename == "model.safetensors.index.json" or filename.endswith(".safetensors"):
151
+ continue
152
+ origin_filename = os.path.join(origin_hf_dir, filename)
153
+ if not os.path.isfile(origin_filename):
154
+ print(f"Skip {filename}, not a file.")
155
+ continue
156
+ src, dst = origin_filename, os.path.join(output_dir, filename)
157
+ print(f"copy from {src} to {dst}")
158
+ shutil.copy(src, dst)
159
+
160
+
161
+ if __name__ == "__main__":
162
+ parser = argparse.ArgumentParser()
163
+ parser.add_argument("--input-dir", type=str, required=True)
164
+ parser.add_argument("--output-dir", type=str, required=True)
165
+ parser.add_argument(
166
+ "--origin-hf-dir",
167
+ type=str,
168
+ required=True,
169
+ help="The original Hugging Face model directory to load config/tokenizer assets.",
170
+ )
171
+ parser.add_argument(
172
+ "-f", "--force", action="store_true", help="Force overwrite the output directory if it exists."
173
+ )
174
+ args = parser.parse_args()
175
+
176
+ if os.path.exists(args.output_dir) and not args.force:
177
+ raise ValueError(f"Output directory {args.output_dir} already exists. Use --force to overwrite it.")
178
+
179
+ model_dir = _detect_model_dir(args.input_dir)
180
+ _convert_fsdp_to_hf(args.origin_hf_dir, model_dir, args.output_dir)
181
+ copy_assets(args.origin_hf_dir, args.output_dir)
tools/convert_hf_to_fp8.py ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """
5
+ python tools/convert_hf_to_fp8.py [-h] [--model-dir MODEL_DIR] [--save-dir SAVE_DIR] [--strategy {block,channel,tensor}] [--block-size [BLOCK_SIZE ...]]
6
+ [--max-workers MAX_WORKERS]
7
+
8
+ options:
9
+ -h, --help show this help message and exit
10
+ --model-dir MODEL_DIR
11
+ Path to the directory of the HF safetensors model.
12
+ --save-dir SAVE_DIR Path to the directory to save the converted model.
13
+ --strategy {block,channel,tensor}
14
+ --block-size [BLOCK_SIZE ...]
15
+ eg. --block-size 32 32
16
+ --max-workers MAX_WORKERS
17
+ Number of worker threads for parallel processing
18
+ """
19
+
20
+ import argparse
21
+ import gc
22
+ import json
23
+ import os
24
+ import shutil
25
+ import threading
26
+ from concurrent.futures import ThreadPoolExecutor
27
+
28
+ import safetensors
29
+ import safetensors.torch
30
+ import torch
31
+ import torch.nn.functional as F
32
+ from tqdm import tqdm
33
+
34
+ FP8_INFO = torch.finfo(torch.float8_e4m3fn)
35
+ FP8_MAX, FP8_MIN = FP8_INFO.max, FP8_INFO.min
36
+
37
+
38
+ def ceildiv(a, b):
39
+ return -(-a // b)
40
+
41
+
42
+ def block_fp8(weight, block_size):
43
+
44
+ # per block quant
45
+ block_n, block_k = block_size[0], block_size[1]
46
+
47
+ shape_0, shape_1 = weight.shape
48
+
49
+ n_tiles = ceildiv(shape_0, block_n)
50
+ k_tiles = ceildiv(shape_1, block_k)
51
+
52
+ q_weight = F.pad(
53
+ weight,
54
+ (0, k_tiles * block_k - shape_1, 0, n_tiles * block_n - shape_0),
55
+ mode="constant",
56
+ value=0.0,
57
+ )
58
+
59
+ qweight = q_weight.reshape(n_tiles, block_n, k_tiles, block_k)
60
+ block_max = torch.max(torch.abs(qweight), dim=1, keepdim=True)[0]
61
+ block_max = torch.max(block_max, dim=3, keepdim=True)[0]
62
+
63
+ scale = block_max.to(torch.float32) / FP8_MAX
64
+ qweight = (
65
+ (qweight / scale)
66
+ .clamp(min=FP8_MIN, max=FP8_MAX)
67
+ .reshape((n_tiles * block_n, k_tiles * block_k))
68
+ .to(torch.float8_e4m3fn)
69
+ )
70
+ qweight = qweight[:shape_0, :shape_1].clone().detach()
71
+ scale = scale.squeeze()
72
+
73
+ return qweight, scale
74
+
75
+
76
+ def channel_fp8(weight):
77
+ channel_max = torch.max(weight.abs(), dim=-1, keepdim=True)[0]
78
+ scale = channel_max.clamp(min=1e-12).to(torch.float32) / FP8_MAX
79
+ qweight = (weight / scale).clamp(min=FP8_MIN, max=FP8_MAX)
80
+ qweight = qweight.to(torch.float8_e4m3fn)
81
+ return qweight, scale
82
+
83
+
84
+ def tensor_fp8(weight):
85
+ scale = weight.abs().max().clamp(min=1e-12).to(torch.float32) / FP8_MAX
86
+ qweight = (weight / scale).clamp(min=FP8_MIN, max=FP8_MAX)
87
+ qweight = qweight.to(torch.float8_e4m3fn)
88
+ scale = scale.view(1)
89
+ return qweight, scale
90
+
91
+
92
+ def quant_fp8(weight, strategy, block_size=None):
93
+ if strategy == "tensor":
94
+ return tensor_fp8(weight)
95
+ elif strategy == "channel":
96
+ return channel_fp8(weight)
97
+ else:
98
+ return block_fp8(weight, block_size)
99
+
100
+
101
+ class ConversionResult:
102
+ def __init__(self):
103
+ self.lock = threading.Lock()
104
+ self.weight_map = {}
105
+ self.param_count = 0
106
+ self.modules_to_not_convert = []
107
+
108
+ def add_result(self, filename, q_weights, module_names):
109
+ with self.lock:
110
+ for k, v in q_weights.items():
111
+ self.weight_map[k] = filename
112
+ self.param_count += len(v)
113
+ self.modules_to_not_convert.extend(module_names)
114
+
115
+
116
+ def process_file(input_path, output_path, filename, strategy, block_size, result_collector):
117
+ if not filename.endswith(".safetensors"):
118
+ return
119
+
120
+ print(f"Processing {filename}, memory usage: {torch.cuda.memory_allocated()}")
121
+ weights = {}
122
+ q_weights = {}
123
+
124
+ with safetensors.safe_open(os.path.join(input_path, filename), framework="pt", device="cuda") as f:
125
+ for k in f.keys():
126
+ weights[k] = f.get_tensor(k)
127
+
128
+ modules_to_not_convert = []
129
+ for key in weights.keys():
130
+ if (
131
+ "weight" in key
132
+ and "layernorm" not in key
133
+ and "embed" not in key
134
+ and "router" not in key
135
+ and "mlp.gate." not in key
136
+ and "norm" not in key
137
+ and "lm_head" not in key
138
+ and "eh_proj" not in key
139
+ ):
140
+ qw, s = quant_fp8(weights[key], strategy, block_size)
141
+ q_weights[key] = qw
142
+ if block_size:
143
+ scale_name = key.replace(".weight", ".weight_scale_inv")
144
+ else:
145
+ scale_name = key.replace(".weight", ".weight_scale")
146
+ q_weights[scale_name] = s
147
+ else:
148
+ modules_to_not_convert.append(key.replace(".weight", ""))
149
+ q_weights[key] = weights[key]
150
+
151
+ safetensors.torch.save_file(q_weights, os.path.join(output_path, filename), metadata={"format": "pt"})
152
+
153
+ result_collector.add_result(filename, q_weights, modules_to_not_convert)
154
+
155
+
156
+ def convert_fp8(input_path, output_path, strategy, block_size=None, max_workers=4):
157
+ input_path = os.path.abspath(input_path)
158
+ os.makedirs(output_path, exist_ok=True)
159
+
160
+ for filename in os.listdir(input_path):
161
+ if not filename.endswith(".safetensors") and not os.path.isdir(os.path.join(input_path, filename)):
162
+ shutil.copyfile(os.path.join(input_path, filename), os.path.join(output_path, filename))
163
+
164
+ safetensors_files = [f for f in os.listdir(input_path) if f.endswith(".safetensors")]
165
+
166
+ result_collector = ConversionResult()
167
+
168
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
169
+ futures = []
170
+ for filename in safetensors_files:
171
+ future = executor.submit(
172
+ process_file, input_path, output_path, filename, strategy, block_size, result_collector
173
+ )
174
+ futures.append(future)
175
+
176
+ for future in tqdm(futures, desc="Processing files"):
177
+ future.result()
178
+
179
+ if strategy == "block" or strategy == "tensor":
180
+ quantization_config = {
181
+ "activation_scheme": "dynamic",
182
+ "fmt": "e4m3",
183
+ "quant_method": "fp8",
184
+ }
185
+ if block_size:
186
+ quantization_config["weight_block_size"] = block_size
187
+ if len(result_collector.modules_to_not_convert) > 0:
188
+ quantization_config["modules_to_not_convert"] = list(set(result_collector.modules_to_not_convert))
189
+ else:
190
+ quant_group = {
191
+ "group_0": {
192
+ "input_activations": {
193
+ "actorder": None,
194
+ "block_structure": None,
195
+ "dynamic": True,
196
+ "group_size": None,
197
+ "num_bits": 8,
198
+ "observer": None,
199
+ "observer_kwargs": {},
200
+ "strategy": "token",
201
+ "symmetric": True,
202
+ "type": "float",
203
+ },
204
+ "output_activations": None,
205
+ "targets": ["Linear"],
206
+ "weights": {
207
+ "actorder": None,
208
+ "block_structure": None,
209
+ "dynamic": False,
210
+ "group_size": None,
211
+ "num_bits": 8,
212
+ "observer": "minmax",
213
+ "observer_kwargs": {},
214
+ "strategy": strategy,
215
+ "symmetric": True,
216
+ "type": "float",
217
+ },
218
+ },
219
+ }
220
+ quantization_config = {
221
+ "config_groups": quant_group,
222
+ "format": "float-quantized",
223
+ "ignore": list(set(result_collector.modules_to_not_convert)),
224
+ "quant_method": "compressed-tensors",
225
+ "quantization_status": "compressed",
226
+ }
227
+
228
+ config_path = os.path.join(input_path, "config.json")
229
+ if os.path.exists(config_path):
230
+ cfg = json.load(open(config_path))
231
+ cfg["quantization_config"] = quantization_config
232
+ json.dump(cfg, open(os.path.join(output_path, "config.json"), "w"), indent=2)
233
+
234
+ index_dict = {"weight_map": result_collector.weight_map, "metadata": {"total_size": result_collector.param_count}}
235
+ json.dump(index_dict, open(os.path.join(output_path, "model.safetensors.index.json"), "w"), indent=2)
236
+
237
+ gc.collect()
238
+ torch.cuda.empty_cache()
239
+
240
+
241
+ if __name__ == "__main__":
242
+ parser = argparse.ArgumentParser()
243
+ parser.add_argument("--model-dir", type=str, help="Path to the directory of the HF safetensors model.")
244
+ parser.add_argument("--save-dir", type=str, help="Path to the directory to save the converted model.")
245
+ parser.add_argument("--strategy", type=str, default="block", choices=["block", "channel", "tensor"])
246
+ parser.add_argument("--block-size", type=int, nargs="*", default=None, help="eg. --block-size 32 32")
247
+ parser.add_argument("--max-workers", type=int, default=1, help="Number of worker threads for parallel processing")
248
+ args = parser.parse_args()
249
+
250
+ if not os.path.exists(args.save_dir):
251
+ print(f"Creating directory {args.save_dir}")
252
+ os.makedirs(args.save_dir)
253
+ elif not os.path.isdir(args.save_dir):
254
+ raise ValueError("The save_dir should be a directory.")
255
+
256
+ convert_fp8(args.model_dir, args.save_dir, args.strategy, args.block_size, args.max_workers)
tools/convert_hf_to_torch_dist.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ import gc
5
+ import os
6
+ import shutil
7
+
8
+ import torch
9
+ import torch.distributed as dist
10
+ from megatron.core.enums import ModelType
11
+ from megatron.training.arguments import parse_args, validate_args
12
+ from megatron.training.checkpointing import get_checkpoint_name, get_checkpoint_tracker_filename, save_checkpoint
13
+ from megatron.training.training import get_model
14
+
15
+ import slime_plugins.mbridge # noqa: F401
16
+ from mbridge import AutoBridge
17
+ from slime.backends.megatron_utils.arguments import set_default_megatron_args
18
+ from slime.backends.megatron_utils.initialize import init
19
+ from slime.backends.megatron_utils.model_provider import get_model_provider_func
20
+ from slime.utils.logging_utils import configure_logger
21
+ from slime.utils.memory_utils import print_memory
22
+
23
+
24
+ def add_convertion_args(parser):
25
+ """Add conversion arguments to the parser"""
26
+ parser.add_argument("--hf-checkpoint", type=str, required=True, help="HuggingFace model path")
27
+ parser.add_argument(
28
+ "--megatron-to-hf-mode",
29
+ choices=["raw", "bridge"],
30
+ default="raw",
31
+ help="The method to convert megatron weights to hugging face weights for SGLang.",
32
+ )
33
+ try:
34
+ parser.add_argument("--padded-vocab-size", type=int, default=None)
35
+ except Exception:
36
+ pass
37
+ return parser
38
+
39
+
40
+ def get_args():
41
+ args = parse_args(add_convertion_args)
42
+ args = set_default_megatron_args(args)
43
+
44
+ # set to pass megatron validate_args
45
+ args.save_interval = 1
46
+ args.micro_batch_size = 1
47
+ world_size = int(os.environ.get("WORLD_SIZE", "1"))
48
+ args.global_batch_size = int(os.environ.get("WORLD_SIZE", "1"))
49
+
50
+ assert world_size <= args.num_layers, (
51
+ f"World size {world_size} must be less than or equal to number of layers {args.num_layers}. "
52
+ "You are using too many GPUs for this conversion."
53
+ )
54
+
55
+ def ceildiv(a, b):
56
+ return -(a // -b)
57
+
58
+ if args.pipeline_model_parallel_size == 1 and world_size > 1:
59
+ pp_size = world_size
60
+ while True:
61
+ args.pipeline_model_parallel_size = pp_size
62
+ args.decoder_last_pipeline_num_layers = args.num_layers - ceildiv(
63
+ args.num_layers, args.pipeline_model_parallel_size
64
+ ) * (args.pipeline_model_parallel_size - 1)
65
+
66
+ if args.decoder_last_pipeline_num_layers > 0:
67
+ break
68
+
69
+ if pp_size % 2 == 0:
70
+ pp_size //= 2
71
+ else:
72
+ raise ValueError(
73
+ f"Cannot find a valid pipeline model parallel size for {args.num_layers} layers and {world_size} GPUs."
74
+ )
75
+ print(
76
+ f"Using pipeline model parallel size: {args.pipeline_model_parallel_size}, decoder last pipeline num layers: {args.decoder_last_pipeline_num_layers}"
77
+ )
78
+
79
+ validate_args(args)
80
+ return args
81
+
82
+
83
+ def main():
84
+ if torch.version.hip:
85
+ import megatron.core.dist_checkpointing.strategies.filesystem_async as filesystem_async_module
86
+ from slime.utils.rocm_checkpoint_writer import ROCmFileSystemWriterAsync
87
+
88
+ filesystem_async_module.FileSystemWriterAsync = ROCmFileSystemWriterAsync
89
+ print("[ROCm] Applied FileSystemWriterAsync patch for HIP compatibility")
90
+
91
+ configure_logger()
92
+
93
+ # Initialize distributed environment
94
+ world_size = int(os.getenv("WORLD_SIZE") or os.getenv("SLURM_NTASKS") or 1)
95
+ local_rank = int(os.getenv("LOCAL_RANK") or os.getenv("SLURM_LOCALID") or 0)
96
+ global_rank = int(os.getenv("RANK") or os.getenv("SLURM_PROCID") or 0)
97
+
98
+ torch.cuda.set_device(local_rank)
99
+ os.environ.setdefault("WORLD_SIZE", str(world_size))
100
+ os.environ.setdefault("RANK", str(global_rank))
101
+ os.environ.setdefault("LOCAL_RANK", str(local_rank))
102
+ os.environ.setdefault("MASTER_ADDR", "localhost")
103
+ os.environ.setdefault("MASTER_PORT", "12355")
104
+ dist.init_process_group(
105
+ backend="nccl",
106
+ world_size=world_size,
107
+ rank=global_rank,
108
+ device_id=torch.device(f"cuda:{local_rank}"),
109
+ )
110
+ args = get_args()
111
+ init(args)
112
+ model = get_model(get_model_provider_func(args), ModelType.encoder_or_decoder, wrap_with_ddp=False)
113
+
114
+ # Load model
115
+ hf_model_path = args.hf_checkpoint
116
+ bridge = AutoBridge.from_pretrained(hf_model_path, trust_remote_code=True)
117
+ bridge.load_weights(model, hf_model_path, memory_efficient=True)
118
+ print(f"Model loaded: {hf_model_path}")
119
+
120
+ print_memory("after loading model")
121
+ torch.cuda.synchronize()
122
+ gc.collect()
123
+ torch.cuda.empty_cache()
124
+
125
+ save_checkpoint(1, model, None, None, 0)
126
+
127
+ if dist.get_rank() == 0:
128
+ # change to release ckpt
129
+ tracker_filename = get_checkpoint_tracker_filename(args.save)
130
+ with open(tracker_filename, "w") as f:
131
+ f.write("release")
132
+ source_dir = get_checkpoint_name(args.save, 1, False, return_base_dir=True)
133
+ target_dir = get_checkpoint_name(args.save, -1, True, return_base_dir=True)
134
+ shutil.move(source_dir, target_dir)
135
+ dist.barrier()
136
+ dist.destroy_process_group()
137
+
138
+
139
+ if __name__ == "__main__":
140
+ main()
tools/convert_k2_thinking_int4_to_bf16.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """
5
+ Usage:
6
+ ------
7
+ python convert_k2_thinking_int4_to_bf16.py [-h] --model-dir MODEL_DIR [--output-dir OUTPUT_DIR]
8
+ [--files FILE [FILE ...]] [--config-path CONFIG_PATH]
9
+ [--overwrite]
10
+ options:
11
+ -h, --help Show this help message and exit.
12
+ --model-dir MODEL_DIR Path to the directory of the HF safetensors quantized model.
13
+ --output-dir OUTPUT_DIR
14
+ Path to the directory to save the converted BF16 model.
15
+ Default: <model-dir>_bf16
16
+ --files FILE [FILE ...]
17
+ Specific safetensors filenames to convert (relative to model-dir).
18
+ Convert all if omitted.
19
+ --config-path CONFIG_PATH
20
+ Path to config.json to extract group_size (default: model-dir/config.json).
21
+ --overwrite Rewrite output files even if they already exist.
22
+
23
+
24
+ Example:
25
+ --------
26
+ python convert_k2_thinking_int4_to_bf16.py --model-dir /Kimi-K2-Thinking --output-dir /Kimi-K2-Thinking-bf16
27
+ """
28
+
29
+ import argparse
30
+ import json
31
+ import os
32
+ import shutil
33
+ from collections import defaultdict
34
+
35
+ import torch
36
+ from compressed_tensors.compressors import unpack_from_int32
37
+ from safetensors.torch import safe_open, save_file
38
+ from tqdm import tqdm
39
+
40
+
41
+ def _load_config(model_dir: str, config_path: str | None) -> tuple[int, int, int]:
42
+ """Read config.json and return hidden_size, inter_size, and group_size."""
43
+ cfg_path = config_path or os.path.join(model_dir, "config.json")
44
+ with open(cfg_path) as f:
45
+ cfg = json.load(f)
46
+ hidden_size = int(cfg.get("hidden_size"))
47
+ inter_size = int(cfg.get("moe_intermediate_size"))
48
+ group_size = int(
49
+ cfg.get("quantization_config", {})
50
+ .get("config_groups", {})
51
+ .get("group_0", {})
52
+ .get("weights", {})
53
+ .get("group_size", 128)
54
+ )
55
+ return hidden_size, inter_size, group_size
56
+
57
+
58
+ def _dequantize_tensor(
59
+ weight_packed: torch.Tensor,
60
+ weight_scale: torch.Tensor,
61
+ weight_shape: torch.Tensor,
62
+ group_size: int,
63
+ ) -> torch.Tensor:
64
+ """Unpack int32 quantized tensor and multiply with scales to create BF16 tensor."""
65
+ if isinstance(weight_shape, torch.Tensor):
66
+ shape = tuple(int(v) for v in weight_shape.view(-1).tolist())
67
+ else:
68
+ shape = tuple(weight_shape)
69
+
70
+ weight = unpack_from_int32(weight_packed, 4, shape)
71
+
72
+ if group_size > 0:
73
+ scale = weight_scale.to(torch.float32)
74
+ if scale.dim() == 1:
75
+ scale = scale.unsqueeze(1)
76
+ scales = torch.repeat_interleave(scale, repeats=group_size, dim=1)
77
+ else:
78
+ scales = weight_scale.to(torch.float32)
79
+
80
+ if scales.shape != weight.shape:
81
+ if scales.numel() == weight.numel():
82
+ scales = scales.reshape_as(weight)
83
+ else:
84
+ raise ValueError(f"Scale shape {scales.shape} incompatible with weight shape {weight.shape}")
85
+
86
+ bf16 = (weight.to(torch.float32) * scales).to(torch.bfloat16)
87
+ return bf16.contiguous()
88
+
89
+
90
+ def _is_quantized_weight_key(key: str) -> bool:
91
+ """Check if the key is a quantized MoE expert weight key."""
92
+ if ".mlp.experts." not in key or ".shared_experts." in key:
93
+ return False
94
+ suffixes = ("weight_packed", "weight_scale", "weight_shape")
95
+ for proj in ("gate_proj", "up_proj", "down_proj"):
96
+ for suffix in suffixes:
97
+ if key.endswith(f".{proj}.{suffix}"):
98
+ return True
99
+ return False
100
+
101
+
102
+ def convert_file(
103
+ input_path: str,
104
+ output_path: str,
105
+ group_size: int,
106
+ skip_existing: bool = True,
107
+ ):
108
+ """Convert a single safetensors file from quantized format to BF16 (GPU accelerated)."""
109
+ if skip_existing and os.path.exists(output_path):
110
+ return
111
+
112
+ tensors = {}
113
+ expert_buffers = defaultdict(lambda: defaultdict(dict))
114
+
115
+ # Load weights directly on GPU
116
+ with safe_open(input_path, framework="pt", device="cuda") as reader:
117
+ keys = list(reader.keys())
118
+ for key in keys:
119
+ tensor = reader.get_tensor(key)
120
+ if not _is_quantized_weight_key(key):
121
+ tensors[key] = tensor
122
+ continue
123
+ parts = key.split(".")
124
+ try:
125
+ expert_idx = parts.index("experts")
126
+ except ValueError:
127
+ tensors[key] = tensor
128
+ continue
129
+ prefix = ".".join(parts[: expert_idx + 2])
130
+ project = parts[-2]
131
+ suffix = parts[-1]
132
+ expert_buffers[prefix][project][suffix] = tensor
133
+
134
+ # Convert quantized weights
135
+ for prefix, components in expert_buffers.items():
136
+ for proj_name in ["gate_proj", "up_proj", "down_proj"]:
137
+ proj_data = components.get(proj_name, {})
138
+ required = {"weight_packed", "weight_scale", "weight_shape"}
139
+
140
+ if not required.issubset(proj_data.keys()):
141
+ # Keep quantized tensors if incomplete
142
+ for suffix, value in proj_data.items():
143
+ tensors[f"{prefix}.{proj_name}.{suffix}"] = value
144
+ continue
145
+
146
+ # Dequantize to BF16
147
+ bf16_weight = _dequantize_tensor(
148
+ proj_data["weight_packed"].to(torch.int32),
149
+ proj_data["weight_scale"].to(torch.float32),
150
+ proj_data["weight_shape"],
151
+ group_size,
152
+ )
153
+ tensors[f"{prefix}.{proj_name}.weight"] = bf16_weight.to(torch.bfloat16)
154
+
155
+ # Save converted file (moved to CPU for compatibility)
156
+ cpu_tensors = {k: v.cpu() for k, v in tensors.items()}
157
+ os.makedirs(os.path.dirname(output_path), exist_ok=True)
158
+ save_file(cpu_tensors, output_path)
159
+
160
+
161
+ def parse_args() -> argparse.Namespace:
162
+ parser = argparse.ArgumentParser(description="Convert GPTQ MoE experts to BF16 weights.")
163
+ parser.add_argument("--model-dir", required=True, help="Directory containing safetensors checkpoints.")
164
+ parser.add_argument(
165
+ "--output-dir",
166
+ default=None,
167
+ help="Destination BF16 model directory (default: <model-dir>_bf16).",
168
+ )
169
+ parser.add_argument(
170
+ "--files",
171
+ nargs="+",
172
+ default=None,
173
+ help="Optional specific safetensor files to convert.",
174
+ )
175
+ parser.add_argument(
176
+ "--config-path",
177
+ default=None,
178
+ help="Path to config.json if not in model-dir.",
179
+ )
180
+ parser.add_argument(
181
+ "--overwrite",
182
+ action="store_true",
183
+ help="Overwrite existing BF16 files.",
184
+ )
185
+ return parser.parse_args()
186
+
187
+
188
+ def main():
189
+ args = parse_args()
190
+ model_dir = os.path.abspath(args.model_dir)
191
+ output_dir = os.path.abspath(args.output_dir or f"{model_dir}_bf16")
192
+
193
+ if not os.path.isdir(model_dir):
194
+ raise FileNotFoundError(f"Model directory not found: {model_dir}")
195
+
196
+ _, _, group_size = _load_config(model_dir, args.config_path)
197
+
198
+ # Collect target files
199
+ if args.files:
200
+ targets = [os.path.join(model_dir, fname) for fname in args.files]
201
+ else:
202
+ targets = [
203
+ os.path.join(model_dir, name) for name in sorted(os.listdir(model_dir)) if name.endswith(".safetensors")
204
+ ]
205
+
206
+ if not targets:
207
+ print("No safetensors checkpoints found.")
208
+ return
209
+
210
+ # Convert with progress bar
211
+ for path in tqdm(targets, desc="Converting weights", unit="file"):
212
+ if not os.path.isfile(path):
213
+ continue
214
+ rel = os.path.relpath(path, model_dir)
215
+ output_path = os.path.join(output_dir, rel)
216
+ convert_file(path, output_path, group_size, skip_existing=not args.overwrite)
217
+
218
+ # Copy config/json/py/tokenizer
219
+ for fname in os.listdir(model_dir):
220
+ src_path = os.path.join(model_dir, fname)
221
+ dst_path = os.path.join(output_dir, fname)
222
+ if fname == "model.safetensors.index.json":
223
+ continue
224
+ if fname.endswith(".json") or fname.endswith(".py") or fname.startswith("tokenizer"):
225
+ shutil.copy2(src_path, dst_path)
226
+
227
+ # Generate new index
228
+ new_index_path = os.path.join(output_dir, "model.safetensors.index.json")
229
+ weight_map = {}
230
+ for fname in sorted(os.listdir(output_dir)):
231
+ if not fname.endswith(".safetensors"):
232
+ continue
233
+ safetensor_path = os.path.join(output_dir, fname)
234
+ with safe_open(safetensor_path, framework="pt") as reader:
235
+ for key in reader.keys():
236
+ weight_map[key] = fname
237
+
238
+ with open(new_index_path, "w") as f:
239
+ json.dump({"metadata": {}, "weight_map": weight_map}, f, indent=2)
240
+
241
+ print(f"\nSuccessful! Output saved to: {output_dir}")
242
+
243
+
244
+ if __name__ == "__main__":
245
+ main()
tools/convert_to_hf.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ import torch
5
+ import torch.distributed as dist
6
+ from megatron.core import mpu
7
+ from transformers import AutoConfig, AutoModelForCausalLM, AutoTokenizer
8
+
9
+ import slime.backends.megatron_utils as megatron_utils
10
+ from slime.backends.megatron_utils import update_weight_utils
11
+ from slime.utils.arguments import parse_args
12
+
13
+
14
+ def add_checkpoint_args(parser):
15
+ parser.add_argument(
16
+ "--output-dir",
17
+ type=str,
18
+ default=None,
19
+ help="Directory to save the converted HF model.",
20
+ )
21
+ parser.add_argument(
22
+ "--check-same",
23
+ action="store_true",
24
+ default=False,
25
+ help="Check if the converted model is the same as the original model.",
26
+ )
27
+ return parser
28
+
29
+
30
+ def main(args):
31
+ megatron_utils.init(args)
32
+
33
+ pp_size = mpu.get_pipeline_model_parallel_world_size()
34
+ ep_size = mpu.get_expert_model_parallel_world_size()
35
+
36
+ is_save_rank = (
37
+ mpu.get_data_parallel_rank(with_context_parallel=True) == 0 and mpu.get_tensor_model_parallel_rank() == 0
38
+ )
39
+
40
+ # Setup the model and optimizer
41
+ args.no_load_optim = True
42
+ args.no_load_rng = True
43
+ model, _, _, _ = megatron_utils.initialize_model_and_optimizer(args)
44
+
45
+ hf_config = AutoConfig.from_pretrained(args.hf_checkpoint, trust_remote_code=True)
46
+ model_name = type(hf_config).__name__.lower()
47
+
48
+ tokenizer = AutoTokenizer.from_pretrained(args.hf_checkpoint, trust_remote_code=True)
49
+
50
+ vocab_size = tokenizer.vocab_size if args.vocab_size is None else args.vocab_size
51
+
52
+ param_infos = update_weight_utils.get_param_infos(args, model)
53
+
54
+ state_dict = {}
55
+ rank = dist.get_rank()
56
+ for info in param_infos:
57
+ if dist.get_rank() == info.src_rank:
58
+ for name_, param_ in update_weight_utils.named_parameters(args, model):
59
+ if name_ == info.name:
60
+ param = param_
61
+ break
62
+ else:
63
+ param = torch.empty(info.shape, dtype=info.dtype, device=torch.cuda.current_device())
64
+
65
+ if pp_size > 1:
66
+ if info.src_rank in dist.get_process_group_ranks(mpu.get_pipeline_model_parallel_group()):
67
+ torch.distributed.broadcast(param, src=info.src_rank, group=mpu.get_pipeline_model_parallel_group())
68
+
69
+ # broadcast params across ep ranks
70
+ if ep_size > 1:
71
+ if ".experts." in info.name:
72
+ src_rank = (
73
+ info.src_rank
74
+ if info.src_rank in dist.get_process_group_ranks(mpu.get_expert_model_parallel_group())
75
+ else rank
76
+ )
77
+ torch.distributed.broadcast(param, src=src_rank, group=mpu.get_expert_model_parallel_group())
78
+
79
+ for key, value in info.attrs.items():
80
+ setattr(param, key, value)
81
+
82
+ param = update_weight_utils.all_gather_param(info.name, param)
83
+ param = update_weight_utils.remove_padding(info.name, param, vocab_size)
84
+ # use torch.distributed
85
+ if is_save_rank:
86
+ converted_named_tensors = update_weight_utils.convert_to_hf(args, model_name, info.name, param)
87
+ for name, param in converted_named_tensors:
88
+ state_dict[name] = param.cpu()
89
+ del param
90
+
91
+ if is_save_rank:
92
+ hf_model = AutoModelForCausalLM.from_pretrained(
93
+ args.hf_checkpoint, torch_dtype="auto", device_map="cpu", trust_remote_code=True
94
+ )
95
+
96
+ if args.check_same:
97
+ for name, param in hf_model.named_parameters():
98
+ if name in state_dict:
99
+ assert (
100
+ param.shape == state_dict[name].shape
101
+ ), f"Shape mismatch for {name}: {param.shape} vs {state_dict[name].shape}"
102
+ assert torch.all(param == state_dict[name]), f"Value mismatch for {name}"
103
+ else:
104
+ print(f"Warning: {name} not found in state_dict")
105
+
106
+ if args.output_dir:
107
+ tokenizer.save_pretrained(args.output_dir)
108
+ print(hf_model.load_state_dict(state_dict, strict=False))
109
+ hf_model.save_pretrained(args.output_dir)
110
+
111
+ dist.barrier()
112
+
113
+
114
+ if __name__ == "__main__":
115
+ args = parse_args(add_custom_arguments=add_checkpoint_args)
116
+ main(args)
tools/convert_torch_dist_to_hf.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import pickle
8
+ import re
9
+ import shutil
10
+ import time
11
+
12
+
13
+ import safetensors.torch
14
+ import torch
15
+ import torch.distributed.checkpoint as dist_cp
16
+ from transformers import AutoConfig
17
+ from typing_extensions import override
18
+
19
+ from slime.backends.megatron_utils.megatron_to_hf import convert_to_hf, remove_padding
20
+
21
+
22
+ class UnpicklerWrapper(pickle.Unpickler):
23
+ @override
24
+ def find_class(self, mod_name, name):
25
+ class DummyClass:
26
+ def __init__(self, *args, **kwargs):
27
+ pass
28
+
29
+ if mod_name.startswith("megatron") or mod_name.startswith("glm"):
30
+ return DummyClass
31
+ return super().find_class(mod_name, name)
32
+
33
+
34
+ pickle.Unpickler = UnpicklerWrapper
35
+
36
+
37
+ class WrappedStorageReader(dist_cp.FileSystemReader):
38
+ @override
39
+ def read_metadata(self):
40
+ path = self.fs.concat_path(self.path, ".metadata")
41
+ with self.fs.create_stream(path, "rb") as metadata_file:
42
+ metadata = UnpicklerWrapper(metadata_file).load()
43
+ if getattr(metadata, "storage_meta", None) is None:
44
+ metadata.storage_meta = dist_cp.StorageMeta()
45
+ metadata.storage_meta.load_id = self.load_id
46
+ if metadata.planner_data is None:
47
+ metadata.planner_data = {}
48
+ return metadata
49
+
50
+
51
+ class EmptyStateDictLoadPlanner(dist_cp.default_planner.DefaultLoadPlanner):
52
+ @override
53
+ def set_up_planner(
54
+ self,
55
+ state_dict: dist_cp.metadata.STATE_DICT_TYPE,
56
+ metadata: dist_cp.metadata.Metadata | None = None,
57
+ is_coordinator: bool = False,
58
+ ) -> None:
59
+ for k, v in metadata.state_dict_metadata.items():
60
+ if "optimizer" in k or "_state" in k:
61
+ continue
62
+ print(f"find {k} in torch_dist ckpt")
63
+ if isinstance(v, dist_cp.metadata.TensorStorageMetadata):
64
+ v = torch.empty(v.size, dtype=v.properties.dtype) # type: ignore[assignment]
65
+ state_dict[k] = v
66
+ super().set_up_planner(state_dict, metadata, is_coordinator)
67
+
68
+
69
+ def get_expert_param(args, name, param):
70
+ if ".experts." not in name:
71
+ yield name, param
72
+ return
73
+
74
+ num_experts = args.num_experts
75
+ match = re.search(r"mlp.experts\.(.+)\.weight(\d+)", name)
76
+ if not match:
77
+ assert param.shape[0] == num_experts
78
+ for expert_id in range(num_experts):
79
+ expert_name = name.replace(".experts.experts.", ".experts.") + str(expert_id)
80
+ expert_param = param[expert_id]
81
+ yield expert_name, expert_param
82
+ else:
83
+ yield name, param
84
+
85
+
86
+ def get_layer_param(args, name, param):
87
+ if ".layers." not in name:
88
+ yield name, param
89
+ return
90
+
91
+ num_layers = args.num_layers
92
+ match = re.search(r"\.layers\.(\d+)\.", name)
93
+ if not match:
94
+ assert param.shape[0] == num_layers
95
+ for layer_id in range(num_layers):
96
+ layer_name = name.replace(".layers.", f".layers.{layer_id}.")
97
+ layer_param = param[layer_id]
98
+ yield from get_expert_param(args, layer_name, layer_param)
99
+ else:
100
+ yield from get_expert_param(args, name, param)
101
+
102
+
103
+ def get_named_params(args, state_dict):
104
+ for name, param in state_dict.items():
105
+ name = f"module.module.{name}"
106
+ yield from get_layer_param(args, name, param)
107
+
108
+
109
+ def save_tensors(args, model_name, state_dict, output_dir, chunk_size, vocab_size=None):
110
+ # for slime update_weight compatible
111
+ args.sglang_enable_ep_moe = False
112
+
113
+ print(f"start saving to {output_dir}")
114
+ os.makedirs(output_dir, exist_ok=True)
115
+ # 2GB
116
+ current_size = 0
117
+ total_size = 0
118
+ modeltensors = [{}]
119
+ for name, param in get_named_params(args, state_dict):
120
+ if vocab_size:
121
+ param = remove_padding(name, param, vocab_size)
122
+ converted_named_tensors = convert_to_hf(args, model_name, name, param)
123
+ for converted_name, converted_param in converted_named_tensors:
124
+ tensor_size = converted_param.numel() * converted_param.element_size()
125
+ if tensor_size + current_size > chunk_size:
126
+ modeltensors.append({})
127
+ current_size = 0
128
+ modeltensors[-1][converted_name] = converted_param
129
+ current_size += tensor_size
130
+ total_size += tensor_size
131
+
132
+ metadata = {"metadata": {"total_size": total_size}, "weight_map": {}}
133
+
134
+ num_files = len(modeltensors)
135
+ for i, tensors in enumerate(modeltensors):
136
+ filename = f"model-{i:05d}-of-{num_files:05d}.safetensors"
137
+ for key in tensors.keys():
138
+ metadata["weight_map"][key] = filename
139
+ index_filepath = os.path.join(output_dir, "model.safetensors.index.json")
140
+ json.dump(metadata, open(index_filepath, "w"), indent=2)
141
+ print(f"{index_filepath} saved.")
142
+
143
+ for i, tensors in enumerate(modeltensors):
144
+ filename = f"model-{i:05d}-of-{num_files:05d}.safetensors"
145
+ t = time.time()
146
+ filepath = os.path.join(output_dir, filename)
147
+ safetensors.torch.save_file(tensors, filepath)
148
+ print(f"{filename} saved in {time.time() - t:.2f} sec.")
149
+
150
+
151
+ def copy_assets(origin_hf_dir, output_dir):
152
+ for filename in os.listdir(origin_hf_dir):
153
+ if filename == "model.safetensors.index.json" or filename.endswith(".safetensors"):
154
+ continue
155
+ origin_filename = os.path.join(origin_hf_dir, filename)
156
+ if not os.path.isfile(origin_filename):
157
+ print(f"Skip {filename}, not a file.")
158
+ continue
159
+ src, dst = origin_filename, os.path.join(output_dir, filename)
160
+ print(f"copy from {src} to {dst}")
161
+ shutil.copy(src, dst)
162
+
163
+
164
+ if __name__ == "__main__":
165
+ parser = argparse.ArgumentParser()
166
+ parser.add_argument("--model-name", type=str, default=None)
167
+ parser.add_argument("--input-dir", type=str, required=True)
168
+ parser.add_argument("--output-dir", type=str, required=True)
169
+ parser.add_argument(
170
+ "--origin-hf-dir",
171
+ type=str,
172
+ default=None,
173
+ help="use the origin hf dir to copy files like tokenizer, config.json, etc.",
174
+ )
175
+ parser.add_argument(
176
+ "-f", "--force", action="store_true", help="Force overwrite the output directory if it exists."
177
+ )
178
+ parser.add_argument(
179
+ "--chunk-size",
180
+ type=int,
181
+ default=5 * 1024**3,
182
+ help="Chunk size for saving tensors, default is 2GB.",
183
+ )
184
+ parser.add_argument(
185
+ "--vocab-size",
186
+ type=int,
187
+ default=None,
188
+ help="Vocab size for removing padding, if applicable. If not provided, no padding will be removed.",
189
+ )
190
+ args = parser.parse_args()
191
+
192
+ if os.path.exists(args.output_dir) and not args.force:
193
+ raise ValueError(f"Output directory {args.output_dir} already exists. Use --force to overwrite it.")
194
+
195
+ if args.model_name is None and args.origin_hf_dir is None:
196
+ raise ValueError(
197
+ "Either --model-name or --origin-hf-dir must be provided, so that we can know the name of the params."
198
+ )
199
+
200
+ if args.model_name is None:
201
+ hf_config = AutoConfig.from_pretrained(args.origin_hf_dir, trust_remote_code=True)
202
+ args.model_name = type(hf_config).__name__.lower()
203
+
204
+ state_dict = {}
205
+ print(f"loading model from {args.input_dir}")
206
+ t = time.time()
207
+ megatron_args = torch.load(os.path.join(args.input_dir, "common.pt"), weights_only=False)["args"]
208
+ dist_cp.state_dict_loader._load_state_dict(
209
+ state_dict,
210
+ storage_reader=WrappedStorageReader(args.input_dir),
211
+ planner=EmptyStateDictLoadPlanner(),
212
+ no_dist=True,
213
+ )
214
+ print(f"model loaded in {time.time()-t:.2f} sec.")
215
+
216
+ save_tensors(megatron_args, args.model_name, state_dict, args.output_dir, args.chunk_size, args.vocab_size)
217
+
218
+ if args.origin_hf_dir:
219
+ copy_assets(args.origin_hf_dir, args.output_dir)
tools/eval_aime2024_vllm.py ADDED
@@ -0,0 +1,494 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """
3
+ Evaluate a HF-format model on AIME 2024 using vLLM.
4
+
5
+ Example:
6
+
7
+ CUDA_VISIBLE_DEVICES=0,1,2,3 python tools/eval_aime_vllm.py \
8
+ --model /workspace/Lightning-OPD/checkpoints/qwen3-4b-lightning-opd-hf \
9
+ --num-gpus 4 \
10
+ --output outputs/eval_aime2024_qwen3_4b_lightning_opd.jsonl
11
+
12
+ Paper-style AIME setting:
13
+ temperature = 0.6
14
+ top_p = 0.95
15
+ max_tokens = 32768
16
+ n_samples = 32
17
+ metric = average pass@1
18
+ """
19
+
20
+ import argparse
21
+ import json
22
+ import os
23
+ import re
24
+ from pathlib import Path
25
+ from typing import Any, Dict, List, Optional
26
+
27
+
28
+ def parse_args():
29
+ parser = argparse.ArgumentParser()
30
+
31
+ # Model / hardware
32
+ parser.add_argument(
33
+ "--model",
34
+ type=str,
35
+ required=True,
36
+ help="Path to HF-format model checkpoint.",
37
+ )
38
+ parser.add_argument(
39
+ "--num-gpus",
40
+ type=int,
41
+ default=1,
42
+ help="Tensor parallel size for vLLM.",
43
+ )
44
+ parser.add_argument(
45
+ "--gpu-ids",
46
+ type=str,
47
+ default=None,
48
+ help='Optional CUDA_VISIBLE_DEVICES, e.g. "0,1,2,3". Must match --num-gpus.',
49
+ )
50
+ parser.add_argument(
51
+ "--dtype",
52
+ type=str,
53
+ default="bfloat16",
54
+ choices=["auto", "float16", "bfloat16", "float32"],
55
+ )
56
+ parser.add_argument(
57
+ "--gpu-memory-utilization",
58
+ type=float,
59
+ default=0.90,
60
+ )
61
+ parser.add_argument(
62
+ "--trust-remote-code",
63
+ action="store_true",
64
+ default=True,
65
+ )
66
+
67
+ # Dataset
68
+ parser.add_argument(
69
+ "--dataset",
70
+ type=str,
71
+ default="AI-MO/aimo-validation-aime",
72
+ help="HF dataset name for AIME 2024.",
73
+ )
74
+ parser.add_argument(
75
+ "--split",
76
+ type=str,
77
+ default=None,
78
+ help="Dataset split. If not set, use the first available split.",
79
+ )
80
+ parser.add_argument(
81
+ "--hf-cache",
82
+ type=str,
83
+ default=None,
84
+ help="Optional HuggingFace cache dir.",
85
+ )
86
+
87
+ # Generation settings from paper
88
+ parser.add_argument("--n-samples", type=int, default=32)
89
+ parser.add_argument("--temperature", type=float, default=0.6)
90
+ parser.add_argument("--top-p", type=float, default=0.95)
91
+ parser.add_argument("--max-tokens", type=int, default=32768)
92
+ parser.add_argument("--seed", type=int, default=42)
93
+
94
+ # Prompt / tokenizer behavior
95
+ parser.add_argument(
96
+ "--no-chat-template",
97
+ action="store_true",
98
+ help="Do not apply tokenizer chat template; use raw prompt.",
99
+ )
100
+ parser.add_argument(
101
+ "--enable-thinking",
102
+ action="store_true",
103
+ default=False,
104
+ help="Pass enable_thinking=True to Qwen3 chat template if supported.",
105
+ )
106
+ parser.add_argument(
107
+ "--disable-thinking",
108
+ action="store_true",
109
+ default=False,
110
+ help="Pass enable_thinking=False to Qwen3 chat template if supported.",
111
+ )
112
+ parser.add_argument(
113
+ "--assistant-prefix",
114
+ type=str,
115
+ default=None,
116
+ help=(
117
+ "Optional text appended after the assistant generation prompt. "
118
+ "Example for no-thinking style: '<think>\\n\\n</think>\\n\\n'"
119
+ ),
120
+ )
121
+ parser.add_argument(
122
+ "--prompt-template",
123
+ type=str,
124
+ default="train",
125
+ choices=["train", "paper", "chat"],
126
+ help=(
127
+ "Prompt template to use. "
128
+ "'train' uses the exact training ChatML prompt; "
129
+ "'paper' uses the paper-style ChatML prompt; "
130
+ "'chat' uses build_problem_prompt plus tokenizer.apply_chat_template."
131
+ ),
132
+ )
133
+
134
+ # Output / debug
135
+ parser.add_argument(
136
+ "--output",
137
+ type=str,
138
+ default="outputs/eval_aime2024_vllm.jsonl",
139
+ help="Path to save per-sample generations and scores.",
140
+ )
141
+ parser.add_argument(
142
+ "--limit",
143
+ type=int,
144
+ default=None,
145
+ help="Limit number of problems for quick debugging.",
146
+ )
147
+ parser.add_argument(
148
+ "--print-examples",
149
+ type=int,
150
+ default=3,
151
+ help="Print first N examples with predictions.",
152
+ )
153
+
154
+ return parser.parse_args()
155
+
156
+
157
+ def normalize_answer(x: Any) -> Optional[str]:
158
+ if x is None:
159
+ return None
160
+
161
+ s = str(x).strip()
162
+ s = s.replace("$", "")
163
+ s = s.replace(",", "")
164
+ s = s.replace("\\,", "")
165
+ s = s.strip()
166
+
167
+ # Remove simple latex wrappers.
168
+ s = s.replace("\\text", "")
169
+ s = s.replace("\\mathrm", "")
170
+ s = s.strip("{}").strip()
171
+
172
+ # AIME answers are integers from 0 to 999.
173
+ m = re.search(r"-?\d+", s)
174
+ if m is None:
175
+ return None
176
+
177
+ try:
178
+ return str(int(m.group(0)))
179
+ except ValueError:
180
+ return None
181
+
182
+
183
+ def extract_last_boxed(text: str) -> Optional[str]:
184
+ """
185
+ Extract the last \\boxed{...}. Handles simple nested braces better than regex.
186
+ """
187
+ marker = r"\boxed{"
188
+ positions = [m.start() for m in re.finditer(re.escape(marker), text)]
189
+ if not positions:
190
+ return None
191
+
192
+ for start in reversed(positions):
193
+ i = start + len(marker)
194
+ depth = 1
195
+ chars = []
196
+
197
+ while i < len(text):
198
+ ch = text[i]
199
+ if ch == "{":
200
+ depth += 1
201
+ chars.append(ch)
202
+ elif ch == "}":
203
+ depth -= 1
204
+ if depth == 0:
205
+ return "".join(chars)
206
+ chars.append(ch)
207
+ else:
208
+ chars.append(ch)
209
+ i += 1
210
+
211
+ return None
212
+
213
+
214
+ def extract_answer(text: str) -> Optional[str]:
215
+ # Prefer boxed answer, because the actual training prompt asks for \boxed{$Answer}.
216
+ boxed = extract_last_boxed(text)
217
+ if boxed is not None:
218
+ ans = normalize_answer(boxed)
219
+ if ans is not None:
220
+ return ans
221
+
222
+ # Fallback: final Answer: line.
223
+ matches = re.findall(r"Answer:\s*([^\n]+)", text, flags=re.IGNORECASE)
224
+ if matches:
225
+ ans = normalize_answer(matches[-1])
226
+ if ans is not None:
227
+ return ans
228
+
229
+ # No valid final answer.
230
+ return None
231
+
232
+ def build_exact_training_prompt(problem: str) -> str:
233
+ return (
234
+ "<|im_start|>user\n"
235
+ "Solve the following math problem step by step. "
236
+ "The last line of your response should be of the form "
237
+ "Answer: \\boxed{$Answer} where $Answer is the answer to the problem.\n\n"
238
+ f"{problem}\n\n"
239
+ "Remember to put your answer on its own line after \"Answer:\"."
240
+ "<|im_end|>\n"
241
+ "<|im_start|>assistant\n\n\n\n"
242
+ )
243
+
244
+ def build_problem_prompt(problem: str) -> str:
245
+ return (
246
+ f"{problem}\n\n"
247
+ "Please reason step by step, and put your final answer within \\boxed{}."
248
+ )
249
+
250
+ def build_paper_math_eval_prompt(problem: str) -> str:
251
+ return (
252
+ "<|im_start|>user\n"
253
+ f"Question: {problem}\n"
254
+ "Please reason step by step, and put your final answer within \\boxed{}.\n"
255
+ "<|im_end|>\n"
256
+ "<|im_start|>assistant\n"
257
+ )
258
+
259
+ def get_field(ex: Dict[str, Any], candidates: List[str]) -> Any:
260
+ for key in candidates:
261
+ if key in ex and ex[key] is not None:
262
+ return ex[key]
263
+ raise KeyError(f"Cannot find any of {candidates}. Example keys: {list(ex.keys())}")
264
+
265
+ # def build_problem_prompt(problem: str) -> str:
266
+ # return (
267
+ # "Solve the following math problem step by step. "
268
+ # "The last line of your response should be of the form "
269
+ # "Answer: $Answer (without quotes) where $Answer is the answer to the problem.\n\n"
270
+ # f"{problem}\n\n"
271
+ # 'Remember to put your answer on its own line after "Answer:".'
272
+ # )
273
+
274
+ def apply_chat_template(tokenizer, prompt: str, args) -> str:
275
+ if args.no_chat_template:
276
+ return prompt
277
+
278
+ messages = [{"role": "user", "content": prompt}]
279
+
280
+ kwargs = {
281
+ "tokenize": False,
282
+ "add_generation_prompt": True,
283
+ }
284
+
285
+ # Qwen3 tokenizer may support enable_thinking.
286
+ if args.enable_thinking and args.disable_thinking:
287
+ raise ValueError("Do not set both --enable-thinking and --disable-thinking.")
288
+
289
+ if args.enable_thinking:
290
+ kwargs["enable_thinking"] = True
291
+ elif args.disable_thinking:
292
+ kwargs["enable_thinking"] = False
293
+
294
+ try:
295
+ text = tokenizer.apply_chat_template(messages, **kwargs)
296
+ except TypeError:
297
+ # Some tokenizers do not accept enable_thinking.
298
+ kwargs.pop("enable_thinking", None)
299
+ text = tokenizer.apply_chat_template(messages, **kwargs)
300
+
301
+ if args.assistant_prefix is not None:
302
+ text += args.assistant_prefix.encode("utf-8").decode("unicode_escape")
303
+
304
+ return text
305
+
306
+
307
+ def main():
308
+ args = parse_args()
309
+
310
+ if args.gpu_ids is not None:
311
+ os.environ["CUDA_VISIBLE_DEVICES"] = args.gpu_ids
312
+
313
+ if args.hf_cache is not None:
314
+ os.environ["HF_HOME"] = args.hf_cache
315
+ os.environ["HF_DATASETS_CACHE"] = str(Path(args.hf_cache) / "datasets")
316
+ os.environ["HF_HUB_CACHE"] = str(Path(args.hf_cache) / "hub")
317
+
318
+ # Import after CUDA_VISIBLE_DEVICES is set.
319
+ from datasets import load_dataset
320
+ from transformers import AutoTokenizer
321
+ from vllm import LLM, SamplingParams
322
+
323
+ output_path = Path(args.output)
324
+ output_path.parent.mkdir(parents=True, exist_ok=True)
325
+
326
+ print("=" * 80)
327
+ print("AIME 2024 vLLM Evaluation")
328
+ print("=" * 80)
329
+ print(f"model: {args.model}")
330
+ print(f"dataset: {args.dataset}")
331
+ print(f"num_gpus / TP size: {args.num_gpus}")
332
+ print(f"CUDA_VISIBLE_DEVICES: {os.environ.get('CUDA_VISIBLE_DEVICES')}")
333
+ print(f"n_samples/problem: {args.n_samples}")
334
+ print(f"temperature: {args.temperature}")
335
+ print(f"top_p: {args.top_p}")
336
+ print(f"max_tokens: {args.max_tokens}")
337
+ print(f"prompt_template: {args.prompt_template}")
338
+ print(f"assistant_prefix: {repr(args.assistant_prefix)}")
339
+ print(f"output: {args.output}")
340
+ print("=" * 80)
341
+
342
+ tokenizer = AutoTokenizer.from_pretrained(
343
+ args.model,
344
+ trust_remote_code=args.trust_remote_code,
345
+ )
346
+
347
+ dataset_dict = load_dataset(args.dataset)
348
+ split = args.split or list(dataset_dict.keys())[0]
349
+ data = dataset_dict[split]
350
+
351
+ # AI-MO/aimo-validation-aime contains multiple AIME years.
352
+ # Keep only AIME 2024 examples.
353
+ if "url" in data.column_names:
354
+ data = data.filter(lambda ex: "2024_AIME" in ex["url"])
355
+ else:
356
+ raise ValueError(
357
+ "Expected a `url` column for filtering AIME 2024, "
358
+ f"but got columns: {data.column_names}"
359
+ )
360
+
361
+ print(f"Filtered AIME 2024 examples: {len(data)}")
362
+ for i in range(min(3, len(data))):
363
+ print(i, data[i].get("url", "NO_URL"), data[i].get("answer", "NO_ANSWER"))
364
+
365
+ assert len(data) == 30, f"Expected 30 AIME 2024 problems, got {len(data)}"
366
+
367
+ if args.limit is not None:
368
+ data = data.select(range(min(args.limit, len(data))))
369
+
370
+ print(f"Loaded split: {split}")
371
+ print(f"Number of problems: {len(data)}")
372
+ print(f"First example keys: {list(data[0].keys())}")
373
+
374
+ prompts = []
375
+ examples = []
376
+
377
+ for idx, ex in enumerate(data):
378
+ problem = get_field(ex, ["problem", "question", "prompt"])
379
+ gold_raw = get_field(ex, ["answer", "final_answer", "target", "solution"])
380
+ gold = normalize_answer(gold_raw)
381
+
382
+ if args.prompt_template == "train":
383
+ # Raw ChatML prompt matching the OPD training parquet.
384
+ # Do NOT call apply_chat_template again, otherwise ChatML will be nested.
385
+ full_prompt = build_exact_training_prompt(problem)
386
+ elif args.prompt_template == "paper":
387
+ # Raw ChatML prompt matching the paper-style math evaluation prompt.
388
+ # Do NOT call apply_chat_template again.
389
+ full_prompt = build_paper_math_eval_prompt(problem)
390
+ elif args.prompt_template == "chat":
391
+ # Normal user-content prompt; tokenizer will add ChatML.
392
+ raw_prompt = build_problem_prompt(problem)
393
+ full_prompt = apply_chat_template(tokenizer, raw_prompt, args)
394
+ else:
395
+ raise ValueError(f"Unknown prompt_template: {args.prompt_template}")
396
+
397
+ # Important: when using raw ChatML prompts, apply_chat_template() is bypassed.
398
+ # Therefore --assistant-prefix must be appended here, not only inside apply_chat_template().
399
+ # This is useful for Qwen3 no-thinking style, e.g.:
400
+ # --assistant-prefix '<think>\\n\\n</think>\\n\\n'
401
+ if args.prompt_template in {"train", "paper"} and args.assistant_prefix is not None:
402
+ full_prompt += args.assistant_prefix.encode("utf-8").decode("unicode_escape")
403
+
404
+ prompts.append(full_prompt)
405
+ examples.append(
406
+ {
407
+ "idx": idx,
408
+ "problem": problem,
409
+ "gold_raw": gold_raw,
410
+ "gold": gold,
411
+ "prompt": full_prompt,
412
+ }
413
+ )
414
+
415
+ llm = LLM(
416
+ model=args.model,
417
+ tensor_parallel_size=args.num_gpus,
418
+ dtype=args.dtype,
419
+ trust_remote_code=args.trust_remote_code,
420
+ gpu_memory_utilization=args.gpu_memory_utilization,
421
+ seed=args.seed,
422
+ )
423
+
424
+ sampling_params = SamplingParams(
425
+ n=args.n_samples,
426
+ temperature=args.temperature,
427
+ top_p=args.top_p,
428
+ #repetition_penalty=1.05,
429
+ max_tokens=args.max_tokens,
430
+ stop=["<|im_end|>"],
431
+ )
432
+
433
+ outputs = llm.generate(prompts, sampling_params)
434
+
435
+ total = 0
436
+ correct = 0
437
+ per_problem_records = []
438
+
439
+ with output_path.open("w", encoding="utf-8") as f:
440
+ for ex, out in zip(examples, outputs):
441
+ sample_records = []
442
+ problem_correct = 0
443
+
444
+ for sample_id, completion in enumerate(out.outputs):
445
+ text = completion.text
446
+ pred = extract_answer(text)
447
+ is_correct = pred == ex["gold"]
448
+
449
+ total += 1
450
+ correct += int(is_correct)
451
+ problem_correct += int(is_correct)
452
+
453
+ record = {
454
+ "idx": ex["idx"],
455
+ "sample_id": sample_id,
456
+ "gold": ex["gold"],
457
+ "gold_raw": str(ex["gold_raw"]),
458
+ "pred": pred,
459
+ "correct": is_correct,
460
+ "completion": text,
461
+ "finish_reason": completion.finish_reason,
462
+ "stop_reason": getattr(completion, "stop_reason", None),
463
+ }
464
+ sample_records.append(record)
465
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
466
+
467
+ problem_acc = problem_correct / max(1, len(out.outputs))
468
+ per_problem_records.append(problem_acc)
469
+
470
+ if ex["idx"] < args.print_examples:
471
+ print("-" * 80)
472
+ print(f"Problem {ex['idx']}")
473
+ print(f"Gold: {ex['gold']} | Correct samples: {problem_correct}/{len(out.outputs)}")
474
+ print(f"First pred: {sample_records[0]['pred']}")
475
+ print(f"First completion preview:\n{sample_records[0]['completion'][:1000]}")
476
+
477
+ avg_pass1_micro = correct / total if total > 0 else 0.0
478
+ avg_pass1_macro = sum(per_problem_records) / len(per_problem_records) if per_problem_records else 0.0
479
+
480
+ print("=" * 80)
481
+ print("Final results")
482
+ print("=" * 80)
483
+ print(f"Problems: {len(examples)}")
484
+ print(f"Samples per problem: {args.n_samples}")
485
+ print(f"Total samples: {total}")
486
+ print(f"Correct samples: {correct}")
487
+ print(f"Average pass@1 micro: {avg_pass1_micro:.6f} ({100 * avg_pass1_micro:.2f}%)")
488
+ print(f"Average pass@1 macro: {avg_pass1_macro:.6f} ({100 * avg_pass1_macro:.2f}%)")
489
+ print(f"Saved generations to: {output_path}")
490
+ print("=" * 80)
491
+
492
+
493
+ if __name__ == "__main__":
494
+ main()
tools/eval_aime2025_vllm.py ADDED
@@ -0,0 +1,488 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """
3
+ Evaluate a HF-format model on AIME 2025 using vLLM.
4
+
5
+ Example:
6
+
7
+ CUDA_VISIBLE_DEVICES=0,1,2,3 python tools/eval_aime_vllm.py \
8
+ --model /workspace/Lightning-OPD/checkpoints/qwen3-4b-lightning-opd-hf \
9
+ --num-gpus 4 \
10
+ --output outputs/eval_aime_2025_qwen3_4b_lightning_opd.jsonl
11
+
12
+ Paper-style AIME setting:
13
+ temperature = 0.6
14
+ top_p = 0.95
15
+ max_tokens = 32768
16
+ n_samples = 32
17
+ metric = average pass@1
18
+ """
19
+
20
+ import argparse
21
+ import json
22
+ import os
23
+ import re
24
+ from pathlib import Path
25
+ from typing import Any, Dict, List, Optional
26
+
27
+
28
+ def parse_args():
29
+ parser = argparse.ArgumentParser()
30
+
31
+ # Model / hardware
32
+ parser.add_argument(
33
+ "--model",
34
+ type=str,
35
+ required=True,
36
+ help="Path to HF-format model checkpoint.",
37
+ )
38
+ parser.add_argument(
39
+ "--num-gpus",
40
+ type=int,
41
+ default=1,
42
+ help="Tensor parallel size for vLLM.",
43
+ )
44
+ parser.add_argument(
45
+ "--gpu-ids",
46
+ type=str,
47
+ default=None,
48
+ help='Optional CUDA_VISIBLE_DEVICES, e.g. "0,1,2,3". Must match --num-gpus.',
49
+ )
50
+ parser.add_argument(
51
+ "--dtype",
52
+ type=str,
53
+ default="bfloat16",
54
+ choices=["auto", "float16", "bfloat16", "float32"],
55
+ )
56
+ parser.add_argument(
57
+ "--gpu-memory-utilization",
58
+ type=float,
59
+ default=0.90,
60
+ )
61
+ parser.add_argument(
62
+ "--trust-remote-code",
63
+ action="store_true",
64
+ default=True,
65
+ )
66
+
67
+ # Dataset
68
+ parser.add_argument(
69
+ "--dataset",
70
+ type=str,
71
+ default="MathArena/aime_2025",
72
+ help="HF dataset name for AIME 2025.",
73
+ )
74
+ parser.add_argument(
75
+ "--split",
76
+ type=str,
77
+ default=None,
78
+ help="Dataset split. If not set, use the first available split.",
79
+ )
80
+ parser.add_argument(
81
+ "--hf-cache",
82
+ type=str,
83
+ default=None,
84
+ help="Optional HuggingFace cache dir.",
85
+ )
86
+
87
+ # Generation settings from paper
88
+ parser.add_argument("--n-samples", type=int, default=32)
89
+ parser.add_argument("--temperature", type=float, default=0.6)
90
+ parser.add_argument("--top-p", type=float, default=0.95)
91
+ parser.add_argument("--max-tokens", type=int, default=32768)
92
+ parser.add_argument("--seed", type=int, default=42)
93
+
94
+ # Prompt / tokenizer behavior
95
+ parser.add_argument(
96
+ "--no-chat-template",
97
+ action="store_true",
98
+ help="Do not apply tokenizer chat template; use raw prompt.",
99
+ )
100
+ parser.add_argument(
101
+ "--enable-thinking",
102
+ action="store_true",
103
+ default=False,
104
+ help="Pass enable_thinking=True to Qwen3 chat template if supported.",
105
+ )
106
+ parser.add_argument(
107
+ "--disable-thinking",
108
+ action="store_true",
109
+ default=False,
110
+ help="Pass enable_thinking=False to Qwen3 chat template if supported.",
111
+ )
112
+ parser.add_argument(
113
+ "--assistant-prefix",
114
+ type=str,
115
+ default=None,
116
+ help=(
117
+ "Optional text appended after the assistant generation prompt. "
118
+ "Example for no-thinking style: '<think>\\n\\n</think>\\n\\n'"
119
+ ),
120
+ )
121
+ parser.add_argument(
122
+ "--prompt-template",
123
+ type=str,
124
+ default="train",
125
+ choices=["train", "paper", "chat"],
126
+ help=(
127
+ "Prompt template to use. "
128
+ "'train' uses the exact training ChatML prompt; "
129
+ "'paper' uses the paper-style ChatML prompt; "
130
+ "'chat' uses build_problem_prompt plus tokenizer.apply_chat_template."
131
+ ),
132
+ )
133
+
134
+ # Output / debug
135
+ parser.add_argument(
136
+ "--output",
137
+ type=str,
138
+ default="outputs/eval_aime_2025_vllm.jsonl",
139
+ help="Path to save per-sample generations and scores.",
140
+ )
141
+ parser.add_argument(
142
+ "--limit",
143
+ type=int,
144
+ default=None,
145
+ help="Limit number of problems for quick debugging.",
146
+ )
147
+ parser.add_argument(
148
+ "--print-examples",
149
+ type=int,
150
+ default=3,
151
+ help="Print first N examples with predictions.",
152
+ )
153
+
154
+ return parser.parse_args()
155
+
156
+
157
+ def normalize_answer(x: Any) -> Optional[str]:
158
+ if x is None:
159
+ return None
160
+
161
+ s = str(x).strip()
162
+ s = s.replace("$", "")
163
+ s = s.replace(",", "")
164
+ s = s.replace("\\,", "")
165
+ s = s.strip()
166
+
167
+ # Remove simple latex wrappers.
168
+ s = s.replace("\\text", "")
169
+ s = s.replace("\\mathrm", "")
170
+ s = s.strip("{}").strip()
171
+
172
+ # AIME answers are integers from 0 to 999.
173
+ m = re.search(r"-?\d+", s)
174
+ if m is None:
175
+ return None
176
+
177
+ try:
178
+ return str(int(m.group(0)))
179
+ except ValueError:
180
+ return None
181
+
182
+
183
+ def extract_last_boxed(text: str) -> Optional[str]:
184
+ """
185
+ Extract the last \\boxed{...}. Handles simple nested braces better than regex.
186
+ """
187
+ marker = r"\boxed{"
188
+ positions = [m.start() for m in re.finditer(re.escape(marker), text)]
189
+ if not positions:
190
+ return None
191
+
192
+ for start in reversed(positions):
193
+ i = start + len(marker)
194
+ depth = 1
195
+ chars = []
196
+
197
+ while i < len(text):
198
+ ch = text[i]
199
+ if ch == "{":
200
+ depth += 1
201
+ chars.append(ch)
202
+ elif ch == "}":
203
+ depth -= 1
204
+ if depth == 0:
205
+ return "".join(chars)
206
+ chars.append(ch)
207
+ else:
208
+ chars.append(ch)
209
+ i += 1
210
+
211
+ return None
212
+
213
+
214
+ def extract_answer(text: str) -> Optional[str]:
215
+ # Prefer boxed answer, because the actual training prompt asks for \boxed{$Answer}.
216
+ boxed = extract_last_boxed(text)
217
+ if boxed is not None:
218
+ ans = normalize_answer(boxed)
219
+ if ans is not None:
220
+ return ans
221
+
222
+ # Fallback: final Answer: line.
223
+ matches = re.findall(r"Answer:\s*([^\n]+)", text, flags=re.IGNORECASE)
224
+ if matches:
225
+ ans = normalize_answer(matches[-1])
226
+ if ans is not None:
227
+ return ans
228
+
229
+ # No valid final answer.
230
+ return None
231
+
232
+ def build_exact_training_prompt(problem: str) -> str:
233
+ return (
234
+ "<|im_start|>user\n"
235
+ "Solve the following math problem step by step. "
236
+ "The last line of your response should be of the form "
237
+ "Answer: \\boxed{$Answer} where $Answer is the answer to the problem.\n\n"
238
+ f"{problem}\n\n"
239
+ "Remember to put your answer on its own line after \"Answer:\"."
240
+ "<|im_end|>\n"
241
+ "<|im_start|>assistant\n\n\n\n"
242
+ )
243
+
244
+ def build_problem_prompt(problem: str) -> str:
245
+ return (
246
+ f"{problem}\n\n"
247
+ "Please reason step by step, and put your final answer within \\boxed{}."
248
+ )
249
+
250
+ def build_paper_math_eval_prompt(problem: str) -> str:
251
+ return (
252
+ "<|im_start|>user\n"
253
+ f"Question: {problem}\n"
254
+ "Please reason step by step, and put your final answer within \\boxed{}.\n"
255
+ "<|im_end|>\n"
256
+ "<|im_start|>assistant\n"
257
+ )
258
+
259
+ def get_field(ex: Dict[str, Any], candidates: List[str]) -> Any:
260
+ for key in candidates:
261
+ if key in ex and ex[key] is not None:
262
+ return ex[key]
263
+ raise KeyError(f"Cannot find any of {candidates}. Example keys: {list(ex.keys())}")
264
+
265
+ # def build_problem_prompt(problem: str) -> str:
266
+ # return (
267
+ # "Solve the following math problem step by step. "
268
+ # "The last line of your response should be of the form "
269
+ # "Answer: $Answer (without quotes) where $Answer is the answer to the problem.\n\n"
270
+ # f"{problem}\n\n"
271
+ # 'Remember to put your answer on its own line after "Answer:".'
272
+ # )
273
+
274
+ def apply_chat_template(tokenizer, prompt: str, args) -> str:
275
+ if args.no_chat_template:
276
+ return prompt
277
+
278
+ messages = [{"role": "user", "content": prompt}]
279
+
280
+ kwargs = {
281
+ "tokenize": False,
282
+ "add_generation_prompt": True,
283
+ }
284
+
285
+ # Qwen3 tokenizer may support enable_thinking.
286
+ if args.enable_thinking and args.disable_thinking:
287
+ raise ValueError("Do not set both --enable-thinking and --disable-thinking.")
288
+
289
+ if args.enable_thinking:
290
+ kwargs["enable_thinking"] = True
291
+ elif args.disable_thinking:
292
+ kwargs["enable_thinking"] = False
293
+
294
+ try:
295
+ text = tokenizer.apply_chat_template(messages, **kwargs)
296
+ except TypeError:
297
+ # Some tokenizers do not accept enable_thinking.
298
+ kwargs.pop("enable_thinking", None)
299
+ text = tokenizer.apply_chat_template(messages, **kwargs)
300
+
301
+ if args.assistant_prefix is not None:
302
+ text += args.assistant_prefix.encode("utf-8").decode("unicode_escape")
303
+
304
+ return text
305
+
306
+
307
+ def main():
308
+ args = parse_args()
309
+
310
+ if args.gpu_ids is not None:
311
+ os.environ["CUDA_VISIBLE_DEVICES"] = args.gpu_ids
312
+
313
+ if args.hf_cache is not None:
314
+ os.environ["HF_HOME"] = args.hf_cache
315
+ os.environ["HF_DATASETS_CACHE"] = str(Path(args.hf_cache) / "datasets")
316
+ os.environ["HF_HUB_CACHE"] = str(Path(args.hf_cache) / "hub")
317
+
318
+ # Import after CUDA_VISIBLE_DEVICES is set.
319
+ from datasets import load_dataset
320
+ from transformers import AutoTokenizer
321
+ from vllm import LLM, SamplingParams
322
+
323
+ output_path = Path(args.output)
324
+ output_path.parent.mkdir(parents=True, exist_ok=True)
325
+
326
+ print("=" * 80)
327
+ print("AIME 2025 vLLM Evaluation")
328
+ print("=" * 80)
329
+ print(f"model: {args.model}")
330
+ print(f"dataset: {args.dataset}")
331
+ print(f"num_gpus / TP size: {args.num_gpus}")
332
+ print(f"CUDA_VISIBLE_DEVICES: {os.environ.get('CUDA_VISIBLE_DEVICES')}")
333
+ print(f"n_samples/problem: {args.n_samples}")
334
+ print(f"temperature: {args.temperature}")
335
+ print(f"top_p: {args.top_p}")
336
+ print(f"max_tokens: {args.max_tokens}")
337
+ print(f"prompt_template: {args.prompt_template}")
338
+ print(f"assistant_prefix: {repr(args.assistant_prefix)}")
339
+ print(f"output: {args.output}")
340
+ print("=" * 80)
341
+
342
+ tokenizer = AutoTokenizer.from_pretrained(
343
+ args.model,
344
+ trust_remote_code=args.trust_remote_code,
345
+ )
346
+
347
+ # Load HF dataset or local JSON/JSONL.
348
+ if args.dataset.endswith(".json") or args.dataset.endswith(".jsonl"):
349
+ dataset_dict = load_dataset("json", data_files=args.dataset)
350
+ else:
351
+ dataset_dict = load_dataset(args.dataset)
352
+
353
+ split = args.split or list(dataset_dict.keys())[0]
354
+ data = dataset_dict[split]
355
+
356
+ assert len(data) == 30, f"Expected 30 AIME 2025 problems, got {len(data)}"
357
+
358
+ print(f"Loaded AIME 2025 examples: {len(data)}")
359
+ for i in range(min(3, len(data))):
360
+ print(i, {k: data[i].get(k, None) for k in data.column_names[:6]})
361
+
362
+ if args.limit is not None:
363
+ data = data.select(range(min(args.limit, len(data))))
364
+
365
+ print(f"Loaded split: {split}")
366
+ print(f"Number of problems: {len(data)}")
367
+ print(f"First example keys: {list(data[0].keys())}")
368
+
369
+ prompts = []
370
+ examples = []
371
+
372
+ for idx, ex in enumerate(data):
373
+ problem = get_field(ex, ["problem", "question", "prompt"])
374
+ gold_raw = get_field(ex, ["answer", "final_answer", "target", "solution"])
375
+ gold = normalize_answer(gold_raw)
376
+
377
+ if args.prompt_template == "train":
378
+ # Raw ChatML prompt matching the OPD training parquet.
379
+ # Do NOT call apply_chat_template again, otherwise ChatML will be nested.
380
+ full_prompt = build_exact_training_prompt(problem)
381
+ elif args.prompt_template == "paper":
382
+ # Raw ChatML prompt matching the paper-style math evaluation prompt.
383
+ # Do NOT call apply_chat_template again.
384
+ full_prompt = build_paper_math_eval_prompt(problem)
385
+ elif args.prompt_template == "chat":
386
+ # Normal user-content prompt; tokenizer will add ChatML.
387
+ raw_prompt = build_problem_prompt(problem)
388
+ full_prompt = apply_chat_template(tokenizer, raw_prompt, args)
389
+ else:
390
+ raise ValueError(f"Unknown prompt_template: {args.prompt_template}")
391
+
392
+ # Important: when using raw ChatML prompts, apply_chat_template() is bypassed.
393
+ # Therefore --assistant-prefix must be appended here, not only inside apply_chat_template().
394
+ # This is useful for Qwen3 no-thinking style, e.g.:
395
+ # --assistant-prefix '<think>\\n\\n</think>\\n\\n'
396
+ if args.prompt_template in {"train", "paper"} and args.assistant_prefix is not None:
397
+ full_prompt += args.assistant_prefix.encode("utf-8").decode("unicode_escape")
398
+
399
+ prompts.append(full_prompt)
400
+ examples.append(
401
+ {
402
+ "idx": idx,
403
+ "problem": problem,
404
+ "gold_raw": gold_raw,
405
+ "gold": gold,
406
+ "prompt": full_prompt,
407
+ }
408
+ )
409
+
410
+ llm = LLM(
411
+ model=args.model,
412
+ tensor_parallel_size=args.num_gpus,
413
+ dtype=args.dtype,
414
+ trust_remote_code=args.trust_remote_code,
415
+ gpu_memory_utilization=args.gpu_memory_utilization,
416
+ seed=args.seed,
417
+ )
418
+
419
+ sampling_params = SamplingParams(
420
+ n=args.n_samples,
421
+ temperature=args.temperature,
422
+ top_p=args.top_p,
423
+ max_tokens=args.max_tokens,
424
+ stop=["<|im_end|>"],
425
+ )
426
+
427
+ outputs = llm.generate(prompts, sampling_params)
428
+
429
+ total = 0
430
+ correct = 0
431
+ per_problem_records = []
432
+
433
+ with output_path.open("w", encoding="utf-8") as f:
434
+ for ex, out in zip(examples, outputs):
435
+ sample_records = []
436
+ problem_correct = 0
437
+
438
+ for sample_id, completion in enumerate(out.outputs):
439
+ text = completion.text
440
+ pred = extract_answer(text)
441
+ is_correct = pred == ex["gold"]
442
+
443
+ total += 1
444
+ correct += int(is_correct)
445
+ problem_correct += int(is_correct)
446
+
447
+ record = {
448
+ "idx": ex["idx"],
449
+ "sample_id": sample_id,
450
+ "gold": ex["gold"],
451
+ "gold_raw": str(ex["gold_raw"]),
452
+ "pred": pred,
453
+ "correct": is_correct,
454
+ "completion": text,
455
+ "finish_reason": completion.finish_reason,
456
+ "stop_reason": getattr(completion, "stop_reason", None),
457
+ }
458
+ sample_records.append(record)
459
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
460
+
461
+ problem_acc = problem_correct / max(1, len(out.outputs))
462
+ per_problem_records.append(problem_acc)
463
+
464
+ if ex["idx"] < args.print_examples:
465
+ print("-" * 80)
466
+ print(f"Problem {ex['idx']}")
467
+ print(f"Gold: {ex['gold']} | Correct samples: {problem_correct}/{len(out.outputs)}")
468
+ print(f"First pred: {sample_records[0]['pred']}")
469
+ print(f"First completion preview:\n{sample_records[0]['completion'][:1000]}")
470
+
471
+ avg_pass1_micro = correct / total if total > 0 else 0.0
472
+ avg_pass1_macro = sum(per_problem_records) / len(per_problem_records) if per_problem_records else 0.0
473
+
474
+ print("=" * 80)
475
+ print("Final results")
476
+ print("=" * 80)
477
+ print(f"Problems: {len(examples)}")
478
+ print(f"Samples per problem: {args.n_samples}")
479
+ print(f"Total samples: {total}")
480
+ print(f"Correct samples: {correct}")
481
+ print(f"Average pass@1 micro: {avg_pass1_micro:.6f} ({100 * avg_pass1_micro:.2f}%)")
482
+ print(f"Average pass@1 macro: {avg_pass1_macro:.6f} ({100 * avg_pass1_macro:.2f}%)")
483
+ print(f"Saved generations to: {output_path}")
484
+ print("=" * 80)
485
+
486
+
487
+ if __name__ == "__main__":
488
+ main()
tools/eval_hmmt2025_vllm.py ADDED
@@ -0,0 +1,486 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """
3
+ Evaluate a HF-format model on HMMT Feb 2025 using vLLM.
4
+
5
+ Example:
6
+
7
+ CUDA_VISIBLE_DEVICES=0,1,2,3 python tools/eval_aime_vllm.py \
8
+ --model /workspace/Lightning-OPD/checkpoints/qwen3-4b-lightning-opd-hf \
9
+ --num-gpus 4 \
10
+ --output outputs/eval_hmmt_feb_2025_qwen3_4b_lightning_opd.jsonl
11
+
12
+ Paper-style AIME setting:
13
+ temperature = 0.6
14
+ top_p = 0.95
15
+ max_tokens = 32768
16
+ n_samples = 32
17
+ metric = average pass@1
18
+ """
19
+
20
+ import argparse
21
+ import json
22
+ import os
23
+ import re
24
+ from pathlib import Path
25
+ from typing import Any, Dict, List, Optional
26
+
27
+
28
+ def parse_args():
29
+ parser = argparse.ArgumentParser()
30
+
31
+ # Model / hardware
32
+ parser.add_argument(
33
+ "--model",
34
+ type=str,
35
+ required=True,
36
+ help="Path to HF-format model checkpoint.",
37
+ )
38
+ parser.add_argument(
39
+ "--num-gpus",
40
+ type=int,
41
+ default=1,
42
+ help="Tensor parallel size for vLLM.",
43
+ )
44
+ parser.add_argument(
45
+ "--gpu-ids",
46
+ type=str,
47
+ default=None,
48
+ help='Optional CUDA_VISIBLE_DEVICES, e.g. "0,1,2,3". Must match --num-gpus.',
49
+ )
50
+ parser.add_argument(
51
+ "--dtype",
52
+ type=str,
53
+ default="bfloat16",
54
+ choices=["auto", "float16", "bfloat16", "float32"],
55
+ )
56
+ parser.add_argument(
57
+ "--gpu-memory-utilization",
58
+ type=float,
59
+ default=0.90,
60
+ )
61
+ parser.add_argument(
62
+ "--trust-remote-code",
63
+ action="store_true",
64
+ default=True,
65
+ )
66
+
67
+ # Dataset
68
+ parser.add_argument(
69
+ "--dataset",
70
+ type=str,
71
+ default="MathArena/hmmt_feb_2025",
72
+ help="HF dataset name for HMMT February 2025.",
73
+ )
74
+ parser.add_argument(
75
+ "--split",
76
+ type=str,
77
+ default=None,
78
+ help="Dataset split. If not set, use the first available split.",
79
+ )
80
+ parser.add_argument(
81
+ "--hf-cache",
82
+ type=str,
83
+ default=None,
84
+ help="Optional HuggingFace cache dir.",
85
+ )
86
+
87
+ # Generation settings from paper
88
+ parser.add_argument("--n-samples", type=int, default=32)
89
+ parser.add_argument("--temperature", type=float, default=0.6)
90
+ parser.add_argument("--top-p", type=float, default=0.95)
91
+ parser.add_argument("--max-tokens", type=int, default=32768)
92
+ parser.add_argument("--seed", type=int, default=42)
93
+
94
+ # Prompt / tokenizer behavior
95
+ parser.add_argument(
96
+ "--no-chat-template",
97
+ action="store_true",
98
+ help="Do not apply tokenizer chat template; use raw prompt.",
99
+ )
100
+ parser.add_argument(
101
+ "--enable-thinking",
102
+ action="store_true",
103
+ default=False,
104
+ help="Pass enable_thinking=True to Qwen3 chat template if supported.",
105
+ )
106
+ parser.add_argument(
107
+ "--disable-thinking",
108
+ action="store_true",
109
+ default=False,
110
+ help="Pass enable_thinking=False to Qwen3 chat template if supported.",
111
+ )
112
+ parser.add_argument(
113
+ "--assistant-prefix",
114
+ type=str,
115
+ default=None,
116
+ help=(
117
+ "Optional text appended after the assistant generation prompt. "
118
+ "Example for no-thinking style: '<think>\\n\\n</think>\\n\\n'"
119
+ ),
120
+ )
121
+ parser.add_argument(
122
+ "--prompt-template",
123
+ type=str,
124
+ default="train",
125
+ choices=["train", "paper", "chat"],
126
+ help=(
127
+ "Prompt template to use. "
128
+ "'train' uses the exact training ChatML prompt; "
129
+ "'paper' uses the paper-style ChatML prompt; "
130
+ "'chat' uses build_problem_prompt plus tokenizer.apply_chat_template."
131
+ ),
132
+ )
133
+
134
+ # Output / debug
135
+ parser.add_argument(
136
+ "--output",
137
+ type=str,
138
+ default="outputs/eval_hmmt_feb_2025_vllm.jsonl",
139
+ help="Path to save per-sample generations and scores.",
140
+ )
141
+ parser.add_argument(
142
+ "--limit",
143
+ type=int,
144
+ default=None,
145
+ help="Limit number of problems for quick debugging.",
146
+ )
147
+ parser.add_argument(
148
+ "--print-examples",
149
+ type=int,
150
+ default=3,
151
+ help="Print first N examples with predictions.",
152
+ )
153
+
154
+ return parser.parse_args()
155
+
156
+
157
+ def normalize_answer(x: Any) -> Optional[str]:
158
+ if x is None:
159
+ return None
160
+
161
+ s = str(x).strip()
162
+ s = s.replace("$", "")
163
+ s = s.replace(",", "")
164
+ s = s.replace("\\,", "")
165
+ s = s.strip()
166
+
167
+ # Remove simple latex wrappers.
168
+ s = s.replace("\\text", "")
169
+ s = s.replace("\\mathrm", "")
170
+ s = s.strip("{}").strip()
171
+
172
+ # AIME answers are integers from 0 to 999.
173
+ m = re.search(r"-?\d+", s)
174
+ if m is None:
175
+ return None
176
+
177
+ try:
178
+ return str(int(m.group(0)))
179
+ except ValueError:
180
+ return None
181
+
182
+
183
+ def extract_last_boxed(text: str) -> Optional[str]:
184
+ """
185
+ Extract the last \\boxed{...}. Handles simple nested braces better than regex.
186
+ """
187
+ marker = r"\boxed{"
188
+ positions = [m.start() for m in re.finditer(re.escape(marker), text)]
189
+ if not positions:
190
+ return None
191
+
192
+ for start in reversed(positions):
193
+ i = start + len(marker)
194
+ depth = 1
195
+ chars = []
196
+
197
+ while i < len(text):
198
+ ch = text[i]
199
+ if ch == "{":
200
+ depth += 1
201
+ chars.append(ch)
202
+ elif ch == "}":
203
+ depth -= 1
204
+ if depth == 0:
205
+ return "".join(chars)
206
+ chars.append(ch)
207
+ else:
208
+ chars.append(ch)
209
+ i += 1
210
+
211
+ return None
212
+
213
+
214
+ def extract_answer(text: str) -> Optional[str]:
215
+ # Prefer boxed answer, because the actual training prompt asks for \boxed{$Answer}.
216
+ boxed = extract_last_boxed(text)
217
+ if boxed is not None:
218
+ ans = normalize_answer(boxed)
219
+ if ans is not None:
220
+ return ans
221
+
222
+ # Fallback: final Answer: line.
223
+ matches = re.findall(r"Answer:\s*([^\n]+)", text, flags=re.IGNORECASE)
224
+ if matches:
225
+ ans = normalize_answer(matches[-1])
226
+ if ans is not None:
227
+ return ans
228
+
229
+ # No valid final answer.
230
+ return None
231
+
232
+ def build_exact_training_prompt(problem: str) -> str:
233
+ return (
234
+ "<|im_start|>user\n"
235
+ "Solve the following math problem step by step. "
236
+ "The last line of your response should be of the form "
237
+ "Answer: \\boxed{$Answer} where $Answer is the answer to the problem.\n\n"
238
+ f"{problem}\n\n"
239
+ "Remember to put your answer on its own line after \"Answer:\"."
240
+ "<|im_end|>\n"
241
+ "<|im_start|>assistant\n\n\n\n"
242
+ )
243
+
244
+ def build_problem_prompt(problem: str) -> str:
245
+ return (
246
+ f"{problem}\n\n"
247
+ "Please reason step by step, and put your final answer within \\boxed{}."
248
+ )
249
+
250
+ def build_paper_math_eval_prompt(problem: str) -> str:
251
+ return (
252
+ "<|im_start|>user\n"
253
+ f"Question: {problem}\n"
254
+ "Please reason step by step, and put your final answer within \\boxed{}.\n"
255
+ "<|im_end|>\n"
256
+ "<|im_start|>assistant\n"
257
+ )
258
+
259
+ def get_field(ex: Dict[str, Any], candidates: List[str]) -> Any:
260
+ for key in candidates:
261
+ if key in ex and ex[key] is not None:
262
+ return ex[key]
263
+ raise KeyError(f"Cannot find any of {candidates}. Example keys: {list(ex.keys())}")
264
+
265
+ # def build_problem_prompt(problem: str) -> str:
266
+ # return (
267
+ # "Solve the following math problem step by step. "
268
+ # "The last line of your response should be of the form "
269
+ # "Answer: $Answer (without quotes) where $Answer is the answer to the problem.\n\n"
270
+ # f"{problem}\n\n"
271
+ # 'Remember to put your answer on its own line after "Answer:".'
272
+ # )
273
+
274
+ def apply_chat_template(tokenizer, prompt: str, args) -> str:
275
+ if args.no_chat_template:
276
+ return prompt
277
+
278
+ messages = [{"role": "user", "content": prompt}]
279
+
280
+ kwargs = {
281
+ "tokenize": False,
282
+ "add_generation_prompt": True,
283
+ }
284
+
285
+ # Qwen3 tokenizer may support enable_thinking.
286
+ if args.enable_thinking and args.disable_thinking:
287
+ raise ValueError("Do not set both --enable-thinking and --disable-thinking.")
288
+
289
+ if args.enable_thinking:
290
+ kwargs["enable_thinking"] = True
291
+ elif args.disable_thinking:
292
+ kwargs["enable_thinking"] = False
293
+
294
+ try:
295
+ text = tokenizer.apply_chat_template(messages, **kwargs)
296
+ except TypeError:
297
+ # Some tokenizers do not accept enable_thinking.
298
+ kwargs.pop("enable_thinking", None)
299
+ text = tokenizer.apply_chat_template(messages, **kwargs)
300
+
301
+ if args.assistant_prefix is not None:
302
+ text += args.assistant_prefix.encode("utf-8").decode("unicode_escape")
303
+
304
+ return text
305
+
306
+
307
+ def main():
308
+ args = parse_args()
309
+
310
+ if args.gpu_ids is not None:
311
+ os.environ["CUDA_VISIBLE_DEVICES"] = args.gpu_ids
312
+
313
+ if args.hf_cache is not None:
314
+ os.environ["HF_HOME"] = args.hf_cache
315
+ os.environ["HF_DATASETS_CACHE"] = str(Path(args.hf_cache) / "datasets")
316
+ os.environ["HF_HUB_CACHE"] = str(Path(args.hf_cache) / "hub")
317
+
318
+ # Import after CUDA_VISIBLE_DEVICES is set.
319
+ from datasets import load_dataset
320
+ from transformers import AutoTokenizer
321
+ from vllm import LLM, SamplingParams
322
+
323
+ output_path = Path(args.output)
324
+ output_path.parent.mkdir(parents=True, exist_ok=True)
325
+
326
+ print("=" * 80)
327
+ print("HMMT Feb 2025 vLLM Evaluation")
328
+ print("=" * 80)
329
+ print(f"model: {args.model}")
330
+ print(f"dataset: {args.dataset}")
331
+ print(f"num_gpus / TP size: {args.num_gpus}")
332
+ print(f"CUDA_VISIBLE_DEVICES: {os.environ.get('CUDA_VISIBLE_DEVICES')}")
333
+ print(f"n_samples/problem: {args.n_samples}")
334
+ print(f"temperature: {args.temperature}")
335
+ print(f"top_p: {args.top_p}")
336
+ print(f"max_tokens: {args.max_tokens}")
337
+ print(f"prompt_template: {args.prompt_template}")
338
+ print(f"assistant_prefix: {repr(args.assistant_prefix)}")
339
+ print(f"output: {args.output}")
340
+ print("=" * 80)
341
+
342
+ tokenizer = AutoTokenizer.from_pretrained(
343
+ args.model,
344
+ trust_remote_code=args.trust_remote_code,
345
+ )
346
+
347
+ # Load HF dataset or local JSON/JSONL.
348
+ if args.dataset.endswith(".json") or args.dataset.endswith(".jsonl"):
349
+ dataset_dict = load_dataset("json", data_files=args.dataset)
350
+ else:
351
+ dataset_dict = load_dataset(args.dataset)
352
+
353
+ split = args.split or list(dataset_dict.keys())[0]
354
+ data = dataset_dict[split]
355
+
356
+ print(f"Loaded HMMT Feb 2025 examples: {len(data)}")
357
+ for i in range(min(3, len(data))):
358
+ print(i, {k: data[i].get(k, None) for k in data.column_names[:6]})
359
+
360
+ if args.limit is not None:
361
+ data = data.select(range(min(args.limit, len(data))))
362
+
363
+ print(f"Loaded split: {split}")
364
+ print(f"Number of problems: {len(data)}")
365
+ print(f"First example keys: {list(data[0].keys())}")
366
+
367
+ prompts = []
368
+ examples = []
369
+
370
+ for idx, ex in enumerate(data):
371
+ problem = get_field(ex, ["problem", "question", "prompt"])
372
+ gold_raw = get_field(ex, ["answer", "final_answer", "target", "solution"])
373
+ gold = normalize_answer(gold_raw)
374
+
375
+ if args.prompt_template == "train":
376
+ # Raw ChatML prompt matching the OPD training parquet.
377
+ # Do NOT call apply_chat_template again, otherwise ChatML will be nested.
378
+ full_prompt = build_exact_training_prompt(problem)
379
+ elif args.prompt_template == "paper":
380
+ # Raw ChatML prompt matching the paper-style math evaluation prompt.
381
+ # Do NOT call apply_chat_template again.
382
+ full_prompt = build_paper_math_eval_prompt(problem)
383
+ elif args.prompt_template == "chat":
384
+ # Normal user-content prompt; tokenizer will add ChatML.
385
+ raw_prompt = build_problem_prompt(problem)
386
+ full_prompt = apply_chat_template(tokenizer, raw_prompt, args)
387
+ else:
388
+ raise ValueError(f"Unknown prompt_template: {args.prompt_template}")
389
+
390
+ # Important: when using raw ChatML prompts, apply_chat_template() is bypassed.
391
+ # Therefore --assistant-prefix must be appended here, not only inside apply_chat_template().
392
+ # This is useful for Qwen3 no-thinking style, e.g.:
393
+ # --assistant-prefix '<think>\\n\\n</think>\\n\\n'
394
+ if args.prompt_template in {"train", "paper"} and args.assistant_prefix is not None:
395
+ full_prompt += args.assistant_prefix.encode("utf-8").decode("unicode_escape")
396
+
397
+ prompts.append(full_prompt)
398
+ examples.append(
399
+ {
400
+ "idx": idx,
401
+ "problem": problem,
402
+ "gold_raw": gold_raw,
403
+ "gold": gold,
404
+ "prompt": full_prompt,
405
+ }
406
+ )
407
+
408
+ llm = LLM(
409
+ model=args.model,
410
+ tensor_parallel_size=args.num_gpus,
411
+ dtype=args.dtype,
412
+ trust_remote_code=args.trust_remote_code,
413
+ gpu_memory_utilization=args.gpu_memory_utilization,
414
+ seed=args.seed,
415
+ )
416
+
417
+ sampling_params = SamplingParams(
418
+ n=args.n_samples,
419
+ temperature=args.temperature,
420
+ top_p=args.top_p,
421
+ max_tokens=args.max_tokens,
422
+ stop=["<|im_end|>"],
423
+ )
424
+
425
+ outputs = llm.generate(prompts, sampling_params)
426
+
427
+ total = 0
428
+ correct = 0
429
+ per_problem_records = []
430
+
431
+ with output_path.open("w", encoding="utf-8") as f:
432
+ for ex, out in zip(examples, outputs):
433
+ sample_records = []
434
+ problem_correct = 0
435
+
436
+ for sample_id, completion in enumerate(out.outputs):
437
+ text = completion.text
438
+ pred = extract_answer(text)
439
+ is_correct = pred == ex["gold"]
440
+
441
+ total += 1
442
+ correct += int(is_correct)
443
+ problem_correct += int(is_correct)
444
+
445
+ record = {
446
+ "idx": ex["idx"],
447
+ "sample_id": sample_id,
448
+ "gold": ex["gold"],
449
+ "gold_raw": str(ex["gold_raw"]),
450
+ "pred": pred,
451
+ "correct": is_correct,
452
+ "completion": text,
453
+ "finish_reason": completion.finish_reason,
454
+ "stop_reason": getattr(completion, "stop_reason", None),
455
+ }
456
+ sample_records.append(record)
457
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
458
+
459
+ problem_acc = problem_correct / max(1, len(out.outputs))
460
+ per_problem_records.append(problem_acc)
461
+
462
+ if ex["idx"] < args.print_examples:
463
+ print("-" * 80)
464
+ print(f"Problem {ex['idx']}")
465
+ print(f"Gold: {ex['gold']} | Correct samples: {problem_correct}/{len(out.outputs)}")
466
+ print(f"First pred: {sample_records[0]['pred']}")
467
+ print(f"First completion preview:\n{sample_records[0]['completion'][:1000]}")
468
+
469
+ avg_pass1_micro = correct / total if total > 0 else 0.0
470
+ avg_pass1_macro = sum(per_problem_records) / len(per_problem_records) if per_problem_records else 0.0
471
+
472
+ print("=" * 80)
473
+ print("Final results")
474
+ print("=" * 80)
475
+ print(f"Problems: {len(examples)}")
476
+ print(f"Samples per problem: {args.n_samples}")
477
+ print(f"Total samples: {total}")
478
+ print(f"Correct samples: {correct}")
479
+ print(f"Average pass@1 micro: {avg_pass1_micro:.6f} ({100 * avg_pass1_micro:.2f}%)")
480
+ print(f"Average pass@1 macro: {avg_pass1_macro:.6f} ({100 * avg_pass1_macro:.2f}%)")
481
+ print(f"Saved generations to: {output_path}")
482
+ print("=" * 80)
483
+
484
+
485
+ if __name__ == "__main__":
486
+ main()
tools/fp8_cast_bf16.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ # Adapt from https://github.com/alibaba/Pai-Megatron-Patch/blob/2b201af08336dea0403df7c6b497c964cf5a2e75/toolkits/model_checkpoints_convertor/deepseek/fp8_cast_bf16.py
5
+ import json
6
+ import os
7
+ from argparse import ArgumentParser
8
+ from glob import glob
9
+
10
+ import torch
11
+ import triton
12
+ import triton.language as tl
13
+ from safetensors.torch import load_file, save_file
14
+ from tqdm import tqdm
15
+
16
+
17
+ @triton.jit
18
+ def weight_dequant_kernel(x_ptr, s_ptr, y_ptr, M, N, BLOCK_SIZE: tl.constexpr):
19
+ pid_m = tl.program_id(axis=0)
20
+ pid_n = tl.program_id(axis=1)
21
+ n = tl.cdiv(N, BLOCK_SIZE)
22
+ offs_m = pid_m * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
23
+ offs_n = pid_n * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
24
+ offs = offs_m[:, None] * N + offs_n[None, :]
25
+ mask = (offs_m[:, None] < M) & (offs_n[None, :] < N)
26
+ x = tl.load(x_ptr + offs, mask=mask).to(tl.float32)
27
+ s = tl.load(s_ptr + pid_m * n + pid_n)
28
+ y = x * s
29
+ tl.store(y_ptr + offs, y, mask=mask)
30
+
31
+
32
+ def weight_dequant(x: torch.Tensor, s: torch.Tensor, block_size: int = 128) -> torch.Tensor:
33
+ assert x.is_contiguous() and s.is_contiguous()
34
+ assert x.dim() == 2 and s.dim() == 2
35
+ M, N = x.size()
36
+ y = torch.empty_like(x, dtype=torch.get_default_dtype())
37
+
38
+ def grid(meta):
39
+ return (triton.cdiv(M, meta["BLOCK_SIZE"]), triton.cdiv(N, meta["BLOCK_SIZE"]))
40
+
41
+ weight_dequant_kernel[grid](x, s, y, M, N, BLOCK_SIZE=block_size)
42
+ return y
43
+
44
+
45
+ def main(fp8_path, bf16_path):
46
+ torch.set_default_dtype(torch.bfloat16)
47
+ os.makedirs(bf16_path, exist_ok=True)
48
+ os.system("cp -rf " + fp8_path + "/config.json " + bf16_path)
49
+ os.system("cp -rf " + fp8_path + "/*.py " + bf16_path)
50
+ os.system("cp -rf " + fp8_path + "/tokenizer* " + bf16_path)
51
+ os.system("cp -rf " + fp8_path + "/chat_template* " + bf16_path)
52
+ model_index_file = os.path.join(fp8_path, "model.safetensors.index.json")
53
+ with open(model_index_file) as f:
54
+ model_index = json.load(f)
55
+ weight_map = model_index["weight_map"]
56
+
57
+ # Cache for loaded safetensor files
58
+ loaded_files = {}
59
+ fp8_weight_names = []
60
+
61
+ # Helper function to get tensor from the correct file
62
+ def get_tensor(tensor_name):
63
+ file_name = weight_map[tensor_name]
64
+ if file_name not in loaded_files:
65
+ file_path = os.path.join(fp8_path, file_name)
66
+ loaded_files[file_name] = load_file(file_path, device="cuda")
67
+ return loaded_files[file_name][tensor_name]
68
+
69
+ safetensor_files = list(glob(os.path.join(fp8_path, "*.safetensors")))
70
+ safetensor_files.sort()
71
+ for safetensor_file in tqdm(safetensor_files):
72
+ print(f"Handling file: {safetensor_file}")
73
+ file_name = os.path.basename(safetensor_file)
74
+ current_state_dict = load_file(safetensor_file, device="cuda")
75
+ loaded_files[file_name] = current_state_dict
76
+
77
+ new_state_dict = {}
78
+ for weight_name, weight in current_state_dict.items():
79
+ if weight_name.endswith("_scale_inv"):
80
+ continue
81
+ elif weight.element_size() == 1: # FP8 weight
82
+ scale_inv_name = f"{weight_name}_scale_inv"
83
+ try:
84
+ # Get scale_inv from the correct file
85
+ scale_inv = get_tensor(scale_inv_name)
86
+ fp8_weight_names.append(weight_name)
87
+ new_state_dict[weight_name] = weight_dequant(weight, scale_inv)
88
+ except KeyError:
89
+ print(f"Warning: Missing scale_inv tensor for {weight_name}, skipping conversion")
90
+ new_state_dict[weight_name] = weight
91
+ else:
92
+ new_state_dict[weight_name] = weight
93
+
94
+ new_safetensor_file = os.path.join(bf16_path, file_name)
95
+ save_file(new_state_dict, new_safetensor_file)
96
+
97
+ # Memory management: keep only the 2 most recently used files
98
+ if len(loaded_files) > 2:
99
+ oldest_file = next(iter(loaded_files))
100
+ del loaded_files[oldest_file]
101
+ torch.cuda.empty_cache()
102
+
103
+ # Update model index
104
+ new_model_index_file = os.path.join(bf16_path, "model.safetensors.index.json")
105
+ for weight_name in fp8_weight_names:
106
+ scale_inv_name = f"{weight_name}_scale_inv"
107
+ if scale_inv_name in weight_map:
108
+ weight_map.pop(scale_inv_name)
109
+ with open(new_model_index_file, "w") as f:
110
+ json.dump({"metadata": {}, "weight_map": weight_map}, f, indent=2)
111
+
112
+
113
+ if __name__ == "__main__":
114
+ parser = ArgumentParser()
115
+ parser.add_argument("--input-fp8-hf-path", type=str, required=True)
116
+ parser.add_argument("--output-bf16-hf-path", type=str, required=True)
117
+ args = parser.parse_args()
118
+ main(args.input_fp8_hf_path, args.output_bf16_hf_path)
tools/merge_poe_lora.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import torch
3
+ from transformers import AutoModelForCausalLM, AutoTokenizer
4
+ from peft import PeftModel
5
+
6
+
7
+ def main():
8
+ parser = argparse.ArgumentParser()
9
+ parser.add_argument("--base-model", required=True)
10
+ parser.add_argument("--adapter", required=True)
11
+ parser.add_argument("--output-dir", required=True)
12
+ parser.add_argument("--dtype", default="bfloat16", choices=["bfloat16", "float16", "float32"])
13
+ args = parser.parse_args()
14
+
15
+ dtype_map = {
16
+ "bfloat16": torch.bfloat16,
17
+ "float16": torch.float16,
18
+ "float32": torch.float32,
19
+ }
20
+ dtype = dtype_map[args.dtype]
21
+
22
+ tokenizer = AutoTokenizer.from_pretrained(
23
+ args.base_model,
24
+ trust_remote_code=True,
25
+ )
26
+
27
+ base_model = AutoModelForCausalLM.from_pretrained(
28
+ args.base_model,
29
+ torch_dtype=dtype,
30
+ device_map="auto",
31
+ trust_remote_code=True,
32
+ )
33
+
34
+ model = PeftModel.from_pretrained(
35
+ base_model,
36
+ args.adapter,
37
+ torch_dtype=dtype,
38
+ )
39
+
40
+ model = model.merge_and_unload()
41
+
42
+ model.save_pretrained(
43
+ args.output_dir,
44
+ safe_serialization=True,
45
+ max_shard_size="4GB",
46
+ )
47
+ tokenizer.save_pretrained(args.output_dir)
48
+
49
+ print(f"Saved merged model to {args.output_dir}")
50
+
51
+
52
+ if __name__ == "__main__":
53
+ main()
tools/train_poe_distill_lora.py ADDED
@@ -0,0 +1,604 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """LoRA training with an online product-of-experts distillation target.
4
+
5
+ This script trains on fixed pi_ref rollouts, but computes full-vocabulary
6
+ teacher/ref distributions online:
7
+
8
+ pi_star(. | s) proportional to pi_T(. | s)^beta * pi_ref(. | s)^(1-beta)
9
+ beta = alpha / (alpha + 1)
10
+
11
+ The trainable model is pi_ref plus LoRA adapters. The frozen pi_ref
12
+ distribution is obtained by disabling the adapter on the same model, avoiding a
13
+ second copy of the 4B reference model.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import contextlib
20
+ import os
21
+ from dataclasses import dataclass
22
+ from typing import Any
23
+
24
+ import torch
25
+ import torch.nn.functional as F
26
+ from datasets import load_dataset
27
+ from peft import LoraConfig, TaskType, get_peft_model
28
+ from torch.nn.utils.rnn import pad_sequence
29
+ from transformers import (
30
+ AutoModelForCausalLM,
31
+ AutoTokenizer,
32
+ TrainerCallback,
33
+ TrainerControl,
34
+ TrainerState,
35
+ Trainer,
36
+ TrainingArguments,
37
+ set_seed,
38
+ )
39
+
40
+
41
+ def parse_args() -> argparse.Namespace:
42
+ parser = argparse.ArgumentParser(description="Product-of-experts LoRA distillation on fixed rollouts.")
43
+
44
+ parser.add_argument("--student-model", default=os.environ.get("SFT_CHECKPOINT"), required=False)
45
+ parser.add_argument("--teacher-model", default=os.environ.get("TEACHER_MODEL", "Qwen/Qwen3-8B"))
46
+ parser.add_argument("--train-data", default="data/rollouts/dapo-math-17k-qwen3-4b-sft-rollouts.parquet")
47
+ parser.add_argument("--output-dir", default="checkpoints/qwen3-4b-poe-distill-lora")
48
+
49
+ parser.add_argument("--alpha", type=float, default=1.0)
50
+ parser.add_argument(
51
+ "--beta-start",
52
+ type=float,
53
+ default=None,
54
+ help="Initial beta. If unset, uses alpha / (alpha + 1) as a fixed beta.",
55
+ )
56
+ parser.add_argument(
57
+ "--beta-end",
58
+ type=float,
59
+ default=None,
60
+ help="Final beta. If unset, uses alpha / (alpha + 1) as a fixed beta.",
61
+ )
62
+ parser.add_argument(
63
+ "--beta-schedule-steps",
64
+ type=int,
65
+ default=None,
66
+ help="Number of optimizer steps used to ramp beta from beta-start to beta-end.",
67
+ )
68
+ parser.add_argument("--beta-schedule", choices=["linear", "cosine"], default="linear")
69
+ parser.add_argument(
70
+ "--beta-hold-steps",
71
+ type=int,
72
+ default=0,
73
+ help="Keep beta fixed at beta-start for this many optimizer steps before scheduling.",
74
+ )
75
+ parser.add_argument(
76
+ "--beta-transition-steps",
77
+ type=int,
78
+ default=None,
79
+ help="Number of optimizer steps used to move beta from beta-start to beta-end after beta-hold-steps.",
80
+ )
81
+ parser.add_argument(
82
+ "--lr-start",
83
+ type=float,
84
+ default=None,
85
+ help="Initial LR for custom hold-then-transition schedule. If unset, uses --learning-rate.",
86
+ )
87
+ parser.add_argument(
88
+ "--lr-end",
89
+ type=float,
90
+ default=None,
91
+ help="Final LR after custom transition. If unset, custom LR scheduling is disabled.",
92
+ )
93
+ parser.add_argument(
94
+ "--lr-hold-steps",
95
+ type=int,
96
+ default=None,
97
+ help="Keep LR fixed at lr-start for this many optimizer steps. If unset, uses beta-hold-steps.",
98
+ )
99
+ parser.add_argument(
100
+ "--lr-transition-steps",
101
+ type=int,
102
+ default=None,
103
+ help="Number of optimizer steps used to move LR from lr-start to lr-end. If unset, uses beta-transition-steps.",
104
+ )
105
+ parser.add_argument(
106
+ "--hold-transition-schedule",
107
+ choices=["linear", "cosine"],
108
+ default="linear",
109
+ help="Schedule type for hold-then-transition beta/LR.",
110
+ )
111
+ parser.add_argument(
112
+ "--loss-type",
113
+ choices=["full_vocab", "sampled_token"],
114
+ default="full_vocab",
115
+ help=(
116
+ "full_vocab matches the normalized PoE distribution over the whole vocab. "
117
+ "sampled_token uses an OPD-style sampled-token surrogate with a PoE advantage."
118
+ ),
119
+ )
120
+ parser.add_argument(
121
+ "--advantage-normalization",
122
+ choices=["none", "batch", "sequence"],
123
+ default="batch",
124
+ help="Only used by --loss-type sampled_token.",
125
+ )
126
+ parser.add_argument(
127
+ "--advantage-clip",
128
+ type=float,
129
+ default=None,
130
+ help="Symmetric clamp for sampled-token advantages. Example: 5.0.",
131
+ )
132
+ parser.add_argument(
133
+ "--use-ppo-clip",
134
+ action="store_true",
135
+ default=False,
136
+ help=(
137
+ "Only used by --loss-type sampled_token. Use PPO-style ratio clipping "
138
+ "with the frozen reference log-prob as the old rollout log-prob."
139
+ ),
140
+ )
141
+ parser.add_argument(
142
+ "--ppo-clip-low",
143
+ type=float,
144
+ default=0.2,
145
+ help="Only used when --use-ppo-clip is set. Lower PPO clip epsilon.",
146
+ )
147
+ parser.add_argument(
148
+ "--ppo-clip-high",
149
+ type=float,
150
+ default=0.2,
151
+ help="Only used when --use-ppo-clip is set. Upper PPO clip epsilon.",
152
+ )
153
+ parser.add_argument(
154
+ "--sampled-loss-reduction",
155
+ choices=["per_sample", "per_token"],
156
+ default="per_sample",
157
+ help=(
158
+ "Only used by --loss-type sampled_token. per_sample averages each response "
159
+ "first, then averages across batch; per_token averages over all response tokens."
160
+ ),
161
+ )
162
+ parser.add_argument(
163
+ "--positive-advantages-only",
164
+ action="store_true",
165
+ default=False,
166
+ help="Only reinforce sampled tokens with positive PoE advantages.",
167
+ )
168
+ parser.add_argument("--max-length", type=int, default=4096)
169
+ parser.add_argument("--distill-chunk-size", type=int, default=128)
170
+ parser.add_argument("--max-train-samples", type=int, default=None)
171
+ parser.add_argument("--seed", type=int, default=42)
172
+
173
+ parser.add_argument("--num-train-epochs", type=float, default=1.0)
174
+ parser.add_argument("--max-steps", type=int, default=-1)
175
+ parser.add_argument("--per-device-train-batch-size", type=int, default=1)
176
+ parser.add_argument("--gradient-accumulation-steps", type=int, default=16)
177
+ parser.add_argument("--learning-rate", type=float, default=2e-5)
178
+ parser.add_argument("--weight-decay", type=float, default=0.0)
179
+ parser.add_argument("--adam-beta1", type=float, default=0.9)
180
+ parser.add_argument("--adam-beta2", type=float, default=0.999)
181
+ parser.add_argument("--adam-epsilon", type=float, default=1e-8)
182
+ parser.add_argument("--warmup-ratio", type=float, default=0.03)
183
+ parser.add_argument("--lr-scheduler-type", default="cosine")
184
+ parser.add_argument("--logging-steps", type=int, default=1)
185
+ parser.add_argument("--save-steps", type=int, default=100)
186
+ parser.add_argument("--save-total-limit", type=int, default=0)
187
+ parser.add_argument("--bf16", action=argparse.BooleanOptionalAction, default=True)
188
+ parser.add_argument("--fp16", action="store_true", default=False)
189
+ parser.add_argument("--gradient-checkpointing", action=argparse.BooleanOptionalAction, default=True)
190
+ parser.add_argument("--report-to", default="none")
191
+
192
+ parser.add_argument("--lora-r", type=int, default=64)
193
+ parser.add_argument("--lora-alpha", type=int, default=128)
194
+ parser.add_argument("--lora-dropout", type=float, default=0.05)
195
+ parser.add_argument(
196
+ "--freeze-lora-b-after-step",
197
+ type=int,
198
+ default=None,
199
+ help="Freeze all LoRA B matrices once global_step reaches this value. Example: 20.",
200
+ )
201
+ parser.add_argument(
202
+ "--lora-target-modules",
203
+ default="q_proj,k_proj,v_proj,o_proj,gate_proj,up_proj,down_proj",
204
+ help="Comma-separated LoRA target modules.",
205
+ )
206
+
207
+ parser.add_argument("--trust-remote-code", action="store_true", default=True)
208
+ parser.add_argument(
209
+ "--attn-implementation",
210
+ default=None,
211
+ choices=[None, "eager", "sdpa", "flash_attention_2"],
212
+ help="Forwarded to from_pretrained when set.",
213
+ )
214
+
215
+ args = parser.parse_args()
216
+ if not args.student_model:
217
+ raise ValueError("Pass --student-model or set SFT_CHECKPOINT to the Qwen3-4B SFT checkpoint.")
218
+ if args.alpha <= 0:
219
+ raise ValueError("--alpha must be positive.")
220
+ fixed_beta = args.alpha / (args.alpha + 1.0)
221
+ if args.beta_start is None:
222
+ args.beta_start = fixed_beta
223
+ if args.beta_end is None:
224
+ args.beta_end = fixed_beta
225
+ if not 0.0 <= args.beta_start <= 1.0:
226
+ raise ValueError("--beta-start must be in [0, 1].")
227
+ if not 0.0 <= args.beta_end <= 1.0:
228
+ raise ValueError("--beta-end must be in [0, 1].")
229
+ if args.beta_schedule_steps is not None and args.beta_schedule_steps <= 0:
230
+ raise ValueError("--beta-schedule-steps must be positive when set.")
231
+ if args.advantage_clip is not None and args.advantage_clip <= 0:
232
+ raise ValueError("--advantage-clip must be positive when set.")
233
+ if args.ppo_clip_low < 0 or args.ppo_clip_high < 0:
234
+ raise ValueError("--ppo-clip-low and --ppo-clip-high must be non-negative.")
235
+ if args.freeze_lora_b_after_step is not None and args.freeze_lora_b_after_step < 0:
236
+ raise ValueError("--freeze-lora-b-after-step must be non-negative when set.")
237
+ if args.fp16 and args.bf16:
238
+ args.bf16 = False
239
+ return args
240
+
241
+
242
+ def first_assistant_index(messages: list[dict[str, str]]) -> int:
243
+ for idx, message in enumerate(messages):
244
+ if message.get("role") == "assistant":
245
+ return idx
246
+ raise ValueError("Rollout row has no assistant message.")
247
+
248
+
249
+ def tokenize_rollout(example: dict[str, Any], tokenizer: AutoTokenizer, max_length: int) -> dict[str, Any]:
250
+ messages = example["messages"]
251
+ assistant_idx = first_assistant_index(messages)
252
+ prompt_messages = messages[:assistant_idx]
253
+ full_messages = messages[: assistant_idx + 1]
254
+
255
+ prompt_text = tokenizer.apply_chat_template(
256
+ prompt_messages,
257
+ tokenize=False,
258
+ add_generation_prompt=True,
259
+ enable_thinking=True,
260
+ )
261
+ full_text = tokenizer.apply_chat_template(
262
+ full_messages,
263
+ tokenize=False,
264
+ add_generation_prompt=False,
265
+ enable_thinking=True,
266
+ )
267
+
268
+ prompt_ids = tokenizer.encode(prompt_text, add_special_tokens=False)
269
+ input_ids = tokenizer.encode(full_text, add_special_tokens=False)
270
+
271
+ if len(input_ids) > max_length:
272
+ input_ids = input_ids[:max_length]
273
+
274
+ # Mask is aligned to labels=input_ids[1:]. A label predicts token position
275
+ # j=i+1, so it belongs to the response when j >= len(prompt_ids).
276
+ label_len = max(len(input_ids) - 1, 0)
277
+ loss_mask = [1 if i + 1 >= len(prompt_ids) else 0 for i in range(label_len)]
278
+
279
+ if sum(loss_mask) == 0:
280
+ # Drop examples where truncation removed the assistant response.
281
+ return {"input_ids": [], "loss_mask": []}
282
+
283
+ return {"input_ids": input_ids, "loss_mask": loss_mask}
284
+
285
+
286
+ @dataclass
287
+ class DistillCollator:
288
+ pad_token_id: int
289
+
290
+ def __call__(self, features: list[dict[str, Any]]) -> dict[str, torch.Tensor]:
291
+ input_ids = [torch.tensor(f["input_ids"], dtype=torch.long) for f in features]
292
+ loss_masks = [torch.tensor(f["loss_mask"], dtype=torch.float32) for f in features]
293
+ lengths = torch.tensor([x.size(0) for x in input_ids], dtype=torch.long)
294
+
295
+ padded_input_ids = pad_sequence(input_ids, batch_first=True, padding_value=self.pad_token_id)
296
+ # loss_mask is one shorter than input_ids because it aligns to shifted labels.
297
+ padded_loss_masks = pad_sequence(loss_masks, batch_first=True, padding_value=0.0)
298
+ positions = torch.arange(padded_input_ids.size(1)).unsqueeze(0)
299
+ attention_mask = (positions < lengths.unsqueeze(1)).long()
300
+ return {
301
+ "input_ids": padded_input_ids,
302
+ "attention_mask": attention_mask,
303
+ "loss_mask": padded_loss_masks,
304
+ }
305
+
306
+
307
+ class PoEDistillTrainer(Trainer):
308
+ def __init__(
309
+ self,
310
+ *args: Any,
311
+ teacher_model: torch.nn.Module,
312
+ beta_start: float,
313
+ beta_end: float,
314
+ beta_schedule_steps: int | None,
315
+ beta_schedule: str,
316
+ loss_type: str,
317
+ advantage_normalization: str,
318
+ advantage_clip: float | None,
319
+ positive_advantages_only: bool,
320
+ use_ppo_clip: bool,
321
+ ppo_clip_low: float,
322
+ ppo_clip_high: float,
323
+ sampled_loss_reduction: str,
324
+ distill_chunk_size: int,
325
+ **kwargs: Any,
326
+ ) -> None:
327
+ super().__init__(*args, **kwargs)
328
+ self.teacher_model = teacher_model
329
+ self.teacher_model.to(self.args.device)
330
+ self.teacher_model.eval()
331
+ self.beta_start = beta_start
332
+ self.beta_end = beta_end
333
+ self.beta_schedule_steps = beta_schedule_steps
334
+ self.beta_schedule = beta_schedule
335
+ self.loss_type = loss_type
336
+ self.advantage_normalization = advantage_normalization
337
+ self.advantage_clip = advantage_clip
338
+ self.positive_advantages_only = positive_advantages_only
339
+ self.use_ppo_clip = use_ppo_clip
340
+ self.ppo_clip_low = ppo_clip_low
341
+ self.ppo_clip_high = ppo_clip_high
342
+ self.sampled_loss_reduction = sampled_loss_reduction
343
+ self.distill_chunk_size = distill_chunk_size
344
+
345
+ def current_beta(self) -> float:
346
+ schedule_steps = self.beta_schedule_steps
347
+ if schedule_steps is None:
348
+ schedule_steps = self.state.max_steps if self.state.max_steps > 0 else None
349
+ if schedule_steps is None or schedule_steps == 0:
350
+ return self.beta_end
351
+
352
+ progress = min(max(self.state.global_step / schedule_steps, 0.0), 1.0)
353
+ if self.beta_schedule == "cosine":
354
+ progress = 0.5 - 0.5 * torch.cos(torch.tensor(progress * torch.pi)).item()
355
+ return self.beta_start + (self.beta_end - self.beta_start) * progress
356
+
357
+ @staticmethod
358
+ def gather_token_logprobs(logits: torch.Tensor, labels: torch.Tensor) -> torch.Tensor:
359
+ logits = logits.float()
360
+ token_logits = logits.gather(dim=-1, index=labels.unsqueeze(-1)).squeeze(-1)
361
+ return token_logits - logits.logsumexp(dim=-1)
362
+
363
+ def normalize_advantages(self, advantages: torch.Tensor, loss_mask: torch.Tensor) -> torch.Tensor:
364
+ if self.advantage_normalization == "none":
365
+ return advantages
366
+
367
+ if self.advantage_normalization == "batch":
368
+ denom = loss_mask.sum().clamp_min(1.0)
369
+ mean = (advantages * loss_mask).sum() / denom
370
+ var = (((advantages - mean) * loss_mask) ** 2).sum() / denom
371
+ return (advantages - mean) / torch.sqrt(var + 1e-6)
372
+
373
+ denom = loss_mask.sum(dim=1, keepdim=True).clamp_min(1.0)
374
+ mean = (advantages * loss_mask).sum(dim=1, keepdim=True) / denom
375
+ var = (((advantages - mean) * loss_mask) ** 2).sum(dim=1, keepdim=True) / denom
376
+ return (advantages - mean) / torch.sqrt(var + 1e-6)
377
+
378
+ def compute_loss(
379
+ self,
380
+ model: torch.nn.Module,
381
+ inputs: dict[str, torch.Tensor],
382
+ return_outputs: bool = False,
383
+ **_: Any,
384
+ ):
385
+ loss_mask = inputs.pop("loss_mask")
386
+ input_ids = inputs["input_ids"]
387
+ attention_mask = inputs["attention_mask"]
388
+ labels = input_ids[:, 1:]
389
+
390
+ with torch.no_grad():
391
+ teacher_logits = self.teacher_model(
392
+ input_ids=input_ids,
393
+ attention_mask=attention_mask,
394
+ use_cache=False,
395
+ ).logits[:, :-1, :].detach()
396
+
397
+ adapter_owner = model.module if hasattr(model, "module") else model
398
+ disable_adapter = getattr(adapter_owner, "disable_adapter", None)
399
+ ref_context = disable_adapter() if disable_adapter is not None else contextlib.nullcontext()
400
+ with ref_context:
401
+ ref_logits = adapter_owner(
402
+ input_ids=input_ids,
403
+ attention_mask=attention_mask,
404
+ use_cache=False,
405
+ ).logits[:, :-1, :].detach()
406
+
407
+ student_outputs = model(input_ids=input_ids, attention_mask=attention_mask, use_cache=False)
408
+ student_logits = student_outputs.logits[:, :-1, :]
409
+ if teacher_logits.size(-1) != student_logits.size(-1) or ref_logits.size(-1) != student_logits.size(-1):
410
+ raise ValueError(
411
+ "Teacher, reference, and student vocab sizes must match for full-vocab PoE distillation. "
412
+ f"Got teacher={teacher_logits.size(-1)}, ref={ref_logits.size(-1)}, "
413
+ f"student={student_logits.size(-1)}."
414
+ )
415
+
416
+ total_loss = student_logits.new_zeros(())
417
+ total_tokens = loss_mask.sum().clamp_min(1.0)
418
+ beta = self.current_beta()
419
+
420
+ if self.loss_type == "sampled_token":
421
+ teacher_logp = self.gather_token_logprobs(teacher_logits, labels)
422
+ ref_logp = self.gather_token_logprobs(ref_logits, labels)
423
+ student_logp = self.gather_token_logprobs(student_logits, labels)
424
+
425
+ poe_score = beta * teacher_logp + (1.0 - beta) * ref_logp
426
+ advantages = poe_score - student_logp.detach()
427
+ advantages = self.normalize_advantages(advantages, loss_mask)
428
+ if self.advantage_clip is not None:
429
+ advantages = advantages.clamp(min=-self.advantage_clip, max=self.advantage_clip)
430
+ if self.positive_advantages_only:
431
+ advantages = advantages.clamp_min(0.0)
432
+
433
+ advantages = advantages.detach()
434
+
435
+ if self.use_ppo_clip:
436
+ # The rollouts are generated by the frozen reference/SFT policy, so ref_logp
437
+ # is used as the old rollout log-prob. This mirrors the PPO-style clipped
438
+ # policy loss used in RL frameworks such as slime.
439
+ ratio = torch.exp(student_logp - ref_logp.detach())
440
+ ratio_clipped = ratio.clamp(1.0 - self.ppo_clip_low, 1.0 + self.ppo_clip_high)
441
+ pg_loss_unclipped = -ratio * advantages
442
+ pg_loss_clipped = -ratio_clipped * advantages
443
+ token_loss = torch.maximum(pg_loss_unclipped, pg_loss_clipped)
444
+ else:
445
+ # Direct OPD-style sampled-token surrogate.
446
+ token_loss = -advantages * student_logp
447
+
448
+ if self.sampled_loss_reduction == "per_token":
449
+ loss = (token_loss * loss_mask).sum() / total_tokens
450
+ else:
451
+ # Per-sample mean: each response contributes equally regardless of length.
452
+ seq_loss = (token_loss * loss_mask).sum(dim=1) / loss_mask.sum(dim=1).clamp_min(1.0)
453
+ loss = seq_loss.mean()
454
+
455
+ return (loss, student_outputs) if return_outputs else loss
456
+
457
+ seq_len = student_logits.size(1)
458
+ for start in range(0, seq_len, self.distill_chunk_size):
459
+ end = min(start + self.distill_chunk_size, seq_len)
460
+ mask = loss_mask[:, start:end]
461
+ if mask.sum() == 0:
462
+ continue
463
+
464
+ teacher_logp = F.log_softmax(teacher_logits[:, start:end, :].float(), dim=-1)
465
+ ref_logp = F.log_softmax(ref_logits[:, start:end, :].float(), dim=-1)
466
+ student_logp = F.log_softmax(student_logits[:, start:end, :].float(), dim=-1)
467
+
468
+ poe_logits = beta * teacher_logp + (1.0 - beta) * ref_logp
469
+ target_probs = F.softmax(poe_logits, dim=-1)
470
+ token_ce = -(target_probs * student_logp).sum(dim=-1)
471
+ total_loss = total_loss + (token_ce * mask).sum()
472
+
473
+ loss = total_loss / total_tokens
474
+ return (loss, student_outputs) if return_outputs else loss
475
+
476
+
477
+ class FreezeLoRABCallback(TrainerCallback):
478
+ def __init__(self, freeze_after_step: int | None) -> None:
479
+ self.freeze_after_step = freeze_after_step
480
+ self.frozen = False
481
+
482
+ def on_step_begin(
483
+ self,
484
+ args: TrainingArguments,
485
+ state: TrainerState,
486
+ control: TrainerControl,
487
+ model: torch.nn.Module | None = None,
488
+ **kwargs: Any,
489
+ ) -> TrainerControl:
490
+ if self.freeze_after_step is None or self.frozen or model is None:
491
+ return control
492
+ if state.global_step < self.freeze_after_step:
493
+ return control
494
+
495
+ frozen_params = 0
496
+ module = model.module if hasattr(model, "module") else model
497
+ for name, param in module.named_parameters():
498
+ if ".lora_B." in name or "lora_B." in name:
499
+ param.requires_grad_(False)
500
+ frozen_params += param.numel()
501
+
502
+ self.frozen = True
503
+ if args.process_index == 0:
504
+ print(f"[PoE Distill] Froze LoRA B at global_step={state.global_step} ({frozen_params} params).")
505
+ return control
506
+
507
+
508
+ def main() -> None:
509
+ args = parse_args()
510
+ set_seed(args.seed)
511
+
512
+ tokenizer = AutoTokenizer.from_pretrained(args.student_model, trust_remote_code=args.trust_remote_code)
513
+ if tokenizer.pad_token_id is None:
514
+ tokenizer.pad_token = tokenizer.eos_token
515
+
516
+ raw_dataset = load_dataset("parquet", data_files=args.train_data, split="train")
517
+ if args.max_train_samples is not None:
518
+ raw_dataset = raw_dataset.select(range(min(args.max_train_samples, len(raw_dataset))))
519
+
520
+ train_dataset = raw_dataset.map(
521
+ lambda ex: tokenize_rollout(ex, tokenizer, args.max_length),
522
+ remove_columns=raw_dataset.column_names,
523
+ desc="Tokenizing pi_ref rollouts",
524
+ ).filter(lambda ex: len(ex["input_ids"]) > 0, desc="Dropping empty responses")
525
+
526
+ model_kwargs = {
527
+ "torch_dtype": torch.bfloat16 if args.bf16 else (torch.float16 if args.fp16 else torch.float32),
528
+ "trust_remote_code": args.trust_remote_code,
529
+ }
530
+ if args.attn_implementation is not None:
531
+ model_kwargs["attn_implementation"] = args.attn_implementation
532
+
533
+ student = AutoModelForCausalLM.from_pretrained(args.student_model, **model_kwargs)
534
+ teacher = AutoModelForCausalLM.from_pretrained(args.teacher_model, **model_kwargs)
535
+ teacher.eval()
536
+ teacher.requires_grad_(False)
537
+
538
+ if args.gradient_checkpointing:
539
+ student.gradient_checkpointing_enable()
540
+ student.config.use_cache = False
541
+ teacher.config.use_cache = False
542
+
543
+ lora_config = LoraConfig(
544
+ task_type=TaskType.CAUSAL_LM,
545
+ r=args.lora_r,
546
+ lora_alpha=args.lora_alpha,
547
+ lora_dropout=args.lora_dropout,
548
+ target_modules=[m.strip() for m in args.lora_target_modules.split(",") if m.strip()],
549
+ )
550
+ student = get_peft_model(student, lora_config)
551
+ student.print_trainable_parameters()
552
+
553
+ training_args = TrainingArguments(
554
+ output_dir=args.output_dir,
555
+ num_train_epochs=args.num_train_epochs,
556
+ max_steps=args.max_steps,
557
+ per_device_train_batch_size=args.per_device_train_batch_size,
558
+ gradient_accumulation_steps=args.gradient_accumulation_steps,
559
+ learning_rate=args.learning_rate,
560
+ weight_decay=args.weight_decay,
561
+ adam_beta1=args.adam_beta1,
562
+ adam_beta2=args.adam_beta2,
563
+ adam_epsilon=args.adam_epsilon,
564
+ warmup_ratio=args.warmup_ratio,
565
+ lr_scheduler_type=args.lr_scheduler_type,
566
+ logging_steps=args.logging_steps,
567
+ save_steps=args.save_steps,
568
+ save_total_limit=args.save_total_limit,
569
+ bf16=args.bf16,
570
+ fp16=args.fp16,
571
+ gradient_checkpointing=args.gradient_checkpointing,
572
+ remove_unused_columns=False,
573
+ report_to=[] if args.report_to == "none" else args.report_to.split(","),
574
+ )
575
+
576
+ trainer = PoEDistillTrainer(
577
+ model=student,
578
+ args=training_args,
579
+ train_dataset=train_dataset,
580
+ data_collator=DistillCollator(pad_token_id=tokenizer.pad_token_id),
581
+ tokenizer=tokenizer,
582
+ teacher_model=teacher,
583
+ beta_start=args.beta_start,
584
+ beta_end=args.beta_end,
585
+ beta_schedule_steps=args.beta_schedule_steps,
586
+ beta_schedule=args.beta_schedule,
587
+ loss_type=args.loss_type,
588
+ advantage_normalization=args.advantage_normalization,
589
+ advantage_clip=args.advantage_clip,
590
+ positive_advantages_only=args.positive_advantages_only,
591
+ use_ppo_clip=args.use_ppo_clip,
592
+ ppo_clip_low=args.ppo_clip_low,
593
+ ppo_clip_high=args.ppo_clip_high,
594
+ sampled_loss_reduction=args.sampled_loss_reduction,
595
+ distill_chunk_size=args.distill_chunk_size,
596
+ callbacks=[FreezeLoRABCallback(args.freeze_lora_b_after_step)],
597
+ )
598
+ trainer.train()
599
+ trainer.save_model(args.output_dir)
600
+ tokenizer.save_pretrained(args.output_dir)
601
+
602
+
603
+ if __name__ == "__main__":
604
+ main()