第 8 章 第二部分:预训练数据工程

Tokenization与Packing

BPE/Unigram/SentencePiece算法、词表设计(32K/64K/128K/256K)、中文坑(字节切分/多语种平衡/数字按位拆)、Packing三种策略、Document Mask、Megatron格式(.bin/.idx+mmap)

Tokenization BPE SentencePiece Packing Document Mask Megatron

Tokenizer 是文本与张量之间的桥梁。

Packing 是数据工程的最后一公里。两者决定 GPU 的真实利用率。


8.1 三大 Tokenization 算法

8.1.1 BPE (Byte Pair Encoding)

BPE 是 GPT 系列、Llama 系列的事实标准。

核心思想:从字符开始,迭代合并最频繁出现的相邻字符对。

初始词汇表: ['a', 'b', 'c', 'd', 'e', ...]  # 单字符
训练语料: ['low', 'lower', 'newest', 'widest']

迭代1: 'l'+'o' → 'lo' (出现3次)
迭代2: 'lo'+'w' → 'low' (出现3次)
迭代3: 'e'+'r' → 'er' (出现2次)
迭代4: 'low'+'er' → 'lower' (出现2次)
...

最终词汇表: ['a', 'b', ..., 'low', 'er', 'lower', ...]

8.1.2 Unigram

Unigram 是 SentencePiece 的可选算法。

核心思想:从一个大词汇表开始,迭代删除”对整体似然贡献最小”的 token。

初始词汇表: 100K 个候选 token
训练语料: 大量文本

迭代1: 计算每个 token 对似然的贡献,删除贡献最小的 10%
迭代2: 重新计算,再删除 10%
...

最终词汇表: 32K 个最优 token

8.1.3 WordPiece

WordPiece 是 BERT 使用的算法,与 BPE 类似但选择标准不同:

  • BPE:选择频率最高的对
  • WordPiece:选择使整体似然最大的对

8.1.4 算法对比

算法代表模型优势劣势
BPEGPT-2/3/4, Llama简单、可控词表增长慢
UnigramT5, ALBERT似然最优、可逆训练慢
WordPieceBERT似然导向多语种支持差
SentencePiece多语种模型语言无关配置复杂

8.1.5 BPE 的改进方向

BPE 虽然是大模型事实标准,但其合并策略仍有显著改进空间。2025-2026 年两项工作值得关注:

  • SuperBPE(COLM 2025):重新设计 BPE 的合并方式,在 30 个 benchmark 上平均提升 4.0%、MMLU 提升 8.2%、减少 33% token 数量、推理计算节省 27%
  • LiteToken(2026-02):去除 BPE 的”中间合并残留”(intermediate merge residues),不需要重训模型即可应用到已有 tokenizer——对工业团队有直接实用价值
  • BoundlessBPE:与 SuperBPE 同属”放松 token 不得跨空白”约束的谱系,在通用文本上最多减少 33% token(对比 PickyBPE 仅 ~1.1% 收益)

“跨空白超词”思想的推理侧外溢:SuperBPE/BoundlessBPE 的”跨空白超词”思想,其应用边界不止预训练效率,还外溢到了推理(生成)效率领域——Shorthand for Thought(arXiv:2604.26355,Writer Inc.)对模型自己的 reasoning trace 做跨词 BPE 合并,得到模型专属的 supertoken 追加到基础词表,再仅微调 embedding 层 + LM head + 少数 transformer 层来”教会”模型使用它们。五个 benchmark 上推理轨迹平均缩短 8.1%。关键区分:它压缩的是“表达方式”而非”内容”,与内容级剪枝(step pruning、latent 替换等丢弃推理步骤的做法)有本质区别——后者会牺牲推理内容,前者只是换一种更紧凑的符号表达。

这些工作说明:即使 BPE 已是大模型事实标准,其合并策略仍有显著改进空间。对于训练成本敏感的项目,优先考虑 LiteToken(即插即用);对于新项目,SuperBPE 可提供更好的 token 效率。

