在 Megatron 的源码里,「构建一个 GPT 模型」的函数几乎从不返回完整的模型——它返回的是模型的一个切片。理解了这件事,训练框架里那几十个配置项就不再是一锅粥:它们各自在回答三个完全不同的问题。
这篇文章基于一份 Megatron 训练侧的真实源码(GPTModelConfig 与 GPTModelBuilder,约 400 行),把「模型配置」这个看起来像字典堆砌的东西拆成一个可复用的心智模型。读者不需要读过 Megatron,但最好写过一点工程代码。如果你此前读过本站推理侧的《从 vLLM ChatCompletionRequest 看懂大模型请求参数》,这一篇就是它在训练侧的对照篇:那边是「一个请求如何被参数控制」,这边是「一个模型如何被配置造出来」。
名词速查
| 术语 | 一句话解释 |
|---|---|
| TransformerConfig | Megatron-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 / FSDP | PyTorch 的两类数据并行包装:整份参数复制做梯度同步 vs 参数分片存放 |
| 混合精度包装(Float16Module) | 把模型包一层,参数与计算走 fp16/bf16,对外接口不变 |
一条主线:三个正交问题
先给结论(这是我的提炼,不是源码里的官方说法):「配置一个模型」其实是三个正交的问题,Megatron 把它们拆给了三个组件。
- 模型长什么样?——多少层、多宽、什么位置编码、词表多大。归
GPTModelConfig(内嵌TransformerConfig)管。 - 每一层用哪套实现?——同一个数学结构,是用 TE 的融合 kernel、纯 PyTorch、还是量化版?归 layer spec(
ModuleSpec)管。 - 切成几块、放在哪张卡上?——流水线切几段、要不要虚拟流水线、用 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)?两个原因:
- 张量并行要均分:embedding 矩阵按行切给 TP 组的每张卡,词表必须能被 TP 度数整除;
- GEMM 对齐:矩阵维度是 128 的倍数时,GPU 矩阵乘 kernel 的效率更好。
所以补齐的目标是 make_vocab_size_divisible_by × TP度数 的整数倍。手算三个例子(数字已用脚本验算):
| 原始词表 | 对齐单位 | 补齐后 | 浪费 |
|---|---|---|---|
| 50257(GPT-2) | 128 × TP1 = 128 | 50304 | 47 个假 token |
| 50257(GPT-2) | 128 × TP8 = 1024 | 51200 | 943 个假 token(约 1.9%) |
| 32000(Llama 2) | 128 × TP8 = 1024 | 32768 | 768 个假 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_stage、num_layers_in_first_pipeline_stage、account_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 负责把它包成能训练的状态,顺序是固定的洋葱结构:
- 按 VPP 段数建出本 rank 的一组切片(所以返回值是
list[GPTModel]); - 每个切片过
pre_wrap_hooks(外部插桩的缝,比如打日志、改权重初始化); - 包混合精度层(默认
Float16Module:参数与计算走 fp16/bf16,对外接口不变); - 包 DDP 或 FSDP(可选 Megatron 自研 FSDP 或 PyTorch FSDP2——参数分片方式不同,接口在这里被抹平);
- 最后过
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)的角落——正交是设计目标,不是既成事实。
带得走的东西:三问速查表
下次读任何训练/推理框架的模型配置,先把每个配置项归到三问之一(下表的映射是我的观点,供校准):
| 框架 | 形状问题 | 实现问题 | 放置问题 |
|---|---|---|---|
| Megatron | TransformerConfig + GPTModelConfig | ModuleSpec / layer spec 决策树 | GPTModelBuilder、PP/VPP、DDP/FSDP 包装 |
| HuggingFace Transformers | config.json | attn_implementation(如 flash-attention) | device_map / accelerate |
| vLLM(推理侧) | ModelConfig | attention backend 选择 | tensor_parallel_size 等引擎参数 |
三条判断规则:
- 一个配置项如果不改变模型的数学输出,它就不是形状问题——量化、kernel 选择、并行度都只是实现和放置;
- 形状问题的字段应该能原封不动地跟着 checkpoint 走;实现和放置字段换个集群就该换;
- 看到一个「本该纯粹」的形状参数长出了古怪的修饰(如词表 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 仓库 grepmake_vocab_size_divisible_by,对照它的真实取整实现和本文推断是否一致。
参考来源
工程实践
arXiv 论文
- arXiv:1909.08053 — Megatron-LM: Training Multi-Billion Parameter Language Models Using Model Parallelism(张量并行)
- arXiv:2104.04473 — Efficient Large-Scale Language Model Training on GPU Clusters Using Megatron-LM(PTD-P 三维并行与 interleaved pipeline)
- arXiv:2404.19737 — Better & Faster Large Language Models via Multi-token Prediction(MTP 训练目标)
- arXiv:2412.19437 — DeepSeek-V3 Technical Report(MTP 的大规模工业采用)