把三句话塞进一个batch里,会发生什么?
“我是一只猫"有4个token,“今天天气真好"也是4个,而"人工智能正在改变世界,从医疗到教育,从交通到金融"则有18个。GPU需要把它们打包成一个规整的tensor——而tensor必须是矩形的。
这就是大模型处理输入时面临的第一道关卡:变长序列。这个看似简单的预处理问题,背后却是一整套精巧的技术体系,从padding策略的选择到attention mask的设计,从训练阶段的sequence packing到推理阶段的left padding要求,每一个决策都直接影响着计算效率和模型表现。
为什么这是个问题
神经网络的批处理机制决定了它必须同时处理多个样本。GPU的并行计算能力建立在规则的tensor操作之上——矩阵乘法、卷积、注意力计算,所有这些操作都假设输入是规整的矩形。
但语言天生就是不规整的。一句话可能是3个字,也可能是300个字。一篇文档可能只有几十个token,也可能达到模型的上下文限制。这种变异性在真实数据中极为普遍:根据AI21的研究,在许多语料库中,padding token可以占到所有处理token的50-70%。这些[PAD]标记消耗着GPU内存和计算资源,却不贡献任何有意义的信息。
graph LR
subgraph "真实数据分布"
A["短文本: 3 tokens"]
B["中等文本: 50 tokens"]
C["长文本: 500 tokens"]
D["超长文本: 4000 tokens"]
end
subgraph "GPU需要的形状"
E["统一长度: 4000 tokens"]
end
A -->|"填充到4000"| E
B -->|"填充到4000"| E
C -->|"填充到4000"| E
D -->|"无需填充"| E
style A fill:#e1f5fe
style B fill:#e1f5fe
style C fill:#e1f5fe
style D fill:#e1f5fe
style E fill:#ffcdd2
问题的核心在于:我们该如何让模型"看见"真实内容,同时"无视"填充的废料?
Padding:最直接的解决方案
最朴素的想法是:既然长度不一,那就把短的补齐。这就是padding——在序列末尾或开头添加特殊的padding token,让所有序列达到相同长度。
但padding的位置选择,却暗藏玄机。
Left Padding vs Right Padding
当我们在句子末尾添加padding token时,这是right padding:
我是一只猫 [PAD] [PAD] [PAD] ...
今天天气真好 [PAD] [PAD] [PAD] ...
人工智能正在改变世界...
当我们在句子开头添加padding token时,这是left padding:
[PAD] [PAD] [PAD] 我是一只猫
[PAD] [PAD] [PAD] 今天天气真好
人工智能正在改变世界...
对于BERT这类Encoder-only模型,right padding是常规选择。原因很简单:模型需要理解整个句子,padding放在哪里似乎区别不大,但right padding与人类从左到右阅读的习惯一致,也更符合直觉。
然而,当涉及Decoder-only模型(如GPT系列)的推理时,情况发生了微妙的变化。Hugging Face的代码会发出警告,建议使用left padding。为什么?
这涉及到Decoder-only模型的生成机制。在自回归生成过程中,模型每次产生一个新token,这个token会追加到序列末尾,参与下一轮的注意力计算。如果使用right padding,新生的token会被放在padding token之后——这在逻辑上是正确的,因为attention mask会屏蔽掉padding。但在工程实现上,这会带来不便:每次生成后,需要重新调整padding的位置。
graph TB
subgraph "Right Padding推理流程"
RP1["原始: [Token1][Token2][PAD][PAD]"]
RP2["生成后: [Token1][Token2][PAD][PAD][NewToken]"]
RP3["问题: 新token在PAD之后"]
RP1 --> RP2 --> RP3
end
subgraph "Left Padding推理流程"
LP1["原始: [PAD][PAD][Token1][Token2]"]
LP2["生成后: [PAD][PAD][Token1][Token2][NewToken]"]
LP3["优势: 新token直接追加"]
LP1 --> LP2 --> LP3
end
style RP3 fill:#ffcdd2
style LP3 fill:#c8e6c9
更关键的是,某些优化后的推理框架(如vLLM)假设真实内容是连续的,left padding保证了这一点。新生成的token可以直接追加在最后一个真实token之后,无需任何调整。
但这只是推理阶段的考量。在训练阶段,特别是使用Teacher Forcing时,padding的位置选择更多取决于框架实现和具体任务。许多预训练代码仍然使用right padding,配合适当的attention mask。
Attention Mask:让模型学会"无视”
Padding解决了形状规整的问题,但带来了新问题:模型不应该关注padding token。如果模型认真"思考"那些无意义的[PAD],不仅浪费计算,还可能引入噪声。
这就需要attention mask。
Mask的数学本质
在Transformer的缩放点积注意力中,计算公式为:
$$\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}} + M\right)V$$其中 $M$ 就是mask矩阵。对于padding mask,padding位置的 $M_{ij} = -\infty$,有效位置的 $M_{ij} = 0$。
当softmax遇到 $-\infty$ 时,对应的指数项趋近于0:
$$\lim_{x \to -\infty} e^x = 0$$这意味着padding位置的注意力权重被强制为0,模型"看不见"这些token。
graph LR
subgraph "原始注意力分数"
S1["位置0: 2.5"]
S2["位置1: 1.8"]
S3["位置2: 3.2"]
S4["位置PAD: 0.5"]
end
subgraph "加上Mask后"
M1["位置0: 2.5"]
M2["位置1: 1.8"]
M3["位置2: 3.2"]
M4["位置PAD: -inf"]
end
subgraph "Softmax结果"
SM1["位置0: 0.35"]
SM2["位置1: 0.17"]
SM3["位置2: 0.48"]
SM4["位置PAD: 0.00"]
end
S1 --> M1
S2 --> M2
S3 --> M3
S4 -->|"加-inf"| M4
M1 --> SM1
M2 --> SM2
M3 --> SM3
M4 -->|"exp(-inf)=0"| SM4
style M4 fill:#ffcdd2
style SM4 fill:#c8e6c9
Padding Mask与Causal Mask的组合
在Decoder-only模型中,情况更复杂。除了要屏蔽padding,还需要屏蔽未来token(保证自回归特性)。这需要两种mask的组合:
- Padding mask:屏蔽batch中的padding token
- Causal mask:屏蔽当前位置之后的所有token(下三角矩阵)
graph TB
subgraph "Padding Mask 示例"
P1["序列1 mask: 1,1,1,0,0"]
P2["序列2 mask: 1,1,1,1,0"]
end
subgraph "Causal Mask 矩阵"
C1["1,0,0,0,0"]
C2["1,1,0,0,0"]
C3["1,1,1,0,0"]
C4["1,1,1,1,0"]
C5["1,1,1,1,1"]
end
subgraph "Combined Mask 计算"
CM["两个mask逐元素逻辑与运算"]
end
P1 --> CM
P2 --> CM
C1 --> CM
C2 --> CM
C3 --> CM
C4 --> CM
C5 --> CM
以一个具体例子说明:假设batch中有两个序列,分别是长度3和长度4,padding到长度5。
对于序列1(有效长度3),完整的二维mask矩阵(True/1表示保留,False/0表示屏蔽):
位置: 0 1 2 3 4
0: [1, 0, 0, 0, 0] # 只能看自己
1: [1, 1, 0, 0, 0] # 能看0和1
2: [1, 1, 1, 0, 0] # 能看0、1、2
3: [0, 0, 0, 0, 0] # padding,全屏蔽
4: [0, 0, 0, 0, 0] # padding,全屏蔽
PyTorch中,这通常通过masked_fill操作实现:
# 假设 attention_scores 是 Q @ K^T 的结果
# attention_mask 是 bool 类型,True表示要屏蔽
attention_scores = attention_scores.masked_fill(
attention_mask, float('-inf')
)
attention_weights = F.softmax(attention_scores, dim=-1)
Padding的真实代价
表面上看,padding只是添加了一些无意义的token。但在大规模训练中,这个代价是惊人的。
计算浪费的量化
AI21的研究团队在训练Jamba2-3B模型时给出了具体数据:在一个标准的在线RL训练设置下(GSM8K数据集,最大prompt长度4k tokens,最大响应长度8k tokens),默认的padding方案导致policy update步骤耗时:
- Jamba2-3B:45秒
- Qwen2.5-7B:63秒
而实际的prompt通常只有几百个token,响应也在500-1000个token之间。大量的计算被浪费在了padding上。
gantt
title 训练步骤时间分解
dateFormat X
axisFormat %s
section 默认Padding
有效计算 : 0, 20
Padding浪费 : 20, 45
section 优化后
有效计算 : 0, 20
Padding浪费 : 20, 14
内存与计算的双重浪费
Padding的代价体现在两个层面:
内存浪费:每个padding token仍然占用显存。对于大batch和长序列,这可能是GB级别的浪费。
计算浪费:即使attention mask屏蔽了padding,注意力计算中的矩阵乘法 $QK^T$ 仍然会计算所有位置。对于长度为 $L$ 的序列,注意力计算是 $O(L^2)$ 的复杂度——即使一半是padding,计算量并不会因此减半。
根据Medium上的分析,在许多语料库中,50-70%的token可能是padding。这些[PAD]消耗着GPU内存和FLOPs,却不贡献任何学习。
Sequence Packing:消除padding的艺术
既然padding是浪费的源头,能否干脆不要padding?
Sequence packing的核心思想是:把多个短序列拼接成一个长序列,让GPU计算更紧凑的数据。
graph TB
subgraph "传统Padding方案"
S1["序列A: 真实token PAD PAD"]
S2["序列B: 真实 PAD"]
S3["序列C: 真实token PAD PAD PAD"]
Note1["大量计算浪费在PAD上"]
end
subgraph "Sequence Packing方案"
P["打包序列: 序列A完整内容 EOS 序列B完整内容 EOS 序列C完整内容"]
Note2["紧凑高效,无padding浪费"]
end
S1 -->|"拼接"| P
S2 -->|"拼接"| P
S3 -->|"拼接"| P
style Note1 fill:#ffcdd2
style Note2 fill:#c8e6c9
关键挑战:避免序列间信息泄露
直接拼接会带来问题:序列B的token可能会关注序列A的token,这是不应该发生的。解决方案是在attention mask中强制序列边界。
Hugging Face的博客详细介绍了实现方法。核心思路是:
- 用EOS token(或其他特殊token)标记序列边界
- 构建特殊的attention mask,确保每个token只能关注同一序列内的token
graph LR
subgraph "打包序列"
A["序列A: T1 T2 T3 EOS"]
B["序列B: T4 T5 EOS"]
P["整体: T1 T2 T3 EOS T4 T5 EOS"]
end
subgraph "注意力边界"
M1["T1,T2,T3可互看"]
M2["T4,T5可互看"]
M3["但不能跨序列"]
end
A --> P
B --> P
P --> M1
P --> M2
P --> M3
style M3 fill:#fff9c4
具体实现中,需要识别所有EOS位置,然后构建一个"分段因果mask”——既保证因果性(只看过去),又保证序列隔离(不看其他序列)。
Position ID的重置
另一个关键是position embedding。当多个序列被拼接时,每个序列的position ID应该从0重新开始,而不是延续前一个序列的位置。
序列A: [Tok0, Tok1, Tok2, EOS]
序列B: [Tok0, Tok1, EOS]
打包后: [A0, A1, A2, EOS, B0, B1, EOS]
位置ID: [ 0, 1, 2, 3, 0, 1, 2]
这确保了模型正确理解每个token在其原始序列中的位置。
性能提升
Sequence packing的效果是显著的。根据NVIDIA NeMo文档,在BERT fine-tuning任务中,sequence packing可以实现:
- 训练速度提升6倍
- 批量推理速度提升高达12倍
IBM的研究团队在使用Flash Attention配合sequence packing时,也观察到了显著的训练效率提升。
Flash Attention与变长序列
Flash Attention是近年来最重要的注意力优化技术之一,它也提供了处理变长序列的原生支持。
cu_seqlens机制
传统的attention mask是 $N \times L \times L$ 的布尔矩阵($N$是batch size,$L$是序列长度),这在大batch和长序列时占用大量内存。
Flash Attention的varlen(variable length)接口采用了不同的方法:使用cu_seqlens(cumulative sequence lengths)数组来标记每个序列的边界。
graph LR
subgraph "三个序列示例"
S1["序列1: 长度3"]
S2["序列2: 长度4"]
S3["序列3: 长度2"]
end
subgraph "cu_seqlens数组"
C["0, 3, 7, 9"]
D["含义: 序列1从0-3, 序列2从3-7, 序列3从7-9"]
end
subgraph "内存对比"
M1["传统mask: N×L×L 布尔矩阵"]
M2["cu_seqlens: N+1 整数数组"]
end
S1 --> C
S2 --> C
S3 --> C
C --> D
D --> M2
style M2 fill:#c8e6c9
这种方法的优势:
- 内存效率:只需要一个长度为 $N+1$ 的整数数组,而不是 $N \times L \times L$ 的浮点矩阵
- 计算效率:Flash Attention的kernel可以直接根据
cu_seqlens边界进行分块计算,无需显式的mask
Varlen API的使用
Flash Attention的varlen API使用示例:
from flash_attn import flash_attn_varlen_func
# q, k, v 是拼接后的所有token
# cu_seqlens_q, cu_seqlens_k 标记query和key/value的序列边界
output = flash_attn_varlen_func(
q, k, v,
cu_seqlens_q=cu_seqlens_q,
cu_seqlens_kv=cu_seqlens_kv,
max_seqlen_q=max_len_q,
max_seqlen_kv=max_len_kv
)
这种方法完全避免了padding,每个序列以其原始长度参与计算。
架构无关的Padding优化
Sequence packing虽然高效,但需要架构层面的支持。对于Mamba等状态空间模型,或者Transformer-SSM混合架构(如Jamba),实现sequence packing需要非trivial的修改。
AI21团队提出了一个替代方案:在tensor进入模型之前就减少padding。
Micro-batch级别的截断
VeRL(一种在线RL训练框架)默认将序列pad到固定的最大长度。实际数据中,大多数序列远短于这个最大值。
优化策略:
- 将所有left padding移到右侧(使padding连续)
- 截断到batch内最大序列长度
graph TB
subgraph "原始状态"
O1["[PAD][PAD][PAD][Token1][Token2]"]
O2["[PAD][Token1][Token2][Token3]"]
end
subgraph "步骤1: 移动padding到右侧"
S1["[Token1][Token2][PAD][PAD][PAD]"]
S2["[Token1][Token2][Token3][PAD]"]
end
subgraph "步骤2: 截断到batch内最大长度"
F1["[Token1][Token2]"]
F2["[Token1][Token2][Token3]"]
end
O1 --> S1
O2 --> S2
S1 --> F1
S2 --> F2
style F1 fill:#c8e6c9
style F2 fill:#c8e6c9
这个简单的操作带来了显著效果:
- Jamba2-3B:45秒 → 20秒(减少56%)
- Qwen2.5-7B:63秒 → 22秒(减少65%)
基于Padding感知的动态分批
进一步优化:将相似长度的序列分到同一个micro-batch中。
传统分批可能将一个10-token序列和一个8000-token序列放在一起,导致10-token序列被迫pad到8000。
graph TB
subgraph "传统分批问题"
T1["序列A: 10 tokens"]
T2["序列B: 8000 tokens"]
T3["结果: A被迫pad到8000"]
end
subgraph "动态分批优化"
D1["短序列组: A,B,C 都是~10 tokens"]
D2["长序列组: D,E,F 都是~8000 tokens"]
D3["结果: 每组内部padding最小"]
end
T1 --> T3
T2 --> T3
D1 --> D3
D2 --> D3
style T3 fill:#ffcdd2
style D3 fill:#c8e6c9
动态分批策略:
- 按长度排序序列
- 在token预算内,将长度相近的序列组成batch
- 每个batch可能有不同数量的序列,但token总量相近
加入这个优化后:
- Jamba2-3B:20秒 → 14秒(再减少30%)
- Qwen2.5-7B:22秒 → 16秒(再减少27%)
最终,这个架构无关的方案消除了约90%的padding相关开销,达到了接近sequence packing的效果(Jamba约91%的改进,Qwen约93%的改进)。
推理阶段的特殊考量
训练和推理对变长序列的处理有不同的侧重点。
动态批处理
在生产环境中,请求是异步到达的,长度各异。动态批处理技术将短时间内到达的请求打包处理,而非逐个服务。
sequenceDiagram
participant User1 as 用户1
participant User2 as 用户2
participant User3 as 用户3
participant Server as 推理服务
participant GPU as GPU
User1->>Server: 请求到达 (50 tokens)
Note over Server: 等待更多请求...
User2->>Server: 请求到达 (100 tokens)
User3->>Server: 请求到达 (80 tokens)
Server->>GPU: 打包batch (pad到100)
GPU->>Server: 批量返回结果
Server->>User1: 响应
Server->>User2: 响应
Server->>User3: 响应
核心权衡:
- 吞吐量:batch越大,GPU利用率越高
- 延迟:等待凑batch会增加首个请求的延迟
Bucket-based batching是一种改进方案:将相似长度的请求分到同一bucket,减少padding浪费。
连续批处理(Continuous Batching)
传统批处理中,整个batch必须等最长的生成完成才能释放。连续批处理允许:
- 已完成生成的请求提前释放
- 新请求随时加入正在处理的batch
这显著提高了GPU利用率,特别是在输出长度差异大的场景。
实践中的权衡
没有一种方案适用于所有场景。选择需要考虑:
graph TB
subgraph "决策因素"
F1["模型架构"]
F2["训练阶段"]
F3["推理场景"]
F4["工程复杂度"]
end
subgraph "方案选择"
S1["标准Transformer: Sequence Packing"]
S2["混合架构: Padding优化"]
S3["生产推理: 动态批处理"]
S4["快速迭代: 架构无关方案"]
end
F1 --> S1
F1 --> S2
F3 --> S3
F4 --> S4
模型架构
- 标准Transformer:可以使用sequence packing,有成熟的实现
- 混合架构:可能需要架构无关的padding优化
- Mamba等SSM:需要专门的序列边界处理
训练阶段
- 预训练:sequence packing收益最大,可以处理海量token
- SFT:padding优化仍有价值,但收益可能较小
- 在线RL:padding优化对训练效率影响显著
推理阶段
- 批量推理:动态批处理,配合left padding
- 流式推理:单序列处理,padding问题不突出
- 多轮对话:需要careful管理上下文窗口
工程复杂度
- Sequence packing:需要修改数据加载和attention实现
- Padding优化:只需修改预处理逻辑,风险更低
- Flash Attention varlen:需要GPU支持,但实现简洁
写在最后
变长序列处理是大模型基础设施中容易被忽视但至关重要的一环。从padding策略的简单选择,到attention mask的精巧设计,从sequence packing的激进优化,到架构无关的渐进改进,每一步都在回答同一个问题:如何让GPU的计算能力更专注于真实信息,而非无意义的填充。
这个问题的答案正在不断演进。随着Flash Attention 3、更高效的动态批处理技术、以及新型模型架构的出现,变长序列处理的方式也在持续优化。但核心原则始终不变:理解数据的真实结构,尊重模型的计算机制,在工程约束下寻找最优的效率平衡。
对于开发者而言,理解这些底层机制的价值在于:当你遇到显存不足、训练缓慢、推理延迟等问题时,能够从源头思考——也许问题不在于模型本身,而在于那些被忽视的padding token。
参考资料
- Efficient LLM Pretraining: Packed Sequences and Masked Attention. Hugging Face Blog, 2024.
- Reducing LLM training waste with model-agnostic padding minimization. AI21 Labs, 2026.
- Vaswani et al. Attention Is All You Need. NeurIPS 2017.
- Dao et al. FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness. NeurIPS 2022.
- Sequence Packing. NVIDIA NeMo Framework User Guide.
- Krell et al. Efficient sequence packing for BERT. 2021.
- Dynamic Batching vs. Sequence Packing. Better ML, 2025.
- Flash Attention with variable-length sequences. PyTorch Forums, 2024.
- Understanding padding side effects in transformer inference. Hugging Face Discuss, 2023.
- PackedBERT: How to accelerate NLP tasks for Transformers. Graphcore, 2023.