BT

您是否属于早期采用者或者创新人士?InfoQ正在努力为您设计更多新功能。了解更多

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

| 作者 张攀 发布于 2017年5月23日. 估计阅读时间: 1 分钟 | 道AI风控、Serverless架构、EB级存储引擎,尽在ArchSummit!

读者须知:《强者恒强: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测试工作组副主席。

评价本文

专业度
风格

您好,朋友!

您需要 注册一个InfoQ账号 或者 才能进行评论。在您完成注册后还需要进行一些设置。

获得来自InfoQ的更多体验。

告诉我们您的想法

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我
社区评论

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

讨论

登陆InfoQ,与你最关心的话题互动。


找回密码....

Follow

关注你最喜爱的话题和作者

快速浏览网站内你所感兴趣话题的精选内容。

Like

内容自由定制

选择想要阅读的主题和喜爱的作者定制自己的新闻源。

Notifications

获取更新

设置通知机制以获取内容更新对您而言是否重要

BT