CSAPP学习笔记之优化代码性能

600 阅读7分钟

写C++的一般都比较注意性能,最近在学csapp,正好有一节课讲到这个了,之前对这些也没怎么注意过。在这里记录总结一下。

编译器做的优化

1. 编译器可能做的优化

1. 使用寄存器加快数据访问。

2. 代码重排(现代cpu使用的流水线技术,可以将逻辑无关的指令流水线执行)

3. 消除死代码执行

4. 常量替换

5. 使用inline函数

2. 编译器不能做优化的场景

但是编译器有时候并不能进行一些我们觉得可以做的优化,主要是存在两个问题:

1. 内存别名引用

2. 程序的副作用

首先来解释一下这两个词的含义。我们先来说一下内存别名,这里用一个csapp里的例子来解释。

void twiddle1(long *xp, long *yp){
    *xp += *yp;
    *xp += *yp;
}

void twiddle2(long *xp, long *yp){
    *xp += 2 * *yp;
}

你们觉得这两个代码有什么区别吗?我第一反应是没想到的。但是有一个edge case,就是xp和yp指向的是同一个对象。在这种情况下第一个最终xp指向的值会是4倍的初始值,而第二个则是3倍的初始值。出现这个结果的原因就是出现了别名引用,即xp和yp指向的是同一个对象。在编译器做安全的优化的时候必须在任何情况下都不会出问题,因此内存别名引用是限制优化的一大因素之一。C99以后加入了一个关键字叫restrict,表明该指针指向的对象不会被其他指针指向,即不存在内存别名引用的问题,让编译器可以做一些更大胆的优化。还有一个大牛对这个有更详细的讨论,可以看这里

我们再来说说程序的副作用。还是用csapp中的例子。

long f1();

long func1(){
    return f1() + f1() + f1() + f1();
}

long func2(){
    return 4 * f();    
}

乍一看好像也没什么问题,但是要看f做了什么操作。在这种情况下就会完全不一样。

long cnt = 0;
long f1(){
    ++cnt;
    return cnt;
}

所以编译器也不能大胆的进行优化,只能调用4次f1() 函数。如果想让编译器可以大胆的优化可以把这里的f1声明为inline函数,这样在func里就可以进行展开,然后编译器就可以根据展开的结果再进行优化。

程序员可以做的代码优化

1. 减少不必要的函数调用。

先看一个非常简单的例子,对一个数组进行加法。for循环的终止条件是大于等于数组的size。第一个函数和第二个函数的区别就是第一个是在循环中调用size()函数,第二个是在一开始就计算size。

static void vectorAdd1(benchmark::State& state) {  vector<int>nums(1000, 1);  
long cnt = 0;  for (auto _ : state) {
  // Code inside this loop is measured repeatedly
    for(size_t i = 0; i < nums.size(); i++){      
            cnt += nums[i];    
        }  
    }
}// Register the function as a benchmarkBENCHMARK(vectorAdd1);

static void vectorAdd2(benchmark::State& state) {  
    // Code before the loop is not measured  vector<int>nums(1000, 1);  
    long sz = nums.size();  
    long cnt = 0;  
    for (auto _ : state) {    
        for(long i = 0; i < sz; i++){      
            cnt += nums[i];    
            }  
        }
 }

BENCHMARK(vectorAdd2);

那这个size()函数又在干啥呢?下面的截图来着clang的libc++的size()实现方式。

其实也很简单,就是把尾迭代器减去头迭代器的偏移强转为size_t类型,然后再返回。第一个和第二个函数相比而言在每次循环过程中都会增加这次计算带来的开销。我们在这里使用这个网站来进行benchmark比较,比较参数是使用clang 优化开到O1级别。比较的结果如下。

可以看到还是有较大的差距的。但是O1的优化都是比较保守,那如果我们把优化级别调到O2甚至更高呢?结果如下。

这个时候已经看不到差距了。看了一下汇编,编译器已经不会再去调用size函数了,编译器识别出来了size大小不会变化,生成了size大小存在寄存器里去比较的。

那我们再加一个小改动,在函数里让这个数组的大小变化,但是一次循环后保持总大小不变(先减少一个再增加一个)再来看看结果。

static void vectorAdd1(benchmark::State& state) {  
    // Code inside this loop is measured repeatedly    
    vector<int>nums(1000, 1);  
    long cnt = 0;  
    for (auto _ : state) {    
        for(size_t i = 0; i < nums.size(); i++){      
            nums.erase(--nums.end());      
            nums.push_back(1);      
            cnt += nums[i];    
            }  
        }
   }// Register the function as a benchmark
BENCHMARK(vectorAdd1);
static void vectorAdd2(benchmark::State& state) {  
    // Code before the loop is not measured  
    vector<int>nums(1000, 1);  
    long sz = nums.size();  
    long cnt = 0;  
    for (auto _ : state) {    
        for(long i = 0; i < sz; i++){
              nums.erase(--nums.end());      
              nums.push_back(1);      
              cnt += nums[i];    
            }  
        }
}BENCHMARK(vectorAdd2);

这个时候还是使用常见的O2级别优化,结果如图。

可以看出来这个时候已经有了较大的差距。因为编译器发现size大小在变化,只能去调用函数去拿到大小,看了一下两个代码生成的汇编确实也是这样的。所以如果在能确定循环过程中循环的内容大小不会发生变化,尤其是在循环中可能对内容进行修改的话(但是我试了一下如果不影响size大小好像是没什么大所谓的),可以考虑使用变量存储终止条件来优化性能。

2. 减少不必要的内存访问。

还是用代码来说话

void v_add1(vector<int>& nums, long* res){  
    auto sz = nums.size();  
    for(long i = 0; i < sz; i++){      
            *res += nums[i];  
        }
    }

