找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

3104

积分

0

好友

416

主题
发表于 4 小时前 | 查看: 2| 回复: 0

无分支优化是性能优化的重要手段之一,其主要原理是通过消除关键代码中的分支语句,以降低分支预测失败率,从而提升代码的运行效率。

本文将通过一个具体的代码示例,逐步应用三种不同的无分支优化方法,深入分析其原理,并借助 perf 工具量化优化效果,最终实现高达3.3倍的性能提升。

本文代码的运行环境:

项目 配置
CPU AMD Ryzen 7 5825U with Radeon Graphics
编译选项 g++ -O2 -g
GCC版本 11.4.0

主要内容如下:

  1. 有分支的基准代码
  2. 借助数组查表优化
  3. 试图借助 cmove 指令优化
  4. 借助算术掩码优化
  5. 有规律的分支条件对性能的影响
  6. perf 性能计数器分析
  7. 完整代码
  8. 总结

指令流水线示意图

01 有分支的代码

先看下面的基准代码,它包含一个关键的条件分支:

// 阻止编译器优化掉对变量a的操作
#define NOTOPTIMIZE(a) asm volatile("" : "+m,r"(a) : : "memory")

void branch_func(const std::vector<int>& p1,
        const std::vector<int>& p2,
        const std::vector<int8_t>& b1,
        int& a1, int& a2) {
    int data_size = p1.size();
    TimeState ts("branch");
    for (size_t i = 0; i < data_size; i++) {
        if (b1[i]) {
            a1 += p1[i] - p2[i];
        } else {
            a2 += p1[i] + p2[i];
        }
    }
    NOTOPTIMIZE(a1);
    NOTOPTIMIZE(a2);
}

在上面的代码中,主要是一个 for 循环,循环内有一个条件判断 if (b1[i]),根据不同的条件进行不同的运算和累加。

NOTOPTIMIZE 宏用于阻止编译器因优化而丢弃对变量 a1a2 的操作。TimeState 是一个简单的计时器类,用于测量代码块的耗时。

性能瓶颈分析:当循环次数非常大时,这段代码的性能瓶颈是什么?答案是 分支预测失效

在现代 CPU 流水线机制下,相邻的多条指令在时间上是重叠执行的,这能极大提升指令吞吐量。然而,当遇到分支语句(如 if-else)时,下一条待执行指令依赖于条件判断的结果。如果 CPU 等待结果完成再执行,流水线的优势就会丧失。

指令流水线示意图

因此,在分支条件结果计算完成前,CPU 的分支预测器会尝试预测程序将走向哪个分支(例如预测执行 if 块还是 else 块)。如果预测错误,CPU 就必须撤销(冲刷)已经部分执行的错误路径上的指令流水线,并重新开始执行正确的路径。这个过程称为 分支预测失效,代价非常高昂,通常会浪费10-20个甚至更多的时钟周期。

if (b1[i]) {
    a1 += p1[i] - p2[i]; // (1) 预测可能执行这里
} else {
    a2 += p1[i] + p2[i]; // (2) 但实际可能执行这里
}

使用以下代码生成随机测试数据并调用上述函数:

const int N = 300*1024*1024;
std::vector<int> p1,p2;
std::vector<int8_t> b1;
int a1 = 0, a2 = 0;
for (int i = 0; i < N; ++i) {
    p1.emplace_back(rand());
    p2.emplace_back(rand());
    // 生成随机的true(1)/false(0)作为分支条件
    b1.emplace_back(rand()&0x1);
}
branch_func(p1, p2, b1, a1, a2);

测试结果显示,处理这批完全随机的数据耗时约 1052 ms。下面,我们将探讨几种消除此关键分支的优化方法。

02 借助数组查表优化

核心思想:利用分支条件 b1[i] 的值 (0或1) 作为数组索引。通过查表直接选择要操作的目标变量指针和对应的计算值,从而彻底避免条件判断分支。

优化后的代码如下:

