背景:很多 SFT 算力消耗在 Padding 上
监督微调(SFT)数据的长度差异往往非常悬殊:一条对话可能只有几十个 Token 的分类回答,另一条却可能是数千 Token 的代码生成或长文分析。传统 Batch 训练要求把同一批样本统一补齐到最长序列的长度,短样本后面的大量 [PAD] 仍然占用显存、触发部分 Kernel 工作,并拉长每一次训练迭代的耗时。
在动手优化之前,建议先用一个简单指标量化问题:
有效 Token 利用率 = 非 Padding Token 数 ÷ 参与计算的总 Token 数
如果一个 Batch 中只有少数长样本,大量短样本被补齐到相同长度,那么 samples/s 看起来正常,tokens/s 却可能很差。Sequence Packing 的目标,就是把多条短样本装入一个固定长度的训练块中,让 GPU 尽量处理真实的训练 Token,而不是空白占位符。
但 Packing 绝不是把数组简单拼起来。真正安全的生产实现必须同时守住三类边界:
- Attention Boundary(注意力边界):样本 B 不能读取样本 A 的上下文。
- Loss Boundary(损失边界):Prompt、System、User 以及跨样本连接位置不能被错误计入损失。
- Position Boundary(位置边界):位置编号必须符合模型和 Attention Kernel 的预期,不能因为拼接而引入未验证的分布变化。
核心原理:把”拼接”升级为边界感知的打包
Padding、Length Bucket 与 Packing 的区别
| 策略 | 原理 | 优点 | 局限 |
|---|---|---|---|
| Padding | 短样本填充到 Batch 内最长序列 | 实现简单,兼容性最好 | 大量无效计算,显存利用率低 |
| Length Bucket | 先按长度分组,让同 Batch 样本长度更接近 | 减少外层 Padding,兼容常规张量形态 | 仍有 Padding 浪费,Bucket 划分需调优 |
| Sequence Packing | 多条样本装入同一个 Token 容器 | 几乎消除 Padding,Token 利用率最高 | 实现复杂,需要 Attention 后端配合 |
Length Bucket 保留”一行一个样本”的常规张量形态,兼容性最好。Sequence Packing 则把多条样本装入同一个 Token 容器,容器总长度接近 max_length,并携带每条样本的起止位置。对于支持 Variable-Length Attention 的实现,Kernel 可以利用累积长度数组 cu_seqlens 直接计算各样本,而不必构造巨大的完整方阵 Mask。
两者不是互斥关系。常见的最佳实践是先使用 Length Bucket 限制长度差异,再在 Bucket 内执行 Packing。
三条边界必须一起设计
假设将三条样本拼成一条 Token 流:
[A0 A1 A2 EOS] [B0 B1 EOS] [C0 C1 C2 C3 EOS]
仅插入 EOS 并不能保证注意力隔离。标准因果 Mask 仍然允许后面的 Token 看到前面所有位置。生产实现需要借助 block-diagonal attention、cu_seqlens、sequence IDs 或后端支持的等价元数据,将三段明确声明为独立序列。
Loss 侧同样需要精细处理。SFT 场景下的典型规则包括:
- System、User、工具结果等输入 Token 的 label 设为
-100(忽略损失); - 仅对 Assistant 或 Completion 部分计算损失;
- 根据数据模板与 label shift 逻辑,排除每条样本的首 Token 和跨样本连接位置,避免前一条样本被迫”预测下一条样本的开头”;
- EOS 是否参与损失必须作为数据契约固化,而不是由不同 Collator 各自决定。
Position ID 通常在每条样本边界重新从零开始,但这并非无条件规则。是否重置、如何传给 FlashAttention Variable-Length 接口,必须与模型架构、RoPE 配置和训练框架保持一致。
工程落地:从长度画像到发布门禁
第一步:先量化 Padding 浪费
不要直接打开 packing=True。先离线统计以下指标:
- P50、P90、P95、P99 样本长度;
- 超过
max_length的样本比例; - 每个 Batch 的有效 Token 利用率;
- Prompt Token 与 Assistant Token 的比例;
- 不同任务、语言和数据源的长度分布。
建议按数据域分别统计。代码任务、客服问答和长文摘要混在一个全局直方图里,容易掩盖局部极端分布。
第二步:选择打包策略
Hugging Face TRL 当前提供多种 Packing 思路:
| 策略 | 行为 | 适用场景 |
|---|---|---|
| BFD(Best-Fit Decreasing) | 先按长度排序,再放入最合适的剩余空间 | 默认起点,不主动切断样本 |
| BFD Split | 允许拆分超长样本以保留更多 Token | 仅当任务语义允许切分且契约已验证 |
| Wrapped | 视作连续 Token 流,按固定长度切块 | 空间利用率高,但容易切断样本、混合无关内容 |
对大多数企业 SFT,建议的优先级顺序是:截断 / 过滤规则先固定 → Length Bucket 降低离散度 → 再使用不拆样本的 BFD Packing。不应只因吞吐更高就直接上线 Wrapped 策略。
第三步:定义统一的 Pack 数据结构
生产 Collator 至少应输出或可追溯以下字段:
packed_batch = {
"input_ids": ..., # 拼接后的 Token
"labels": ..., # 已应用 Prompt/Assistant Loss Mask
"position_ids": ..., # 每条样本的位置编号
"cu_seqlens": ..., # [0, len_a, len_a + len_b, ...]
"max_seqlen": ..., # Pack 内最长独立样本
"sample_ids": ..., # 便于回放和审计
}
sample_ids 不一定进入模型计算图,但应保留在训练日志或旁路元数据中。当出现 Loss 跳变、NaN 或特定样本污染时,必须能从 Pack 反查到原始样本和模板版本。
第四步:把 Loss Mask 当作数据契约
TRL 的 SFT Trainer 支持 Packing 和 Assistant-Only Loss:
from trl import SFTConfig
config = SFTConfig(
max_length=4096,
packing=True,
packing_strategy="bfd",
assistant_only_loss=True,
)
但 assistant_only_loss=True 并非万能开关。它依赖 Chat Template 能提供 Assistant 区间对应的 generation mask。模板不支持时,必须在预处理阶段显式生成 labels。
上线前的必要断言:
def validate_pack(input_ids, labels, position_ids, cu_seqlens):
assert cu_seqlens[0] == 0
assert cu_seqlens[-1] == len(input_ids)
assert all(b > a for a, b in zip(cu_seqlens, cu_seqlens[1:]))
for start, end in zip(cu_seqlens[:-1], cu_seqlens[1:]):
assert position_ids[start] == 0
assert end > start
# 按本项目模板验证 Prompt 区域 label == -100
# 并验证 Assistant 区域至少存在一个可训练 label
更严格的做法是进行未打包对照回放:对同一小批样本分别运行未打包基线和打包实现,比较以下维度:
- 可训练 Label 数量;
- 每条样本的 Token 序列;
- 单样本 Loss;
- 梯度范数;
- 若后端承诺等价,则比较 Logits 或梯度的数值误差。
第五步:确认 Attention 后端真的隔离了样本
Padding-Free Training 通常依赖 FlashAttention 2/3 或等价的 Variable-Length Attention。不能只看配置项是否成功启动,还要验证实际执行路径:
- 是否调用了 Variable-Length Kernel;
cu_seqlens是否覆盖全部 Token;- Pack 内不同样本之间是否存在非预期 Attention;
- 回退到普通 Attention 时是否仍然安全;
- 混合精度、Gradient Checkpointing、FSDP/DeepSpeed 与该路径是否兼容。
NVIDIA Megatron Bridge 的 Packed Sequence 文档也强调,支持范围与具体模型、配方和训练特性有关;某些离线 Packed SFT 配置还可能要求特定的 Micro Batch 约束。不能把”框架支持 Packing”理解为”所有模型与并行组合都已验证”。
第六步:重新定义训练步数和调度口径
Packing 后,一个 Batch 中的样本数和 Token 数都可能发生变化。如果仍然按照原来的 batch_size × gradient_accumulation_steps 理解训练规模,学习率调度、日志和成本核算可能全部偏移。
建议将以下指标提升为一等监控对象:
- 每个 optimizer step 的有效训练 Token 数;
- 每秒有效训练 Token 数;
- 每百万有效 Token 的 GPU 时间;
- 每个数据域进入训练的 Token 占比;
- Padding、截断和丢弃 Token 的比例;
- Pack 填充率及其分布。
对于多任务数据,建议按 Token 预算而非样本条数做采样,否则短样本任务可能因为 Packing 获得意外的权重变化。
适用场景
Sequence Packing 更适合以下场景:
- ✅ SFT、PEFT 或多任务训练中样本长度差异明显
- ✅ 大量样本远短于
max_length - ✅ Attention 后端支持可靠的 Variable-Length 或边界感知计算
- ✅ 团队能够保存样本到 Pack 的映射,并执行未打包对照回放
- ✅ 训练成本主要受 Padding 浪费影响
它不适合被当作长上下文训练的替代品。单条样本超过上下文窗口时,仍需要截断、样本切分、Context Parallelism 或模型级长上下文方案。Packing 解决的是”多条短样本如何共享一个计算容器”,不是”单条超长样本如何放进模型”。
常见误区
误区一:有 EOS 就不会跨样本注意
EOS 是语义分隔符,不是物理 Attention Mask。若后端仍使用普通因果 Mask,后面的样本仍可能访问前面的 Token。
误区二:只验证训练能跑通
跨样本 Attention 和错误 Loss Mask 往往不会立即报错,Loss 甚至可能更快下降。必须做未打包对照、边界断言和样本级回放。
误区三:只追求最高 Pack 填充率
Wrapped 策略可能获得更高利用率,却切断样本或改变训练语义。生产目标应是有效 Token 吞吐与质量约束下的最优点,而非单一填充率。
误区四:把 samples/s 当成主指标
Packing 会改变一个 Step 包含的样本数量。应优先看有效 tokens/s、每步 Token 数和验证集质量。
误区五:忽略数据混合权重
按样本采样时,不同长度任务贡献的 Token 数差异很大。Packing 可能将隐藏的长度偏差放大,需要按任务、语言和数据源核对 Token 占比。
上线检查清单
- 已统计全量及分数据域的长度分布、Padding 比例和超长样本比例
- 已明确 BFD、BFD Split 或 Wrapped 的选择理由,并记录拆分规则
- Attention 后端能够根据
cu_seqlens或等价元数据隔离样本 - Prompt、Assistant、EOS 和样本首 Token 的 Loss Mask 规则已固化
- Position ID 重置策略与模型、RoPE 和 Kernel 实现一致
- Pack 可追溯到原始
sample_id、数据版本和 Chat Template 版本 - 已执行打包与未打包的小规模数值回放
- 已覆盖空回答、纯 Prompt、超长样本、特殊 Token、工具调用和多轮对话等边界情况
- 已核对每步有效 Token 数、学习率调度和 Gradient Accumulation 口径
- 已监控有效 tokens/s、Pack 填充率、截断率、Loss、梯度范数和验证集指标
- Attention 后端回退、框架升级或并行配置变化时会重新触发兼容性测试
- 出现质量回退时可切回 Length Bucket + Padding 的安全基线