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

分发的代价:虚函数、模板、variant 与类型擦除

2026-10-1315,590 字36 张配图

本文由@溯光 出品 站点:https://yyy3d.online/blog/ | 本系列:https://yyy3d.online/blog/#topic-cpp-language-foundations 四种把类型藏起来的办法:模板/虚函数/variant/类型擦除,以及各自的代价落点 题图 四种把类型藏起来的办法:模板/虚函数/variant/类型擦除,以及各自的代价落点 @ 本系列绘制


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


背景与边界

上一篇算的是构建期这笔账:LTO 拆翻译单元边界,PGO 拆类型与分支边界。这一篇把它反过来问。

把类型藏起来,要付多少运行时代价。

先来看看"把类型藏起来"是 C++ 里最常用的一类设计手法:写一个接受"任意可调用对象"的函数、写一个能装下多种形态的容器、写一个不暴露实现细节的接口。模板、虚函数、std::variant、类型擦除,四种办法都能做到这件事,而它们的代价不在同一个地方。

0.1 一句话结论先放这里

  • 代价有四层:间接调用、分支预测、堆分配、类型检查。四种办法各踩其中几层,不是一条统一的账单。
  • 同一档位的代价,取决于编译器能不能把它划掉。实测里最刺眼的一处:同一份单态虚调用的代码,GCC -O2 把它划成了 0(与直调同速),MSVC /O2 没划掉(1.618 vs 0.766 ns/op)。
  • 所以"哪个更快"这个问题,脱离编译器与标准库版本是无法回答的。本篇给出同一份代码在四组配置下的排序——它们互不相同。

0.2 实测环境

项 值
MSVC cl / link / dumpbin 19.44.35228(工具集 14.44.35207),/std:c++17
GCC MSYS2 ucrt64 g++ 16.2.0 + objdump
计时 被测进程内 std::chrono::steady_clock,每档跑 5 次取中位
负载 4096 元素 × 20000 轮 ≈ 8,192 万次分发
平台 Windows x64,PE/COFF

0.3 本篇不涉及什么(边界声明)

  • 不涉及微架构预测器的内部实现。分片预测、TAGE、gshare 这些分支预测器的具体结构不在本篇范围——本篇只说"这一层的代价存在,并且可以量"。
  • 不涉及 C++ 入门教学:不解释什么是虚函数、什么是模板。需要前置的朋友请看本系列 ep01(对象布局)与 ep04(编译期账本)。
  • 不做 Clang/LLVM 的本机实测:本机无 Clang、无 WSL。LLVM 的 WholeProgramDevirt、ThinLTO 只引官方原文。
  • 不给"用 XX 替代虚函数"的通用建议。本篇给判据与读数;建议必须绑定具体场景。

0.5 按需跳读

本篇有 11 章,但不是每一章都对所有读者有用。三条路线:

  • 只想知道"我该选哪个":读 §一(四种办法对照)、§四(阶梯表)、§九(判据与建议)——三章,约 2,500 字。
  • 正在优化某段热路径:加读 §五(目标确定性)与 §六(多态分发)。这两章才是选型的真正依据。
  • 想学方法(怎么自己量):读 §三(实测设计)、§七(分配与阈值怎么量)、§十(证据形态与复现)。

如果你这次只想知道"std::function 到底慢在哪",直接跳到 §7.1——那一节的结论可能会推翻你的预期。

0.6 这一篇与上一篇的接续

好了,上一篇(ep07)算的是构建期这笔账:LTO 把"看不见"变成"看得见",PGO 把"猜"变成"验",两者各自拆掉一条边界。

这一篇把镜头切到运行期,问一个更常见的问题:当你主动把类型藏起来时,代价从哪来?

两篇之间的接口在 §4.5:ep07 的靶子是"编译器看不见"(跨 TU),本篇的靶子是"编译器看得见但不敢定"(运行期多变)。两种靶子对应两种不同的优化——LTO 治前者,去虚拟化治后者——所以两篇的读数不会重复,也不该混着用。

0.7 为什么这一篇要用"阶梯"而不是"结论"

说白了,网上关于这个话题的文章,通常以一张排序表收尾:模板最快、虚函数次之、std::function 最慢。这张表在某个特定环境里是对的,但它把三个变量压成了一个数字:编译器版本、标准库实现版本、以及代码的具体形状。

本篇不这么做。本篇把被分发的操作故意做到极小(一条 x+1 之类的表达式),然后把 11 种分发办法放在同一份负载上跑,再把四组配置的结果并排摆开。这样做的结果是:排序表变成了阶梯表,而阶梯的形状会随列变化——那个变化本身才是本篇要讲的东西。


一、四种把类型藏起来的办法

四种办法的差别不在语法糖上,而在类型信息在什么时候被丢掉。模板根本不丢类型,它把类型编进代码;虚函数把类型压缩成一个 vptr,运行期再查回来;variant 把类型压缩成一个整数索引,就地存放;类型擦除最彻底,它连存的是什么都不保留,只留一个函数指针。下面这张对照表先把四者的形态摊开,后文所有读数都是它逐行展开的结果。

四种机制的代码形态对照与代价来源
题图1 四种机制的代码形态对照与代价来源@ 本系列绘制

1.1 模板:把类型写进代码里

/* 模板 —— 类型在编译期完全确定 */
struct IncPolicy { int operator()(int x) const { return x + 1; } };
template <class P> long long run(P f, const int *p, int n) {
    long long s = 0;
    for (int i = 0; i < n; ++i) s = f((int)s + p[i]);   /* 目标已知 → 可直接内联 */
    return s;
}

言归正传,模板不"藏"类型,它把类型编进代码。每个 P 生成一份独立的函数,调用点看到的是完整定义,于是内联、常量传播、向量化都照常进行。代价转移到了别处:代码体积与编译时间(这是 ep04 的主题)。

1.2 虚函数:把类型藏进 vptr

/* 虚函数 —— 目标在运行时才知道 */
struct Op { virtual int apply(int x) const = 0; virtual ~Op() = default; };
struct A : Op { int apply(int x) const override { return x + 1; } };

long long run(const Op *op, const int *p, int n) {
    long long s = 0;
    for (int i = 0; i < n; ++i) s = op->apply((int)s + p[i]);   /* 取 vptr → 取槽位 → 间接调用 */
    return s;
}

好了,对象里多了一个 vptr,调用时多两步取值和一次间接跳转。这一层代价能不能被消掉,取决于编译器能不能证明 op 的动态类型——这是本篇最重的一处变量(§五)。

1.3 std::variant:把类型藏在索引里

std::variant<A, B, C, D> 就地存放其中一个备选,不碰堆;访问用 std::visit,实现上是一次基于 index() 的分支。它的语义由 WG21 提案 P0088R3与 工作草案的 variant 章定义——注意访问失败抛的是 std::bad_variant_access,而不是 bad_any_cast 那一套。它的代价落在分支而不是间接调用上——这是一个重要的区别,因为两者的优化路径完全不同。

1.4 类型擦除:把手写 vtable 包装起来

std::function、std::any、以及手写的 Erased 结构,本质是同一个手法:先擦掉类型,再通过一个函数指针把它调回来。

/* 手写类型擦除 —— std::function 的最小内核 */
struct Erased {
    const void *obj;
    int (*fn)(const void *, int);
    int call(int x) const { return fn(obj, x); }   /* 一次间接调用 */
};
template <class T> int erased_thunk(const void *o, int x) { return ((const T *)o)->operator()(x); }

这一层额外多出两个问题:对象要放在哪(栈上的本地缓冲,还是堆),以及类型对不对(any_cast 要检查)。

1.5 四种办法在一张表里

办法 类型确定时机 代价落点 能不能内联
模板 / CRTP 编译期 代码体积、编译时间 能
虚函数 运行期(vptr) 间接调用、分支预测 只在被去虚拟化时
std::variant 运行期(索引) 分支、代码体积 命中分支可内联
类型擦除 运行期(函数指针) 间接调用 + 可能的堆分配 + 类型检查 只在被去虚拟化时

这四种办法可以按类型确定的时机归类:模板在编译期就把类型定死;另外三种都拖到运行期,各自靠 vptr、索引、函数指针来记类型。运行期这三种还能按代价落在哪一层再分一次——虚函数与类型擦除落在间接调用上,variant 落在分支预测上,而只有类型擦除额外背上堆分配与类型检查这两笔(§二会把这四层逐一拆开)。


1.6 同一段业务代码里的四种写法

上面四个片段是"最小形态"。放到一段真实业务里,选择会具体成这样:

