437 lines
18 KiB
Python
437 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
XiaLi 云端 GPU 训练脚本 v1.0
|
|
=============================
|
|
两阶段训练 + ModelScope 自动下载数据 + 断点续训
|
|
|
|
用法(云GPU上):
|
|
python3 train_cloud.py # 默认训练
|
|
python3 train_cloud.py --resume # 从最新检查点续训
|
|
python3 train_cloud.py --epochs 10 # 指定预训练轮数
|
|
"""
|
|
import sys, os, json, time, glob, re, math, random
|
|
from pathlib import Path
|
|
|
|
import torch
|
|
import torch.nn as nn
|
|
from torch.utils.data import Dataset, DataLoader
|
|
import numpy as np
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from llm_chat_improved import (
|
|
CharTokenizer, LLMModel, LLMChat,
|
|
ConversationDataset, tokenizer_instance
|
|
)
|
|
|
|
# ── 配置 ──────────────────────────────────────────────────
|
|
class CloudConfig:
|
|
# 模型架构
|
|
d_model: int = 320
|
|
n_heads: int = 8
|
|
n_layers: int = 6
|
|
d_ff: int = 1536
|
|
max_length: int = 256 # 对齐用
|
|
dropout: float = 0.1
|
|
use_memory: bool = False # 关掉记忆/思考/MoE 加速
|
|
use_thinking: bool = False
|
|
use_moe: bool = False
|
|
|
|
# 预训练
|
|
pt_batch_size: int = 64 # 32G显存可跑64, 24G改32
|
|
pt_grad_accum: int = 1 # 大batch不需要累积
|
|
pt_epochs: int = 5
|
|
pt_lr: float = 5e-4
|
|
pt_min_lr: float = 5e-5
|
|
pt_warmup_steps: int = 200
|
|
pt_max_length: int = 256 # 长序列学更好
|
|
pt_weight_decay: float = 0.1
|
|
pt_max_norm: float = 1.0
|
|
|
|
# 对齐
|
|
align_batch_size: int = 32
|
|
align_grad_accum: int = 1
|
|
align_epochs: int = 20
|
|
align_lr: float = 1e-4
|
|
align_min_lr: float = 1e-5
|
|
align_warmup_steps: int = 50
|
|
align_weight_decay: float = 0.01
|
|
align_max_norm: float = 1.0
|
|
|
|
# 通用
|
|
device: str = "cuda" if torch.cuda.is_available() else "cpu"
|
|
num_workers: int = 4
|
|
use_amp: bool = True
|
|
seed: int = 42
|
|
save_dir: str = "/root/xiaoli-llm-chat" # AutoDL 默认路径
|
|
log_every: int = 10
|
|
save_interval: int = 1
|
|
keep_checkpoints: int = 3
|
|
|
|
|
|
# ── 词表加载 ──────────────────────────────────────────────
|
|
def load_vocab(vocab_path: str) -> dict:
|
|
special = {'<PAD>': 0, '<UNK>': 1, '<SOS>': 2, '<EOS>': 3,
|
|
'<START>': 4, '<END>': 5, '<SEP>': 6}
|
|
with open(vocab_path, 'r') as f:
|
|
normal_vocab = json.load(f)
|
|
full_vocab = {**special, **normal_vocab}
|
|
# 同步到全局 tokenizer
|
|
tokenizer_instance.char_to_id = full_vocab.copy()
|
|
tokenizer_instance.id_to_char = {v: k for k, v in full_vocab.items()}
|
|
for attr in ['_multi_trie', '_multi_keys', '_single_map']:
|
|
if hasattr(tokenizer_instance, attr): delattr(tokenizer_instance, attr)
|
|
return full_vocab
|
|
|
|
|
|
# ── 预训练数据集(流式加载,不爆内存)────────────────────
|
|
class PretrainDataset(Dataset):
|
|
def __init__(self, data_dir: str, vocab: dict, max_length: int = 128):
|
|
self.vocab = vocab
|
|
self.max_length = max_length
|
|
self.pad_id = vocab.get('<PAD>', 0)
|
|
self.texts = []
|
|
data_dir = Path(data_dir)
|
|
for fpath in sorted(data_dir.glob("*")):
|
|
if fpath.suffix in ('.txt', '.json', '.jsonl'):
|
|
self._load_file(fpath)
|
|
if not self.texts:
|
|
raise ValueError(f"预训练数据目录为空: {data_dir}")
|
|
total_chars = sum(len(t) for t in self.texts)
|
|
print(f" 预训练数据: {len(self.texts):,} 条, {total_chars:,} 字符")
|
|
|
|
def _load_file(self, path: Path):
|
|
try:
|
|
if path.suffix == '.jsonl':
|
|
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
for line in f:
|
|
try:
|
|
item = json.loads(line)
|
|
t = item.get('text', '').strip()
|
|
if len(t) >= 10: self.texts.append(t)
|
|
except: continue
|
|
except: pass
|
|
|
|
def __len__(self): return len(self.texts)
|
|
|
|
def __getitem__(self, idx):
|
|
text = self.texts[idx]
|
|
tokens = tokenizer_instance.encode(text)
|
|
if len(tokens) > self.max_length:
|
|
start = random.randint(0, len(tokens) - self.max_length)
|
|
tokens = tokens[start:start + self.max_length]
|
|
else:
|
|
tokens = tokens + [self.pad_id] * (self.max_length - len(tokens))
|
|
input_ids = torch.tensor(tokens[:-1], dtype=torch.long)
|
|
target_ids = torch.tensor(tokens[1:], dtype=torch.long)
|
|
loss_mask = torch.ones_like(target_ids)
|
|
return {'input_ids': input_ids, 'target_ids': target_ids,
|
|
'loss_mask': loss_mask, 'weights': 1.0}
|
|
|
|
|
|
# ── 训练器 ──────────────────────────────────────────────
|
|
class CloudTrainer:
|
|
def __init__(self, model: nn.Module, config: CloudConfig, stage: str = "pretrain"):
|
|
self.model = model.to(config.device)
|
|
self.device = config.device
|
|
self.config = config
|
|
self.stage = stage
|
|
self.criterion = nn.CrossEntropyLoss(ignore_index=0, label_smoothing=0.1)
|
|
|
|
if stage == "pretrain":
|
|
base_lr = config.pt_lr
|
|
wd = config.pt_weight_decay
|
|
else:
|
|
base_lr = config.align_lr
|
|
wd = config.align_weight_decay
|
|
|
|
# 分层学习率:embedding/fc_out 用半速
|
|
param_groups = []
|
|
for name, param in model.named_parameters():
|
|
if not param.requires_grad: continue
|
|
if 'embedding' in name or 'fc_out' in name:
|
|
param_groups.append({'params': param, 'lr': base_lr * 0.5, 'weight_decay': wd})
|
|
elif 'norm' in name or 'rmsnorm' in name:
|
|
param_groups.append({'params': param, 'lr': base_lr, 'weight_decay': 0.0})
|
|
else:
|
|
param_groups.append({'params': param, 'lr': base_lr, 'weight_decay': wd})
|
|
|
|
self.optimizer = torch.optim.AdamW(param_groups, lr=base_lr, betas=(0.9, 0.95), eps=1e-8)
|
|
self.scaler = torch.cuda.amp.GradScaler(enabled=True)
|
|
self.scheduler = None
|
|
|
|
if torch.cuda.is_available():
|
|
torch.backends.cuda.matmul.allow_tf32 = True
|
|
torch.backends.cudnn.allow_tf32 = True
|
|
torch.backends.cudnn.benchmark = True
|
|
|
|
def build_scheduler(self, total_steps: int):
|
|
if self.stage == "pretrain":
|
|
warmup = self.config.pt_warmup_steps
|
|
min_lr = self.config.pt_min_lr
|
|
else:
|
|
warmup = self.config.align_warmup_steps
|
|
min_lr = self.config.align_min_lr
|
|
base_lr = self.optimizer.param_groups[0]['lr']
|
|
|
|
def lr_lambda(step):
|
|
if step < warmup:
|
|
return step / max(1, warmup)
|
|
progress = (step - warmup) / max(1, total_steps - warmup)
|
|
cosine = 0.5 * (1 + math.cos(math.pi * progress))
|
|
return (1 - min_lr / base_lr) * cosine + min_lr / base_lr
|
|
|
|
self.scheduler = torch.optim.lr_scheduler.LambdaLR(self.optimizer, lr_lambda)
|
|
|
|
def train_epoch(self, dataloader, epoch, total_epochs, stage_name="pretrain"):
|
|
self.model.train()
|
|
total_loss = 0
|
|
n_batches = len(dataloader)
|
|
accum_steps = self.config.pt_grad_accum if stage_name == "pretrain" else self.config.align_grad_accum
|
|
max_norm = self.config.pt_max_norm if stage_name == "pretrain" else self.config.align_max_norm
|
|
|
|
self.optimizer.zero_grad()
|
|
for batch_idx, batch in enumerate(dataloader):
|
|
input_ids = batch['input_ids'].to(self.device, non_blocking=True)
|
|
target_ids = batch['target_ids'].to(self.device, non_blocking=True)
|
|
mask = self.model.generate_mask(input_ids.size(1)).to(self.device, non_blocking=True)
|
|
|
|
with torch.amp.autocast('cuda', enabled=True):
|
|
outputs = self.model(input_ids, mask)
|
|
loss = self.criterion(outputs.reshape(-1, outputs.size(-1)), target_ids.reshape(-1))
|
|
loss = loss / accum_steps
|
|
|
|
self.scaler.scale(loss).backward()
|
|
|
|
if (batch_idx + 1) % accum_steps == 0:
|
|
self.scaler.unscale_(self.optimizer)
|
|
torch.nn.utils.clip_grad_norm_(self.model.parameters(), max_norm)
|
|
self.scaler.step(self.optimizer)
|
|
self.scaler.update()
|
|
self.optimizer.zero_grad()
|
|
if self.scheduler: self.scheduler.step()
|
|
|
|
total_loss += loss.item() * accum_steps
|
|
|
|
if (batch_idx + 1) % max(1, n_batches // 10) == 0:
|
|
lr = self.optimizer.param_groups[0]['lr']
|
|
mem = f" | Mem: {torch.cuda.max_memory_allocated()/1024**3:.1f}GiB" if torch.cuda.is_available() else ""
|
|
print(f" [{stage_name}] Epoch {epoch}/{total_epochs} | "
|
|
f"Batch {batch_idx+1}/{n_batches} | Loss: {total_loss/(batch_idx+1):.4f} | LR: {lr:.2e}{mem}")
|
|
|
|
return total_loss / n_batches
|
|
|
|
def save_checkpoint(self, path: str, vocab: dict, epoch: int, loss: float):
|
|
core = getattr(self.model, '_orig_mod', self.model)
|
|
torch.save({
|
|
'model_state_dict': core.state_dict(),
|
|
'optimizer_state_dict': self.optimizer.state_dict(),
|
|
'scheduler_state_dict': self.scheduler.state_dict() if self.scheduler else None,
|
|
'vocab': vocab,
|
|
'epoch': epoch,
|
|
'loss': loss,
|
|
'config': {k: getattr(self.config, k) for k in dir(self.config)
|
|
if not k.startswith('_') and not callable(getattr(self.config, k))},
|
|
}, path)
|
|
print(f" ✅ 已保存: {path} (epoch {epoch}, loss {loss:.4f})")
|
|
|
|
def load_checkpoint(self, path: str) -> tuple:
|
|
cp = torch.load(path, map_location=self.device, weights_only=False)
|
|
core = getattr(self.model, '_orig_mod', self.model)
|
|
core.load_state_dict(cp['model_state_dict'])
|
|
self.optimizer.load_state_dict(cp['optimizer_state_dict'])
|
|
if cp.get('scheduler_state_dict') and self.scheduler:
|
|
self.scheduler.load_state_dict(cp['scheduler_state_dict'])
|
|
return cp.get('epoch', 0), cp.get('loss', float('inf')), cp.get('vocab', {})
|
|
|
|
|
|
# ── 数据下载 ──────────────────────────────────────────────
|
|
def download_data(save_dir: str):
|
|
"""从 ModelScope 下载预训练数据"""
|
|
data_dir = os.path.join(save_dir, 'pretrain_data')
|
|
if os.path.exists(data_dir) and list(Path(data_dir).glob("*.jsonl")):
|
|
print(" 预训练数据已存在,跳过下载")
|
|
return data_dir
|
|
|
|
os.makedirs(data_dir, exist_ok=True)
|
|
print(" 📥 从 ModelScope 下载预训练数据...")
|
|
try:
|
|
import subprocess
|
|
subprocess.run([
|
|
sys.executable, '-m', 'pip', 'install', 'modelscope', '-q'
|
|
], check=True, capture_output=True)
|
|
subprocess.run([
|
|
sys.executable, '-m', 'modelscope', 'download',
|
|
'--dataset', 'gongjy/minimind_dataset',
|
|
'--include', 'pretrain_t2t_mini*',
|
|
'--local_dir', data_dir,
|
|
], check=True)
|
|
print(f" ✅ 下载完成: {data_dir}")
|
|
except Exception as e:
|
|
print(f" ⚠️ 自动下载失败: {e}")
|
|
print(f" ⚠️ 请手动下载放到 {data_dir}")
|
|
print(f" ⚠️ 命令: modelscope download --dataset gongjy/minimind_dataset --include 'pretrain_t2t_mini*' --local_dir {data_dir}")
|
|
return data_dir
|
|
|
|
|
|
# ── 主函数 ──────────────────────────────────────────────
|
|
def main():
|
|
import argparse
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--resume", action="store_true", help="从最新检查点续训")
|
|
parser.add_argument("--epochs", type=int, default=5, help="预训练轮数")
|
|
parser.add_argument("--align-epochs", type=int, default=20, help="对齐轮数")
|
|
parser.add_argument("--save-dir", type=str, default="/root/xiaoli-llm-chat")
|
|
parser.add_argument("--batch-size", type=int, default=0, help="覆盖batch大小")
|
|
args = parser.parse_args()
|
|
|
|
config = CloudConfig()
|
|
config.pt_epochs = args.epochs
|
|
config.align_epochs = args.align_epochs
|
|
config.save_dir = args.save_dir
|
|
|
|
if args.batch_size > 0:
|
|
config.pt_batch_size = args.batch_size
|
|
config.align_batch_size = args.batch_size // 2
|
|
|
|
if not torch.cuda.is_available():
|
|
print("⚠️ 未检测到 GPU!云端训练建议使用 AutoDL / 矩池云 / 恒源云")
|
|
|
|
print("=" * 65)
|
|
print(f" XiaLi 云端训练")
|
|
print(f" GPU: {torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'N/A'}")
|
|
print(f" 显存: {torch.cuda.get_device_properties(0).total_mem / 1024**3:.1f} GiB" if torch.cuda.is_available() else "")
|
|
print(f" 模型: {config.d_model}d, {config.n_layers}L, {config.n_heads}H")
|
|
print(f" 预训练: {config.pt_epochs} epochs, batch={config.pt_batch_size}")
|
|
print(f" 对齐: {config.align_epochs} epochs, batch={config.align_batch_size}")
|
|
print("=" * 65)
|
|
|
|
os.makedirs(config.save_dir, exist_ok=True)
|
|
|
|
# 1. 加载词表
|
|
vocab_path = os.path.join(config.save_dir, 'vocab.json')
|
|
if not os.path.exists(vocab_path):
|
|
print(f"❌ 未找到 {vocab_path}")
|
|
print(f" 请从 Gitee 仓库获取: https://gitee.com/shuiyu1123/xiaoli-llm-chat")
|
|
return
|
|
vocab = load_vocab(vocab_path)
|
|
print(f"词表: {len(vocab)} tokens, vocab_size={max(vocab.values())+1}")
|
|
|
|
# 2. 创建模型
|
|
model = LLMModel(
|
|
vocab_size=max(vocab.values())+1,
|
|
d_model=config.d_model,
|
|
n_heads=config.n_heads,
|
|
n_layers=config.n_layers,
|
|
d_ff=config.d_ff,
|
|
max_len=config.max_length,
|
|
dropout=config.dropout,
|
|
use_memory=False, use_thinking=False, use_moe=False,
|
|
)
|
|
params = sum(p.numel() for p in model.parameters())
|
|
print(f"参数量: {params:,} ({params/1e6:.2f}M)")
|
|
|
|
# 3. 续训检测
|
|
start_epoch = 1
|
|
if args.resume:
|
|
checkpoints = sorted(glob.glob(os.path.join(config.save_dir, 'pretrain_epoch_*.pth')))
|
|
if checkpoints:
|
|
latest = checkpoints[-1]
|
|
trainer_pt = CloudTrainer(model, config, stage="pretrain")
|
|
loaded_epoch, loaded_loss, _ = trainer_pt.load_checkpoint(latest)
|
|
start_epoch = loaded_epoch + 1
|
|
print(f" 从 {latest} 续训 (epoch {loaded_epoch}, loss {loaded_loss:.4f})")
|
|
|
|
# 4. 下载数据
|
|
pt_dir = download_data(config.save_dir)
|
|
|
|
# 5. 预训练
|
|
print(f"\n{'='*65}")
|
|
print(f" 阶段一:预训练 ({config.pt_epochs} epochs)")
|
|
print(f"{'='*65}")
|
|
|
|
pt_dataset = PretrainDataset(pt_dir, vocab, config.pt_max_length)
|
|
pt_loader = DataLoader(
|
|
pt_dataset, batch_size=config.pt_batch_size, shuffle=True,
|
|
num_workers=config.num_workers,
|
|
pin_memory=True,
|
|
)
|
|
|
|
trainer = CloudTrainer(model, config, stage="pretrain")
|
|
total_steps = len(pt_loader) * config.pt_epochs
|
|
trainer.build_scheduler(total_steps)
|
|
|
|
for epoch in range(start_epoch, config.pt_epochs + 1):
|
|
t0 = time.time()
|
|
loss = trainer.train_epoch(pt_loader, epoch, config.pt_epochs, "pretrain")
|
|
epoch_time = time.time() - t0
|
|
lr = trainer.optimizer.param_groups[0]['lr']
|
|
print(f" → Epoch {epoch}/{config.pt_epochs} | Loss: {loss:.4f} | LR: {lr:.2e} | 耗时: {epoch_time:.0f}s")
|
|
|
|
if epoch % config.save_interval == 0:
|
|
ckpt_path = os.path.join(config.save_dir, f'pretrain_epoch_{epoch}.pth')
|
|
trainer.save_checkpoint(ckpt_path, vocab, epoch, loss)
|
|
# 清理旧检查点
|
|
all_ckpts = sorted(glob.glob(os.path.join(config.save_dir, 'pretrain_epoch_*.pth')))
|
|
for old in all_ckpts[:-config.keep_checkpoints]:
|
|
os.remove(old)
|
|
|
|
print(f"\n ✅ 预训练完成")
|
|
|
|
# 6. 对齐
|
|
print(f"\n{'='*65}")
|
|
print(f" 阶段二:角色对齐 ({config.align_epochs} epochs)")
|
|
print(f"{'='*65}")
|
|
|
|
merged_path = os.path.join(config.save_dir, 'xiaoli_merged_data_v2.json')
|
|
if not os.path.exists(merged_path):
|
|
print(f" ⚠️ 未找到合并数据: {merged_path}")
|
|
print(f" ⚠️ 跳过对齐阶段")
|
|
else:
|
|
with open(merged_path, 'r') as f:
|
|
raw_data = json.load(f)
|
|
pairs = []
|
|
for item in raw_data:
|
|
q = item.get("question", "") or item.get("input", "") or item.get("prompt", "")
|
|
a = item.get("answer", "") or item.get("output", "") or item.get("response", "")
|
|
if q and a: pairs.append((q, a))
|
|
|
|
random.seed(config.seed)
|
|
random.shuffle(pairs)
|
|
split = int(len(pairs) * 0.95)
|
|
train_pairs = pairs[:split]
|
|
print(f" 对齐数据: {len(pairs)} 条 (训练 {len(train_pairs)} + 验证 {len(pairs)-split})")
|
|
|
|
train_ds = ConversationDataset(train_pairs, vocab, config.max_length)
|
|
train_loader = DataLoader(
|
|
train_ds, batch_size=config.align_batch_size, shuffle=True,
|
|
num_workers=config.num_workers, pin_memory=True,
|
|
)
|
|
|
|
trainer2 = CloudTrainer(model, config, stage="align")
|
|
total_align_steps = len(train_loader) * config.align_epochs
|
|
trainer2.build_scheduler(total_align_steps)
|
|
|
|
for epoch in range(1, config.align_epochs + 1):
|
|
t0 = time.time()
|
|
loss = trainer2.train_epoch(train_loader, epoch, config.align_epochs, "align")
|
|
epoch_time = time.time() - t0
|
|
print(f" → Epoch {epoch}/{config.align_epochs} | Loss: {loss:.4f} | 耗时: {epoch_time:.0f}s")
|
|
|
|
if epoch % 5 == 0:
|
|
ckpt_path = os.path.join(config.save_dir, f'xiaoli_align_epoch_{epoch}.pth')
|
|
trainer2.save_checkpoint(ckpt_path, vocab, epoch, loss)
|
|
|
|
# 保存最终模型
|
|
final_path = os.path.join(config.save_dir, 'xiaoli_final.pth')
|
|
trainer2.save_checkpoint(final_path, vocab, config.align_epochs, loss)
|
|
print(f"\n ✅ 对齐完成!最终模型: {final_path}")
|
|
|
|
print(f"\n{'='*65}")
|
|
print(f" 🎉 训练完成!")
|
|
print(f" 模型保存在: {config.save_dir}")
|
|
print(f" {'='*65}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |