写点什么

强者恒强:x86 高性能编程笺注之分支

  • 2017-05-22
  • 本文字数:4635 字

    阅读完需:约 15 分钟

读者须知:《强者恒强:x86 高性能编程笺注》是云杉网络推出的系列技术分享,该系列文章将分享 x86 高性能开发方面的实践和思考。主要内容目录如下,欢迎各位业界同仁与我们讨论交流相关话题。

  • 第一部分:介绍
    • 寻找软件中的 Hotspot
    • x86 CPU 架构
    • Good/Bad examples
  • 第二部分:性能因素
    • 内存
      • 缓存
      • 数据对齐
      • Prefetch
      • NUMA
      • 大页
      • 实例
    • 循环
      • 数据依赖
      • 循环展开
      • pointer aliasing
      • 实例
    • 分支
      • 流水线
      • 分支预测
      • Branch-less 编程
      • 实例
    • 多线程
      • 锁 / 阻塞
      • CPU 核绑定
      • 无锁操作
  • 附:
    • 性能测试工具

序言

以流水线的眼光来看,分支并不是高速公路上两个目的地的选择,如果预测失败,将是直接拐向出口处的收费站,还是不带 ETC 的。越是高级的流水线,受分支的影响也就越深。我们在观察各种性能测试工具提供给我们的结果的时候,经常会看到“stalled”这个单词,这个词与“发动机”这类词一起连用的时候,就是“熄火”的意思。而 stalled-cycles-frontend,作为一个衡量流水线前端 (Fetch & Decode) 运行水平的指标,与错误的分支预测 (Branch Misprediction) 有密切的关系。

有很多办法可以帮助我们优化分支,除了套用一个 likely()/unlikely() 这种熟知的方式之外,还有很多需要深入了解的知识。想尽量消除分支对性能的影响,先从了解分支开始吧。

分支的类型

分支分为条件分支 (Conditional Branch) 和非条件分支 (Unconditional Branch)。可以很容易的从字义区分:

条件分支对性能的影响最为显著。从前面一节关于流水线的描述中我们已能得知,在判断条件的指令没有经过执行 (EXE) 阶段之前,是无法确定正确的分支的。但等待显然不是最优的方案,CPU 还是会继续选取某一个分支的指令继续送入流水线。当发现这一分支是错误分支的时候,流水线不得不停止所有现有的工作,清空错误分支的指令,并从分支正确的地方重新开始,性能的损失也就不可避免。

对于非条件分支,也需要注意除了 goto 这种专门的转跳指令之外,调用某一个函数,或是函数指针,以及从子函数中返回也都属于非条件分支的范畴。

分支优化的原则

简单来说,就是靠猜。当然,在学术上叫“预测”,其实叫“看人品”也没错。如果能一直猜对正确的分支,那么这部分代码运行起来和无分支代码也没什么区别。这个工作主要由 CPU 架构中的分支预测单元 (Branch Prediction Unit, BPU 或 Branch Predictor) 完成。不过呢,BPU 不掷骰子,所有的预测都是基于历史结果的记录和分析。

Side Note: 分支预测 (Branch Prediction) 和分支目标预测 (Branch Target Prediction) 有区别。分支预测是在确定(条件)分支之前判断哪一分支更有可能;分支目标预测是在确定某一条件分支或无条件分支之后预测其转跳目标以便预取。不过这两者通常由同一处 CPU 电路完成。

在这里简单地讲一下分支预测的原理。2-bit Saturating Counter 是一种古老的技术,但作为现代分支预测器之滥觞,仍然值得我们分析一下。

如下图所示的状态机,共存在 4 种状态。其状态转换的条件,是本次该分支是否被选择。如果以 1 代表被选择 (taken),0 代表没有 (not taken),那么 1 向右边移动,0 向左边移动。当该分支确实是大概率转跳的分支 (taken),那么状态会大部分时间维持在 strongly taken 附近,如此可为下一次的预测提供预测。反之,则状态会维持在 strongly not taken 附近,也可以作为预测的凭据。

Side Note: 各个分支的 Saturating Counter 组织在一个表中,并以指令的地址作为 index,那么 CPU 就可以在 Fetch 阶段取得该指令的 Saturating Counter 状态。