假设要写一个"对一批数据做一次可配置的归一化"的函数。四种写法分别是:

  • 模板:template <class Norm> void normalize(Norm n, Batch &b)。调用方在编译期把 Norm 定下来;每种归一化生成一份代码。加一种归一化要重编,但当天就能上线。
  • 虚函数:void normalize(const Norm &n, Batch &b),Norm 是抽象基类。加一种归一化只要实现一个子类、不用重编主程序——这正是插件与热更新的基础。
  • std::variant:void normalize(const NormVariant &n, Batch &b)。归一化的种类是有限且已知的(比如只有三种),那就不该为它开一条继承链。
  • 类型擦除:void normalize(const Callable &n, Batch &b),Callable 是 std::function 那类包装。调用方随手传个 lambda 就行,代价是 §七那一整套。

这段对照的真正用处,是把"选型"翻译成四个问题:归一化的种类会不会增长?增长时能不能停机?被归一化的数据量有多大(是不是热路径)?这段代码一年会被改几次?

本篇后面所有读数,都是为了回答第三个问题服务的。 前三个问题属于工程约束,不是性能问题——但它们决定了你是否需要关心性能。

1.7 术语约定

本篇反复用到几个词,先统一口径,避免后文歧义:

词 本篇口径
分发 从调用点到被调函数体之间的那一段"找目标并跳过去"的过程
目标确定性 编译器能否在编译期或链接期把被调目标定为唯一值
划掉 去虚拟化成功:间接调用被替换成直接调用并可被内联
账单 分发带来的可测代价,分四层(§二)
monostate 全程只走一个实现的写法,用来测"目标唯一"这一支
poll4 / 多态轮换 4 个实现轮流上场的写法(tab[i & 3]->apply(...)),用来模拟"目标真的会变"

"划掉"这个词不是本文生造的——GCC 手册里对这个动作的表述是"把虚函数调用转换为直接调用",LLVM 那边的 pass 名字直译过来就是"整程序去虚拟化"。本篇用"划掉",只为在后文反复指代时更短。

二、代价的四层账单

把类型藏起来这件事,常被笼统地说成只有一次间接调用;但间接调用只是四层里最薄的那一层。实测里真正拉大差距的往往是另外三层,而且它们各有各的取证方式。这张图先把四层与各自的量法对应起来,后面每一章都会回到这四层上来对账。

分发代价的四层账单:间接调用/分支预测/堆分配/类型检查
图 8-12 分发代价的四层账单:间接调用/分支预测/堆分配/类型检查@ 本系列绘制

2.1 第一层:间接调用本身

说白了,取 vptr、取槽位、call。这几条指令的开销是固定的、可数的——在一段几百条指令的循环体里几乎看不见。

所以只算这一层,会得出"虚函数几乎不花钱"的结论。 这个结论在"一次调用背后有几十上百条指令"的业务代码里是对的(控制反转的成本被摊薄),在本篇这种"被分发的操作只有一条 x+1"的极端情形里是错的。

2.2 第二层:分支预测

间接跳转的目标要到运行时才知道,CPU 只能用历史去猜。猜对了几乎免费,猜错了要冲刷流水线。

这一层的代价不体现在指令数上——两种写法可以生成完全相同的指令序列,只因为运行时目标是否稳定而差出成倍的时间。本篇 §6.3 的 monostate → poll4 就是这个变量:指令没变多,时间涨了 36%(MSVC)。

2.3 第三层:堆分配

std::function 与 std::any 都可能把被擦除的对象放到堆上。这一层有两笔账:

  • 分配本身(malloc/free 的固定开销);
  • 缓存局部性(对象散落在堆上,每次调用都可能是一次 cache miss)。

本篇用重载全局 operator new 计数把第一笔账量出来(分配次数逐档可见),第二笔账则体现在时序上。

2.4 第四层:类型检查

std::any 的 any_cast 必须验证被擦除的类型是不是你要的那个;std::variant 的访问要处理索引不匹配。这一层在 std::any 上是主要成本——本篇实测它是整个阶梯最慢的一档。

2.5 为什么必须分层

因为一层快不代表整体快,一层慢也不代表整体慢。最典型的是 std::function:它的第 1 层(间接调用)与手写擦除完全一样,第 3 层(堆分配)在小捕获时可以完全避免,但它仍然比手写擦除慢——差别在第 1 层能不能被划掉。下面的实测会把这件事逐层拆开。


2.6 为什么"框架代码里的分发"通常不贵

有一个现象值得提前说清,否则本篇的读数会让人过度紧张:大多数业务代码里,虚函数或 std::function 的开销是可以忽略的。

原因是代价摊薄:

  • 如果一次调用背后的函数体有 200 条指令,那 3 条分发指令的占比不到 2%——测不出来;
  • 如果那个函数体本身还要访问内存、加锁、序列化,分发的 3 条指令更是完全淹没在访存延迟里。

反过来,本篇的用例把函数体压到一条 x+1,于是分发的占比接近 100%。这个极端设置是故意的:只有把业务逻辑剪掉,才能让分发的账单显形。

所以本篇的结论要这样用:

  • 读到的倍率(2.9 倍、7 倍)是分发在总耗时里的占比上限,不是"换成虚函数就会慢 7 倍";
  • 真正要问的是"我的这段热路径里,函数体有多重"——函数体越轻、调用越密,本篇的读数才越接近你的现实。

这也是本篇为什么不用"典型业务负载"做基准:用真实业务做基准,测出来的是"这个业务逻辑有多重",不是"分发有多贵"。要量工具的成本,就得把工具单独拿出来量。

2.7 四层账单的取证手段对照

四层里有两层可以"数",两层只能"测"。这张对照表是后面所有结论的取数依据:

层 能数吗 怎么取
一 · 间接调用 能数 反汇编里数 call qword ptr [..] / call *(%r..)
二 · 分支预测 不能数 只能靠对照实验(同一份代码,换目标分布看时间差)
三 · 堆分配 能数 重载全局 operator new 计数
四 · 类型检查 部分能数 看反汇编里有没有类型比较分支;失败路径只能靠异常验证

"不能数"的那一层才是最容易骗人的。 一份代码可以做到"指令数与直调完全相同、分配次数为 0",却因为间接跳转的目标分布而慢一倍——任何只看静态指标(指令数、分配数)的评估方法,都会漏掉这一层。

三、实测方法:把被分发的操作做到极小

下面进入要量分发本身的成本,必须先让它成为唯一变量。这一节交代三件事:被分发的操作被压到多小、负载为什么写成串行依赖链、以及每一档同时取哪三类数据。方法本身比读数重要——读数会随机器变,方法可以搬走。

实测设计:极小操作、串行依赖链与三类取数
图 8-23 实测设计:极小操作、串行依赖链与三类取数@ 本系列绘制

3.1 三条设计原则

原则一:被分发的操作必须极小。如果每个 apply 里有 50 条指令,那 50 条会把 3 条的分发开销压成噪声。本篇四个实现的函数体都只有一条表达式。

原则二:负载必须一致。11 档共用同一份 4096 元素的伪随机数组、同样 20000 轮、同样的累加器形状(s = f(s + p[i]) —— 一条串行依赖链,这样编译器无法把循环并行化掉,分发就真的在关键路径上)。

原则三:不靠单一读数下结论。每档同时取三类数据:ns/op(时间)、g_allocs(堆分配次数)、以及反汇编里的间接调用条数(结构)。当时间读数互相矛盾时,以结构性证据为准——这是 ep07 学过的方法。

3.2 负载与口径

/* 统一负载:4096 元素 × 20000 轮 ≈ 8192 万次分发 */
for (int r = 0; r < rounds; ++r)
    for (int i = 0; i < n; ++i)
        s = dispatch(s + p[i]);        /* 串行依赖链:分发在关键路径上 */
/* 堆分配计数:重载全局 operator new */
long long g_allocs = 0;
void *operator new(std::size_t n) { ++g_allocs; void *p = std::malloc(n ? n : 1); if (!p) throw std::bad_alloc(); return p; }
void operator delete(void *p) noexcept { std::free(p); }
/* 反汇编计数:dumpbin /disasm(MSVC)与 objdump -d(GCC)里数间接 call */
/* 计数口径:call qword ptr [..] / call *(%r..) 记为间接;call ?name@@.. 记为直接 */

3.3 一个必须先说清的前提

好了,MSVC 默认是 C++14。<variant> 与 <any> 在 C++14 下会直接报 STL4038:

C:\...\include\any(12): warning STL4038: The contents of <any> are available only with C++17 or later.

加 /std:c++17 才能编。 本组实测的第一个"坑"就是它——不显眼,但会让整轮对照跑不起来。

3.4 为什么用"串行依赖链"而不是"独立求和"

