Files
ModelHub XC d4abdb70fa 初始化项目,由ModelHub XC社区提供模型
Model: pathcosmos/frankenstallm
Source: Original Platform
2026-07-14 04:21:16 +08:00

587 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
ORPO (Odds Ratio Preference Optimization) training script.
Uses TRL 0.29.0 ORPOTrainer/ORPOConfig (trl.experimental.orpo).
Optimized for 8x NVIDIA B200 GPUs (183GB VRAM each, ~1.47TB total).
Usage:
# Full training (8 GPU DDP)
torchrun --nproc_per_node=8 train/orpo.py \
--config configs/korean_3b_orpo.yaml
# Quick test (200 steps)
python train/orpo.py --config configs/korean_3b_orpo.yaml --max_steps 200
# Single GPU test
python train/orpo.py --config configs/korean_3b_orpo.yaml --device cuda:0
Prerequisites:
pip install trl==0.29.0 transformers accelerate peft datasets
"""
from __future__ import annotations
import argparse
import datetime
import json
import logging
import os
import signal as _signal_mod
import sys
import time
import traceback
from pathlib import Path
import torch
from datasets import Dataset, load_dataset
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
EarlyStoppingCallback,
TrainerCallback,
)
# TRL imports -- ORPOTrainer/ORPOConfig (TRL 0.29.0, experimental path)
try:
from trl.experimental.orpo import ORPOConfig, ORPOTrainer
except ImportError:
print("ERROR: trl not installed or outdated. Run: pip install trl==0.29.0")
sys.exit(1)
# Telegram notifications
try:
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from scripts.telegram_notify import send_telegram_safe
HAS_TELEGRAM = True
except ImportError:
HAS_TELEGRAM = False
def send_telegram_safe(msg, **kw): return False
# ---------------------------------------------------------------------------
# Logging setup
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger("orpo")
# ---------------------------------------------------------------------------
# Custom callback for detailed monitoring
# ---------------------------------------------------------------------------
class ORPOMonitorCallback(TrainerCallback):
"""Monitors ORPO-specific metrics and sends alerts on anomalies."""
def __init__(self, alert_fn=send_telegram_safe):
self.alert_fn = alert_fn
self.start_time = None
self.last_eval_loss = None
self.eval_loss_increases = 0
self.negative_margin_streak = 0
def on_train_begin(self, args, state, control, **kwargs):
self.start_time = time.time()
log.info("ORPO training begin -- monitoring active")
def on_log(self, args, state, control, logs=None, **kwargs):
if logs is None:
return
step = state.global_step
# Monitor rewards/margins
margin = logs.get("rewards/margins")
if margin is not None:
if margin < 0:
self.negative_margin_streak += 1
if self.negative_margin_streak >= 10:
msg = (f"[ORPO ALERT] rewards/margins negative for "
f"{self.negative_margin_streak} consecutive logs at step {step} "
f"(margin={margin:.4f})")
log.warning(msg)
self.alert_fn(msg)
else:
self.negative_margin_streak = 0
# Log key metrics every logging step
loss = logs.get("loss")
chosen = logs.get("rewards/chosen")
rejected = logs.get("rewards/rejected")
if loss is not None:
elapsed = time.time() - self.start_time if self.start_time else 0
log.info(
f"step={step} loss={loss:.4f} "
f"margin={margin if margin is not None else 'N/A'} "
f"chosen={chosen if chosen is not None else 'N/A'} "
f"rejected={rejected if rejected is not None else 'N/A'} "
f"elapsed={elapsed/3600:.1f}h"
)
# Check for NaN/Inf
if loss is not None and (not isinstance(loss, (int, float)) or loss != loss):
msg = f"[ORPO CRITICAL] NaN/Inf loss detected at step {step}!"
log.error(msg)
self.alert_fn(msg)
def on_evaluate(self, args, state, control, metrics=None, **kwargs):
if metrics is None:
return
eval_loss = metrics.get("eval_loss")
step = state.global_step
if eval_loss is not None:
log.info(f"[EVAL] step={step} eval_loss={eval_loss:.4f}")
if self.last_eval_loss is not None and eval_loss > self.last_eval_loss:
self.eval_loss_increases += 1
log.warning(
f"[EVAL] eval_loss increased: {self.last_eval_loss:.4f} -> {eval_loss:.4f} "
f"({self.eval_loss_increases}/3 before early stop)"
)
else:
self.eval_loss_increases = 0
self.last_eval_loss = eval_loss
def on_train_end(self, args, state, control, **kwargs):
elapsed = time.time() - self.start_time if self.start_time else 0
log.info(f"ORPO training ended -- total time: {elapsed/3600:.2f}h, "
f"total steps: {state.global_step}")
def on_save(self, args, state, control, **kwargs):
log.info(f"Checkpoint saved at step {state.global_step}")
class VRAMMonitorCallback(TrainerCallback):
"""Measures peak VRAM usage across all GPUs during training."""
def on_train_begin(self, args, state, control, **kwargs):
if torch.cuda.is_available():
for i in range(torch.cuda.device_count()):
torch.cuda.reset_peak_memory_stats(i)
log.info("[VRAM] Peak memory stats reset for all GPUs")
def on_train_end(self, args, state, control, **kwargs):
if torch.cuda.is_available():
for i in range(torch.cuda.device_count()):
peak_mb = torch.cuda.max_memory_allocated(i) / (1024**2)
log.info(f"[VRAM] GPU {i} peak: {peak_mb:.0f} MiB")
def load_hf_preference_dataset(dataset_name: str, token: str | None = None) -> Dataset:
"""Load and normalize a HuggingFace preference dataset to {prompt, chosen, rejected}."""
ds = load_dataset(dataset_name, split="train", token=token)
# kuotient/orca-math-korean-dpo-pairs format: {system, question, chosen, rejected}
if "question" in ds.column_names and "chosen" in ds.column_names:
def normalize(example):
prompt = example.get("system", "") + "\n" + example["question"]
return {"prompt": prompt.strip(), "chosen": example["chosen"], "rejected": example["rejected"]}
return ds.map(normalize, remove_columns=ds.column_names)
# nayohan/preference-collection-ko-full format: {response_A, response_B, orig_preference}
if "orig_preference" in ds.column_names:
def normalize_pref(example):
prompt = example.get("orig_instruction", example.get("instruction", ""))
if example["orig_preference"] == "B":
return {"prompt": prompt, "chosen": example["orig_response_B"], "rejected": example["orig_response_A"]}
else:
return {"prompt": prompt, "chosen": example["orig_response_A"], "rejected": example["orig_response_B"]}
return ds.map(normalize_pref, remove_columns=ds.column_names)
# Already in {prompt, chosen, rejected} format
if all(c in ds.column_names for c in ["prompt", "chosen", "rejected"]):
return ds
raise ValueError(f"Unknown dataset format. Columns: {ds.column_names}")
def load_custom_jsonl(path: str) -> Dataset:
"""Load custom JSONL with {prompt, chosen, rejected} fields."""
data = []
with open(path) as f:
for line in f:
data.append(json.loads(line))
return Dataset.from_list(data)
def load_yaml_config(path: str) -> dict:
"""Load YAML config and return as dict."""
import yaml
with open(path) as f:
return yaml.safe_load(f)
def main():
parser = argparse.ArgumentParser(description="ORPO Training (TRL 0.29.0 -- 8xB200 optimized)")
parser.add_argument("--config", type=str, default=None, help="YAML config file path")
parser.add_argument("--model_path", type=str, default=None, help="HF format model path")
parser.add_argument("--dataset", type=str, default="kuotient/orca-math-korean-dpo-pairs")
parser.add_argument("--custom_data_path", type=str, default=None, help="Custom JSONL preference data")
parser.add_argument("--output_dir", type=str, default="checkpoints/korean_3b_orpo")
parser.add_argument("--hf_token", type=str, default=None)
parser.add_argument("--epochs", type=int, default=3)
parser.add_argument("--lr", type=float, default=5e-6)
parser.add_argument("--beta", type=float, default=0.1, help="ORPO beta (odds ratio weight)")
parser.add_argument("--batch_size", type=int, default=4)
parser.add_argument("--gradient_accumulation_steps", type=int, default=4)
parser.add_argument("--max_length", type=int, default=1536)
parser.add_argument("--bf16", action="store_true", default=True)
parser.add_argument("--weight_decay", type=float, default=0.01)
parser.add_argument("--eval_split_ratio", type=float, default=0.05, help="Fraction of data for eval")
parser.add_argument("--eval_steps", type=int, default=500)
parser.add_argument("--early_stopping_patience", type=int, default=3)
parser.add_argument("--max_steps", type=int, default=-1, help="Override max steps (for quick test)")
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--save_total_limit", type=int, default=5)
parser.add_argument("--warmup_ratio", type=float, default=0.05)
parser.add_argument("--lr_scheduler_type", type=str, default="cosine")
parser.add_argument("--logging_steps", type=int, default=10)
parser.add_argument("--save_steps", type=int, default=500)
parser.add_argument("--gradient_checkpointing", action="store_true", default=True)
parser.add_argument("--report_to", type=str, default="none")
parser.add_argument("--dataset_num_proc", type=int, default=8,
help="Number of processes for parallel tokenization in ORPOTrainer")
parser.add_argument("--dataloader_num_workers", type=int, default=4,
help="Number of dataloader worker processes")
parser.add_argument("--no_load_best", action="store_true", default=False,
help="Disable load_best_model_at_end (for sweep/quick tests)")
parser.add_argument("--max_samples", type=int, default=0,
help="Limit dataset size (0=use all, >0=subset for benchmarking)")
parser.add_argument("--skip_filter", action="store_true", default=False,
help="Skip NaN-prevention filter (for benchmarking only)")
args = parser.parse_args()
# Override CLI defaults with YAML config values
if args.config:
cfg = load_yaml_config(args.config)
for key, value in cfg.items():
if hasattr(args, key):
setattr(args, key, value)
if not args.model_path:
parser.error("--model_path is required (or set model_path in YAML config)")
# Log all resolved config
local_rank = int(os.environ.get("LOCAL_RANK", 0))
is_main = local_rank == 0
if is_main:
log.info("=" * 70)
log.info("ORPO Training Configuration (8xB200 optimized)")
log.info("=" * 70)
for k, v in sorted(vars(args).items()):
log.info(f" {k}: {v}")
log.info("=" * 70)
# GPU info
if torch.cuda.is_available():
for i in range(torch.cuda.device_count()):
mem = torch.cuda.get_device_properties(i).total_memory / 1e9
log.info(f" GPU {i}: {torch.cuda.get_device_name(i)} ({mem:.1f} GB)")
# Validate paths
if not Path(args.model_path).exists():
raise FileNotFoundError(f"Model path not found: {args.model_path}")
if args.custom_data_path and not Path(args.custom_data_path).exists():
raise FileNotFoundError(f"Data path not found: {args.custom_data_path}")
# NCCL/DDP environment diagnostics
if is_main:
log.info("--- DDP/NCCL Environment ---")
for env_key in ["RANK", "WORLD_SIZE", "LOCAL_RANK", "MASTER_ADDR", "MASTER_PORT",
"NCCL_IB_DISABLE", "NCCL_BUFFSIZE", "NCCL_P2P_LEVEL",
"OMP_NUM_THREADS", "PYTORCH_CUDA_ALLOC_CONF"]:
log.info(f" {env_key}={os.environ.get(env_key, '(not set)')}")
log.info(f" torch.distributed.is_available={torch.distributed.is_available()}")
if torch.distributed.is_initialized():
log.info(f" world_size={torch.distributed.get_world_size()}, "
f"rank={torch.distributed.get_rank()}")
# Load model (bfloat16 + flash_attention_2 for B200)
log.info(f"Loading model from {args.model_path}...")
t0 = time.time()
try:
model = AutoModelForCausalLM.from_pretrained(
args.model_path,
torch_dtype=torch.bfloat16,
attn_implementation="flash_attention_2",
)
except Exception as e:
log.error(f"Model loading failed: {e}")
send_telegram_safe(f"[ORPO FATAL] Model load failed: {e}")
raise
tokenizer = AutoTokenizer.from_pretrained(args.model_path)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
if is_main:
n_params = sum(p.numel() for p in model.parameters())
log.info(f"Model loaded: {n_params:,} params in {time.time()-t0:.1f}s")
log.info(f"Tokenizer: vocab_size={tokenizer.vocab_size}, "
f"pad_token='{tokenizer.pad_token}', eos_token='{tokenizer.eos_token}'")
# Load dataset
t0 = time.time()
try:
if args.custom_data_path:
log.info(f"Loading custom data from {args.custom_data_path}...")
fsize_mb = Path(args.custom_data_path).stat().st_size / 1e6
log.info(f" File size: {fsize_mb:.1f} MB")
dataset = load_custom_jsonl(args.custom_data_path)
else:
log.info(f"Loading dataset {args.dataset}...")
dataset = load_hf_preference_dataset(args.dataset, token=args.hf_token)
except Exception as e:
log.error(f"Dataset loading failed: {e}")
send_telegram_safe(f"[ORPO FATAL] Data load failed: {e}")
raise
# Subset for benchmarking (skip tokenization bottleneck)
if args.max_samples > 0 and len(dataset) > args.max_samples:
dataset = dataset.select(range(args.max_samples))
if is_main:
log.info(f"[BENCH] Dataset subset: {args.max_samples:,} samples")
if is_main:
log.info(f"Dataset loaded: {len(dataset)} pairs in {time.time()-t0:.1f}s")
# Data quality check
sample = dataset[0]
log.info(f"Sample keys: {list(sample.keys())}")
for key in ["prompt", "chosen", "rejected"]:
if key not in sample:
raise ValueError(f"Dataset missing required column: {key}")
val = sample[key]
log.info(f" {key}: {str(val)[:100]}...")
# Length distribution check (sample first 1000)
sample_size = min(1000, len(dataset))
lengths = [len(str(dataset[i]["prompt"])) + max(len(str(dataset[i]["chosen"])),
len(str(dataset[i]["rejected"]))) for i in range(sample_size)]
avg_len = sum(lengths) / len(lengths)
max_len = max(lengths)
log.info(f" Char lengths (sample {sample_size}): avg={avg_len:.0f}, max={max_len}")
# Filter out samples where prompt is too long for the response to fit in max_length.
# Without this, samples with 0 response tokens cause NaN in ORPO log-probability computation
# (division by zero in average_log_prob when loss_mask is all-zero).
# Also catches TRL truncation bug: tokenize_row uses longer_response_length = max(chosen_len, rejected_len)
# and truncates BOTH responses to [:max_length - longer_response_length]. When longer >= max_length,
# the shorter response becomes EMPTY → NaN.
if args.skip_filter:
if is_main:
log.info("[BENCH] Skipping NaN-prevention filter (--skip_filter)")
else:
pre_filter = len(dataset)
def _has_response_room(example):
prompt_tok_len = len(tokenizer.encode(example["prompt"], add_special_tokens=False))
chosen_tok_len = len(tokenizer.encode(example["chosen"], add_special_tokens=False))
rejected_tok_len = len(tokenizer.encode(example["rejected"], add_special_tokens=False))
# 1. Prompt must leave room for at least 16 response tokens
if prompt_tok_len + 16 > args.max_length:
return False
# 2. Each response independently must fit with prompt
# (TRL adds BOS/EOS, so use +2 margin)
if prompt_tok_len + chosen_tok_len + 2 > args.max_length * 2:
return False # extremely long, will cause issues
if prompt_tok_len + rejected_tok_len + 2 > args.max_length * 2:
return False
# 3. The longer response must not exceed max_length alone
# (TRL bug: both responses truncated by max(chosen_len, rejected_len))
longer = max(chosen_tok_len, rejected_tok_len)
if longer >= args.max_length:
return False
return True
dataset = dataset.filter(_has_response_room, num_proc=min(args.dataset_num_proc, 32) if is_main else 1)
if is_main:
log.info(f"Filtered: {pre_filter:,} -> {len(dataset):,} "
f"(removed {pre_filter - len(dataset):,} samples with prompt > max_length-16 or TRL truncation risk)")
# Train/eval split
split = dataset.train_test_split(test_size=args.eval_split_ratio, seed=args.seed)
train_dataset = split["train"]
eval_dataset = split["test"]
log.info(f"Train: {len(train_dataset):,}, Eval: {len(eval_dataset):,}")
# Compute training stats for warmup_steps calculation
n_gpus = max(torch.cuda.device_count(), 1) if torch.cuda.is_available() else 1
eff_batch = args.batch_size * args.gradient_accumulation_steps * n_gpus
steps_per_epoch = len(train_dataset) // eff_batch
total_steps = args.max_steps if args.max_steps > 0 else steps_per_epoch * args.epochs
computed_warmup_steps = int(total_steps * args.warmup_ratio)
if is_main:
log.info(f"Training plan: eff_batch={eff_batch}, steps/epoch={steps_per_epoch:,}, "
f"total={total_steps:,}, warmup={computed_warmup_steps}")
# DDP tokenization strategy:
# TRL ORPOTrainer uses main_process_first() — rank 0 tokenizes first, then ranks 1-7.
# With multiprocessing (num_proc>1), ranks 1-7 all spawn workers simultaneously,
# causing CPU/memory oversubscription (e.g. 7 ranks × 8 workers = 56 processes).
# Fix: rank 0 uses full num_proc for speed, other ranks use 1 (should hit cache).
world_size = int(os.environ.get("WORLD_SIZE", 1))
if world_size > 1:
effective_num_proc = args.dataset_num_proc if is_main else 1
if is_main:
log.info(f"DDP tokenization: rank 0 uses num_proc={args.dataset_num_proc}, "
f"other {world_size-1} ranks use num_proc=1 (cache)")
else:
effective_num_proc = args.dataset_num_proc
# ORPOConfig (TRL 0.29.0) -- optimized for 8x B200
orpo_config = ORPOConfig(
output_dir=args.output_dir,
num_train_epochs=args.epochs,
per_device_train_batch_size=args.batch_size,
per_device_eval_batch_size=args.batch_size,
gradient_accumulation_steps=args.gradient_accumulation_steps,
learning_rate=args.lr,
beta=args.beta,
lr_scheduler_type=args.lr_scheduler_type,
warmup_steps=computed_warmup_steps,
weight_decay=args.weight_decay,
bf16=args.bf16,
logging_steps=args.logging_steps,
save_steps=args.save_steps,
save_total_limit=args.save_total_limit,
max_length=args.max_length,
gradient_checkpointing=args.gradient_checkpointing,
report_to=args.report_to,
remove_unused_columns=False,
eval_strategy="steps",
eval_steps=args.eval_steps,
metric_for_best_model="eval_loss" if not args.no_load_best else None,
load_best_model_at_end=not args.no_load_best,
greater_is_better=False if not args.no_load_best else None,
max_steps=args.max_steps,
seed=args.seed,
# B200 hardware optimizations
dataloader_num_workers=args.dataloader_num_workers,
dataloader_pin_memory=True,
ddp_find_unused_parameters=False,
ddp_timeout=7200, # 2h — tokenization takes ~30min on 683K samples
dataset_num_proc=effective_num_proc,
)
# ORPOTrainer (no reference model needed)
log.info("Initializing ORPOTrainer (tokenization will happen here — may take a while)...")
t0 = time.time()
monitor = ORPOMonitorCallback()
try:
trainer = ORPOTrainer(
model=model,
args=orpo_config,
train_dataset=train_dataset,
eval_dataset=eval_dataset,
processing_class=tokenizer,
callbacks=[cb for cb in [
EarlyStoppingCallback(early_stopping_patience=args.early_stopping_patience)
if not args.no_load_best else None,
monitor,
VRAMMonitorCallback(),
] if cb is not None],
)
except Exception as e:
log.error(f"ORPOTrainer init failed: {e}\n{traceback.format_exc()}")
send_telegram_safe(f"[ORPO FATAL] Trainer init failed: {e}")
raise
if is_main:
log.info(f"ORPOTrainer initialized in {time.time()-t0:.1f}s "
f"(dataset_num_proc={args.dataset_num_proc})")
# SIGHUP/SIGTERM defense -- graceful shutdown with emergency checkpoint
def _graceful_shutdown_handler(signum, frame):
sig_name = _signal_mod.Signals(signum).name
log.warning(f"Received {sig_name}. Saving emergency checkpoint...")
try:
emergency_path = os.path.join(args.output_dir, "emergency_checkpoint")
trainer.save_model(emergency_path)
log.info(f"Emergency checkpoint saved to {emergency_path}")
send_telegram_safe(
f"[ORPO] Signal {sig_name} received at step {trainer.state.global_step}. "
f"Emergency checkpoint saved."
)
except Exception as e:
log.error(f"Emergency save failed: {e}")
send_telegram_safe(f"[ORPO] Emergency save FAILED after {sig_name}: {e}")
sys.exit(1)
for _sig in (_signal_mod.SIGHUP, _signal_mod.SIGTERM):
_signal_mod.signal(_sig, _graceful_shutdown_handler)
# Pre-training VRAM report
if is_main and torch.cuda.is_available():
torch.cuda.reset_peak_memory_stats()
alloc = torch.cuda.memory_allocated() / 1e9
reserved = torch.cuda.memory_reserved() / 1e9
log.info(f"Pre-train VRAM: allocated={alloc:.1f}GB, reserved={reserved:.1f}GB")
start_msg = (
f"[ORPO] Training started\n"
f" model: {args.model_path}\n"
f" beta: {args.beta}, lr: {args.lr}\n"
f" train: {len(train_dataset):,}, eval: {len(eval_dataset):,}\n"
f" eff_batch: {eff_batch}, steps/epoch: {steps_per_epoch:,}, total: {total_steps:,}\n"
f" warmup: {computed_warmup_steps} steps ({args.warmup_ratio*100:.0f}%)\n"
f" max_length: {args.max_length}, max_steps: {args.max_steps}\n"
f" dataset_num_proc: {args.dataset_num_proc}, dl_workers: {args.dataloader_num_workers}"
)
log.info(start_msg.replace("[ORPO] ", ""))
send_telegram_safe(start_msg)
try:
trainer.train()
# Post-training VRAM report
if is_main and torch.cuda.is_available():
peak = torch.cuda.max_memory_allocated() / 1e9
log.info(f"Peak VRAM usage: {peak:.1f}GB")
trainer.save_model(args.output_dir)
log.info(f"Model saved to {args.output_dir}")
# Extract final metrics
final_metrics = {}
for entry in reversed(trainer.state.log_history):
if "loss" in entry and "loss" not in final_metrics:
final_metrics["loss"] = entry["loss"]
if "eval_loss" in entry and "eval_loss" not in final_metrics:
final_metrics["eval_loss"] = entry["eval_loss"]
if "rewards/margins" in entry and "rewards/margins" not in final_metrics:
final_metrics["rewards/margins"] = entry["rewards/margins"]
if len(final_metrics) >= 3:
break
done_msg = (
f"[ORPO] Training complete!\n"
f" output: {args.output_dir}\n"
f" steps: {trainer.state.global_step}\n"
f" final loss: {final_metrics.get('loss', 'N/A')}\n"
f" final eval_loss: {final_metrics.get('eval_loss', 'N/A')}\n"
f" final margins: {final_metrics.get('rewards/margins', 'N/A')}"
)
log.info(done_msg.replace("[ORPO] ", ""))
send_telegram_safe(done_msg)
except KeyboardInterrupt:
log.warning("Training interrupted by user (KeyboardInterrupt)")
send_telegram_safe(f"[ORPO] Training interrupted at step {trainer.state.global_step}")
trainer.save_model(os.path.join(args.output_dir, "interrupted_checkpoint"))
log.info("Interrupted checkpoint saved.")
except Exception as e:
tb = traceback.format_exc()
error_msg = f"[ORPO] Training FAILED at step {trainer.state.global_step}: {e}"
log.error(f"{error_msg}\n{tb}")
send_telegram_safe(f"{error_msg}\n{tb[:500]}")
# Try emergency save
try:
trainer.save_model(os.path.join(args.output_dir, "error_checkpoint"))
log.info("Error checkpoint saved.")
except Exception:
log.error("Error checkpoint save also failed.")
raise
if __name__ == "__main__":
main()