本专题除讲解常见性能问题以及分析、调优手段外,还会重点讲解一些对系统性能至关重要,却又经常被忽视的高级话题,如Cache、指令流水线、Superscalar、SIMD、分支预测、内存屏障等。此外,还会涉及到编译器、操作系统等话题。

一切都要从一个面试题说起!

一道面试题

朋友私信给我,聊了下自己的一段面试经历,有这么一段代码:

图片

免责声明:这个面试官真的不是我!!!

面试官:这段求阶乘的代码怎么样?

答:挺简洁的,简单易懂。不过如果参数n值比较大的话,会导致fact溢出,结果是错的。

面试官:嗯,是的。不过,咱们先不考虑溢出的问题,你觉得这段代码的性能怎么样?

答:时间复杂度是O(n),而且代码比较精炼,性能应该还挺不错的吧?(心虚ing...)

面试官:你能想办法把它优化一下,让性能更好吗?

思考ing...

答:在多CPU系统上,如果n的值比较大的话,可以考虑用多线程来实现。

面试官:嗯,这是一个思路。如果是单CPU呢?

再次思考ing...

答:用GCC编译的话,可以加上优化选项-O3,应该能提高性能。

面试官:嗯,还有吗?

答:没了。

面试官:好了,感谢来参加面试,回去等通知吧!

思考一下,如果是你的话,会怎么回答呢?

下面,来深入讲解一下,隐藏在这道题背后的深层次知识!

本文较长,且涉及到CPU内部很底层的知识,请耐心看完,一定会有收获!

测试程序

测试程序test.c非常简单,计算1000000000的阶乘:

图片

为方便分析,函数calc()前面加上__attribute__((noinline)),禁止GCC把calc内联在main()中。此外,calc()中,fact类型是int,main()中调用calc(1000000000),会导致fact溢出,但不影响测试,不用管它。

编译一下,然后用time命令测量下运行耗时:

图片

然后,把程序稍微改一下,命名为test_2.c:

图片

注意:这里为方便讲解,假设n总是4的倍数。如果要处理n不是4的倍数的情况,只需要在主循环体外,增加一个小的循环单独处理最后的n%4个数,也就是最多3个数即可,对整体性能影响几乎为0.

编译,并用time命令测量下运行耗时:

图片

运行耗时从原来的3.29秒降到了1秒,性能提升了200%!你以为这就完了?

这还不是最终的结果,因为我们还有一个优化技巧还没加上,最终优化后的结果是0.3秒!文末会讲!先不着急,咱们一个一个来讲!

关于循环展开:你真的理解吗?

看到这里,有人会说,不就是循环展开嘛,很简单的,没什么好研究的,而且加了优化选项之后,编译器会自动进行循环展开的,没必要手动去展开,也就没有研究的价值了!

真的是这样吗?先尝试回答下面几个问题:

  1. 1. 循环展开为什么能提高程序性能,其背后的深层次原理是什么?

  2. 2. 循环随便怎么展开都一定可以提高性能吗?

  3. 3. 用了优化选项,编译器一定会帮我们自动进行循环展开优化吗?

第一个问题后面会详细讲解,我们先用实例回答下第2个和第3个问题。

先看第2个问题。

循环随便展开都能提高性能吗?

我们把test_2.c稍微改一下,命令为test_3.c:

图片

仍然是循环展开,只不过把循环展开的方式稍微改了一下。再编译一下,用time命令测量下运行耗时:

图片

和test.c相比运行耗时只减少了0.2秒!为什么同样是循环展开,test_2.c只需要1.6秒,而test_3.c却要3秒,为什么性能差异这么大呢?别着急,后面细讲。

再看第三个问题,加了优化选项,编译器一定会帮我们自动进行循环展开优化吗?一试便知!

-O3,编译器一定会循环展开吗?

重新编译下test.c, test_2.c, 和test_3.c,只不过,这次我们加上-O3优化选项,然后分别用time命令再测量下运行时间。

先是test.c:

图片

加了-O3优化后,程序耗时从原来的3.29秒降到了1.07秒,性能提升确实非常明显!是否好奇,-O3选项对test.c做了什么样的优化,能够把程序耗时降到三分之一?这个后面再讲。

