Pytorch Lightning 和 HuggingFace 的 Trainer 哪个好用?

Transformers的Trainer本身已经集成了Acceletor和DeepSpeed,支持DDP和DeepSpeed-Zero,以及基于Acceletor的朴素流水线并行(GPU吞吐率较低)。而Transformers家的TRL库也是基于BaseTrainer(其基类是Transformers Trainer),又封装了一层,比如SFT-Trainer / DPO-Trainer / GRPO-Trainer。

Trainer本身已经算是封装地比较厉害了,中小型的实验可以快速使用Trainer或TRL,或者魔改。常用的LlamaFactory,也是在Trainer基础上二次开发。


本文主对Trainer源码进行了删改,便于理解其主流程

SFT训练基本代码

import torch
from transformers import TrainerCallback

rank = 64
alpha = rank * 2
lora_config = LoraConfig(
    task_type = TaskType.CAUSAL_LM,
    target_modules = ["q_proj","k_proj","v_proj","o_proj", "gate_proj", "up_proj", "down_proj"],
    inference_mode = False, # 训练模式
    r=rank,
    lora_alpha=alpha, #Lora aLaph,具体作用参见Lora原理
    lora_dropout=0.1
)

train_args = TrainingArguments(
    output_dir=f"./output/Qwen/test_rank{rank}_alpha{alpha}",
    per_device_train_batch_size=1,
    gradient_accumulation_steps=3,
    logging_steps=4,
    num_train_epochs=7,    # 小模型要多训练会儿
    save_steps=100,
    eval_steps=10,
    learning_rate=8e-5,
    save_on_each_node=True,
    gradient_checkpointing=True,
    lr_scheduler_type="cosine",
    warmup_steps=20,
    report_to=["tensorboard"]
)

# Load best model at end=True
logger.info(f"args: {train_args}")
logger.info(f"【Start Training!】")

# 不LoRA
# model = get_peft_model(model, lora_config)
# model.print_trainable_parameters()

class MemoryTraceCallback(TrainerCallback):
    def __init__(self, output_dir="memory_trace", target_step=42):
        self.output_dir = output_dir
        self.target_step = target_step
        self.recorded = False

    def on_train_begin(self, args, state, control, **kwargs):
        # 开启显存历史记录 (包含堆栈信息)
        print(f"🚀 [Memory] 开始记录显存分配历史...")
        torch.cuda.memory._record_memory_history(
            max_entries=100000,
            context="all"  # 记录 Python 堆栈
        )

    def on_step_end(self, args, state, control, **kwargs):
        # 我们只需要跑几步,比如第 5 步结束后抓取快照
        if state.global_step == self.target_step and not self.recorded:
            print(f"📸 [Memory] 第 {self.target_step} 步结束,正在保存显存快照...")
            try:
                # 保存快照
                torch.cuda.memory._dump_snapshot(f"{self.output_dir}_snapshot.pickle")
                print(f"✅ [Memory] 快照已保存至 {self.output_dir}_snapshot.pickle")

                # 停止记录
		        torch.cuda.memory._record_memory_history(enabled=None)
                self.recorded = True
                # 可选:直接停止训练,因为我们只想要分析数据
                control.should_training_stop = True
            except Exception as e:
                print(f"❌ [Memory] 保存失败: {e}")


# 在 Trainer 中添加这个 Callback
trainer = Trainer(
    model=model,
    args=train_args,
    train_dataset=tokenized_id,
    data_collator=DataCollatorForSeq2Seq(tokenizer=tokenizer, padding=True),
    callbacks=[MemoryTraceCallback()],
)
trainer.train()

完整代码(LoRA训练,注释即为全参SFT)
Qwen3_0_6B_LoRA_自我认知.ipynb

  Trainer SFT代码运行结果
Trainer SFT代码运行结果

通过注入PyTorch Memory Snapshot代码,并分析结果:

  1. 拿到生成的 xxx_snapshot.pickle 文件。
  2. 打开 PyTorch 官方提供的分析页面:https://pytorch.org/memory_viz
  3. 把文件拖进去。

 PyTorch Memory Snapshot-Qwen3-0.6B-SFT
PyTorch Memory Snapshot-Qwen3-0.6B-SFT

可以看到原生Trainer最小版本全参SFT,显存占用为模型参数的10倍量级左右(猜测是torch.amp简化了混合精度的MasterWeight,而且可能优化器状态也是半精度),0.6B在5GB显存多一点左右


