【LLM基础教程】从序列切分到上下文窗口02_三种数据切分方法

上一节内容:【LLM基础教程】从序列切分到上下文窗口01_为什么序列建模必须切分数据
本节也是对沐神课程的进一步理解:53 语言模型【动手学深度学习v2】

​ 在上一节中我们看到:序列切分的目的,是把一条很长的文本,转化为模型可以处理的固定长度监督样本。

​ 那么接下来的问题就是:这些长度为 T T T的子序列,应该如何从原始文本中取出来?

​ 不同的切分方式,实际上对应着不同的建模假设与工程取舍。在介绍具体策略之前,我们先明确一个统一的训练设定。之后,我们依次介绍三种最常见的策略。

零、统一的训练设定:固定长度的 next-token prediction

​ 为了便于分析,下面所有切分策略都基于同一个基本假设:模型每次处理一段固定长度为 T T T的token序列。

1. 输入与输出的构造方式

​ 在语言建模任务中,我们通常采用 next-token prediction 作为训练目标。具体来说:

  • 输入:长度为 T T T的 token 序列

  • 标签:将输入序列整体右移 1 位,长度仍为 T T T

  • 例如,对于原始 token 序列:
    ( x 1 , x 2 , x 3 , x 4 , x 5 , x 6 , ⋯   ) (x_1, x_2, x_3, x_4, x_5, x_6, \cdots) (x1,x2,x3,x4,x5,x6,)
    我们可以构造一个训练样本:

    • 输入: [ x 1 , x 2 , x 3 , x 4 , x 5 ] [x_1, x_2, x_3, x_4, x_5] [x1,x2,x3,x4,x5]
    • 输出: [ x 2 , x 3 , x 4 , x 5 , x 6 ] [x_2, x_3, x_4, x_5, x_6] [x2,x3,x4,x5,x6]
  • 这种“输入一段上下文、预测下一步 token”的训练方式,正是语言模型中最经典、也最通用的 next-token prediction 形式。

​ 在这一设定下,序列切分的差异,就体现在:这些长度为 T T T的输入片段,是如何从原始长序列中取出的。

(1) 为什么训练形式是「序列 → 序列」

​ 从表面上看,这种训练方式有一个容易让人困惑的地方:输入是一个序列,输出为什么也是一个序列,而不是单个 token?

​ 答案在于:**语言模型的预测目标本身就是“逐时间步定义的”。**也就是说,这并不是人为设计的约定,而是由自回归建模方式自然决定的。

​ 在自回归语言模型中,序列的联合概率通过链式法则分解为:
P ( x 1 , ⋯   , x T ) = ∏ t = 1 T P ( x t ∣ x < t ) P(x_1, \cdots, x_T) = \prod_{t=1}^TP(x_t|x_{<t}) P(x1,,xT)=t=1TP(xtx<t)
​ 这意味着模型在时间步 t t t,模型的目标是预测 P ( x t ∣ x < t ) P(x_t|x_{<t}) P(xtx<t)。也就是说,每一个时间步,都对应着一个独立且明确的预测任务

(2) 一次前向传播,对应 T T T次预测

​ 当模型接收一整段长度为 T T T的输入序列时,它并不是“等看完再预测”,而是会在内部并行地产生一组预测结果:
P ( x t + 1 ∣ x ≤ t ) , t = 1 , 2 , ⋯   , T P(x_{t+1}|x_{\le t}), t=1,2,\cdots, T P(xt+1xt),t=1,2,,T
​ 从实现角度看:

  • 模型会为序列中的 每一个位置 生成一个隐状态

  • 每个位置的隐状态,都会输出一个对“下一个 token”的预测分布

    因此,把这 T T T个时间步的预测结果一次性组织成一个序列输出,就成为最自然、也是最高效的训练方式。

2. 工程视角下的三点收益

​ 将训练目标设计为“序列 → 序列”,在工程上有非常直接的好处:

  • 充分利用监督信号
    一段长度为 T T T的真实文本,可以同时提供 T T T个预测目标
  • 训练与推理形式一致
    训练阶段和自回归生成阶段,遵循同样的“逐 token 预测”逻辑
  • 高度并行化
    所有时间步的预测可以在一次前向传播中完成,显著提升训练效率

​ 因此,语言模型“输出也是一个序列”,并不是人为增加的复杂设计,而是由自回归建模方式和模型结构共同决定的自然结果

一、理论上的滑动窗口(stride = 1)

