C/C++ 现代语言与底层 · 第 四 篇

编译期账本:模板实例化与 -ftime-trace

2026-10-1316,131 字36 张配图

本文由@溯光 出品 站点:https://yyy3d.online/blog/ | 本系列:https://yyy3d.online/blog/#topic-cpp-language-foundations 编译期账本总览——实例化机制/前端时间/二进制体积/跨编译器差异/构建层优化五层脉络 图 1 编译期账本总览——实例化机制/前端时间/二进制体积/跨编译器差异/构建层优化五层脉络 @ 本系列绘制


本系列已发表篇目(全系列共 12 篇,分四波连载):

本篇是第二波「编译器与链接器」的开篇。 第一波讲的是"编译器给你的对象和值做了什么",第二波换一个方向——讲"编译器自己花了多少时间"。

目录总览:本篇共十一章。零章划边界,一至四章拆实例化机制与代价,五章讲 MSVC 侧观测工具链,六章给一个跨编译器的实测差异,七至九章讲三类优化手段(约束方式/模块/构建系统),十章收束。


背景与边界

0.1 从"值语义的账"到"编译期的账"

ep01 算的是对象布局的账——sizeof 里每一字节从哪来。ep02 算的是内存分配的账——malloc 背后是谁在管。ep03 算的是值语义的账——move、拷贝、SSO、emplace_back 各要多少钱。

这三篇有一个共同点:账都记在运行时。程序跑起来,你才能用探针数出构造了几次、分配了几次、拷贝了几字节。

从 ep04 开始换账本。这一篇算的是编译期的账——你的代码从 .cpp 变成 .obj,编译器自己花了多少时间、花在了哪一步、哪些写法让这笔账变大。

这笔账之所以值得单独算,是因为它有个要命的性质:它不显示在任何运行时指标里,却实实在在拖慢你的每一次构建。 一个"运行时零开销"的模板抽象,可能在编译期让你多等 30 秒——而且是每天、每次改代码都多等。

0.2 本篇的边界(四条不写)

  • 不逐模块讲任何具体库:Godot 引擎架构归 T02《Godot 4 引擎架构》,Jolt 物理归 T06《Jolt Physics 3D》。本系列只讲语言与工具链那一层——同一段 C++,那两卷讲"它实现的机制",本系列讲"编译器与链接器对它做了什么"。
  • 不做 C++ 入门教学:不写模板语法教程、不写 SFINAE 教程。中文侧同题竞争激烈(cppreference 中文、CSDN/知乎/掘金),没有差异度。
  • 不做"面试题/最佳实践清单":无一手证据的泛泛总结违反站规。
  • 不碰图形算法与 GPU 硬件:本系列的工具是编译器与链接器,不是抓帧器。

0.3 工具链声明(先说清楚什么能用、什么不能用)

这一篇的题眼里带着 -ftime-trace,但我必须先声明一件事:本机没有 Clang,-ftime-trace 用不了。

-ftime-trace 是 Clang 独有的编译时间追踪特性,它生成 JSON 格式的时间线,可以在 chrome://tracing 或 Perfetto 里看(见 Clang 官方文档)。本机装的是 Visual Studio 2022 Community 17.14.35,自带的 LLVM 只有 clang-format 和 clang-tidy,没有 clang 编译器本身。

g++ 也不行。 实测:

$ g++ --version
g++.exe (Rev3, Built by MSYS2 project) 16.2.0
$ g++ -ftime-trace -o test.exe test.cpp
g++.exe: error: unrecognized command-line option '-ftime-trace'

GCC 至今没有实现 -ftime-trace。所以本篇的编译时间证据全部来自 MSVC 侧的替代三件套:

工具 版本 用途 本机状态
cl /Bt+ MSVC 19.44.35228 编译各阶段(前端 c1xx.dll/后端 c2.dll/链接器)时间 ✅ 实测可用
vcperf 14.44.35207 采集 C++ 构建的 ETW 追踪,用 WPA 查看 ⚠️ 存在,但采集需管理员权限(本机受拒)
MSBuild /bl MSBuild 17.14.40 生成结构化二进制日志(binlog) ✅ 实测可用
/d2cgsummary MSVC 19.44 后端函数级代码生成明细 ✅ 实测可用
dumpbin 14.44.35228.0 段尺寸、符号表(数实例化) ✅ 实测可用
g++ 16.2.0 (MSYS2 ucrt64) 第二条工具链,用于跨编译器对照 ✅ 可用(但无 -ftime-trace)

本篇所有时间读数均为本机实测,标注 cl /Bt+、/O2、5 轮取中位数。不同机器、不同版本会有差异,你的环境请自己量——本文给的是方法与口径,不是绝对基准。

这一条边界很重要:如果你在找"-ftime-trace 怎么用",本篇给不了本机示范,只能引官方文档(Clang 文档)。但如果你在 MSVC 上想知道"编译时间花在哪",本篇的 cl /Bt+ 路线是完整可跑的。

0.4 本篇的四个反直觉发现(先给结论)

这一篇的写法跟前面几篇一样:每个结论都要有本机实测撑着。 下面是四个"跟流行说法不太一样"的发现,先摆在这里,后面逐条给证据:

  1. 模板实例化是"惰性"的——编译器不为你写的模板付费,只为你用到的成员付费。 同一份 100 成员模板,只用 2 个成员生成的 .obj 是 2,913 字节(3 个符号),用全部 100 个是 65,361 字节(101 个符号)——差 22 倍。
  2. extern template 不是万能药——实测在 MSVC 19.44 上没生效,在 GCC 16.2 上生效。 这个结论直接推翻了我原本打算写的"省 10 倍"教程。
  3. "新机制更快"是错觉——实测 if constexpr 比 concepts 更快。 200 类型下 if constexpr 比 SFINAE 快约 24%,concepts 快约 16%——if constexpr 仍领先 concepts 约 9%。
  4. Unity Build 是双刃剑——全量构建快 9.7 倍,但增量构建反而变慢。 改一个文件就要重编整个超级 TU。

这四条的共同点是:它们都不是靠"读文档"能得到的,必须动手量。 这正是本系列的方法论——机制靠原文,代价靠自跑。如果你只想要结论,上面四条就够了;如果你想知道这些数字是怎么量出来的、以及怎么在自己工程里复现,往下看。


一、一句话点题:模板实例化是编译时间的隐形成本

先把这一篇的核心结论放在最前面:

模板实例化(template instantiation)是 C++ 编译时间的主要驱动因素之一,而它的成本结构是"惰性"的——不看你写了多少模板代码,只看你实际触发了多少实例化。

这句话里有两个反直觉点。

第一,成本不看"代码量",看"实例化量"。你可以在头文件里写一个 500 行的模板,只要没人用它的成员函数,编译器几乎不花时间——因为隐式实例化是按需、按成员触发的。反过来,一个 20 行的模板,如果被 200 种类型各用一遍、每种用了全部成员,它能吃掉几秒钟。

第二,"模板实例化很贵"这个说法,只在特定条件下成立。本机实测(cl /Bt+,/O2):

程序 前端时间 c1xx.dll 后端时间 c2.dll 总墙钟
空程序 int main(){} 0.0142 s 0.0078 s 0.0814 s
带头文件与容器模板的程序 0.7755 s 0.3864 s 1.2245 s

同样一个 main,只因为 #include 了 <vector>/<string>/<map>/<algorithm> 并实际用了几次,前端时间涨了约 55 倍(0.0142 → 0.7755)。而后端时间也涨了约 50 倍。

空程序与模板程序的 cl /Bt+ 输出对比
题图2 空程序 int main(){} 的 cl /Bt+ 输出——前端 c1xx 仅 14.2 ms@ MSVC 19.44 实测
模板程序的 cl /Bt+ 输出
图 4-13 含容器模板程序的 cl /Bt+ 输出——前端 c1xx 涨到 776 ms@ MSVC 19.44 实测

这就是编译期账本的第一笔:涨的是"前端",不是"后端"。 前端(c1xx.dll)负责预处理、词法/语法/语义分析、模板实例化;后端(c2.dll)负责优化与代码生成。模板实例化发生在前端——所以当你的编译时间失控时,先看前端时间;如果 c1xx 比 c2 大一个数量级,八成是模板撑的。

1.1 为什么这笔账值得单独算

编译期成本最阴的地方在于它的复利效应:

  • 本地开发循环:改一行代码 → 增量编译 → 跑测试。如果增量编译从 2 秒涨到 20 秒,一次调试要迭代 20 次,就是 6 分钟的额外等待。一天下来是小时级。
  • CI 流水线:全量构建从 8 分钟涨到 40 分钟,等于每天少跑好几轮合并验证。团队越大,这笔账越贵。
  • 它不会自己变好:模板相关的编译时间增长,通常是"每加一个类型/每多一个参数组合就多一份实例"的累积过程。没人会在加第 200 个类型时立刻察觉——直到某天构建崩了。

而且这笔账有个反直觉的特点:它跟"代码质量"没有正相关。 写得最抽象、最"现代"、模板用得最花的那部分代码,往往正是编译时间的大头。本篇要做的就是把这些"看不见的开销"变成可测、可复现、可定位的数字。