8.2 SentencePiece 详解

SentencePiece 是 Google 开发的多语种 Tokenization 库,支持 BPE 和 Unigram 两种算法。

8.2.1 基础用法

import sentencepiece as spm

# 训练 SentencePiece 模型
spm.SentencePieceTrainer.train(
    input='corpus.txt',
    model_prefix='tokenizer',
    vocab_size=32000,
    character_coverage=0.9995,
    model_type='bpe',         # 或 'unigram'
    normalization_rule_name='nmt_nfkc',
    input_sentence_size=1000000,  # 训练时抽样100万句
    shuffle_input_sentence=True,
    train_extremely_large_corpus=True
)

# 使用
sp = spm.SentencePieceProcessor()
sp.load('tokenizer.model')

# 编码
tokens = sp.encode('Hello, world!')
print(tokens)  # [1234, 5678, 9, 10]
print(sp.id_to_piece(tokens))  # ['▁Hello', ',', '▁world', '!']

# 解码
text = sp.decode(tokens)
print(text)  # 'Hello, world!'

8.2.2 关键参数详解

# 推荐的 SentencePiece 训练参数
TRAINING_CONFIG = {
    'vocab_size': 32000,           # 词汇表大小
    'character_coverage': 0.9995,  # 字符覆盖率(多语种推荐)
    'model_type': 'bpe',           # BPE 或 unigram
    'normalization_rule_name': 'nmt_nfkc',  # NFKC 归一化
    
    # 训练效率
    'input_sentence_size': 1000000,
    'shuffle_input_sentence': True,
    'train_extremely_large_corpus': True,
    
    # 特殊 token
    'pad_id': 0,
    'eos_id': 1,
    'unk_id': 2,
    'bos_id': 3,
    
    # 数字处理
    'split_by_unicode_script': True,
    'split_by_whitespace': True,
    'split_by_number': True,        # 数字按位拆
    
    # 多语种支持
    'add_dummy_prefix': True,       # 句首加 ▁
    'remove_extra_whitespaces': True,
}

8.3 词表设计权衡

8.3.1 词表大小对比

词表大小优势劣势适用场景实际案例
32K训练快、内存省多语种覆盖差英文单语种Llama-1/2
64K平衡中等英文+少量多语Qwen2.5-7B
128K多语种好内存大、训练慢多语种模型Llama-3, GPT-4o(o200k_base)
152K更大覆盖更大内存超多语种Qwen2.5-72B
200K多语种极好大内存+慢超多语种GPT-4o
256K极多语种内存巨大、稀有token多超多语种Gemma-2/3 (262K)

词表军备竞赛的背后逻辑:NeurIPS 2024 证明词表大小与模型参数量呈 log-linear 关系——7B 模型最优 32K,70B 需 216K。词表膨胀并非简单的”越大越好”,而是与模型规模协同设计的必然结果。

8.3.2 词表大小对模型的影响

设总训练 tokens 数 = T,词表大小 = V
参数量贡献:embed 层 = V × d_model
词表d_model=4096embed 参数占比(7B)
32K134M1.9%
64K268M3.8%
128K536M7.7%
256K1.07B15.3%

词表过大时,embedding 层占参数比例过高,挤压 transformer 主体。 256K 时 embedding 占 15%,已经不划算。

8.3.3 中文词表的坑

坑1:字符级切分导致碎片化

直接用 BPE 训练中文,结果可能是”你好”被切成 ▁, 你, 好 三个 token,效率低。

解决:用 SentencePiece + Unigram,让算法学习常见中文词组。

坑2:字节级切分导致 OOV

字节级 BPE 把中文每个字切成 3 个 UTF-8 字节,“你好” 变成 6 个 token,效率极差。

解决:用字符级 BPE,不要用字节级。

