从分支预测角度看 C++:为什么你的热循环慢得离谱?

CPU 用一种叫分支预测的机制在我们的 if 上疯狂赌博。赌赢了它笑嘻嘻,赌输了嘛,那就是全场消费由王公子买单。

指令流水线与分支的断档

1. 一个经典现象

先看一段 C++ 代码,我们可能会觉得这都差不多,性能能差到哪儿去?

c++ 复制代码
int main()
{
    constexpr size_t N = 50000;
    std::vector<int> data(N);
    for (auto& v : data) v = rand() % 256;
    
    // 不排序,直接累加 >=128 的元素
    auto start = std::chrono::high_resolution_clock::now();
    long long sum = 0;
    for (size_t i = 0; i < N; ++i)
        if (data[i] >= 128)
            sum += data[i];
    auto end = std::chrono::high_resolution_clock::now();
    
    auto t1 = std::chrono::duration_cast<std::chrono::microseconds>(end - start).count();
    std::cout << "Unsorted: " << t1 << " us\n";
}

这段代码运行 1000 次后平均耗时为 396.56 us,我们在 start 前进行排序 std::sort(data.begin(), data.end()) 后运行 1000 次平均耗时 160.39 us。

更别提开了优化(-O2)后排序比未排序快了约 5 倍。算法逻辑完全一致,if 条件判断的次数也一模一样,只是数据排了个序,CPU 就像突然被打通任督二脉。

因此 CPU 跑我们的 if 根本不是一行一行老实执行的,它在赌,赌对了起飞,赌错了就在那浪费时间。

2. 流水线式的 CPU

现代 CPU 执行一条指令大概分这么几步:取指 → 译码 → 执行 → 访存 → 写回。简单点来说:

  • 取指(IF):把下一条命令从代码仓库里拿出来。
  • 译码(ID):读懂指令到底要我做什么,并准备好材料。
  • 执行(EX):真正动手算,该加法加法,该移位移位。
  • 访存(MEM):如果需要读写数据内存,就在这个阶段做。
  • 写回(WB):把结果存回寄存器,更新状态。

这五个阶段就像工厂的流水线,每个阶段占用一个时钟周期,但可以同时处理五条不同的指令:

  • 时钟周期1:指令A取指
  • 时钟周期2:指令A译码,指令B取指
  • 时钟周期3:指令A执行,指令B译码,指令C取指
  • ......

在理想情况下,每个周期都能完成一条指令。

我们的循环体核心就是 if (datai >= 128) sum1 += datai。这一句通常会被编译成类似:

test 复制代码
比较 data[i] 和 128
如果小于,跳转到 skip
加法
skip:
i++,判断循环条件

取指阶段要很快决定下一条指令的地址,如果是条件分支,在执行阶段之前,CPU 并不知道会不会跳转,因为条件还没算出来。但为了不流水线断流,取指必须赌一个方向(分支预测)。

  • 未排序:数据随机,分支方向随机,预测经常错。一旦猜错,流水线中已经取进来、译码甚至执行了一部分的错误路径指令必须全部清空,重新从正确地址取指。这会浪费许多时间,所以平均下来每条指令耗时暴增。
  • 排序后:模式极其规律(先连续不跳转,再连续跳转),分支预测器轻松命中,流水线全速奔跑,每个周期几乎都能退休一条指令,所以速度飞快。

我们的测试结果正是流水线在分支预测失败时反复清空流水线的直观代价。

3. 分支预测机制

核心问题来了:CPU 怎么猜分支方向?

分支预测器不看我们代码,不看变量名,它只盯着两样东西:历史模式

一个最糙的版本叫静态预测,纯傻:

  • 永远猜不跳转,或者看偏移方向:向后跳(循环)猜跳转,向前跳(if-else)猜不跳转。

现代 CPU 用的动态预测,则是一边跑一边用小本本记着:

1. 两位饱和计数器

每个分支指令在分支历史表(BHT)里有一个 2 比特状态机:

  • 00 强不跳
  • 01 弱不跳
  • 10 弱跳
  • 11 强跳

每次实际执行后,就往实际方向挪一格。

这玩意对"连续同方向"极其友好,对"单次反方向"容错一次。比如一直不跳,偶尔跳一次,它不会立刻转向,只是变成弱不跳,下次还不跳就又回到强不跳。

但如果我们疯狂来回抽搐,就得疯。有序数组让它稳如老狗,随机数组让它左右横跳,预测正确率直奔 50%,跟蒙选择题差不多。

2. 两级自适应预测

我不光看你自己的历史,我还看最近整个程序的分支走向。

做法:

  • 每个分支有一个局部历史寄存器,记录自己最近 N 次的跳 / 不跳。
  • 再用一个模式历史表(PHT),把"当前全局分支历史"和"该分支局部历史"组合成索引,去查一个两位饱和计数器。

这就等于 CPU 在学"当最近几个分支都是不跳-跳-跳这种模式时,我们这分支大概率会怎么办"。

对于有序数组,局部历史是全不跳全跳,模式极其单调,PHT 里对应条目的计数器很快训练到强状态。对于随机数组,模式本身就是噪声,PHT 里存的计数器被来回洗,跟抽风一样。

3. 感知器预测器和 TAGE

它们的核心是:把我们最近一堆分支走向当成特征向量加权求和,超过阈值就猜跳,否则猜不跳,然后根据实际结果用类似梯度下降的方法更新权重。

如何看穿 CPU 的预测行为

枯燥的理论结束了,现在进入实操环节(已经在尽量减少理论部分了)。

1. 性能计数器与 perf 基础

现代 CPU 里埋着一堆性能监控单元(PMU),相当于留给我们的后门。它们不参与计算,只在旁边看着:执行了多少指令、多少周期、分支预测失败了多少次......我们可以通过 perf 这个工具去读这些计数器的值,而且完全不修改我们的程序。

perf stat

假设我们编译了一个可执行文件叫 branch_demo,只需要:

bash 复制代码
perf stat ./branch_demo

输出大概长这样:

test 复制代码
Performance counter stats for './branch_demo':

      2.60 msec task-clock              #    0.744 CPUs utilized   
         0      context-switches        #    0.000 /sec             
         0      cpu-migrations          #    0.000 /sec             
       157      page-faults             #   60.287 K/sec           
10,413,160      instructions            #    1.67  insn per cycle 
 6,224,285      cycles                  #    2.390 GHz
 1,776,701      branches                #  682.245 M/sec 
    34,883      branch-misses           #    1.96% of all branches           

       0.003501470 seconds time elapsed

       0.002370000 seconds user
       0.001185000 seconds sys

这里我们只盯着最后两个:branches 是执行的所有分支指令总数,branch-misses是预测失败次数。1.96% 的失败率看着不高,不过现代 CPU 上一个错误预测的惩罚大约在 15~20 个周期,也就是说我们的程序需要留些时间给预测器擦屁股。

失败率飙到 10% 以上,基本可以判定我们的核心热点里有个反骨分支,CPU 猜一次错一次,流水线冲得跟开闸泄洪一样。

ps:跑 perf stat 需要权限,如果我们看到 access to performance monitoring and observability operations is limited 之类的报错,临时调一下 perf_event_paranoid:

bash 复制代码
echo 0 | sudo tee /proc/sys/kernel/perf_event_paranoid

查看可用事件,专挑分支相关

perf list 能列出当前 CPU 支持的所有事件,我们可以 grep 出跟分支相关的:

bash 复制代码
perf list | grep branch

我们会看到:

  • branch-instructions OR branches
  • branch-misses

之类的这些东西。

需要更精确时,可以这样干:

bash 复制代码
perf stat -e instructions,cycles,branches,branch-misses ./branch_demo

然后就能看到想看的东西:

test 复制代码
23,580,129       instructions            #    1.24  insn per cycle
18,948,276       cycles
 4,174,084       branches 
    97,751       branch-misses           #    2.34% of all branches

       0.015768591 seconds time elapsed

       0.012103000 seconds user
       0.003724000 seconds sys