逻辑关系梳理:

SFT-Trainer (trl) -> BaseTrainer(trl) ->Trainer(transformers)

trainer的入参

 trainer的入参
trainer的入参

trainer的train()函数

 trainer的train()函数
trainer的train()函数

train()函数内部的真正训练调用inner_training_loop()函数

 train()到inner_training_loop()
train()到inner_training_loop()


_inner_training_loop()

_inner_training_loop的入口部分代码, 通过self.get_train_dataloader()加载训练样本train_dataloader,并获取一些训练步长相关的超参数。同时根据不同的配置项加载或从checkpoint中恢复模型。

 _inner_training_loop的入口
_inner_training_loop的入口

这里的次要逻辑较复杂,故忽略

在微调时get_train_dataloader()可选的dataclloator,本质都是在其内部调用pad_without_fast_tokenizer_warning方法,使用toknizer对已经tokenized的本文即features(features主要由{input_ids,attention_mask,labels}构成)进行padding,并执行label 对齐的 batch 构造器

例如:

>>> features = [
...     {"input_ids": [1, 2, 3], "attention_mask": [1, 1, 1]},
...     {"input_ids": [4, 5], "attention_mask": [1, 1]},
... ]

>>> tokenizer.pad(features, return_tensors="pt")
{
  "input_ids": tensor([[1, 2, 3], [4, 5, 0]]),
  "attention_mask": tensor([[1, 1, 1], [1, 1, 0]])
}
collator 名称 面向模型类型特点 GPT 可用吗
DataCollatorForLanguageModeling(mlm=True) BERT 随机 mask 训练
DataCollatorForLanguageModeling(mlm=False)GPT 自回归 label 构造
DataCollatorForSeq2Seq Seq2Seq (T5/BART),兼容 GPT通用 padding、label 对齐、支持 decoder_input_ids✅(会自动退化)
default_data_collator 通用 简单 pad + tensor 化
DataCollatorWithPadding 通用 动态 padding,仅对输入 ✅(但不处理 labels)

譬如DataCollatorWithPadding:

class DataCollatorWithPadding:
    """Data collator that will dynamically pad the inputs received."""
    tokenizer: PreTrainedTokenizerBase
    padding: Union[bool, str, PaddingStrategy] = True
    max_length: Optional[int] = None
    pad_to_multiple_of: Optional[int] = None
    return_tensors: str = "pt"

    def __call__(self, features: list[dict[str, Any]]) -> dict[str, Any]:
        batch = pad_without_fast_tokenizer_warning(
            self.tokenizer,
            features,
            padding=self.padding,
            max_length=self.max_length,
	        pad_to_multiple_of=self.pad_to_multiple_of,
            return_tensors=self.return_tensors,
        )
        if "label" in batch:
            batch["labels"] = batch["label"]
            del batch["label"]
        if "label_ids" in batch:
            batch["labels"] = batch["label_ids"]
            del batch["label_ids"]

        return batch

这里就是我们用trainer训练开始时,经常看到的命令行日志了。

 trainer训练命令行日志
trainer训练命令行日志

这里就是真正的训练内循环了,按num_train_epochs进行迭代,在这个内循环中取样本做前向传播,得到1个step的损失tr_loss_step

get_batch_samples()函数,将从data_loader的迭代器里面取batch_samplesnum_items_in_batch,然后获取单个inputs再调用training_step(),这样来完成单次前向传播和反向传播计算梯度的过程(后面将会详细解释它)。

  training_step训练的迭代流程)
training_step训练的迭代流程)

然后,在training_step()之后,做梯度裁剪,并衔接优化器做参数更新optimizer.step()

如果涉及混合精度训练,还有对应的梯度损失缩放

 梯度裁剪到优化器更新参数
梯度裁剪到优化器更新参数


training_step()

那么training_step()的输入是:

  • model: nn.Module
  • inputs: dict[str, Union[torch.Tensor, Any]]
  • num_items_in_batch: Optional[torch.Tensor] = None

training_step()里面做了什么呢,主要分为3步:

  training_step的主要流程
