读者须知:《强者恒强:x86 高性能编程笺注》是云杉网络推出的系列技术分享,该系列文章将分享 x86 高性能开发方面的实践和思考。主要内容目录如下,欢迎各位业界同仁与我们讨论交流相关话题。
- 第一部分:介绍
- 寻找软件中的 Hotspot
- x86 CPU 架构
- Good/Bad examples
- 第二部分:性能因素
- 内存
- 缓存
- 数据对齐
- Prefetch
- NUMA
- 大页
- 实例
- 循环
- 数据依赖
- 循环展开
- pointer aliasing
- 实例
- 分支
- 流水线
- 分支预测
- Branch-less 编程
- 实例
- 多线程
- 锁 / 阻塞
- CPU 核绑定
- 无锁操作
- 内存
- 附:
- 性能测试工具
前言
高性能软件不仅仅用来构筑市场壁垒。写作高性能软件,是一项杂糅了编程技巧、硬件架构、操作系统、编译器原理等知识与经验的智力享受。这些条件相互促进,又相互制约,像是按照平仄合辙的词牌填词,也像遵循对位赋格来谱曲。性能的提升,会给工程师以巨大的心理奖赏。软件,作为程序员心理活动的副产品,也将一同进入正反馈的循环。所谓“强者恒强”,就是这个道理。
作为时下“软件定义一切”大潮的受益者,基于 x86 和 Linux 服务器的软件开发正受到越来越多的关注。相对于它所要取代的传统硬件,x86 软件的性能始终是其瓶颈所在。但在整个行业上上下下的努力之下,通用 x86 处理器也已经在某些应用中达到了专用逻辑电路的性能指标。在这一系列文章之中,笔者想将自己在性能优化方面的实践和思考分享给大家。因为涉及相对底层的操作,所以在文章中选用了方便的 C 语言作为示例语言。不过主旨仍是讲述原理和实践经验,读者大可不必拘泥于形式。
作为系列的第一篇文章,先来和大家探讨一下性能优化的原则。
什么是“性能”
在实践中,我们有时会不自觉地把一些似是而非的指标用来评判一个软件的性能。最常见的,是用一段代码编译后的汇编指令数目来评判软件的性能。越少的指令,就不言自明地代表了越高的性能。也许我们确实可以观察到这一“表象”,但事实上,指令数目少,是软件性能高的既不充分也不必要条件。下面举一个不是看腻了的横纵遍历数组的例子。
有一组固定长度的伪随机数数组,数值范围在 0 至 255 之间:
uint32_t i = 0; for (; i < ARRAY_LEN; i++) { array[i] = rand() % 256; }
我们希望将其中小于 128 的数字累加求和。除了简单地遍历筛选一遍之外,我们也可以先对数组进行排序之后再执行遍历操作。但“排序”在这里似乎是多余的操作,因为排不排序,都不影响结果的正确性,同时还引入了额外的开销。依据一般“直觉”,排序之后再操作并不是明智的方式。我们的这两种方案表示如下:
#ifdef PRE_SORT qsort(&array, ARRAY_LEN, sizeof(uint32_t), qsort_cmp); printf("Sorted array\n"); #endif uint64_t sum = 0; uint32_t ite_count = 0; for (; ite_count < 10000; ite_count++) { for (i = 0; i < ARRAY_LEN; i++) { if (array[i] < 128) { sum += array[i]; } } }
由宏 PRE_SORT 控制是否启动排序操作。在操作开始和结束的时候分别记录 CPU Cycle 计数,以便比较执行时间。使用 gcc 默认参数编译并执行,首先是引入了排序的代码:
# gcc -DPRE_SORT branch.c # ./a.out Sum: 20787670000, CPU cycles: 4869610134
下面是未引入排序的执行情况:
# gcc branch.c # ./a.out sum: 20787670000, CPU cycles: 14611881138
由于伪随机的原因,两种方式计算的结果都是相同的。而与预想情况不同的是,增加了”多余“排序的程序,执行效率是不排序的 3 倍(在实验的特定平台上)。当我们调整相关参数,比如数组的长度和迭代的次数,还可以使相差更大。
可以使用 rdtsc 指令读取 CPU 的 cycle 计数,由硬件计数器直接提供,根据 CPU 主频可以计算出绝对时间。在某些型号的多核 CPU 上 rdtsc 指令可能会出现计数误差。但时代一直在进步,Intel 已经在新型号的 CPU 上改良了这一情况。后续的文章中会有详细说明。
完整代码已上传至 https://github.com/PanZhangg/x86perf
那么原因是什么呢?要回答这个问题,就必须借助性能测试工具的力量。
在性能优化的过程中,测试工具占据了绝对重要的地位。它给出的结果,几乎可以当作一切性能优化行为的入手点和逻辑起点。在 Linux 环境下,有数种性能测试和性能监控工具。在后续的文章中,会基于最常用的几种,穿插介绍它们的特性和使用方法。
为防止指令乱序执行导致计时不准,可采用 asm volatile("" ::: “memory”); 的方式添加内存屏障。相关概念会在后续内容中逐一介绍。
下面使用 perf 这一工具分析一下两者性能差异的原因,使用命令 perf stat ./a.out,针对未引入排序的程序,我们得到如下信息:
5,902,900,466 instructions # 0.42 insns per cycle 1,312,563,125 branches # 359.846 M/sec 326,150,404 branch-misses # 24.85% of all branches
第一行是总指令数目,以及 IPC(instruction per cycle) 指标。第二行是程序执行过程中的分支数量,第三行是分支判断失败的占比。
而在引入排序的程序中,可以看到:
5,939,795,977 instructions # 1.26 insns per cycle 1,321,069,122 branches # 1084.608 M/sec 406,006 branch-misses # 0.03% of all branches
虽然在指令和分支总数方面都多于前者,但因为显著降低的分支判断失败比例,排序的程序还是以 3 倍的执行速度完成了相同的任务。如果利用工具进一步观察,可以发现未引入排序的代码,在判断一个数字是否大于 128 的时候产生了太多分支判断失败,因为它所处理的数字大小是随机的,CPU 很难做出正确的预测。而在引入排序之后,预测下一个数字是否大于 128,就轻松容易得多。
如果更改了 gcc 的编译参数,比如打开优化选项 -O3,会有什么情况发生?另外针对程序的操作,还能想到哪些优化的方法?请动手试一下。:D
分支预测为何关键,它对程序的性能如何施加影响,以及如何据此提高程序的性能等等,都会在后续的章节中详细说明。在这里,我们希望能先明确一个概念,就是什么是性能。通过上面的例子,可以发现我们经常会使用一些指标来帮助我们评判软件的性能,比如指令数,比如 Cache 命中的次数,比如 CPU 和内存带宽占用率,甚至可以用服务器耗电功率来间接评价。但这些都可以作为“性能的表征”,却都不是性能本身。“性能”其实是一个很宽泛的概念,在不同的语境下都可以有不同的含义,但如果一定要拉出一根墨绳,以我的理解,性能就是一个纯粹的时间概念。可以在更短的时间内完成一项特定的工作,就是性能高;也只有时间短才是性能高的充要条件。
当然,指令少可能时间短,Cache 命中多可能时间短,但是否时间真的短了,也只能是时间说了算。我们平时所提到的 xps,即 x per second 就隐式表达了这一概念。x 可以是数据包,可以是比特,可以是任何程序所关心的东西在时间尺度 per second 上的衡量。也许有人会提到,吞吐是不是性能,并发数是不是性能,资源占用量是不是性能?没有问题,这些都可以作为一种“市场方面的性能”加入到产品介绍手册里。但从 CPU 的角度,它关心什么是吞吐吗?它只是想拼命在最短的时间里执行完指令缓存(CPU Cache)中的指令而已。
基准测试的几点要求
和很多控制系统的架构类似,性能优化的过程是一个负反馈的闭环。上面介绍的例子,其实就可以看作是一次性能优化的过程。当我们不满意程序的性能时候,使用测试工具发现性能的瓶颈。针对瓶颈进行优化,再次测试软件性能,重复这一过程,直至满意为止。在这一闭环中的关键,就是设计有效的基准测试。
一般来说,基准测试需要遵循以下几个原则:
可复现
重复多次可以给出非常近似的测试结果。这意味着需要考虑尽量减少系统等外界因素的干扰,比如同时运行的某个资源占用大户,或者阻塞式 IO,以及各种系统的限制,例如 NUMA 和 CPU 核数等。基准测试的目的并非是求出一个“平均”或者“最大最小”的性能指标,而是为性能测试工具提供一个良好的观察对象,以便发现和诊断性能瓶颈所在。
覆盖典型执行路径
无论什么类型的测试,其实都是对被测对象输入输出的管理。有些测试可以将被测对象看作黑盒子,但性能基准测试,必须要构建能够覆盖典型执行路径的输入。因为作为在使用环境中大量调用的代码,它们的性能是我们主要关心的内容。同时也是给测试工具发挥最大效用提供必要的条件。而像在功能测试中对 corner case 的覆盖倒不必过度考虑。
简单易用
为小的功能点设计有针对性的测试例,除了可以使热点更突出,瓶颈定位更准确之外,更主要的是出于程序员自我心理管理的需要。程序员除了完成代码需求之外,也要时刻考虑如何自我心理调控,这比码代码要重要的多。简单易用的测试例跑起来,每次都是“完成了一次迭代”所附带的心理奖赏,显著降低焦虑水平。即便这次不成功,也愿意尝试第二次。而每次都要花大量时间精力才能执行一次的性能测试,会在得到不理想的测试结果之前过早地达到厌烦阈值。同时复杂意味着不稳定,既是测试结果的不稳定,也是心理的不稳定:D 而这二者其实是一个东西。同单元测试一样,成熟的测试架构可以帮助我们解决这一问题。
关于性能优化的几点误区
性能优化是一项非常注重实践的活动,但很多时候却容易陷入“清谈”和“玄想”的误区中去。对于性能优化,其实和其他学科一样,还是以实验和观察的结果为根基。“你无法管理你不能测量的东西”,像量子力学一样可以通过理论预言粒子的性质也是可能的,但 Intel 还没生产量子 CPU 是不是?
先实现功能,再考虑优化
这是一句经常听到的话,颇有“以大局为重”的意味。性能优化其实是一件和正确性测试很相似的事情,你不能等整个产品都开发完成了才开始跑第一个测试例,这样当(必然)发现了 BUG 之后,定位和改动都要花费巨大的精力与资源。性能优化同理,尤其是希望以性能为卖点的产品。你在敲下第一行代码的时候,就要开始考虑性能。小到结构体怎么构建,里面的变量如何对齐;大到内存的划分,并行架构的设计。就像最基本的函数单元测试例,小型的性能基准实验跑起来,耗费不了太多时间,却能带来饱满的收益。性能优化确实可以集中一段时间来做,但不能到那个时候才开始考虑如何优化性能。
相信直觉
性能优化是反直觉的。好比我们在本文开头举的例子,多余的操作反而提高了性能。在实践过程中,有很多我们一厢情愿地“安插”在软件中的热点。往往是费了力气去优化,性能反而下降了。当软件遭遇性能瓶颈的时候,要第一时间使用性能测试工具帮助你发现热点的真正所在。而对工具的运用和对测试结果的解释,是可以凭借经验的。
同时也应意识到,我们使用的硬件,编译我们软件的编译器等等也都是在不断进步的。很多我们自以为是的性能热点,都已经在我们看不到的地方得到了很好的解决。过去的瓶颈如今已不再,以过去的经验为基础所构筑的直觉自然也是靠不住的。
相信套路
很多时候我们会看到或者听到一些“万金油”式的性能优化方法。这里面最具代表性的三个就是 prefetch、core affinity 和 hugepage。很多人不假思索地绑核,等性能测试结果出来,却成了一声棒喝。举例来说,绑的可能是两个不同的逻辑核心,但它们是不是在同一个物理核心上?所需要读取的内存是否在同一个 NUMA 节点上?是否会造成 False sharing?所有这些都是需要以全局为考量,谋定而后动的。同理,很多人搞性能优化的第一件事就是上 Hugepage 和 prefetch,不谋全局者不足以谋一隅,所遭遇的故事和绑核是一样的。
在后续的文章中,将会针对 x86 软件性能优化的各个方面进行细致地剖析。包括但不限于缓存、循环、分支、多线程、无锁,向量化等内容。
作者简介
张攀,云杉网络工程师,专注于 x86 网络软件的开发与性能优化,深度参与 ONF/OPNFV/ONOS 等组织及社区,曾任 ONF 测试工作组副主席。
感谢木环对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ , @丁晓昀),微信(微信号: InfoQChina )关注我们。
评论