void v_add2(vector<int>& nums, long* res){  
    auto sz = nums.size();  
    auto tmp = *res;  
    for(long i = 0; i < sz; i++){      
            tmp += nums[i];  
        }  
        *res = tmp;
    }

static void vectorAdd1(benchmark::State& state) {  
    // Code inside this loop is measured repeatedly    
    vector<int>nums(1000, 1);  
    long sz = nums.size();  
    long cnt = 0;  
    long* ptr = &cnt;  
    for (auto _ : state) {    
        v_add1(nums, ptr);  
    }
}// Register the function as a benchmark

BENCHMARK(vectorAdd1);

static void vectorAdd2(benchmark::State& state) {  
    // Code before the loop is not measured  
    vector<int>nums(1000, 1);  
    long sz = nums.size();  
    long cnt = 0;  
    long* ptr = &cnt;  
    for (auto _ : state) {    
            v_add2(nums, ptr);  
        }
    }
BENCHMARK(vectorAdd2);

还是两个简单的数组内容相加。不同的是第二个是一开始存贮了到了一个tmp变量,最后计算完成再赋值的。可以看出来每次循环里,第一个都多做了一次内存访问。

先看一下O1下的结果。

可以看到还是有一定的性能差距的。看生成的汇编代码确实是第一个函数增加了额外的内存访问。那我们把编译优化选项开到O3再看看。

这个时候性能已经没有明显的差异了,看汇编也是生成的一样的代码,即将结果放置于临时变量中,最后再赋值。

3.循环展开

在看循环展开的之前我们先要了解一下现代CPU的工作原理才能知道循环展开是如何优化性能的。我们现在使用的CPU都是使用多流水线的支持超标量(superscale)乱序执行的CPU。所谓的超标量即是在一个循环周期内可以同时执行多个指令,当然能过同时执行的前提是指令之间没有相互依赖才可以。具体一个时钟周期能执行多少个指令要看CPU执行的规格。循环展开就是利用这个超标量的技术来加速程序运行的,即尽肯能的使得逻辑无关的代码可以在一个时钟周期来执行。

为了更详细的解释这个东西我用csapp的课件里的图片来解释一下。

在执行一个具体的机器指令的时候会存在多个阶段,比如取值,解码,执行,访存,执行等等。这些操作需要依次执行,但是每个阶段可能可以做多个。可以看到a * b 和 a * c是可以并行执行的,而且这个cpu支持多个乘法操作,我们假设这里需要三个阶段执行一个机器指令,于是在执行的时候由于存在流水线a * b 和 a * c就可以共用中间重叠的时钟周期。而p1 * p2需要使用前两个的结果,因此必须要等待前两个计算完成才可以继续。使用了这种超标量的流水线技术我们就把原来需要9个时钟周期的操作缩短到了7个时钟周期。加速了程序执行的时间。

那我们现在来看一下这个循环展开到底怎么来操作。还是用那个最简单的加法操作。

static void vectorAdd1(benchmark::State& state) {
  // Code inside this loop is measured repeatedly

  vector<int>nums(1000, 1);
  for(int i = 0; i < 1000; i++){    nums[i] = i;  }
  long sz = nums.size();
  long cnt = 0;
  for (auto _ : state) {
    for(long i = 0; i < sz; i++){
      cnt += nums[i];
    }
    //cout << cnt;
    benchmark::DoNotOptimize(vectorAdd1); 
  }
}
// Register the function as a benchmark
BENCHMARK(vectorAdd1);

使用循环展开的话我们就要构造出来可以逻辑上没有互相依赖执行的代码,我们这里是做加法,我们可以在一个循环里进行多次操作。代码如下。

static void vectorAdd2(benchmark::State& state) {
  // Code before the loop is not measured
  vector<int>nums(1000, 1);
  for(int i = 0; i < 1000; i++){    nums[i] = i;  }
  long sz = nums.size();
  long cnt1 = 0;
  long cnt2 = 0;
  long cnt = 0;
  for (auto _ : state) {
    for(long i = 0; i < sz; i+=2){
      cnt1 += nums[i];
      cnt2 += nums[i + 1];
    }
    cnt = cnt1 + cnt2;
    benchmark::DoNotOptimize(vectorAdd2);
  }
}
BENCHMARK(vectorAdd2);

我们在一个循环里做了多次加法,然后使用两个变量统计结果使得逻辑没有依赖,然后最后再把两个分结果合并生成真正的结果。用图来展示的话就是这样的。(图来自csapp的课件)

这样就可以利用超标量的流水线来实现指令级别的并行操作。我们来看一下O1优化级别下的性能差异。

可以看到在O1的优化下性能提高了30%。再看看优化开到最高的O3的差别。这个时候差别更大了。看汇编使用了xmm寄存器实现并行加法,效率差不多是2倍。

再看一下4 * 4的循环展开的效率对比图,效率差不多提升了四倍(以下都是在O3的情况下)。

再看10 * 10的还是在增长。

当然循环展开也不是能够无限提升效率的,首先你的最快速度不能超过IO bound(就是一开始stage 1 2 3那个图里的理论最快速度),其次在使用这种xmm ymm矢量加法寄存器计算的时候循环展开的层数要小于寄存器的个数,否则会存储相关变量只能在栈上开辟空间。这种现象就是寄存器溢出,但是x64的寄存器个数很多,很少会发生这个情况,往往在发生寄存器溢出前就已经接触到了IO bound。

这个是SIMD (single input multiple data)使用的XMM和YMM寄存器的样子。可以看到一次指令执行操作可以进行多次计算,有效的提高了计算效率。

总结一下,学这一张还是学到了不少东西的。感觉为了优化性能要对CPU的工作原理有比较清晰的认识。以后还是要多学习一下这方面的知识。