void nobranch_func_1(const std::vector<int>& p1,
        const std::vector<int>& p2,
        const std::vector<int8_t>& b1,
        int& a1, int& a2) {
    int data_size = p1.size();
    int* a[2] = {&a2, &a1}; // 索引0对应a2,索引1对应a1
    TimeState ts("nobranch_1");
    for (size_t i = 0; i < data_size; i++) {
        int s[2] = {p1[i] + p2[i], p1[i] - p2[i]}; // 索引0对应加法,索引1对应减法
        *a[b1[i]] += s[b1[i]];
    }
    NOTOPTIMIZE(a1);
    NOTOPTIMIZE(a2);
}

这里,a 数组存储了指向 a1a2 的指针,s 数组存储了两种可能的计算结果。b1[i] 作为统一的索引,一次性确定了操作对象和操作数。虽然引入了额外的数组索引和解引用操作,但成功消除了分支。

使用相同的随机数据测试,优化后耗时降至约 358 ms,性能提升至原始版本的 约3倍

03 试图借助 cmove 指令优化

核心思想:利用三元运算符 ? :,期望编译器将其编译为无分支的条件移动指令。cmov 指令会计算两个可能的结果,但仅在条件满足时才将结果写入目标寄存器,避免了实际的分支跳转。

代码如下:

void nobranch_func_2(const std::vector<int>& p1,
        const std::vector<int>& p2,
        const std::vector<int8_t>& b1,
        int& a1, int& a2) {
    int data_size = p1.size();
    int d1 = 0;
    int d2 = 0;
    TimeState ts("nobranch_2");
    for (size_t i = 0; i < data_size; i++) {
        const int diff = p1[i] - p2[i]+d1;
        const int sum = p1[i] + p2[i]+d2;
        d1 = b1[i] ? diff: d1; // 期望编译为 cmov 指令
        d2 = b1[i] ? d2 : sum; // 期望编译为 cmov 指令
    }
    a1 += d1;
    a2 += d2;
    NOTOPTIMIZE(a1);
    NOTOPTIMIZE(a2);
}

然而,使用相同的随机数据测试,耗时约为 1013 ms,相比原始分支版本(1052ms)提升微乎其微。后续的 perf 分析将揭示,编译器在此场景下并未生成我们期望的 cmov 指令。

04 借助算术掩码优化

核心思想:将分支条件 b1[i] (0或1) 及其逻辑非 !b1[i] 作为算术掩码。通过乘法运算,让条件为真时有效累加一个结果,条件为假时累加另一个结果。

void nobranch_func_3(const std::vector<int>& p1,
        const std::vector<int>& p2,
        const std::vector<int8_t>& b1,
        int& a1, int& a2) {
    int data_size = p1.size();
    TimeState ts("nobranch_3");
    for (size_t i = 0; i < data_size; i++) {
        a1 += b1[i] * (p1[i] - p2[i]);   // b1[i]=1时有效累加
        a2 += (!b1[i]) * (p1[i] + p2[i]); // b1[i]=0时有效累加
    }
    NOTOPTIMIZE(a1);
    NOTOPTIMIZE(a2);
}

这段代码非常巧妙。当 b1[i] 为1时,(!b1[i]) 为0,第二行累加结果为0;反之亦然。它用两次乘法和一次逻辑非运算的确定开销,换取了不可预测的分支预测失败开销。

使用相同的随机数据测试,优化后耗时降至约 322 ms性能提升至原始版本的约3.3倍,效果最为显著。

05 有规律的分支条件的影响

无分支优化在任何情况下都优于分支代码吗?我们来测试一种特殊情况:分支条件高度可预测。将测试数据从随机变为有规律交替的 true/false

std::vector<int> p1,p2;
std::vector<int8_t> b1;
int a1 = 0, a2 = 0;
std::cout << "regular input data" << std::endl;
for (int i = 0; i < N; ++i) {
    p1.emplace_back(i);
    p2.emplace_back(i+1);
    // 生成交替的true/false (0x1, 0x0, 0x1, 0x0, ...)
    b1.emplace_back(i&0x1);
}
// 分别调用四个函数测试