1. 最直接、也最“完整”的切分方式

  • 在所有切分策略中,滑动窗口(sliding window) 是最直观的一种:

    • 窗口长度固定为 T T T
    • 每次向前移动 1 个 token(即 stride = 1)
    • 从序列开头一直滑到结尾
  • 形式化地说,对于原始序列: ( x 1 , x 2 , ⋯   , x N ) (x_1, x_2, \cdots, x_N) (x1,x2,,xN),可以构造出如下训练样本:
    ( x 1 , ⋯   , x T ) , ( x 2 , ⋯   , x T + 1 ) , ⋯ (x_1, \cdots, x_T), (x_2, \cdots, x_{T+1}),\cdots (x1,,xT),(x2,,xT+1),
    ​ 对于长度为 N N N的原始序列,使用 窗口大小为 T T T步长(stride)为 1 的滑动窗口切分,会得到 N − T N-T NT个序列训练样本。

    ​ 此外,可以看到样本高度重叠、数据最密集、信息利用最充分。

2. 为什么它是“理论上的理想方案”

​ 这种方式具有一个非常重要的性质:它最大限度地保留了局部上下文信息。

  • 几乎每一个 token,都会出现在多个不同的上下文窗口中

  • 模型可以充分学习到条件概率 P ( x t ∣ P x < t ) P(x_t|P_{x<t}) P(xtPx<t)的局部统计规律

    因此,从数据利用率的角度看,滑动窗口通常被视为理论上的理想上界

  • 局限性

    虽然滑动窗口在理论上很优雅,但在工程实践中很少直接使用:

    • 样本数量巨大,计算和存储成本极高
    • 同一 token 会被重复计算多次
    • GPU 并行效率低

    因此,真实训练中通常采用更高效的近似方案

3. 工程上的现实问题

​ 尽管理论上的滑动窗口(stride = 1)能够完整覆盖所有可能的 next-token 监督信号,但在工程实践中,它会带来一系列非常现实、且难以忽视的问题。

  • 相邻样本高度重叠,信息冗余严重

    相邻滑动窗口样本之间,重叠的是 T − 1 T-1 T1 个 token,这意味着新增的计算,往往只引入极少量的“新信息”。模型在连续的多次前向传播中,反复处理几乎相同的上下文。

  • 样本数量线性增长,训练规模迅速膨胀

    对于长度为 N N N的原始序列,窗口大小为 T T T时,滑动窗口(stride = 1)会生成 N − T N-T NT个序列级训练样本。

    在大规模语料场景下, N N N往往以百万token计, N − T ≈ N N-T \approx N NTN,这意味着训练样本数几乎与原始语料长度等量增长,数据加载、shuffle、batch 组织的开销显著增加。

  • 计算与存储成本难以接受

    将上述两点结合起来,滑动窗口策略会在多个层面放大资源消耗:

    • 计算成本
      大量高度相似的样本,导致重复的前向与反向计算
    • 显存 / 内存压力
      更大的 batch 中包含大量冗余 token,降低有效 token 吞吐率
    • 存储与 I/O 开销
      若提前生成并保存切分后的样本,数据体积会急剧膨胀

    在大模型训练中,这类冗余会直接转化为可观的时间与硬件成本

  • 因此,在实际训练系统中,滑动窗口更多是一种“分析基准”,而不是直接采用的工程方案。

二、随机采样

Random Sampling

1. 为什么需要随机采样

  • 在理论上,**滑动窗口(stride=1)**可以生成最完整的训练集;但在工程实践中,它的代价过高:

    • 样本数量随序列长度线性膨胀
    • 相邻样本高度重叠,token 被重复计算
    • 存储、IO 与计算成本都难以接受

    因此,实际系统中更常见的做法是:不构造完整训练集,而是在“潜在完整样本空间”中进行随机采样。

  • 随机采样的目标并不是“制造更多样本”,而是:

    • 避免全量滑动窗口带来的资源消耗
    • 在多个 epoch 中不断改变子序列的对齐方式
    • 提升训练过程的随机性与泛化能力

2. 核心思想

​ 与滑动窗口(stride=1)会生成完整训练集不同,随机采样方式并不会生成所有长度为 T T T的子序列,而是:

在完整训练集上随机抽取若干子序列用于训练