负载写成了 s = f(s + p[i]) —— 每一步都依赖上一步。这不是随手写的,有两个理由:

  1. 挡住优化器的自动并行化。如果写成 s += f(p[i]),各次调用互相独立,编译器(或 CPU 的乱序执行)可以把多次迭代重叠起来,分发的延迟就被完全隐藏,测出来的是吞吐而不是延迟。串行链让每一次分发都落在关键路径上。
  2. 让"延迟"成为被测对象。本篇关心的是"跳过去要多久",这是延迟问题;独立写法测的是吞吐。两者都真实,但只有延迟能反映分发本身的成本——因为吞吐可以被流水线掩盖。

口径提醒:本篇所有 ns/op 都是串行链上的延迟口径。如果你的代码里分发彼此独立,实际影响会小于本篇读数——这是又一次"别把极端用例的倍率直接搬回家"。

3.5 顺带踩到的第二个坑:any_cast 的类型必须完全匹配

第一版用例里写了 std::any a = [k](int x){ ... };,然后用 std::any_cast<std::function<int(int)>>(a) 取回来。这在运行期直接抛 std::bad_any_cast,进程以 0xC0000409 退出(STATUS_STACK_BUFFER_OVERRUN,MSVC 下未捕获 C++ 异常的典型退出码)。

原因是 std::any 保存的是你给它的那个确切类型——它存的是 lambda 的闭包类型,不是 std::function。要按 std::function 取,就得先按 std::function 存:

/* 错:存的是 lambda 闭包类型,按 std::function 取会抛 bad_any_cast */
std::any a = [k](int x) { return x + k; };
s = std::any_cast<std::function<int(int)>>(a)(s + p[i]);

/* 对:存什么类型,就按什么类型取 */
std::any a = std::function<int(int)>([k](int x) { return x + k; });

顺手给每一档加了 try/catch,让一档失败不至于把整轮实测带走。这条坑的形态值得记:any 的"通用"是有条件的,它的运行时类型检查正是它的第四层代价——这次是它把程序打死的。


3.6 复现需要的最小材料

全部复现材料只有四件东西,可以整理进一个目录直接跑:

ep08recon/
├── iface.h       /* 四个虚函数实现 + 11 个入口声明 + 分配计数器 */
├── ops.cpp       /* 重载全局 operator new 做分配计数 */
├── bench.cpp     /* 11 档实现 + SBO 阈值探针 */
└── main.cpp      /* 统一负载与输出格式(ROW 行 + SBO 行) */

构建命令(两套工具链各一条,都必须 C++17):

MSVC :  cl /nologo /O2 /std:c++17 /EHsc /MD main.cpp ops.cpp bench.cpp /Fe:o2.exe
GCC  :  g++ -O2 -std=c++17 -o o2.exe ops.cpp bench.cpp main.cpp

输出是两段机读格式,可以直接用脚本解析取中位:

ROW <档位名> <毫秒> <ns/op> <分配次数> <累加和>
SBO <首次发生堆分配的捕获字节数>

"机读格式"这一点值得单说:如果只打印人类可读的表格,做 5 次取中位就得手工抄数;把每档输出写成一行固定格式、带 ROW 前缀,取中位、算比值、生成面板都可以脚本化。本篇那 44 个读数都是这么来的。

四、阶梯结果:四组列的排序互不相同

4.1 MSVC 的两列

MSVC 这一侧先给出两列:只开 /O2,以及再叠上 /GL + /LTCG。两列并排是为了回答一个很自然的问题——把链接期优化也开了,这一篇的阶梯会变样吗? 读数给出的答案是否定的(§4.5 会解释为什么),但只有并排看才敢下这个结论。

MSVC 阶梯读数:/O2 与 /O2+/GL+/LTCG 两列共 11 档
图 8-34 MSVC 阶梯读数:/O2 与 /O2+/GL+/LTCG 两列共 11 档@ 本机实测(MSVC 19.44.35228)
档位                        MSVC /O2    MSVC /O2+/GL+/LTCG
direct(编译期已知)           0.766           0.768
template(策略模板)           0.768           0.772
fnptr(裸函数指针)            0.770           0.773
variant+visit(单态)          1.054           1.098
manual-erasure(手写擦除)     1.316           1.342
virtual-monostate             1.618           1.710
virtual-poll4                 2.208           1.962
std::function(大捕获,堆)     2.343           2.399
std::function(小捕获,SBO)    2.394           2.396
variant-poll4                 2.421           2.375
std::any                      5.897           5.874

4.2 GCC 的两列

同样的 11 档,换到 GCC 的 -O2 与 -O2 -flto。这一列是本篇最反直觉的一列:前六个档位的读数几乎并排落在 0.77 附近——包括虚函数与类型擦除。看这一列时请特别留意 virtual-monostate 那一行。

GCC 阶梯读数:-O2 与 -O2 -flto 两列,前六档并排在 0.766–0.772
图 8-45 GCC 阶梯读数:-O2 与 -O2 -flto 两列,前六档并排在 0.766–0.772@ 本机实测(g++ 16.2.0)
档位                        GCC -O2     GCC -O2 -flto
direct(编译期已知)           0.772           0.763
template(策略模板)           0.771           0.766
fnptr(裸函数指针)            0.766           0.768
variant+visit(单态)          0.766           0.773
manual-erasure(手写擦除)     0.768           0.774
virtual-monostate             0.772           0.771
variant-poll4                 1.058           1.115
std::function(小捕获,SBO)    1.613           1.312
std::function(大捕获,堆)     1.341           1.602
virtual-poll4                 2.229           2.195
std::any                      5.409           5.532

4.3 四列并排:排序真的会变

四组列并排:排序真的会变
图 8-56 四组列并排:排序真的会变@ 本机实测(两套工具链)

把它并成一张表可以看出三件事(数值见上面两张面板):

  1. GCC 侧的前六档几乎并列在 0.766–0.772:direct、template、fnptr、variant+visit、manual-erasure、virtual-monostate 全部被压到同一个数——它们在这份代码里被 GCC 划成了同一种东西。
  2. MSVC 侧同一批档位被拉开到 1.054–2.394,中间隔着 variant+visit(1.054)、manual-erasure(1.316)、virtual-monostate(1.618)。
  3. 两套工具链唯一一致的是"底"与"顶":direct 那一路都在 0.77 附近,std::any 那一路都在 5.4–5.9——中间那一段的排序,是编译器决定的,不是语言决定的。

4.4 为什么排序会变:三个变量

  • 编译器版本与优化策略:GCC 在 -O2 就启用推测性去虚拟化;MSVC 的去虚拟化与 final 标注挂钩(§5.3)。
  • 标准库实现版本:std::visit 在 libstdc++ 里走过一次实现换代(从跳转表改成"分支数少就用 switch"),公开实测显示这一改会让 variant 与虚函数的相对快慢翻转。
  • 代码形状:本篇的对象是"编译单元里唯一且已知"的常量,所以 GCC 敢划掉;换成来自构造参数、跨编译单元的指针,结论就会变。

这三条加起来说明一件事:把"哪个更快"当成语言知识去背,是会背错的。该背的是判据(§5.5、§6.5),不是排序。


4.5 为什么 LTO 在这一篇几乎没帮上忙

第三列与第四列(/GL+/LTCG 与 -flto)的读数与基线相差很小,这与 ep07 里"LTO 让跨 TU 调用快 7 倍"形成了鲜明对比。为什么?

因为这一篇的用例里没有跨 TU 问题:

  • 四个实现(A/B/C/D)与三个 run 函数都在同一个编译单元里;
  • 没有"小函数被 TU 边界挡住"的情形;
  • LTO 能做的跨模块内联,在这里本来就已经能做。

ep07 的靶子是"看不见"(编译 b.cpp 时看不见 a.cpp);本篇的靶子是"知道但不肯定"(看得见类型,但类型在运行期会变)。两种靶子对应两种优化:LTO 治前者,去虚拟化治后者。 LTO 对本篇无能为力,恰好反证了这一点。

4.6 一个附带结论:优化开关的组合不是"越多越好"

四列里唯一出现"LTO 反而更慢"的位置是 MSVC 的 virtual-monostate(1.618 → 1.710)与 std::function 两档(2.394→2.396、2.343→2.399)。差异都在 5% 以内,属于抖动范围。

但它提醒了一件事:本篇的阶梯表里,没有任何一档是因为"多开了一个优化开关"而变快 2 倍以上的。真正的差距来自代码形状(目标是否唯一)与实现路径(标准库怎么写 visit)。优化开关能拿的收益是有上限的;代码形状决定的那部分,开关拿不到。

五、决定性发现:同一份单态虚调用,GCC 划掉了、MSVC 没划掉

5.1 读数