坑3:多语种词汇表分配失衡

某团队词表 64K,给中文分配 8K,结果中文常用词覆盖不全。中文 3500 常用字 + 几千常用词组就需要 15K+ token。

解决:按语种比例分配词汇表。

# 推荐的多语种词表分配(128K 词表)
VOCAB_ALLOCATION = {
    'english': 50000,        # 40%(主力语言)
    'chinese': 30000,         # 24%(中文字符+词组)
    'code_tokens': 20000,     # 16%(代码专用)
    'numbers_punctuation': 10000,  # 8%
    'special_tokens': 8000,   # 6%(多语种稀有用)
    'reserved': 10000,        # 8%(预留)
}

坑4:数字处理

数字”12345” 可以是:

  • 12345 单个 token(数字直记)
  • 1, 2, 3, 4, 5 按位拆(推荐)

推荐按位拆:让模型学会数字运算,而不是死记大数字。

# SentencePiece 配置:数字按位拆
spm.SentencePieceTrainer.train(
    ...
    split_by_number=True,           # 启用数字按位
    max_sentence_length=4192,        # 长句截断
    num_sub_iterations=2,            # 子迭代次数
)

8.4 中文 Tokenizer 实战

8.4.1 训练中文 Tokenizer

import sentencepiece as spm

# 训练中英双语 Tokenizer
spm.SentencePieceTrainer.train(
    input='corpus_zh_en.txt',
    model_prefix='tokenizer_zh_en',
    
    # 词汇表
    vocab_size=64000,
    character_coverage=0.9995,
    
    # 算法
    model_type='unigram',  # 中文推荐 unigram
    
    # 数字与标点
    split_by_unicode_script=True,
    split_by_whitespace=True,
    split_by_number=True,
    
    # 中文专用配置
    # 不限制字符种类,让算法学习
    max_sentence_length=8192,
    
    # 训练效率
    input_sentence_size=2000000,
    shuffle_input_sentence=True,
    train_extremely_large_corpus=True,
)

# 测试
sp = spm.SentencePieceProcessor()
sp.load('tokenizer_zh_en.model')

# 中文
text_zh = "你好,世界!这是一个测试。"
print(sp.encode(text_zh))  # [5, 234, 17, 89, 92, ...]
print(sp.encode_as_pieces(text_zh))  # ['▁', '你好', ',', '世界', '!', '这是', '一个', '测试', '。']

# 英文
text_en = "Hello, world! This is a test."
print(sp.encode_as_pieces(text_en))  # ['▁Hello', ',', '▁world', '!', ...]

# 代码
text_code = "def hello():\n    print('hi')"
print(sp.encode_as_pieces(text_code))

8.4.2 踩坑:稀有 token 过多

某团队训出的 Tokenizer 有 30% 的 token 在训练语料中只出现 1-2 次,这些”死 token”占用词表空间但没贡献。

解决:训练后做 token 频率分析,删除低于阈值的 token。

def analyze_token_frequency(sp, corpus_file):
    """分析 token 频率"""
    from collections import Counter
    freq = Counter()
    
    with open(corpus_file) as f:
        for line in f:
            tokens = sp.encode(line.strip())
            for t in tokens:
                freq[t] += 1
    
    # 找出稀有 token
    rare_tokens = [t for t, count in freq.items() if count < 5]
    print(f'Rare tokens (count<5): {len(rare_tokens)} / {len(freq)}')
    
    return freq

8.5 Packing 三种策略

Packing 是把变长 token 序列打包成定长序列(如 4096)以喂给 GPU。

8.5.1 三种策略对比

策略描述利用率推荐度
Naive Padding单文档填充到定长30-50%❌ 不推荐
Concat Packing多文档拼接90-95%⚠️ 注意跨文档
Best-Fit Packing最佳适配装箱95-100%✅ 推荐

8.5.2 Naive Padding(不推荐)