​ 为此,引入了一个关键设计:随机偏移量 offset
offset ∼ U ( 0 , T − 1 ) \text{offset} \sim \mathbf{U}(0, T-1) offsetU(0,T1)

  • 这一步的作用是:

    • 随机改变整条序列的“切分起点”
    • 避免模型在每个 epoch 中总是从 x 1 x_1 x1开始看到序列
  • 此时,子序列的划分形式变为:
    ( x offset + 1 , x offset + 2 , ⋯   , x offset + T ) (x_{\text{offset}+1}, x_{\text{offset}+2}, \cdots, x_{\text{offset}+T}) (xoffset+1,xoffset+2,,xoffset+T)

  • 需要特别强调的是:随机采样并不是在子序列内部打乱 token,而是随机选择子序列的起点。

3. 样本之间的关系:不连续、不保证顺序

  • 在随机采样中:

    • 每个样本都是从原始长序列中独立截取的一段长度为 T T T的子序列
    • batch 内的样本在原始序列中不一定相邻,甚至毫无关系
  • 例如,假设这一轮训练随机抽取了 3 个样本,其起点分别为:

    • 样本 A 起点 = 3
    • 样本 B 起点 = 10
    • 样本 C 起点 = 6

    对应的子序列为:
    A : ( x 3 , x 4 , ⋯   , x T + 2 ) B : ( x 10 , x 11 , ⋯   , x T + 9 ) C : ( x 6 , x 7 , ⋯   , x T + 5 ) A: (x_3, x_4, \cdots, x_{T+2}) \\ B: (x_{10}, x_{11}, \cdots, x_{T+9}) \\ C: (x_6, x_7, \cdots, x_{T+5}) A:(x3,x4,,xT+2)B:(x10,x11,,xT+9)C:(x6,x7,,xT+5)
    可以看到:

    • A 与 B 在原序列中相距很远
    • C 与 A 有部分重叠,但并非相邻样本
    • batch 内的样本整体呈现为乱序、非连续状态

    这正是随机采样希望达到的效果。

4. 代码实现:基于随机偏移的无重叠子序列采样

​ 下面的实现展示了一个典型的随机采样 DataLoader。在这里,参数batch_size指定了每个小批量中子序列样本的数目, 参数num_steps是每个子序列中预定义的时间步数。

Step 1:随机偏移,避免固定切分边界
offset = random.randint(0, num_steps - 1)
trimmed = corpus[offset:]

​ 这里随机跳过前 offset 个 token,防止模型每一轮训练都看到相同的切分方式。

Step 2:计算可生成的子序列数量(无重叠)
num_subseqs = (len(trimmed) - 1) // num_steps

​ 之所以减 1,是因为语言模型采用 next-token prediction,需要为每个输入 token 提供一个对应的标签。

Step 3:确定所有子序列的起点(等距、不重叠)
initial_positions = list(range(0, num_subseqs * num_steps, num_steps))

​ 每个子序列的起点是等距的(KaTeX parse error: Expected 'EOF', got '_' at position 12: T=\text{num_̲steps})。这些起点为:0, T, 2T, 3T, ...

​ 例如,生成的子序列索引:[0:5], [5:10], [10:15], ...

  • 因此生成的子序列满足:

    • 完全不重叠,每个字序列之间相距num_steps
    • 不共享任何 token,每个样本都是从 corpus 中不相交的片段截出来的
    序列:     a b c d e f g h i j k l m n o
    子序列:   └─────┘ └─────┘ └─────┘
                4       4       4
    
Step 4: 打乱子序列起点,实现随机采样
import random
random.shuffle(initial_positions)

​ 随机采样不是按顺序取片段,而是随机选择片段。这样做,保证 batch 内的句子不连续、不相关。

  • 例如,原序列:
    x 1 , x 2 , ⋯   , x 14 , x 15 x_1, x_2, \cdots, x_{14}, x_{15} x1,x2,,x14,x15
    设定:

    • num_steps = 5
    • 则可切分的无重叠子序列起点为:
    [0, 5, 10]
    
    • 对应的子序列为:
      • 起点 0 → [x1, x2, x3, x4, x5]
      • 起点 5 → [x6, x7, x8, x9, x10]
      • 起点 10 → [x11, x12, x13, x14, x15]
    • 现在进行随机打乱:[10, 0, 5]
      • Batch1 可能拿的是 [x11, x12, x13, x14, x15]
      • Batch2 可能是 [x1, x2, x3, x4, x5]
      • Batch3 可能是 [x6,x7, x8, x9, x10]

    可以看出,非常随机,不受原始顺序影响。

