一方面,串行代码优化有时能获得成千上万倍的加速;另一方面因为单个并行控制流的内部依旧是串行的。
一般而言,不同算法上的优化是最有效的。假设你已经有了一个能得到正确结果的程序,需要在此基础上进行优化,本章将串行代码的优化分为以下几个层次:
1. 系统级别:要求找出程序的性能控制因素以做针对性的优化,单机/多机,高内存,高IO,高吞吐量;
2. 应用级别:在程序编写前就要确定应用的配置,编译优化级别,是否使用函数库;
3. 算法级别:选择不同的数据组织形式,或者选择不同的算法,这两者对性能的作用非常大;
4. 函数级别:通常用来减少函数调用的开销或者减少函数调用带来的编译器性能优化阻碍;
5. 循环级别:由于执行次数多,循环更易成为性能瓶颈,需要发掘循环的并行性,减少循环内多余运算。
6. 语句级别: 不同的语句产生的指令并不相同,应尽量使用产生指令条数少的语句。
7. 指令级别: 不同指令的吞吐量和延迟并不相同,应优先使用吞吐量大,延迟小的指令。
顶级代码优化人员会以分析为导向,以程序热点为目标,以知识为后盾,一次一个目标,步步为营地把代码的性能做到极致。
首先需要在系统级别找出程序的性能控制因素,然后在做针对性优化。如果限制因素是处理器的计算能力,那么优化内存访问就不会产生很大的效果。
如果处理器的利用率一直是100%,那么限制因素就是处理器,需要减少计算。
如果处理器使用率不高但很平稳,此时就需要测试程序的内存带宽,比较程序的带宽和内存能够提供的带宽(指内存的有效带宽,而不是理论带宽,可以使用stream工具),以估计程序还有多少优化空间,此时优化应注意减少存储器访问。
如果处理器利用率一会高一会低,就需要查找是什么原因导致处理器长时间空闲,常见的因素是有其他进程占用处理器。
如果应用通过网络互连交换数据或指令,那么网络速度、利用率和网络负载均衡也要考虑。
如果经常等待网络数据的传输和到达,那么就得考虑网络的速度和利用率,如果是集群,还得考虑网络负载均衡。如果网络利用率很高,但是数据传输还是很慢,那么就得查看网线或网卡的带宽是否不够,另外网络拓扑或路由设置不好,导致数据传输经过的节点过多也有可能。如果网络中某些节点的负载特别不均衡,那么就可能需要更改网络拓扑结构或者为那几个节点选用更好的网卡或网线。
Linux下top命令可以查看计算机各个核心的负载。处理器的利用率在两种情况下可能存在性能问题:
1) 处理器空闲较多:很明显程序不是最优的,因为程序无法发挥硬件的计算能力;
2) 处理器利用率过高:如果核心使用率为100%,或者单核程序中某个核的使用率100%,这意味着处理器称为性能限制因素。对于大多数优化很好的程序来说,计算限制可能意味着优化已经达到极限;对其他的一些程序来说,可能意味着程序开发人员没有合适的编写代码,导致无效计算量过多。
根据处理器利用率的常见优化方法是:找出为什么处理器利用率比较低或比较高。比如,处理器空闲较多可能是由于访存瓶颈,或其他进程或线程释放共享资源等。
优化计算受限程序的性能并不意味着要降低处理器利用率,办法主要有减少计算数量,表达式移除,分支优化等,有时也可能优化算法。
此时需要先测试存储器系统的可用带宽(可用stream工具或其他编写代码测试)、程序的实际带宽(可使用硬件计数器的数据计算得到)和程序的有效带宽(从算法分析得到)。
优化存储器访问性能的方法主要有:
1) 提高存储器访问的局部性以增加缓存利用率。常见的二维数据以行主序访问。
2) 将数据保存在临时变量中以减少存储器读写。由于临时变量通常占用空间更小,硬件能够将它们缓存到一级缓存,或编译器能够将它们分配到寄存器中,避免了访问更慢的存储器。
3) 减少读写依赖。读写依赖会导致流水线指令等待,减弱了流水线效率。
4) 同时读写多个数据以提高系统的指令级并行和向量化能力。
如果处理器利用率一会高一会低,通常是因为一些因素阻塞了处理器运算,比如IO操作(如读文件),其他用户的进程占用了处理器或内存带宽。通常的解决方法是使用非阻塞函数的调用,如异步IO操作,或使用独立的线程来处理IO操作。
应用级别的优化应当最先采用,一方面因为它可能关系到程序的各个部分,另一方面也因为软件开发到后面,越不容易采用应用级别的优化。
一般而言使用高级的优化选项总比使用低优化选项的速度要快一点。O3优化可能会产生错误的结果(大多数情况是程序员编写的原因导致的)。
使用GCC时,建议使用如下编译选项:
-O3 -ffast=math -funroll-all-loops -mavx -mtune=naive
其中fast-math表示对超越函数使用更快但进度低一些的版本;
unroll-all-loops表示使用循环展开;
avx表示使用avx指令级向量化;
tune-naive表示为当前编译的处理器做优化;
直接调用高性能库,如优化后的BLAS,FFTW,可以减少大量工作。一方面因为它们通常已经比较成熟,另一方面也应为编写库代码的开发人员素质通常比较高,性能和质量都比较有保证。
全局变量尤其是多个文件共享的全局数据结构会阻碍编译器的优化,因为编译器需要在多个文件间分析使用状态。为了保证不至于产生错误结果,编译器就变得极其保守。像移除子表达式、合并某些操作的结果这些常见的优化方式,编译器也必须小心翼翼。
// global var
int x = 0;
int f()
{ return x++;}
int g()
{ return f() + f(); }
另外,全局变量的使用也使得程序员不便追踪其变化,难以进行手工优化。
对于并行程序来说,全局变量(除非是不可变的)应当是绝对禁止的,因为多个控制流需要协调对全局变量的修改,如何保证全局变量在各个控制流的状态是一致的,这是并行编程的难点。
C99为了减少存储器别名对性能的影响,引入了“受限的指针”特性,受限的指针表示该指针不会和其他指针存在存储器别名。存储器别名是指多个指针指向同一内存地址或指向的内存地址有重叠,它会阻碍编译器对程序进行指令重排,表达式移除等优化。
int f(int* a, int* b)
{
*a += *b;
*a += *b;
}
第一种情况:如果指针a、b指向不同内存地址,也就是说a,b之间不存在存储器别名。 则函数f可简化为:
int f(int* a, int* b)
{
int temp = *b;
*a += temp;
}
第二种情况:如果指针a、b指向同一内存地址,即a,b之间存在存储器别名。则函数f简化为:
int f(int* a, int* b)
{
int temp = *b;
temp += temp;
temp += temp;
*a = temp;
}
使用受限的指针能够允许编译器做更多的优化,潜在地提升性能。但是调用者自己要保证指针之间不存在存储器别名,而在一些复杂的程序中要保证不存在存储器别名是非常困难的。
通常使用条件编译以满足可移植性。相比于运行时检测,条件编译生成的代码更短,因此效率更高。C语言提高了#if#else#elif#endif#ifdef等指令来支持条件编译。
由于宏条件在编译时就已经确定,故编译器可直接忽略不成立的分支;而分支判断的条件是在运行时求值的,故编译后的代码要长。
不过如果要支持多个条件编译分支版本,条件编译的方式只能使用多个程序,而运行时分支版本却只需要一个。
// switch 条件分支
switch(mode)
{
case ON_ARM_CPU:
arm_f();
break;
case ON_X86_CPU:
x86_f();
break;
default:
}
// 条件编译
#ifdef ON_ARM_CPU
arm_f();
#elif ON_x86_cpu
x86_f();
#endif
算法级别的优化通常涉及程序的一部分,而这部分包含一个或多个函数,甚至只有一段语句。
算法级别优化主要涉及算法实现时要考虑的问题,如数据的组织、算法策略,实现的策略。
缓存利用和数据的组织形式密切相关,软件预取能够隐藏访存延迟,而查表是一种有效的算法级优化。
缓存优化:发掘程序访存的局部性,以合理地发挥缓存的带宽,通常要求在算法设计时候就要考虑数据结构。
访问多维数据时的局部性直接与各维数据在内存中存放的先后顺序相关。如C语言中数组是以行主序存放的。
for (int i = 0; i < N; i++)
for (int j = 0; j < M; j++)
r[j] += a[j][i];
// 优化后
for (int i = 0; i < M; i++)
{
float ret = 0.0f;
for (int j = 0; j < N; j++)
ret += a[i][j];
r[i] = ret;
}
对于优化前代码来说,在内层循环上,相邻循环体内访问a的地址相隔N个元素,在N比较大时,可能会存在满不命中和冲突不命中的情况,故访问a的局部性很差。
现代处理器都有多个层次的缓存,如果数据大小超过了缓存大小,那么就容易出现满不命中的情况,此时常见的减弱满不命中的方法主要是缓存分块。
对于多维数据而言,在某一维度上访问的局部性很好,并不代表在其他维上的访问局部性也很好。
通常以核心间不共享的最低层缓存分块,这意味为二级缓存分块,但是这也不绝对,在某些情况下,也许L1分块更好,在某些情况下,可能会同时为多个缓存层次进行分块优化。但是在多线程的情况下,多个核心访问的数据都会被共享的缓存层次所缓存,多个线程会竞争共享缓存的带宽。
预取是指在数据被使用之前,投机地将其加载到缓存中。预取通常有硬件和软件两种方式,软件预取是指由编译器或软件开发人员将预取指令插入到代码的适当地方。
指令集通常会提供预取指令供编译器优化时使用。这类指令直接把目标预取数据载入缓存。在一些特殊情况下,为了隐藏访问内存的延迟,软件开发人员也会手动在代码中插入编译器厂商提供的预取内置函数。
在使用预取技术时,必须妥善考虑进行的时机和实施强度。如果过早地预取,预取的数据有可能在用到之前就已经因为冲突置换、满不命中而清除;
通常会预取2-4个缓存线长度的数据,即在处理地址x的数据的同时,预取地址x + 256上的数据。
如果预取得太多或太频繁,则预取的数据可能会占用过多的缓存,进而导致容量缺失和冲突缺失。
查表法提前把数据组织成表格(一维,二维,多维)。
在一些科学计算中,需要多次计算一些复杂函数的结果,此时也可利用查表法减少计算。对表格中不存在的数据使用插值的话。可能会导致精度的下降。
在函数调用时,需要将调用参数通过寄存器或栈传递,且将函数返回地址入栈。函数级别的优化通常用于减少这部分的消耗及其导致的优化障碍。
建议函数只访问自己的局部变量和通过参数传入的值,这样编译器能够只依据函数内的信息就可以分析变量使用情况。
在64位X86处理器上,函数的参数优先通过寄存器传递,超量之后才会通过栈传递。另外,如果一个函数的参数过少,那么这个函数就更容易被记住和使用。
如果函数参数是大结构体或类,应当传递指针或引用来减少调用时复制和返回时销毁的开销,因为函数可能只使用大结构体中一部分域。
struct BigStruct
{
int x[30];
float y;
float x;
};
float getByValue(BigStruct bs)
{
return bs.y;
}
float getByPtr(BigStruct* bs)
{
return bs->y;
}
函数getByPtr就比getByValue要好。实际上,解决getByValue函数调用时的入栈开销的一个有效方法是:内联函数。
如果函数使用了全局变量,那么就有可能阻止编译器优化。因此就算函数要使用全局变量,也要通过参数传递。
内联小函数能够消除函数调用的开销,且可能会提供更多的指令级并行、表达式移除等优化机会,进而增加指令流水线的性能。
另外,函数调用也可能阻止编译器优化,如果循环内有函数调用,则编译器很难进行向量化。对于这些情况,内联可以解决问题。
如果内联后的函数比较长,就可能会增加寄存器压力。如果函数内有分支,内联后会对指令流水线产生不利的影响,因此通常对少于10行且其中代码没有分支的函数内联。
以发掘循环的并行性、减少寄存器和缓存的使用为主,以更好地利用硬件资源。
展开循环不但减少了每次判断数量和循环变量改变的计算次数,更能够增加处理器流水线执行的性能。
通常展开小循环且内部没有判断的会获益,展开大循环则可能会因为寄存器溢出而出现性能下降,而展开内部有判断的循环会增加分支预测的开销,也可以会导致性能下降。
float sum = 0.0f;
for (int i = 0; i < num; i++)
sum += a[i];
float sum = 0.0f, sum1 = 0.0f, sum2 = 0.0f, sum3 = 0.0f; sum4 = 0.0f;
for (int i = 0; i < num; i += 4)
{
sum1 += a[i];
sum2 += a[i + 1];
sum3 += a[i + 2];
sum4 += a[i + 3];
}
sum += sum1 + sum2 + sum3 + sum4;
对于两层循环来说,通常建议优先展开外层循环,但是这不是普适的准则。
许多编译器可以自动展开循环,也有些编译器提供了控制循环展开的伪指令,开发人员可优先选择使用伪指令,在必要时在采取手动展开。
循环累积主要和循环展开同时使用,在减少寄存器的使用量的同时保证并行度。
float sum = 0.0f, sum1 = 0.0f, sum2 = 0.0f;
for (int i = 0; i < num; i += 4)
{
sum += a[i] + a[i + 1];
sum2 += a[i + 2] + a[i + 3];
sum3 += a[i + 4] + a[i + 5];
}
sum += sum1 + sum2;
如果直接循环展开6次,则总共需要至少6个临时变量,而现在只需要3个,潜在地减少了寄存器的使用。在寄存器总量一定的前提下,减少寄存器的使用,就可以将循环展开更多次。
本书作者在X86处理器上测试发现,累计的效果越来越不明显。
如果多个小循环使用的寄存器数量没有超过处理器的限制,那么合并这几个小循环可能会带来性能好处(增加了乱序执行可能性)。另一个可能是因为循环内分支判断过多。
循环合并不但可以减少判断次数,还能增加指令级并行能力。(能够合并的两个循环通常没有依赖)
for (int i = 0; i < num; i++)
{
sum1 += a[i];
}
for (int i = 0; i < num; i++)
{
sum2 += b[i];
}
// 循环合并
for (int i = 0; i < num; i++)
{
sum1 += a[i];
sum2 += b[i];
}
循环合并的另一个常用场景是并行化,由于增加每个控制流的计算量,因此将多个循环合并后能够更好地隐藏线程建立的开销。
如果大循环(循环体内代码比较多,执行时间长)中出现寄存器溢出的情况,那么将大循环拆分为几个小循环,就能够提高寄存器的使用,而且能够为循环展开等技术的使用提供条件。
循环拆分和合并的使用,通常与处理器的缓存容量、寄存器数量等相关。
for (int i = 0; i < num; i++)
{
doA();
doB();
}
// 循环拆分后
for (int i = 0; i < num; i++)
{
doA();
}
for (int i = 0; i < num; i++)
{
doB();
}
从表面上看,循环拆分增加了循环条件的执行次数,如果循环条件的计算量比循环内的代码计算量要小的话,那么循环拆分所导致的循环条件执行代价即可忽略。
实现同一功能的不同语句在编译后的指令数量不同、指令类型也不同,会导致性能也不相同。
对于实现同样功能的不同指令系列,其中的指令延迟和吞吐量可能也不相同,故性能也不相同。
对于语句级别的优化来说,需要尽量避免语句产生不需要的指令(生成更少的指令),或者让语句生成更加高效的指令(生成更快的指令)。
从本质上来说,语句级别的优化和指令级别的优化比较相似,因为二者的粒度都非常小,而且没有本质区别。
一次内存读写需要200~400个周期,而处理器一个周期可完成多个浮点乘加计算,对比之下访问内存速度非常慢。因此应当优先重用数据而不是解引用。如果多次使用指针指向的值,应当先将其保存在寄存器中。
大多数情况下,编译器能够很好的解决这个问题,但是在具有存储器别名或读写依赖的情况下,就需要开发人员手动处理。
for (int i = 1; i < n; i++)
{
a[i] += a[i - 1];
}
// 优化后
float temp = a[0];
for (int i = 1; i < n; i++)
{
temp += a[i]
a[i] = temp;
}
通过保存中间计算结果减少了一次全局内存访问。
在某些情况下,可以重复计算某些值而不是计算后保存到内存中再读取,此时需要权衡计算的代价和读写内存的延迟。
由于能够在缓存中放置更多的数据,小尺寸类型数据的访问频率比大尺寸数据类型要快(指以数据的个数统计,而不是带宽)。
使用short int而不是int,或者char。如果数值范围在数据类型表示之内。
以ARM NEON 128位向量指令集为例,其寄存器为128位,若使用int型,每个向量只能保存4个数据,但是使用short的话,每个向量可以保存8个数据。
声明结构体时,尽量大数据类型在前,小数据类型在后,一方面会节省一些空间,另一方面可以更好地满足处理器的对齐要求。
对齐规则是一致的:
1. 结构体占用总字节数尽量是2的幂;
2. 每个域开始的地址是它大小的整数倍;
编译器提供了按字节对齐的编译原语,如GCC的__attribute__(aligned(x))和__attribute__(packed())。
struct s
{
char x;
double y;
int z;
short w;
};
// 调整数据位置后
struct t
{
double y;
int z;
short w;
char x;
};
结构体s占用的空间是t的1.5倍。意味着s对存储体的带宽要求更多。
有些结构体声明使得它们在不同的硬件上或不同的操作系统上大小并不一致,基于这一点,软件开发人员要记住永远使用sizeof运算符,而不是确切的字节数。
数据占用的字节越小,在同样大小的缓存线中存放的数据数量就越多,缓存线的利用就越好。但是轻易不要使用编译器的packed选项将结构体的存储空间压缩到最小,因为这可能会导致不对齐的读写存储器。
表达式移除是指去掉重复的、共同的计算或访存,这能够减少计算或内存访问次数。
void readVI(VI* vi, int id)
{
int len = vi->size();
if (id < len) return vi[id];
else return ERROR;
}
for (int i = 0; i < len; i++)
{
readVI(a, i);
...
}
if (len >= a->size) return ERROR
for (int i = 0; i < len; i++)
{
a[i];
}
很多开发人员害怕不检查索引而导致的访问越界等错误。本书作者建议:在开发阶段所有的访存都检查索引是否越界,程序都验证正确性之后,再将性能相关部分代码的索引检查代码去掉。
现代CPU都设计了多级流水线,有的甚至有20级以上,当CPU遇到条件指令的时候,为了不中断流水线(某些处理器流水线中断的代价大约五六十个时钟),硬件会做一个预测,把预测的分支代码载入流水线,当发现预测错误的时候,需要清空流水线,重新载入正确的分支后的指令到流水线,此时实际损失的周期数通常是流水线长度的几倍!因此分支的优化非常重要。
要优化条件分支,通常这些分支代码应该满足:该分支是热点代码的一部分,并且分支预测错误率比较高,这样才能得到好的优化收益。
由于分支预测失误会对流水线产生非常不利的影响,因此要避免循环内有判断语句,此时尽量改成判断里面有循环(这要求循环和判断之间没有依赖)。
for (int i = 0; i < n; i++)
{
if (x > y) doSomething();
}
// 优化后
if (x > y)
{
for (int i = 0; i < n; i++)
{
doSomething();
}
}
某些循环中分支非常多,这可能会导致处理器分支预测失败率增加,把它拆分成几个小循环有可能改善处理器的分支预测正确率;在另外一些循环代码中,循环内分支条件依赖循环变量,这种情况可通过拆分循环去掉分支,如常见的奇偶分支:
for (int i = 0; i < num; i++)
{
if (i & 1 == 0) do0();
else do1();
}
// 优化后
for (int i = 0; i < num - 1; i += 2)
{
do0();
do1();
}
if (0 != num%2) do0();
如果do0和do1使用的临时变量比较多的话,循环拆分会更好。
for (int i = 0; i < num; i += 2)
{
do0();
}
for (int i = 1; i < num; i += 2)
{
do1();
}
if (0 != num%2) do0();
一些分支是包括多个比较的复杂表达式,编译器通常将它们编译成嵌套的多个if语句代码,如果能够将其修改成一个运算,那么只需要一个分支,就可以提高分支预测成功率。
if ((a0 == 0) && (a1 == 0) && (a2 == 0))
{
...
}
// 优化后
int x = (a0 | a1 | a2);
if (0 == x)
{
...
}
某些编译器将生成三个条件跳转指令,而使分支可预测性降低,改写成优化后的代码,从而同时改进代码质量和分支预测率。
合并分支条件要求在分支执行前计算分支的结构,在某些分支条件计算量非常大的情况下(如函数调用),并不应当使用这一技术。
很多时候可以利用C中判断式的结果为1,0来去掉分支。但是由于增加了计算量,在分支预测失败概率很小或编译器能够将其优化为条件。
if (a > 0)
x =a;
else
x = b;
// 优化为
x = a > 0;
a = a * x + b * (1 - x);
在64位为X86 CPU上,可以使用条件复制cmov来移除分支。编译器会将C语言中简单的三目运算符编译成条件复制指令。例如:
x = (a > 0) ? a :b;
查表法是提前计算一些结果,将结果放到一张具有索引的表中。如C++ stl的map。如果能够将各个分支的计算结果放到一张表中,并将分支条件转化为表中值对应索引,那么就可以将分支跳转为访问表中元素,这就是查表法移除分支的主要思想。
使用查表法去除分支有两点要求:1) 分支路径很容易转换为路径;2) 分支结果能够提前计算出来。
C中判断式是短路的,也就是说如果现在的信息已经能够决定整体的结果,后面的就不用算了。如if(a & b),如果a为假,那么if语句就一定不能执行,故b不用在求值。
例如a、b中a的计算量相当大,就应该讲它放在后面。对||运算类似。
交换变量的值操作在编程中进程出现。
unsigned char temp = a[ji];
a[ji] = a[jj];
a[jj] = temp;
// 优化后
unsigned char aji = a[ji];
unsigned char ajj = a[jj];
a[ji] = ajj;
a[jj] = aji;
前一段代码只需要使用1个临时变量,而后一段代码使用2个,但是后一段代码两次读之间和两次写之间不存在依赖关系,因此并行性要高。
不同的代码具有不同的吞吐量,在实现相同的功能下,使用高吞吐量的指令明显能够提升程序性能。例如计算某个数的平方采用自己的移位计算。
许多编译器都提供了一些数学函数的快速实现,有些还提供了更快但精度更低一些的实现。
数据依赖会减弱处理器乱序访问性能,另外读写依赖会极大地减弱内存性能。
// 寄存器依赖
float tmp = 0.0f;
for (int i = 0; i < n; i++)
{
tmp += a[i];
a[i] = tmp;
}
// 数据依赖
for (int i = 1; i < n; i++)
{
a[i] += a[i - 1];
}
数据依赖时,每一次循环读取i地址的操作都依赖前一次循环写入的地址i的数据;
寄存器依赖时,循环之间的只依赖临时变量tmp,编译器很可能将其放入寄存器,因此依赖更少。
减少数据依赖能够提升代码的指令级和向量级并行能力。在前缀求和的寄存器依赖版本中,读取数组a就具有很好的指令级并行能力。
在很多处理器上,单指令发射能力不能保证所有执行单元都可同时运行,因此引入了双发射和多发射能力。但是实际上一些指令系列即使不存在依赖也不能多发射,因此应尽量将能同时发射的指令安排在一起,不过这可能要求使用内置函数或汇编语言编写代码。
一般整数的位运算最多只要一个周期,而乘法要三个周期,除法十几个,模余要几十个甚至上百个,而通常的位运算只需要一个周期。
for (int i = 0; i < numRows; i++)
for (int j = 0; j < numCols; j++)
{
int index = i * numCols + j;
r[index] = a[index] / b[j];
}
// 将除法改成乘法
for (int j = 0; j < numCols; j++)
{
rb[j] = 1.0f / b[j];
}
for (int i = 0; i < numRows; i++)
for (int j = 0; j < numCols; j++)
{
int index = i * numCols + j;
r[index] = a[index] * rb[j];
}
为了将多次除法装成一次除法加多次乘法,笔者先使用一个临时数组rb保存中间结果。
整数乘以整数可以转换为左移位操作,如果乘数是常量,编译器会自动执行这种转换。整数除法和模余操作非常耗时,要少使用,如果是乘以或模2的幂,则可以转变为右移或位与运算。
如2的整数次方可以采用移位操作实现,而且很多语言的数学库有内置特殊操作的快速实现。如求2的n次方,可以使用函数pow2(n),而不是pow(2, n)。
如声明float时加f后缀,使用const、static、少用虚函数等。尽量给编译器更具体的信息,以便编译器能够做出更好的优化决定。
编译器也是一种好的优化方法,通过查看编译器生成的汇编代码就可以知道它做了哪些优化。
本章涉及的优化尺度归类为:系统、应用、算法、函数、循环、语句和指令。