第 9 章 篇一:预训练数据工程

Tokenization 与序列 Packing

分词器选择、词汇表构建、序列打包(packing)与样本边界处理。

Tokenization Packing 分词器

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

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


9.1 三大 Tokenization 算法

9.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', ...]

9.1.2 Unigram

Unigram 是 SentencePiece 的可选算法。

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

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

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

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

9.1.3 WordPiece

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

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

9.1.4 算法对比

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

效应量限定:算法之间的差异可能远小于直觉。 上表给的是方向性判断,落地时应先看效应量。LREC 2026 主会(Osváth, Molnár, Gunics, Ligeti-Nagy《The Impact of Tokenization Algorithms on Hungarian Language Model Performance》, pp. 2545–2556, DOI 10.63317/29hx92kq2dxe)在 OSCAR 匈牙利语子集上跨多个词表规模比较 BPE / WordPiece / Unigram(含一个形态感知初始化的改良 Unigram 变体),结论有三条:BPE 产出最紧凑且形态对齐最好的子词表示;改良 Unigram 的下游表现最好;但各方法之间、各词表规模之间的差异总体很小,除 HuCoPA 一个任务外都不具统计显著性。

⚠️ 匈牙利语不等于中文,跨语言外推需标注。 但对本章有一条直接可用的落地含义:§9.4.1 的”中文推荐 unigram”应理解为”中文场景下 unigram 是合理默认”,而不是”unigram 显著优于 BPE”;调词表规模与语料清洗的优先级,应当排在算法选型之前。

9.1.5 BPE 的改进方向

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

  • SuperBPE(COLM 2025):重新设计 BPE 的合并方式,在 30 个 benchmark 上平均提升 4.0%(30 个任务中 25 个胜出)、MMLU 提升 8.2%、减少 33% token 数量、推理计算节省 27%。官方已于 2025-11-03 发布 128k 词表的更新版英文 SuperBPE tokenizer,并推荐用于后续实验,可直接取用

⚠️ 跨语种限定(中文项目引入前必读): 上面四项数字是作者本人在以英语为中心的 benchmark、8B 规模上报告的,本章至今没有拿到中文上的同设计对照。SuperBPE / BoundlessBPE 的核心机制是放松”token 不得跨空白”这一约束——而这个”空白”假设在中文这类不靠空格分词的语言上是否成立,与 §9.3.3 坑1 讨论的中文切分问题是同一条线索。中文项目引入前,应在自己的中文语料上先做 bits-per-byte 或 tokens/千字 的对照,而不是直接套用 4.0%。

  • LiteToken(2026-02):去除 BPE 的”中间合并残留”(intermediate merge residues),不需要重训模型即可应用到已有 tokenizer——对工业团队有直接实用价值

  • BoundlessBPE:与 SuperBPE 同属”放松 token 不得跨空白”约束的谱系,在通用文本上最多减少 33% token(对比 PickyBPE 仅 ~1.1% 收益)

    落地提示(这是该方案能否排进 pipeline 的分界线):BoundlessBPE 的早期实现训练极慢——1GB 数据需要 4.7 个 CPU 天,工业上基本不可用。arXiv:2604.05192《Faster Superword Tokenization》(Schmidt、Tanner、Pinter,2026-04-06,v2 2026-08-12)指出 supermerge 候选可以像普通 pretoken 一样按频率聚合,从而不必把整篇文档驻留在内存(原实现的最大瓶颈),把同一 1GB 数据的训练时间压到 603 秒(BoundlessBPE)/ 593 秒(SuperBPE),加速 600 倍以上,并为 BPE / SuperBPE / BoundlessBPE 三者各开源参考 Python 实现与快速 Rust 实现。

    同一工作还厘清了两件事:① 给出 BoundlessBPE 的两阶段形式化(第一阶段学常规 merge、第二阶段学 supermerge),结果与原实现完全一致;② 证明两阶段 BoundlessBPE 与 SuperBPE 近似等价,唯一差别是 SuperBPE 需手工设定的那个超参,可在 BoundlessBPE 第二阶段自动确定。

    → 因此本章把二者并列呈现时读者必然产生的”该选哪个”疑问,答案是:选型主要看实现可用性,而非效果差异。

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

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