Step 5: 构造 batch,并生成 X / Y
trimmed corpus
 └── 划分 → num_subseqs = 可切出的所有子序列数量
        └── 按 batch_size 分组
                └── 得到 num_batches 个 batch

num_subseqs = 总子序列数  
batch_size  = 每个 batch 样本数  
num_batches = 总子序列数 / 每个 batch 样本数
  • 计算batch个数
    在这里插入图片描述

吐槽一下csdn,这里的latex公式无论怎么打都不能正常显示,我是最讨厌用截图来敷衍的。大家将就一下。

  • 每个 batch 取 batch_size 个起点,每个起点对应一个长度为 num_steps 的子序列

    for i in range(0, batch_size * num_batches, batch_size):
        batch_positions = initial_positions[i:i + batch_size]
    

    这里的batch_positions是从initial_positions中取出的一个 batch 里的起始位置集合。

    • 例如:

      initial_indices = [200, 40, 600, 20, 480, 300, ...]  # 已打乱
      
      batch_size = 2
      第一次 batch -> [200, 40]
      第二次 batch -> [600, 20]
      ...
      
  • 构造输入序列 X 与标签序列 Y

    def get_subseq(start):
        return trimmed[start:start + num_steps]
    
    X = [get_subseq(pos) for pos in batch_positions]
    Y = [get_subseq(pos + 1) for pos in batch_positions]
    
完整实现:
import random
import torch

def seq_data_iter_random(corpus, batch_size, num_steps):
    """
    随机采样方式的小批量序列生成器
    corpus: 整个语料(token 序列)
    batch_size: 每个 batch 中的样本数量
    num_steps: 每个子序列的长度
    """
    # ---- 1. 随机偏移 ----
    offset = random.randint(0, num_steps - 1)
    trimmed = corpus[offset:]

    # ---- 2. 计算可切分的子序列数量 ----
    num_subseqs = (len(trimmed) - 1) // num_steps
    initial_positions = list(range(0, num_subseqs * num_steps, num_steps))

    # ---- 3. 打乱所有子序列的起点 ----
    random.shuffle(initial_positions)

    def get_subseq(start):
        return trimmed[start:start + num_steps]

    # ---- 4. 构造 batch ----
    num_batches = num_subseqs // batch_size
    for i in range(0, batch_size * num_batches, batch_size):
        batch_positions = initial_positions[i:i + batch_size]

        X = [get_subseq(pos) for pos in batch_positions]
        Y = [get_subseq(pos + 1) for pos in batch_positions]

        yield torch.tensor(X), torch.tensor(Y)
  • 例如,我们生成一个从 0 0 0 34 34 34的序列。 假设批量大小为 2 2 2(batch_size=2),时间步数为 5 5 5(num_steps=5),这意味着可以生成 ⌊ 35 − 1 5 = 6 ⌋ \lfloor \frac{35-1}{5} = 6\rfloor 5351=6个“特征-标签”子序列对。 因为每个小批量中的样本为2,我们只能得到3个小批量。

    my_seq = list(range(35))
    batch_count = 0
    for X, Y in seq_data_iter_random(my_seq, batch_size=2, num_steps=5):
        print(f"=====batch {batch_count+1}======")
        print('X: ', X, '\nY:', Y)
        batch_count += 1
    
    =====batch 1======
    X:  tensor([[15, 16, 17, 18, 19],
    [ 0,  1,  2,  3,  4]]) 
    Y: tensor([[16, 17, 18, 19, 20],
    [ 1,  2,  3,  4,  5]])
    =====batch 2======
    X:  tensor([[20, 21, 22, 23, 24],
    [25, 26, 27, 28, 29]]) 
    Y: tensor([[21, 22, 23, 24, 25],
    [26, 27, 28, 29, 30]])
    =====batch 3======
    X:  tensor([[ 5,  6,  7,  8,  9],
    [10, 11, 12, 13, 14]]) 
    Y: tensor([[ 6,  7,  8,  9, 10],
    [11, 12, 13, 14, 15]])
    

    在这里插入图片描述

5. 为什么随机采样要这样设计

​ 从建模假设上看,随机采样并不要求不同子序列之间保持原始文本中的连续性。这是因为,在语言模型的训练目标中,每一个长度为 T T T的子序列,本身就已经构成了一个完整、独立的监督样本

​ 具体来说,语言模型采用的是 next-token prediction 训练形式:

X = [t0, t1, t2, t3, t4]
Y = [t1, t2, t3, t4, t5]

