当前位置: 首页 > news >正文

高性能编程之分支预测

文章目录

  • CPU Instruction pipelining(为什么需要分支预测器)
  • 减缓分支技术
    • 使用计算方法代替branch
    • 可预测的分支概率(Likely/Unlikely)
    • CMOV 指令(条件移动)
    • 查表法
    • 循环展开
  • 总结
  • 参考文献

CPU Instruction pipelining(为什么需要分支预测器)


果要解释这个问题,我们需要先了解现代处理器的工作原理,任何一条指令在CPU中的执行都必须经历如下这些步骤:

  1. 取指(Fetch):取指阶段从存储器读取指令字节,地址为程序计数器(PC)的值。
  2. 译码(Decode):译码阶段完成指令的翻译,从寄存器文件读入最多两个操作数。
  3. 执行(Execute):执行指令,如果是执行的是一条跳转指令的话,这个阶段会检查条件码和分支条件,决定是否选择分支。
  4. 写回(Write Back):将指令执行结果保存到内存中。

现代处理器使用流水线架构主要是为了提高程序执行效率,比如在第一条指令进入执行阶段时,第二条指令已经开始译码,第三条指令处于取指阶段……相对于第一条指令完全执行完并写回内存再开始第二条指令的取指,效率提高了很多倍。当然,现代处理器一般流水线深度高达10-31级,对程序执行速度有着显著提高。
在这里插入图片描述
上述流水线架构对于顺序执行的命令,效果提高显著,但是遇到跳转命令时效率便会急剧下降,对于分支跳转指令,我们在执行完该指令之前是不知道是否发生跳转的,也就是说,我们在分支指令执行完之前,我们无法确定分支指令的下一条指令的地址,所以也就没法把分支指令的下一条命令放入流水线中,只能等待分支指令执行完毕才能开始下一条命令的取指步骤,所以流水线中就会出现气泡(Bubble),这会大大降低流水线的吞吐能力。

为了解决上述问题,分支预测器应运而生。当指令执行到分支跳转指令时,CPU不再是空等待分支跳转指令执行完毕给出下一条命令的地址,而是根据模型预测分支是否发生跳转以及跳转到哪里,CPU将预测到的指令直接放入流水线,去执行指令的取指、译码等工作。

当分支跳转指令完成执行阶段后,给出是否跳转的结果,CPU即可判断分支跳转预测是否正确,如果指令执行后的跳转结果与分支预测器预测结果相一致,则流水线继续往下执行,如果发现分支预测结果出现错误,则需要清空流水线,将前面不该进入流水线的指令清空,然后将正确的指令放入流水线重新执行。

于是出现了在编译器上面做分支预测,以及在CPU上面做分支预测。不管在哪里做分支预测,总会有预测失败的时候。在程序端我们也可以做到减少分支,从而提供代买的吞吐,下面重点介绍程序代码中减少分支代码技术

减缓分支技术

分支预测失败(Branch Misprediction)会导致 CPU 流水线清空,损失 10~20 个时钟周期,对性能影响极大。下面提供了一系列方法来减少分支。

使用计算方法代替branch

  1. 使用算术运算替代if-else
// 传统分支写法
int max(int a, int b) {
    if (a > b) return a;
    else return b;
}

// 无分支写法(CMOV 或掩码)
int max(int a, int b) {
    return a * (a > b) + b * (a <= b);  // 逻辑运算代替分支
}
  1. 利用位运算方法替代if
// 计算绝对值(无分支)
int abs(int x) {
    int mask = x >> 31;      // 如果 x < 0,mask = 0xFFFFFFFF(-1)
    return (x ^ mask) - mask; // 等同于 x < 0 ? -x : x
}

我们来看传统的二分查找方法:

int binary_search(int* arr, int n, int target) {
    int left = 0, right = n - 1;
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (arr[mid] == target) return mid;      // 分支 1
        if (arr[mid] < target) left = mid + 1;   // 分支 2
        else right = mid - 1;                    // 分支 3
    }
    return -1;
}

上述循环语句中存在这三个分支语句,为了提供分支预测性:我们可以将bool转为int(false为0,true为1) 方式

#include <stdint.h>  // 用于 int64_t

int branchless_binary_search(int* arr, int n, int target) {
    int64_t left = 0, right = n - 1;
    while (left <= right) {
        int mid = (left + right) >> 1;  // 等价于 (left + right) / 2
        int cmp = (arr[mid] <= target) - 1;  // arr[mid] <= target → 0 or -1
        left = mid + 1 + cmp;  // if (arr[mid] < target) → left = mid + 1
        right = mid + cmp;     // else → right = mid - 1
    }
    return (right >= 0 && arr[right] == target) ? right : -1;
}

bool转为int(false为0,true为1)虽然任何语言都是未定义行为,但是在主流编译器中都实现成false为0,true为1。因此可以使用

上述二分查找代码我们还可以进一步优化,核心思想:使用 符号位掩码

int branchless_binary_search_v2(int* arr, int n, int target) {
    int64_t left = 0, right = n - 1;
    while (left <= right) {
        int mid = (left + right) >> 1;
        int cmp = (arr[mid] - target) >> 31;  // 取符号位(0 or -1)
        left = mid + 1 + cmp;  // if (arr[mid] < target) → left = mid + 1
        right = mid + ~cmp;    // else → right = mid - 1
    }
    return (right >= 0 && arr[right] == target) ? right : -1;
}

