C 程序性能优化

一、如何衡量程序的运行性能

   主要从两个角度来考虑,空间时间。实现同样功能的情况下,代码运行时占用内存更少,运行速度更快的代码性能更高。运行时间内存消耗是衡量程序性能的基本指标。

二、性能优化技巧

1.利用高速缓存

  高速缓存对数据的访问速度是普通内存的上百倍,它对性能的提升,在于两点:时间局部性空间局部性

  时间局部性:被引用过一次的内存位置可能在不远的将来被多次访问。

  空间局部性:如果一个内存位置被引用了一次,那么程序很可能在不久的将来引用其附近的另一个位置。

  CPU 在缓存数据时,会按照局部性原则,缓存第一次访问的内存及其附近的数据,如果每次访问的数据不够连续或者同一数据长时间引用一次,使得缓存总是不被命中,导致直接对内存进行频繁的访问,最终使程序整体性能降低。为了更好的利用高速缓存,可以按照以下几个原则来编写代码:

——尽量确保定义的局部变量能被多次引用;

——在循环结构中,以较短的步长访问数据;

——对于数组结构,使用行优先遍历;

——循环体越小,循环迭代次数越多,则局部性越好;

alignas 关键字

  在计算机内部,高速缓存是以缓存行的形式被组织的,也就是说,一大块连续的高速缓存会被分为多个组,每个组有多个行,每个行有固定的大小,一般为 64 个字节,通过 getconf -a |grep CACHE_LINESIZE 命令可以查询当前架构的缓存行大小。当缓存不命中时,CPU 会将数据从低层次缓存中以固定的块大小(通常为缓存行大小)拷贝到更高层次的缓存行中。为了减少 CPU 需要进行的内存拷贝次数,我们希望连续的数据被组织在尽可能少的缓存行中。利用 alignas 关键字,将较长的数据设为 64 字节对齐,当这段数据被拷贝到高速缓存中时,会从缓存行的开头处开始放置数据,这在最大程度上的减少了连续数据需要的缓存行数,如下段代码所示:

struct data {

  char x;

  alignas(64) char y[118];

};

  如果没有 64 字节对齐,这个数组可能会占三个缓存行,而现在只占了两个缓存行。

2.利用代码内联

  通过 inline 关键字,可以建议编译器,将某个函数的实现内联到它的实际调用处。通过这种方式,程序不需要通过 call 指令来调用函数,省去了函数帧栈的创建和销毁过程,以节省 CPU 时钟周期。这是一种典型的时间换空间的思想。在函数需要被频繁调用时,这种方式对性能的提升尤为明显。但是,inline 关键字只是对编译器的建议,至于是否会采纳,还要看编译器的具体实现;同时,在高优化等级下,编译器也会自动采用内联对程序进行优化。

3.利用 restrict 关键字

  restrict 关键字只能用于指针类型,用以表明该指针是数据的唯一访问方式。在计算机领域中,有一个名为 aliasing 的概念,就是说内存中的某一个位置,可以通过程序多于一个的变量来访问或修改其包含的数据。这会导致一个潜在的问题,当通过某个变量修改数据时,会导致所有与其他变量相关的数据访问发生改变。所以 aliasing 使得编译器难以对程序进行过多的优化。以下是一段普通的代码:

   这段代码对应的汇编是先从内存中取值,然后再相加。如果给变量 z 加上 restrict 关键字,如下图所示:

   发现加上 restrict 关键字后,只会从内存中读取一次值。可以看出, restrict 关键字通过减少内存的引用,从而提高效率

4.消除不必要的内存引用

  在某些情况下,只需要对程序的结构稍作修改,便能在很大程度上提升程序的运行性能。如下图所示:

   这种情况下,每次赋值都要写进内存,如果添加一个变量,经过优化后,编译器会将值放进寄存器中,在计算完后,将数据放进内存,如下图所示:

5.循环展开

   这个优化的原理在于 CPU 执行指令的方式。早期的 CPU 是串行执行的,必须在执行完本条指令后才能执行下一条指令。而指令的执行涉及到多个功能模块,在执行某条指令的某个阶段时,其他的功能模块是空闲的,这造成了 CPU 资源的浪费。所以到了后来,出现了流水线技术,将指令拆分为多个步骤执行,使各个功能模块得到充分的利用。比如,对于一个五级 RISC 流水线来说,CPU 会将指令的执行细分为指令提取、指令编译、指令执行、内存访问以及寄存器写回这五个步骤。这种情况下,当第本条指令被提取完后进行编译的同时,CPU 可以提取下一条指令,这种方式使得 CPU 执行指令由原来的串行变成了并行,提高了执行效率

 

 

  所以这种优化就是通过某种方式,让 CPU 在执行程序指令时,能够以满足流水线的方式进行。下图有一份代码,data 数组中放了一些数字,fun 函数的作用是计算所有数字的乘积和。这段代码存在一个问题,就是变量 i 和变量 acc 总是依赖上一轮的结果,无法提前计算下一轮循环变量的值,只有等这次循环执行完写入后才会开始下一轮。如下图所示:

  展开后的代码如下图所示,稍微完善了一下,变成了一个可以编译运行的代码,其实效果和上图一样的,就是循环次数变少了:

 

 

   这样一来,减少了循环的次数,两个累积值不存在相关性,增加了 CPU 并行执行这些指令的机会,提高了程序的效率。

  但是,一般编译器在高优化等级下会自行展开,如果确认编译器没有自行展开,并且确实这种方式可以带来性能提升的话,再考虑使用。 

6.优先使用条件传送指令

  条件传送指令: CPU 中存在某类指令,在条件满足时,会将数据传送到指令位置。与其类似的有条件分支指令,这类指令会根据 CPU 标志位的不同状态,选择执行程序不同部分的代码。对于代码中的某些逻辑,使用这两类指令都可以完成,但是使用条件分支指令需要承受分支预测失败的代价。当 CPU 预测错误时,CPU 会将状态重置为跳转前所处的状态,并取出正确方向的指令,重新处理,这会导致更多的 CPU 周期被浪费。

  下面的代码比较 x 和 y 这两个数组相同索引下的值,将较大的值存入 y 数组中:

 

  在 C 语言中,条件传送指令通常用来实现问号表达式,所以将 if 语句转为问号表达式:

 

   问号表达式虽然避免了分支预测失败带来的损耗,但是每次循环也多了几次赋值和比较的操作,但是相比较而言,判断次数多的情况下,使用问号表达式性能更优需要注意的是,使用问号表达式时后面的值需要类型一致,否则会产生一些奇怪的问题。

7.使用更高的编译优化等级

无需多言

8.尾递归优化

   参考函数

posted @ 2022-02-19 16:03  一只吃水饺的胡桃夹子  阅读(487)  评论(0编辑  收藏  举报