写点什么

强者恒强: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:492822

评论

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

Netty学习之旅------高仿Dubbo服务调用模型、私有协议实现、编码解码器使用实践

爱好编程进阶

Java 面试 后端开发

[Day23]-[数据结构]手写LRU

方勇(gopher)

LeetCode LRU 数据结构算法

Plato Farm-以柏拉图为目标的农场元宇宙游戏

西柚子

Choreographer全解析

爱好编程进阶

Java 面试 后端开发

Java 线程池原理分析

爱好编程进阶

Java 面试 后端开发

kotlin 如何解决 java 开发痛点,让程序员 happier

爱好编程进阶

Java 面试 后端开发

MySQL-InnoDB-事务

爱好编程进阶

Java 面试 后端开发

初探 Lambda Powertools TypeScript

亚马逊云科技 (Amazon Web Services)

typescript Serverless Lambda AWS

采用百度飞桨EasyDL完成指定目标识别

DS小龙哥

4月月更

世界读书日:我想推荐这几本书

宇宙之一粟

书籍推荐 书单 4月月更

顶级元宇宙游戏Plato Farm,近期动作不断利好频频

小哈区块

2021年秋招,薪资排行NO

爱好编程进阶

Java 面试 后端开发

我是如何用 Amazon Serverless 创建一个门铃的

亚马逊云科技 (Amazon Web Services)

Serverless Lambda AWS showdev

在docker上编译openjdk8

程序员欣宸

Java JVM 4月月更

自动化的艺术

俞凡

架构 大厂实践 PayPal

将新增和编辑的数据同步更新到列表

岛上码农

flutter ios开发 安卓开发 4月月更 跨平台开发

22道Java Spring Boot高频面试题

爱好编程进阶

Java 面试 后端开发

Java岗大厂面试百日冲刺 - 日积月累,每日三题【Day39

爱好编程进阶

Java 面试 后端开发

Java语言特点

爱好编程进阶

Java 面试 后端开发

Java单例模式实现,一次性学完整,面试加分项

爱好编程进阶

Java 面试 后端开发

krpano全景之vtour文件夹和tour

爱好编程进阶

Java 面试 后端开发

解锁OpenHarmony技术日!年度盛会,即将揭幕!

OpenHarmony

大会 OpenHarmony

AtomicIntegerArray源码分析与感悟

爱好编程进阶

Java 面试 后端开发

Java 结合实例学会使用 静态代理、JDK动态代理、CGLIB动态代理

爱好编程进阶

Java 面试 后端开发

Netty 核心源码解读 —— ServerBootstrap 篇

爱好编程进阶

Java 面试 后端开发

OpenFaaS实战之四:模板操作(template)

爱好编程进阶

Java 面试 后端开发

k8s client-go源码分析 informer源码分析(1)-概要分析

良凯尔

Kubernetes 容器 云原生 Client-go

redis优化系列(二)Redis主从原理、主从常用配置

乌龟哥哥

4月月更

Java泛型机制详解;这些你都知道吗?

爱好编程进阶

Java 面试 后端开发

JVM+分布式+算法

爱好编程进阶

Java 面试 后端开发

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