模型配置到底在配置什么:从 Megatron 源码读懂 Config、Spec、Builder 三层

基于 Megatron 训练侧 GPTModelConfig/GPTModelBuilder 源码,把「模型配置」拆成三个正交问题:模型长什么样(Config)、每层用哪套实现(Spec)、切成几块放在哪张卡上(Builder)。含词表 padding 手算、layer spec 决策树伪代码和 MTP 支线,末尾给一张读任何训练框架配置都能用的三问速查表。

在 Megatron 的源码里,「构建一个 GPT 模型」的函数几乎从不返回完整的模型——它返回的是模型的一个切片。理解了这件事,训练框架里那几十个配置项就不再是一锅粥:它们各自在回答三个完全不同的问题。

这篇文章基于一份 Megatron 训练侧的真实源码(GPTModelConfigGPTModelBuilder,约 400 行),把「模型配置」这个看起来像字典堆砌的东西拆成一个可复用的心智模型。读者不需要读过 Megatron,但最好写过一点工程代码。如果你此前读过本站推理侧的《从 vLLM ChatCompletionRequest 看懂大模型请求参数》,这一篇就是它在训练侧的对照篇:那边是「一个请求如何被参数控制」,这边是「一个模型如何被配置造出来」。

名词速查

术语一句话解释
TransformerConfigMegatron-Core 里描述 transformer「数学形状」的配置类:层数、隐藏维度、头数等
ModuleSpec一张「用哪个类、带哪些子模块」的声明式清单,Megatron 用它把架构写成数据
Transformer Engine(TE)NVIDIA 的高性能 transformer 算子库,提供融合 kernel 和低精度(如 FP8)支持
TP(张量并行)把单个矩阵乘切开分给多张卡算,详见《CUDA 张量并行之后,到底有哪几种计算》
PP / VPP(流水线并行 / 虚拟流水线)按层切模型给不同卡(PP);每张卡再拿多个不连续的层块来减小流水线空泡(VPP)
MoE混合专家:FFN 换成多个「专家」,每个 token 只激活少数几个,详见《Dense Models 到底 Dense 在哪》
MTP(多 token 预测)训练时让模型在每个位置多预测几个未来 token 的辅助目标,DeepSeek-V3 使其流行
DDP / FSDPPyTorch 的两类数据并行包装:整份参数复制做梯度同步 vs 参数分片存放
混合精度包装(Float16Module)把模型包一层,参数与计算走 fp16/bf16,对外接口不变

一条主线:三个正交问题

先给结论(这是我的提炼,不是源码里的官方说法):「配置一个模型」其实是三个正交的问题,Megatron 把它们拆给了三个组件。

  1. 模型长什么样?——多少层、多宽、什么位置编码、词表多大。归 GPTModelConfig(内嵌 TransformerConfig)管。
  2. 每一层用哪套实现?——同一个数学结构,是用 TE 的融合 kernel、纯 PyTorch、还是量化版?归 layer spec(ModuleSpec)管。
  3. 切成几块、放在哪张卡上?——流水线切几段、要不要虚拟流水线、用 DDP 还是 FSDP 包。归 GPTModelBuilder 管。
flowchart TD
    Q["造一个能训练的 GPT"] --> A["问题一:长什么样<br>GPTModelConfig<br>+ TransformerConfig"]
    Q --> B["问题二:每层用哪套实现<br>ModuleSpec<br>(layer spec 决策树)"]
    Q --> C["问题三:切成几块放在哪<br>GPTModelBuilder<br>+ DDP/FSDP 包装"]
    A --> M["GPTModel 的一个切片<br>(某个流水线 stage)"]
    B --> M
    C --> M

为什么值得把这三问分开?因为它们的变化频率完全不同:模型形状定了就很少动;实现层随硬件和 kernel 库演进(今天 TE,明天量化);放置策略随集群规模天天变。把变化频率不同的东西塞进同一个配置对象,就是大多数训练脚本演化成「参数沼泽」的原因。下面按层拆。

第一层:Config——模型长什么样

两层嵌套 + 属性代理

GPTModelConfig 不是一个扁平字典,它内嵌了一个 TransformerConfig

  • TransformerConfig 管通用 transformer 的数学形状:层数、hidden size、MoE 专家数、并行度参数——任何 transformer 模型(GPT、BERT、多模态)都用得上;
  • GPTModelConfig 只加 GPT 特有的东西:词表大小、序列长度、位置编码类型、要不要共享输入输出 embedding。

