minimind/trainer/trainer_utils.py

594 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""
训练工具函数集合
"""
import os
import sys
__package__ = "trainer"
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
import re
import json
import random
import math
import shutil
import numpy as np
import torch
import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel
from torch.utils.data import Sampler
from transformers import AutoTokenizer, AutoModel, AutoModelForSequenceClassification
from model.model_minimind import MiniMindConfig, MiniMindForCausalLM
from model.model_minimind_mla import MiniMindMLAConfig, MiniMindMLAForCausalLM
DEFAULT_DEEPSPEED_CONFIG = os.path.join(os.path.dirname(__file__), "ds_config_zero2.json")
def _resolve_repo_path(path):
if os.path.isabs(path) and os.path.exists(path):
return path
if os.path.exists(path):
return path
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
candidate = os.path.join(repo_root, path)
return candidate
def add_model_profile_args(parser):
parser.add_argument("--model_profile", type=str, default="", help="SearchLM模型规模配置名,如 SearchLM-100M/SearchLM-300M")
parser.add_argument("--model_profiles_path", type=str, default="configs/searchlm_profiles.json", help="模型规模配置文件")
def add_deepspeed_args(parser, default_config=DEFAULT_DEEPSPEED_CONFIG):
parser.add_argument("--deepspeed_config", type=str, default=default_config, help="DeepSpeed ZeRO配置JSON")
parser.add_argument("--local_rank", type=int, default=-1, help="DeepSpeed launcher自动注入")
def apply_model_profile(args):
"""将 SearchLM profile 写入 argparse args;显式传空则不修改。"""
profile_name = getattr(args, "model_profile", "")
if not profile_name:
return None
profiles_path = _resolve_repo_path(getattr(args, "model_profiles_path", "configs/searchlm_profiles.json"))
with open(profiles_path, "r", encoding="utf-8") as f:
profiles = json.load(f)
if profile_name not in profiles:
raise ValueError(f"Unknown model_profile={profile_name}. Available: {', '.join(profiles)}")
profile = profiles[profile_name]
if "hidden_size" not in profile:
raise ValueError(f"Profile {profile_name} is not a trainable from-scratch profile")
fields = [
"hidden_size", "num_hidden_layers", "num_attention_heads", "num_key_value_heads",
"attention_type", "intermediate_size", "kv_lora_rank", "q_lora_rank", "rope_dim", "max_seq_len"
]
for field in fields:
if field in profile and hasattr(args, field):
setattr(args, field, profile[field])
return profile
def get_model_suffix(lm_config):
"""根据模型配置返回 checkpoint 文件名后缀"""
suffix = ''
if getattr(lm_config, 'use_moe', False): suffix += '_moe'
attention_type = getattr(lm_config, "attention_type", "gqa")
if isinstance(lm_config, MiniMindMLAConfig) or attention_type == "mla":
suffix += '_mla'
elif attention_type in {"mha", "mqa"}:
suffix += f'_{attention_type}'
return suffix
def unwrap_model(model):
"""兼容 DDP / DeepSpeed / torch.compile 的模型解包。"""
raw_model = getattr(model, "module", model)
return getattr(raw_model, "_orig_mod", raw_model)
def get_model_weight_path(lm_config, save_dir="../out", weight="model", epoch=None, step=None):
model_suffix = get_model_suffix(lm_config)
step_suffix = f"_epoch{epoch}_step{step}" if epoch is not None and step is not None else ""
return f"{save_dir}/{weight}_{lm_config.hidden_size}{model_suffix}{step_suffix}.pth"
def save_model_weights(lm_config, model, save_dir="../out", weight="model", dtype=torch.float16):
"""保存一份轻量推理权重;DeepSpeed完整训练状态由 save_checkpoint 单独保存。"""
os.makedirs(save_dir, exist_ok=True)
ckp_path = get_model_weight_path(lm_config, save_dir=save_dir, weight=weight)
raw_model = unwrap_model(model)
state_dict = raw_model.state_dict()
state_dict = {k: v.detach().to(dtype).cpu() for k, v in state_dict.items()}
tmp_path = ckp_path + ".tmp"
torch.save(state_dict, tmp_path)
os.replace(tmp_path, ckp_path)
del state_dict
if torch.cuda.is_available():
torch.cuda.empty_cache()
return ckp_path
def load_deepspeed_config(args, total_steps):
"""加载并补全 DeepSpeed 配置,保持 batch / lr / precision 与 CLI 参数一致。"""
ds_path = _resolve_repo_path(getattr(args, "deepspeed_config", DEFAULT_DEEPSPEED_CONFIG))
with open(ds_path, "r", encoding="utf-8") as f:
cfg = json.load(f)
world_size = dist.get_world_size() if dist.is_initialized() else int(os.environ.get("WORLD_SIZE", "1"))
if cfg.get("train_micro_batch_size_per_gpu") == "auto":
cfg["train_micro_batch_size_per_gpu"] = args.batch_size
if cfg.get("gradient_accumulation_steps") == "auto":
cfg["gradient_accumulation_steps"] = args.accumulation_steps
if cfg.get("train_batch_size") == "auto":
cfg["train_batch_size"] = args.batch_size * args.accumulation_steps * world_size
if "gradient_clipping" in cfg:
cfg["gradient_clipping"] = args.grad_clip
optimizer_cfg = cfg.setdefault("optimizer", {"type": "AdamW", "params": {}})
optimizer_cfg.setdefault("params", {})["lr"] = args.learning_rate
scheduler_cfg = cfg.get("scheduler")
if scheduler_cfg and scheduler_cfg.get("type") == "WarmupDecayLR":
sched_params = scheduler_cfg.setdefault("params", {})
if sched_params.get("warmup_max_lr") == "auto":
sched_params["warmup_max_lr"] = args.learning_rate
if sched_params.get("warmup_num_steps") == "auto":
sched_params["warmup_num_steps"] = max(1, total_steps // 20)
if sched_params.get("total_num_steps") == "auto":
sched_params["total_num_steps"] = max(1, total_steps)
dtype = getattr(args, "dtype", "float16")
cfg.setdefault("fp16", {})["enabled"] = dtype == "float16"
cfg.setdefault("bf16", {})["enabled"] = dtype == "bfloat16"
return cfg
def get_wandb_id(wandb=None):
if not wandb:
return None
if hasattr(wandb, "get_run"):
run = wandb.get_run()
return getattr(run, "id", None) if run else None
return getattr(wandb, "id", None)
def get_cuda_peak_memory_gb():
if not torch.cuda.is_available():
return None
return torch.cuda.max_memory_allocated(torch.cuda.current_device()) / (1024 ** 3)
def reduce_metrics(metrics, average=True):
"""聚合所有 rank 的标量指标,避免日志只反映 rank0 局部 batch。"""
if not dist.is_initialized():
return metrics
numeric_items = [(key, value) for key, value in metrics.items() if isinstance(value, (int, float))]
if not numeric_items:
return metrics
device = torch.device("cuda", torch.cuda.current_device()) if torch.cuda.is_available() else torch.device("cpu")
values = torch.tensor([float(value) for _, value in numeric_items], device=device)
dist.all_reduce(values, op=dist.ReduceOp.SUM)
if average:
values /= dist.get_world_size()
reduced = dict(metrics)
for (key, _), value in zip(numeric_items, values.tolist()):
reduced[key] = value
return reduced
def get_deepspeed_tag(lm_config, weight):
return f"{weight}_{lm_config.hidden_size}{get_model_suffix(lm_config)}"
def get_deepspeed_step_tag(lm_config, weight, epoch, step):
return f"{get_deepspeed_tag(lm_config, weight)}_epoch{epoch}_step{step}"
def get_deepspeed_latest_file(lm_config, weight, save_dir="../checkpoints"):
return os.path.join(save_dir, f"{get_deepspeed_tag(lm_config, weight)}_latest")
def _read_deepspeed_latest_tag(lm_config, weight, save_dir="../checkpoints"):
latest_file = get_deepspeed_latest_file(lm_config, weight, save_dir)
if not os.path.exists(latest_file):
return None
with open(latest_file, "r", encoding="utf-8") as f:
tag = f.read().strip()
return tag or None
def _write_deepspeed_latest_tag(lm_config, weight, tag, save_dir="../checkpoints"):
latest_file = get_deepspeed_latest_file(lm_config, weight, save_dir)
tmp_file = latest_file + ".tmp"
with open(tmp_file, "w", encoding="utf-8") as f:
f.write(tag)
os.replace(tmp_file, latest_file)
def _candidate_deepspeed_tags(lm_config, weight, save_dir="../checkpoints"):
base_tag = get_deepspeed_tag(lm_config, weight)
candidates = []
latest_tag = _read_deepspeed_latest_tag(lm_config, weight, save_dir)
if latest_tag:
candidates.append(latest_tag)
candidates.append(base_tag)
if os.path.exists(save_dir):
step_tags = []
prefix = f"{base_tag}_epoch"
for name in os.listdir(save_dir):
path = os.path.join(save_dir, name)
if name.startswith(prefix) and os.path.isdir(path):
step_tags.append((os.path.getmtime(path), name))
candidates.extend(name for _, name in sorted(step_tags, reverse=True))
dedup = []
seen = set()
for tag in candidates:
if tag not in seen:
seen.add(tag)
dedup.append(tag)
return dedup
def _parse_deepspeed_tag_metadata(tag):
match = re.search(r"_epoch(\d+)_step(\d+)$", tag)
if not match:
return {}
return {
"epoch": int(match.group(1)),
"step": int(match.group(2)),
}
def _prune_old_deepspeed_step_tags(lm_config, weight, keep_tag, save_dir="../checkpoints"):
if not os.path.exists(save_dir):
return
base_tag = get_deepspeed_tag(lm_config, weight)
prefix = f"{base_tag}_epoch"
for name in os.listdir(save_dir):
if name == keep_tag or not name.startswith(prefix):
continue
path = os.path.join(save_dir, name)
if os.path.isdir(path):
shutil.rmtree(path, ignore_errors=True)
def assert_no_training_output_collision(lm_config, weight, save_dir="../out", checkpoint_dir="../checkpoints", from_resume=0, overwrite=0):
if int(from_resume) == 1 or int(overwrite) == 1:
return
base_tag = get_deepspeed_tag(lm_config, weight)
latest_weight = get_model_weight_path(lm_config, save_dir=save_dir, weight=weight)
latest_file = get_deepspeed_latest_file(lm_config, weight, checkpoint_dir)
existing = []
if os.path.exists(latest_weight):
existing.append(latest_weight)
if os.path.exists(latest_file):
existing.append(latest_file)
if os.path.exists(os.path.join(checkpoint_dir, base_tag)):
existing.append(os.path.join(checkpoint_dir, base_tag))
if os.path.exists(checkpoint_dir):
prefix = f"{base_tag}_epoch"
for name in os.listdir(checkpoint_dir):
if name.startswith(prefix):
existing.append(os.path.join(checkpoint_dir, name))
break
if not existing:
return
raise RuntimeError(
"检测到同名训练产物,已阻止覆盖。"
f" save_weight={weight}, hidden_size={lm_config.hidden_size}, existing={existing}. "
"如果是继续训练,请加 --from_resume 1;如果是新实验,请换一个 --save_weight;"
"如果确认要覆盖/复用旧目录,请显式加 --overwrite_output 1。"
)
def save_deepspeed_checkpoint(model_engine, lm_config, weight, epoch=0, step=0, wandb=None, save_dir="../checkpoints", **kwargs):
"""DeepSpeed checkpoint 必须所有 rank 都调用,不能只在 rank0 保存。"""
os.makedirs(save_dir, exist_ok=True)
wandb_id = get_wandb_id(wandb) if is_main_process() else None
if dist.is_initialized():
wandb_box = [wandb_id]
dist.broadcast_object_list(wandb_box, src=0)
wandb_id = wandb_box[0]
client_state = {
"epoch": epoch,
"step": step,
"world_size": dist.get_world_size() if dist.is_initialized() else 1,
"wandb_id": wandb_id,
}
client_state.update({k: v for k, v in kwargs.items() if v is not None})
tag = get_deepspeed_step_tag(lm_config, weight, epoch, step)
model_engine.save_checkpoint(save_dir, tag=tag, client_state=client_state)
if is_main_process():
_write_deepspeed_latest_tag(lm_config, weight, tag, save_dir)
_prune_old_deepspeed_step_tags(lm_config, weight, keep_tag=tag, save_dir=save_dir)
Logger(f"DeepSpeed checkpoint saved: {save_dir}/{tag}")
if dist.is_initialized():
dist.barrier()
return tag
def load_deepspeed_checkpoint(model_engine, lm_config, weight, save_dir="../checkpoints"):
errors = []
loaded_tag = None
for tag in _candidate_deepspeed_tags(lm_config, weight, save_dir):
try:
load_path, client_state = model_engine.load_checkpoint(save_dir, tag=tag)
except Exception as exc:
errors.append(f"{tag}: {exc}")
continue
if load_path is None:
errors.append(f"{tag}: load_path=None")
continue
loaded_tag = tag
break
else:
if is_main_process():
Logger("DeepSpeed checkpoint not loaded" + (f": {'; '.join(errors)}" if errors else ""))
return None
client_state = client_state or {}
tag_state = _parse_deepspeed_tag_metadata(loaded_tag or "")
for key, value in tag_state.items():
client_state.setdefault(key, value)
client_state.setdefault("world_size", 1)
saved_ws = client_state.get("world_size", 1)
current_ws = dist.get_world_size() if dist.is_initialized() else 1
if saved_ws != current_ws and is_main_process():
Logger(f"DeepSpeed checkpoint world_size changed: {saved_ws} -> {current_ws}")
if is_main_process():
Logger(
f"DeepSpeed checkpoint loaded: {load_path}, "
f"resume epoch={client_state.get('epoch')}, step={client_state.get('step')}"
)
return client_state
def build_lm_config(hidden_size=768, num_hidden_layers=8, use_moe=False, attention_type="gqa", kv_lora_rank=128, **kwargs):
"""统一构建不同注意力结构的 MiniMind 配置。"""
if attention_type == "mla":
return MiniMindMLAConfig(
hidden_size=hidden_size,
num_hidden_layers=num_hidden_layers,
use_moe=use_moe,
kv_lora_rank=kv_lora_rank,
**kwargs
)
if attention_type not in {"gqa", "mha", "mqa"}:
raise ValueError(f"Unsupported attention_type: {attention_type}")
return MiniMindConfig(
hidden_size=hidden_size,
num_hidden_layers=num_hidden_layers,
use_moe=use_moe,
attention_type=attention_type,
**kwargs
)
def resolve_attention_type(args):
"""兼容旧参数 --use_mla,同时支持新的 --attention_type。"""
if getattr(args, "use_mla", 0):
return "mla"
return getattr(args, "attention_type", "gqa")
def get_model_params(model, config):
total = sum(p.numel() for p in model.parameters()) / 1e6
#getattr用于动态获取对象的属性或方法
n_routed = getattr(config, 'n_routed_experts', getattr(config, 'num_experts', 0))
n_active = getattr(config, 'num_experts_per_tok', 0)
n_shared = getattr(config, 'n_shared_experts', 0)
expert = sum(p.numel() for n, p in model.named_parameters() if 'mlp.experts.0.' in n) / 1e6
shared_expert = sum(p.numel() for n, p in model.named_parameters() if 'mlp.shared_experts.0.' in n) / 1e6
base = total - (expert * n_routed) - (shared_expert * n_shared)
active = base + (expert * n_active) + (shared_expert * n_shared)
if active < total: Logger(f'Model Params: {total:.2f}M-A{active:.2f}M')
else: Logger(f'Model Params: {total:.2f}M')
def log_training_setup(args, lm_config, stage="train", dataset_len=None, iters=None, tokens_per_sample=None, extra=None):
"""打印可直接沉淀到实验报告里的 DDP/训练配置信息。"""
world_size = dist.get_world_size() if dist.is_initialized() else 1
rank = dist.get_rank() if dist.is_initialized() else 0
per_gpu_batch = getattr(args, "batch_size", 1)
accumulation = getattr(args, "accumulation_steps", 1)
effective_batch = per_gpu_batch * world_size * accumulation
max_seq_len = tokens_per_sample or getattr(args, "max_seq_len", None)
tokens_per_step = effective_batch * max_seq_len if max_seq_len else None
attention_type = getattr(lm_config, "attention_type", "gqa")
device = getattr(args, "device", "unknown")
gpu_name = None
if torch.cuda.is_available() and "cuda" in str(device):
try:
gpu_name = torch.cuda.get_device_name(torch.cuda.current_device())
except Exception:
gpu_name = None
if rank == 0:
Logger("=" * 80)
Logger(f"[Training Setup] stage={stage}")
backend = getattr(args, "training_backend", None)
if backend is None:
backend = "DDP" if world_size > 1 else "single-process"
Logger(
f" distributed={backend}, world_size={world_size}, "
f"device={device}" + (f", gpu={gpu_name}" if gpu_name else "")
)
Logger(
f" model: attention_type={attention_type}, hidden_size={lm_config.hidden_size}, "
f"num_layers={lm_config.num_hidden_layers}, use_moe={getattr(lm_config, 'use_moe', False)}"
)
if attention_type == "mla" or isinstance(lm_config, MiniMindMLAConfig):
Logger(
f" mla: kv_lora_rank={lm_config.kv_lora_rank}, q_lora_rank={lm_config.q_lora_rank}, "
f"rope_dim={lm_config.rope_dim}"
)
Logger(
f" batch: per_gpu={per_gpu_batch}, accumulation_steps={accumulation}, "
f"effective_batch={effective_batch}"
)
if tokens_per_step:
Logger(f" sequence: max_seq_len={max_seq_len}, tokens_per_optimizer_step~{tokens_per_step:,}")
if dataset_len is not None:
Logger(f" data: samples={dataset_len:,}" + (f", batches_per_epoch={iters:,}" if iters is not None else ""))
Logger(
f" precision={getattr(args, 'dtype', 'unknown')}, lr={getattr(args, 'learning_rate', 'unknown')}, "
f"epochs={getattr(args, 'epochs', 'unknown')}, compile={getattr(args, 'use_compile', 0)}"
)
if extra:
for key, value in extra.items():
Logger(f" {key}: {value}")
Logger("=" * 80)
def is_main_process():
return not dist.is_initialized() or dist.get_rank() == 0
def Logger(content):
if is_main_process():
print(content)
def get_lr(current_step, total_steps, lr):
return lr*(0.1 + 0.45*(1 + math.cos(math.pi * current_step / total_steps)))
def init_distributed_mode():
if int(os.environ.get("RANK", -1)) == -1:
return 0 # 非DDP模式
dist.init_process_group(backend="nccl")
local_rank = int(os.environ["LOCAL_RANK"])
torch.cuda.set_device(local_rank)
return local_rank
def setup_seed(seed: int):
random.seed(seed)#设置python的随机种子
np.random.seed(seed)#设置numpy的随机种子
torch.manual_seed(seed)#设置cpu随机种子
torch.cuda.manual_seed(seed)#设置pytorch在当前GPU上的随机种子
torch.cuda.manual_seed_all(seed)#设置pytorch在所有GPU上的随机种子
torch.backends.cudnn.deterministic = True#使用确定性算法
torch.backends.cudnn.benchmark = False#禁用cudnn的自动优化
def lm_checkpoint(lm_config, weight='full_sft', model=None, optimizer=None, epoch=0, step=0, wandb=None, save_dir='../checkpoints', **kwargs):
os.makedirs(save_dir, exist_ok=True)
moe_path = get_model_suffix(lm_config)
ckp_path = f'{save_dir}/{weight}_{lm_config.hidden_size}{moe_path}.pth'
resume_path = f'{save_dir}/{weight}_{lm_config.hidden_size}{moe_path}_resume.pth'
if model is not None:#存储模型模式
#如果模型被DDP包了,真正的模型在model.module里
raw_model = unwrap_model(model)
state_dict = raw_model.state_dict()
#half表示转成float16,然后移到cpu上
state_dict = {k: v.half().cpu() for k, v in state_dict.items()}
ckp_tmp = ckp_path + '.tmp'
torch.save(state_dict, ckp_tmp)
#os.replace用于将一个文件替换成另一个文件 os.replace(src, dst) 将 src 文件替换为 dst 文件,如果 dst 已经存在,则会被替换掉。这是一个原子操作,确保在替换过程中不会出现文件损坏或丢失的情况。
os.replace(ckp_tmp, ckp_path)
wandb_id = None
if wandb:
if hasattr(wandb, 'get_run'):
run = wandb.get_run()
wandb_id = getattr(run, 'id', None) if run else None
else:
wandb_id = getattr(wandb, 'id', None)
resume_data = {
'model': state_dict,
'optimizer': optimizer.state_dict(),
'epoch': epoch,
'step': step,
#训练时用了几张GPU
'world_size': dist.get_world_size() if dist.is_initialized() else 1,
'wandb_id': wandb_id
}
for key, value in kwargs.items():
if value is not None:
if hasattr(value, 'state_dict'):
raw_value = unwrap_model(value)
resume_data[key] = raw_value.state_dict()
else:
resume_data[key] = value
resume_tmp = resume_path + '.tmp'
torch.save(resume_data, resume_tmp)
os.replace(resume_tmp, resume_path)
#删除临时变量,然后清理cuda缓存
del state_dict, resume_data
torch.cuda.empty_cache()
else: # 加载模式
if os.path.exists(resume_path):
#加载到gpu
ckp_data = torch.load(resume_path, map_location='cpu')
saved_ws = ckp_data.get('world_size', 1)
current_ws = dist.get_world_size() if dist.is_initialized() else 1
if saved_ws != current_ws:
ckp_data['step'] = ckp_data['step'] * saved_ws // current_ws
Logger(f'GPU数量变化({saved_ws}→{current_ws}),step已自动转换为{ckp_data["step"]}')
return ckp_data
return None
def init_model(lm_config, from_weight='pretrain', tokenizer_path='../model', save_dir='../out', device='cuda'):
tokenizer = AutoTokenizer.from_pretrained(tokenizer_path)
if isinstance(lm_config, MiniMindMLAConfig):
model = MiniMindMLAForCausalLM(lm_config)
else:
model = MiniMindForCausalLM(lm_config)
if from_weight!= 'none':
model_suffix = get_model_suffix(lm_config)
weight_path = f'{save_dir}/{from_weight}_{lm_config.hidden_size}{model_suffix}.pth'
weights = torch.load(weight_path, map_location=device)
model.load_state_dict(weights, strict=False)
get_model_params(model, lm_config)
Logger(f'Trainable Params: {sum(p.numel() for p in model.parameters() if p.requires_grad) / 1e6:.3f}M')
return model.to(device), tokenizer
#对sampler的重写,主要重写iter方法和len方法,sampler决定取哪些数据,给dataloaer拿到索引后,通过getitem方法取数据
class SkipBatchSampler(Sampler):
def __init__(self, sampler, batch_size, skip_batches=0):
self.sampler = sampler
self.batch_size = batch_size
self.skip_batches = skip_batches
def __iter__(self):
batch = []
skipped = 0
#self.sampler每次吐出一个样本的索引
for idx in self.sampler:
batch.append(idx)
if len(batch) == self.batch_size:
if skipped < self.skip_batches:
skipped += 1
batch = []
continue
yield batch
batch = []
if len(batch) > 0 and skipped >= self.skip_batches:
yield batch
def __len__(self):
total_batches = (len(self.sampler) + self.batch_size - 1) // self.batch_size
return max(0, total_batches - self.skip_batches)
class LMForRewardModel:
def __init__(self, model_path, device="cuda", dtype=torch.float16):
self.tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
self.model = AutoModel.from_pretrained(model_path, torch_dtype=dtype, trust_remote_code=True)
self.model = self.model.to(device).eval()
self.device = device
@torch.no_grad()
def get_score(self, messages, response):
history_text = "\n".join([f"{m['role']}: {m['content']}" for m in messages[:-1]])
last_query = messages[-1]['content'] if messages else ""
message_context = f"{history_text}\n以上是对话历史。我的新问题是:\n{last_query}" if history_text else last_query
eval_messages = [
{"role": "user", "content": message_context},
{"role": "assistant", "content": response}
]
score = self.model.get_score(self.tokenizer, eval_messages)
#clip到[-3,3]
return max(min(score, 3.0), -3.0)