431 lines
17 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
"""
Qwen2.5-7B 全参数 SFT 训练入口 · train.py
签发: 铸渊 · ICE-GL-ZY001 · 国作登字-2026-A-00037559
V100 32G × 4 上的 Qwen2.5-7B 全参 SFT.
策略: DeepSpeed ZeRO-3 + 优化器 CPU offload + gradient checkpointing + fp16
启动方式 ( start-training.sh 调用):
deepspeed --num_gpus=4 train.py
stdout 协议( watch-training-output.sh 解析):
ZY_PROGRESS step=N total=M loss=X lr=Y epoch=E total_epochs=TE thr=T
环境变量:
ZY_TRAIN_DATA 数据根 (默认 /data/guanghu)
ZY_MODEL_DIR 模型路径 (默认 $ZY_TRAIN_DATA/models/Qwen2.5-7B)
ZY_DATA_PATH SFT JSONL (默认 $ZY_TRAIN_DATA/processed/sft.jsonl)
ZY_OUTPUT_DIR checkpoint 输出 (默认 $ZY_TRAIN_DATA/checkpoints/qwen2_5_7b_sft)
ZY_DS_CONFIG DeepSpeed json (默认 server/training-agent/configs/ds_zero3_offload.json)
ZY_NUM_EPOCHS 默认 3
ZY_LR 默认 2e-5
ZY_MAX_SEQ_LEN 默认 2048
ZY_PER_DEVICE_BSZ 默认 1
ZY_GRAD_ACCUM 默认 16
ZY_SAVE_STEPS 默认 200
ZY_LOGGING_STEPS 默认 5
ZY_REPORT_EVERY_STEPS 默认 5 (ZY_PROGRESS 协议输出节流)
"""
from __future__ import annotations
import json
import math
import multiprocessing as _mp
import os
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
# ── multi-rank × multi-proc fork 安全护栏 ──
# 必须在 import torch / transformers / datasets 之前生效:
# 1. 关掉 fast tokenizer 的 Rust 线程池, 避免 fork 后子进程死锁/abort
# (这是 deepspeed 多 rank 同时在 datasets.map 里 fork worker 时
# iflatmap_unordered 静默崩溃的最常见根因).
# 2. 把 multiprocessing 默认启动方式从 fork 切到 spawn, 即使后续有人
# 把 ZY_MAP_NUM_PROC 调高也安全.
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
try:
# force=True 会强制覆盖已有的 start method;
# 极少数情况下 (子解释器/已有活跃池) 仍会抛 RuntimeError, 此时降级跳过.
_mp.set_start_method("spawn", force=True)
except RuntimeError:
pass
import torch
from datasets import load_dataset
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
Trainer,
TrainingArguments,
TrainerCallback,
set_seed,
)
# ── 配置 ──
DATA_ROOT = Path(os.environ.get("ZY_TRAIN_DATA", "/data/guanghu"))
MODEL_DIR = Path(os.environ.get("ZY_MODEL_DIR", str(DATA_ROOT / "models" / "Qwen2.5-7B")))
DATA_PATH = Path(os.environ.get("ZY_DATA_PATH", str(DATA_ROOT / "processed" / "sft.jsonl")))
OUTPUT_DIR = Path(os.environ.get("ZY_OUTPUT_DIR", str(DATA_ROOT / "checkpoints" / "qwen2_5_7b_sft")))
DS_CONFIG = Path(os.environ.get("ZY_DS_CONFIG", str(Path(__file__).parent / "configs" / "ds_zero3_offload.json")))
NUM_EPOCHS = float(os.environ.get("ZY_NUM_EPOCHS", "3"))
LR = float(os.environ.get("ZY_LR", "2e-5"))
MAX_SEQ_LEN = int(os.environ.get("ZY_MAX_SEQ_LEN", "2048"))
PER_DEVICE_BSZ = int(os.environ.get("ZY_PER_DEVICE_BSZ", "1"))
GRAD_ACCUM = int(os.environ.get("ZY_GRAD_ACCUM", "16"))
SAVE_STEPS = int(os.environ.get("ZY_SAVE_STEPS", "200"))
LOGGING_STEPS = int(os.environ.get("ZY_LOGGING_STEPS", "5"))
REPORT_EVERY = int(os.environ.get("ZY_REPORT_EVERY_STEPS", "5"))
SEED = int(os.environ.get("ZY_SEED", "42"))
# datasets.map 的 worker 数. 默认 1 — 这是有意的:
# - deepspeed --num_gpus=N 起 N 个 rank, 每个 rank 都会调一次 build_dataset.
# 如果这里再 fork 出 cpu//2 个 map worker, 就是 N × (cpu//2) 个 fork 子进程
# 同时加载 fast tokenizer + 同时写 datasets cache, 极易触发
# `iflatmap_unordered` 静默崩溃.
# - 几万行 SFT 用 fast tokenizer 串行 tokenize 通常 < 1 分钟, 远小于一次训练代价.
# - 真有大数据集需要并行, 用 ZY_MAP_NUM_PROC=N 显式开 (此时务必只让 rank 0 跑 map,
# 由调用方保证, 这里不再做 multi-rank 同时并行 map 的兼容).
MAP_NUM_PROC = max(1, int(os.environ.get("ZY_MAP_NUM_PROC", "1")))
IGNORE_INDEX = -100
def is_main_process() -> bool:
return int(os.environ.get("LOCAL_RANK", "0")) == 0
def log(msg: str):
if is_main_process():
print(msg, flush=True)
# ── 数据加载 + 模板化 ──
def _resolve_role_anchors(tokenizer) -> dict[str, list[int]]:
"""解析 ChatML 的 assistant 段定位锚点.
返回:
im_start_id <|im_start|> token id
im_end_id <|im_end|> token id
asst_role_ids "assistant\n" 编码后的 token id 序列 (作为内容前的角色头)
思路:
Qwen2.5 chat_template 把每段对话包成 <|im_start|>{role}\n{content}<|im_end|>\n.
<|im_start|> / <|im_end|> special token, BPE 不会跨它们合并,
因此可以在 full_ids 上直接用 token-id 做线性扫描定位 assistant ,
不依赖任何 "前缀对齐" 假设. 这是修复 "有效样本: 0" 故障的关键.
"""
im_start_id = tokenizer.convert_tokens_to_ids("<|im_start|>")
im_end_id = tokenizer.convert_tokens_to_ids("<|im_end|>")
if im_start_id is None or im_end_id is None or im_start_id == tokenizer.unk_token_id:
raise RuntimeError(
"[train] tokenizer 缺少 <|im_start|>/<|im_end|> special token, "
"确认这是 Qwen2.5 系列模型 (而不是兼容包装)."
)
# "assistant\n" 编码为 content token (不带任何 special token)
asst_role_ids = tokenizer.encode("assistant\n", add_special_tokens=False)
if not asst_role_ids:
raise RuntimeError("[train] 无法编码 'assistant\\n' 角色头, tokenizer 异常.")
return {
"im_start_id": im_start_id,
"im_end_id": im_end_id,
"asst_role_ids": asst_role_ids,
}
def _mask_assistant_segments(
full_ids: list[int],
im_start_id: int,
im_end_id: int,
asst_role_ids: list[int],
) -> tuple[list[int], int]:
"""在 full_ids 上线性扫描, 找出所有 assistant 段并返回 labels + 标记到的 token 数.
被标记的范围 = assistant 内容 + 闭合的 <|im_end|> (让模型学会自然停止).
max_seq_len 截断导致最后一段没有闭合的 im_end_id, 则标到序列末尾.
"""
n = len(full_ids)
labels = [IGNORE_INDEX] * n
role_len = len(asst_role_ids)
marked = 0
k = 0
while k < n:
if full_ids[k] != im_start_id:
k += 1
continue
# 检查 <|im_start|> 后是否是 "assistant\n"
if k + role_len < n and full_ids[k + 1 : k + 1 + role_len] == asst_role_ids:
content_start = k + 1 + role_len
# 找到内容结尾的 <|im_end|>
j = content_start
while j < n and full_ids[j] != im_end_id:
j += 1
content_end = min(j, n - 1) # 含 im_end (若存在), 否则到序列末尾
for p in range(content_start, content_end + 1):
labels[p] = full_ids[p]
marked += content_end + 1 - content_start
k = content_end + 1
else:
k += 1
return labels, marked
def build_dataset(tokenizer):
if not DATA_PATH.is_file():
raise FileNotFoundError(f"训练数据不存在: {DATA_PATH} · 请先跑 preprocess-corpus.py")
log(f"[train] 加载数据: {DATA_PATH}")
raw = load_dataset("json", data_files=str(DATA_PATH), split="train")
log(f"[train] 样本数: {len(raw)}")
anchors = _resolve_role_anchors(tokenizer)
im_start_id = anchors["im_start_id"]
im_end_id = anchors["im_end_id"]
asst_role_ids = anchors["asst_role_ids"]
def encode(example: dict[str, Any], idx: int | None = None) -> dict[str, list[int]]:
try:
msgs = example["messages"]
# apply_chat_template 一次性按 Qwen2.5 ChatML 格式化整段对话
full_ids = tokenizer.apply_chat_template(
msgs, tokenize=True, add_generation_prompt=False,
truncation=True, max_length=MAX_SEQ_LEN,
)
labels, _marked = _mask_assistant_segments(
full_ids, im_start_id, im_end_id, asst_role_ids
)
return {"input_ids": full_ids, "labels": labels, "attention_mask": [1] * len(full_ids)}
except Exception as e:
# 让 worker 子进程在崩之前留下"那一条样本是谁"的痕迹.
# iflatmap_unordered 在主进程只能看到 worker 的最终 traceback,
# 这里把样本指纹打到 stderr, 便于事后定位脏数据.
try:
msgs = example.get("messages")
preview = json.dumps(msgs, ensure_ascii=False)[:300] if msgs is not None else "<no messages>"
except Exception:
preview = "<unprintable>"
sys.stderr.write(
f"[train.encode] 样本编码失败 idx={idx} err={type(e).__name__}: {e}\n"
f"[train.encode] messages 预览: {preview}\n"
)
sys.stderr.flush()
raise
cols = raw.column_names
log(f"[train] tokenize map: num_proc={MAP_NUM_PROC} (默认 1, 用 ZY_MAP_NUM_PROC 覆盖)")
ds = raw.map(encode, with_indices=True, remove_columns=cols, num_proc=MAP_NUM_PROC)
# 过滤掉没有 assistant token 的样本 (理论上极少, 留作防御)
ds = ds.filter(lambda ex: any(l != IGNORE_INDEX for l in ex["labels"]))
log(f"[train] 有效样本: {len(ds)}")
# ── "自动门" 防呆守护 ──
# 若一条都没标到, 立即停, 不让 Trainer 在空数据集上裸奔.
# 同时打首条样本的诊断信息, 让下一次定位时 < 30 秒.
if len(ds) == 0:
sample = raw[0] if len(raw) > 0 else None
diag = ["[train] ❌ 有效样本为 0 — 标记环节失效, 终止训练."]
if sample is not None:
try:
fid = tokenizer.apply_chat_template(
sample["messages"], tokenize=True, add_generation_prompt=False,
truncation=True, max_length=MAX_SEQ_LEN,
)
preview = tokenizer.decode(fid[:200], skip_special_tokens=False)
diag.append(f"[train] 诊断: im_start_id={im_start_id} im_end_id={im_end_id} "
f"asst_role_ids={asst_role_ids}")
diag.append(f"[train] 诊断: 首条样本 token 数={len(fid)}")
diag.append(f"[train] 诊断: 首条样本前 200 token 解码=\n{preview}")
except Exception as e:
diag.append(f"[train] 诊断失败: {e}")
for line in diag:
log(line)
raise RuntimeError("有效样本为 0 — 见上方诊断.")
# 标注质量统计 — "门会回报它做了什么"
if is_main_process():
try:
sample_n = min(64, len(ds))
asst_tokens = 0
total_tokens = 0
for i in range(sample_n):
row = ds[i]
total_tokens += len(row["labels"])
asst_tokens += sum(1 for l in row["labels"] if l != IGNORE_INDEX)
ratio = asst_tokens / total_tokens if total_tokens else 0.0
log(f"[train] 标注统计 (前 {sample_n} 条采样): "
f"平均 assistant token 占比={ratio:.2%} "
f"(平均 {asst_tokens // sample_n} tok/样本)")
except Exception as e:
log(f"[train] 标注统计失败(非致命): {e}")
return ds
@dataclass
class PadCollator:
tokenizer: Any
pad_to_multiple_of: int = 8
def __call__(self, features: list[dict]) -> dict[str, torch.Tensor]:
max_len = max(len(f["input_ids"]) for f in features)
if self.pad_to_multiple_of > 1:
max_len = math.ceil(max_len / self.pad_to_multiple_of) * self.pad_to_multiple_of
pad_id = self.tokenizer.pad_token_id
if pad_id is None:
pad_id = self.tokenizer.eos_token_id
def _pad(seq: list[int], val: int) -> list[int]:
return seq + [val] * (max_len - len(seq))
input_ids = torch.tensor([_pad(f["input_ids"], pad_id) for f in features], dtype=torch.long)
labels = torch.tensor([_pad(f["labels"], IGNORE_INDEX) for f in features], dtype=torch.long)
attn = torch.tensor([_pad(f["attention_mask"], 0) for f in features], dtype=torch.long)
return {"input_ids": input_ids, "labels": labels, "attention_mask": attn}
# ── 心跳协议: Trainer Callback ──
class ZYProgressCallback(TrainerCallback):
"""每 REPORT_EVERY 步输出 stdout 协议行,被 watch-training-output.sh 解析."""
def __init__(self):
self.t0 = time.time()
self.last_step = -1
def on_log(self, args, state, control, logs=None, **kwargs): # noqa: D401
if not is_main_process() or not logs:
return
step = state.global_step or 0
if step == self.last_step:
return
if step > 0 and (step - self.last_step) < REPORT_EVERY and step != state.max_steps:
return
self.last_step = step
loss = logs.get("loss") if "loss" in logs else logs.get("train_loss")
lr = logs.get("learning_rate")
elapsed = max(time.time() - self.t0, 1e-6)
thr = step / elapsed if step > 0 else 0.0
epoch = math.floor(state.epoch or 0)
line = (
f"ZY_PROGRESS step={step} total={state.max_steps} "
f"epoch={epoch} total_epochs={int(args.num_train_epochs)} "
f"loss={loss if loss is not None else 'nan'} "
f"lr={lr if lr is not None else 'nan'} "
f"thr={thr:.4f}"
)
print(line, flush=True)
def main() -> int:
set_seed(SEED)
if not MODEL_DIR.is_dir():
log(f"❌ 模型目录不存在: {MODEL_DIR} · 请先跑 download-model.py")
return 2
if not DS_CONFIG.is_file():
log(f"❌ DeepSpeed 配置不存在: {DS_CONFIG}")
return 2
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
log(f"[train] 模型: {MODEL_DIR}")
log(f"[train] 数据: {DATA_PATH}")
log(f"[train] 输出: {OUTPUT_DIR}")
log(f"[train] DS: {DS_CONFIG}")
tokenizer = AutoTokenizer.from_pretrained(str(MODEL_DIR), trust_remote_code=True, use_fast=True)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
train_ds = build_dataset(tokenizer)
log("[train] 加载模型 (fp16)...")
model = AutoModelForCausalLM.from_pretrained(
str(MODEL_DIR),
dtype=torch.float16,
trust_remote_code=True,
use_cache=False, # 与 gradient_checkpointing 冲突
)
model.gradient_checkpointing_enable()
if hasattr(model, "enable_input_require_grads"):
model.enable_input_require_grads()
args = TrainingArguments(
output_dir=str(OUTPUT_DIR),
num_train_epochs=NUM_EPOCHS,
per_device_train_batch_size=PER_DEVICE_BSZ,
gradient_accumulation_steps=GRAD_ACCUM,
learning_rate=LR,
warmup_ratio=0.03,
lr_scheduler_type="cosine",
weight_decay=0.0,
max_grad_norm=1.0,
fp16=True,
bf16=False,
gradient_checkpointing=True,
logging_steps=LOGGING_STEPS,
save_steps=SAVE_STEPS,
save_total_limit=3,
save_strategy="steps",
report_to=["tensorboard"],
deepspeed=str(DS_CONFIG),
ddp_find_unused_parameters=False,
dataloader_num_workers=2,
dataloader_pin_memory=True,
remove_unused_columns=False,
seed=SEED,
)
trainer = Trainer(
model=model,
args=args,
train_dataset=train_ds,
data_collator=PadCollator(tokenizer=tokenizer),
callbacks=[ZYProgressCallback()],
)
if is_main_process():
# 写一个供副将查询的训练 meta
try:
(OUTPUT_DIR / "training-meta.json").write_text(
json.dumps({
"model": str(MODEL_DIR),
"data": str(DATA_PATH),
"max_seq_len": MAX_SEQ_LEN,
"num_train_epochs": NUM_EPOCHS,
"per_device_batch": PER_DEVICE_BSZ,
"grad_accum": GRAD_ACCUM,
"effective_batch": PER_DEVICE_BSZ * GRAD_ACCUM * max(1, torch.cuda.device_count()),
"lr": LR,
"fp16": True,
"deepspeed_config": str(DS_CONFIG),
}, indent=2, ensure_ascii=False),
encoding="utf-8",
)
except Exception as e:
log(f"[train] meta 写入失败(非致命): {e}")
log("[train] 🔥 开始训练")
train_result = trainer.train()
log("[train] 训练结束 · 保存最终模型...")
trainer.save_model(str(OUTPUT_DIR / "final"))
trainer.save_state()
if is_main_process():
try:
tokenizer.save_pretrained(str(OUTPUT_DIR / "final"))
except Exception as e:
log(f"[train] tokenizer 保存失败(非致命): {e}")
log(f"[train] ✅ 完成 · global_step={trainer.state.global_step} · loss={train_result.training_loss:.4f}")
return 0
if __name__ == "__main__":
sys.exit(main())