现在,我们先试下test_2.c:

图片

同样,加了-O3后,程序耗时从原来的1秒降到了0.368秒!此外,在同样加了-O3的情况下,使用了循环展开的test_2.c,程序耗时仍然是test.c的三分之一!可见,编译器确实优化了一些东西,但是,无论是否加-O3优化选项,进行手动循环展开的test.c仍然是性能最好的!

最后,再试下test_3.c:

图片

看到了吧?同样加了-O3优化选项的前提下,性能仍然与test_2.c相差甚远!

小结一下我们现在得到的几组测试结果:

图片

在解释这些性能差异的原因之前,必须要先补充一些CPU相关的基础知识,否则无法真正理解这背后的原因!所以,请务必认真看完!

这会涉及到CPU内部实现细节的知识,相对比较底层,而且对绝大多数程序员是透明的,因此很多人甚至都没听说过这些概念。不过,也不用担心,跟之前一样,我会尽量用通俗易懂的语言来解释这些概念。

背景知识:CPU内部架构

指令流水线(pipeline)

所谓流水线,是把指令的执行过程分成多个阶段,每个阶段使用CPU内部不同的硬件资源来完成。以经典的5级流水线为例,一条指令的执行被分为5个阶段:

  • • 取指(IF):从内存中取出一条指令。

  • • 译码(ID):对指令进行解码,确定该指令要执行的操作。

  • • 执行(EX):执行该指令要执行的操作。

  • • 访存(MEM):进行内存访问操作。

  • • 写回(WB):把执行的结果写回寄存器或内存。

在时钟信号的驱动下,CPU依次来执行这些步骤,这就构成了指令流水线(pipeline)。如下图所示:

图片

在CPU内部,执行每个阶段使用不同的硬件资源,从而可以让多条指令的执行时间相互重叠。当第一条指令完成取指,进入译码阶段时,第二条指令就可以进入取指阶段了。以此类推,在一个5级流水线中,理想情况下,可以有5条不同的指令的不同阶段在同时执行,因此每个时钟周期都会有一条指令完成执行,从而大大提高了CPU执行指令的吞吐率,从而提高CPU整体性能。这就叫做ILP - 指令级并行(Instruction Level Parallelism)。如下图所示:

图片

通过把指令执行分为多个阶段,CPU每个时钟周期只处理一个阶段的工作,这样大大简化了CPU内部负责每个阶段的功能单元,每个时钟周期要做的事情少了,提高时钟频率也变得简单了。

前面说过,有了流水线技术,理想情况下,每个时钟周期,CPU可以完成一条指令的执行。那有没有什么方法,可以让CPU在每个时钟周期,完成多条指令的执行呢,这岂不是会大大提高CPU整体性能吗?

当然有!这就是Superscalar技术!(除此之外还有VLIW,不是本文重点,不再展开讨论。)

超标量(Superscalar)

Superscalar,通过在CPU内部实现多条指令流水线,可以真正实现多条命令并行执行,也被称为多发射数据通路技术。以双发射流水线为例,每个时钟周期,CPU可以同时读取两条指令,然后同时对这两条指令进行译码,同时执行,然后同时写回。如下图所示:

图片

目前为止,这一切看起来都很完美,对吧?然而,现实往往没有理想那么丰满!接着往下看吧。

流水线冲突

大家可能注意到了,前面多次强调过,“在理想状态下”,为什么呢?

现实中程序的指令序列之间往往存在各种各样的依赖和相关性,而CPU为了解决这种指令间的依赖和相关性,有时候不得不“停顿”下来,直到这些依赖得到解决,这就导致CPU指令流水线无法总是保持“全速运行”。

这种现象被称之为Pipeline Hazard,很多资料翻译为“流水线冒险”,我觉得“流水线冲突”更为贴切易懂。

归结起来,有三种情况:

  • • 数据冲突(Data Hazard)

  • • 控制冲突(Control Hazard)

  • • 结构冲突(Structure Hazard)

下面分别举例解释这三种类型的冲突。

数据冲突

所谓数据冲突,简单讲,就是两条在流水线中并行执行的指令,第二条指令需要用到第一条指令的执行结果,因此第二条指令的执行不得不暂停,一直到可以获取到第一条指令的执行结果为止。

