现代处理器与代码性能优化


现代处理器的一些特性html

现代处理器取得了了不得的功绩之一,是他们采用复杂而奇异的处理器结构,其中,多条指令能够并行的执行,同时又呈现出一种简单的顺序执行指令的表象。——《深刻理解计算机系统》程序员

在咱们直观的认识中,处理器就是那个按着编译好的代码指令,不断顺序重复着取指、译码、执行操做的单调而可靠的机器。事实上,现代处理器对待代码指令的处理方式,早已再也不是表面上看起来的那么规矩,对不一样形式的代码,它将可能呈现不一样的运行策略。算法

关于现代处理器的特性,本文只简单介绍与后面代码优化技巧有关的几个,更多更丰富的特性介绍,建议参考资料[1],里面有专业而详细的描述。编程

 

1.1 超标量数组

能够在每一个时钟周期执行多个操做的处理器称为“超标量处理器”。现代处理器主要从两个方面实现超标量处理:缓存

  1. 多个并行的功能单元。这些单元能同时执行相同或不一样的指令,如TI的C64X+架构就配置了8个并行功能单元,分别负责乘加、逻辑、存取等操做。VLIW(Very Long Instruction Word)超长指令集被设计来给这多个功能单元进行指令分发;性能优化

  2. SSE(Streaming SIMD Extensions, 流SIMD指令扩展 ),SIMD即Single-In-struction,Multiple Data(单指令多数据)。经过扩展额外的矢量处理功能单元以及矢量寄存器等,能够实现单个指令控制多路相同的计算,如一次作8个Byte的数据存取 ,又或是一次作8个16x16乘法。ARM处理器中的NEON协处理器就是对ARM架构的SIMD扩展。微信

 

1.2 高速缓存架构

高速缓存(cache)是一个小而快速的存储设备,通常而言,CPU对高速缓存的访问速度仅次于寄存器。缓存空间的大小不只与其价格高有关,更重要的是随着存储能量的扩大,存储的访问延迟将随之增长,所以不少处理器设计了多级的缓存结构,越靠近CPU的层级其容量越小。ide

现代处理器包括独立的I-cache(指令缓存)和D-cache(数据缓存),它们由专门的硬件逻辑来管理。简单来讲,缓存管理器在CPU第一次访问某个低层级的存储时(缓存缺失),会连带把该存储地址以后的多个指令/数据与上级缓存交互,这样当CPU接下来想访问下一个连续的指令/数据时,就只须要访问缓存便可(缓存命中)。

有这样几个重要指标来衡量高速缓存的性能:缺失率、命中率、命中时间、缺失处罚。其中从缺失 处罚这个指标中,咱们来看看缓存对加快CPU的运算性能有多么的重要。

缺失处罚是因为缓存不命中所须要的额外时间开销。对L1高速缓存来讲,命中时间的数量级是几个时钟周期;L1缺失须要从L2获得服务的处罚,一般是数10个周期;从L3获得服务的处罚为50个周期;从主存获得服务的处罚为200个周期!

 

1.3 分支预测、投机执行、条件传送

分支代码对于流水线处理而言是一个障碍,由于编译器,包括硬件很是有可能没法预知下一步到底将执行哪一个分支的指令,因而只好等待分支判断结果出来后再继续填充流水线,形成流水线中的“空泡”。

现代处理器采用了一种称为分支预测的技术,它会猜想是否会选择分支,同时还预测分支的目标地址。以后,使用投机执行的技术,处理器会开始取出位于它预测的分支跳转处的指令,并对指令译码,甚至在它肯定分支是否预测正确以前就开始执行这些操做。直到肯定了实际的分支路径,若是预测正确,处理器就会“提交”投机执行的指令的结果。

当分支预测逻辑预测错误时,条件分支可能会招致很大的“预测错误惩罚”。这时,处理器必须丢掉全部投机执行的结果,在正确的位置从新开始取指令的过程,在产生有用的结果以前,必须从新填充指令流水线。

另外一种处理分支的方法是使用“条件传送指令”。编译器能产生使用这些指令的代码,依据条件知足与否选择执行或忽略指令,而不是传统的基于控制的条件转移。翻译成条件传送的基本思想是计算出一个条件表达式或语句两个方向上的值,而后用条件传送选择指望的值。条件传送指令能够被实现为普通指令流水线化处理的一部分,没有必要猜想条件是否知足,所以猜想错误也没有处罚。

 

1.4 乱序执行