当心多线程

如果我们的程序是多线程,perf stat 默认统计的是所有线程的总和,包括预测失败也是累加值。想分线程看,可以加 --per-thread;想分 CPU 核看,上 -a 和 --per-socket。但说实话,多线程下分支预测失败互相污染,分析起来脑壳疼,建议先从单线程热路径入手。

虽然 perf 是 Linux 的默认选择,但有时候它不够用。感兴趣可以了解其它的工具(Intel VTune Profiler、AMD uProf......),这里就不介绍了。

2. 小实验

最后来个小实验,写个完整程序,然后 perf stat 一跑,效果立竿见影。

c++ 复制代码
#include <iostream>
#include <vector>
#include <algorithm>

int main(int argc, char *argv[])
{
    // 通过参数决定排序
    bool sort_first = false;
    if (argc > 1) sort_first = true;

    constexpr size_t N = 32000; // 数组大小
    constexpr size_t REPEAT = 1000; // 关键循环重复次数

    std::srand(0);
    std::vector<int> data(N);
    for (auto &v : data) v = std::rand() % 256;

    // 排序
    if (sort_first)
        std::sort(data.begin(), data.end());

    // 多次重复循环
    volatile long long sum = 0;
    for (size_t r = 0; r < REPEAT; ++r)
    {
        for (size_t i = 0; i < N; ++i)
        {
            if (data[i] >= 128)
                sum += data[i];
        }
    }

    // 防止编译器把循环优化没
    std::cout << sum << std::endl;
}

然后编译:

bash 复制代码
g++ -o branch_test branch_test.cpp

跑两次,一次不排序,一次排序,都用 perf stat 套上:

bash 复制代码
# 不排序
perf stat -e instructions,cycles,branches,branch-misses ./branch_test

# 排序
perf stat -e instructions,cycles,branches,branch-misses ./branch_test sorted

不排序的结果:

test 复制代码
8,325,661        branch-misses         #    8.08% of all branches

排序后的结果:

test 复制代码
608,569          branch-misses         #    3.98% of all branches

不看别的,直接盯分支失败率:从 8.08% 暴跌到 3.98% ,失败率砍半。指令数一模一样,逻辑一模一样,性能却天差地别,这差距就是分支预测失败造成的。

提高分支预测成功率的方法

1. \[likely] 与 \[unlikely]

C++20 给了我们标准化的属性 \[likely] 和 \[unlikely],不过它不直接修改分支预测器,而是修改代码布局。

编译器看到 if (cond) \[likely] { ... } 时,会把 { ... } 这块代码放在不跳转的路径上,也就是紧跟在比较指令之后。

对于静态预测器,这正好命中;但对于现代动态预测器,其布局的影响已经远小于历史模式了,除非预测器冷启动时没有历史记录,才会退回到静态预测。所以如果我们觉得加了 \[likely] 就能让 CPU 猜得更准,那大概率是在自我安慰。

它真正的威力在于减少 I-cache 的浪费:把热门路径挤在一块,冷门路径踢远点,指令缓存用得更紧凑,间接减少了取指延迟。

我们先写一个简单的函数,看看编译器怎么对待这个属性:

c++ 复制代码
// example1_likely.cpp
int process(int val)
{
    if (val == 0) [[unlikely]]
    {
        volatile int sink = -1; // 强制有副作用
        return sink; // 错误路径,极少发生
    }
    else [[likely]]
    {
        return val * 2; // 主路径
    }
}

编译并查看汇编(-O1):

bash 复制代码
g++ -std=c++20 -O1 -c example1_likely.cpp -o example1_likely.o
objdump -d example1_likely.o

关键部分出来的汇编长这样:

