在学这部分内容的时候我曾特地问过一些网友,我想了解一下大家对底层优化的看法,似乎大家还是比较赞同把 注意力集中在代码的上层架构设计上。各种原因吧,有的人可能觉得现代机器够快底层优化是在浪费时间,有的人认为干这种事情还地考虑到项目成本预算,有的人认为一些关键的代码已经有别人写好以API的形式提供了。书上的一些例子表明,一些底层的优化可以把一段代码的性能提提高到30-40倍。最近我没有太多时间在自己的机器上研究实际的优化效果。这本书是几年前写的,里面的理论也许不能全部应用到现在的开发中。不过即使是这样,作为程序员我们也有理由了解cpu究竟是如何工作的。以下是以我个人对书上知识点的理解来写的,算是一些个人总结,没有另外写开代码,也不严谨。
看了书中处理器相关的优化技巧,我感觉这种优化是介于处理器和程序代码之间的,因为机器虽然会按照汇编代码的指示完成特定的工作,但是机器的某些工作方式对程序员却是隐藏的,这造成一些完成相同功能的以不同方法书写的代码的性能差异 (这种性能差异与所选的设计和算法无关,只与处理器有关)。
现代处理器已经实现了流水线的工作方式,以及乱续执行和投机执行的技术。
一条指令的执行会被分成多个阶段,而且不同指令在一个处理器上执行时被划分成的这些阶段是相同的,这种设计节省了硬件的制造成本。因为所有的指令都通过一个执行流水线来执行。而且它在一个周期可以同时处理多个指令的不同部分(指令鱼贯而入流水线)。
乱续执行是指在逻辑上在后面的指令可以先于前面的指令执行。这更提高了硬件的执行效率。
投机执行是指硬件在某条件跳转语句还未处理完时也不暂停执行,而是预测它的执行方向 ,读入并执行下一条预测地址处的指令。如果预测错误,处理器负责返回到还未预测执行方向前的状态,并执行正确的指令,这个代价是高昂的。
处理器的各个阶段都有一些不同的功能单元负责处理不同的运算(有一些不同的单元可以处理相同的运算),它们完成相应处理所需要的周期是不一样的,除了执行时间以外,它们还有发射时间(就是两个连续执行的间隔时间)。这些不同单元的执行时间关系到程序的性能,另外,功能单元的个数也是程序性能的一个约束。下面的例子以书上的处理器模型为基础。例子也是书上给出的。
。循环展开
下面的代码是循环未展开的一个版本
fun( vec_ptr v, data_t *dest )
{
....
data_t * data = get_start( v );
data_t x = 0;
for( i = 0; i < length ; i++ )
{
x = data[i];
}
*dest = x;
}
下面是cpu执行的流程图
下面的代码是循环展开的一个版本
fun( vec_ptr v, data_t *dest )
{
....
data_t * data = get_start( v );
data_t x = 0;
int limit = length -3;
for( i = 0; i < limit ; i+=3 )
{
x = data[i] + data[i + 1] + data[ i + 2 ];
}
for( ; i < length ; i++)
{
x = x + data[i];
}
*dest = x;
}
执行图是这样的
对比可以看到未展开时每次循环只处理一次加法,而大多数执行时间都在处理准备下一次循环的工作。
这里准备循环的处理消耗了这个循环代码大多数的执行时间,根本原因是处理器没有太多的处理整数的功能单元,这个处理器模型只有2个这样的功能单元。
3次展开之后整数功能单元处理循环内容的时间相对多了,处理器的执行调度使得从周期10开始可以每个周期执行一次x的累加。
并不是所有的循环展开都有效,它还受功能单元个数的影响。另外还受到数据相关的影响,下面就会看到。
。循环分割
下面是未进行循环分割的代码:
void fun(vec_ptr v, data_t *dest)
{
int i;
int length = vec_length(v);
data_t *data = get_vec_start(v);
data_t x = IDENT;
*dest = 1;
for (i = 0; i < length; i++) {
x = x * data[i];
}
执行图:
从周期9以后,能够完成汇编指令imull的2个整数功能单元在多数时间里有一个都是空闲的。这是因为下一次乘法的被乘数是上一次循环的结果,也就是说两次乘法之间有数据相关,下一次循环的乘法必须等待上一次乘法完成之后才能才能继续。这就造成功能单元的空闲。影响了程序执行的效率。
下面是循环分割后的代码:
void combine6(vec_ptr v, data_t *dest)
{
int length = vec_length(v);
int limit = length-1;
data_t *data = get_vec_start(v);
data_t x0 = 1;
data_t x1 = 1;
int i;
for (i = 0; i < limit; i+=2) {
x0 = x0 OPER data[i];
x1 = x1 OPER data[i+1];
}
for (; i < length; i++) {
x0 = x0 OPER data[i];
}
*dest = x0 * x1;
}
执行图:
2次展开和2次循环分割,结果分别存在x0和x1中,两次乘法之间并没有数据相关 ,所以图中2个整数功能单元可以同时执行。不是所有的循环分割都有效,它受功能单元数量的影响。
程序性能还会受到寄存器数量的影响,如果寄存器数量过少,会发生寄存器溢出,寄存器不够用时,会把值存到存储器中,对存储器的读写开销也是不可忽略的。
可以用一些减少寄存器使用量的编码方式,书里给出了一种指针来代替循环变量的办法。
在处理浮点数时也会有遇到浮点性能异常而导致性能急剧下降的问题。
还有一些性能的消耗是因为上面提到过的转移的预测错误引起的。
。存储的执行时间
在优化程序性能(1)--基础优化 中有提到过避免不必要的存储器引用。因为这会引起性能的下降。但是情况没这么简单。
如果无法避免生成存储器引用的代码,那么存储器的访问性能还受到存储数据相关的影响。假设有个循环的功能是要把一个数组的第i个元素设置成第i-1个元素的值,而且循环变量i的值是从小到大的,这样一来,i位置的元素必须等到i-1位置的元素从i-2位置的元素那把值载入到处理器并存储进来时才能开始把i-1位置的值载入cpu并存储进来。这样,load操作就被延迟了。其它位置的load操作也一样。如果是把i+1位置的元素值存到i就不存在这个问题。因为i的存储操作不会发生在i-1对i位置值的载入操作前。其实,我觉得这个问题可以把i按照大到小顺序进行递减。这样情况就变成了和刚才描述的把i+1的值存到i位置的情况一样。
书里还介绍了可以用GPROF工具来分析程序的性能。
Amdahl定律
S = 1/((1-a) + a/k)
a是所要优化的部分在程序总运行时间里的比重,可见a越大s越大。