先来看看这一处差异是本篇最值得细看的一组数字。表里四行分别是两套工具链各两个构建下的 direct、virtual-monostate 以及两者的比值。比值那一列才是重点——它把虚函数到底要不要付钱这个问题,变成了一个可比较的数。

单态虚调用:GCC 1.00× 与 MSVC 2.11× 的去虚拟化差距
图 8-67 单态虚调用:GCC 1.00× 与 MSVC 2.11× 的去虚拟化差距@ 本机实测(两套工具链)
                             direct     virtual-monostate     比值
GCC -O2                       0.772           0.772           1.00×
GCC -O2 -flto                 0.763           0.771           1.01×
MSVC /O2                      0.766           1.618           2.11×
MSVC /O2 + /GL + /LTCG        0.768           1.710           2.23×

GCC 那一行是 1.00×。同一份代码、同一个负载,virtual-monostate 与 direct 的时间完全相同。

这说明 GCC 把那个虚调用完全去虚拟化并内联了:static A a; 的动态类型在编译单元内是唯一且已知的,op->apply 的目标可以静态确定。

5.2 反汇编证据

说白了,读数只能说明快了,不能说明被划掉了。要区分这两种情况,必须回到汇编上看调用点的形态:间接调用还在不在、被调函数体有没有被展开进循环。下面两张面板分别是 MSVC 与 GCC 在同一档上的反汇编片段,并排看它们,差异是可见的。

读数只是"更快了"。要看它是不是"划掉了",得看汇编。

MSVC /O2 单态虚调用的反汇编:间接调用未消掉
图 8-78 MSVC /O2 单态虚调用的反汇编:间接调用未消掉@ 本机实测(dumpbin /disasm)
GCC -O2 同一档的反汇编:已被划掉并直接内联
图 8-89 GCC -O2 同一档的反汇编:已被划掉并直接内联@ 本机实测(objdump -d)

判定方式与 ep07 一致,看命中路径上还有没有间接 call:

构建 循环体里对 apply 的调用形态
MSVC /O2 取 vptr → 取槽位 → 间接 call(未消掉)
GCC -O2 无间接 call(已内联进循环)

5.3 官方口径对得上

去虚拟化的官方开关原文:GCC/GCC Internals/LLVM/微软
图 8-910 去虚拟化的官方开关原文:GCC/GCC Internals/LLVM/微软@ 官方文档原文摘录
  • GCC:-fdevirtualize把虚调用转成直接调用,既在过程内做,也是过程间常量传播与间接内联的一部分;-fdevirtualize-speculatively把调用转成"直接 vs 间接"的条件分支,在 -O2/-O3/-Os 下启用。GCC Internals 还写明 pass_ipa_devirt 会基于类型继承图判断"某多态调用在编译单元内是否只有一个可能目标"——本篇的 static A a 恰好命中这个条件。
  • LLVM:WholeProgramDevirt在类型集合固定时做整程序去虚拟化,手段之一正是用 vptr 与目标 vtable 地址比较来替换虚调用;LLVM 开发者会议的讲稿给出了 profile 驱动的调用点形态 if (get_class_id(receiver) == expected) call expected else deoptimize。
  • MSVC:微软 C++ Team Blog明确 devirtualization 是"在编译期而非运行期解析虚函数调用",并指出 final 标注有助于去虚拟化。

顺便一个量级参照:一份公开计时给出 virtual 28.86 ms / template 36.19 ms / std::function 150.56 ms(出处),量级关系与本篇一致。三条口径合起来解释了两边的差异:GCC 在 -O2 就默认启用推测性去虚拟化,而 MSVC 侧把去虚拟化的触发条件与 final 标注绑得更紧。同一份代码要 MSVC 划掉,通常需要给它一个更强的承诺。

5.4 一个同源现象:手写擦除与 variant 也被划掉了

GCC 侧被划掉的不止虚函数:

档位 GCC -O2 与 direct 之比
manual-erasure 0.768 0.99×
variant+visit 0.766 0.99×
virtual-monostate 0.772 1.00×

三个"运行时分发"的档位全部被压到直调水平。原因相同:擦除本身不是开销,开销来自"编译器不知道目标是谁"。

  • manual-erasure 里 Erased::fn 被初始化为 &erased_thunk<IncT>,而 IncT 是个 static 常量对象——函数指针是编译期常量;
  • variant 里备选只有一个 A,index() 恒为 0——分支被折叠掉。

这就是本篇最值得记住的一条:类型擦除、虚函数、variant 都不是"天生慢",它们是"要求编译器在运行期做它本可以在编译期做完的决定"。能不能把这个决定收回去,是编译器的能力,也是代码形状给的线索。

5.5 判据(第一条)

把这一章收成一条可以在代码评审里直接问的判据。判据的形态刻意做成先问什么、再问什么,因为顺序反了很容易得出错误的结论——先怀疑分发、再去看目标是否确定,往往会把本来免费的东西优化一遍。

去虚拟化的判据:先问编译器能不能确定目标
图 8-1011 去虚拟化的判据:先问编译器能不能确定目标@ 本系列绘制

判据一:如果你在意分发开销,先问"编译器能不能确定目标"。能确定 → 三档(擦除/虚函数/variant)都可能是零成本;不能确定 → 再看下一节的图。


5.7 怎么把"目标确定性"变成代码事实

§5.1 的差异说明:"目标确定"这件事不是一个抽象概念,它有具体的代码写法。 想让编译器拿到确定性,有四种立即可用的手段:

手段 为什么有用 代价
给类加 final 告诉编译器"这里不会再有第五个子类" 放弃再继承(多数接口类本来就该如此)
把对象放在编译单元局部的匿名命名空间 让"这个类型的实例只有这一处"成为可证事实 该类型不能被外部引用
用值语义而非指针 对象的动态类型就是静态类型 需要拷贝或移动(ep03 的账)
密封继承层次(enum + switch,或 variant) 把"候选集合"变成编译期可见的有限集 加一种形态要改中心代码

第一条是本篇性价比最高的建议:final 是零运行时成本的标注,而微软官方明确它有助于去虚拟化。"这个类会不会再被继承",本该在写它的时候就有答案 —— 如果答案是不会,就别把这个信息藏着。

第 2 条值得单独提一句:本篇 §5.1 那个被 GCC 划掉的用例,之所以能被划掉,正是因为 static A a; 让 A 的实例在编译单元里唯一。这是"代码形状给编译器线索"的最直白例子——把对象改成从构造参数传进来、或从另一个编译单元取的指针,GCC 也就划不掉了。

5.8 为什么 GCC 更敢划:一个设计取向的解释

言归正传,官方口径(§5.3)显示,GCC 把推测性去虚拟化的开关默认开在 -O2,并且由 pass_ipa_devirt 基于类型继承图自动判断;LLVM 的 WholeProgramDevirt 也走类似的"类型集合是否固定"的判断;而 MSVC 的公开表述把去虚拟化与 final 这类显式承诺绑得更近。

这三种取向没有绝对优劣,它们反映的是不同的风险偏好:

  • 激进划掉:收益是免费的加速,风险是"如果判断错了会怎样"——所以这类优化都带一个"守卫"或"去优化"回退路径;
  • 保守不划:收益少一点,但行为更好预测——"我写了虚调用,它就是虚调用"。

对使用者的意义是一致的:别假设编译器会替你把这一层划掉,也别假设它不会。想确认,就去看反汇编(§5.2);想争取,就给它承诺(§5.7 的四种手段)。

六、多态分发:去虚拟化救不了的那一档

6.1 读数

先来看看把目标从 1 个换成 4 个轮换,读数会怎么变?这一节的两行数字回答了这个问题。注意两套工具链的读数几乎一样——这是全篇唯一一处两边高度一致的档位,而它恰好也就是去虚拟化帮不上忙的那一档。

多态虚调用:两套工具链都是 direct 的约 2.9 倍
图 8-1112 多态虚调用:两套工具链都是 direct 的约 2.9 倍@ 本机实测(两套工具链)
                             MSVC /O2     GCC -O2     与 direct 之比
virtual-monostate              1.618        0.772       2.11× / 1.00×
virtual-poll4                  2.208        2.229       2.88× / 2.89×

这一栏两套工具链几乎一模一样(2.208 vs 2.229),而且都是 direct 的 2.9 倍。

原因很直白:4 个类型轮换时,op->apply 的目标每秒都在变,编译器无法把它静态确定,去虚拟化无从下手。这一档是"把类型藏起来"真正的、不打折的账单。

6.2 variant 的两种命运

多态 variant:MSVC 更慢、GCC 快一倍(同一行两侧相反)
图 8-1213 多态 variant:MSVC 更慢、GCC 快一倍(同一行两侧相反)@ 本机实测(两套工具链)
                             MSVC /O2     GCC -O2