Saturating Counter 确实帮助我们解决了一部分问题。但这种机制也存在缺陷。它对按某种规律重复出现的分支处理结果缺乏良好的支持,而这种情况在我们的程序中也经常出现,考虑如下代码:

复制代码
for (i = 0; i < 1000; i++) {
if (i & 1) { /* If i is odd num */
count++;
}
}

奇数总是间隔出现,if 的分支也是每隔一次循环执行一次。如果继续采用如上的方式,那么 Saturating Counter 总是会在两个相邻状态之间摇摆,并且总是提供错误的预测。对此,又引入了二级自适应预测器 (Two-level adaptive predictor)。

该机制可以理解为,为一个分支分配多个 Saturating Counter,以历史的情况决定使用哪一个 Saturating Counter。以上面的代码举例,if 分支共分配有 4 个 Saturating Counter,Table Entry Index 分别为 00,01,10 和 11。由该分支的最后两次选取结果决定使用哪一个 Saturating Counter。在上面的代码中,选取结果为 01010101010…当最后两次结果为 01 的时候,Index 01 的 Saturating Counter 接受本次的选取结果 0,并向 Strongly not taken 移动且维持该状态;当最后两次结果为 10 的时候,Index 10 的 Saturating Counter 接受本次的选取结果 1,并向 Strongly taken 移动且维持该状态。故当 10 这一选取规律再次出现的时候,对应的 Strongly taken 状态便可帮助 CPU 预测本次该分支将会被选取。如此,便可以为按某一规律出现的分支提供预测。

从上面的分析中可以得出,分支优化的原则就是让程序中分支的选取更加规律,这是一件十分合分支预测器胃口的事情。比如我们在第一节中所举的例子,对输入的随机数据首先排序再进行判断,就是利用的这一原则。但分支优化的最重要的原则,并不是这一条,而是在能不用分支的时候,尽量不要用分支。比如将上面的代码改成:

复制代码
for (i = 0; i < 1000; i++) { /* Ignore the optimization of loop*/
count += (i & 1);
}

性能相关的分支类型

从性能优化的角度来看,程序中的分支大致可分为如下几类:

第一次执行的条件分支

所谓“第一次”执行,可以真的是第一次,也可以是因为缓存更新等原因导致分支预测器中没有保存其历史数据。因为是第一次执行,所以很难对分支的情况作出有效的预测。例如 C 语言中的 if,while,for 以及汇编中的 jz,jne 等指令,CPU 虽然在这种情况下也会有一套执行的标准,但这取决于不同 CPU 的不同实现。除了使用 likely()/unlikely() 人为标注之外,还可以使用一些消除分支的通用方法。但这种情况非常罕见,优化的效果也难尽如人意。对于分支优化,和其他所有的优化一样,首要是抓住经常多次执行的关键代码段,以期取得优化效果的最大化。

重复执行的分支

重复执行的分支可以理解为分支预测器中存有其相关历史的分支。分支预测器能够以历史为依据,对下一次的分支选择做出判断。对这一类型的分支,可以用测试工具查看其 Branch Misprediction Rate。程序员需要做的,是尽可能让此类分支呈现出规律性,消除随机性带来的负面影响。现代的分支预测器已经远远比上文中介绍的复杂,发现分支中隐藏的规律并不是一个有特别难度的问题,当前,前提是规律真的存在:D

间接转跳分支

除了 if,while 之外还有利用函数指针或者 Jump Table 进行的间接转跳分支。间接转跳与 if while 这类转跳的不同之处在于,其转跳目标 (Branch Targets) 存在无限多种可能(函数指针可指向任意函数)。而 if while 只存在两种可能:下一条指令,或者转跳分支的第一条指令。现代的 CPU 同样对分支目标准备了预测机制,但和分支预测类似,该机制也同样依赖于“规律”二字。

非条件直接转跳分支

此类分支属于最少让人操心的分支。分支预测,100% 正确,分支目标预测 100% 正确,很难引发性能下降。

分支优化的技术

使用 Setcc 及 CMOVx 指令

