串行代码性能优化
串行代码优化可分为以下几个层次: 系统级别,应用级别,算法级别,函数级别,循环级别,语句级别,指令级别。
1.系统级别
1.1 网络速度,利用率及负载均衡
如果应用经常等待网络数据的传输和到达,那么就得考虑网络的速度和利用率,如果是集群还得考虑网络的负载均衡。
1.2 处理器利用率
找出处理器利用率较低或较高的原因。
1.3 存储器带宽利用率
(1)提高存储器访问的局部性以增加缓存利用。
(2)将数据保存在临时变量中以减少存储器的读写。
(3)减少读写依赖。
(4)同时读写多个数据以提高系统的指令级并行和向量化能力。
1.4 去阻塞处理器运算的因素
通过观察处理器的使用率来估计处理器有多少比例的运算时间在等待IO操作完成,如果比较接近,则可使用非阻塞的函数调用或独立的线程来处理IO。
2 应用级别
2.1 编译器选项
如GCC有 O0(无优化),O1,O2(常用),O3等优化选项。
2.2 调用高性能库
如BLAS ,FFTW
2.3 去掉全局变量
全局变量尤其是多个文件共享的全局数据结构会阻碍编译器的优化。对于并行程序,全局变量应当绝对禁止。
2.4 受限的指针
指针标识符 restrict
2.5 条件编译
条件编译可使代码更简短,效率更高。
3. 算法级别
3.1 索引顺序
访问多维数据时的局部性直接与各维数据在内存中存放的先后顺序相关,如C语言中数组是以行为主序存放的,在计算时尽量按行访问数据。
优化前的代码:
for(int i = 0; i < N; i++){ for(int j = 0; i < M; j++){ r[j] += a[j][i]; } }
优化后的代码:
for(int i = 0; i < M; i++){ float ret = 0.0f; for(int j = 0; i < N; j++){ ret += a[i][j]; } r[i] = ret; }
3.2 缓存分块
如果数据的大小超过了缓存的大小,那么久容易出现满不命中的情况,此时常见的减弱不命中的代价的方法主要是缓存分块。
以矩阵乘法为例:
for(i =0; i < N; i+= NB) for(j = 0; j <M; j+= NB) for(k = 0; k < K; k+= NB) for(i0 = i; i0 < (i+NB); i0++) for(j0 = j; j0 < (j+NB); j0++) for(k0 = k; k0 < (k+NB); k0++){ c[i0][j0] += A[i0][k0]+B[k0][j0]; }
3.3 软件预取
预取指在数据被使用前,投机地将其加载到缓存中。必须妥善考虑进行的时机和实施强度。
3.4 查表法
通常将查表法和线性插值结合使用,以减少计算精度的降低。
4 函数级别
4.1 函数调用参数
如果函数参数是大结构体,应当通过传指针或引用以减少调用是复制和返回时销毁的开销。
4.2 内联小函数
能够消除函数调用的开销,提供更过指令并行,表达式移除等优化机会,进而增强指令流水线的性能。
5 循环级别
5.1 循环展开
循环展开不仅减少了每次的判断数量和循环变量的计算次数,更能够增加处理器的流水线性能。通常展开小循环且内部没有判断的会获益,展开大循环则可能会因为导致寄存器溢出而导致性能下降,而展开内部有判断的循环会增加反之预测的开销也会导致性能下降。
循环展开前:
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; for(int i = 0; i < num; i++){ sum1 += a[i]; sum2 += a[i+1]; sum3 ++ a[i+2]; sum +=a[i+3]; } sum += sum1 + sum2 + sum3;
5.2 循环累积
循环累积主要和循环展开同时使用,在减少寄存器的使用量的同时保证平行度。
float sum = 0.0f, sum1 = 0.0f ,sum2 = 0.0f; for(int i = 0; i < num; i += 6;){ sum1 += a[i] + a[i+1]; sum += a[i+2] + a[i+3]; sum2 += a[i+4] + a[i+5]; } sum += sum1 + sum2;
如果直接使用循环展开6次,则总需至少6个临时变量,而现在只要3个,潜在减少了寄存器的使用。
5.3 循环合并
如果多个小循环使用的寄存器数量没有超过处理器的限制,那么合并这几个小循环可能会带来性能好处(增加了乱序执行能力)。 合并的两循环之间没有依赖。
5.4 循环拆分
如果大循环存在寄存器溢出的情况,那么将大循环拆分能提高寄存器的使用率,
6 语句级别
6.1 减少内存读写
后一次循环需要使用前一次循环写入内存的结果,编译器无法很好的优化。
for(int i = 0; i < n; i++){ a[i] += a[i-1]; }
通过保存中间计算结果,减少了一些内存的访问。
temp = a[0]; for(int i = 0; i < n; i++){ temp += a[i]; a[i] = temp; }
6.2 选用尽量小的数据类型
6.3 结构体对齐
尽量大数据类型在前,小数据类型在后,一方面这样可以节省一些空间,另一方面可以更好地满足处理器的对齐要求。
规则:1.结构体占用总字节数尽量是2的幂。
2.每个域的开始地址是它的大小的整数倍。
3. 编译器提供了按字节对齐的原语,如GCC的 __attribute__((aligned(x)))
6.4 表达式移除
表达式移除指去掉重复的,共同的计算或访存,这能减少计算或内存访问次数。
建议: 在开发阶段对所有的访存都检查索引是否越界,再将性能相关部分代码的索引检查代码去掉。
6.5 分支优化
(1)尽量避免把判断放到循环里面。由于分支预测失误会对流水线产生非常不利的影响,因此要避免循环里面有判断语句,此时尽量该车判断里面有循环。
(2)拆分循环。某些循环分支很多,这可能会导致处理器分支预测失败率增加,把他拆分成几个小循环有可能改善处理器的分支预测正确率;
在另一些循环代码中,循环内分支条件依赖循环变量,这种情况可通过拆分循环去掉分支,如常见的奇偶分支模式。如下所示:
//奇偶分支模式 for(int i= 0; i < len; ++i){ if(i&1 == 0) do0(); else do1(); }
此时可以将循环展开两次,一次处理奇数循环,一次处理偶数循环,自然就不需要分支判断:
for(int i= 0; i < len - 1; i+=2){ do0(); do1(); } if(0 != len%2) do0();
如果do1和do0使用临时变量过多,拆分循环可能更好,因为每个循环使用的寄存器可能更少。
(3)合并多个条件。一些分支条件是包含多个比较操作的复杂表达式,编译器通常将他们编译成嵌套的多个if语句代码,如果能将其修改成一个运算,那么只需一个分支,就可以提高分支预测成功率。
(4)使用条件状态值生成掩模来移除条件分支。如:
if(a > 0){ x = a; }else{ x = b; } //可优化为: x = a > 0; a = a*x + b*(1-x);
(5)使用条件复制指令以移除分支
(6)查表法移除分支
(7)分支顺序
6.6 优化交换性能
交换变量值的操作在编程中经常出现,故优化其性能比较重要,ARM在指令上提供了支持。
unsigned char tmp = a[ji]; a[ji] = a[jj]; a[jj] = tmp; //可优化为: unsigned char aji = a[ji]; unsigned char ajj = a[jj]; a[ji] = ajj; a[jj] = aji;
后种代码两次读写之间没有依赖关系,因此并行性更好。
7 指令级别
减少数据依赖,注意处理器的多发射能力,优化乘除法和余模,选择更具体的库函数或算法。