virtual-poll4                  2.208        2.229
variant-poll4                  2.421        1.058     ← 同一行两侧结论相反

同一行两侧结论相反:

  • MSVC:variant 的多态访问(2.421)比虚函数还慢。多了一次索引判定,却没换来更便宜的分发。
  • GCC:variant(1.058)比虚函数快一倍。libstdc++ 的 std::visit 在分支数少时走 switch 快路径,把间接调用消掉,命中分支还能内联。

这一处不是"谁实现得更好"的问题,而是"谁的实现路径恰好匹配这份代码"的问题(指针分发 vs variant 的另一份实测、以及 按数据分布讨论的俄文实测都指向同一结论)。公开实测里也有同向结论:一位作者的实测给出,同一份代码在 libstdc++ 从 9.5 换到 12.4 之后,variant 从 3.95 ns/call 降到 1.44 ns/call、直接翻转了与虚函数的相对快慢——根因就是 visit 的实现换代。另一份实测显示 visit 的跳转表与递归 switch 两种实现本身可以差约 4 倍。中文社区也有一句务实的总结:不存在普适的"variant 优于虚函数"结论。

6.3 单态 → 多态:分支预测那一层

单态到多态的抬升发生在分支预测层
图 8-1314 单态到多态的抬升发生在分支预测层@ 本系列绘制
单态 4 类型轮换 抬升
MSVC 1.618 2.208 +36%
GCC 0.772 2.229 +189%

两边的抬升幅度差很多,但注意它们的方向一致:只要目标开始变化,时间就上去。

关于这一层,最经典的实证是 「处理有序数组为什么比无序数组快」那篇:可预测分支几乎零代价,不可预测分支触发流水线冲刷。Agner Fog 的优化手册则把预测依据讲到了 BTB(Branch Target Buffer)这一层。这一层的关键特征是"指令没变多"。把两种写法的反汇编并排看,多态版并没有多出一大段指令——变化的是间接跳转的目标分布。这也是为什么"数指令条数"这件事在分发代价上会失效:第一层可以数,第二层只能测。

6.4 裸函数指针为什么与直调同速

说白了,函数指针写起来像间接调用,但在这一档里它测出来与直调同速。这个结果初看反常识,实际上它把语法形态与运行期事实这两件事彻底分开了——这也是本篇第二个判据的来源。

裸函数指针与直调同速:因为目标在编译期就是常量
图 8-1415 裸函数指针与直调同速:因为目标在编译期就是常量@ 本机实测(两套工具链)
                             MSVC /O2     GCC -O2
direct                         0.766        0.772
fnptr                          0.770        0.766     ← 与直调同速

这个结果乍看反常识:函数指针不也是"间接调用"吗?

因为它在这里不是间接调用。 Fn f = fp_inc; 之后 f 的值是编译期常量,编译器做一次常量传播就知道目标是 fp_inc,于是直接内联——第 1 层代价在生成代码之前就消失了。

把它和 §6.1 的多态虚调用放在一起看,就得到了本篇的第二个判据。

6.5 判据(第二条)与本章小结

判据二:间接调用是否要付账,不看它写成了什么语法,看目标在运行期是否真的会变。f 是常量 → 免费;op->apply 有 4 个候选 → 约 2.9 倍。

本章三件事合起来:

  1. 多态是不打折的那一档(两套工具链都是 ~2.2 ns/op);
  2. variant 与虚函数谁快,取决于标准库实现路径,实测里两侧结论相反;
  3. 单态 → 多态的抬升来自分支预测层,量出来的(+36% / +189%)而不是数出来的。

6.6 一个可以自己做的对照(两类别轮换)

本篇只做了两组:单态(1 个实现)与 4 类型轮换。中间还可以插一档:2 个实现轮换。

把 §6.3 的思路延伸一下,这一档值得你自己在本地跑一次,因为它回答一个很实际的问题:"我的虚调用只有两个候选"是不是就已经要付全部账了?

做法与设计要点:

  1. 保持代码形状不变,只把 tab[i & 3] 改成 tab[i & 1]——只有一个字符的差别,保证"指令数"这一项几乎不变;
  2. 同时观察两个读数:ns/op 与反汇编(间接调用条数)。如果间接调用条数没变而时间变了,你就又一次把"分支预测层"单独抓了出来;
  3. 再补一组"伪随机选择":tab[(i * 2654435761u) >> 31] 之类,让选择器彻底不可预测。单态 → 两类别 → 4 类别 → 伪随机 这四档连起来看,就能看出"目标分布的熵"与耗时的关系曲线。

本篇没有跑这一档,原因是篇幅与预算;但方法是现成的,四档加起来不到 20 行改动。这属于"读完之后能自己接着做"的部分——比记住一个倍率有用得多。

6.7 把"分发"放回整条流水线

分发的代价必须放进流水线里看才有意义。一次"取数据 → 分发 → 计算 → 写回"的循环里,分发的占比取决于另外三步有多重:

循环形态 分发占比(估算) 该关心分发吗
纯算术(本篇用例) 接近 100% 是,这是量工具成本的情形
算术 + 连续访存 10–30% 看数据量:大数组下访存会成为新瓶颈
算术 + 随机访存 / 指针链 < 10% 先把访存改连续(缓存布局的账)
算术 + 系统调用 / IO < 1% 不必:分发的账单已被完全淹没

这张表的用法是排除法:只有在最上面两行,本篇的判据才值得动用。如果在第四行还去纠结"该用虚函数还是 variant",那是在优化一个占比不到 1% 的项——这是性能工作里最常见的浪费。

七、类型擦除:SBO、堆分配与 std::any 的底

7.1 std::function 的两档

std::function 两档:零分配那档并不更快
图 8-1516 std::function 两档:零分配那档并不更快@ 本机实测(两套工具链)
                             MSVC /O2     GCC -O2     GCC -flto
std::function(小捕获,SBO)    2.394        1.613       1.312
std::function(大捕获,堆)     2.343        1.341       1.602
堆分配次数                       0            0           0       ← 小捕获
                                1            1           1       ← 大捕获

这里有一个反直觉的结果:MSVC 上"零分配"的那一档(2.394)比走堆的那一档(2.343)还略慢。

原因不在 SBO 本身,而在被擦除的对象形状不同:

  • 小捕获那档捕的是一个 int k,std::function 把它放进了本地缓冲;
  • 大捕获那档捕的是 64 字节的 BigCap,走了堆。

两者都逃不掉"经擦除后入口间接调用"这一层——只要 std::function 的类型在编译期无法确定,那一层就消不掉,而 SBO 省的只是"一次 malloc",省不掉"一次间接调用"。当间接调用是主要成本时,省掉一次 malloc 换来的是 2% 的摆动。

口径提醒:这两档的差异(2.394 vs 2.343,约 2%)落在计时抖动范围内。本篇把它如实列出,是因为它推翻了一个更常见的说法——"std::function 慢是因为堆分配"。在这份代码上不是。

7.2 SBO 阈值:可以量的一个数

SBO 阈值扫描:MSVC 49 字节 / GCC 24 字节
图 8-1617 SBO 阈值扫描:MSVC 49 字节 / GCC 24 字节@ 本机实测(两套工具链)

探针的做法是逐个尺寸试:生成一个恰好 N 字节捕获的可调用对象,重置分配计数,构造 std::function,看计数何时从 0 变 1。探测点取 1/8/16/24/32/40/48/49/56/57/64/72。

编译器          首次发生堆分配的捕获尺寸
MSVC 19.44            49 字节
g++ 16.2.0            24 字节

同一个标准设施,两个实现的"不分配上限"差一倍。这不是标准行为——SBO 从来不是标准要求的(cppreference 的 std::function 页只描述语义、不承诺缓冲尺寸),它是实现的自由。

而且注意一个容易误读的点:而 libstdc++ 的std_function.h 源码里那个 _M_max_size(sizeof(_Nocopy_types))在 64 位下是 16 字节,而实测的首次分配尺寸是 24 字节。源码阈值与实测阈值不是一回事:实测值还包含对齐要求与实际放入对象的完整尺寸。所以想知道你的工具链上是多少,只能量,不能推。

7.3 SBO 机制:省下的是哪一笔

SBO 机制:省下的是分配,不是间接调用
图 8-1718 SBO 机制:省下的是分配,不是间接调用@ 本系列绘制

好了,SBO 的收益很具体:省掉一次分配 + 省掉一次释放 + 换来对象与被调方在同一缓存行附近。

但它省不掉的是:类型已经擦掉了。std::function 的 operator() 仍然要跳到那个被擦除的入口。所以 SBO 有效的前提是——你的瓶颈在分配上,而不是在间接调用上。这句话在"被分发的操作本身很重"的业务代码里成立,在"操作只有一条 x+1"的极端用例里不成立。