​ 在这一设定下,模型在每一个时间步 t t t,只关心: P ( t i + 1 ∣ t ≤ i ) P(t_{i+1}∣t_{≤i}) P(ti+1ti),监督信号完全来自子序列内部不依赖于相邻子序列是否在原始语料中连续。

​ 因此,从训练目标的角度看:只要子序列内部保持 token 的顺序关系,不同子序列之间是否相邻,并不会影响 next-token prediction 的正确性。

  • 在这一前提下,随机采样的设计带来了非常明确的工程优势:

    • 高度并行化
      每个子序列都是独立样本,可以在 GPU 上同时计算,不需要维护跨序列状态。
    • 便于打乱与随机化
      通过打乱子序列起点,可以有效减弱位置偏置(position bias),使模型不会过度依赖特定位置模式。
    • 计算与存储效率更高
      子序列之间不重叠,避免了同一个 token 在大量样本中被重复计算,从而显著降低计算成本。
  • 从这个角度看,“不重叠”并不是信息利用不足,而是一种有意识的工程取舍:在不改变训练目标的前提下,用更少的重复计算,获得足够多、足够随机的监督信号。

    这也是为什么,在实际的语言模型训练系统中,随机采样往往比 stride=1 的滑动窗口更常用。

三、顺序采样

Sequential Sampling

​ 在语言模型(尤其是 RNN / LSTM / 早期 Transformer)中,序列的连续性本身就是一种重要信号

  • 句法结构是跨 token 延续的

  • 语义状态会随时间逐步演化

  • 隐状态(hidden state)天然假设时间连续

    因此,与完全打乱的随机采样不同,顺序采样试图最大程度保留原始语料的时间结构

1. 顺序采样的动机

​ 随机采样强调样本独立性和并行效率,但它刻意破坏了时间上的连续性。而在某些场景(尤其是 RNN / LSTM)中,我们希望:**模型在 batch 内仍然能够看到连续的上下文片段。**顺序采样正是为此而设计的。

  • 核心思想:让模型看到语料中“连续”的序列片段,尽可能保持上下文的自然衔接。

  • 顺序采样的特点可以概括为:

    • 不打乱语料的整体顺序
    • batch 内的每一行是一段连续时间序列
    • 行与行之间相互独立

    其本质是:在保持 batch 并行计算的前提下,尽量保留时间维度上的连续性。

2. 与随机采样的根本区别

  • 随机采样: 把语料视为一个“token 池”,子序列之间不要求相关性

  • 顺序采样: 把语料视为一条“长时间序列”,子序列是这条序列的切片

  • 与随机采样不同,顺序采样中整个 corpus 会被按顺序切成若干子片段。
    在这里插入图片描述

    这也是为什么顺序采样更常用于:

  • RNN / LSTM 的语言模型

  • 需要跨 batch 传递隐状态的训练方式

  • 分析模型对长期依赖的建模能力

3. 顺序采样的实现

Step 1:随机偏移(Random Offset)
# ---- 1. 随机偏移 ----
offset = random.randint(0, num_steps)
  • 为什么需要 offset?

    如果每个 epoch 都从 corpus[0] 开始切分,那么:

    • 每一轮训练看到的子序列切分位置完全相同
    • 模型容易记住“切分边界”,而不是语言规律
    • 泛化能力会明显下降
  • 随机偏移的作用是:

    • 打破固定切分边界
    • 让同一个 token 在不同 epoch 中出现在不同位置
    • 在不破坏顺序结构的前提下引入随机性
  • 在顺序采样中,offset 可以取到 num_steps(闭区间),因为我们后面是 先构造大段序列,再 reshape,不会产生越界问题。