test 复制代码
_Z7processi:
    endbr64
    lea    (%rdi,%rdi,1),%eax     # eax = val * 2 (高频路径预先计算)
    test   %edi,%edi
    je     c <_Z7processi+0xc>    # 如果 val == 0 跳转到低频路径
    ret                           # 否则直接返回 eax (val*2)
    # 低频路径:
    movl   $0xffffffff,-0x4(%rsp) # 将 -1 写入栈内存
    mov    -0x4(%rsp),%eax        # 从内存读回 eax
    ret
  • 高频路径(val != 0)是直通(fall‑through):je 不跳转时,顺序执行下一条 ret,eax 仍是 val*2,零开销返回。
  • 低频路径(val == 0)需要条件跳转:je c 跳到函数后面的冷代码块,执行额外的写入/读取操作后才返回。

这正是 \[likely] 在 C++20 中的作用:将概率更高的分支放在 fall‑through 位置,减少流水线因跳转而中断的概率;而 \[unlikely] 则把低概率路径移开,即使跳转发生,也因为概率极低,对整体性能影响很小。

我们把 \[likely] 和 \[unlikely] 去掉,布局就会颠倒:

test 复制代码
_Z7processi:
    endbr64
    lea    (%rdi,%rdi,1),%eax      # eax = val * 2
    test   %edi,%edi
    jne    17 <_Z7processi+0x17>   # 如果 val != 0 跳转到 ret
    movl   $0xffffffff,-0x4(%rsp)  # val == 0 时执行(fall-through)
    mov    -0x4(%rsp),%eax
    ret
  • 高频路径(val != 0):需要 jne 跳转才能到达 ret。
  • 低频路径(val == 0):位于直通(fall-through) 位置。

这就是它实际的作用:影响代码布局,减少热路径上的跳转次数,从而节省取指带宽和减少 I-cache 压力。

2. 无分支编程

这招容易写出让人看不懂的代码。如果我们无法让分支被预测,那就把它变成顺序执行,让 CPU 根本不用猜。

经典手段是用位运算和算术运算替代 if-else:

c++ 复制代码
// 原来:难预测
if (a > b)
    result = x;
else
    result = y;

// 改为无分支
int mask = (a > b) ? -1 : 0; // 或 -(int)(a > b)
result = (x & mask) | (y & ~mask);

比较操作 (a > b) 在 C++ 里结果是 bool,转为 int 是 0 或 1,再取负就是 0 或全 1 的掩码。两条位运算和一条比较,全是数据依赖,没有分支指令。

不过别盲目追求无分支,现代编译器在高优化级别可能会主动把我们的三元运算符翻译成无分支的 cmov(条件传送指令),我们写 if-else 可能已经没有分支了。我们需要用 perf stat 去确认是不是真的还有 branch-misses 的异常,无脑把 if 改成位运算是搬起石头砸自己的脚,先把编译器生成的汇编看一眼再说。

那么来个测试程序看看吧:

c++ 复制代码
// example2_abs.cpp
#include <iostream>
#include <chrono>

int abs_naive(int x) { return x < 0 ? -x : x; }
int abs_branchfree(int x) { int m = x >> 31; return (x + m) ^ m; }

int main(int argc, char* argv[])
{
    bool use_naive = true;
    if (argc > 1 && std::string(argv[1]) == "branchfree")
        use_naive = false;

    constexpr int N = 10000000;
    volatile int sink = 0;
    int* data = new int[N];
    for (int i = 0; i < N; ++i) data[i] = (rand() % 200) - 100;

    if (use_naive) {
        for (int i = 0; i < N; ++i) sink += abs_naive(data[i]);
    } else {
        for (int i = 0; i < N; ++i) sink += abs_branchfree(data[i]);
    }

    std::cout << "sink = " << sink << std::endl;
    delete[] data;
}

用 -O2 编译并用 perf stat 查看

bash 复制代码
perf stat -e branches,branch-misses ./example2_abs naive
perf stat -e branches,branch-misses ./example2_abs branchfree

我们会看到 abs_naive 循环的 branch-misses 为 3.02%,而 abs_branchfree 版本生成的就没有分支指令,因为编译器会用 cmov 或直接按无分支的位运算来编,branch-misses 为零,IPC 高出一截。

test 复制代码
Performance counter stats for './example2_abs naive':

1,071,096      branch-misses           #    3.02% of all branches