SIMD(如 SSE/AVX)或位运算掩码:

// 传统分支
void clamp(int* arr, int n, int min, int max) {
    for (int i = 0; i < n; i++) {
        if (arr[i] < min) arr[i] = min;
        else if (arr[i] > max) arr[i] = max;
    }
}

// 无分支掩码写法
void clamp(int* arr, int n, int min, int max) {
    for (int i = 0; i < n; i++) {
        arr[i] = (arr[i] < min) * min + (arr[i] > max) * max 
               + (arr[i] >= min && arr[i] <= max) * arr[i];
    }
}

可预测的分支概率(Likely/Unlikely)

如果分支概率可预测,用 __builtin_expect 提示编译器。当然C++20已将likely/unlikely纳入条件提示中了

#define likely(x)    __builtin_expect(!!(x), 1)
#define unlikely(x)  __builtin_expect(!!(x), 0)

if (likely(condition)) {  // 告诉编译器 condition 大概率成立
    // 快速路径
} else {
    // 慢速路径
}

它适用于错误处理(unlikely 标记罕见分支)以及循环中的热点路径(likely 标记高频分支)。

CMOV 指令(条件移动)

在 C 语言中,if-else 和 ?:(三目运算符)都可以用来做条件判断,但它们的底层实现和性能表现有所不同。原因在于:未优化时,?: 可能更快

  1. ?: 更倾向于编译成无分支代码(如 CMOV)。
  2. if-else 可能编译成跳转指令(JMP),导致分支预测失败惩罚(~10-20 CPU 周期)。

if-else 和 ?: 在开启优化(如 -O2 / -O3)后,生成的汇编代码通常相同。

在某些情况下if-else 可能更优,看如下代码

// 三目运算符(两个函数都会调用!)
int val = (cond) ? foo() : bar();

// if-else(只调用一个函数)
int val;
if (cond) val = foo();
else val = bar();

如果 A 或 B 是 复杂计算(如函数调用),?: 会 全部计算,而 if-else 只计算符合条件的部分

CMOV(Conditional Move)是 x86/x86-64 架构提供的一种 无分支条件赋值指令,它能在 不引入跳转 的情况下,根据标志位(Flags)决定是否执行数据移动操作。它的核心作用是 避免分支预测失败带来的性能惩罚,适用于关键路径上的条件逻辑优化。

查表法

如果分支逻辑可以枚举,用 数组查表 代替 switch-case 或 if-else:

// 传统分支写法
char getChar(int type) {
    if (type == 0) return 'A';
    else if (type == 1) return 'B';
    else return '?';
}

// 无分支查表法
char getChar(int type) {
    const char table[] = {'A', 'B', '?'};
    return table[(type >= 0 && type < 2) ? type : 2];
}

进一步语言中的switch的实现原理其一也用到查表方法。因此需要合理选择switch和if语句。

循环展开

减少循环内的分支次数:

// 传统循环(每次迭代都有分支)
for (int i = 0; i < n; i++) {
    sum += data[i];
}

// 循环展开(减少分支)
for (int i = 0; i < n; i += 4) {
    sum += data[i];
    sum += data[i+1];
    sum += data[i+2];
    sum += data[i+3];
}

总结

在分支预测中,优先用无分支技术(如 CMOV、位运算、查表)。进一步避免高频小分支(如循环内的 if)。 可以利用提示编译器分支概率(likely/unlikely)。最后办法SIMD 和循环展开适用于数据并行场景。

参考文献

浅谈 CPU 分支预测技术

相关文章:

  • Mysql数据库基本操作-DML
  • 阿里计算机专业面试黄金宝典2
  • Hadoop大数据平台部署(Hadoop3.2.4+Hive4.0.1)
  • RabbitMQ 深度解析:从基础到高级应用的全面指南
  • 使用 Axios 进行 API 请求与接口封装
  • 傲梅分区助手单文件版:无损分区管理工具
  • 正版金币捕鱼海洋管家APP源码结构解析与运行环境说明
  • 智慧酒店企业站官网-前端静态网站模板【前端练习项目】
  • 多线性读取数据实现QCustomPlot瀑布图
  • javaSE.抛出异常
  • 2025天梯赛 L2专项训练
  • 项目中引入 Redis 及 常用五种数据类型
  • Django:高效构建现代Web应用的利器
  • MAC Mini M4 上测试Detectron2 图像识别库
  • 青少年编程与数学 02-016 Python数据结构与算法 19课题、矩阵算法
  • colorful一词的常见中译
  • 模版初阶及STL简介
  • 快速入手K8s+Docker+KubeSphere+DevOps
  • 基于STM32、HAL库的MAX3109ETJ总线转UART驱动程序设计
  • Python 机器学习 第6章 机器学习的通用工作流程实例
  • 为何未来的福利国家必须绿色且公平
  • 马上评丨发钱奖励结婚,支持婚育就该系统性发力
  • 中方在IMF发声:美滥施关税威胁全球金融稳定,对新兴市场和发展中国家构成严峻挑战
  • 从“高阶智驾”到“辅助驾驶”,上海车展上的“智驾”宣发变调
  • 天问三号开放20千克质量资源,邀国际合作开展火星探测研究
  • 长三角与粤港澳大湾区融合发展,无锡何以成为窗口?