7.4 std::any:这个阶梯的底

std::any:这个阶梯的底(7 倍量级)
图 8-1819 std::any:这个阶梯的底(7 倍量级)@ 本机实测(两套工具链)
                             MSVC /O2     GCC -O2     与 direct 之比
direct                         0.766        0.772         1.00×
std::any                       5.897        5.409         7.70× / 7.01×

言归正传,std::any 是 11 档里最慢的,两套工具链上都是 7 倍量级。它贵在三处叠加:

  1. 必然经擦除后调用(第 1 层);
  2. 极小的对象也可能走堆——实测两套工具链上 std::any 那一档的分配次数都是 1(第 3 层);
  3. 每次取值都要做类型检查——any_cast 必须验证被擦除的类型对不对(第 4 层)——值返回型不匹配时抛 std::bad_any_cast,而指针返回型只返回空指针、不抛。

三层叠加,于是它把阶梯拉到了底。如果一段代码里 std::any 出现在热路径上,那它几乎不可能是免费的。

7.5 any_cast 崩掉的现场

any_cast 类型不匹配的现场:0xC0000409 退出
图 8-1920 any_cast 类型不匹配的现场:0xC0000409 退出@ 本机实测(MSVC 19.44)

本篇的用例第一版就是这么崩的(§3.4 已交代修法)。这条现场值得单独留个图,因为它把"第 4 层代价"从抽象名词变成了一个退出码:

exit=-1073740791        (= 0xC0000409,MSVC 下未捕获 C++ 异常的典型退出码)

类型检查不是免费的装饰,它是一条会在运行期决定程序生死的分支。 也是因为这个原因,本篇给每一档都套了 try/catch —— 实测工作流里,"一档挂掉带走整轮"是很常见的浪费。

7.6 各档的堆分配次数

各档的堆分配次数:仅 std::function(大捕获)与 std::any 为 1
图 8-2021 各档的堆分配次数:仅 std::function(大捕获)与 std::any 为 1@ 本机实测(两套工具链)
档位                        分配次数(8,192 万次调用)
direct / template / fnptr              0
virtual-monostate / virtual-poll4      0
variant+visit / variant-poll4          0
manual-erasure                         0
std::function(小捕获,SBO)             0
std::function(大捕获,堆)              1
std::any                               1

这张表有一个很干净的读法:除了 std::function 与 std::any,其余全部是零分配。"把类型藏起来"这件事本身不需要堆——需要堆的是那两个标准库设施的实现选择。

7.7 手写擦除:两种命运

手写类型擦除的两种命运:1.00× 与 1.71×
图 8-2122 手写类型擦除的两种命运:1.00× 与 1.71×@ 本系列绘制

先来看看手写擦除是理解这整件事最好的样本,因为它的代码只有五六行,却能同时演示"免费"和"付费"两种命运:

                             MSVC /O2     GCC -O2
direct                         0.766        0.772
manual-erasure                 1.316        0.768     ← 一侧 1.7×,一侧 1.0×
  • GCC 侧 0.768 = 直调水平:Erased::fn 是编译期常量(&erased_thunk<IncT>),整条擦除被划掉。
  • MSVC 侧 1.316:那一次间接调用留下来了,约等于直调的 1.7 倍。

同一份擦除代码、同一个用例,只在编译器不同时分开出两种结论。 这也是为什么 §4.4 说"排序是编译器决定的"——擦除这个动作本身,账单金额由编译器填。


7.8 SBO 阈值的工程含义

§7.2 量出的两个数(MSVC 49 字节、GCC 24 字节)不只是"一个有趣的差异",它有两个可操作的推论:

推论一:跨平台代码不能假设 SBO 行为一致。 一个捕 32 字节的可调用对象,在 MSVC 上完全不分配,在 GCC 上每次构造都要 malloc。如果你的代码要在热路径上构造 std::function,这个差别是实打实的——而且是那种"在开发机上测不出来、在另一套工具链上暴露"的差别。

推论二:把捕获压到阈值以下,是一个可以主动设计的动作。 阈值的窄口是 24 字节(本机 GCC),意味着:

  • 捕获一个指针 + 一个整数(16 字节)通常还在缓冲里;
  • 捕获两个指针 + 两个整数(24 字节)就危险了;
  • 捕获一个 std::shared_ptr(16 字节)安全,捕获两个(32 字节)就会掉到堆上。

这些数字只有在量过之后才敢用。 本篇给出的 49 与 24 是本机实测值,换个标准库版本就会变——所以自检清单里那一条是"量一下本工具链的阈值",而不是"记住 24 这个数"。

八、机制层:vtable 布局、官方口径与代码膨胀

8.1 代码体积

代码体积:两套工具链方向相反
图 8-2223 代码体积:两套工具链方向相反@ 本机实测(两套工具链)
                        exe           代码段
MSVC /O2                30,208 B      13,824 B
MSVC /O2 + /GL + /LTCG  30,720 B      14,336 B     ← 涨 512 B
GCC -O2                281,568 B          —
GCC -O2 -flto          270,900 B          —        ← 降 10,668 B

两套工具链的体积方向又是相反的——与 ep07 观察到的现象一致。这一点在系列里第二次出现,说明它不是偶然:内联复制的量与死代码消除的量,在两套实现里的相对大小不同。

8.2 间接调用计数

间接 call 静态计数与口径说明
图 8-2324 间接 call 静态计数与口径说明@ 本机实测(dumpbin / objdump)
MSVC /O2                  间接 call 68(内存) + 12(寄存器)
MSVC /O2 + /GL + /LTCG    间接 call 100(内存) + 12(寄存器)
GCC -O2                   间接 call 41
GCC -O2 -flto             间接 call 42

这张表有一个必须交代的口径问题:这是全 EXE 的静态计数,包含 CRT 启动代码里的间接调用(__guard_dispatch_icall_fptr 等),所以它不能直接当成"你这 11 档用了多少间接调用"。

它的用途是对照:同一套代码在不同构建下,间接调用的总量有没有变。MSVC 从 68 涨到 100,说明 LTO 之后链接器把更多调用点变成了间接形式(或者展开了更多代码路径)——这也解释了为什么体积会涨。

8.3 类布局转储

MSVC 类布局转储:{vfptr} 与 alignment member
图 8-2425 MSVC 类布局转储:{vfptr} 与 alignment member@ 本机实测(/d1reportAllClassLayout)

MSVC 有一个可以直接把类布局打出来的开关。给一个三层的类族:

struct Op { virtual int apply(int x) const = 0; virtual ~Op() = default; };
struct A : Op { int payload; int apply(int x) const override { return x + 1; } };
struct B : A { double extra; int apply(int x) const override { return x ^ 7; } };
class Op  size(8):
  0   | {vfptr}

class A   size(16):
  0   | +--- (base class Op)
  0   | | {vfptr}
  8   | payload
      | <alignment member> (size=4)

class B   size(24):
  0   | +--- (base class A)
  0   | | +--- (base class Op)
  0   | | | {vfptr}
  8   | | payload

三个可以直接读出来的结论:

  1. Op 的 8 字节里只有 vptr——纯接口类的对象里除了一个指针什么都没有;
  2. A 与 B 共用同一个 vptr 位置(offset 0),派生没有新增 vptr——这就是"每类一份 vtable、每对象一个指针"在布局上的样子;
  3. A 里有 <alignment member> (size=4)——int payload 之后要放 B 的 double extra,为了 8 字节对齐补了 4 字节空洞。这是 ep01 那条"对齐与 padding"的账在本篇里又收了一次。

8.4 vptr 与 vtable 的结构

vptr 与 vtable:一次虚调用的三步
图 8-2526 vptr 与 vtable:一次虚调用的三步@ 本系列绘制

把布局与调用串起来:

  1. 每个对象在头部存一个 vptr(MSVC 调试器里叫 __vfptr);
  2. 每个类有一份 vtable,槽位按声明顺序排列,基类的虚函数排在派生类新定义的之前;
  3. 一次虚调用 = 取 vptr → 取槽位 → 间接调用。

这三步里,"取"的两步是固定的、可数的;只有第三步的目标是不确定的。 所以四层账单里,第 1 层薄、第 2 层厚——这与 §6.3 的实测互相印证(指令没变多,但时间上去了)。

8.5 Itanium ABI 的原文

Itanium C++ ABI:vtable 的官方定义原文
图 8-2627 Itanium C++ ABI:vtable 的官方定义原文@ Itanium C++ ABI 原文摘录