Performance counter stats for './example2_abs branchfree':

<not counted>      branch-misses

3. 让分支行为有规律

我们的第一个排序示例已经说明一切:纯随机的分支就是预测器的灾难,排序后规律尽显,失败率跌到地板。

这里补充几个更常见的套路:

  • 按类型分类处理

如果我们有一个大循环,里面根据对象类型做不同操作,并且类型混杂出现,那分支模式就是一团乱麻。改成先按类型排序,然后同一类型的对象批量处理,预测器会爽到起飞。代价是排序本身花时间,得衡量。

  • 避免在热循环里对"几乎不变但偶尔变"的标志位反复判断

比如循环里检查 while (!done),而 done 绝大多数时间为 false,偶尔被其他线程改成 true。这看似无害,但每次循环预测器都要赌,赌错了就是误冲刷。更好的方式是让那极少发生的事件触发别的手段,比如信号、事件通知。

  • 用 bitmap 或其他数据结构集中处理

如果有些条件只在特定元素上触发,可以先用一个 bitmap 标记出来,然后集中处理标记的位置。这等于我们手动把随机的分支模式重排成了连续相同结果的序列。

4. 驯服间接分支

间接分支是预测失败的重灾区,它不仅要猜方向,还要猜目标地址,猜错了代价比普通条件分支大得多。因为整个指令流都偏了,乱序窗口中挤满的都是错误路径指令。

间接分支的痛,写个多态容器遍历一下就懂了:

c++ 复制代码
// example3_indirect.cpp
#include <iostream>
#include <vector>
#include <memory>
#include <chrono>
#include <cstdlib>

struct Base
{
    virtual int value() = 0;
    virtual ~Base() = default;
};
struct A : Base { int value() override { return 1; } };
struct B : Base { int value() override { return 2; } };
struct C : Base { int value() override { return 3; } };

int main()
{
    constexpr int N = 10000000;
    std::vector<std::unique_ptr<Base>> objs;
    for (int i = 0; i < N; ++i)
    {
        int r = rand() % 3;
        if (r == 0) objs.push_back(std::make_unique<A>());
        else if (r == 1) objs.push_back(std::make_unique<B>());
        else objs.push_back(std::make_unique<C>());
    }

    volatile long long sum = 0;
    auto t0 = std::chrono::high_resolution_clock::now();
    for (const auto& p : objs) sum += p->value();
    auto t1 = std::chrono::high_resolution_clock::now();
    std::cout << "virtual call: " 
        << std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count() 
        << " us" << std::endl;
    std::cout << "sum = " << sum << std::endl; // 防止编译器优化掉循环
}

这次就不开任何优化直接编译,使用 perf stat 查看一下:

bash 复制代码
perf stat -e branches,branch-misses ./example3_indirect
virtual call: 175571 us

 Performance counter stats for './example3_indirect':

180,648,985     branches
19,177,725      branch-misses           #   10.62% of all branches

       2.814842034 seconds time elapsed

       2.656043000 seconds user
       0.160002000 seconds sys

10.62% 的整体分支预测失败率,真高。

所以为什么会有这么多分支预测失败?

在我们的循环核心:

c++ 复制代码
for (const auto& p : objs) sum += p->value();

这里 p->value() 是一次虚函数调用,它会被编译成:

  1. 从对象的 vtable 指针找到 vtable;
  2. 从 vtable 里取出 value 的函数地址;
  3. 间接跳转

这个间接跳转的目标地址完全取决于当前对象的真实类型。由于我们的对象是 A、B、C 随机混杂排列的,每次循环的目标函数都可能不同,CPU 的间接分支预测器很难猜中下一个目标地址,因此会产生大量预测失败。

  • 普通条件分支,如 if 是两选一(跳或不跳),模式简单时预测准确率高。
  • 间接分支(虚函数、函数指针、switch 跳表等)可能有多个目标,预测难度更大。在目标地址随机变化时,预测失败率会明显上升。

