Appearance
indirect-branch —— 间接分支预测实验
通过直接调用、单态/双态/巨态虚函数、函数指针、switch 跳转表六种场景,用
perf stat -e branch-loads,branch-load-misses定量测量间接分支预测器对不同"态"(目标地址数量)的表现差异。
一、快速开始
bash
make # 编译(-O2)
make run # 跑全部模式对比表(耗时)
make perf # perf stat 看直接+间接分支全指标(全模式混合)
make perf-branch-loads # 只看 branch-loads / branch-load-misses(全模式)
make perf-record # perf record + report 定位具体函数(单态+巨态)
# 按模式单独跑 perf —— 看真实指标差异(推荐)
make perf-mono # 单态虚函数
make perf-mega # 巨态虚函数(重点观察)
make perf-dual # 双态虚函数
make perf-switch-seq # switch 顺序
make perf-switch-rnd # switch 随机二、术语速查
全文高频出现的缩写和术语,首次遇到时可回查此表。更完整的背景见 间接分支预测专题 和 分支预测总论。
2.1 硬件与微架构
| 缩写 | 全称 | 中文 | 一句话 |
|---|---|---|---|
| BTB | Branch Target Buffer | 分支目标缓冲 | 用指令地址查"上次跳去哪",直接/间接分支都用 |
| BHT | Branch History Table | 分支历史表 | 记录每条分支"上次走没走",管 jcc 方向预测 |
| ITTAGE | Indirect Target TAGE | 间接目标 TAGE 预测器 | 本文核心:IP + 全局历史联合索引,预测 call*/jmp* 的目标地址 |
| RSB | Return Stack Buffer | 返回栈缓冲 | 硬件 LIFO 栈,call 压入返回地址 / ret 弹出 |
| IP / RIP | Instruction Pointer | 指令指针 | x86-64 下叫 RIP,指向当前被 fetch 的指令地址;BTB/ITTAGE 的索引键 |
| pipeline flush | — | 流水线清空 | 分支预测失败后,丢弃流水线中所有投机执行的指令,从正确地址重新取指,代价 ~15-20 cycle |
| IPC | Instructions Per Cycle | 每周期指令数 | instructions / cycles,衡量流水线利用率;单线程健康值 1.0~4.0,<1.0 存在严重 stall |
2.2 perf PMU 事件
| 缩写 | 全称 | 统计对象 | 一句话 |
|---|---|---|---|
branch-instructions | — | 直接分支 (jcc) | 条件跳转指令的执行次数 |
branch-misses | — | 直接分支 (jcc) | 方向预测错误的次数 |
branch-loads | — | 间接分支 (call*/jmp*) | 间接分支的执行次数,本文的核心计数指标 |
branch-load-misses | — | 间接分支 (call*/jmp*) | 间接分支目标地址预测错误的次数 |
cycles | — | CPU 周期 | 程序消耗的 CPU 周期总数 |
instructions | — | 指令 | 退休的指令总数 |
关键区分:
branches/branch-misses统计直接分支(jcc),branch-loads/branch-load-misses统计间接分支(虚函数、函数指针、switch 跳转表)。两组事件互不重叠。
2.3 实验与优化术语
| 术语 | 含义 | 本文中的角色 |
|---|---|---|
| 单态 (monomorphic) | 虚函数调用只有一个实际目标类型 | 日常 OOP 最常见的场景——90%+ 虚函数调用是单态的 |
| 双态 (bimorphic) | 两种实际类型交替出现 | 如策略模式的两个策略来回切 |
| 巨态 (megamorphic) | 四种以上类型随机混排,目标不可预测 | 间接分支预测器的噩梦——插件系统、回调注册表 |
| vtable dispatch | call *vtable[idx] —— 从虚函数表取函数地址再跳转 | 本文实验的测量对象 |
| 跳转表 (jump table) | 编译器为密集 switch 生成的 jmp *disp(,%reg,8) | 同 vtable dispatch,本质也是间接分支 |
| warmup | 计时前的预热循环(本文 WARMUP=500 轮) | 让 iCache、predictor、睿频进入稳态后再计时 |
| 中位数 (median) | 5 trial 取中位数作为最终耗时 | 比平均数更抗调度中断/睿频抖动的干扰 |
| CRTP | Curiously Recurring Template Pattern | 编译期多态替代虚函数,零运行时开销 |
| PGO | Profile-Guided Optimization | 编译器根据运行时 profile 自动去虚化 |
| 去虚化 (devirtualization) | 把虚函数调用转成直接调用 | 巨态场景的主要优化方向 |
三、为什么间接分支预测重要
分支预测 要猜两件事:方向(跳不跳)和目标(跳去哪)。直接分支(jcc)目标编码在指令里,只需猜方向;间接分支(call *%rax / jmp *%rax)目标地址是寄存器/内存值,还要猜目标——难得多。
| 分支类型 | 典型场景 | 猜什么 | 负责部件 |
|---|---|---|---|
直接分支 (jcc) | if / for / while | 方向 (taken/not-taken) | 方向预测器 (BHT/TAGE) |
间接分支 (call *) | 虚函数、函数指针 | 方向 + 目标地址 | 间接分支预测器 (ITTAGE) |
间接分支 (jmp *) | switch 跳转表 | 方向 + 目标地址 | 间接分支预测器 (ITTAGE) |
函数返回 (ret) | 函数调用返回 | 目标地址 | RSB (Return Stack Buffer) |
前置知识:间接分支预测器 (ITTAGE)
现代 CPU 的间接分支预测器与方向预测器共享 TAGE 框架,但额外维护目标地址历史——不仅记录"这个 IP 最近跳到了哪里",还结合全局分支历史模式来区分同一 IP 在不同上下文下的不同目标。单态(一个目标)时学到就是零开销;巨态(多个目标随机切换)时和直接分支的随机方向一样抓瞎。
观察指标:
branch-loads/branch-load-misses—— 注意不是branches/branch-misses,后者只统计直接分支(jcc)。
四、程序结构
六种模式跑不同调用方式,区别在"目标地址是否变化、变化有无规律":
| 模式 | 调用方式 | 目标数 | 目标规律 | 间接分支预测 |
|---|---|---|---|---|
| 直接调用 | direct_add(i) | 1 | 编码在指令中 | 无间接分支 |
| 单态虚函数 | ops[i]->compute(i) 全是 OpAdd | 1 | 始终相同 | predictor 学会,几乎零开销 |
| 双态虚函数 | 交替 OpAdd / OpSub | 2 | 严格交替 | predictor 学交替模式,接近单态 |
| 巨态虚函数 | 四种子类随机混排 | 4 | 完全随机 | predictor 抓瞎,误判率高 |
| 函数指针(单目标) | 单一函数指针 | 1 | 始终相同 | 同单态虚函数 |
| 函数指针(多目标) | 四个 lambda 轮转 | 4 | 轮转(有规律) | 取决于别名冲突 |
| switch(顺序 op) | switch(i % 5) 跳转表 | 5 | 顺序循环 | predictor 可能学到大步进模式 |
| switch(随机 op) | switch(rand() % 5) 跳转表 | 5 | 完全随机 | 预测失败率上升 |
五、代码设计分析
5.1 为什么用虚函数而不是其他间接调用方式
虚函数调用(call *vtable[idx])是 C++ 多态最核心的性能开销来源——它不是"小众场景",而是几乎所有 OOP 代码热路径上的潜在瓶颈。同时它的间接分支行为可控(通过控制对象数组的类型分布),非常适合做对照实验。
5.2 三种"态"的选择
| 态 | 目标数 | 实际对应 |
|---|---|---|
| 单态 (monomorphic) | 1 | 大多数虚函数调用的实际情况——虽然语法上是多态,但运行时 90%+ 是同一个子类 |
| 双态 (bimorphic) | 2 | 两种实现交替,如策略模式的两个策略来回切 |
| 巨态 (megamorphic) | 4+ | 插件系统、回调注册表、类型擦除容器——目标完全不可预测 |
5.3 volatile long sum:防优化
cpp
volatile long sum = 0;
// ... sum += ops[i]->compute(i);
(void)sum;volatile阻止编译器把 sum 优化成寄存器变量或消除整个循环(void)sum给编译器的冗余声明,"我知道 sum 被读过"- 没有这两道防线,
-O2会直接把整个循环删掉
5.4 预生成 + 固定种子:消除分配开销 + 可复现
cpp
std::vector<Op*> make_ops(Pattern p) {
// 在计时前就分配好所有对象
// 种子固定为 42,保证跨机器可复现
}5.5 ROUNDS=5000, N=4096
内层循环 4096 次虚函数调用 × 外循环 5000 遍 = 约 2000 万次间接调用。足够让时钟测量稳定,总耗时控制在几秒内。N 不能太大——4096 个对象指针数组约 32 KB,L1d 装得下,确保内存延迟不掩盖间接分支预测差异。
5.6 每种 bench 独立测量 + warmup + 中位数
每个 bench_* 函数内部自己调 make_ops() 生成独立对象数组,测量完 free_ops() 释放。这样每种模式从"冷缓存"起步,互相不污染。
但是"冷缓存起步"恰恰是问题的根源——如果第一个 bench 直接计时,它跑出来的时间和第二个 bench 跑出来的时间不可比:
| 跑序 | 状态 | 计时结果 | 解释 |
|---|---|---|---|
| 第 1 个 | iCache 冷、predictor 空、CPU 未睿频 | 偏慢 | 包含 iCache miss、predictor 学习建立过程 |
| 第 2 个 | iCache 已热、predictor 已建、睿频已到 | 偏快 | 微架构状态已被前面 bench 暖起来 |
这会导致 "虚函数比直接调用还快" 的荒谬结论(直接调用是第一个跑的,虚函数是第二个)。
修复方案:每个 bench 函数内部先跑 WARMUP=500 轮不计时(让 iCache、predictor、睿频都进入稳态),再跑 TRIALS=5 遍计时取中位数:
cpp
double bench_xxx() {
volatile long sum = 0;
// 1) warmup — 不计时
for (int r = 0; r < WARMUP; r++)
for (int i = 0; i < N; i++) sum += ...;
(void)sum;
// 2) trials — 跑 TRIALS 遍取中位数
std::vector<double> times;
for (int t = 0; t < TRIALS; t++) {
volatile long s = 0;
auto t0 = chrono::high_resolution_clock::now();
for (int r = 0; r < ROUNDS; r++)
for (int i = 0; i < N; i++) s += ...;
auto t1 = chrono::high_resolution_clock::now();
times.push_back(chrono::duration<double>(t1 - t0).count());
(void)s;
}
return pick_median(times); // 中位数比平均数更抗偶发中断
}为什么取中位数而不是平均数? 单次 bench 可能被调度器中断、睿频降频、其他核活动干扰——这些都让某些 trial 显著偏慢。平均数被一两个 outlier 拖高,中位数更稳。
5.7 为什么 switch 用跳转表
5 个 case 的密集值(0,1,2,3,4),编译器会生成跳转表:jmp *disp(,%rax,8) —— 这是一条间接跳转指令,和虚函数的 call *(%rax) 本质相同,都走间接分支预测器。区别在于跳转表的目标地址在同一条指令的地址附近(跳转表条目是连续的内存),而虚函数的目标地址分散在各子类的代码段。
六、预期发现(理论预测)
下面的数据是有 warmup + 中位数之后应当看到的趋势。早期版本(无 warmup)曾出现"虚函数比直接调用还快 30%"的伪数据,那是因为直接调用是第一个跑的、CPU 还没热。
| 直接调用 | 单态虚函数 | 双态虚函数 | 巨态虚函数 | switch(顺序) | switch(随机) | |
|---|---|---|---|---|---|---|
| branch-load-misses | 0 | 接近 0 | 低 | 高 | 低 | 中等 |
| 耗时(相对直接调用) | 100% | ~100% | ~110% | ~500%+ | ~110% | ~2000%+ |
核心预期:巨态虚函数(四个目标随机)是间接分支预测的噩梦——每次 call *(%rax) 都要猜四个目标之一,预测器无法学习,误判率大幅上升。switch 跳转表(5 个随机目标)受影响更严重。
七、实测数据
以下为典型 x86-64 Linux 环境下、
-O2编译、make run实测的结果。实际值因 CPU 微架构而异(Skylake/Ice Lake/Zen3/Zen4 的间接分支预测器容量和算法不同),但相对量级具有代表性。
7.1 耗时对比(make run 实测)
虚函数调用 (vtable dispatch)
| 模式 | 耗时 | 相对 |
|---|---|---|
| 直接调用 (direct call) | 26.90 ms | 100.00% |
| 单态虚函数 (monomorphic) | 26.40 ms | 98.13% |
| 双态虚函数 (bimorphic) | 27.51 ms | 102.26% |
| 巨态虚函数 (megamorphic) | 146.04 ms | 542.84% ← 慢 5.4 倍! |
函数指针调用
| 模式 | 耗时 | 相对 |
|---|---|---|
| 函数指针(单一目标) | 59.19 ms | 100.00% |
| 函数指针(四目标轮转) | 58.94 ms | 99.58% ← 几乎无差异(轮转有规律) |
switch 跳转表
| 模式 | 耗时 | 相对 |
|---|---|---|
| switch(顺序 op) | 26.14 ms | 100.00% |
| switch(随机 op) | 575.66 ms | 2202.28% ← 慢 22 倍!! |
7.2 关键发现
发现一:单态虚函数 ≈ 直接调用(98% vs 100%)
单态虚函数甚至比直接调用还略快(26.40 vs 26.90 ms,差 ~2%)——这在误差范围内,等价结论:单态虚函数零开销。间接分支预测器学会"这个 IP 只有一个目标"后,每次 call *(%rax) 命中 cache,代价仅是一次 vtable 加载(已被 prefetch 提前到 L1)。日常 OOP 代码中绝大多数虚函数调用都是单态的,所以不必谈虚函数色变。
发现二:巨态虚函数慢 5.4 倍(542%)
四个目标随机出现时,间接分支预测器无法学习,每次 call *(%rax) 都要猜四个目标之一(约 75% 误判)。误判后整个 pipeline flush(典型 ~15-20 周期),叠加 vtable 加载延迟和跨子类代码的 iCache 冲突,导致开销比理论预测的 ~150% 严重得多。这和直接分支预测中"随机数据 → 50% 误判"是同一个原理,但间接分支还要猜目标地址,所以惩罚更重。
发现三:函数指针四目标轮转 ≠ 巨态
函数指针四目标轮转耗时和单目标几乎一样(99.58%)——因为 i % 4 产生的是严格周期序列 0,1,2,3,0,1,2,3,...,间接分支预测器能学到这个周期模式。这和"巨态"完全不同:巨态虚函数四种子类在数组中真正随机混排,每次访问的 vtable 入口是不可预测的。
重要区分:预测器失败不是因为"目标数 > 1",而是因为"目标出现模式不可学"。
发现四:switch 随机 op 慢 22 倍(2202%)—— 比巨态虚函数还夸张
switch(rand() % 5) 真正均匀随机,比虚函数巨态还慢 4 倍。可能原因:
- 5 个目标(比虚函数 4 个多),每次猜错的概率更高(80% vs 75%)
- 跳转表的目标地址在内存中连续排列(跳转表条目是
&case0, &case1, &case2, &case3, &case4),而虚函数的目标地址分散在各子类的代码段,地址别名冲突可能让 ITTAGE 表项更容易混淆 rand()调用本身(即使是 libstdc++ 的 minstd_rand)会在数据依赖链上增加额外周期
而 switch(顺序 op) 和直接调用几乎一样快——周期模式 0,1,2,3,4,0,1,2,3,4 完全可以被预测。
7.3 总结表
| 模式 | 相对耗时 | 预测器能否学习 | 实际开销来源 |
|---|---|---|---|
| 直接调用 | 100% | N/A(无间接分支) | 指令本身 |
| 单态虚函数 | 98% | ✅ 完全学到 | vtable 加载(已 prefetch) |
| 双态虚函数 | 102% | ✅ 学到交替 | 同单态,偶发误判 |
| 巨态虚函数 | 543% | ❌ 真正随机 | pipeline flush + iCache miss |
| 函数指针(单目标) | 100% | ✅ | 同单态虚函数 |
| 函数指针(四目标轮转) | 100% | ✅ 周期模式 | 同上 |
| switch(顺序 op) | 100% | ✅ 周期模式 | 同上 |
| switch(随机 op) | 2202% | ❌ 真正随机 | pipeline flush + 别名冲突 |
7.4 perf stat 实测:单态 vs 巨态
直接对两种模式跑 perf stat -e branch-loads,branch-load-misses(每次只跑一种模式,避免混合):
bash
# 单态虚函数
$ perf stat -e branch-loads,branch-load-misses ./indirect_branch --mode mono
621,401,313 branch-loads:u
68,824 branch-load-misses:u # 0.011% of all branch-loads
0.311367492 seconds time elapsed
# 巨态虚函数
$ perf stat -e branch-loads,branch-load-misses ./indirect_branch --mode mega
621,452,343 branch-loads:u
153,662,489 branch-load-misses:u # 24.73% of all branch-loads
1.547721290 seconds time elapsed| 指标 | 单态 (mono) | 巨态 (mega) | 倍数 |
|---|---|---|---|
| branch-loads | 621,401,313 | 621,452,343 | ≈ 1.0×(计算量相同) |
| branch-load-misses | 68,824 | 153,662,489 | 2233× |
| miss 率 | 0.011% | 24.73% | — |
| elapsed | 0.311 s | 1.548 s | 4.97× |
关键观察:
branch-loads几乎相等(621M vs 621M)——证明两种模式执行的间接分支次数相同,差异完全来自预测器表现- miss 数从 68,824 暴涨到 1.5 亿,2233 倍
- 耗时从 0.31s 涨到 1.55s,4.97 倍 ——和 miss 数 2233 倍的差异说明:每次 miss 的代价是固定的(~15-20 cycle pipeline flush + 重新取指),而"miss 数 × 单次代价"恰好对应总耗时
- 注意:每次 miss 的代价可以叠加到后续指令上,所以 miss 数 2233× → 耗时 5×(不是 2233×)的物理意义是:miss 惩罚摊到 6.21 亿次分支上后,单次 miss 平均造成 ~5× 的总体减速
7.4.1 完整 perf stat 输出(含 cycles / IPC)
加上 cycles / instructions / branch-misses 后能看到流水线空转的直接证据:
bash
$ perf stat -e cycles,instructions,branch-instructions,branch-misses,\
branch-loads,branch-load-misses ./indirect_branch --mode mega
6,255,194,732 cycles:u (66.67%)
2,901,011,377 instructions:u (83.35%) # 0.46 insn per cycle
621,400,730 branch-instructions:u (83.34%)
153,676,061 branch-misses:u (24.73% of all branches)
621,320,516 branch-loads:u (83.34%)
153,674,061 branch-load-misses:u (83.31%)
1.573302418 seconds time elapsed
perf stat输出中每个数字的含义(以上面 mega 行为例,逐列解释):
输出列 含义 本例解读 6,255,194,732事件原始计数值 程序消耗了 62.5 亿个 CPU 周期 cycles:u事件名 + 监控级别 :u= 用户态(userspace)(66.67%)multiplexing 比例:该事件的 PMU 计数器实际被激活的时间占比 6 个事件共享 4 个硬件计数器,perf 用轮转 multiplexing—— cycles只采样了 66.67% 的时间,最终值是按比例外推的全时间估计# 0.46 insn per cycleperf 自动计算的派生指标(非 PMU 事件) instructions / cycles = 2.9B / 6.3B = 0.46,即 IPC# 24.73% of all branches同上, branch-misses / branch-instructions24.73% 的分支预测错误 multiplexing 比例为什么不是 100%?
现代 CPU 只有 4 个通用 PMU 计数器(Intel Skylake),但我们同时监控了 6 个事件(cycles、instructions、branch-instructions、branch-misses、branch-loads、branch-load-misses)。perf 会自动做 round-robin multiplexing:每个事件轮流占用计数器,采样一段时间后换下一个。
(66.67%)表示该事件实际被监控的时间只有 66.67%,perf 把采样值按比例放大到全时间。这对间接分支实验影响很小——所有 6 个事件都 ≥ 66%,且
branch-loads和branch-load-misses都是 83.34%/83.31%,说明 perf 对间接分支事件优先分配了更多计数器时间,外推误差在可接受范围内。如需消除 multiplexing,可减少到 4 个事件或分两次跑。
# 0.46 insn per cycle不是 PMU 事件,是 perf 自动算的这个
#开头的行不是 PMU 计数,而是 perf 在--stat模式下自动计算的派生指标。类似地,# 24.73% of all branches=branch-misses / branch-instructions。这些自动计算和我们在 7.4 节手动算的 miss 率(24.73%)是同一回事。
发现 1:IPC = 0.46 —— 流水线严重空转
正常 CPU 的稳态 IPC 在 1.0 ~ 4.0 之间(IPC≥3 才算流水线充分利用)。0.46 意味着 54% 的周期在 stall(等内存、刷新 pipeline、等待分支结果)——这恰好对应巨态虚函数慢 5 倍的物理来源:每次 call *%rax miss 触发 ~15-20 cycle 的流水线清空,6.21 亿次分支里 24.73% miss = 1.54 亿次 flush × ~15 cycle = 流水线有大量时间在"白等"。
发现 2:branch-misses ≈ branch-load-misses —— 间接分支是唯一瓶颈
注意两个数字:
branch-misses= 153,676,061(所有分支 miss:jcc + call*/jmp*)branch-load-misses= 153,674,061(仅间接分支 miss)- 差值 = 2000(在 1.5 亿次 miss 中只占 0.001%)
结论:内层循环里的 for (i=0; i<N; i++) 这种直接分支(jcc)几乎从不 miss ——BHT/TAGE 完全预测得到。所有性能瓶颈都来自间接分支,这进一步坐实了"branch-load-misses 是这个 demo 的唯一关键指标"。
| 指标 | 数值 | 含义 |
|---|---|---|
| cycles | 6,255,194,732 | 总 CPU 周期 |
| instructions | 2,901,011,377 | 总指令数 |
| IPC | 0.46 | 流水线严重空转 |
| branch-instructions | 621,400,730 | 全部分支(jcc + 间接) |
| branch-misses | 153,676,061 | 24.73% miss 率 |
| branch-loads | 621,320,516 | 仅间接分支 |
| branch-load-misses | 153,674,061 | 占总 miss 的 99.999% |
优化提示:看到 IPC < 1.0 的微基准,先看
branch-load-misses和branch-loads的比值。如果 > 5%,考虑去虚化 / 减小目标集合 / CRTP / PGO。
7.4.2 三模式横向对比:mono vs mega vs baseline
把 mono(健康流水线)、mega(灾难性流水线)和一个非间接分支的 baseline(运行程序时故意传错参数使 main() 直接打印帮助退出,0.0054 s 完成)摆在一起看 IPC 差异:
| 指标 | mono(单态) | mega(巨态) | baseline(启动 + 打印) |
|---|---|---|---|
| cycles | 1,323,076,189 | 6,255,194,732 | 3,527,600 |
| instructions | 2,905,149,200 | 2,901,011,377 | 3,609,938 |
| IPC | 2.19 | 0.46 | 1.02 |
| branch-instructions | 621,911,930 | 621,400,730 | 433,259 |
| branch-misses | 67,920 | 153,676,061 | 14,165 |
| branch-miss 率 | 0.01% | 24.73% | 3.27% |
| branch-loads | 628,323,172 | 621,320,516 | 541,056 |
| branch-load-misses | 72,928 | 153,674,061 | 18,933 |
| elapsed | 0.359 s | 1.573 s | 0.0054 s |
这个表里藏着 3 个非显然的事实:
事实 1:mega 的 IPC 0.46 < baseline 的 IPC 1.02 —— 流水线被"主动破坏"
正常程序即使只跑启动 + 打印帮助,单线程 IPC 也能到 1.0+。mega 的 IPC 0.46 反而比 baseline 还低——这不是"流水线没利用",是主动被破坏:每个 call *%rax miss 触发 ~15-20 cycle 的 pipeline flush,6.21 亿次分支里 24.73% miss = 1.54 亿次清空,流水线甚至出现了"负加速"(单条指令完成时间被拉长到 2+ cycles,理论最优是 0.25 cycle/instr)。
事实 2:mono 的 IPC 2.19 是 mega 的 4.76×,与耗时比 4.97× 互为印证
text
耗时比 elapsed_mega / elapsed_mono = 1.573 / 0.359 = 4.38×(perf 这次 vs 6.4 的 4.97× 略低,CPU 噪声)
IPC 比 IPC_mono / IPC_mega = 2.19 / 0.46 = 4.76×两个独立维度(耗时 / IPC)都给出一致的 ~4.5× 差异,这不是巧合:单次 miss 的代价 ~15-20 cycle 是固定常数,miss 次数 2100× 摊到 6.21 亿次分支上后,每条指令多承担 ~5× 的等待 → 耗时 5×、IPC 1/5。两个数据互相佐证说明:本次测量噪声 < 10%,结论可靠。
事实 3:branch-instructions ≈ branch-loads —— "全分支几乎都是间接分支"
| 模式 | branch-instructions | branch-loads | 比值 |
|---|---|---|---|
| mono | 621,911,930 | 628,323,172 | 1.01 |
| mega | 621,400,730 | 621,320,516 | 1.00 |
两种模式下 branch-loads / branch-instructions ≈ 1.0——意味着内层循环里几乎所有分支都是间接分支(call *%rax),直接分支(jcc for 循环)占比 < 1%。这进一步坐实:这是一个"纯间接分支实验",没有其他分支逻辑在干扰测量。
baseline 行解释(常见疑问):baseline 是用户故意传错 --mode 让程序打印帮助后退出,0.0054 s 内只跑了 541k branch-loads、3.6M instructions——这不是一种 bench 模式,是 perf 子系统 hook + 程序启动的开销。branch-misses 3.27% 全部是冷启动 iCache/BTB 空 miss,不代表任何真实场景。
对照判据:看到 IPC < 1.0 的微基准 → 一定存在严重 stall(miss pipeline / cache miss / 依赖链);看
branch-load-misses / branch-loads比例 > 5% → 间接分支是主要嫌疑。
7.5 反直觉的发现:为什么巨态 miss 率只有 24.73% 而不是 75%?
理论上 4 个真正随机目标的间接分支,naive 期望 miss 率是 75%(4 个目标均匀分布,predictor 稳态下只能稳定猜 1/4)。但实测只有 24.73%——predictor 比想象的聪明。
为什么 miss 率 < 75%?
ITTAGE 用全局路径历史,不只靠 IP。即使
compute()的 IP 不变,调用栈路径(来自外层 bench 循环、warmup 状态等)会提供额外的区分信号。bench_mega_virtual()内部for (i=0; i<N; i++)这条路径是固定的,predictor 可以利用这一点。目标地址局部性。4 个
OpAdd/OpSub/OpMul/OpShl子类的compute()函数地址固定,4 个目标地址本身就构成一个小集合,predictor 可以记录"这个 IP 的 4 个可能目标"。统计噪声。连续多次命中同一目标时,predictor 在一个时间窗口内会持续猜它(局部最优)。
make_ops(MEGA)虽然是"随机混排",但实际上是预生成的固定数组,前 N=4096 次访问的目标序列是确定的、可重复的。predictor 跑久了会"记住"这个特定序列(虽然不是真的随机)。iCache + BTB 配合。即使 indirect predictor 猜错了目标地址,BTB 仍能命中跳转结构,部分缓解了 miss 的代价。
更纯净的对比:switch(rand() % 5)(第七章发现四)miss 率更高、慢 22 倍。rand() 是 PRNG 流式产生,每次返回都基于前一次状态,predictor 几乎完全无法利用历史。这印证了:
预测器失败不是因为"目标数 > 1",而是因为"目标出现模式不可学"——随机数的"无记忆性"是真正的杀手。
八、perf 命令速查
8.1 基础统计:看间接分支
perf stat 同样建议用 --mode 按模式分开跑,否则八种模式混在一起,只能看到混合总数:
bash
# 对比单态 vs 巨态 —— 这才是核心差异
perf stat -e branch-loads,branch-load-misses ./indirect_branch --mode mono
perf stat -e branch-loads,branch-load-misses ./indirect_branch --mode mega
# 完整指标(加 cycles / IPC / 直接分支 miss)—— 关键看 branch-misses vs branch-load-misses
# 巨态场景:两者应该几乎相等,说明 100% 的分支 miss 来自间接分支
perf stat -e cycles,instructions,\
branch-instructions,branch-misses,\
branch-loads,branch-load-misses \
./indirect_branch --mode mega或者用 Makefile 快捷方式(跑全部模式,适合看耗时对比和对齐):
bash
make perf # 全模式 perf stat
make perf-branch-loads # 只看间接分支注意:branch-loads / branch-load-misses 和 branch-instructions / branch-misses 是两组不同的 PMU 事件:
branches/branch-misses→ 直接分支 (jcc)branch-loads/branch-load-misses→ 间接分支 (call*/jmp*)
8.2 定位具体函数(需 --mode 隔离)
关键警告:不要在全部模式下 perf record——直接调用、单态、巨态、switch 全混在一起,branch-load-misses 是八种模式的混合总量,看不清单个模式的行为。
正确做法:用 --mode 每次只跑一种模式:
bash
# 单态虚函数 —— 预期 branch-load-misses 极低
perf record -e branch-load-misses:pp -g \
./indirect_branch --mode mono
perf report --stdio
# 巨态虚函数 —— 预期 branch-load-misses 显著升高
perf record -e branch-load-misses:pp -g \
./indirect_branch --mode mega
perf report --stdio
# 进入热点函数后看具体指令
perf annotate或者直接用 Makefile 快捷目标:
bash
make perf-record # 单态 + 巨态各 record 一次
make perf-record-dual # 双态
make perf-record-switch-seq # switch 顺序
make perf-record-switch-rnd # switch 随机8.2.1 perf report 输出解读:为什么 miss 报告里占比看上去很高?
perf record -e branch-load-misses:pp 输出的百分比不是 miss 率,而是"miss 发生时刻的调用栈分布"——branch-load-misses 是计数型 event,perf record 只在事件触发时打点采样。
以单态模式为例,perf report --stdio 输出:
bash
Samples: 941 of event 'branch-load-misses:u'
Event count (approx.): 68467
72.37% indirect_branch [.] bench_mono_virtual ← 72% 的 miss 发生在这里
9.75% ld-2.17.so [.] do_lookup_x ← 动态链接器启动 miss
4.37% ld-2.17.so [.] strcmp
4.36% ld-2.17.so [.] check_match.9525
3.41% ld-2.17.so [.] _dl_sysdep_start
3.11% ld-2.17.so [.] dl_main
2.54% libc-2.17.so [.] __dl_addr
2.44% [unknown] [.] 0x0000000000606100 ← 未识别代码理解要点:
一句话总结三类百分比的区别:
| 出现在哪里 | 例 | 分母是什么 | 含义 |
|---|---|---|---|
perf stat 事件后的 (xx.xx%) | (66.67%) | 100% 程序运行时间 | PMU multiplexing 比例(见 7.4.1 解释框),不是性能指标 |
perf stat 事件后的 # xx.xx% of ... | # 24.73% of all branch-loads | 该事件的总计数 | 真正的 miss 率:branch-load-misses / branch-loads |
perf report 的百分比 | 72.37% | 所有采样到的 miss sample 总数 (941) | miss 时刻的调用栈分布:72% 的 miss 发生在这个函数里 |
理解要点:
72.37%≠ 虚函数 miss 率 72%。这是"100% 的 sample 里 72% 的 miss 时刻发生在bench_mono_virtual"。miss 率要用perf stat算(mono 实测 0.011%,见 7.4 节)。为什么
bench_mono_virtual占 72%? 这个函数跑了 4096 × 10000 = 4096 万次 vtable dispatch。即便 predictor 把 miss 率压到 0.011%,仍然有 ~4500 次 miss 发生在这一个函数里——只要这个函数被反复执行,miss 必然聚集在这里。这恰好是健康预测器应该看到的样子:所有 miss 都集中在热路径上,其他地方几乎没有。ld-2.17.so(动态链接器)占 ~20% ——这些 miss 不是 vtable dispatch,而是程序启动时动态链接的间接调用 miss(do_lookup_x/_dl_sysdep_start/dl_main),是 perf 子系统的 hook 在冷启动期采样到的"启动开销 miss",和间接分支预测主题无关。[unknown]2.44% ——通常来自 JIT 代码或没带调试符号的库,perf 无法解析符号;perf record 一次采样的最后一段几乎都会有,无须担心。真正有用的信息:72.37% + 14% + 9% + 2.54% + 2.44% ≈ 100% → 所有 miss 都被分类了,没有"未解释"部分。配合
perf stat算出的真实 miss 率(0.011% / 24.73%),可以双向校验:报告的 miss 时刻分布 + stat 的总数。
总结:
perf report的 72% 不代表"miss 率 72%",而是"72% 的 miss 落在bench_mono_virtual这个热函数里"——这是健康预测器的标志,不是问题。
--mode 值 | 对应实验 | 间接分支特征 |
|---|---|---|
direct | 直接调用(基准) | 无间接分支 |
mono | 单态虚函数 | 一个目标,predictor 学会 |
dual | 双态虚函数 | 两个目标交替 |
mega | 巨态虚函数 | 四个目标随机 |
fptr-single | 函数指针(单目标) | 同单态 |
fptr-multi | 函数指针(多目标轮转) | 四目标轮转 |
switch-seq | switch 跳转表(顺序) | 五目标循环 |
switch-rnd | switch 跳转表(随机) | 五目标随机 |
all(默认) | 全部 | 混合,仅适合看耗时对比 |
8.3 对比虚函数 vs 去虚化
bash
# 如果手上有去虚化版本(如 CRTP、final、PGO),直接对比
perf stat -e branch-loads,branch-load-misses ./indirect_branch # 虚函数版
perf stat -e branch-loads,branch-load-misses ./indirect_branch_devirt # 去虚化版九、实验结论
结论一:单态虚函数不是性能杀手
虚函数调用的开销主要来自间接分支预测失败,而不是"查 vtable 多一次内存访问"。单态场景下间接分支预测器学会目标地址后,开销几乎为 0(实测单态虚函数 26.40 ms vs 直接调用 26.90 ms,差 ~2%)——日常 OOP 代码中绝大多数虚函数调用都是单态的,不必谈虚函数色变。
结论二:巨态虚函数才是真正的瓶颈
四个以上目标随机出现时,间接分支预测器抓瞎,实测耗时比直接调用慢 5 倍以上(542%)。如果用 switch 跳转表实现真正的均匀随机分发,开销甚至可达 22 倍(2202%)。perf stat 直接证据:单态 miss 率 0.011%(68k/621M)→ 巨态 miss 率 24.73%(154M/621M),miss 数增加 2233 倍、耗时增加 4.97 倍。常见巨态场景:插件系统、回调注册表、std::function 容器、类型擦除。
结论三:什么时候该去虚化
| 场景 | 推荐 | 原因 |
|---|---|---|
| 单态虚函数(90%+ 同类型) | 保留虚函数 | 间接分支预测器已学会,开销极小 |
| 双态虚函数(两种交替) | 保留虚函数 | 预测器能学交替模式 |
| 巨态虚函数(多种随机) | 考虑去虚化 | 间接分支预测失败率高,实测慢 5 倍+ |
| 热路径上的虚函数 | 用 perf 确认 | 先 perf stat -e branch-loads,branch-load-misses 看数据 |
| 模板可替代多态 | CRTP / 模板 | 编译期多态,零运行时开销 |
结论四:去虚化手段
| 手段 | 原理 | 适用场景 |
|---|---|---|
final 关键字 | 告诉编译器这个类不再被继承 → 直接调用 | 叶子类 |
| CRTP (Curiously Recurring Template Pattern) | 编译期多态替代运行时多态 | 可模板化的接口 |
| PGO (Profile-Guided Optimization) | 编译器根据实际运行数据,把单态虚函数投机去虚化 | 有 profile 数据的构建 |
std::variant + visit | 用 switch 跳转表替代虚函数表 | 有限类型集合 |
| 分支打散 (branch-to-branch) | 把巨态分发拆成多层双态分发 | 目标数多但有层次结构 |
一句话
间接分支预测器比方向预测器更难——它不仅要猜"跳不跳",还要猜"跳去哪"。实测数据:单态虚函数 ≈ 直接调用(98%)、双态虚函数 ≈ 直接调用(102%);但巨态虚函数慢 5.4 倍(miss 率 0.011% → 24.73%)、随机 switch 慢 22 倍——和直接分支的"随机 → 50% 误判"是同一原理,但间接分支还要猜目标地址,惩罚更重。优化口诀:先 perf 看
branch-loads/branch-load-misses,单态/少态不管,巨态去虚化。
十、关联文档
- concepts/microarch/branch-prediction.md —— 分支预测完整原理
- concepts/microarch/indirect-branch-prediction.md —— 间接分支预测完整专题
- tools/code/perf.md —— perf 工具主文档
- concepts/tools/perf-demos-architecture.md —— perf 12 场景学习架构总纲
- demos/branch-predict —— 直接分支预测实验(有序/随机/无分支)