耗时对比如下:

优化方法 函数 规律数据耗时 (ms) 随机数据耗时 (ms)
原始分支 branch_func 189 1052
数组查表 nobranch_func_1 336 358
cmove尝试 nobranch_func_2 185 1013
算术掩码 nobranch_func_3 326 322

关键发现

  1. 分支预测器的威力:对于高度可预测的规律数据,原始分支版本的性能 (189ms) 甚至优于无分支优化版本。这是因为现代 CPU 的分支预测器能够完美学习并预测这种简单的交替模式,使得分支预测失效率极低。
  2. 无分支代码的稳定性:数组查表法和算术掩码法的性能对输入数据模式不敏感,无论在规律数据还是随机数据下,耗时都保持相对稳定。
  3. “优化”的失败nobranch_func_2 在两种数据下的表现都与原始分支版本几乎一致,说明其优化并未生效。

这个对比实验揭示了一个重要原则:无分支优化并非银弹。当分支本身高度可预测时,其开销可能远小于无分支化引入的额外计算开销。例如,项目中常见的错误检查分支(如 if (ptr == nullptr) return;)在正常流程中极少被执行,分支预测器能极准确地预测其“不跳转”,此时盲目进行无分支改造可能适得其反。理解这些底层原理是进行有效优化的前提。

06 perf 性能计数器分析

理论需要数据验证。我们使用 Linux 的 perf 工具来统计程序运行过程中的分支数 (branches) 和分支预测失败数 (branch-misses)。

6.1 使用 perf 工具

基本命令如下,它会统计整个进程的运行情况:

sudo perf stat ./a.out

或者,可以指定只监控分支相关事件:

sudo perf stat -e branches -e branch-misses ./a.out

程序运行结束后,会打印类似下面的统计信息:
perf统计输出示例

其中 branches 表示发生分支的总次数,branch-misses 表示分支预测失败的次数。

6.2 统计数据对比

我们对四个函数在两种数据输入下的八种情况进行了 perf 统计,关键数据摘要如下:

分支数 (branches,单位:百万次)

优化方法 函数 规律数据 随机数据
原始分支 branch_func 2,864 17,876
数组查表 nobranch_func_1 2,549 17,561
cmove尝试 nobranch_func_2 2,863 17,878
算术掩码 nobranch_func_3 2,549 17,560

分支预测失败数 (branch-misses,单位:百万次)

优化方法 函数 规律数据 随机数据
原始分支 branch_func 106 265
数组查表 nobranch_func_1 106 108
cmove尝试 nobranch_func_2 106 266
算术掩码 nobranch_func_3 106 107

6.3 数据分析

  1. cmove 尝试彻底失败nobranch_func_2 的分支数和分支预测失败数与原始版本 branch_func 几乎完全相同。这确凿地证明,编译器并未将三元运算符编译为无分支的 cmov 指令,而是仍然生成了条件分支指令。这也解释了其耗时为何与原始版本无异。
  2. 数组与掩码法成功消除分支nobranch_func_1nobranch_func_3 的分支数,在两种数据下均比原始版本稳定减少了约300M次(正好等于循环次数 N)。这直接证明了这两种方法有效移除了循环内部的关键分支语句
  3. 随机数据下分支预测失败大幅降低:在随机数据测试中,nobranch_func_1nobranch_func_3 的分支预测失败数从原始版本的 ~265M 大幅降低至 ~107M。这 ~158M 的减少量,正是被消除的300M关键分支在随机数据下预期的预测失败次数(50%失败率),与理论相符。
  4. 规律数据下无分支优化无优势:在规律数据下,所有版本的分支预测失败数基本一致 (~106M)。这是因为原始版本的分支预测器能够完美预测交替模式,其关键分支的预测失败率极低。此时,无分支优化版本虽然消除了这个几乎不会失败的分支,但引入了额外的计算和内存访问开销,导致其耗时反而更高。