training_step的主要流程

  • 输入预处理, _prepare_context_parallel_inputs(model, inputs)函数, 将用于获取模型输入(包括input_ids, labelsshift_labels,以及position_idsattention_mask
  • 前向传播损失计算, compute_loss(model, inputs),进行这个函数通常可以重写
  • 反向传播计算梯度, backward()

compute_loss()部分就是inputs传入model得到输出, 其具体损失计算逻辑如下(在输入有labels的场景下,如果有compute_loss_func()函数或配置了label_smooth标签平滑,此时会将labels pop取出来,单独计算loss):

  • 如果有自定义compute_loss且输入有labels,则执行该损失函数
  • 如果label_smooth为True且输入有labels, 则执行label_smooth()函数
  • 否则,直接取模型的内部算出的loss
场景 是否 pop 掉 labels模型是否算 lossloss 来源
自定义 compute_loss ✅ 是 ❌ 否 用户自定义
label smoothing ✅ 是 ❌ 否 label_smoother
普通训练(无 smoothing)❌ 否 ✅ 是 模型内部 loss
if (self.label_smoother is not None or self.compute_loss_func is not None) and "labels" in inputs:
    labels = inputs.pop("labels")
else:
    labels = None

if self.model_accepts_loss_kwargs:
    kwargs = {}
    if num_items_in_batch is not None:
        kwargs["num_items_in_batch"] = num_items_in_batch
    inputs = {**inputs, **kwargs}
outputs = model(**inputs)

 compute_loss函数损失计算逻辑
compute_loss函数损失计算逻辑

SFT损失函数

本质上SFT和RL-LLM都是Teacher-Forcing的范式,前向传播强制相当于计算一次prefix,然后得到逐token的logit计算损失(RL在LLM就是加权交叉熵)

 SFT-TeacherForcing损失计算流程
SFT-TeacherForcing损失计算流程

 Qwen3ForCausalLM的损失计算
Qwen3ForCausalLM的损失计算

以Qwen3的dense模型为例,在modeling_qwen3.py中Qwen3ForCausalLM继承自Qwen3PreTrainedModel, Qwen3PreTrainedModel又继承自模型基类PreTrainedModel,其loss_fucntion默认是ForCausalLM类别,故然会调用loss/loss_utils中的ForCausalLMLoss函数。

def ForCausalLMLoss(
    logits,
    labels,
    vocab_size: int,
    num_items_in_batch: Optional[torch.Tensor] = None,
    ignore_index: int = -100,
    shift_labels: Optional[torch.Tensor] = None,
    **kwargs,
) -> torch.Tensor:

    # Upcast to float if we need to compute the loss to avoid potential precision issues
    logits = logits.float()
    if shift_labels is None:
        # Shift so that tokens < n predict n
        labels = nn.functional.pad(labels, (0, 1), value=ignore_index)
        shift_labels = labels[..., 1:].contiguous()

    # Flatten the tokens
    logits = logits.view(-1, vocab_size)
    shift_labels = shift_labels.view(-1)

    # Enable model parallelism
    shift_labels = shift_labels.to(logits.device)
    loss = fixed_cross_entropy(logits, shift_labels, num_items_in_batch, ignore_index, **kwargs)

    return loss

ForCausalLMLoss函数,又基于fixed_cross_entropy函数。

def fixed_cross_entropy(
    source: torch.Tensor,
    target: torch.Tensor,
    num_items_in_batch: Optional[torch.Tensor] = None,
    ignore_index: int = -100,
    **kwargs,
) -> torch.Tensor:
    reduction = "sum" if num_items_in_batch is not None else "mean"
    loss = nn.functional.cross_entropy(source, target, ignore_index=ignore_index, reduction=reduction)
    if reduction == "sum":
        # just in case users pass an int for num_items_in_batch, which could be the case for custom trainer
        if torch.is_tensor(num_items_in_batch):
            num_items_in_batch = num_items_in_batch.to(loss.device)
        loss = loss / num_items_in_batch
    return loss

在看源码的时候忘了更新版本,发现是4.32.0的,后面更新到4.57.3了。重新看了下代码,整体流程区别不大。因为trainer本身还只是训练封装的类。


SFT-Trainer和Trainer

SFT-Trainer继承了Trainer,并且添加了SFT的逻辑, 改动很小:

  • _prepare_dataset, 加入了formatting_func()函数匹配样本中的提示词模板将其标签置为-100
  • compute_loss()函数基本就是调用trainer的compute_loss()函数

其实GRPO-Trainer的主流程还是继承SFT-Trainer,但是改写了训练前的样本Rollout的过程,生成了一组用优势加权的SFT样本

本文由 Zhihu on Obsidian 创作并发布

编辑于 2026-01-21 · 著作权归作者所有