补充一个时点判断:SuperBPE / BoundlessBPE 此前的主要障碍不是效果而是训练成本(1GB 数据 4.7 个 CPU 天)。自 2026-04 起该成本已降至约 10 分钟,且有了开源 Rust 实现,因此”新项目上 SuperBPE / BoundlessBPE”这条建议现在具备工程可行性,不再只是论文结论。上一节若只写”能省 33% token”而不写这一点,读者会得到一个正确但无法落地的结论。

再补一根轴:这个决策能不能事后改。 上面的”时点判断”讲的是训练 tokenizer 本身贵不贵;工程上更常被问的是另一件事——我已经有一个训好的基座,还能不能改。按这一问法,本章涉及的三条路线分属三档:

路线是否需要重训基座可回退性
LiteToken(清理中间合并残留)不需要重训模型,直接应用到已有 tokenizer高
byteification(Nature,2026-10-07)不需要重训基座,追加一段对齐训练高
SuperBPE / BoundlessBPE词表变了 → embedding 与 LM head 需重训低(决策要在项目早期做)

因此本章”成本敏感用 LiteToken,新项目用 SuperBPE”的建议仍然成立,但读者还应知道:“已有基座、想改善 token 效率”这个场景现在有 LiteToken 与 byteification 两条不重训的路,不是只能在”忍着”和”重训”之间二选一。

9.2 SentencePiece 详解

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

9.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!'

9.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,
}

9.3 词表设计权衡

9.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。词表膨胀并非简单的”越大越好”,而是与模型规模协同设计的必然结果。

9.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%,已经不划算。

9.3.3 中文词表的坑

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

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

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

坑2:字节级切分导致 OOV

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

解决(分两句,禁的对象不同):

  1. 如果是从零预训练、直接采用字节级 BPE 处理中文:确实极差,用字符级 BPE。上面这句”不要用字节级”只在这个语境下成立。
  2. 但”字节级”作为一条技术路线并没有被判死刑,因为它现在可以事后补上。

2026-10-07,Ai2 的 byteification 工作发表于 Nature(《Retrofitting language models to operate over bytes》,Minixhofer et al.;Ai2 官方博客同日发布)。它的做法是把已经训好的 subword 模型改造成 byte-level 模型:阶段 1 冻结原全局模型、只训练新增的 byte-level 组件做对齐;阶段 2 再做全模型训练。产物是 Bolmo 1B / Bolmo 7B(源模型 Olmo)、Bwen 8B(源 Qwen3 8B)、Blama 8B(源 Llama 3 8B)——即该流程不限于自家基座,可迁移到其他模型族;官方称 Bwen 8B 是其最强的 byteified 模型,在聚合评测上超过 Bolmo 7B。原始 Bolmo 论文(arXiv:2512.15586,2025-12-17)的摘要结论是:转换后的模型超过此前所有 byte-level 方案、在字符级推理任务上表现突出、同时在标准 benchmark 上保持竞争力。

对中文读者,这条路线有两点直接意义:① 它针对的正是 subword tokenization “obscures fine-grained information” 这一缺陷,收益场景明确包含需要细粒度字符理解的代码与生物序列;② “English-centric 词表偏置”是在 subword 范式下难以绕开的问题,而 byte-level 天然不受词表约束。

⚠️ 待核实(本轮未获一手来源,不写入正文数字):

可被本章安全引用的部分(已一手核实)是:Nature 正刊发表 + 两阶段转换配方 + 四个产物模型名 + 可迁移到其他模型族。成本与增益的量级请回 Nature 正文核实后再引用。

坑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%(预留)
}