Itanium C++ ABI(GCC/Clang 在 Linux 侧遵循的那份规范)给了这套结构一个正式定义:

  • "A dynamic class has an associated table ... which contains information about its dynamic attributes, e.g. virtual function pointers, virtual base class offsets"——vtable 是"与动态类关联的表",含虚函数指针与虚基类偏移;
  • 动态类(含虚函数或虚基类)需要一个 vptr(Itanium C++ ABI 正文;Revision 1.83);
  • 主基类在 offset 0 处与派生类共享 vptr——这与 §8.3 转储里 A/B 共用 offset 0 完全对应,ABI 的示例页还以表格给出了 vtable 条目的具体排列。

8.6 MSVC 侧的对应口径

MSVC 侧口径:__vfptr 与 vtable 填充顺序
图 8-2728 MSVC 侧口径:__vfptr 与 vtable 填充顺序@ Microsoft Learn 原文摘录
  • 调试器里那个指针叫 __vfptr;
  • vtable 按声明顺序填充:微软的口径说明,若基类没有虚函数,派生类里第一个声明的虚函数对应表中第 0 项;基类的虚函数排在新定义的虚函数之前;vtable 按类构建;
  • 类布局可以用 /d1reportSingleClassLayoutAAA 与 /d1reportAllClassLayout 转出来(Visual Studio 官方博客确认了这两个开关的用法)——本篇 §8.3 的转储就是这么拿到的。

两套 ABI 在这里对得上(每类一份 vtable、每个对象一个 vptr、主基类共享 vptr 位置),这也是为什么本篇的实测能同时用两套工具链解释同一批读数。

8.7 去虚拟化的官方口径(已见 §5.3)

GCC 的 -fdevirtualize / -fdevirtualize-speculatively、pass_ipa_devirt 的类型继承图分析、LLVM 的 WholeProgramDevirt、MSVC 与 final 的关联——这四条在 §5.3 已引原文。放在这里是为了收口:本篇最重的一处差异(同一份单态虚调用,一侧划掉一侧没划掉)不是玄学,它落在四份官方文档都明确写着的那组开关上。

8.8 模板膨胀:实测的斜率

模板膨胀的斜率与 /OPT:ICF 折叠
图 8-2829 模板膨胀的斜率与 /OPT:ICF 折叠@ 本机实测(MSVC 19.44)

下面进入模板是"把类型编进代码",代价自然落在体积上。实测:

同一模板,8 个实例化     代码段 98,816 B
同一模板,16 个实例化    代码段 100,352 B     ← +1,536 B(+1.6%)

8 个额外实例化只买到 1.5 KB。 这个数字比很多人的预期小,原因有两个,都值得写清:

  1. 膨胀的斜率取决于每个实例化的函数体大小。Stroustrup 在访谈里就以 template<class T> class list 举例说明过这件事。本篇的模板体很小(一层 vector<T> 的包装),所以每个实例化也就一两百字节。换成"每个实例化几百条指令"的模板,斜率会完全不同。
  2. /OPT:ICF 会把相同的 COMDAT 折叠掉。实测把 ICF 关掉后,代码段是 87,552 B,比开着时(86,528 B)多出 1,024 B——16 个实例化之间那点差异,有相当一部分被链接器折叠掉了。

第二条是本篇想强调的:模板膨胀的可见性,取决于链接器愿不愿意做相同代码折叠。所以"模板会带来多少体积"这个问题,同样不能脱离工具链回答。

8.9 膨胀的对策

模板膨胀的三条对策
图 8-2930 模板膨胀的三条对策@ 本系列绘制

好了,工程上的对策只有一条主线:把与类型无关的部分抽出去,让实例化只覆盖真正与类型相关的薄层。具体三种做法:

  • 抽公共基:把不依赖 T 的逻辑放进非模板函数或基类,模板里只留薄薄的转发层(工程侧的整理);
  • 编译期分支:用 if constexpr 在同一实例里处理不同分支,而不是再生成一份;
  • 反向选择:用类型擦除换体积——把多变体收敛成一个擦除接口,代价是 §7 那一整套开支。这一条只在"非热路径"上划算。

第三条是对本篇前半部分的回收:膨胀与擦除是同一枚硬币的两面——一边省体积(擦除)、一边省间接调用(模板),选哪面取决于这段代码是否在热路径上。


8.10 vtable 里还有别的东西:RTTI 槽位

§8.3 的转储里,Op 的 vtable 旁边还多出一行 vftable 标签(以 Op:: 开头、带一个美元符号分隔的名字段)与 &Op_meta。这个 _meta 指向的是类型信息结构——vtable 不只存虚函数指针,它旁边还挂着 RTTI 的数据。

这带来两件本篇不展开、但要留个记号的事:

  1. 带虚函数的类天生带 RTTI 数据(除非禁用 RTTI),这部分成本不由你选择;
  2. dynamic_cast 与 typeid 走的就是这份数据——它们是另一条"运行时问类型"的路径,与 any_cast(§7.5)不同但同类。

这正是下一篇 ep09 的入口:本篇讲"主动把类型藏起来要付什么",下一篇讲"语言机制替你留下了哪些痕迹"。RTTI 数据是其中最显眼的一处——它在你没写任何一行相关代码的情况下,已经躺在二进制里了。

九、选型判据与工程建议

说白了,前面所有章节都在量代价,这一章把它们收成一组可以在设计评审里直接用的判据。判据按顺序问,不按重要性排——因为前一条的答案会决定后一条要不要问。

选型判据:按顺序问的四条
图 8-3031 选型判据:按顺序问的四条@ 本系列绘制

9.1 四条判据,按顺序问

判据一(热路径):这段代码在热路径上吗?不在 → 选可读性最好的那个,本篇所有读数与它无关。

判据二(目标确定性):在热路径上,且目标在编译期或运行期唯一且已知 → 模板最稳;虚函数/variant/擦除可能被划掉(GCC 更积极,MSVC 更保守),别赌,量。

判据三(目标多变):在热路径上,且目标真的会在运行期变化 → 这是唯一必须付账的场合。此时:

  • 候选集合小且封闭 → 优先 variant(无堆、可内联),但先在两套工具链上量一遍——本篇实测两侧结论相反;
  • 候选集合开放(插件、回调) → 虚函数或擦除二选一,选分配行为可控的那个;
  • 把分配挪出热路径:对象预分配、用 pmr 内存资源(ep02 的主题),别让热循环去 malloc。

判据四(体积与编译时间):只有前三条都指向模板时,才去核对代码体积与编译时间(ep04 的账本)。顺序不能反——为了体积去选模板,通常会挑错。

9.2 三条可操作的工程建议

  1. 给编译器一个承诺:如果你的类不会再被继承,写上 final。微软官方明确 final 有助于去虚拟化——这是"零成本的性能标注"里最划算的一个。
  2. 别把 std::any 放进热路径。它是本篇阶梯的底(7 倍量级),且三层代价叠加。需要"装任意类型"时,先问"类型集合是否真的开放"——封闭就换 variant。
  3. std::function 的瓶颈通常不是堆。本篇实测零分配那一档并不更快。要优化它,就去减少它在热路径上的出现次数,而不是去调 SBO 阈值。

9.3 关于"用 variant 替代虚函数"的流行建议

本篇不支持把它当通用建议。实测是:

  • MSVC 上 variant-poll4(2.421)比 virtual-poll4(2.208)更慢;
  • GCC 上反过来(1.058 对 2.229);
  • 公开实测里还有"随 libstdc++ 版本翻转"的记录。

另一份追踪 libstdc++ 实现演进的实测也印证了同一件事。所以正确说法是:"在封闭集合、且你的标准库 visit 实现走快路径的前提下,variant 有优势——这个前提必须自己验。"一句话的通用建议,会把三个变量全藏起来。


9.4 常见误用清单

把本篇实测能直接支持的五条误用列出来,每条都配一句"该怎么做":

误用 为什么是误用 该怎么做
在热循环里构造 std::function 构造本身可能带一次分配(超过 SBO 阈值时) 循环外构造一次,循环里只调用
用 std::any 传"可能多种类型"的参数 实测 7 倍量级,且极小对象也走堆 类型集合封闭就换 variant
为了"少一次间接调用"给所有类加 final final 只有在编译器真去虚拟化时才有收益,它同时放弃了继承 只给出设计上确实不该被继承的类加
照抄"variant 比虚函数快"改架构 实测两侧结论相反 先在本工具链量一遍,再决定改不改
把 std::function 慢归因于堆分配 实测零分配那一档并不更快 先分清瓶颈在分配还是在间接调用

这五条有一个共同点:它们的错误都不是"性能直觉错了",而是"把一个带前提的结论当成了无条件结论"。本篇从头到尾在讲同一件事——性能结论的前提,就是它的适用边界。

十、收束与下一篇钩子

10.1 全篇读数汇总

