现代处理器与代码性能优化
2019-07-13 19:09 发布
生成海报
现代处理器的一些特性 现代处理器取得了了不起的功绩之一,是他们采用复杂而奇异的处理器结构,其中,多条指令可以并行的执行,同时又呈现出一种简单的顺序执行指令的表象。——《深入理解计算机系统》 在我们直观的认识中,处理器就是那个按着编译好的代码指令,不断顺序重复着取指、译码、执行操作的单调而可靠的机器。事实上,现代处理器对待代码指令的处理方式,早已不再是表面上看起来的那么规矩,对不同形式的代码,它将可能呈现不同的运行策略。 关于现代处理器的特性,本文只简单介绍与后面代码优化技巧有关的几个,更多更丰富的特性介绍,建议参考资料[1],里面有专业而详细的描述。 1.1 超标量 可以在每个时钟周期执行多个操作的处理器称为“超标量处理器”。现代处理器主要从两个方面实现超标量处理: 多个并行的功能单元。这些单元能同时执行相同或不同的指令,如TI的C64X+架构就配置了8个并行功能单元,分别负责乘加、逻辑、存取等操作。VLIW(Very Long Instruction Word)超长指令集被设计来给这多个功能单元进行指令分发; SSE(Streaming SIMD Extensions, 流SIMD指令扩展 ),SIMD即Single-In-struction,Multiple Data(单指令多数据)。通过扩展额外的矢量处理功能单元以及矢量寄存器等,可以实现单个指令控制多路相同的计算,如一次做8个Byte的数据存取 ,又或是一次做8个16x16乘法。ARM处理器中的NEON协处理器就是对ARM架构的SIMD扩展。 1.2 高速缓存 高速缓存(cache)是一个小而快速的存储设备,一般而言,CPU对高速缓存的访问速度仅次于寄存器。缓存空间的大小不仅与其价格高有关,更重要的是随着存储能量的扩大,存储的访问延迟将随之增加,因此很多处理器设计了多级的缓存结构,越靠近CPU的层级其容量越小。 现代处理器包括独立的I-cache(指令缓存)和D-cache(数据缓存),它们由专门的硬件逻辑来管理。简单来说,缓存管理器在CPU第一次访问某个低层级的存储时(缓存缺失),会连带把该存储地址之后的多个指令/数据与上级缓存交互,这样当CPU接下来想访问下一个连续的指令/数据时,就只需要访问缓存即可(缓存命中)。 有这样几个重要指标来衡量高速缓存的性能:缺失率、命中率、命中时间、缺失处罚。其中从缺失 处罚这个指标中,我们来看看缓存对加快CPU的运算性能有多么的重要。 缺失处罚是由于缓存不命中所需要的额外时间开销。对L1高速缓存来说,命中时间的数量级是几个时钟周期;L1缺失需要从L2得到服务的处罚,通常是数10个周期;从L3得到服务的处罚为50个周期;从主存得到服务的处罚为200个周期! 1.3 分支预测、投机执行、条件传送 分支代码对于流水线处理而言是一个障碍,因为编译器,包括硬件非常有可能无法预知下一步到底将执行哪个分支的指令,于是只好等待分支判断结果出来后再继续填充流水线,造成流水线中的“空泡”。 现代处理器采用了一种称为分支预测的技术,它会猜测是否会选择分支,同时还预测分支的目标地址。之后,使用投机执行的技术,处理器会开始取出位于它预测的分支跳转处的指令,并对指令译码,甚至在它确定分支是否预测正确之前就开始执行这些操作。直到确定了实际的分支路径,如果预测正确,处理器就会“提交”投机执行的指令的结果。 当分支预测逻辑预测错误时,条件分支可能会招致很大的“预测错误惩罚”。这时,处理器必须丢掉所有投机执行的结果,在正确的位置重新开始取指令的过程,在产生有用的结果之前,必须重新填充指令流水线。 另一种处理分支的方法是使用“条件传送指令”。编译器能产生使用这些指令的代码,依据条件满足与否选择执行或忽略指令,而不是传统的基于控制的条件转移。翻译成条件传送的基本思想是计算出一个条件表达式或语句两个方向上的值,然后用条件传送选择期望的值。条件传送指令可以被实现为普通指令流水线化处理的一部分,没有必要猜测条件是否满足,因此猜测错误也没有处罚。 1.4 乱序执行 对于单个线程而言,如果只是顺序执行指令,有时后面的指令需要依赖前面指令的执行结果,因此可能引起功能单元或流水线等待,降低了处理效率。 乱序执行是指在逻辑上在后面的指令可以先于前面的指令执行,这更提高了硬件的执行效率,达到更高的指令级并行度。处理器采用一种“寄存器重命名”的方式实现指令乱序执行的同时,保证不影响程序最终的结果。 代码优化的必要性 现代处理器具有相当的计算能力,但是我们可能需要按非常程序化的方式来编写程序,以便将这些能力诱发出来。——《深入理解计算机系统》 让我们暂时抛开代码架构以及代码可读性,只谈论对整个程序运行性能影响最大的那些核心代码段。大部分程序员更多关心的是实现代码功能的算法,而很少注意到使用对编译器和处理器友好的代码。例如数组排序,我们会想到底是用冒泡排序,还是插入排序,亦或是堆排序……然后乐此不疲地比较哪种算法可以消耗最少的算力。在工程实践中我发现,适当调整代码实现的技巧,往往能比选择算法本身带来更大的效率提升,有时这种提升是成倍甚至几十倍的!这么说当然不是认为算法选择不重要,而是想说明代码优化同样非常重要。 编译器通常集成有优化器,能自动地对用户代码做出合适的优化。尽管有些优化器已经极尽其所能了,但人为的优化干预依然是必要的。一方面,调整代码结构有风险,为避免因优化造成的代码错误,编译器总是做保守估计;另一方面,由于通常的程序本身不具有并行性,严重地削弱了通过超标量执行实现的指令级并行性,即使最聪明的乱序 超标量处理器,同时结合聪明的和富有竞争性的编译器,依然会 受到加载延迟、 cache 缺失、分支和指令之间相关等的综合影响,使得处理器在 很少的周期内充满 ( 全速运行) 。 鉴于上面的原因,用户可以大致从两个方面着手优化自己的代码: 编译器友好化。理解优化编译器的能力和局限性,尽量通过代码本身和预编译伪指令“告诉”编译器用户的真实意图; 处理器友好化。调整代码实现方式,尽可能充分地利用处理器的硬件单元。 尽管同样的优化策略在不同的处理器上不一定有同样的效果,但是操作和优化的通用原则,对各种各样的处理器都适用。 简易却有效的优化技巧 3.1 消除不必要的内存引用 代码片段1 void array_sum(short *a, short *sum, length)
{
unsigned int i;
for(i=0; i对于上面这段代码,每次迭代需进行两次读内存操作+1次写内存操作+1次加法。然而除了最后一次迭代时,我们需要把计算结果写入sum所代表的存储地址外,中间的计算过程实际上可以临时保存在寄存器中。因此对代码做如下改动: 代码片段2 void array_sum(short *a, short *sum, length)
{
unsigned int i;
short sum_temp = 0;
for(i=0; i这样便将每次迭代的内存操作从两次读和一次写减少到了只需一次读。 试想,如此明显的优化难道编译器不会自动完成吗?如果我们仔细分析代码,会发现倘若调用函数时a和sum指向了相同的地址,以上两段代码将可能会得到不同的两个结果。而在无法确认是否会出现这种存储混叠的情况下,编译器将采取保守的态度! 3.2 多个累积变量 重新考虑代码片段2,因为下一次sum_temp的计算依赖于上一次sum_temp的累加结果,每个周期最多只能计算一个元素的累加值 。假设处理器拥有两个并行的加法单元,则总会有一个单元是闲置的。考虑到这一点,再把代码改写成如下的形式: 代码片段3 void array_sum(short *a, short *sum, length)
{
unsigned int i;
short sum_temp1 = 0;
short sum_temp2 = 0;
for(i=0; i用两个临时累积变量同时累加,使得在同一个周期内处理器的两个加法单元能同时运行,提升了指令的并行度。 3.4 书写适合条件传送实现的代码 代码片段4 for (i=0; i> i);
y_coord = y_coord + (x_coord >> i);
angle_accumulate = angle_accumulate - angleLUT[i];
}
else
{
x_coord = x_coord + (y_coord >> i);
y_coord = y_coord - (x_coord >> i);
angle_accumulate = angle_accumulate + angleLUT[i];
}
}
如上代码段4所示,循环内包含了分支判断语句,使得编译器难以对循环体进行流水编排。另外,由于分支预测只对有规律的模式可行,上述y_coord < 0 条件的判断几乎无法预测,因此分支预测将会处理得很糟糕。 如果编译器能够产生使用条件数据传送而不是使用条件控制转移的代码,可以极大提高程序的性能。有些表达条件行为的方法能够直接地被翻译为条件传送,避免了需要处理器进行分支预测的可能。 把代码片段4改为如下的风格,并通过检查产生的汇编代码,确认其确实生成了使用条件传送的代码。 代码片段5 int x_temp, y_temp;
for (i=0; i> i;
y_temp = y_coord >> i;
x_coord = (y_coord < 0)? (x_coord - y_temp ) : (x_coord + y_temp);
y_coord = (y_coord < 0)? (y_coord + x_temp ) : (y_coord - x_temp);
angle_accumulate = (y_coord < 0)? (angle_accumulate - angleLUT[i]) : (angle_accumulate + angleLUT[i]);
}
3.4 缓存友好型代码 在本公众号的另一篇文章《计算机系统中与存储有关的那些事》中,已经介绍了存储访问的时间局部性和空间局部性,并给出了编写局部性好的代码示例。 这里再讨论一个容易被忽视的问题,它出现在我的实际项目调试过程中。有一个函数,不考虑存储访问的仿真结果显示,该函数完整运行大概耗时100us,但实际运行却发现该函数消耗了700us左右。因为仿真没有考虑内存访问的延迟,所以我们容许实际运行结果会比仿真结果稍多一些,但700us相比于100us足足大了7倍,这就有点异常了。 经过一番排查,最终发现问题出在一条变量初始化语句上。一个全局数组short a[1920*8],在函数开头对它进行初始化处理: memset(a, 0, sizeof(a)); 然而就这一条语句就消耗了500多个us!事后对代码功能进行分析,发现通过一些调整是可以完全避免对该变量进行初始化的。特别是像这样大的数组,局部性再好其缓存缺失次数也将很大,而且会造成缓存被大片刷新。 通常一些好的编程习惯,可能会导致性能的恶化,比如数据块的初始化,在代码中经常可以看到malloc后马上memset,然后再对数据块赋值,如果操作的内存块很大,对性能影响很明显。因此,变量初始化是一个好的编程习惯,但如果跟性能冲突尽可能避免这样的操作或者只对关键的数据进行初始化,避免大块数据的操作。 程序性能剖析 4.1 确认性能瓶颈 在处理大程序时,要明确地知道应该优化什么地方都是很难的。此时可以借助代码剖析工具(code profiler),在程序执行时收集每个函数的调用次数和所花费的时间等参数,通过打印的剖析报告就能得出函数的耗时分布情况。 Amdahl定律可以用于分析程序中某部分性能的提升最终能给程序的整体性能带来多大的影响。 Amdahl定律指出,设原程序执行时间为Told,其某部分代码所需执行时间占该时间的比例为a,而该部分性能提升的比例为b,则整个程序的加速比为: Told/Tnew = 1/[(1-a) + a/b] 4.2 程序的最大性能 在对代码进行优化后,通过仿真或者实际运行,可以测试优化的效果。然而这终究只是一个相对的比较,如果能建立一种评估办法,首先确立一个性能指数的边界(就像参数估计中的克拉美罗界一样),然后通过测试所写代码的该项性能指数,不就能得出代码优化的绝对程度,以及预知还存在多大优化空间吗? 《深入理解计算机系统》这本书中,作者就给我们提供了这样的一套评估办法。书中,作者以每元素的周期数(CPE)作为统计指数,以延迟界限和吞吐量界限两项来描述程序的最大性能。 CPE指数只是针对循环代码而言的(几乎可以说代码性能优化就是对循环的优化),它指处理数据的每个元素所消耗的周期数。之所以使用每个元素的周期数而不是每个循环的周期数来度量,是因为循环次数可能随循环展开的程度不同而变化,而我们最终关心的是,对于给定的向量长度,程序运行的速度如何。 延迟界限描述的是,当一系列操作必须按照严格的顺序执行时,处理每个元素所历经的关键路径(最长路径)的周期数。当数据相关问题限制了指令级并行的能力时,延迟界限能够限制程序性能。 吞吐量界限描述的是,处理器功能单元全力运行时的原始计算能力。比如处理器具有两个能同时做乘法的单元,对于只有1次乘/元素的循环而言,此时的吞吐量界限就是0.5。吞吐量界限是程序性能的终极界限。 参考资料 【1】Modern Microprocessors:A 90-Minute Guide! 【2】BRYANT R E, O’HALLARON D R. Computer Systems: A Programmer’s Perspective[M]. 3 edition. Boston: Pearson, 2015.(译名:深入理解计算机系统) 【3】CC++代码优化的27个建议--伯乐在线. 【4】程序性能优化(一、二、三)--坚持的博客园. ·END· 想进一步跟踪本博客动态,欢迎关注我的个人微信 订阅号:信号君 郑重·专业·有料
打开微信“扫一扫”,打开网页后点击屏幕右上角分享按钮