使用 SETcc 及 CMOVx 指令可以帮助消除分支。不过这其实是一种类似于“空间换时间”的交换。这两个指令消除了分支在指令流程上的依赖,但引入了数据依赖,并将进一步影响乱序执行核心 (OOO Core) 的操作。并且这两条指令相当于将两个分支都执行了一次,对于可以预测的分支,一定不要采取这种技术。

Side Notes: 现代 x86 CPU 往往拥有很高的分支预测正确率。持续的无法预测的分支是比较罕见的情况。对于是否采用这两个指令,一定要以实际测试数据为依据。

  • SETcc

举一个经典的例子:

复制代码
R = (A < B) ? NUM1 : NUM2;

R 最终的取值取决于 A 与 B 相较的结果。当 A 与 B 之间无甚关联的时候,CPU 很难预测结果。该条指令比较“正统”的汇编指令如下:

复制代码
cmp a, b ; Condition
jbe L30 ; Conditional branch
mov ebx num1 ; ebx holds R
jmp L31 ; Unconditional branch
L30:
mov ebx, num2
L31:

在 jbe L30 处产生分支,最终 ebx 的取值,取决于 A 与 B 的比较结果。使用 SETcc 指令可以用如下的方式消除分支:

复制代码
xor ebx, ebx ; Clear ebx (R in the C code)
cmp A, B
setge bl ; When ebx = 0 or 1
; OR the complement condition
sub ebx, 1 ; ebx=11...11 or 00...00
and ebx, NUM3 ; NUM3 = NUM1-NUM2
add ebx, NUM2 ; ebx=NUM1 or NUM2

setge 并没有真正避免 A 与 B 的比较,而是依据比较结果修改了寄存器 ebx 的值。之后又利用了补码的一个数学运算上的小技巧,消除了分支。

  • CMOVx

CMOVx 所采用的思路,是将两种可能的结果都先计算出来,然后根据条件判断结果 (EFLAGS) 决定采用哪一结果。

考虑如下代码:

复制代码
if (x < 0) {
x = -x;
}
mov edx, eax ; 假设 x 的值在 eax 寄存器, 该指令使 edx=eax
neg eax ;eax=-eax 该指令的结果将设置条件寄存器的状态
cmovs eax,edx ; 如果状态为负, 将 edx 的值传递给 eax

这两个指令表面看来不存在分支,但其实是将分支判断隐藏在了对条件寄存器(EFLAGS) 的状态判断中。只不过这些指令的判断有 CPU 硬件电路支持,所以和通常意义的软件分支判断有所区别。当分支预测正确率不理想的时候,采用这两个指令会带来一定的性能提升。

使用 likely()/unlikely()

likely()/unlikely() 是最常见的帮助分支预测的方法。其为 GCC 编译器提供的两个内建函数(其他编译器也有类似的功能)的宏:

复制代码
#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)

程序员可以通过这两个宏将分支预测的信息提供给编译器,由编译器根据 CPU 执行代码的标准生成汇编代码,将可能转跳 (likely()) 或可能不转跳 (unlikely()) 的分支的代码放置于合适的位置,以达到人为提供分支预测信息的目的。这两个语句很常见,只要是稍微有点性能优化的意识,就会用到这两个宏。比较好奇的情况是,当实际运行情况与程序员标注的情况相左时,CPU 是否会自己纠正?这就要留给读者自己动手去验证了:D

使用无分支代码

还记得分支优化的第一原则吗?就是尽量消除分支。SETcc 和 CMOVx 指令是一种消除分支的方法,但也还有很多其他的方法。例如我们在第一节所举的例子:

复制代码
if (data[i] > 128) {
sum += data[i];
}

对于影响分支预测的随机输入,除了先排序之外,当我们想采用 CMOVx 指令的时候,可以写为:

复制代码
int t = data[i];
sum += (t > 128) ? t : 0;

CMOVx 指令固然可以消除分支,但并非是没有代价。当我们想避免这种代价的时候,还可以使用如下无分支代码 (Branch-less Code) 操作:

复制代码
int t = (data[i] - 128) >> 31;
sum += ~t & data[i];

