Guanghu Domestic Migration d1e47f4565
Some checks are pending
自动更新代码和重启 / update-and-restart (push) Waiting to run
CI检查 + 自动部署 / check (push) Waiting to run
CI检查 + 自动部署 / deploy (push) Blocked by required conditions
重启聊天服务 / restart (push) Waiting to run
chore: import sanitized domestic snapshot for REPO-002
Source snapshot: ca48d3ddf926d79aa138306164169baf764bb829
2026-07-17 15:54:41 +08:00

431 lines
17 KiB
Python
Raw 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.

#!/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())