共享词表不是唯一解:模块化分词器。 上面的分配表在”必须用一张共享词表”时仍然是正确解法,但配额只能缓解、不能消除共享带来的两个结构问题。arXiv:2609.15528《To Each Language Its Tokenizer: Modular Tokenizers for Efficient Multilingual LLMs》(2026-09-14)给出的替代方案分两层:① 学习大型 modular BPE / Unigram 词表,可从中抽取面向任意语言子集的 subtokenizer,压缩率与单语 tokenizer 相当,并改善跨语言公平性;② 预训练时按 batch 采样 subtokenizer、把预测限制在相关词表子集上,从而在大词表下仍高效训练;推理时可用任意语言词表组合,降内存、提速且不牺牲效果。

与本章的关系有两点值得单独记:跨语言公平性是本章 §9.3.3 从未讨论过的一根轴(本章只讨论”覆盖够不够”,不讨论”各语言压缩率是否公平”);“模型实际只用得到语言子集”造成的浪费,是 §9.3.2 从”参数占比”角度讨论词表大小时没有覆盖的视角。

选型判据:“按语种分配配额”是共享词表约束下的工程兜底(零改造成本),“模块化词表”是结构解法(需要训练与推理管线配套)。前者今天就能用,后者要改管线。

坑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,            # 子迭代次数
)

9.4 中文 Tokenizer 实战

9.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))

9.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

除”稀有 token 占比”,还应报”词表利用率”。 上面这段代码给的是绝对频次视角:有多少 token 出现次数低于阈值。它回答不了另一个问题——这张词表有多大比例被真正用起来了。建议在代表性语料上同时统计”实际出现过的词表条目数 / 词表总条目数”,即词表利用率(vocabulary utilization)。

之所以要单独报这个数:arXiv:2610.01127(2026-10-01)在把词表扩到 1M 时测得 CNF-MCE 的利用率为 96.9%,BPE 为 52.9%,且 BPE 的利用率在 512K → 1M 区间已经走平——利用率曲线比单点压缩率更能暴露”词表建得对不对”。只数稀有 token,会在”词表建错了但每个建错的 token 都被用到”的情况下给出干净的报告。

⚠️ 读压缩率提升时的防呆:先问增益来自算法还是词表。 CNF-MCE 报告在 250K 词表下相对 o200k_base 与 qwen250k 提升压缩率 26% / 30%(正文 §4.2 为 26.7% / 32.4%)。但第三方评审(Pith)指出:在固定 qwen250k 词表下,MCE 相对 BPE 在英文 web 文本上只从 222.5 → 221.6 tokens/kchar(约 0.4%);换成 MCE 自建的 mix250k 词表才降到 168.0(24.5%)——增益几乎全部来自”跨空白的多词词表”这一性质,而它正是 SuperBPE / BoundlessBPE 已经提供的;该论文未做 matched-size 的 SuperBPE / BoundlessBPE 对比。评审同时指出摘要与正文数值不一致(26%/30% vs 26.7%/32.4%),全文无种子与误差棒。()

9.5 Packing 三种策略

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

9.5.1 三种策略对比

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

9.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

9.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

9.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

9.5.5 利用率对比

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

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

9.6 Document Mask:防跨文档串味

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

9.6.1 跨文档串味问题

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

9.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

9.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'

多模态特化:视觉 token 与多模态 packing

本章的 tokenization 有一个隐含前提:输入先被切成离散符号,再查表映射成 id。图像没有这一步——图像不分词,它先被切块编码成视觉 token:ViT 按 patch(常见 14x14)把一张图切成若干块,每块经视觉编码器产出 token,再经投影层对齐到 LLM 的隐空间。于是”词表大小""OOV""数字按位拆”基本失效,取而代之的是三个新问题。

1. 序列长度剧烈波动,token 预算成为配比的一等公民。固定分辨率时代一张图就是几百个 token,动态分辨率之后完全不同:LLaVA-NeXT 的 anyres 把图切成多个子图分别编码,InternVL 按 448x448 的 tile 拼接,Qwen2.5-VL 的原生动态分辨率让单图 token 数可在 4 到 16384 之间浮动。于是 packing 不再是单纯的”填充率优化”,而是配比问题:视觉 token 占掉多少预算,直接决定文本侧还剩多少容量——这与第 10 章的数据配比是同一个决策的两面。