perf 的数据清晰地量化了每种优化手段的实际效果,是性能分析与优化中不可或缺的工具。

07 完整代码

以下是完整的测试代码,包含了基准函数、三种优化实现以及数据生成和测试逻辑:

#include<iostream>
#include<chrono>
#include<string>
#include<vector>

const int N = 300*1024*1024;
#define NOTOPTIMIZE(a) asm volatile("" : "+m,r"(a) : : "memory")

// 简单的计时工具类,记录构造到析构的耗时并打印
class TimeState {
public:
    TimeState(const std::string& name): name_(name) {
        start_ = std::chrono::high_resolution_clock::now();
    }

    ~TimeState() {
        auto diff = std::chrono::high_resolution_clock::now() - start_;
        std::cout << name_ << " cost time:\t"
            << std::chrono::duration_cast<std::chrono::milliseconds>(diff).count()
            << " ms, \t per cost:\t"
            << std::chrono::duration_cast<std::chrono::nanoseconds>(diff*10/N).count()
            << "  *0.1ns" << std::endl;
    }

private:
    std::string name_;
    std::chrono::system_clock::time_point start_;
};

void branch_func(const std::vector<int>& p1,
        const std::vector<int>& p2,
        const std::vector<int8_t>& b1,
        int& a1, int& a2) {
    int data_size = p1.size();
    TimeState ts("branch");
    for (int i = 0; i < data_size; i++) {
        if (b1[i]) {
            a1 += p1[i] - p2[i];
        } else {
            a2 += p1[i] + p2[i];
        }
    }
    NOTOPTIMIZE(a1);
    NOTOPTIMIZE(a2);
}

void nobranch_func_1(const std::vector<int>& p1,
        const std::vector<int>& p2,
        const std::vector<int8_t>& b1,
        int& a1, int& a2) {
    int data_size = p1.size();
    int* a[2] = {&a2, &a1};
    TimeState ts("nobranch_1");
    for (int i = 0; i < data_size; i++) {
        int s[2] = {p1[i] + p2[i], p1[i] - p2[i]};
        *a[b1[i]] += s[b1[i]];
    }
    NOTOPTIMIZE(a1);
    NOTOPTIMIZE(a2);
}

void nobranch_func_2(const std::vector<int>& p1,
        const std::vector<int>& p2,
        const std::vector<int8_t>& b1,
        int& a1, int& a2) {
    int data_size = p1.size();
    int d1 = 0;
    int d2 = 0;
    TimeState ts("nobranch_2");
    for (int i = 0; i < data_size; i++) {
        const int diff = p1[i] - p2[i]+d1;
        const int sum = p1[i] + p2[i]+d2;
        d1 = b1[i] ? diff: d1;
        d2 = b1[i] ? d2 : sum;
    }
    a1 += d1;
    a2 += d2;
    NOTOPTIMIZE(a1);
    NOTOPTIMIZE(a2);
}

void nobranch_func_3(const std::vector<int>& p1,
        const std::vector<int>& p2,
        const std::vector<int8_t>& b1,
        int& a1, int& a2) {
    int data_size = p1.size();
    TimeState ts("nobranch_3");
    for (int i = 0; i < data_size; i++) {
        a1 += b1[i] * (p1[i] - p2[i]);
        a2 += (!b1[i]) * (p1[i] + p2[i]);
    }
    NOTOPTIMIZE(a1);
    NOTOPTIMIZE(a2);
}