不过开启 -O2 优化后就是另一回事了。编译器通过去虚拟化、内联、循环展开,将原来随机性极强、难以预测的间接跳转变成了可预测的条件分支,失败率会大幅降低。

当然也可以使用 std::variant + visit,不过也是治标不治本。最好的方法还是把对象按类型排序,让相同类型的对象连续存放,那么间接跳转的目标地址就会变得规律。

5. PGO

既然我们很难手工标注所有概率,为什么不让编译器实际跑一遍程序,记录下真相,然后第二次编译时利用这些信息?这就是配置文件引导优化(PGO)。

操作流程不复杂:

  • 插桩编译:-fprofile-instr-generate (Clang) 或 -fprofile-arcs (GCC)
  • 用代表性负载运行程序,生成 .profraw / .gcda 文件
  • 重新编译,应用配置文件:-fprofile-instr-use 或 -fprofile-use

来个例子:一个简单的字符串处理函数,大部分输入都是短字符串,偶尔长字符串。

c++ 复制代码
// example4_pgo.cpp

// 计算字符串长度
int process(const char* s)
{
    size_t len = strlen(s);
    if (len > 100) [[unlikely]]
    {
        // 长字符串走复杂但极罕见的路径
        int hash = 0;
        for (size_t i = 0; i < len; ++i) hash = hash * 131 + s[i];
        return hash;
    }
    else
    {
        // 短字符串快速路径
        return len * 2;
    }
}

int main()
{
    // 90% 短,10% 长
    char buf[200];
    volatile int r = 0;
    for (int i = 0; i < 100000; ++i)
    {
        int len = (rand() % 100 < 90) ? 10 : 150;
        memset(buf, 'a', len);
        buf[len] = '\0';
        r += process(buf);
    }
    std::cout << "Result: " << r << std::endl;
}

使用 PGO,以 Clang 为例:

bash 复制代码
# 插桩编译
clang++ -O2 -fprofile-instr-generate example4_pgo.cpp -o pgo_gen

# 运行程序,生成 .profraw 文件
LLVM_PROFILE_FILE="pgo_%p.profraw" ./pgo_gen

# 合并 raw 文件
llvm-profdata merge -output=code.profdata pgo_*.profraw

# 用 profile 重新编译
clang++ -O2 -fprofile-instr-use=code.profdata example4_pgo.cpp -o pgo_opt

然后分别 perf stat 对比 pgo_gen 和 pgo_opt,这里就不放了。

还得注意一下我们使用 PGO 得保证测试数据集必须代表真实负载,否则编译器就学了一身歪门邪道,优化出个负提升。并且每次改代码重跑一次 profile,CI 时间直接膨胀,但效果是实打实的。

结尾

那么我们以后还能不能写 if 了?能写,当然能写,但别让 CPU 太难做。

不给数据排序,还不开 -O2,还指望它跑得快------典型的又想马儿跑的快,又不给马儿吃草,哪有那么好的事。

最后一句话:我们可以不相信爱情,但要相信 perf stat 里的 branch-misses

相关推荐
郝学胜-神的一滴2 小时前
Qt 高级开发 018:复刻经典登录界面布局与窗口美化全解析
开发语言·c++·qt·程序人生·用户界面
郝亚军2 小时前
IEEE 754 单精度浮点的SEM表示
开发语言·c++·算法
Yyyyyy~3 小时前
【C++】数组篇
开发语言·c++
qq_333120973 小时前
C++高并发内存池的整体设计和实现思路_C 语言
java·c语言·c++
牛肉在哪里3 小时前
ros2 从零开始27 编写广播C++
开发语言·c++·机器人
Curvatureflight3 小时前
前端国际化 i18n 落地实践:语言包、动态文案和格式化问题怎么处理?
前端·c++·vue
黄小白的进阶之路4 小时前
C++提高编程---3.9 STL-常用容器-map/multimap 容器【P231~P235】
c++
WBluuue4 小时前
Codeforces 1096 Div3(ABCDEFGH)
c++·算法
誰能久伴不乏4 小时前
ibmodbus “Invalid argument“ 错误的排查与修复
c++·qt·modbus