比如,用伪代码举例:

x = 1;
y = x;

要对y进行赋值,必须要先得到x的值,因此这两条语句无法完全并行执行。

这只是其中的一种典型情况,其他情况不再赘述。

控制冲突

所谓控制冲突,简单讲,就是在CPU在执行分支跳转时,无法预知下一条要执行的指令。

比如:

if(a > 100) {
    x = 1;
} else {
    y = 2;
}

在CPU计算出a > 100这个条件是否成立之前,无法确定接下来是应该执行x = 1 还是执行 y = 2。

为了解决这个问题,CPU可以简单的让流水线停顿一直到确定下一条要执行的指令,也可以采取如分支预测(branch prediction)和推测执行(speculation execution)等手段,但是,预测失败的话,流水线往往会受到比较严重的性能惩罚。之后会有专门的文章分析这个问题,感兴趣的话,可以右上角关注一下!

结构冲突

结构冲突,简单来说,就是多条指令同时竞争同一个硬件资源,由于硬件资源短缺,无法同时满足所有指令的执行请求。如两条并行执行的命令需要同时访问内存,而内存地址译码单元可能只有一个,这就产生了结构冲突。

有了上面这些基础知识做铺垫,接下来就可以开始真正分析这个问题了。

test.c为什么性能最差?

对于计算阶乘,test.c可能是最简单直观、可读性最强的算法。不过可惜的是,它也是性能最差的。

我们再看一下test.c的源码:

图片

说它性能最差,主要有三点原因:

  1. 1. 热点路径无用指令太多。

  2. 2. 热点路径跳转指令太多。

  3. 3. 热点路径内存访问太多。

注意,这里说的无用指令,是指对计算阶乘本身不产生直接影响的指令,但是它们对整个算法的正确性仍然是必不可少的!

为例方便理解,我们来分别看下test.c不加优化选项和加了-O3编译之后的汇编代码。

test.c不加优化选项时

先是不加优化选项的:

图片

绿色方框标注出来的8 ~ 14行是for循环,也就是主循环体。其中,蓝色方框标注出来的8 ~ 11行是真正计算阶乘的代码,12 ~ 14行是循环控制代码,对计算阶乘来说,则是无用代码。

不难看出:

  1. 1. 热点路径上,也就是循环体内无用指令占比是3/7 = 42%!即便在不考虑其他因素的情况下,CPU单单用来执行这些无用的指令,也是一笔不小的开销!

  2. 2. 整个阶乘计算过程中,循环体内需要执行1000000000次条件跳转指令!条件跳转又会造成控制冲突,使得流水线无法全速运行,从而造成巨大的性能损失。

  3. 3. 整个函数一共有10个内存访问操作,而循环体内就有6个内存操作!尽管很多时候可以通过Cache来缓解,但相对于CPU计算速度来说,内存操作仍然是非常慢的,而且容易造成流水线冲突!

那加了-O3优化选项之后,编译器能不能帮我们解决这些问题呢?

test.c加了-O3优化选项后

现在我们看下加了-O3之后的汇编代码:

图片

首先,不得不感叹,现在的编译器的优化真的是太强大了!直接把整个for循环优化成了4条指令!

不难看出,对于test.c而言,加了-O3之后,GCC做的最大的优化是把所有变量存放在寄存器中,消除了所有的内存访问操作!

可以回过头去看下优化之前的汇编代码,整个函数一共有10个内存访问操作,其中6个是在循环体内,而加了-O3之后,整个函数没有任何内存访问操作!难怪-O3编译后性能提升那么多!由此可见,内存访问相对寄存器访问的开销实在是太大了!当然,即便不使用-O3,也有优化内存操作的办法,这个后面再讲。

但是,也不难看出,对于其他两个问题,GCC并没有帮我们解决:现在无用指令占比是 2/4 = 50%! 整个阶乘计算过程,仍然需要执行1000000000次条件跳转指令,仍然无法充分发挥流水线和superscalar的指令并行执行能力!

知道了test.c性能差的原因之后,现在我们来看看,通过手动循环展开,test_2.c又帮我们解决什么问题呢?

test_2.c性能提升原因

再看下test_2.c的源码:

图片

