开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 2 天,点击查看活动详情
Bentley Rules for Optimizing Work
关于Work
我们可以把程序要做的工作总和作为程序的工作量,或者说Work。例如我们要计算一个矩阵乘法,那么矩阵乘法的全部工作量就是Work。
再比如我们需要对一个数组的数据进行排序,排序这样一件事情也是Work。我们知道排序算法有很多种,并且有不同的算法复杂度。值得注意的是,考虑到cache、向量化、分支预测等原因,减少程序的Work并不一定会减少程序的运行时间。尽管这样,减少程序的Work仍然可以被用来尝试减少程序的运行时间。
Bentley Rules是一系列处理多年前的计算机架构的方法,本文将介绍一系列新的Bentley Rules的相关内容来减少程序的工作量。
Data structures
Packing and encoding
Packing和Encoding的思路是一致的,通过更少的数位来表达同样的信息。具体来看,Packing将数据存储到数据位上,按照规则直接解析数据就可以得到数据;而Encoding则需要一种对应关系,通过对应来获取到对应的数据。
举例来看,我们可以将一个表示日期的字符串“September 11, 2022”映射到一串比特位上。
用Encoding的角度来看,我们可以假定年份保持在[-4096, 4096]这样的区间中,那么这个区间里的天数一共是天,那么用二进制表示的话大约需要bit位即可。每次解析数据的时候,我们只需要根据这个就可以计算出当天的年月日,不过这个转换过程有一点麻烦。
而用Packing的角度来看,则相对简单得多。我们尝试用二进制分别表示年月日。在同样的背景下,我们可以判断出年位需要bit,而月份的话只需要4bit就可以表示,日则需要5bit进行表示。虽然整体而言消耗的字节数是一样的,但是转换速度上Packing会更快一些。
这样的话,我们就将一个原来需要18bytes的字符串转换成为了一个不到32bit的数据,大大减少了存储和传输时间。但是这样的使用并不是百利而无一害的,我们需要在使用数据的时候进行转换,而转换也需要一定的开销。
Augmentation
Augmentation则是一种以空间换时间的思路,通过多存储一些信息来减少一些操作的时间。
例如在单链表中,当我们需要添加一个节点时,我们需要先找到尾节点,在进行链接。这需要我们每次都进行一次链表的遍历,所以我们不妨在链表结构中加上一个记录尾结点的指针,这样我们就可以快速的进行添加。
Pre-computation
Pre-computation是一种预计算的方式,通过空间来换时间。
举例来说,当我们需要计算二项式系数的时候,我们可以通过如下的方式来进行计算:
与此同时,我们也可以用递推的方式进行计算。
考虑到递推,我们可以借助帕斯卡三角来辅助运算:
如果我们仅需要计算一次二项式系数,那么其工作量是一致的。但是如果我们需要计算n次二项式系数的话,为了减少重复运算,我们不妨用空间换时间的思路,先做一次预计算,然后去找对应的结果即可。
Compile-time initialization
编译时优化也是一种空间换时间的方式,我们在编译的时候就将一些静态数据编译到代码中,从而减少执行时间。
值得注意的是,如果数据量过大的话,那么程序代码就会增加,有时候会因为内存空间的不足导致换页从而影响程序运行速度。
Caching
Caching,顾名思义,是一种保存结果的方式。但是与硬件cache不同的是,我们在软件层面记录下已有的结果,当下次遇到这个数据的时候我们直接返回即可。值得注意的是,这个和硬件cache一样,需要我们的代码有比较高的局部性,否则命中的次数会非常的低,甚至不如不加这个代码。
例如如下的代码:
inline double test(double A, double B) {
return sqrt(A * A + B * B);
}
如果代码中需要经常的做同样数据的计算的话,我们不妨进行如下的调整:
double cache_A = 0.0;
double cache_B = 0.0;
double cache_h = 0.0;
inline double test(double A, double B) {
// 如果局部性太差 这里的if分支成功率将会极低,从而影响运算速度
if (A == cached A && B == cache_B) {
return cached_h;
}
cache_A = A;
cache_B = B;
cache_h = sqrt(A * A + B * B)
return cache_h;
}
Lazy evaluation
延迟计算,仅仅在需要的时候才进行计算,而不是一开始就算好。
不妨以操作系统中的COW特性作为例子。当我们复制进程时,我们也会将页表复制过去,但是在这个时候,我们并不会修改页表中的内容,而是直接全盘复制,并在标志位上标志其为写时复制的页表;当我们第一次去访问的时候,因为该页表是写时复制的,所以其不能够被修改,从而会触发中断,这之后我们才能对其进行修改。
从做整件事的运行时间上来说,我们并没有减少时间,只是将做这个事情的时间相对的延后了,从而减少在一开始的大量运算。
Sparsity
稀疏化,是一种处理稀疏数据的方法,通过略去处理零值的方式来加快运算速度。
以矩阵乘法为例子:当我们处理如下图的矩阵乘法时,我们可以看出其大部分数据都为0,所以我们不妨只计算非零值。
和数据结构中说到的稀疏矩阵类似,我们需要用另一种方式来进行数据的存储。这里我们就不做展开。
Logic
Constant folding and propagation
常量折叠与常量传播,是编译过程中经常用到的一种手段。如果一个变量的值在后续的一段时间里并没有变化,我们就可以在编译的时候直接将该变量编译成常量。这一点目前的编译器已经足够支持。
Common-subexpression elimination
公共子表达式消除,也是在编译中经常用到的一种优化方式。假设有个表达式A=B+C被多次用到,并且在该过程中B和C的值都没有变化,那么编译器就会将该结果保存下来并重复使用,不会再在运行时进行计算,从而加快程序执行速度。
Algebraic identities
代数恒等式是一种编码时的优化,这需要程序员在代码上做优化。
具体而言,虽然两种不同的操作在结果上是一致的,但是在程序运行上本身就有差异,我们就可以进行替换。
例如,当我们判断点是否在圆内的时候,我们可以使用距离和半径进行比较,其中一种方式是:
但是开方操作消耗的时间更多一点,我们不妨用如下的方式进行判断:
Short-circuiting
短路操作是一种减少不必要操作的思想,我们假设有一件事有多个充分条件,那么在一个条件充分以后我们就没有必要进行更多的运算了;或者已经达到条件了,我们就直接结束即可。
例如,我们判断数组加和是否超过某个数:
bool sum_exceeds(int *A, int n, int limit){
int sum = 0;
//计算加和 不考虑溢出的情况
for(int i = 0; i < n; ++i){
sum += limit;
}
return sum > limit;
}
由于可能在中间的某个时刻,加和已经超过了该limit,我们不妨做如下的修改:
bool sum_exceeds(int *A, int n, int limit){
int sum = 0;
//计算加和 不考虑溢出的情况
for(int i = 0; i < n; ++i){
sum += limit;
if(sum > limit){
return true;
}
}
return false;
}
Ordering tests
顺序测试,顺序测试和前文的短路操作的思想上有相似的地方。假设我们要做一系列逻辑判断的时候,我们期望越靠前的判断的命中率越高,这样我们就无须执行到最后一个判断就可以跳转。值得注意的是,在这里我们很难知道哪个判断的命中率是最高的,我们可能需要对顺序进行测试,基于测试的结果来进行顺序的排布。
Creating a fast path
Creating a fast path,创建快速路径,是指我们可以通过一种很明显的方式进行结果的判断,进行剪枝。
举例而言,当我们判断两个圆是否碰撞的时候,我们可以用如下的方式进行判断:
当两圆不碰撞的时候,我们可以假设有如下的情况:
我们不妨做一些简单的快速判断:
在这些情况下,我们无须去计算平方就可以快速的判断出两圆并没有碰撞。
Combining tests
组合判断,是一种处理多个分支的时候,将分支结果进行合并,合并成一个判断或者switch的方式。
我们以全加器举例:
我们可以看到,如果我们分别对a、b、c进行判断的话,那么我们会有非常多的if与else条件,我们不妨把a、b、c组合到一起,形成一个3bit的数据,基于如上的表进行switch即可。
Loops
Hoisting
循环不变量提取,我们可以将循环中用到的不变量表达式提取到循环之外,以减少重复计算。目前很多编译器已经可以做到这一点。
举例:
void scale(double *X, double *Y, int N){
for(int i = 0; i < n; ++i){
Y[i] = X[i] * exp(sqrt(M_PI/2));
}
}
提取,如下:
void scale(double *X, double *Y, int N){
double factor = exp(sqrt(M_PI/2));
for(int i = 0; i < n; ++i){
Y[i] = X[i] * factor;
}
}
Sentinels
哨兵是在循环中经常用到减少边界条件的用法。
我们假设数组均为正数,判断加和是否越界,代码如下:
bool overflow(int64_t *A, size_t N){
int64_t sum =0;
for(size_t i = 0; i < N; ++i){
sum += A[i];
//overflow 成为负数
if( sum < A[i] ) return true;
}
return false;
}
我们尝试通过添加一个哨兵实现:
bool overflow(int64_t *A, size_t N){
A[N] = INT64_MAX; //哨兵
A[N+1] = 1;
size_t i = 0;
int64_t sum = A[0];
//通过哨兵替代i < n的边界条件
while( sum >= A[i] ){
sum += A[++i];
}
if( i < n ) return true;
return false;
}
Loop unrolling
循环展开,循环展开是一种牺牲程序空间换取运行速度的方式,可以由程序员手动循环展开也可以由编译器完成。通过循环展开,程序可以减少迭代的次数来减少分支预测失败的次数,更好地利用CPU的流水线特性,并且增加了并行执行执行的可能。循环展开分为全展开和部分展开。
全循环展开:
//展开前
int sum = 0;
for(int i = 0; i < 8; ++i){
sum += A[i];
}
//展开后
int sum = 0;
sum += A[0];
sum += A[1];
sum += A[2];
sum += A[3];
sum += A[4];
sum += A[5];
sum += A[6];
sum += A[7];
部分循环展开:
//展开前
int sum = 0;
for(int i = 0; i < n; ++i) {
sum += A[i];
}
//展开后
int sum = 0;
int j;
//在控制块里有至少四条指令,可以更好地利用流水线
for(j = 0; j < n - 3; j += 4) {
sum += A[j];
sum += A[j+1];
sum += A[j+2];
sum += A[j+3];
}
for(int i = j; i < n; ++i) {
sum += A[j];
}
Loop fusion
循环融合,需要我们把两个相同的循环进行组合,利用同一个循环控制语句来进行更多的指令操作。
//原来
for(int i = 0; i < n; ++i){
C[i] = (A[i] <= B[i]) ? A[i]: B[i];
}
for(int i = 0; i < n; ++i){
D[i] = (A[i] >= B[i]) ? A[i]: B[i];
}
//融合后
for(int i = 0; i < n; ++i){
C[i] = (A[i] <= B[i]) ? A[i]: B[i];
D[i] = (A[i] >= B[i]) ? A[i]: B[i];
}
当然,并不要求两个循环的范围完全一致,只要两者的迭代器能有一些关系,就可以进行融合。
Eliminating wastes iterations
减少无效迭代,是指我们可以修改循环边界来减少执行无效的语句。
// 原来
for(int i = 0; i < n; ++i) {
for(int j = 0; j < n; ++j) {
if(i > j){
...
}
}
}
// 优化后
for(int i = 0; i < n; ++i){
for(int j = 0; j < i; ++j){
...
}
}
Functions
函数级别的优化主要在减少函数调用上。
Inlining
内联也是一种从编译的角度加快代码执行速度的方式,考虑到函数调用会有开销,我们可以用inline将一些小函数在编译的时候编译掉,这样就可以加快代码执行。
Tail-recursion elimination
尾递归消除,需要我们将出现在函数末尾的递归调用变成分支使用,从而减少函数调用的开销。
// 消除前
void quicksort(int *A, int n) {
if (n > 1){
int r = partition(A, n);
quicksort(A, r);
quicksort(A + r + 1, n - r -1);
}
}
// 消除后
void quicksort(int *A, int n){
while(n > 1){
int r = partition(A, n);
quicksort(A, r);
A += r + 1;
n -= r + 1;
}
}
值得注意的是,目前很多编译器已经做了尾递归的优化。
Coarsening recursion
粗化递归,是指在递归到一个比较小的case的时候,不继续进行递归,而是直接计算结果,通过这样的方式来减少函数调用。例如快排,我们可以在仅有几个数据的情况下直接进行某一种排序,这样即使其复杂度会达到,但是其数据量比较小并且减少了函数调用,还是可以使用的。
建议
- 减少过早的优化,先保证程序的正确性
- 减少程序的工作并不一定会减少其运行时间。
- 关注编译器所做的工作,很多低级别的优化现在的编译器已经可以做到。
- 如果想要判断编译器是否进行了特定的优化,需要去查看汇编代码。