Step2:计算可用于 batch 的 token 数
num_tokens = ((len(corpus) - offset - 1) // batch_size) * batch_size

​ 这一行是纯工程约束,但非常关键

  • len(corpus) - offset - 1:可用的 token 数,其中- offset是跳过随机起点之前的 token;

    - 1:为构造 Y(右移一位)预留空间

  • // batch_size:按 batch_size 整除,避免最后出现“凑不齐一个 batch” 的情况

  • * batch_size:重新计算出可整除的 token 数

    顺序采样的一个重要特点是:我们宁愿丢掉少量尾部数据,也要保证 batch 内结构严格一致。

Step3:构造 X 和 Y(严格对齐的 next-token prediction)
Xs = torch.tensor(corpus[offset : offset + num_tokens])
Ys = torch.tensor(corpus[offset + 1 : offset + 1 + num_tokens])

​ 这是语言模型中最经典、也是最“干净”的训练格式:

  • X(输入)

    [t0, t1, t2, ..., tn-1]
    
  • Y(标签)

    [t1, t2, t3, ..., tn]
    

    二者长度完全一致,只是整体右移一位。这正是 next-token prediction 的数学实现形式: P ( x t + 1 ∣ x ≤ t ) P(x_{t+1} \mid x_{\le t}) P(xt+1xt)

Step 4:reshape 成 batch_size 行的“并行长序列”
Xs, Ys = Xs.reshape(batch_size, -1), Ys.reshape(batch_size, -1)

​ 此时,每一行是一条 时间上连续的长序列,不同行之间是 相互独立、并行训练的样本

​ 可以把它理解为:我们从一条超长时间轴中, “抽取了 batch_size 条并行时间轨道”

Step 5:按 num_steps 切出连续的训练片段
num_batches = Xs.shape[1] // num_steps

for i in range(0, num_steps * num_batches, num_steps):
    X = Xs[:, i: i + num_steps]
    Y = Ys[:, i: i + num_steps]
    yield X, Y

这里切出来的每一个 (X, Y)

  • 行内是连续的 token
  • 列方向保持严格对齐
  • 在 batch 之间 时间顺序不被打乱

这一步确保了:模型在训练时,看到的是“自然语言原本的时间展开方式”。

完整实现
def seq_data_iter_sequential(corpus, batch_size, num_steps):  #@save
    """使用顺序分区生成一个小批量子序列"""
    # 从随机偏移量开始划分序列
    offset = random.randint(0, num_steps)
    num_tokens = ((len(corpus) - offset - 1) // batch_size) * batch_size
    Xs = torch.tensor(corpus[offset: offset + num_tokens])
    Ys = torch.tensor(corpus[offset + 1: offset + 1 + num_tokens])
    Xs, Ys = Xs.reshape(batch_size, -1), Ys.reshape(batch_size, -1)
    num_batches = Xs.shape[1] // num_steps
    for i in range(0, num_steps * num_batches, num_steps):
        X = Xs[:, i: i + num_steps]
        Y = Ys[:, i: i + num_steps]
        yield X, Y
        
        
my_seq = list(range(35))
for X, Y in seq_data_iter_sequential(my_seq, batch_size=2, num_steps=5):
		print('X: ', X, '\nY:', Y)

输出:

X:  tensor([[ 1,  2,  3,  4,  5],
     [17, 18, 19, 20, 21]]) 
Y: tensor([[ 2,  3,  4,  5,  6],
     [18, 19, 20, 21, 22]])
X:  tensor([[ 6,  7,  8,  9, 10],
     [22, 23, 24, 25, 26]]) 
Y: tensor([[ 7,  8,  9, 10, 11],
     [23, 24, 25, 26, 27]])
X:  tensor([[11, 12, 13, 14, 15],
     [27, 28, 29, 30, 31]]) 
Y: tensor([[12, 13, 14, 15, 16],
     [28, 29, 30, 31, 32]])

​ 输出示例清楚地说明:

  • 每一行内部是严格连续的 token

  • 不同行是并行的独立样本

  • batch 之间保持时间顺序推进:同一行在不同 batch 中是连续的。

    这正是顺序采样的设计目标:时间连续性不是体现在 batch 内,而是体现在 batch 之间

四、三种切分方式的对比与取舍

​ 在理论上,如果对一个长度为 n n n的序列进行建模,最“彻底”的方式是采用 滑动窗口(stride = 1),在每一个时间步都构造一个训练样本。

​ 但在实际训练系统中,无论是随机采样还是顺序采样,生成的 X X X样本通常都是互不重叠的,而不是每个时间步都生成一个样本。

​ 这是一个工程取舍问题,而不是建模正确性的问题。

1. 整体对比

方式是否重叠样本数量计算效率使用场景
滑动窗口极多小数据、分析验证
随机采样适中大规模训练
顺序采样适中保留上下文

重叠样本不是“更正确”,而是一种成本极高的策略。

​ 在大规模训练中,不重叠 + 批量并行,往往性价比更高。

1. 理论滑动窗口 VS 实际训练切分

​ 以序列长度为 n n nnum_steps=5 为例:

方式样本长度样本数量是否重叠优缺点
理论滑动窗口5 n − 5 + 1 n - 5 + 1 n5+1最大化训练样本数量,但训练效率低
常规 seq / random5 ⌊ n / 5 ⌋ \lfloor n / 5 \rfloor n/5高效、课并行、上下文连续、训练稳定

​ 可以看到,两者的本质差异并不在于样本形式,而在于是否允许重叠

2. 滑动窗口采样的样本数量是如何膨胀的

​ 假设序列长度为 n n n
x 0 , x 1 , ⋯   , x n − 1 x_0, x_1, \cdots, x_{n-1} x0,x1,,xn1
​ 我们希望 每个样本长度为num_steps,并用于预测下一个 token(或下一个 num_steps 个 token):

  • 滑动窗口方式(步长 = 1):

    • 每个样本长度 = num_steps
    • 样本 KaTeX parse error: Expected 'EOF', got '_' at position 42: … x_{i+\text{num_̲steps}-1}]
    • 对应的KaTeX parse error: Expected 'EOF', got '_' at position 46: … x_{i+\text{num_̲steps}}]
  • 如果 num_steps = 5,那么第一条样本i=0就是:
    X [ 0 ] = [ x 0 , x 1 , ⋯   , x 4 ] Y [ 0 ] = [ x 1 , x 2 , ⋯   , x 5 ] X[0] = [x_0, x_1, \cdots, x_4] \\ Y[0] = [x_1, x_2, \cdots, x_5] X[0]=[x0,x1,,x4]Y[0]=[x1,x2,,x5]

    • 第二条样本(滑动一步):
      X [ 1 ] = [ x 1 , x 2 , ⋯   , x 5 ] Y [ 1 ] = [ x 2 , x 3 , ⋯   , x 6 ] X[1] = [x_1, x_2, \cdots, x_5] \\ Y[1] = [x_2, x_3, \cdots, x_6] X[1]=[x1,x2,,x5]Y[1]=[x2,x3,,x6]

    • …依此类推,最终可生成的样本数量为: n - num_steps

  • 但是:相邻样本之间共享 num_steps − 1 个 token,高度重叠。

