把三句话塞进一个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的组合:

  1. Padding mask:屏蔽batch中的padding token
  2. 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的博客详细介绍了实现方法。核心思路是:

  1. 用EOS token(或其他特殊token)标记序列边界
  2. 构建特殊的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

这种方法的优势:

  1. 内存效率:只需要一个长度为 $N+1$ 的整数数组,而不是 $N \times L \times L$ 的浮点矩阵
  2. 计算效率: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到固定的最大长度。实际数据中,大多数序列远短于这个最大值。

优化策略:

  1. 将所有left padding移到右侧(使padding连续)
  2. 截断到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

动态分批策略:

  1. 按长度排序序列
  2. 在token预算内,将长度相近的序列组成batch
  3. 每个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必须等最长的生成完成才能释放。连续批处理允许:

  1. 已完成生成的请求提前释放
  2. 新请求随时加入正在处理的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。

参考资料

  1. Efficient LLM Pretraining: Packed Sequences and Masked Attention. Hugging Face Blog, 2024.
  2. Reducing LLM training waste with model-agnostic padding minimization. AI21 Labs, 2026.
  3. Vaswani et al. Attention Is All You Need. NeurIPS 2017.
  4. Dao et al. FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness. NeurIPS 2022.
  5. Sequence Packing. NVIDIA NeMo Framework User Guide.
  6. Krell et al. Efficient sequence packing for BERT. 2021.
  7. Dynamic Batching vs. Sequence Packing. Better ML, 2025.
  8. Flash Attention with variable-length sequences. PyTorch Forums, 2024.
  9. Understanding padding side effects in transformer inference. Hugging Face Discuss, 2023.
  10. PackedBERT: How to accelerate NLP tasks for Transformers. Graphcore, 2023.