但用户使用时感觉不到两层,因为它做了属性代理(真源码,有删节):

def __getattr__(self, name):
    # __getattr__ 只在常规属性查找失败后才被调用
    transformer = object.__getattribute__(self, "transformer")
    if hasattr(transformer, name):
        return getattr(transformer, name)
    raise AttributeError(...)

访问 config.num_layers 时自己身上没有,就透传给内层的 transformer。写属性同理:如果内层有这个字段,写操作会穿透到内层,保证同一个字段永远只有一份真值——不会出现外层 num_layers=32、内层 num_layers=48 的分裂。

这里有个值得学的 Python 细节:代理里用 object.__getattribute__ 而不是 self.transformer。因为 dataclass 初始化期间 transformer 字段还不存在,用 self.transformer 会再次触发 __getattr__,无限递归。源码注释明确写了这一点——这类「防递归」写法是所有属性代理的标配坑。

位置编码:一个字段藏着一部演化史

position_embedding_type: Literal["learned_absolute", "rope", "mrope", "yarn", "none"] = "learned_absolute"

这一行枚举值恰好是位置编码的时间线:learned_absolute 是 GPT-2 时代的可学习绝对位置;rope 是旋转位置编码(当前主流);yarn 是 RoPE 的长上下文外推改进;mrope 是多模态场景的多维 RoPE;none 留给不需要位置信息的场景。默认值还停在 learned_absolute,而工业界主流早已是 RoPE——默认值是框架的历史包袱,不是当前的最佳实践,读任何框架的配置都要带着这个警惕。

词表 padding:一个被并行策略污染的「形状」参数

直觉上词表大小是纯粹的模型形状:tokenizer 有多少词就是多少。但源码里它长这样:

vocab_size: int | None = None
make_vocab_size_divisible_by: int = 128
should_pad_vocab: bool = False

为什么要把词表补齐(padding)?两个原因:

  1. 张量并行要均分:embedding 矩阵按行切给 TP 组的每张卡,词表必须能被 TP 度数整除;
  2. GEMM 对齐:矩阵维度是 128 的倍数时,GPU 矩阵乘 kernel 的效率更好。

所以补齐的目标是 make_vocab_size_divisible_by × TP度数 的整数倍。手算三个例子(数字已用脚本验算):

原始词表对齐单位补齐后浪费
50257(GPT-2)128 × TP1 = 1285030447 个假 token
50257(GPT-2)128 × TP8 = 102451200943 个假 token(约 1.9%)
32000(Llama 2)128 × TP8 = 102432768768 个假 token

注意第三行:32000 看起来是个整数,但在 TP=8 下依然要补 768 个位置。这些「假 token」的 embedding 行会被训练但永远不会被 tokenizer 产出。这就是我说的「形状参数被并行策略污染」——词表的最终大小不由 tokenizer 单独决定,而由 tokenizer 和你买了几张卡共同决定

(补齐的具体算法我按 Megatron-LM 公开仓库的一贯实现推断为「向上取整到对齐单位的倍数」,本文这份源码里只看到调用 calculate_padded_vocab_size,没看到其函数体——此处标注为推断。)

finalize():配置的「体检」环节

配置对象在交给 builder 前要过一遍 finalize(),它做的是跨字段一致性检查——单看每个字段都合法、组合起来却矛盾的情况。源码里有两个例子:

  • 开了 CUDA Graph(把一段 GPU 操作录制下来重放以省启动开销)就必须用 TE 的随机数状态跟踪器,否则直接 assert 失败;
  • 开了虚拟流水线(VPP)时,每张卡的层数必须能被虚拟段数整除——除非你用了灵活流水线布局(首尾段层数可以不同、embedding/loss 单独占段)。

这类校验放在「配置就绪之后、构建开始之前」是有讲究的:报错发生在还没分配任何 GPU 显存的时刻,失败成本最低。等到 3000 张卡都把模型建了一半才发现层数除不尽,代价完全不同。

第二层:Spec——每一层用哪套实现

同一个数学结构,N 套实现