# Naive Padding:单文档填充
def naive_padding(tokens, max_length=4096, pad_id=0):
    """单文档填充到 max_length"""
    if len(tokens) > max_length:
        return tokens[:max_length]
    return tokens + [pad_id] * (max_length - len(tokens))

# 问题:如果平均文档长度 500 token,填充到 4096 后
# 利用率 = 500/4096 = 12%,GPU 90% 时间在算 padding

8.5.3 Concat Packing

def concat_packing(documents, max_length=4096):
    """
    多文档拼接打包
    简单但可能跨文档串味
    """
    packed = []
    current_pack = []
    current_length = 0
    
    for doc in documents:
        if current_length + len(doc) > max_length:
            # 当前包满,处理
            if current_length < max_length:
                # 填充剩余
                current_pack.extend([0] * (max_length - current_length))
            packed.append(current_pack)
            
            # 开始新包
            current_pack = doc[:max_length]
            current_length = len(current_pack)
        else:
            current_pack.extend(doc)
            current_length += len(doc)
    
    return packed

8.5.4 Best-Fit Packing(推荐)

def best_fit_packing(documents, max_length=4096):
    """
    最佳适配装箱
    类似装箱问题,用 First-Fit-Decreasing 算法
    """
    # Step 1: 按文档长度降序排序
    sorted_docs = sorted(enumerate(documents), key=lambda x: -len(x[1]))
    
    # Step 2: 用 bin packing 算法
    bins = []  # 每个bin是 (current_length, doc_indices)
    
    for doc_idx, doc in sorted_docs:
        doc_len = len(doc)
        
        # 找到第一个能装下的 bin
        placed = False
        for i, (bin_len, _) in enumerate(bins):
            if bin_len + doc_len <= max_length:
                bins[i] = (bin_len + doc_len, bins[i][1] + [doc_idx])
                placed = True
                break
        
        # 没找到,开新 bin
        if not placed:
            bins.append((doc_len, [doc_idx]))
    
    # Step 3: 生成 packed 序列
    packed = []
    for bin_len, doc_indices in bins:
        sequence = []
        for idx in doc_indices:
            sequence.extend(documents[idx])
        
        # 填充
        if len(sequence) < max_length:
            sequence.extend([0] * (max_length - len(sequence)))
        
        packed.append({
            'tokens': sequence[:max_length],
            'doc_indices': doc_indices,
            'utilization': bin_len / max_length
        })
    
    return packed

8.5.5 利用率对比

Naive Padding:    平均利用率 ~25%(如果平均文档 1000 tokens)
Concat Packing:   平均利用率 ~92%
Best-Fit Packing: 平均利用率 ~98%

Best-Fit 相比 Naive 提升 4 倍 GPU 利用率——同样的算力能训 4 倍数据。

8.6 Document Mask:防跨文档串味

Concat Packing 和 Best-Fit Packing 把多个文档塞进一个序列,但 transformer 的 attention 会让文档 A “看到” 文档 B 的内容。

8.6.1 跨文档串味问题

Packed 序列:
  [doc_A (1000 tokens)] [doc_B (2000 tokens)] [doc_C (1096 tokens)]
                                                    
问题:训练时 doc_A 的 token 会 attend 到 doc_B 的 token
      → 模型学到不正确的"上下文"

8.6.2 解决:Document Mask

import torch

def build_document_mask(doc_indices, max_length=4096):
    """
    构建 document mask
    阻止跨文档 attention
    
    返回: [max_length, max_length] 的 mask
    1 = 允许 attention, 0 = 阻止
    """
    # 给每个 token 标记所属文档
    token_doc_id = []
    for doc_idx, doc_len in doc_indices:
        token_doc_id.extend([doc_idx] * doc_len)
    
    # 填充部分标记为 -1
    while len(token_doc_id) < max_length:
        token_doc_id.append(-1)
    
    token_doc_id = torch.tensor(token_doc_id)
    
    # 构建 mask:同 doc id 的 token 才能 attention
    mask = (token_doc_id.unsqueeze(0) == token_doc_id.unsqueeze(1)).float()
    
    # 把 padding 的部分(-1)置为 0,避免 padding 互相 attention
    padding_mask = (token_doc_id != -1).float()
    mask = mask * padding_mask.unsqueeze(0) * padding_mask.unsqueeze(1)
    
    return mask