好了,把本篇测出的全部读数集中在一张表里,便于对照与复核。每一档的原始输出都随篇留档在 evidence/ 下,表里任何一个数字都可以逐字回溯到某一行的日志。

全篇 20 项关键读数汇总
图 8-3132 全篇 20 项关键读数汇总@ 本机实测(两套工具链)
分发阶梯(ns/op,中位)
                               MSVC /O2   MSVC+LTO   GCC -O2   GCC -flto
direct                           0.766      0.768      0.772     0.763
template                         0.768      0.772      0.771     0.766
fnptr                            0.770      0.773      0.766     0.768
variant+visit(单态)            1.054      1.098      0.766     0.773
manual-erasure                   1.316      1.342      0.768     0.774
virtual-monostate                1.618      1.710      0.772     0.771
virtual-poll4                    2.208      1.962      2.229     2.195
variant-poll4                    2.421      2.375      1.058     1.115
std::function(小捕获,SBO)      2.394      2.396      1.613     1.312
std::function(大捕获,堆)       2.343      2.399      1.341     1.602
std::any                         5.897      5.874      5.409     5.532

其他关键读数
SBO 首次分配阈值                MSVC 49 字节 / GCC 24 字节
堆分配次数                      仅 std::function(大捕获)与 std::any 为 1,其余全 0
exe 体积                        MSVC 30,208→30,720 B(涨)| GCC 281,568→270,900 B(降)
间接 call 计数                  MSVC 68+12 → 100+12 | GCC 41 → 42
模板 8→16 实例化                代码段 98,816→100,352 B(+1.6%);关 ICF 后 87,552 B
类布局                          Op size 8(仅 {vfptr})| A size 16 | B size 24

10.2 两套工具链的证据形态

本篇的结论之所以敢写排序会变,是因为每一处判断都有不依赖时间的证据兜底。这一节把两套工具链各自能拿到哪些结构性证据列清楚——如果你的环境里只有其中一套,照这张表也能把同一批结论验一遍。

两套工具链的证据形态对照
图 8-3233 两套工具链的证据形态对照@ 本机实测(两套工具链)
要什么证据 MSVC GCC
类布局 /d1reportAllClassLayout(§8.3) -fdump-lang-class 等
反汇编与间接 call 计数 dumpbin /disasm objdump -d --demangle
代码段大小 dumpbin /headers 的 size of code size 工具
堆分配 重载 operator new 同左

两侧都能拿到同一批结构性证据——这是本篇敢把"排序"讲成"阶梯"的底气:每一步结论都有不是时间的证据兜底。

10.3 复现流程

下面进入复现这一篇不需要任何私有环境:四个源文件、两套公开工具链、每套两档开关。下面这张图把完整流程串起来,包括编译器版本、必须的 C++17 开关、以及三类取数各自用什么命令。

复现流程:四个源文件、两套工具链、三类取数
图 8-3334 复现流程:四个源文件、两套工具链、三类取数@ 本系列绘制

复现需要:四个源文件、两套工具链(MSVC 19.44 / g++ 16.2,都要 C++17)、每套两档开关。全部原始日志与反汇编转储随篇留档于 evidence/。

10.4 自检清单

分发代价自检清单
图 8-3435 分发代价自检清单@ 本系列绘制
  • [ ] 先确认这段代码在热路径上(不在,本篇所有读数与它无关)
  • [ ] 再确认目标在运行期是否真的会变(不变 → 可能免费)
  • [ ] 不会再被继承的类写上 final(有助于去虚拟化)
  • [ ] 把 std::any 移出热路径(实测 7 倍量级)
  • [ ] 分清瓶颈是分配还是间接调用(本篇实测 std::function 的瓶颈不是堆)
  • [ ] 需要 SBO 时量一下本工具链的阈值(MSVC 49 / GCC 24 字节)
  • [ ] variant 与虚函数在你的编译器上量一遍(本篇两侧结论相反)
  • [ ] 用模板换性能时,核对代码体积与编译时间(ep04 的账本)
  • [ ] 冻结工具链版本前重测一次(排序会随版本翻转)
  • [ ] 全部原始输出留档(否则前面九条都只是"相信")

10.5 与系列其他篇目的交叉点

这篇不是孤立的,它与系列里四篇有直接接口:

  • 与 ep01《对象布局、对齐与 padding》:vptr 是对象布局的一部分。§8.3 的转储里,Op 的 8 字节全是 vptr,A 的 16 字节里有 4 字节是对齐空洞——ep01 讲的是"字段怎么摆",本篇讲的是"摆进去的那一个指针带来什么"。
  • 与 ep02《分配器解剖》:§7 那几个分配次数,最终都落在 operator new 上。如果你在热路径上无法避免分配,下一步就是换分配器或改用 pmr 内存资源——那是 ep02 的内容。
  • 与 ep04《编译期账本》:模板的代价不在运行期,在编译期与体积。本篇 §8.8 量出的膨胀斜率(8→16 实例化 +1.6%),要配合 ep04 的编译时间读数一起看,才能判断"用模板换性能"这笔交易是否划算。
  • 与 ep07《链在一起》:两篇的靶子正好互补。ep07 的靶子是"编译器看不见"(TU 边界),本篇的靶子是"编译器看得见但不敢定"(运行期多变)。§4.5 那一段把两者的分工说清了:LTO 治看不见,去虚拟化治不敢定。

10.6 一条可以搬走的方法

言归正传,把本篇的做法抽象成三步:

  1. 把被测对象做到极小,让它的代价不被业务逻辑淹没——只有 3 条指令的成本,才能被 3 条指令的差异量出来;
  2. 让对照只差一个变量,并且这个变量要能"数出来"或"看出来"(目标是否唯一、捕获多少字节、实例化几个类型);
  3. 同时取时间与结构两类证据,冲突时以结构为准——指令数、分配次数、符号与 vtable 布局都不会抖动,时间会。

这三步与 ep07 的方法论是同一条,只是这一篇的"结构证据"多了一种:类布局转储(§8.3)。当你能把布局打出来时,"vptr 在哪、有没有空洞"这类问题就不再需要推理了。

10.7 资源清单

  • 标准库与标准:std::function、std::variant、std::any 的语义(cppreference)|variant 提案 P0088R3 |libstdc++ 的 std_function.h 源码(SBO 判定)——见 https://en.cppreference.com/w/cpp/utility/variant
  • ABI 与类布局:Itanium C++ ABI(vtable 定义与示例)——见 https://itanium-cxx-abi.github.io/cxx-abi/abi.html|微软的类布局转储说明与 vtable 填充顺序
  • 去虚拟化:GCC 手册 -fdevirtualize / -fdevirtualize-speculatively|GCC Internals 的 IPA devirtualization pass|LLVM WholeProgramDevirt|微软 C++ Team Blog 关于 final 与 devirtualization
  • 分支预测与微架构:Agner Fog 优化手册|"处理有序数组为什么更快"的经典实证
  • 公开实测:stdlib 实现差异导致 variant/虚函数排序翻转的实测文、"variant 访问策略跳转表 vs switch"的实测、指针分发 vs variant 的实测
  • 本篇证据:四个源文件 + 两套工具链全部原始日志、反汇编转储与读数 JSON,见本篇目录 evidence/

10.8 下一篇:异常与 RTTI

这一篇算的是"主动把类型藏起来"的账。下一篇算被动的那一笔:

ep09《异常与 RTTI:零成本到底成不成立》 异常处理常被称作"零成本"——不抛异常时不该有开销。这句话在多大程度上成立?这一篇会盯住三个位置:函数入口多出来的那段(如果有)、抛异常时的展开路径、以及 RTTI 在二进制里留下的那些数据结构(type_info、vtable 里的 RTTI 槽位)。判据还是那两条:反汇编里多了什么、以及运行时真的走了哪条路。 会与本篇形成对照:本篇讲"把类型藏起来的主动代价",ep09 讲"语言机制替你留下的被动痕迹"。


本篇实测环境:MSVC 19.44.35228(VS 2022 Community,工具集 14.44.35207,/std:c++17)|MSYS2 ucrt64 g++ 16.2.0(Rev3)|Windows x64 / PE-COFF。本机无 Clang、无 WSL,故 LLVM 侧的 WholeProgramDevirt 与 ThinLTO 只引官方原文、不做本机实测;计时为进程内 steady_clock 五次中位,非跨机器可比。

本文由@溯光 出品 | 站点:https://yyy3d.online/blog/ | 本系列共 12 篇,本篇为第 8 篇

这一篇把四种"藏类型"的办法逐档量到了汇编一级,也把"排序会随编译器翻转"这件事摆到了台面上。如果它能帮你少一次"照着性能排序表选错方案"的返工,希望能对大家有所帮助。