void regular_input_test(){
    std::vector<int> p1,p2;
    std::vector<int8_t> b1;
    int a1 = 0, a2 = 0;
    p1.reserve(N);
    p2.reserve(N);
    b1.reserve(N);
    std::cout << "regular input data" << std::endl;
    for (int i = 0; i < N; ++i) {
        p1.emplace_back(i);
        p2.emplace_back(i+1);
        b1.emplace_back(i&0x1);
    }
    branch_func(p1, p2, b1, a1, a2);
    nobranch_func_1(p1, p2, b1, a1, a2);
    nobranch_func_2(p1, p2, b1, a1, a2);
    nobranch_func_3(p1, p2, b1, a1, a2);
}

void random_input_test(){
    std::vector<int> p1,p2;
    std::vector<int8_t> b1;
    int a1 = 0, a2 = 0;
    p1.reserve(N);
    p2.reserve(N);
    b1.reserve(N);
    std::cout << "random input data" << std::endl;
    for (int i = 0; i < N; ++i) {
        p1.emplace_back(rand());
        p2.emplace_back(rand());
        b1.emplace_back(rand()&0x1);
    }
    branch_func(p1, p2, b1, a1, a2);
    nobranch_func_1(p1, p2, b1, a1, a2);
    nobranch_func_2(p1, p2, b1, a1, a2);
    nobranch_func_3(p1, p2, b1, a1, a2);
}

int main(){
    regular_input_test();
    std::cout << "-----------" << std::endl;
    random_input_test();
    return 0;
}

编译并运行:

g++ nobranch.cpp -g -O2 && ./a.out

运行结果:

regular input data
branch cost time:   189 ms,      per cost:  6  *0.1ns
nobranch_1 cost time:   336 ms,      per cost:  10  *0.1ns
nobranch_2 cost time:   185 ms,      per cost:  5  *0.1ns
nobranch_3 cost time:   326 ms,      per cost:  10  *0.1ns
-----------
random input data
branch cost time:   1052 ms,     per cost:  33  *0.1ns
nobranch_1 cost time:   358 ms,      per cost:  11  *0.1ns
nobranch_2 cost time:   1013 ms,     per cost:  32  *0.1ns
nobranch_3 cost time:   322 ms,      per cost:  10  *0.1ns

08 总结

本文通过一个具体的 C++ 代码示例,深入实践并对比了三种无分支优化方法:

  1. 数组查表法 (nobranch_func_1):成功消除分支,在随机数据上性能提升约3倍。代价是引入了额外的数组内存访问和计算。perf 数据确认了分支的消除。
  2. 条件移动 (cmov) 尝试 (nobranch_func_2)优化未生效perf 证实编译器未生成 cmov 指令,其分支行为与原始版本一致。这表明依赖编译器进行此类优化需要实际验证,不能仅凭编码直觉。
  3. 算术掩码法 (nobranch_func_3):成功消除分支,在随机数据上性能提升约 3.3倍(最佳)。代价是引入了额外的乘法和逻辑非运算。代码简洁,是实践中非常有效的技巧。

核心洞见与实践指导

  • 权衡的艺术:无分支优化通过确定的计算开销来消除不确定的分支预测失败开销。在分支预测失败率高(如数据完全随机)时收益巨大;在分支预测成功率高(如条件高度可预测或稳定偏置)时,可能因引入额外开销而适得其反。
  • 数据驱动决策:在进行此类底层优化前,应像本文一样,结合 perf 等工具对目标分支的预测失败率进行量化分析,避免盲目优化。
  • 应用场景:对于项目中常见的、在正常流程中极少执行的分支(如错误检查),其预测成功率极高,通常无需进行无分支优化。应将优化精力集中在那些被频繁执行且条件不可预测的“热”分支上。

掌握高效的编程技巧,并结合扎实的性能分析,才能让我们在性能优化的道路上走得更稳、更远。希望这篇实践解析能为你带来启发,也欢迎在 云栈社区 交流探讨更多性能优化话题。




上一篇:Python实战:基于OpenWeather API从零构建天气查询MCP Server
下一篇:C语言指针与数组深度解析:地址本质、内存布局与实战应用
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-4-8 08:59 , Processed in 0.566702 second(s), 42 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表