对于单个线程而言,若是只是顺序执行指令,有时后面的指令须要依赖前面指令的执行结果,所以可能引发功能单元或流水线等待,下降了处理效率。

乱序执行是指在逻辑上在后面的指令能够先于前面的指令执行,这更提升了硬件的执行效率,达到更高的指令级并行度。处理器采用一种“寄存器重命名”的方式实现指令乱序执行的同时,保证不影响程序最终的结果。

 

代码优化的必要性

现代处理器具备至关的计算能力,可是咱们可能须要按很是程序化的方式来编写程序,以便将这些能力诱发出来。——《深刻理解计算机系统》

让咱们暂时抛开代码架构以及代码可读性,只谈论对整个程序运行性能影响最大的那些核心代码段。大部分程序员更多关心的是实现代码功能的算法,而不多注意到使用对编译器和处理器友好的代码。例如数组排序,咱们会想究竟是用冒泡排序,仍是插入排序,亦或是堆排序……而后乐此不疲地比较哪一种算法能够消耗最少的算力。在工程实践中我发现,适当调整代码实现的技巧,每每能比选择算法自己带来更大的效率提高,有时这种提高是成倍甚至几十倍的!这么说固然不是认为算法选择不重要,而是想说明代码优化一样很是重要。

编译器一般集成有优化器,能自动地对用户代码作出合适的优化。尽管有些优化器已经极尽其所能了,但人为的优化干预依然是必要的。

一方面,调整代码结构有风险,为避免因优化形成的代码错误,编译器老是作保守估计。

另外一方面,因为一般的程序自己不具备并行性,严重地削弱了经过超标量执行实现的指令级并行性,即便最聪明的乱序超标量处理器,同时结合聪明的和富有竞争性的编译器,依然会受到加载延迟、cache 缺失、分支和指令之间相关等的综合影响,使得处理器在不多的周期内充满( 全速运行)。

鉴于上面的缘由,用户能够大体从两个方面着手优化本身的代码:

  1. 编译器友好化。理解优化编译器的能力和局限性,尽可能经过代码自己和预编译伪指令“告诉”编译器用户的真实意图;

  2. 处理器友好化。调整代码实现方式,尽量充分地利用处理器的硬件单元。

尽管一样的优化策略在不一样的处理器上不必定有一样的效果,可是操做和优化的通用原则,对各类各样的处理器都适用。

 

简易却有效的优化技巧

3.1 消除没必要要的内存引用

代码片断1

void array_sum(short *a, short *sum, length)

{

     unsigned int i;

     for(i=0; i<length ; i++)

     {

          *sum = *sum  + a[i];

     }

}

对于上面这段代码,每次迭代需进行两次读内存操做+1次写内存操做+1次加法。然而除了最后一次迭代时,咱们须要把计算结果写入sum所表明的存储地址外,中间的计算过程实际上能够临时保存在寄存器中。所以对代码作以下改动:

代码片断2

void array_sum(short *a, short *sum, length)

{

     unsigned int i;

     short sum_temp = 0;

     for(i=0; i<length ; i++)

     {

          sum_temp = sum_temp   + a[i];

     }

     *sum = sum_temp;

}

这样便将每次迭代的内存操做从两次读和一次写减小到了只需一次读。

试想,如此明显的优化难道编译器不会自动完成吗?若是咱们仔细分析代码,会发现假若调用函数时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<length-1 ; i+=2)

     {

          sum_temp1 = sum_temp1   + a[i];

          sum_temp2 = sum_temp2   + a[i+1];

     }

     for(; i<length; i++)

     {

          sum_temp1 = sum_temp1   + a[i];

     }

     *sum = sum_temp1 + sum_temp1;

}

用两个临时累积变量同时累加,使得在同一个周期内处理器的两个加法单元能同时运行,提高了指令的并行度。

 

3.4 书写适合条件传送实现的代码

代码片断4

for (i=0; i<CORDIC_level; i++)

{

            if (y_coord < 0) 

            {

                x_coord = x_coord - (y_coord >> 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<CORDIC_level; i++)

{

     x_temp = x_coord >> 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】C\C++代码优化的27个建议--伯乐在线.

【4】程序性能优化(1、2、三)--坚持的博客园.

 

 

·END·

 

欢迎来个人微信公众号作客:信号君

专一于信号处理知识、高性能计算、现代处理器&计算机体系 

 

技术成长 | 读书笔记 | 认知升级

幸会~

相关文章
相关标签/搜索