通过对循环进行4次展开,之前每次循环执行1次乘法,现在每次循环执行4次,这就带来了三点很重要的变化:

  1. 1. 循环次数减少75%,无用指令减少了,相应的CPU执行这些指令本身的开销也少了。

  2. 2. 计算过程中,热点路径的条件跳转指令少了75%,这样就减少了由于控制相关引起的流水线冲突,提升了流水线执行的效率。

  3. 3. 提升了指令的并行度,使得CPU superscalar的技术得到更充分的发挥,提高了每个时钟周期并行执行指令的条数。

这也就是为什么在使用同样的编译选项时,test_2.c比test.c的性能提升了200%!不过,热点路径上内存访问操作太多的问题仍然存在。其实,这个其实很好解决,我会在下文给出解决方法。我们先把注意力放在这里所说的三点变化上。

对于第1点和第2点,有了前面介绍的指令流水线的背景知识,即便从C语言的角度也很好理解,不需要过多解释。

至于第3点,为了便于理解,我们和test_3.c对比来看。

test_3.c性能差的原因

再看下test_3.c的代码:

图片

test_3.c虽然也把循环进行了4次展开,但是展开的方式和test_2.c是不一样的。

test_2.c是这样展开的:

fact0 *= i;
fact1 *= i + 1;
fact2 *= i + 2;
fact3 *= i + 3;

而test_3.c则是这样做的:

fact *= i;
fact *= i + 1;
fact *= i + 2;
fact *= i + 3;

很明显,后面一条指令执行前,必须要先知道前面一条语句计算的结果。还记得前面讲过的造成流水线冲突的三个原因吗?这就是典型的数据依赖,会造成流水线冲突!

可见,虽然test_3.c也通过循环展开,减少了无用指令,也减少了热点路径上分支跳转引起的流水线控制冲突,但它同时引入了数据依赖,进而导致流水线冲突,仍然无法发挥流水线和superscalar的指令级并行执行的能力!

这就是为什么,用同样的选项编译时,test_3.c虽然比未经过循环展开的test.c性能稍微提升了一点点,但相比同样循环展开且没有引入数据相关性的test_2.c来说,性能仍然是非常差的!

讲到这里,本想演示下用perf测量出来的性能指标的,但由于篇幅过长,就不再展开讨论了,以后会专门更新文章介绍perf相关工具的使用!

最后,来看一下前面遗留的那个问题:不加优化选项的情况下,怎么解决热点路径内存访问过多的问题。

杀手锏:优化热点路径内存访问

其实很简单,只需要把test_2.c中定义局部变量的时候加上register关键字就可以了:

图片

C语言中,register关键字的作用是建议编译器,尽可能地把变量存放在寄存器中,以加快其访问速度。

我们现在看下,加了register关键字后,test_2.c的性能如何呢?

为方便对比,我们再看下添加register之前,test_2.c的耗时:

图片

然后是加了register关键字之后的耗时:

图片

看到差异了吧,相差3倍!加了register后,几乎达到了和加-O3优化选项一样的性能!

当然,register的使用还有很多限制,而且它只是给编译器的一种建议,不是强制要求,编译器只能尽量满足,当变量太多,寄存器不够用的时候,还是不得不把变量放到栈中,这和-O3的行为是一样的。

register不是本文重点,限于篇幅,不再赘述。

小结

循环展开是一种非常重要的优化方法,也是编译器后端中常用的一种优化方式,它可以通过减少热点路径上的“无用指令”以及分支指令的个数,来更好地发挥CPU指令流水线的指令并行执行能力,从而提高程序整体性能。

很多时候,我们可以借助编译器来帮我们实现这种优化,但编译器也有失效的时候,比如文中这个例子。这时,我们就不得不手动来进行循环展开来优化程序性能。循环展开时,必须尽量减少语句间的相互依赖。

此外,循环展开的次数并没有一个固定的公式,需要根据具体代码和CPU来决定,通常需要多次尝试来找到一个最优值

不过,手动循环展开往往是以牺牲代码可读性为代价的,因此使用时也做好取舍。此外,循环展开还会在一定程度上增加程序代码段的大小,还可能会影响到程序局部性,对cache产生影响,因此使用时候,要仔细权衡。

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