3. 为什么实际 batch 训练中不构造 n − 1 n-1 n1个样本

  • 在真实训练中,我们通常不会采用 stride = 1 的滑动窗口,而是:

    • 一次取 num_steps 个时间步作为一个样本
    • 在 batch 维度上并行训练多条序列轨道
  • 具体来说:

    • 若序列长度为 n n n
    • 每个样本长度为 num_steps
    • 最多可以生成 KaTeX parse error: Expected 'EOF', got '_' at position 22: …r n / \text{num_̲steps} \rfloor互不重叠的样本
  • 这样做的好处:

    • 避免重复计算

      • 滑动窗口会反复计算几乎相同的 token 组合
    • 显著提升 GPU 利用率

      • 不重叠样本更适合批量并行
    • 训练更稳定

      • 减少高度相关样本带来的梯度冗余
    • 更贴合现代模型训练范式

      • Transformer / GPT 并不依赖跨样本隐状态

⚠️ 如果希望引入更多上下文覆盖,可以使用 步长 < num_steps 的滑动窗口,但这一定会带来更高的时间与显存成本。

使用 **2 张 4090D(单卡 24GB 显存)** 推理 **32B 参数的 int8 量化大模型** 时,最大支持的上下文长度需综合考虑 **模型权重显存、KV 缓存显存、多卡并行效率**,以下是详细计算和优化建议: --- ### **1. 显存占用关键因素** #### **(1) 模型权重显存(静态占用)** - **32B 模型 int8 量化**: 原始 FP16 权重大小为 `32B × 2 bytes = 64GB`,int8 量化后为 `32B × 1 byte = 32GB`。 - **2 卡并行(Tensor Parallel)**: 每卡分配 `32GB / 2 = 16GB` 的权重数据(包括线性层切分后的部分)。 #### **(2) KV 缓存显存(动态占用)** - **KV 缓存计算**: 对于每个 token,需存储 **Key(K)** 和 **Value(V)** 矩阵,大小与模型隐藏层维度(`hidden_size`)和头数(`num_heads`)相关。 假设模型架构类似 Llama-2(`hidden_size=8192`,`num_heads=64`),则: - **每个 token 的 KV 缓存大小**: `2 × (hidden_size × num_heads / num_heads_per_block) × bytes_per_element` (int8 量化下 `bytes_per_element=1`,但实际可能因实现差异为 2 字节) 简化计算:`2 × 8192 × 64 × 1 ≈ 1MB/token`(实际可能更低,需实测)。 - **总 KV 缓存**: `max_context_length × tokens_per_block × 2 cards`(若跨卡共享 KV 缓存,则无需乘以 2)。 #### **(3) 其他显存开销** - **临时缓冲区**: 注意力计算中的 `QK^T` 矩阵、Softmax 等需额外显存(通常为 `batch_size × seq_len × head_dim`)。 - **优化器状态(推理时无需)**: 仅训练时占用,推理可忽略。 --- ### **2. 显存分配计算** #### **假设模型参数** - 模型架构:Llama-2 风格(`hidden_size=8192`,`num_heads=64`,`num_layers=80`)。 - 量化方式:int8(权重 1 字节,激活值可能仍为 FP16/BF16)。 - 多卡策略:Tensor Parallel(权重切分) + PagedAttention(KV 缓存分块)。 #### **单卡显存预算** - 总显存:24GB - 保留 10% 缓冲:`24GB × 0.9 = 21.6GB` 可用 - 已分配权重:16GB - **剩余 KV 缓存预算**:`21.6GB - 16GB = 5.6GB` #### **KV 缓存计算** - **单 token 缓存大小**: 实测或参考类似模型(如 Qwen-7B int8 的 KV 缓存约为 `0.8MB/token`)。 假设为 `1MB/token`(保守估计)。 - **单卡最大 KV 缓存**: `5.6GB / 1MB/token ≈ 5600 tokens` - **两卡共享 KV 缓存**(若使用 vLLM 的 PagedAttention): 总上下文长度可扩展至 `5600 × 2 = 11200 tokens`(需验证是否跨卡共享)。 #### **更精确的估算方法** 使用 vLLM 的 `--max-model-len` 测试法: ```bash # 启动服务并逐步增加上下文长度,观察显存占用 vllm serve \ --model-path ./32B-int8-model \ --tensor-parallel-size 2 \ --max-model-len 8192 # 初始值 ``` 通过 `nvidia-smi` 监控显存,直到报错 `CUDA OOM`,记录临界值。 --- ### **3. 实际可达的上下文长度** | **场景** | **上下文长度** | **依据** | |------------------------|----------------|--------------------------------------------------------------------------| | **保守估计** | 8192 tokens | 假设 KV 缓存为 `1MB/token`,两卡共享 `5.6GB × 2 = 11.2GB`,留安全余量 | | **乐观估计** | 12288 tokens | 若 KV 缓存优化至 `0.8MB/token`,则 `5.6GB / 0.8MB ≈ 7000`,两卡约 14000(需折减) | | **实测推荐** | **10240 tokens** | 平衡安全性和性能,建议通过压力测试验证 | --- ### **4. 优化建议** #### **(1) 降低 KV 缓存开销** - **启用 PagedAttention**: vLLM 的 `PagedAttention` 机制可动态分配 KV 缓存块,减少碎片。 - **减小 `block_size`**: `--block-size 16`(默认 32)可提升短序列的显存利用率。 - **量化激活值**: 若支持,将激活值量化为 int8(需模型兼容)。 #### **(2) 模型优化** - **LoRA 微调**: 冻结大部分权重,仅微调少量参数,减少临时显存占用。 - **梯度检查点**: 虽主要用于训练,但推理时可借鉴其显存优化思想(如分块计算)。 #### **(3) 系统级优化** - **CUDA 内存池**: 使用 `CUDA_CACHE_DISABLE=1` 避免缓存占用。 - **卸载非关键层**: 将 Embedding 或 Projection 层卸载到 CPU(需修改模型代码)。 --- ### **5. 示例配置** ```bash vllm serve \ --model-path ./32B-int8-model \ --tensor-parallel-size 2 \ # 2 卡并行 --max-model-len 10240 \ # 上下文长度 --block-size 16 \ # 优化 KV 缓存分配 --disable-log-stats \ # 减少日志显存占用 --gpu-memory-utilization 0.9 # 最大化显存利用 ``` --- ### **6. 验证方法** ```python import requests # 测试长上下文生成 prompt = "A" * 10000 # 10K token 输入 data = { "prompt": prompt, "max_tokens": 100, "temperature": 0.7 } response = requests.post("http://localhost:8000/generate", json=data) print(response.json()) ``` - **成功**:继续增加 `prompt` 长度。 - **失败(OOM)**:降低 `--max-model-len` 或优化 KV 缓存。 ---
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值