无分支的代码是一种编码风格,虽然它彻底消除了分支对 CPU 流水线的影响,但它也存在增加计算量,降低代码可读性的问题。现代 CPU 的分支预测正确率已经可以在一般情况下维持在 95% 以上,所以当分支存在可预测的规律的时候,还是以性能测试的结果为最终的优化依据。

作者简介

张攀,云杉网络工程师,专注于 x86 网络软件的开发与性能优化,深度参与 ONF/OPNFV/ONOS 等组织及社区,曾任 ONF 测试工作组副主席。

2017-05-22 17:492512

评论

发布
暂无评论
发现更多内容

什么是IP地址盗用?又要如何预防?

郑州埃文科技

IP地址 IP地址盗用 安全防御

ATT&CK V11版本发布,新增结构化检测内容

青藤云安全

银行借助纵向联邦学习 集中化进行长尾客群的精准营销

易观分析

联邦学习 联邦计算

应“云”而生,软件觉醒 揭秘华为云软件开发生产线DevCloud如何呼唤高效“开发”

科技热闻

龙蜥云原生机密计算 SIG 成立,7 大开源项目重磅亮相!

OpenAnolis小助手

云原生 开源项目 龙蜥社区 sig

2年,0事故,效能提升10倍的云原生安全最佳实践

青藤云安全

金融行业 金融服务安全 青藤

做不好资产清点的网络安全防护都是耍流氓!

青藤云安全

如何使用Python实现图像融合及加法运算

华为云开发者联盟

Python OpenCV 图像处理 图像融合 加法运算

什么是流动性池?(上)|流动性池的出现及名词解析

区块链前沿News

流动性 Hoo

不愧是阿里高工耗时182天肝出来1015页分布式全栈手册,从基础到高级,把分布式核心原理讲得明明白白

Java全栈架构师

程序员 架构 面试 分布式 程序员人生

引领创新!青藤入选“网信自主创新尖锋企业”

青藤云安全

不用PyScript,网页端运行的Python编辑器

OpenHacker

Python 编辑器 代码编辑器

满足多用途和峰值性能需求,英特尔 Arctic Sound-M成就出色游戏串流体验

科技新消息

当你运行npm run命令时,会发生什么

华为云开发者联盟

JavaScript typescript npm Script run命令

西门子PLC设备如何接入AIRIOT物联网低代码平台 ?

AIRIOT

物联网, PLC 低代码开发 低代码平台

企评家 | 每日互动股份有限公司成长性评价简介

企评家

又是一年开源之夏,八大课题项目奖金等你来拿!

白鲸开源

Apache 大数据 开源 DolphinScheduler workflow

毕业设计项目

凌波微步

「架构实战营」

技术创新!青藤威胁检测论文入选国家中文核心期刊

青藤云安全

论文 威胁检测

Node.js可以用来做什么事?

小学僧

node.js 前端 5月月更

青藤正式加入微软MAPP计划

青藤云安全

青藤参与编写的《数据安全法》实施参考(第一版)发布

青藤云安全

一文简述:容灾等级&保护程度

穿过生命散发芬芳

容灾 5月月更

全新升级!阿里巴巴2022最新Spring源码全家桶全彩笔记开源

Java全栈架构师

spring 源码 程序员 面试 程序人生

重入锁与读写锁

急需上岸的小谢

5月月更

2022年3月视频行业用户洞察:用户增长,长短视频探索共赢新模式

易观分析

短视频 视频

手机网站一键秒变App?详细教程来了

YonBuilder低代码开发平台

APP开发 APICloud 手机网站

基于STM32+华为云IOT设计智能称重系统

华为云开发者联盟

物联网 传感器 stm32 华为云IoT平台 智能称重系统

贝壳上云&云上架构

赵亮-贝壳云原生

云原生 监控 框架 链路 扩缩容

10个产品主导的增长原则|Bessemer

观测云

一文详述DMS资源池队列阻塞告警及原理

华为云开发者联盟

数据库 资源池 DMS 队列阻塞告警 资源池队列阻塞

强者恒强:x86高性能编程笺注之分支_语言 & 开发_张攀_InfoQ精选文章