2. 视觉 token 压缩是有代价的。三条路线:pixel shuffle(InternVL 系,重排 + 通道折叠,压缩比温和且保留细粒度)、Perceiver Resampler(Flamingo 系,压到固定长度的 latent)、Q-Former(BLIP-2 系,同样压到固定长度,另带预训练目标)。后两者长度可控、显存友好,代价是丢失细粒度 OCR 能力——把整图压成几十个 latent,小字、表格线、公式下标基本就没了。选型判据很直接:下游要不要读文档和图表?要,就别用重度压缩。

3. Packing 的约束比 9.6 更严。9.6 的 document mask 解决”跨文档串味”,多模态下还要再加两条硬约束:一张图不能被拆到两个训练样本里(视觉 token 序列必须完整落在同一条序列内),以及同一样本内的多图之间、图文之间需要 attention 隔离。前者意味着 packer 必须按”图块”而非按 token 装箱(本质是带不可分割物品的 bin packing),后者意味着 attention mask 要比 9.6.2 多一层样本/模态维度的分块。

结论:多模态训练的吞吐与显存由视觉侧主导——视觉 token 数通常远超同一样本的文本 token,优化视觉 token 数的收益比优化文本 packing 率高一个量级。

(详见第 26 章多模态数据工程)

9.7 Megatron 格式:.bin + .idx

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

9.7.1 文件结构

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

9.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))

9.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+ 数据也能轻松加载

9.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

9.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 突然飙升。生成新数据必须用新文件名,不能覆盖正在使用的文件。

9.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 数据,测量加载时间和内存占用

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

🛠️ 本章衍生工具

📦

数据工程·Packing 装箱可视化

《AI 数据全书》第 9 章「Tokenization 与序列 Packing」配套——① 分词器竞技场:BPE / Unigram / WordPiece 三种算法在浏览器内真训真跑(BPE 数 pair 合并、Unigram 跑 EM + Viterbi、WordPiece 按互信息式评分),8 条样本(中英混合/代码/数字/emoji/古文/多语种菜单/超长复合词/纯标点)并排切分 + §9.3.2 词表参数账(32K 占 1.9% 一路到 262K 占 15.3%,超过 15% 弹出「已经不划算」原文告警)+ log-linear 最优词表曲线 + §9.3.3 中文四坑一键复现(字符级 ▁/你/好 3 token vs 学会词组的 1 token · 字节级 BPE 6 token · 词表分配失衡 15K 门槛 · 数字按位拆)· ② Packing 装箱沙盘:Naive / Concat / FFD / BFD 四策略并排实算,5 种文档长度分布 + 文档数/均值/方差 σ/序列长度滑杆,利用率与 padding 浪费全部浏览器现算、不硬编码,带 rAF 逐步装箱动画 + §9.5.5 结论复现面板(书上的 25/92/98% 只作对照列,实算倍率随分布从 1.3 倍跳到 8 倍,讲清「4 倍是有条件的」)+ 书上三处源码 bug 的找 bug 卡 · ③ 跨文档串味实验室:doc_A 1000 + doc_B 2000 + doc_C 1096 的块级 attention 可见性矩阵 + 鼠标探针看「这个 token 能看见谁」+ §9.9 坑 3 续写示意 + Megatron .bin/.idx 七字段结构图 · ④ 多模态 Packing 升级包:视觉 token 预算滑杆(4~16384)+ 三条压缩路线的 OCR 保真度权衡 + 不可分割物品的 bin packing(图被拆到两条序列时整条变红,这是纯文字 packing 不存在的新失效模式)+ 三维 attention mask

Tk

Tokenizer 可视化 2.0

4 标签页——BPE 学习 + 13 种 Tokenizer (2024-2026) + 多模态视觉分词实验室(DiVT/UniAR) + PK 对决排行榜