「transformer 层」在数学上是一个固定结构:注意力 + FFN + 归一化。但工程上它至少有这些实现:TE 融合 kernel 版(快,依赖 NVIDIA 库)、纯 PyTorch 本地版(慢,处处能跑)、量化感知版(层里埋好量化节点)、推理优化版、实验性注意力变体版、MoE 版、异构版(每层形状不同)。

Megatron 的解法是把「用哪个类、带哪些子模块」写成一个声明式的数据结构——ModuleSpec。配置描述数学形状,spec 绑定具体实现,两者分离。这正是《AI 顶级原理望远镜》里「表示决定成败」的工程版:把架构表示成数据之后,换实现变成了换一张清单,而不是改一片代码。

默认决策树

用户不指定 spec 时,default_layer_spec 按优先级替你决定。压缩成伪代码(对应源码 default_layer_spec() 主干,省略参数传递):

# 伪代码:layer spec 决策树
def default_layer_spec(config, vp_stage):
    if config.transformer_impl == "inference_optimized" and 不是MoE:
        return 推理优化版层spec
    if config.restore_modelopt_state:
        return ModelOpt量化版spec        # 量化感知训练/校准
    if config.experimental_attention_variant is not None:
        return 实验性注意力变体的整块spec
    if config.num_moe_experts is not None:
        return MoE整块spec               # 逐层可不同,dense/MoE 可混排
    if isinstance(config.transformer, HeterogeneousTransformerConfig):
        return 异构层spec                # 每层形状都可以不一样
    if config.transformer_impl == "transformer_engine":
        return TE高性能实现spec
    return 纯PyTorch本地实现spec         # 兜底

分支顺序即优先级:特化需求(推理优化、量化、实验变体)压过架构差异(MoE、异构),架构差异压过后端选择(TE vs 本地)。读懂这个顺序,就读懂了这个框架认为什么更「特殊」。

另外两个值得注意的设计:

  • spec 字段是三态的transformer_layer_spec 可以是 None(走上面的决策树)、一个现成的 ModuleSpec(直接用)、或一个函数(运行时调用,标准的依赖注入)。源码甚至会用 inspect.signature 嗅探你的函数接不接受 vp_stage 参数,接受就传——这是框架对新旧两代回调签名的兼容手法。
  • spec 可以做外科手术:因为 spec 是普通数据结构,构建前可以直接改字段。源码里注意力后端选了 local 时就是这么干的:
if attention_backend == AttnBackend.local:
    spec.submodules.self_attention.submodules.core_attention = MCoreDotProductAttention

一行赋值就把注意力内核换掉了,不需要任何继承或猴子补丁。这是「架构即数据」最直接的红利。

第三层:Builder——切成几块、放在哪

build_model 返回的是切片,不是模型

回到导语那个断言。build_model 的关键几行(真源码,有删节):

pre_process  = is_vp_first_stage(...) and is_pp_first_stage(pg_collection.pp)
post_process = is_vp_last_stage(...)  and is_pp_last_stage(pg_collection.pp)

pre_process 决定这个实例含不含词嵌入层post_process 决定含不含输出头和损失计算。在流水线并行下,只有第一段有嵌入、最后一段有输出头,中间段就是一叠光秃秃的 transformer 层:

flowchart LR
    subgraph G0["GPU 0(PP rank 0)"]
        E["词嵌入<br>pre_process=True"] --> L1["层 1-8"]
    end
    subgraph G1["GPU 1"]
        L2["层 9-16"]
    end
    subgraph G2["GPU 2"]
        L3["层 17-24"]
    end
    subgraph G3["GPU 3(PP rank 3)"]
        L4["层 25-32"] --> O["输出头 + 损失<br>post_process=True"]
    end
    L1 --> L2 --> L3 --> L4

所以「一个 GPTModel 实例」在分布式训练里从来不是完整模型,而是由本进程在流水线里的位置决定形状的一个切片。这个事实反过来解释了很多配置项的存在:vp_stagenum_layers_in_first_pipeline_stageaccount_for_embedding_in_pipeline_split——它们全是在描述「怎么切」。

vp_stage 值得多说一句:虚拟流水线(interleaved pipeline)让每张卡持有多个不连续的层块(比如 GPU 0 拿层 1-4 和层 17-20),用更细的交错调度减小流水线空泡。这来自 Megatron 的第二篇论文(arXiv:2104.04473),论文报告该调度能带来 10% 以上的吞吐提升(来源为论文摘要,未亲手复现)。张量并行则来自第一篇(arXiv:1909.08053)。两篇合起来就是「TP 切宽度、PP 切深度、DP 切数据」的三维并行格局,本站《计算层决策地图》里有它们在整个技术栈中的位置。