8.6.3 Megatron-LM 的实现

Megatron-LM 用 attention_mask 参数实现 document mask:

# Megatron 格式
import numpy as np

def create_megatron_format(documents, tokenizer, max_length=4096):
    """
    生成 Megatron-LM 训练格式
    输出: .bin (token序列) + .idx (索引)
    """
    all_tokens = []
    all_doc_lens = []
    
    # Best-fit packing
    packs = best_fit_packing(documents, max_length)
    
    for pack in packs:
        all_tokens.extend(pack['tokens'])
        # 记录每个文档长度
        for doc_idx in pack['doc_indices']:
            all_doc_lens.append(len(documents[doc_idx]))
    
    # 保存 .bin (二进制 token 序列)
    tokens_array = np.array(all_tokens, dtype=np.uint16)
    tokens_array.tofile('train.bin')
    
    # 保存 .idx (索引)
    # 格式: [num_docs] [doc_lens...] [context_lengths...] [sequence_lengths...]
    write_idx_file('train.idx', all_doc_lens, max_length)
    
    return 'train.bin', 'train.idx'

8.7 Megatron 格式:.bin + .idx

Megatron-LM 用一种高效的二进制格式存储 token 数据,配合 mmap 实现零拷贝读取。

8.7.1 文件结构

train.bin    # 二进制 token 序列(uint16 或 uint32)
train.idx    # 索引文件(文档边界信息)

8.7.2 索引文件格式

# .idx 文件结构(简化版)
def write_idx_file(idx_path, doc_lens, max_length):
    """
    写入 Megatron 索引文件
    """
    import struct
    
    with open(idx_path, 'wb') as f:
        # Header
        f.write(struct.pack('I', 1))             # 版本号
        f.write(struct.pack('Q', len(doc_lens)))  # 文档数
        f.write(struct.pack('I', max_length))     # 序列长度
        
        # 文档长度列表
        for doc_len in doc_lens:
            f.write(struct.pack('I', doc_len))
        
        # 文档起始位置(在 .bin 中的偏移)
        offset = 0
        for doc_len in doc_lens:
            f.write(struct.pack('Q', offset))
            offset += doc_len
        
        # 序列边界(每个 max_length 一个 sequence)
        num_sequences = offset // max_length
        f.write(struct.pack('Q', num_sequences))
        
        for i in range(num_sequences):
            seq_start = i * max_length
            seq_end = min((i + 1) * max_length, offset)
            f.write(struct.pack('Q', seq_start))
            f.write(struct.pack('Q', seq_end))

8.7.3 mmap 读取

import numpy as np
import mmap

class MegatronDataset:
    """Megatron 数据集 mmap 读取"""
    
    def __init__(self, bin_path, idx_path):
        # 加载索引
        self.idx = self._load_idx(idx_path)
        
        # mmap 二进制数据
        self.bin_file = open(bin_path, 'rb')
        self.bin_mmap = mmap.mmap(
            self.bin_file.fileno(), 0, 
            access=mmap.ACCESS_READ
        )
        
        # 转换为 numpy view(零拷贝)
        self.tokens = np.frombuffer(
            self.bin_mmap, dtype=np.uint16
        )
    
    def __getitem__(self, idx):
        """获取第 idx 个 sequence"""
        start = self.idx['seq_starts'][idx]
        end = self.idx['seq_ends'][idx]
        return self.tokens[start:end]
    
    def __len__(self):
        return self.idx['num_sequences']

