序列 Packing
一句话:序列 Packing 把多条长短不一的样本拼进同一条定长训练序列以填满 context,省掉 padding 的浪费,在短样本场景下能显著提升训练吞吐。(Krell et al., 2021. Efficient Sequence Packing without Cross-contamination) 代表工作年份:2021(Krell et al.,无跨样本污染的高效 Packing)· 机构/团队:Graphcore · 会议/来源:arXiv:2107.02027
前置阅读:SFT 总览、Chat Template
直觉与动机
SFT 数据的长度分布通常长尾且方差大:有的样本几十个 token,有的几千个。训练时 batch 内要对齐到统一长度,常规做法是把每条样本 pad 到 batch 最长(或固定 max_len)。pad token 不携带信息、要被 mask 掉、却照样消耗算力。
衡量浪费的指标是有效 token 占比:
其中
Packing 的思路是:把多条短样本首尾相接拼成一条接近 max_len,收益越大。
方法与公式
装箱:怎么拼
把"长度
- 朴素流式拼接:顺序遍历样本,往当前序列里塞,塞不下就开新序列。实现极简,但若数据没打乱、长度有序,碎片会较多。
- first-fit / first-fit-decreasing binning:先(按长度降序)排序,再用 first-fit 策略选箱,碎片更少、
更高。对静态数据集可离线预计算 packing 方案。
Cross-contamination:必须隔离 attention
把多条样本拼成一条后,如果直接走标准 causal attention,序列后段的 token 会 attend 到前段属于别的样本的 token——这就是 cross-contamination(样本间泄漏)。它会让模型学到不该有的跨样本依赖,污染训练信号。
解决办法是 block-diagonal attention mask:只允许同一原始样本内部的 token 互相注意,跨样本一律屏蔽。设拼接序列被切成若干段
同时 position_ids 要在每段开头重置为 0,否则后段样本会拿到偏大的位置编码(尤其 RoPE 会因此偏移),等价于把它当成"一条超长序列的后半段",与训练分布不符。
FlashAttention varlen 接口
显式构造 cu_seqlens(cumulative sequence lengths),kernel 内部就只在每段内部做 attention,既隔离了样本又零额外内存、且保持 FlashAttention 的速度。这是当前隔离 Packing 的主流实现路径。
与 baseline 对比
| 维度 | Padding | 朴素 Packing | 隔离 Packing(varlen) |
|---|---|---|---|
| 有效 token 占比 | 低(可能 50% 量级) | 高(接近 1) | 高(接近 1) |
| 训练吞吐 | 低 | 高 | 高 |
| 样本间泄漏 | 无 | 有 | 无 |
| position_ids 正确性 | 正确 | 需重置 | 需重置 |
| 实现复杂度 | 低 | 低 | 中(需 cu_seqlens / mask) |
| 推荐度 | 短样本场景偏低 | 不推荐(有泄漏) | 推荐 |
实现要点
核心是离线/在线构造 packed batch,并把 cu_seqlens 与 position_ids 一并传给模型。
def first_fit_pack(samples, max_len):
# samples: List[List[int]] 已 tokenize 的样本(含各自模板/EOS)
bins = [] # 每个 bin 是 List[sample]
for s in sorted(samples, key=len, reverse=True):
if len(s) > max_len: # 超长样本单独成箱或截断
bins.append([s[:max_len]]); continue
for b in bins:
if sum(len(x) for x in b) + len(s) <= max_len:
b.append(s); break
else:
bins.append([s])
return bins
def build_packed(bin_samples, max_len, pad_id):
input_ids, labels, pos, seqlens = [], [], [], [0]
for s in bin_samples:
input_ids += s
labels += loss_mask_labels(s) # 见 /sft/loss-masking
pos += list(range(len(s))) # 每段 position 从 0 重置
seqlens.append(len(input_ids))
# 尾部 pad 到 max_len(pad 段的 label 全 -100)
pad = max_len - len(input_ids)
input_ids += [pad_id] * pad
labels += [-100] * pad
pos += [0] * pad
cu_seqlens = seqlens + ([len(input_ids)] if pad else [])
return dict(input_ids=input_ids, labels=labels,
position_ids=pos, cu_seqlens=cu_seqlens)要点清单:
- 每条样本自带完整模板与 EOS,拼接后段间天然有结束符,不要因为 packing 省掉它。
- position_ids 每段从 0 重置,对 RoPE 模型尤其关键。
- labels 与 input_ids 同步拼接,pad 段全置
-100;与 Loss Masking 协同。 - cu_seqlens 传给 varlen attention(FlashAttention
flash_attn_varlen_func或框架封装的_get_unpad_data路径),不要手搓mask。 - loss 归一化口径要想清楚:packed batch 把多条样本混在一起,按 token 平均 vs 按样本平均会改变长短样本的相对权重,详见 Loss Masking 的归一化讨论。
调参与实践经验
- 是否影响效果:在正确做了 attention 隔离与 position 重置的前提下,Packing 对最终效果的影响通常可忽略——它只改吞吐,不改训练信号。朴素 Packing(不隔离)则可能掉点,因为引入了跨样本泄漏。
- 什么时候必须隔离:只要一条序列里装了多于一条独立样本,就应隔离。唯一例外是"本来就是同一篇长文档/同一段连续上下文"的拼接(continued pretraining 场景),那种情况跨段注意力是合理的。
- 收益评估:先统计数据集的
。若平均长度接近 max_len,padding 浪费本就不大,Packing 收益有限,没必要为它增加实现复杂度;若偏低(短样本为主),Packing 收益明显。 - 超长样本:超过
max_len的样本要么截断、要么单独成箱,避免破坏装箱逻辑。 - 梯度等价性:Packing 后单条序列里有效样本数不固定,配合梯度累积时要注意分母口径,确保与逐样本训练在期望上一致,否则学习率的有效尺度会漂移。
- 吞吐验证:上线 Packing 后实测 tokens/sec 与显存占用,确认 varlen kernel 真的生效(没退化回稠密 mask)。
参考文献
- Krell et al., 2021. Efficient Sequence Packing without Cross-contamination: Accelerating Large Language Models without Impacting Performance. arXiv:2107.02027.
- Dao et al., 2022. FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness. arXiv:2205.14135.
- Dao, 2023. FlashAttention-2: Faster Attention with Better Parallelism and Work Partitioning. arXiv:2307.08691.