机制类引用:隐式实例化的触发条件写在标准 [temp.inst](https://timsong-cpp.github.io/cppwp/temp.inst)里,部分特化的选择规则写在 [temp.spec.partial](https://eel.is/c++draft/temp.spec.partial)。本篇不逐条抄标准,只在需要时给稳定标签的链接。


二、模板实例化机制:隐式、显式与"按成员惰性"

先来看看这套机制到底是怎么运转的——要把账算清楚,先得知道实例化在哪触发、按什么粒度触发、什么时候不触发。

2.1 隐式实例化(implicit instantiation):用到才生成

C++ 标准在 [temp.inst](https://timsong-cpp.github.io/cppwp/temp.inst)里规定了隐式实例化的触发条件:当某个模板特化的完整定义被需要时。比如你声明了一个 std::vector<int> 变量,编译器需要它的完整定义才知道对象多大;你调用了它的 push_back,编译器需要生成那个函数的实例才能生成调用代码。

关键细节,也是很多人不知道的一条:隐式实例化是"按成员"(per-member)的,不是"整个类一次性生成"。你用了 vector<int> 的构造函数,但没调用 push_back,那么 push_back 不会被实例化。类模板的成员函数定义只在被用到(odr-used)时才实例化。

这条"惰性"性质是本篇后续很多结论的根。用实测量化它最直观——我写了一个 Big<T> 模板,包含 100 个成员函数 m000()…m099(),然后对比两种用法:

用法 单个 .obj 体积 Big<int> 相关符号数
只用 2 个成员(m000、m099) 2,913 字节 3
用全部 100 个成员 65,361 字节 101
惰性实例化的符号证据——只用 2 个成员时的符号表
图 4-24 只用 2 个成员时 lazy2.obj 的符号表——只有 3 个 Big<int> 符号@ MSVC 19.44 实测
全量实例化的符号证据——用全部 100 个成员时的符号表
图 4-35 用全部 100 个成员时 all100.obj 的符号表——101 个 Big<int> 符号@ MSVC 19.44 实测

同一份模板定义、同一份头文件,只因为调用面不同,生成的代码量差了 22 倍(2,913 → 65,361 字节)。这是"惰性实例化"最直接的证据:编译器不为你写下的模板付费,只为你用到的成员付费。

模板实例化机制图——隐式 / 显式 / 部分特化的触发与粒度
图 4-46 模板实例化机制——隐式实例化按成员惰性触发、显式实例化强制全量、部分特化只改匹配不改时机@ 本系列绘制

2.2 显式实例化(explicit instantiation):强制全量生成

如果你想主动控制实例化时机,用 template 关键字做显式实例化:

// big.h —— 模板定义
template <typename T>
struct Big { /* 100 个成员函数 */ };

// use.cpp —— 显式实例化定义:把这个类的所有成员都生成出来
template struct Big<int>;

这一行 template struct Big<int>; 的含义是:"在这个翻译单元(translation unit, TU)里,把 Big<int> 的所有成员都实例化出来。"注意与隐式实例化的对比——显式实例化是全量的,不看调用面。

实测同一个 Big<int>:

实例化方式 .obj 里的 Big<int> 符号
隐式(只用 2 个成员) 3
显式实例化定义 template struct Big<int>; 101
显式实例化 vs 惰性实例化的 .obj 对比
图 4-57 显式实例化(全量 101 个符号)vs 惰性隐式实例化(3 个符号)的 .obj 体积对比@ 本系列绘制

这条差异有个直接后果:显式实例化既是"加速"手段,也是"增重"手段。它把实例化集中到一个 TU(利好其他 TU),但那个倒霉的 TU 要生成全部成员(自伤)。所以在"每个 TU 只用少量成员"的工程里,显式实例化可能得不偿失。

2.3 部分特化(partial specialization):只改"用哪个定义",不改时机

类模板可以有部分特化。标准在 [temp.spec.partial](https://eel.is/c++draft/temp.spec.partial)里规定了选择规则:多个匹配时按偏序(partial ordering)选最特化的那个;一个都不比其他更特化则程序 ill-formed;都不匹配则用主模板。

template <typename T> struct Trait;            // 主模板
template <typename T> struct Trait<T*>;        // 部分特化:指针
template <typename T> struct Trait<const T*>;  // 部分特化:const 指针

Trait<const int*> t;   // 两个都匹配 → 偏序选出 Trait<const T*>

从编译时间角度看,部分特化不改变实例化的时机和粒度,只改变"实例化时用哪份定义"。特化分支多会略微增加匹配计算,但本机实测里它从来不是瓶颈——真正的成本在"实例化了几份"和"每份多大"。

2.4 模板参数推导(template argument deduction)

推导规则在 [temp.deduct](https://eel.is/c++draft/temp.deduct)。单次推导本身很便宜。贵的是推导的"次数"——当你有一堆重载的函数模板,编译器要逐个尝试推导(overload resolution),这就是前端时间的主要来源之一。

反直觉发现一:编译时间不一定由 #include 数量主导。 WG21 提案 P2172R0(https://www.open-std.org/JTC1/sc22/WG21/docs/papers/2020/p2172r0.pdf)指出,C++ 编译时间常由重载解析(overload resolution)驱动,而不是单纯的"解析了多少头文件"。一个巨大的模板重载集合会显著拉长前端时间——哪怕你只 #include 了一个头。

2.5 两阶段名字查找(two-phase name lookup):为什么错误总是迟到

模板编译有个出了名的体验问题:错误信息总是迟到,而且一迟到就是几十条。 根因在"两阶段名字查找"。

模板定义要经过两次检查:

  • 第一阶段(定义点检查):只检查不依赖模板参数的部分。比如拼错的全局变量名、语法错误——这些在模板定义处就能报。
  • 第二阶段(实例化点检查):检查依赖模板参数的部分。比如 T::foo() 到底存不存在、T 支持不支持 +——这些只有在 T 确定之后才知道。
template <typename T>
void f(T t){
  int x = undefined_global;   // 第一阶段就能报(不依赖 T)
  t.some_method();            // 第一阶段不报,第二阶段(实例化时)才报
}

这条机制的直接后果是:一个模板可以在头文件里"编译通过"存在很久,直到某天有个新类型实例化它,才爆出错误。 而那时的报错信息,通常是"从实例化点回溯"的一长串调用栈——因为编译器要说清"是哪个实例化触发的哪一层失败"。

从编译时间角度看,两阶段查找还有一笔隐性成本:每个依赖名字在每个实例化点都要重新解析一遍。这也是为什么模板里的依赖名字越多、实例化开销越大——它们不像普通函数那样在定义时就绑定好了。

相关标准:temp.res(名字解析与实例化点)、temp.dep(依赖名字)。这与第三章要讲的"编译器不为你写的模板付费、只为你用的成员付费"是同一枚硬币的两面——延迟检查换来灵活性,代价是把成本推到了实例化点。

2.6 实例化机制的时间线(演进脉络)

把这条机制的演进按年份排一下,能看清"惰性 + 可控"这条路是怎么被逐步补上的:

年份 事件 提出者 / 文档
1998 C++98 确立隐式实例化与显式实例化模型 ISO/IEC 14882:1998
2003 TC1 澄清两阶段名字查找 ISO/IEC 14882:2003
2004 N1690 右值引用提案(间接推动模板实例化语义细化) Howard Hinnant
2007 N2316 / N2402 引入 extern template(外部模板) John Spicer 等
2011 C++11 正式纳入 extern template ISO/IEC 14882:2011
2017 C++17 引入 if constexpr(编译期分支,避免无谓实例化) ISO/IEC 14882:2017
2020 C++20 引入 concepts(约束检查替代 SFINAE 替换) ISO/IEC 14882:2020
2020 C++20 引入 modules(从根上减少重复解析) P1103R3 / cpp.pre

三、实例化的代价:前端时间是主要账单

OK,机制讲完了,这一章开始算账:实例化要花多少钱。

3.1 每个唯一参数组合 = 一份独立代码

std::vector<int>、std::vector<double>、std::vector<std::string> 是三个完全不同的类型。编译器要把 vector 模板里被用到的每个成员,对每种元素类型各生成一遍。用探针把这件事拍下来——__FUNCSIG__ 会展开出每个实例的真实类型名:

template <typename T> void probe(){ printf("inst: %s\n", __FUNCSIG__); }

int main(){
  probe<int>(); probe<double>(); probe<char>();
  probe<std::string>(); probe<std::vector<int>>(); probe<std::map<int,double>>();
}

实测输出(MSVC 19.44):

inst: void __cdecl probe<int>(void)
inst: void __cdecl probe<double>(void)
inst: void __cdecl probe<char>(void)
inst: void __cdecl probe<class std::basic_string<char,struct std::char_traits<char>,class std::allocator<char> >>(void)
inst: void __cdecl probe<class std::vector<int,class std::allocator<int> >>(void)
inst: void __cdecl probe<class std::map<int,double,struct std::less<int>,class std::allocator<struct std::pair<int const ,double> > >>(void)
模板实例化探针输出——6 个不同实例的 __FUNCSIG__
图 4-68 __FUNCSIG__ 探针输出——6 次调用生成 6 个完全不同的实例@ MSVC 19.44 实测

六次调用、六个不同的函数实例。注意 std::string 的真实类型名有多长——basic_string<char, char_traits<char>, allocator<char>>。类型名越长,编译器要处理的符号名越长,实例化开销越大——这也是模板嵌套越深越慢的原因之一。

顺带把 sizeof 也量出来(实例化的"体积账"):

实例 sizeof
Holder<int> 4 字节
Holder<double> 8 字节
Holder<char> 1 字节
Holder<std::string> 32 字节
Holder<T> 的 sizeof 实测表
图 4-79 Holder<T> 对不同 T 的 sizeof 实测——4 / 8 / 1 / 32 字节@ MSVC 19.44 实测

3.2 递归模板(recursive template):展开深度的账

模板元编程(template metaprogramming)的核心手法是递归模板:用模板特化做编译期递归。最经典的例子是编译期阶乘或类型链:

template <int N> struct Chain { static constexpr long long v = Chain<N-1>::v + 1; };
template <> struct Chain<0> { static constexpr long long v = 0; };
int main(){ return (int)(Chain<100>::v & 1); }

Chain<100> 会展开成 Chain<100> → Chain<99> → … → Chain<0>,共 101 个类型实例。实测不同深度的前端时间:

递归深度 前端时间 c1xx.dll
10 0.0158 s
100 0.0214 s
500 0.0466 s
1000 0.0456 s
递归模板深度与编译时间
图 4-810 递归模板深度 10/100/500/1000 的前端时间实测@ MSVC 19.44 实测
递归模板展开链示意
图 4-911 递归模板展开链——Chain<100> 逐层触发 101 个实例@ 本系列绘制

这几组读数里有两处值得单独说。第一,增长是亚线性的:深度涨 50 倍(10→500),时间只涨了约 2.9 倍。原因是编译器对实例化结果做了记忆化(memoization)——同一个实例只算一次。第二,深度 500 到 1000 基本持平(0.0466 s 与 0.0456 s),说明这个尺度下时间被其他固定开销吃掉了,递归本身不再是主导。

结论要说得诚实:递归模板确实会增加编译时间,但在"每层只算几个常量"这种轻量递归里,它的绝对成本并不吓人。 真正让递归模板变贵的是"每层做很多事"——比如每层实例化一批类型、做一次类型列表拼接。那种情况下的增长会明显得多。

3.3 三种"约束方式"的编译期账单

模板里做条件选择有三代手法,编译期成本各不相同。我用同一个规模(200 个类型,一半有 size() 一半没有)测了三遍:

// ① SFINAE(C++11/14):靠"替换失败不报错"来选
template <typename T, std::enable_if_t<HasSize<T>::value,int> = 0> int get(T& t){ return (int)t.size(); }
template <typename T, std::enable_if_t<!HasSize<T>::value,int> = 0> int get(T&){ return 0; }

// ② if constexpr(C++17):编译期分支,只实例化匹配的那一支
template <typename U> int get(U& u){ if constexpr (requires{ u.size(); }) return (int)u.size(); else return 0; }

// ③ concepts(C++20):先声明约束,编译器快速排除
template <typename U> concept HasSize = requires(U& u){ u.size(); };
template <HasSize U> int get(U& u){ return (int)u.size(); }

实测前端时间(200 类型、5 轮中位数):

方式 前端时间 c1xx.dll 相对最慢
SFINAE 0.1171 s 基准
if constexpr 0.0894 s −23.7%
concepts 0.0981 s −16.2%

先把三次编译的原始输出摆出来,再逐条读。下面四张图里,第一张是三条读数的横向对比,后三张是每个版本的 cl /Bt+ 原始输出——你会发现三个版本的差异并不在同一个量级上,这也正是为什么"concepts 一定比 SFINAE 快"这个流行说法在本机实测里站不住脚。

三种约束方式的前端时间对比
图 4-1012 SFINAE / if constexpr / concepts 的前端时间对比(200 类型)@ 本系列绘制

SFINAE 那一版的原始读数如下。它的前端时间在三者里最慢,原因是每个类型都要走一遍"替换尝试"——先试正向重载,失败,再试反向重载。注意这一版只有 2 个候选,如果候选加到 5 个、10 个,这个数字还会继续往上走。

SFINAE 版本的编译时间
图 4-1113 SFINAE 版本的前端时间实测@ MSVC 19.44 实测

if constexpr 那一版读数最快。它跟前两者的本质区别在于:它根本不产生"多候选选择"这一步,只有一个函数模板,分支在实例化时被裁掉。所以它的编译期成本最低——这不是"新特性更快",纯粹是机制的必然结果。

if constexpr 版本的编译时间
图 4-1214 if constexpr 版本的前端时间实测@ MSVC 19.44 实测

concepts 那一版比 SFINAE 略快,但快得有限。原因也不难理解:concepts 省掉的是"替换尝试"里的失败分支,可约束检查本身要遍历一遍 requires 表达式——省下来的那部分,又被检查成本吃掉了一部分。

concepts 版本的编译时间
图 4-1315 concepts 版本的前端时间实测@ MSVC 19.44 实测

这里必须说一句与流行说法相反的话。 网上常说"concepts 比 SFINAE 快很多",但本机实测(MSVC 19.44、200 类型)里最快的是 if constexpr,不是 concepts:if constexpr 比 SFINAE 快约 24%,concepts 快约 16%——if constexpr 仍领先 concepts 约 9%。

为什么?因为三者的机制不同:SFINAE 要逐个候选尝试替换(失败才排除),concepts 是先检查约束再选(省下替换尝试),而 if constexpr 是一个函数模板内部的分支——根本不存在"多候选选择"这一步,实例化面最小。

所以选型建议是看场景,不是看"谁新":

场景 推荐 理由
一个函数内按类型分支 if constexpr 无候选选择开销,实例化面最小(实测最快)
需要约束一组接口(多个函数共享) concepts 约束可复用、报错清晰,成本略优于 SFINAE
维护 C++14 老代码 SFINAE 别无选择,但注意候选数越多越慢

证据纪律:这三组读数是本机 cl /Bt+ 实测(交错采样 9 轮取中位数),规模固定为 200 类型。别把"−24%"当普适结论——不同构造器、不同编译器的排序可能不同。方法比数字重要:你可以用同样三份源码在自己环境里复现。

3.4 变参模板(variadic template):实例化面最容易失控的地方

如果说有一个语言特性最能让实例化数量"炸开",那就是变参模板与完美转发(perfect forwarding)。原因是 它会把一个调用展开成 N 个候选、N 层实例化。

看一个典型的变参模板:

template <typename... Args>
void log(Args&&... args){
  (std::cout << ... << args);   // C++17 折叠表达式
}

看着只有一行。但 log(1, "x", 2.5) 会生成一份 log<int, const char(&)[2], double> 的实例;log(1, 2) 又是另一份。每多一种参数组合,就多一份实例化。 在一个大工程里,如果这个 log 被 500 个不同参数组合调用,就有 500 份实例。

更贵的是"转发链"。变参模板常常层层转发:

template <typename... Args> void make(Args&&... args){ impl(std::forward<Args>(args)...); }

std::forward<Args>(args)... 会把参数包逐层展开——每一层都要重新推导一遍参数类型、重新生成一份转发代码。这就是为什么很多工程里"日志/工厂/容器 emplace"这些变参接口,是编译时间的热点。

控制手段(按收益排序):

手段 做法 效果
改非模板重载 把高频的少数组合写成普通函数重载 实例化面直接归零
类型擦除 先包成 std::function / variant 再传 把 N 份实例压成 1 份(代价是运行时开销)
限制参数包 用 concepts 约束 Args 的种类 减少无效实例化的尝试
显式实例化 对已知组合做显式实例化 集中生成,避免重复(见第六章的坑)

这也是"运行时零开销"的一个反面:变参模板把开销从运行时挪到了编译期,如果你不需要那点运行时性能,用类型擦除换编译时间往往更划算。这个取舍没有普适答案,但知道了账在哪,就能主动选。

3.5 编译期容器(compile-time container):类型列表的账

模板元编程里还有一种结构值得单独提——编译期容器,比如类型列表(type list):

template <typename... Ts> struct TypeList {};

// 拼接两个类型列表
template <typename L1, typename L2> struct Concat;
template <typename... A, typename... B>
struct Concat<TypeList<A...>, TypeList<B...>> { using type = TypeList<A..., B...>; };

每做一次拼接,就要实例化一次 Concat<...>;做一次查找、过滤、去重,又是一批。一个长度 20 的类型列表,如果要做"两两组合",实例化数量是 O(20²) = 400 量级;如果做"幂集",就是 2²⁰。

这类结构的编译时间增长通常是超线性的,而且要小心它撞上 constexpr 的深度预算(第七章详述)。本机实测里,纯粹的线性类型链(第三章那个 Chain<N>)增长是亚线性的,但一旦引入"每层都要展开一批类型"的组合运算,增长会明显陡峭起来。

实践判断法:如果你的编译时间在"增加一个类型"后突然跳涨,而不是平稳增加,八成是撞上了组合运算。这时候要看的不是"哪个模板慢",而是"哪个模板被实例化了 N 次方"——用第五章的 Build Insights 模板视图能直接看出来。

3.6 实例化点(point of instantiation):同一个实例为什么会被"多算几遍"

还有一个容易忽略的机制:同一个模板特化可能存在多个实例化点(point of instantiation, POI)。

标准规定,函数模板特化的实例化点包括:使用它的那个点,以及所在的翻译单元末尾;如果多个 TU 都用了同一个特化,每个 TU 都有自己的实例化点。这带来两个后果:

  • 同一个实例在多个 TU 里各生成一份——这是第四章 COMDAT 去重的由来;编译器在每个 POI 都要判断"我这里需要生成吗"。
  • 实例化的语义归属可能影响正确性——如果某个特化的行为依赖"能看到哪些声明",POI 的位置就会影响结果。这也是"头文件里越少依赖越好"的另一个理由。

从编译时间看,POI 的意义是:你不能只看"我实例化了几次",还要看"跨 TU 一共实例化了几次"。 一个头文件里的重型模板,被 100 个 .cpp 各用一次,就是 100 个 POI、100 份实例——这正是 PCH 与 modules 想解决的问题(第八章、第九章)。

相关标准:temp.point(实例化点)。


四、二进制体积的账:实例化不只花时间

好了,时间上的账先放一放。前三章算的都是"时间"。但实例化还有另一笔账——它花二进制体积。这笔账不显示在编译时间上,却显示在你的 .obj、.exe、安装包里。

4.1 从 3 字节到 25,559 字节

还拿第一章那两个程序来量。用 dumpbin /summary 读 .obj 的 .text 段(代码段)尺寸:

程序 .obj 的 .text 段
空程序 int main(){} 3 字节
含容器模板的程序 25,559 字节

差了约 8,500 倍。 空程序的 main 只有一条 xor eax, eax; ret(3 字节);而容器模板程序因为实例化了一批 vector/string/map/sort 的成员,代码段涨到 25 KB。

这个数字要看清楚口径:25,559 字节是这一个 TU 的 .obj,还没算链接后的去重。真正进最终可执行文件的量通常小于这个数——因为链接器会把重复的实例(COMDAT)折叠掉。

4.2 链接器的隐含工作:COMDAT 折叠(COMDAT folding)

模板实例化的一个天然后果是:同一个实例会在多个 TU 里各生成一份。你的工程有 50 个 .cpp 都用了 std::vector<int>,理论上就有 50 份 vector<int>::push_back。

干这件事的是链接器,不是编译器。机制是这样的:

  • 编译器把每个模板实例放在一个独立的 COMDAT 段里(一个实例一个段);
  • 链接器看到多个同名 COMDAT 段时,只保留一份,其余丢弃(去重,deduplication);
  • MSVC 链接器还有 /OPT:ICF(Identical COMDAT Folding),把内容完全相同的不同 COMDAT 段也合并——比如 vector<int>::push_back 和 vector<unsigned>::push_back 如果生成了完全一样的机器码,ICF 会把它们折叠成一个。

这就是为什么"模板膨胀"在最终可执行文件里没有 .obj 加起来那么夸张。但要注意顺序:去重发生在链接阶段——编译阶段那 50 份实例化的时间,已经花掉了,退不回来。

4.3 单一定义规则(One Definition Rule, ODR)与 inline 的默契

为什么同一个模板可以在 50 个 TU 里各定义一份而不违反"单一定义规则"(ODR)?因为标准对模板、inline 函数、类内定义的成员函数开了特例——它们允许多个 TU 里存在相同定义,条件是这些定义逐 token 相同;链接器负责合并。

这条特例是模板能"头文件即实现"的根基,但它也有个隐藏成本:编译器在每个 TU 里都必须重新做一遍"这符合 ODR 吗"的检查,以及重新实例化。这正是 C++20 modules 想从根上解决的问题(第八章讲)。

相关规则:basic.def.odr(ODR 条款)、temp.inst(实例化的 ODR 要求)。

4.4 体积账的小结

层面 谁负责 后果
单 TU 生成实例代码 编译器前端 + 后端 .obj 变大(实测 3 B → 25,559 B)
跨 TU 重复实例 链接器去重(COMDAT) 最终可执行文件小于 .obj 之和
内容相同的不同实例 链接器 ICF(/OPT:ICF) 进一步折叠,但只在链接期

4.5 还有一笔隐藏的账:调试信息(debug info)

.text 不是全部。如果你用 /Zi//Z7 生成调试信息,那么每一个实例化都会在调试信息里留下一条记录——类型名、成员函数名、模板参数的完整展开。

这笔账有个特点:它比代码本身涨得更快。 原因是调试信息要记录完整的类型名,而模板类型名是"逐层嵌套展开"的:

Big<int>                                    ← 短
std::map<int, std::vector<std::pair<std::string, std::unique_ptr<Foo>>>>   ← 长到离谱

模板嵌套越深,类型名的长度是指数级的(每嵌一层就把内层的完整名字抄一遍)。这就是为什么大型 C++ 工程的 .pdb 经常几个 GB、链接时间以分钟计——调试信息里的模板类型名是主要元凶之一。

缓解手段(按顺序试):

  • /Z7 vs /Zi:/Z7 把调试信息嵌进 .obj(并行链接更友好),/Zi 走 .pdb(单文件但可能成为链接瓶颈)。
  • /DEBUG:FASTLINK:链接阶段只写索引,不合并全部调试信息——链接快,但调试体验略降。
  • 裁剪模板深度:减少深层嵌套模板,是唯一能同时改善代码体积与调试信息体积的做法。
  • 发布构建不带 /Zi:这条最朴素,但很多人忘了发布版还在生成调试信息。

说明:调试信息体积的量化因工具链而异(MSVC .pdb/GCC DWARF/Clang DWARF 的编码差异很大),本篇不做跨平台数值对比,只指出这笔账存在、而且随模板嵌套指数增长。想看自己工程的数字:把 /Zi 换成 /Z7 后比较 .obj 总大小,或直接看 .pdb 体积。


五、MSVC 侧工具链:cl /Bt+ / vcperf / MSBuild binlog

先来看看"怎么看到慢在哪"——这是本篇最"可抄走"的一章。前面讲了"为什么慢",这一章讲工具。本机没有 Clang 的 -ftime-trace,所以下面三条路线都是 MSVC 侧的替代方案,全部实测可跑。

5.1 cl /Bt+:最轻量的分阶段时间

/Bt+ 是 MSVC 的一个非正式选项——它不改变编译结果,只在标准输出里追加每个编译子进程的时间。输出长这样:

time(...\c1xx.dll)=0.71600s < ... > BB [tpl.cpp]
time(...\c2.dll)=0.36290s < ... > BB [tpl.cpp]

三个组成部分:

字段 含义
c1xx.dll C/C++ 前端——预处理、词法/语法/语义分析、模板实例化
c2.dll 后端——优化与代码生成
link.exe 链接器(用 /c 只编译时不出现)
编译流水线——前端 c1xx / 后端 c2 / 链接器
图 4-1416 编译流水线——模板实例化发生在前端 c1xx.dll,优化与代码生成在后端 c2.dll@ 本系列绘制

这三个阶段的分工,决定了"编译慢"要往哪里查。前端拿到的是文本,输出的是语义已确定、模板已展开的中间表示;后端拿到的是这份中间表示,输出的是机器码。所以凡是"跟类型系统、模板、头文件有关"的慢,都在前端;"跟指令选择、寄存器分配、优化有关"的慢,都在后端。这条分界线是后面所有排查的基础。

cl /Bt+ 的分阶段时间输出
图 4-1517 cl /Bt+ 输出的三段式时间(前端 / 后端 / 链接)@ MSVC 19.44 实测

把三段式读数放在一起看,你会立刻知道该往哪个方向查。这张图里前端时间明显大于后端——这是模板型工程的典型特征。反过来,如果你看到后端占大头,那说明模板不是主要矛盾,该去看优化选项与代码生成。

怎么用:先用 /Bt+ 找到"是前端还是后端"。如果前端占大头,才是模板实例化的嫌疑;如果后端占大头,问题在优化器(那是 ep05 的话题)。

官方文档:MSVC /Bt、/Bt+、/Bt-。注意 /Bt+ 属"非正式选项"家族——行为可能随版本变化,本系列只把它当"相对趋势"的量尺,不当绝对值。

5.2 vcperf:ETW 级别的构建追踪(附本机受阻记录)

/Bt+ 的粒度只到"前端/后端/链接"三段。要看更细的——哪个 TU 慢、哪个模板实例化最贵——标准答案是 vcperf。它是 C++ Build Insights 的命令行前端,采集构建过程的 ETW(Event Tracing for Windows)事件:

vcperf.exe /start MySession
# 在这里执行你的构建
vcperf.exe /stop MySession /timetrace build.json

这一路我本机没跑通,原因得如实记下来:vcperf /start 需要管理员权限——它要打开内核级的 ETW 会话。本机以普通权限运行,实测被拒绝:

Microsoft (R) Visual C++ (R) Performance Analyzer 2.4.24101401
Starting tracing session MySession...
Failed to start trace.
This operation requires administrator privileges.
vcperf 采集尝试的真实输出——需要管理员权限
图 4-1618 vcperf /start 的真实输出:本机普通权限下被拒(This operation requires administrator privileges)@ MSVC 14.44 实测

/stop 阶段同样报错(FAILURE_STOP_MSVC_TRACE),因为会话从未成功建立。所以本篇不展示追踪视图——那是需要管理员权限 + Windows Performance Analyzer 才有的东西。这里把失败现场留出来,一是记录"工具能跑"与"权限够不够"是两件事,二是给你一个可复现的判据:如果你也看到这条报错,就先用管理员身份重开终端。

教程:微软官方的 vcperf 与 WPA 教程。vcperf 的路径在 VC\Tools\MSVC\<版本>\bin\Hostx64\x64\vcperf.exe——它不在 PATH 里,需要手动指定。

那没有管理员权限时,后端明细还能不能看?能。 MSVC 有一个不需要任何权限的选项——/d2cgsummary,它输出后端(c2.dll)的代码生成摘要,精确到每个函数的耗时与指令数:

cl /c /d2cgsummary /std:c++20 /EHsc /O2 tpl.cpp
cl /d2cgsummary 的真实输出——函数级代码生成明细
图 4-1719 cl /d2cgsummary 的真实输出——449 个函数、代码生成 0.406 s、总编译 1.265 s、6 个异常耗时函数@ MSVC 19.44 实测

这份输出里有三个数字值得记住:总函数数 449(这就是第一章那个"模板程序"实际生成的函数数量)、代码生成耗时 0.406 s、总编译耗时 1.265 s。最有用的是它直接列出"异常耗时函数"(Anomalistic Compile Times)——实测排在第一位的是 std::sort 内部的一个 partition 函数(0.167 s、1195 条指令),第二位是 main(0.094 s、1842 条指令)。

换句话说,/d2cgsummary 把"后端时间花在哪"细到了函数级,而完全不需要管理员权限。 这是本篇找到的、/Bt+ 与 vcperf 之间的一个实用补位——代价是它只覆盖后端(代码生成),前端(模板实例化)那一侧看不到。

5.3 MSBuild /bl:结构化构建日志

如果你用 MSBuild 构建(VS 工程基本都是),加 /bl 会生成一个 msbuild.binlog。这个文件把整次构建的全部过程——项目、target、task、property、诊断信息——都序列化了下来,供事后离线分析:

MSBuild MySolution.sln /bl /m
MSBuild /bl 生成二进制日志
图 4-1820 MSBuild /bl 生成 msbuild.binlog@ MSBuild 17.14 实测

.binlog 用 MSBuild Structured Log Viewer 打开后,你能看到完整的构建树:哪个项目先构建、哪个 target 花了多少时间、哪个 task 在等待。它的价值在于把"构建"这个黑盒展开成可逐节点计时的一棵树——/Bt+ 只看得到单个编译进程,看不到构建系统本身的调度开销。

MSBuild 详细生成摘要的真实输出
图 4-1921 MSBuild /bl 附带的详细生成摘要——项目独占时间/总时间与节点利用率@ MSBuild 17.14 实测

第一次打开 binlog 的人常常会惊讶:原来构建里有一大半时间不在"编译",而在跑各种自定义 target、复制文件、检查依赖。这类开销 /Bt+ 完全看不到——它只统计 cl.exe 自己花的时间。所以当你觉得"编译时间对不上"时,binlog 是唯一能给出完整账本的工具。

binlog 的独特价值在于"构建级别"——如果你的编译慢不是模板的锅,而是某个 target 反复重跑、某个 task 在等 IO,binlog 能看出来。/Bt+ 看不到这些。

参考:微软官方博客 C++ 构建吞吐量排查与调优 给了完整的 binlog 排查流程;MSBuild Binlog Analyzer 则把 binlog 分析搬进了 VS Code。

5.4 Build Insights 的"模板视图"

C++ Build Insights 里有一个专门为模板实例化做的视图——按实例化耗时排序。它直接回答那个最难回答的问题:"我这几百个模板里,到底是哪一个最贵?" 前面的 /Bt+ 只能告诉你"前端慢",vcperf 能告诉你"哪个 TU 慢",而模板视图能精确到单个模板特化的实例化耗时,这是定位编译时间黑洞最直接的工具。

MS Build Insights 模板视图的官方来源面板
图 4-2022 MS Build Insights 模板视图——官方文档来源面板(按实例化耗时排序;本机无该视图,引官方教程)@ learn.microsoft.com

这个视图还能按"实例化次数"排序。这两个排序给出的答案往往完全不同:耗时榜第一是"一个特别大的模板被实例化了一次",次数榜第一是"一个小模板被实例化了几千次"。前者要改设计(拆模板),后者要改接口(减少参数组合)——先看清是哪一种,再决定怎么改。

官方教程(含界面截图):模板实例化对构建时间的影响。微软在此文档中给出的三条建议是:把实例化移到源文件、用 if constexpr 替代 SFINAE、避免递归模板模式——正好对应本篇第二、三、六章。

5.5 三条路线怎么选

你想知道 用什么 成本
前端还是后端慢 cl /Bt+ 加个选项,零配置
后端哪个函数最贵 cl /d2cgsummary 加个选项,零配置、不需权限
哪个 TU/哪个模板最贵 vcperf + WPA 需要装 WPA
构建系统层面谁拖后腿 MSBuild /bl 需要 Structured Log Viewer
模板实例化热点排行 Build Insights 模板视图 需要 VS 企业版/社区版(17.x 已含)

实践建议:从 /Bt+ 开始。 它零成本,先判断"是不是模板的问题"。确认是了,再上 vcperf 精确定位。

5.6 一次完整的排查演练(可照抄的流程)

把上面四件工具串成一条可照抄的排查流程。假设你的工程编译突然变慢:

第一步:确认"慢在哪"——cl /Bt+

挑一个改动最频繁、体感最慢的 TU,单独编译:

cl /c /Bt+ /std:c++20 /EHsc my_heavy.cpp

看 c1xx.dll 与 c2.dll 各占多少。

  • 前端大 → 模板实例化/头解析嫌疑,继续第二步。
  • 后端大 → 优化器成本(那是 ep05 的范围);想立刻看到"哪个函数最贵",加 /d2cgsummary 直接看函数级明细。

第二步:确认"是不是头解析"——对比 PCH

把该 TU 的头改成 PCH,再测一次:

cl /c /Ycpch.h /Fp"out\pch.pch" pchsrc.cpp
cl /c /Yupch.h /Fp"out\pch.pch" /Bt+ my_heavy.cpp

如果前端时间断崖式下降(本篇实测是 0.6639 → 0.0372),说明大头是头解析,不是模板实例化——上 PCH 就够了。

如果降得不明显,说明是实例化本身贵,继续第三步。

第三步:定位"哪个模板最贵"——Build Insights 模板视图

用 vcperf 采一次构建追踪,在 WPA 或 Build Insights 模板视图里按实例化耗时排序。排在最上面的那个模板,就是你的目标。

第四步:动手改,再量一遍

针对那个模板,按本篇的方法选手段:

症状 手段
每个 TU 只用少量成员 别动(隐式实例化本来就便宜)
每个 TU 都用大量成员、且编译器支持 extern template(先用 6.5 的方法验证生效)
多候选条件选择 改 if constexpr / concepts
变参接口参数组合爆炸 改非模板重载或类型擦除
头解析占大头 PCH
全量构建慢、增量不敏感 Unity Build

改完必须再量一遍——本篇第六章就是教训:extern template 理论漂亮,实测在 MSVC 上没生效。没有二次测量的优化,都是猜。


六、extern template:一个跨编译器的实测差异

说白了,这一章是本篇最"意外"的一章。我原本打算写一篇标准的"extern template 能省 10 倍编译时间"的教程——实测结果把我的结论推翻了。

6.1 extern template 应该做什么

extern template 是 C++11 引入的(GCC 文档有详细说明),设计目的很明确:告诉编译器"这个实例的代码在别的 TU 里,你这里不要生成"。

// big.h
#pragma once
template <typename T> struct Big { /* ... */ };
extern template struct Big<int>;   // 声明:别在本 TU 里实例化它

// def.cpp
#include "big.h"
template struct Big<int>;          // 定义:全量实例化,就在这里生成

预期收益:10 个 TU 都用 Big<int> 时,不加 extern template 就是 10 份实例化;加了之后只有 def.cpp 实例化 1 份,其余 9 个 TU 跳过——理论上前端时间能省到接近十分之一。

extern template 原理——一处实例化、多处引用
图 4-2123 extern template 原理——一处显式实例化定义 + 多处 extern 声明,链接器合并@ 本系列绘制

这张图解释了 extern template 的整个设计意图:把"生成代码"这件事从 N 个 TU 收拢到 1 个 TU,其余 TU 只留下"引用"。 收益的上限就是"省下 N−1 份实例化";而它的代价则藏在最后一步——链接器必须能找到那一份唯一的定义,找不到就是 undefined reference。

6.2 实测:符号层面先看它生不生效

理论很漂亮。但实测第一步就出了问题——我先验证"它到底有没有抑制实例化",用符号表数一数。

构造一个 Big<T>(100 个成员),10 个 TU 每个都调用全部 100 个成员,一半加 extern template、一半不加。然后数每个 .obj 里 Big<int> 的定义符号:

编译器 / 选项 不加密 加 extern template
MSVC 19.44(/Od /Ob0) 已定义 101 个 已定义 101 个 ← 没变
GCC 16.2(-O0 -fno-inline) 已定义 101 个 已定义 0 个,未定义 101 个 ← 生效了
MSVC 下 extern template 的符号实测
图 4-2224 MSVC 19.44 下 extern template 前后符号数不变(101 → 101)——未抑制实例化@ MSVC 19.44 实测

先看 MSVC 这一侧。加不加 extern template,Big<int> 的定义符号都是 101 个——说明它照旧把这些成员全部实例化了一遍,extern template 那一行形同虚设。

GCC 下 extern template 的符号实测
图 4-2325 GCC 16.2 下 extern template 后实例化被抑制(101 → 0 定义、101 未定义)@ g++ 16.2.0 实测

再看 GCC 这一侧,情况完全反过来:加了 extern template 之后,Big<int> 的定义从 101 个变成 0 个,同时出现 101 个未定义符号(U)——这正是"我知道它存在,但代码在别处"的标准表现。同一个标准条款,两个编译器给了相反的答案。

两个编译器的行为完全不同。 GCC 按标准做了(抑制实例化,符号变成"未定义"等待链接);MSVC 19.44 在本机实测里没有抑制——extern template 之后符号照样全量定义。

6.3 决定性验证:让它链接

符号表还不够硬。做一个更狠的验证——只提供 extern template 声明、不提供任何显式实例化定义,然后链接。如果编译器真的抑制了实例化,链接必然失败(找不到符号)。

// e.h
template <typename T> struct Foo { T m() const { return T(1); } T n() const { return T(2); } };
extern template struct Foo<int>;   // 只声明,全工程没有任何显式实例化定义

// main.cpp
#include "e.h"
int main(){ Foo<int> f; return (int)(f.m() + f.n()) & 1; }
编译器 结果
MSVC 19.44 编译链接成功(rc=0,无 LNK2019)→ 说明它自己实例化了
GCC 16.2 链接失败:undefined reference to Foo<int>::m() const → 说明它真的没有实例化
跨编译器对照——GCC 生效、MSVC 未生效
图 4-2426 extern template 跨编译器实测对照——GCC 抑制实例化、MSVC 19.44 未抑制@ 本系列绘制

6.4 时间上也一样:10 TU 全量构建

符号层面成立了,时间层面自然也一致。10 个 TU 全量构建(禁用优化,确保实例化代码真的被生成、不被内联优化掉):

编译器 不加密 加 extern template 变化
MSVC 19.44 /Od 1.093 s 1.058 s −3.2%(噪声范围内,无可复现收益)
GCC 16.2 -O0 3.283 s 2.422 s −26.2%(更快)

MSVC 侧基本持平(−3.2% 落在测量噪声内),测不到可复现的收益。

6.5 这个结论怎么用

反直觉发现二:extern template 的收益是编译器相关的——本机实测 MSVC 19.44 未生效,GCC 16.2 生效。

这条结论有几个实用含义:

  • 别盲信"extern template 能省 10 倍":那个数字在 GCC/Clang 上可能成立(本机 GCC 实测省了约 26%,规模更大时收益更明显),但在 MSVC 19.44 上我测不到收益。跨编译器项目要分别验证。
  • 验证方法可抄走:判断 extern template 有没有生效,最快的办法是删掉显式实例化定义再看能不能链接。链接失败 = 生效了;还能链接 = 没生效。
  • 它的收益有前提:即便在 GCC 上,extern template 也只在你"每个 TU 都用大量成员"时才明显。如果每个 TU 只用一两个成员(第二章那个 2,913 字节的例子),隐式实例化本来就便宜,extern template 省不了多少。
  • 它的风险是链接错误:extern template 削弱了共享头文件的独立性——某个 TU 用了 Big<int>,但全工程没人做显式实例化定义,就会出现"链接期 undefined reference"。这是个延迟暴露的错误,排查起来不便宜。

证据纪律:以上均为本机实测(MSVC 19.44.35228 / GCC 16.2.0),用 /Od /Ob0 与 -O0 -fno-inline 禁用内联以保证实例化代码真的生成。"MSVC 不生效"是本机这个版本、这个用例的实测结论,不排除与语言模式、/Zc 选项或后续版本有关——你的环境请用 6.5 的方法自己验一遍。这条差异的来源不在本篇范围(可能牵涉 MSVC 的 COMDAT 发射策略),留作后续观察项。

6.6 减少实例化的三种手段:横向对比

extern template 是"减少实例化"这一类手段里最有名的一个,但不是唯一一个。把这一类手段放一起对比:

手段 机制 实测收益 适用前提 风险
extern template 抑制隐式实例化,集中到一处 GCC −26.2%;MSVC 未生效 多 TU 都用大量成员;编译器支持 链接期 undefined reference
显式实例化 在指定 TU 强制全量生成 取决于调用面 已知固定参数组合 那个 TU 自伤(生成本可惰性跳过的成员)
改非模板重载 直接把高频组合写成普通函数 实例化面归零 参数组合种类少 代码重复
类型擦除 用 variant / function 收口 压成 1 份 不需要极致运行时性能 运行时开销(虚调用/内存)

这张表的核心信息是:extern template 不是首选。 从实测收益和风险看,它的性价比反而不如"改非模板重载"——后者没有链接期风险,收益更确定。extern template 真正适合的场景很窄:一个参数组合固定、被大量 TU 大量成员使用的重型模板(标准库的 extern template class basic_string<char> 就是典型)。

一个反常识的排序建议:

  1. 先问"真的需要这么多实例化吗"——很多时候问题在接口设计(变参、深嵌套),不在实例化本身。
  2. 再看"能不能少几个参数组合"——减少组合数是最直接的收益。
  3. 最后才考虑 extern template/显式实例化这类"编译器级"手段。

先改设计,再改编译选项。 这条顺序能避掉大量"优化了但没用、还引入了链接风险"的情况。


七、编译期计算的账:constexpr 的求值上限

下面开始讲编译期还有哪些地方在花钱。模板不是唯一在编译期干活的东西。constexpr 函数、consteval、模板元编程都在编译期跑代码——在编译期跑代码是要付编译时间的,而且编译器给它设了预算上限。

7.1 constexpr 求值有硬上限

我先试了一个最朴素的递归 constexpr 求和:

constexpr long long csum(long long n){ return n == 0 ? 0 : n + csum(n - 1); }
int main(){ constexpr long long v = csum(400); return (int)(v & 1); }

然后逐步加大深度,看什么时候编译失败。实测(MSVC 19.44,默认选项):

递归深度 编译结果
100 ✅ 通过
400 ✅ 通过
500 ✅ 通过
511 ❌ 失败:C2131 表达式的计算结果不是常数
1000 ❌ 失败(C2131)
2000 ❌ 失败(C2131)
constexpr 递归深度上限实测
图 4-2527 constexpr 递归深度上限实测——500 通过、511 失败(默认选项)@ MSVC 19.44 实测

上限卡在 500 与 511 之间。 这条边界对应的正是 MSVC 的 /constexpr:depth 默认值 512——递归深度超过这个数,编译器直接判定"求值不收敛",报 C2131。这不是 bug,是编译器保护自己不被编译期死循环拖死的机制。

用 /constexpr:depth4096 放开后,深度 1000 就能通过。但把深度继续推到 10000 时,即便放宽 /constexpr:depth 与 /constexpr:steps 仍然失败(本机实测,未进一步定位原因——可能碰到 MSVC 的内部硬上限)。

实用含义:别在编译期写深递归。 500 层听着很多,但真实工程里的模板元编程(类型列表拼接、编译期解析器、状态机展开)很容易超。一旦超了,报错是 C2131,不熟悉的人会以为是"表达式写错了",其实是撞了深度预算。

7.2 constexpr 计算 vs 运行时计算

constexpr 的卖点是"把计算从运行时挪到编译期"。那编译期这一侧要多花多少钱?同样深度 400 的递归求和,测两种写法:

写法 前端时间 c1xx.dll
纯 constexpr(编译期求出结果) 0.0180 s
纯运行时(volatile 阻止优化掉) 0.0142 s
constexpr 计算与运行时计算的编译时间对比
图 4-2628 纯 constexpr 与纯运行时版本的编译时间对比(深度 400)@ 本系列绘制

constexpr 版本的前端时间多了约 27%。这个差值就是"编译期求值"的账单。

这笔账怎么理解? constexpr 把计算挪到了编译期,运行时的 CPU 时间省了——但如果这段计算只执行一次、或者本来就不在热路径上,那你是在用"每次构建都多花"换"每次运行都省一点"。只有当这段计算在运行时被高频调用时,这笔交换才划算。 这是个典型的"编译期 vs 运行时"权衡,不是无脑选 constexpr。

7.3 编译期计算的三条纪律

综合本章与第三章的数据,编译期计算有三条可操作的纪律:

  1. 别深递归——超过约 500 层就撞 /constexpr:depth 默认上限(实测 500 通过/511 失败)。
  2. 别在热路径之外滥用 constexpr——它把成本从运行时搬到编译期,只在"运行时高频调用"时才划算(实测前端多花约 29%)。
  3. 优先 consteval/constinit 表达意图——让"必须编译期求值"的语义显式化,而不是靠 constexpr 的"可能编译期求值"模糊地带。

相关标准:dcl.constexpr(constexpr 说明符)、expr.const(常量表达式)。MSVC 选项:/constexpr:depth 与 /constexpr:steps。


八、C++20 Modules:编译时间的"次元折叠"

言归正传,前面讲的都是"在头文件模型内优化"。C++20 的 modules 换了个思路——从根上改掉头文件模型本身。

8.1 头文件模型的 O(n×m) 问题

传统头文件模型的成本结构是这样的:n 个头文件被 m 个源文件包含,每个 TU 都要完整解析一遍所有被包含的头。

假设你的工程有 50 个 .cpp、每个都 #include <vector>——编译器要把 <vector> 完整解析 50 遍。<vector> 背后还有 <bits/stl_algobase.h>、<bits/allocator.h>……一整棵树。50 遍就是 50 倍。

这就是头文件模型的两个固有缺陷:

  • 重复解析:同一个头被解析 m 遍,复杂度 O(n×m)。
  • 重复实例化:同一个模板实例在每个 TU 各生成一份(第四章讲的 COMDAT 去重只是补救)。

8.2 modules 的 O(n+m) 承诺

C++20 modules 的核心机制是:把头文件(或模块接口)编译成一次性的二进制模块接口(Binary Module Interface, BMI);之后 import 它时只读取预编译结果,不重新解析。

模块与头文件的解析复杂度对比
图 4-2729 头文件 O(n×m) 重复解析 vs modules O(n+m) 预编译导入@ 本系列绘制

这张图是 modules 全部收益的来源。注意"重复解析"这条路上的每一个方框,在传统模型里都要在每个 TU 重新走一遍——词法分析、宏展开、语法树构建、语义检查,一步都不能少。modules 把这份工作提前做了一次,存成 BMI;后续 TU 只做一次"读取 + 绑定",不重放整条流水线。

复杂度从 O(n×m) 降到 O(n+m)。LLVM 的模块文档(Standard C++ Modules)把这条机理说得最清楚:n 个头被 m 个源文件包含时,复杂度降为"模块扫描 + 模块编译"两部分。

WG21 的实测数据:

来源 数据
P2172R0 导入整个标准库约 0.10 秒,比逐个 #include 快约 10 倍
P2412R0 import std 明显快于 #include <iostream>;"真模块"比 header unit 再快 2 倍以上
微软官方博客 Moving a project to C++ named modules ball-pit.cpp 从 3.55 s 降到 0.15 s(10 次运行平均)
Clang -ftime-trace 的官方来源面板
图 4-2830 Clang -ftime-trace 的 JSON 时间线——官方文档来源面板(本机无 Clang,引官方文档)@ clang.llvm.org

为什么把这张图留在这里?因为它是"验证收益"的标准工具——数据是别人测的,方法却是你能用的。如果你在 Clang 上做模块迁移,把 -ftime-trace 的 JSON 导进 Perfetto,就能一眼看出"这次迁移到底省了哪一段"。没有这段对照,迁移就是盲改。

Clang 用户可以用 -ftime-trace 直接量化"#include 花了多少、模块导入花了多少"——这是本篇唯一给不出本机实测的地方,只能引 Clang 官方文档。

8.3 header units 的陷阱

反直觉发现三:modules 不是"用了就快"——header units 的表现依赖采用顺序。

P2898R0 指出:header units 的编译时间收益依赖自底向上(bottom-up)的采用顺序。如果你贪图省事,先从高层头开始转(比如把 <iostream> 直接转成 header unit),编译器可能因为传统 include guard 优化效果更好而表现得更差——即"改了反而更慢"。

正确的做法是从最底层的头(<cstddef>、<type_traits> 这类)开始,逐层向上。这也是为什么"整体迁移到 modules"是个大工程——顺序错了会负优化。

8.4 现实:2026 年的迁移状态

C++20 modules 的标准语法在 cpp.pre(以及 module.import)里。MSVC 19.44 已经支持命名模块,标准库也提供了 import std;。

但现实中的迁移仍然困难,原因有三个:

  • 第三方库的模块支持参差不齐:你依赖的 20 个库可能只有 3 个提供了模块接口。
  • 过渡期可能更慢:头文件与模块混用的时候,编译器要同时处理两套模型,可能出现"混合比纯头文件还慢"的阶段。
  • 构建系统支持:CMake 对 modules 的支持仍在演进,跨平台的模块编译顺序管理是个真问题。

结论:modules 目前是"绿地项目值得一开始就用"的技术,不是"现有大工程立即全面迁移"的解药。 想在自己的项目里验证收益,最稳的路是先找一个叶子模块(依赖最少的最底层)转过去,量出收益再决定要不要继续。

8.5 一个最小模块示例:从 #include 到 import

modules 的语法本身不复杂。最直观的对比是把一段"头文件 + 使用"改成"模块 + 导入":

// ===== 传统头文件模型 =====
// math_utils.h
#pragma once
int add(int a, int b);

// math_utils.cpp
#include "math_utils.h"
int add(int a, int b){ return a + b; }

// main.cpp
#include "math_utils.h"
int main(){ return add(1, 2); }
// ===== C++20 模块模型 =====
// math_utils.ixx(模块接口单元)
export module math_utils;          // 声明这是一个模块
export int add(int a, int b);      // export:对外可见

module : private;                  // 后面的内容不导出
int add(int a, int b){ return a + b; }

// main.cpp
import math_utils;                 // 导入,不解析头
int main(){ return add(1, 2); }

两处关键差异:

  • export module 名字;:把这一份定义确立为一个模块,而不是每次 #include 都重新解析。
  • export 的边界由代码显式声明:只有标了 export 的才对外可见,其余是模块私有——这带来了"实现细节真正隔离",是头文件模型做不到的(头文件里所有东西都可见,只能靠约定)。

编译顺序也变了:math_utils.ixx 必须先编译成 BMI(通常是 .ifc),main.cpp 才能 import。这意味着构建系统必须知道模块依赖图——这也是 modules 落地最大的工程量所在。MSVC 提供了 /interface(标记接口单元)与 /reference(指定 BMI 路径),CMake 3.28+ 有模块扫描支持,但跨平台仍需仔细配置。

标准与实现参考:cpp.pre、module.import,以及 LLVM 的 Standard C++ Modules(含完整编译命令示例)。


九、构建系统层:PCH 与 Unity Build 的实测账

好了,第八章讲的是语言层面的解法。如果短期内用不上 modules,构建系统层面还有两把磨得发亮的旧刀——预编译头(PCH)和统一构建(Unity Build)。这两把刀本机都能实测。

9.1 预编译头(Precompiled Header, PCH):实测 16.6 倍

PCH 的思路很直接:把一组稳定的头文件预先编译成二进制,后续编译直接加载,不再重新解析。

MSVC 的用法是两步:

# ① 创建 PCH(/Yc:把 pch.h 编译成 pch.pch)
cl /c /Ycpch.h /Fp"out\pch.pch" pchsrc.cpp

# ② 使用 PCH(/Yu:编译时加载 pch.pch,跳过 pch.h 的解析)
cl /c /Yupch.h /Fp"out\pch.pch" usepch.cpp

实测同一个 TU(都包含 <vector>/<string>/<map>/<algorithm>/<memory>):

模式 前端时间 c1xx.dll 说明
不用 PCH(直接 #include) 0.6639 s 每次都完整解析
用 PCH(/Yu + /Fp) 0.0372 s 加载预编译结果
PCH 前后编译时间对比
图 4-2931 PCH 前后前端时间对比——0.6639 s → 0.0372 s@ MSVC 19.44 实测

0.6639 → 0.0372,快了约 17.8 倍。 这是本篇所有优化手段里收益最大的一个,而且改动量最小——加两个选项。

PCH 的原理是序列化 AST:编译器解析完头文件后,把抽象语法树(AST)和辅助数据结构序列化成压缩的 bitstream;后续编译加载这个 bitstream,直接跳过词法/语法/语义分析。省掉的就是头文件解析这一整段。

官方文档:MSVC /Yc(创建 PCH) 与 /Yu(使用 PCH)。

PCH 的局限(必须知道,否则会踩坑):

  • "全有或全无":MSVC 一个工程通常只能有一个 PCH,且每个 TU 必须在最前面 #include 它。TU A 用了含 <vector> 的 PCH,TU B 想跳过 <vector> 也做不到。
  • 只对"稳定且重"的头有效:如果头文件天天改,PCH 要频繁重建,收益被抵消。
  • 它会掩盖真实的包含依赖:PCH 让"我到底依赖哪些头"变得不透明,删错头可能要等换环境才暴露。

9.2 Unity Build:全量构建快 9.7 倍,增量却更慢

Unity Build(也叫 Jumbo Build)的思路是:把多个 .cpp 合并成一个"超级 TU"编译,让共享的头文件只解析一次。CMake 有原生支持:

set_target_properties(MyTarget PROPERTIES UNITY_BUILD ON)

(见 CMake UNITY_BUILD 文档。)

实测 10 个分片(每个都 #include <vector>/<string>),对比"10 个分开编译"与"合并成 1 个 TU":

模式 全量构建墙钟时间
10 个分片分别编译 6.341 s
合并成 1 个 TU 0.645 s
Unity Build 全量构建时间对比
图 4-3032 Unity Build 全量构建时间对比——10 分片 6.341 s → 合并 0.645 s@ MSVC 19.44 实测

快了约 9.8 倍。 原因就是第八章那个 O(n×m):10 个 TU 各自解析一遍 <vector> + <string>(约 0.5 s/个),合并后只解析一遍。

但 Unity Build 有个代价,而且这个代价很隐蔽:

反直觉发现四:Unity Build 加速全量构建,却拖慢增量构建。

实测"改一个文件后重编译"的时间:

模式 增量重编译时间
10 分片模式(改 1 个分片) 0.600 s
合并模式(改任意一处) 0.645 s(整个超级 TU 重编)
Unity Build 增量构建时间对比
图 4-3133 Unity Build 增量构建时间对比——改一处要重编整个合并 TU@ MSVC 19.44 实测

分片模式下改一个文件只重编那一个(0.600 s);合并模式下改任何一个文件都要重编整个超级 TU(0.645 s)。单个文件看起来差别不大,但真实工程里一个超级 TU 可能是几十个源文件、好几秒——增量构建会明显变慢。

这与社区经验一致:Unity Build 在全量构建上有 30%–70% 的加速,但增量构建会变慢。推荐策略是"CI 用全量 + Unity Build,本地开发关掉 Unity Build"——两个场景各取所长。

PCH 与 Unity Build 的收益与代价对照
图 4-3234 PCH 与 Unity Build 的收益/代价对照——全量收益 vs 增量代价@ 本系列绘制

9.3 分布式构建:第三把刀

三把刀里的最后一把是分布式构建(distributed build)——把编译任务分发到多台机器并行跑。Incredibuild、distcc、goma 都是这条路。

它的账与 PCH/Unity 不同:前两者省的是单机重复劳动,分布式省的是墙钟并行度。代价是网络往返、缓存命中率、多机环境一致性。有一点要注意:模板实例化在分布式构建里是每个节点各做一遍——所以 extern template 在分布式场景下仍然有理论价值(前提是它在你的编译器上真生效,见第六章)。

9.4 三把刀的选型表

手段 全量收益 增量影响 改动量 主要风险
PCH 实测 17.8× 头改动时会重建 加 2 个选项 包含依赖不透明
Unity Build 实测 9.8× 变慢 CMake 一个属性 全局符号冲突、增量退化
分布式构建 依赖机器数 基本无 需要基建 环境一致性、成本
C++20 modules 理论最高 依赖采用顺序 大(第八章) 第三方库支持

9.5 PCH 与 Unity Build 能一起用吗?

一个常见的问题:既然 PCH 和 Unity Build 都省"头解析",能不能叠起来用?

能,但要知道它们省的是同一笔钱。 两者都在减少"头文件被解析的次数":

  • PCH 让每个 TU 不再重复解析头(每 TU 省一次);
  • Unity Build 让多个 TU 合并成一个,头只解析一遍(跨 TU 省 N−1 次)。

叠加使用时的实际效果:先靠 Unity Build 把 10 个 TU 合成 1 个,再用 PCH 让这 1 个 TU 也不解析头——理论上收益是"乘法"的,但现实中会因为两者的缓存机制互相遮蔽而打折。真要用,建议分开 A/B 测:单独开 PCH、单独开 Unity、两个都开,各量一遍,看边际收益。

冲突点(容易踩的坑):

  • 符号冲突:Unity Build 把多个 .cpp 拼在一起后,同名 static 函数、宏定义、using 声明可能撞车。CMake 的 UNITY_BUILD 会尝试规避,但不保证全覆盖。
  • PCH 的位置约束:每个 TU 必须在最前面 #include PCH——而 Unity Build 拼接后的合并 TU 里,这个"最前面"是谁的最前面,要看构建系统的拼接顺序。
  • 增量构建的交互:PCH 重建 + 超级 TU 重编如果同时发生,增量构建会非常慢(这是本节最该警惕的组合)。

一句话建议:二选一,分开量,别默认叠加。


十、收束与下一篇钩子

10.1 全篇账本总汇

OK,收尾了。把这一篇实测出来的账合在一起:

实验 指标 读数
空程序 vs 模板程序 前端 c1xx 0.0142 s → 0.7755 s(×55)
同一模板,用 2 成员 vs 100 成员 .obj 2,913 B → 65,361 B(×22)
空程序 vs 模板程序 .obj .text 3 B → 25,559 B
递归模板深度 10 → 500 前端 c1xx 0.0158 s → 0.0466 s(×2.9,亚线性)
SFINAE / if constexpr / concepts 前端 c1xx 0.1171 / 0.0894 / 0.0981 s
extern template MSVC 10 TU 1.093 s → 1.058 s(未生效)
extern template GCC 10 TU 3.283 s → 2.422 s(−26.2%,生效)
constexpr 递归深度上限 默认选项 500 通过 / 511 失败
纯 constexpr vs 纯运行时 前端 c1xx 0.0180 s vs 0.0142 s(+27%)
PCH 前后 前端 c1xx 0.6639 s → 0.0372 s(×17.8)
Unity Build 前后 全量墙钟 6.341 s → 0.645 s(×9.8)
Unity Build 增量 墙钟 0.600 s → 0.645 s(变慢)

这张表是本篇所有实测的浓缩。横着读它,你会发现一个规律:收益最大的手段(PCH ×17.8、Unity ×9.8)都跟"头解析"有关,而不跟"模板实例化"有关。 这不是巧合——第九章的那个 O(n×m) 才是编译时间的真正大头,模板实例化只是第二大头。这也解释了为什么很多团队"优化了模板却没什么感觉":方向偏了。

全篇读数总表
图 4-3335 全篇实测读数总表@ MSVC 19.44 / g++ 16.2.0 实测

10.2 全量清单:本篇实测过的所有编译期手段

先把本篇出现过的编译期手段一条不漏地列全(全量清单),再在 10.3 做二次归类:

  1. cl /Bt+——分阶段时间观测(前端/后端/链接)
  2. vcperf + WPA——ETW 级构建追踪
  3. MSBuild /bl——结构化构建日志
  4. Build Insights 模板视图——实例化热点排序
  5. dumpbin /summary——段尺寸
  6. dumpbin /symbols——符号表(数实例化)
  7. extern template——抑制隐式实例化
  8. 显式实例化 template struct X<int>;——强制全量生成
  9. if constexpr——编译期分支,减少实例化面
  10. concepts——约束检查替代 SFINAE
  11. 改非模板重载——消除实例化
  12. 类型擦除(variant/function)——收口实例
  13. PCH(/Yc + /Yu)——预编译头
  14. Unity Build——合并 TU
  15. 分布式构建——多机并行
  16. C++20 modules——模块化替代头文件
  17. /constexpr:depth、/constexpr:steps——放宽求值预算
  18. /Zi / /Z7 / /DEBUG:FASTLINK——调试信息策略
  19. /OPT:ICF 与 COMDAT 去重——链接期折叠(被动发生)

10.3 二次归类:按"省的是哪笔钱"分四档

上面 19 条不是同一个层面的东西,混在一起比较没有意义。按"省的是哪笔钱"二次归类,得到四档:

档位 省的是什么 包含手段 实测代表收益
观测档 不省时间,只让你看见钱花在哪 1、2、3、4、5、6 ——(前提,不是优化)
实例化档 少生成实例代码 7、8、9、10、11、12 if constexpr −24%
解析档 少解析头文件 13、14、16 PCH ×17.8、Unity ×9.8
构建/产物档 并行度、链接期折叠、调试信息 15、17、18、19 依赖机器数/配置

这一层归类带来一个清晰的行动顺序:

  1. 先观测(档一)——不知道钱花在哪,任何优化都是猜。
  2. 再判断是"实例化贵"还是"解析贵"(档二 vs 档三)——这两档的解法完全不同,选错了白干。
  3. 最后才动构建与产物(档四)——它们收益大但改动也大,且容易引入增量退化、符号冲突等副作用。

10.4 五条可以带走的结论

  1. 模板实例化的账记在前端(c1xx.dll):判断"是不是模板问题",先跑 cl /Bt+ 看前端时间占比。
  2. 隐式实例化是按成员惰性的:编译器不为你写的模板付费,只为你用的成员付费(实测 4 符号 vs 101 符号)。
  3. "新机制"不等于"更快":实测里 if constexpr 比 concepts 更快;extern template 在 MSVC 上没生效。
  4. PCH 是本篇性价比最高的手段:加两个选项换来 17.8 倍,但要接受"包含依赖不透明"的代价。
  5. Unity Build 要看场景:全量构建快 9.8 倍,增量构建反而变慢——CI 开、本地关。

10.5 本篇边界

本篇只讲编译期——编译器把源码翻译成 .obj 时花的钱。不涉及:

  • 优化器做了什么选择(内联决策、向量化、UB 利用):这是下一篇 ep05 的主题。
  • 链接器层面的账(LTO/PGO 的收益从哪来):ep07 讲。
  • 运行时的分发代价(虚函数、异常、RTTI):ep08–ep09 讲。

把这一篇的方法论压缩成一张可以贴在显示器旁的清单——每一步都对应本篇的一处实测证据,照着走就能在你自己的工程里复现一遍。这张清单也是本系列每一篇的通用套路:先量、再定位、后改、改完再量。

编译期自检清单
图 4-3436 编译期账本自检清单——六条可直接抄走的检查项@ 本系列绘制

资源清单

下篇预告

【C/C++ 现代语言与底层】(五)优化器的边界:UB、严格别名与向量化报告——编译器为什么能把你写的循环变成一条 SIMD 指令?-ffast-math 省下的那些时间是从哪"偷"来的?未定义行为(undefined behavior)到底给了优化器多大的自由?用 /Qvec-report 与反汇编把优化器的每一次决策摊开来看。

希望能对大家有所帮助。下一篇见。


本文由@溯光 出品 站点:https://yyy3d.online/blog/ | 本系列:https://yyy3d.online/blog/#topic-cpp-language-foundations