# 使用
dataset = MegatronDataset('train.bin', 'train.idx')
print(f'Total sequences: {len(dataset)}')
print(f'First sequence: {dataset[0]}')

mmap 优势:

  1. 零拷贝:数据不进入 Python 内存
  2. 多进程共享:多个 DataLoader 进程共享同一份 mmap
  3. 大文件友好:100GB+ 数据也能轻松加载

8.8 工业级 Tokenization 流水线

class TokenizationPipeline:
    """工业级 Tokenization + Packing 流水线"""
    
    def __init__(self, tokenizer_path):
        self.tokenizer = spm.SentencePieceProcessor()
        self.tokenizer.load(tokenizer_path)
    
    def process(self, documents, max_length=4096):
        """
        完整处理流程
        documents: list of {'text': str, 'metadata': dict}
        """
        # Step 1: Tokenize
        tokenized = []
        for doc in documents:
            tokens = self.tokenizer.encode(doc['text'])
            
            # 过滤过长文档(或切分)
            if len(tokens) > max_length:
                # 切分成多个 chunk
                for i in range(0, len(tokens), max_length):
                    tokenized.append({
                        'tokens': tokens[i:i+max_length],
                        'doc_id': doc.get('id'),
                        'chunk_id': i // max_length
                    })
            else:
                tokenized.append({
                    'tokens': tokens,
                    'doc_id': doc.get('id'),
                    'chunk_id': 0
                })
        
        # Step 2: Best-Fit Packing
        packed = best_fit_packing(
            [d['tokens'] for d in tokenized], 
            max_length=max_length
        )
        
        # Step 3: 构建 Document Mask
        for pack in packed:
            pack['attention_mask'] = build_document_mask(
                pack['doc_indices'], max_length
            )
        
        # Step 4: 保存 Megatron 格式
        self._save_megatron_format(packed, max_length)
        
        return packed

8.9 踩坑经验

坑1:词表过小导致 OOV

某团队用 16K 词表,结果中文常用字都覆盖不全,“矗” 这种字频繁 <unk>。词表至少 32K,多语种至少 64K。

坑2:未做归一化导致同义词被切开

“café” 和 “cafe” 被切成不同的 token,模型把它们当成两个词。必须做 NFKC 归一化。

坑3:Packing 没做 Document Mask

某团队用 Concat Packing 但没做 document mask,结果模型经常在结尾生成下一段的开头(跨文档泄露)。必须做 document mask。

坑4:数字未按位拆

某团队训出的 Tokenizer 把”12345”作为一个 token,结果模型不会做 2345 + 10000 这类运算。数字必须按位拆。

坑5:mmap 读取时文件被修改

某团队在训练中重新生成了 .bin 文件,导致 mmap 数据损坏、训练 loss 突然飙升。生成新数据必须用新文件名,不能覆盖正在使用的文件。

8.10 本课总结

  • 三大算法:BPE(GPT)、Unigram(T5)、WordPiece(BERT)
  • SentencePiece 是多语种 Tokenization 的事实标准
  • 词表大小权衡:32K(英文)→ 64K(双语)→ 128K(多语)→ 256K(超多语)
  • 中文坑:字符级而非字节级、Unigram 优于 BPE、按比例分配词表
  • Packing:Naive(不推荐)→ Concat → Best-Fit(推荐,利用率 98%)
  • Document Mask:防止跨文档 attention 串味
  • Megatron 格式:.bin + .idx + mmap,零拷贝高效读取

课后练习

  1. 训练一个 32K 的中英双语 Tokenizer,评估其压缩比
  2. 实现三种 Packing 策略,对比利用率
  3. 实现 Document Mask,验证其阻止跨文档 attention
  4. 用 mmap 加载 1GB token 数据,测量加载时间和内存占用

下一章我们将进入课程化与退火策略——预训练的最后阶段,决定模型最终能力的”压轴戏”。