build_distributed_models:包装的洋葱

单个切片建好后,build_distributed_models 负责把它包成能训练的状态,顺序是固定的洋葱结构:

  1. 按 VPP 段数建出本 rank 的一组切片(所以返回值是 list[GPTModel]);
  2. 每个切片过 pre_wrap_hooks(外部插桩的缝,比如打日志、改权重初始化);
  3. 包混合精度层(默认 Float16Module:参数与计算走 fp16/bf16,对外接口不变);
  4. 包 DDP 或 FSDP(可选 Megatron 自研 FSDP 或 PyTorch FSDP2——参数分片方式不同,接口在这里被抹平);
  5. 最后过 post_wrap_hooks

Builder 和 Config 分离的价值在这里最明显:同一份 config,单卡推理时调 build_model 建一个完整模型(pre/post 都为 True),集群训练时调 build_distributed_models 建一组包好的切片。「模型是什么样」和「模型怎么部署」彻底解耦。

支线:MTP——一个新训练目标如何长进配置系统

源码里有一段专门处理 MTP(multi-token prediction):配置里给了 mtp_num_layers,builder 就会在模型尾部追加 MTP 块——训练时每个位置除了预测下一个 token,还要预测再往后几个。这个目标由 Meta 提出(arXiv:2404.19737),DeepSeek-V3 采用后广为人知(arXiv:2412.19437),其 MTP 头在推理时还能当草稿模型做自投机解码——这条线在《Medusa 深读》里展开过。

有意思的是源码处理的一个边缘 case:MTP 块要复用「最后一个 decoder 层的 spec」,但在 MoE + 流水线切分下,最后一个 stage 可能一层 decoder 都没有(比如它只放输出头),这时 spec 列表是空的,代码只好显式重新推导一份层 spec。这个补丁值得玩味:三个正交问题在理想设计里互不干扰,但现实中「放置」问题(切分)会渗透进「实现」问题(spec)的角落——正交是设计目标,不是既成事实。

带得走的东西:三问速查表

下次读任何训练/推理框架的模型配置,先把每个配置项归到三问之一(下表的映射是我的观点,供校准):

框架形状问题实现问题放置问题
MegatronTransformerConfig + GPTModelConfigModuleSpec / layer spec 决策树GPTModelBuilder、PP/VPP、DDP/FSDP 包装
HuggingFace Transformersconfig.jsonattn_implementation(如 flash-attention)device_map / accelerate
vLLM(推理侧)ModelConfigattention backend 选择tensor_parallel_size 等引擎参数

三条判断规则:

  1. 一个配置项如果不改变模型的数学输出,它就不是形状问题——量化、kernel 选择、并行度都只是实现和放置;
  2. 形状问题的字段应该能原封不动地跟着 checkpoint 走;实现和放置字段换个集群就该换;
  3. 看到一个「本该纯粹」的形状参数长出了古怪的修饰(如词表 padding),去找是哪台并行机器把手伸了进来。

诚实的提醒

  • 本文的源码片段来自一份 Megatron 训练侧代码(megatron.training.models.gpt 模块,NVIDIA 2026 版权头)。我没有运行过这份代码,也没有逐行核对它与 GitHub 公开仓库某个 commit 的对应关系;Megatron 的模块组织在版本间变化很快,读者手里的版本可能不同。
  • calculate_padded_vocab_size 的行为是按 Megatron-LM 公开实现推断的(向上取整到 make_vocab_size_divisible_by × TP 的倍数),表中三个补齐数字已用一次性脚本验算,但未在真实 Megatron 环境里跑过。
  • 「interleaved schedule 提升 10%+ 吞吐」来自 arXiv:2104.04473 摘要,未亲手复现。
  • 最低成本验证实验:不需要 GPU,10 行 Python 就能验证词表 padding 逻辑——math.ceil(50257 / (128*8)) * (128*8),看是否得到 51200;再 clone Megatron-LM 仓库 grep make_vocab_size_divisible_by,对照它的真实取整实现和本文推断是否一致。

参考来源

工程实践

arXiv 论文