最近看到两篇不错的文章,是关于编译器做的循环优化,分别是编译器怎么做开发者怎么协助编译器来做,还挺有趣的,本文的主要内容也来源于这两篇文章。

1、理想的循环体

一个理想的循环体是:

  • 只做必要之事。 把一些不必要的、冗余的操作放到循环之外。
  • 性能高。 采用更加快速、有效的指令来完成任务。
  • 使用 SIMD。 单条指令操作多个数据。
  • 良好的 CPU 流水线指令。 避免让 CPU 的某单元空转,应充分利用不同单元(divisor、load unit等)来操作。
  • 良好的内存访问局部性。 尽量保证内存访问在同一条 cache line,充分利用局部性。

2、inlining 内联优化

为什么要介绍 inline 内联优化?实际上很多优化操作的前提,是 inline 之后才会出现机会。

在上图中,mul 函数直接被 inline 成 a * b,由于 a 和 b 的值,并没有在过程中被修改,实际上就可以被直接优化成 4 * 2,最后直接替换为 8。减少计算乘法的指令,优化了性能。

注:需要编译器能够同时看到这两个函数的实现体,因为某些函数为外部引用,编译器看不到其实现,只能临时占位(待链接器补充),也就无从优化。这里先不考虑这些情况。(关于跨模块优化可以看下之前的 LTO 优化的文章)

3、充分利用寄存器

CPU 的寄存器数量是有限的,寄存器访问速度比内存访问速度要快很多。

编译器有寄存器的分配算法(register allocator) 来控制寄存器持有一些变量,其他一些变量就要通过访问内存的方式来读取了。

我们想要尽可能地充分利用寄存器,但也会有一些阻碍这样优化的操作:

  1. 循环体内使用过多的变量,导致编译器需要把一些变量操作变成内存访问,该问题称之为寄存器溢出(register spilling)。
  2. 指针别名,使用指针来对变量进行修改,那么相关指令是需要操作到内存,才能真正修改变量在内存中的值。

3.1、使用过多变量问题

在 Intel 平台有特别多的编译器标识(pragma、attribute等),来指导编译器应该如何操作,但是会有局限性,主要是使用 Intel C++ Compiler Classic, 不通用。

其中的一个标识 distribute_point,可以建议编译器把一个大型的循环体,分割为小的。

如果一个循环体里需要操作过多的变量,那么寄存器也无法记载储存这么多变量值,只能通过内存去进行访问了,这也会阻止向量化(vectorization,SIMD,一条指令同时操作多个数据) 的优化。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#define NUM 1024 
void loop_distribution_pragma2(
       double a[NUM], double b[NUM], double c[NUM],
       double x[NUM], double y[NUM], double z[NUM] ) {
  int i;

  // After distribution or splitting the loop.
  for (i=0; i< NUM; i++) {
    a[i] = a[i] +i;
    b[i] = b[i] +i;
    c[i] = c[i] +i;
    #pragma distribute_point
    x[i] = x[i] +i;
    y[i] = y[i] +i;
    z[i] = z[i] +i;
  } 
}

加上这个标识后,上面代码可能就会被编译器分成两个循环了,看似操作更多了,但是加快了变量的访问速度,同时也保证了更好的 CPU cache,性能得到了提升。

但需要确认的是,确实会发生寄存器溢出问题(register spilling),再去使用,所以这种操作不算通用,一般编译器是能自己进行 loop optimization(循环优化),但取决于优化程度。

3.2、指针别名

指针修改变量,确实是需要操作内存,但有一些指针操作的只是局部的变量,编译器无法保证指针的指向会不会修改到内存,所以也导致无法进行优化。

所以把一些无关的操作,提前设置好,或者对不需要修改的变量,生成一个备份来进行操作,让编译器认为可以安全地把值加载到寄存器当中,从而更加有效地利用寄存器。

(关于这一块,感兴趣的可以看下之前的文章《那些编译器优化盲区 - 1》里的指针别名部分)

4、去除冗余操作

4.1、无用操作(unneeded computation)

1
2
3
4
5
6
7
8
void add(int* a, int* b) {
    (*a)++;
    if (b) (*b)++;
}

for (int i = 0; i < n; i++) {
    add(&a[i], nullptr);
}

在 inline 之后,编译器可以发现对变量 b 的操作毫无作用,在无用代码删除(dead code elimination) 时可能会删掉。

4.2、固定值的重复操作(loop invariant computation)

1
2
3
4
5
6
for (int i = 0; i < n; i++) {
    switch (operation) {
        case ADD: a[i]+= x * x; break;
        case SUB: a[i]-= x * x; break;
    }
}

在这个循环体当中,实际上 x * x 是没必要反复计算的,因为它在过程中没有任何改变,所以是个可以提前计算好的固定结果。

这种编译器优化,称之为 loop invariant code motion,算是编译器经常会做的基础优化。

但这里面其实还有一个不变的重复操作,那就是用于判断的 operation,实际上,编译器也是可以进行优化的:为判断得到的不同分支操作,创建各自的循环体版本。这种优化称之为 loop unswitching。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
auto x_2 = x * x;
if (operation == ADD) {
    for (int i = 0; i < n; i++) {
        a[i] += x_2;
    }
} else if (operation == SUB) {
    for (int i = 0; i < n; i++) {
        a[i] -= x_2;
    }
}

但对于 loop unswitching,编译器是很谨慎的,因为这会导致代码量暴增,所以如果确认这么做更好的话,还是开发者手动修改更稳妥。

5、使用性价比高的指令

某些指令的操作会耗比较多的 CPU 时间,那么采用可替代性的、性能更好的指令对循环体会更好。

像一些乘法或除法,是可以用移位操作来替代的。

一种比较常见的编译器优化是 induction variables(变量归纳),比如一些线性的操作 i * 3 是可以像下面这样从 1 优化成 2:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 1
for (int i = 0; i < n; i++) {
    a[i] = i * 3;
}

// 2
tmp = 0;
for (int i = 0, int tmp = 0; i < n; i++) {
    a[i] = tmp;
    tmp += 3;
}

// 3
class MyClass {
    double a;
    double b;
    double c;
};
for (int i = 0; i < n; i++) {
    a[i].b += 1.0;
}

而第 3 个代码,b 是在固定的偏移量 8,所以 a[i].b 的地址,编译器会转换成 a + i * sizeof(MyClass) + 8,而且更进一步,像第 2 个代码一样,每次加上偏移量 sizeof(MyClass) 就好了,而不用每次都使用乘法操作。

6、循环展开(loop unrolling)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 1 
// a[i] = b[i/2]
for (int i = 0; i < n; i++) {
    index = i / 2;
    b_val = load(b + index);
    store(a + i, b_val);
}

// 2
// 有效地减少了 load 的次数
// 因为每两次循环 load 出来的值是一样的
for (int i = 0; i < n; ) {
    index = i / 2;
    b_val = load(b + index);
    store(a + i, b_val);
    i++;

    store(a + i, b_val);
    i++;

    index = i / 2;
    b_val = load(b + index);
    store(a + i, b_val);
    i++;

    store(a + i, b_val);
    i++;
}

循环展开可以有效地降低循环体的开销,而且还可以带来一些额外的优化(去除冗余操作等),甚至可以的话,使用自动向量化计算(像 SIMD 单条指令同时操作多个数据)。

但循环展开也存在一些劣势:增加了内存的负担,特别是对指令 cache 和 CPU 的译指单元不友好,对现代 CPU 的乱序执行(out-of-order)是负面优化。

在早期,编译器还在发展阶段,开发者手工进行循环展开确实有效;但是现在这么操作是会阻碍编译器的其他优化的,最好还是采用编译器所提供的能力,比如 clang 提供了 pragma clang loop unroll factor(n) 来指明循环展开的因子(次数)是多少。

7、循环流水线(loop pipelining)

指令之间的依赖关系(instructions dependencies) 会限制 CPU 的运行速度,比如有两条指令 A 和 B,而 B 指令的操作数是从 A 指令的结果拿的,那么 B 就依赖于 A 了。最简单的案例就如 c[i] = a[i] + b[i]

1
2
3
4
5
6
for (int i = 0; i < n; i++) {
   val_a = load(a + i);
   val_b = load(b + i);
   val_c = add(val_a, val_b);
   store(val_c, c + i);
}

有一种编译器优化 loop pipelining 可以打破这种依赖的链路,实际上就是把每次循环里的操作分离,比如计算上一次的结果、加载下一次的数据,这样就不会出现需要等待结果的情况。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
val_a = load(a + 0);
val_b = load(b + 0);
val_c = add(val_a, val_b);

val_a = load(a + 1);
val_b = load(b + 1);

for (int i = 0; i < n - 2; i++) {
    store(val_c, c + i);
    val_c = add(val_a, val_b);
    val_a = load(a + i + 2);
    val_b = load(b + i + 2);
}

store(val_c, n - 2);

val_c = add(val_a, val_b);
store(val_c, n - 1);

但在现代乱序执行(out-of-order)的 CPU 并没有很好的收益,因为它们可以先执行其他指令,而不需要等待。

8、向量化(vectorization)

向量化其实是利用了 CPU 里的 vector registers,这个寄存器可以同时持有同种类型的多个变量。CPU 也提供了相关的指令,来在这个寄存器中一次性操作所有的数据。相当于利用并行,来提高了性能。一般是用 SIMD(single instruction multiple data) 的语句和指令。

1
2
3
4
5
6
for (int i = 0; i < n; i+=4) { 
    double<4> b_val = load<4>(B + i);
    double<4> c_val = load<4>(C + i);
    double<4> a_val = add<4>(b_val, c_val);
    store<4>(a_val, A + i);
}

但向量化是有一定的前提条件的:

  • 类型简单。 内置类型 int、double 等,或小型的类、结构体。
  • 循环是线性地遍历数组。 增量为 1 最好,否则会带来内存瓶颈(memory wall)。
  • 循环是可计算的。 比如 +1,而不是判断字符串之类的,因为不知道何时才会结束。
  • 循环之间没有依赖关系。 比如 A[i] = B[i] + C[i] 没有依赖,而 A[i] = B[i] + A[i - 1] 会依赖上次结果。
  • 没有指针别名。 比如 double * C = A - 1,因为不知道 C 的指向,所以不能排除存在依赖关系的可能。

所以 SIMD 同样也会存在劣势,上面的前提条件可以说是劣势,还有就是对分支状态的管理。

SIMD 对分支状态是无感知的,一般是用位标识(bit masking,位掩码),比如下面案例,就会导致无效计算的存在。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
for (int i = 0; i < N; i++) {
    if (A[i] >= 1) {
        A[i] = A[i] - B[i];
    }
}
// 上面代码被转换如下
vec4 ones = { 1, 1, 1, 1 }
for (int i = 0; i < N; i+= 4) {
    vec4 A_tmp = load4(A + i);
    vec4 B_tmp = load4(B + i);
    // Calculates the result for each element in the vector, 
    // even those that won't be needing it.
    vec4 res = sub4(A_tmp, B_tmp);
    // Calculates the predicate, i.e. those elements of the
    // vector that will load the new value
    vec4<bool> greater_than_one = operator>=(A_tmp, ones);
    // Conditional load: loads the new value only for those
    // elements where greater_than_one is true.
    A_tmp = load_if_condition(A_tmp, greater_than_one);
    store4(A_tmp, A + i)
} 

9、不常见的优化

9.1、loop interchange

loop interchange 是为了提高内存访问的局部性(提升 cache 命中率)。

1
2
3
4
5
for (int i = 1; i < LEN; i++) {
    for (int j = 0; j < LEN; j++) {
        b[j][i] = a[j][i] - a[j - 1][i];
    }
}

比如上面代码是先进行对每一行的操作,loop interchange 优化可以把这样的操作转换成:先对每一列进行操作。

这个优化在 LLVM 是可以手动开启的:-mllvm -enable-loopinterchange

9.2、loop distribution

loop distribute 是把一个复杂的循环分割成多个,跟 3.1、使用过多变量 的介绍是一样的,这样操作就有机会支持向量化、避免寄存器溢出问题。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
for (int i = 0; i < n; i++) {
    a[i] = a[i - 1] * b[i];
    c[i] = a[i] + e[i];
}

// loop distribute
for (int i = 0; i < n; i++) {
    a[i] = a[i - 1] * b[i];
}

for (int i = 0; i < n; i++) {
    c[i] = a[i] + e[i];
}

在 LLVM 同样可以手动开启:-mllvm -enable-loop-distribute

同时也有编译器标识可以设置:pragma clang loop distribute(enable)

9.3、loop fusion

有循环分割,那就会有分支融合 loop fusion。loop fusion 用于合并两个独立的循环体。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// Find minimum loop
auto min = a[0];

for (int i = 1; i < n; i++) {
    if (a[i] < min) min = a[i];
}

// Find maximum loop
auto max = a[0];

for (int i = 1; i < n; i++) {
    if (a[i] > max) max = a[i];
}

// loop fusion
for (int i = 1; i < n; i++) {
	if (a[i] < min) min = a[i];
    if (a[i] > max) max = a[i];
}

10、开发者应该如何协助编译器?

看完上面的编译器优化,那么也就知道有很多情况下,会导致编译器无法有效地进行优化,其中两大杀手可以说是函数调用和指针别名了。作为开发者,有什么方式来协助(hint)编译器来进行有效的优化?

10.1、function calls

1
2
3
4
5
6
for (int i = 0; i < n; i++) {
   ...
   if (debug) { 
       printf("The data is NaN\n"); 
   }
}

对于函数调用,编译器会担扰其更改了内存状态,导致循环体出现变化。在上面的代码中,如果变量 debug 是全局变量、堆分配的内存、类变量的话,都是有可能改变这个变量的状态的。如果编译器知道这个状态是不会被改变,那么就可能进行 loop unswitching 优化(本文 4.2 有介绍)。

破局之道就在于,让编译器认为函数调用并不会更改这个状态,最好的方式就是给这个变量做一个备份。

1
2
3
4
5
6
7
8
bool debug_local = debug;

for (int i = 0; i < n; i++) {
   ...
   if (debug_local) { 
       printf("The data is NaN\n"); 
   }
}

还有很多情况下,是需要 inline 了函数调用之后,才能看到优化机会的(本文 2 有介绍)。

10.2、pointer aliasing

指针别名导致没法用寄存器去持有数据,而只能够通过内存去操作,同时也阻碍了向量化的优化(本文 8 有介绍向量化的前提条件,之前的文章《那些编译器优化盲区 - 1》指针别名部分也有介绍),因为潜在依赖的可能性,编译器必须兼容所有的情况。

1
2
3
4
5
6
7
8
9
for (int i = 0; i < n; i++) {
   b[i] = 0;
   for (int j = 0; j < n; j++) {
      b[i] += a[i][j];
   }
}
// 设想
double** a = new double*[n];
double* b = new double[n];

这里 a 和 b 是有可能指向同一个对象的,这样就会导致有依赖关系。

作为开发者,如果是明确没有这种依赖关系的话,可以使用编译器提供的关键字 __restrict__ ,指明这个 变量/指针 是独立的。

1
2
3
4
5
6
7
8
for (int i = 0; i < n; i++) {
   double sum = 0;
   double* __restrict__ a_row = a[i];
   for (int j = 0; j < n; j++) {
      sum += a_row[j];
   }
   b[i] = sum;
}

10.3、attribute

如果处于不同的编译单元(translation unit),会导致函数没能进行 inline 优化。但 gcc 和 clang 还是提供了 attribute,让开发者可以提醒编译器进行相应的优化。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// fun.h (暴露给使用方的头文件)
	// 方案 1
int square(int num);
	// 方案 2
int __attribute__((pure)) square1(int num);

// fun.cpp (不重要,外部只能看到头文件,这里只做展示)
#include "fun.h"
int square(int num) {
	return num * num;
}

// use.cpp (模拟使用方)
#include "fun.h"
int use_fun(int num) {
  int n = 0;
  for(int i = 0; i < 5; ++i) {
    n += square(num);
  }
  return n;
}

attribute pure 代表函数的返回值(结果),只会依赖于函数的入参和内存的状态。从而向编译器保证它不会写内存,也不会调用其他函数。

这样,外层对于 pure 函数就可以进行优化,比如省略对于以相同入参调用相同函数的次数,或者当结果没被用到时去掉函数调用。

来看看上述案例的汇编代码是怎样的?亲测有效!不需要看明白汇编代码,看个大概就知道编译器所做的优化了,正是省略对于以相同入参调用相同函数的次数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// g++ fun.h -c use.cpp -O3

// 方案1:int square(int num);
 stp     x20, x19, [sp, #-0x20]!
 stp     x29, x30, [sp, #0x10]
 add     x29, sp, #0x10
 mov     x19, x0
 bl       __Z6squarei     ; square(int)
 mov     x20, x0
 mov     x0, x19
 bl       __Z6squarei     ; square(int)
 add     w20, w0, w20
 mov     x0, x19
 bl       __Z6squarei     ; square(int)
 add     w20, w0, w20
 mov     x0, x19
 bl       __Z6squarei     ; square(int)
 add     w20, w0, w20
 mov     x0, x19
 bl       __Z6squarei     ; square(int)
 add     w0, w0, w20
 ldp     x29, x30, [sp, #0x10]
 ldp     x20, x19, [sp], #0x20
 ret

// 方案2:int __attribute__((pure)) square1(int num);
 stp     x29, x30, [sp, #-0x10]!
 mov     x29, sp
 bl      __Z6squarei             ; square(int)
 add     w0, w0, w0, lsl #2
 ldp     x29, x30, [sp], #0x10
 ret

还有另外一个 attribute const,代表函数的返回值(结果),只会依赖于函数的入参。所以 const 也是 pure 的,不过 const 更进一步,编译器对于未能 inline 的 attribute const 函数(前提是函数可见、非外部引用),可以直接在编译时计算好结果,然后代替函数调用。

参考

memory wall

Crash course introduction to parallelism: SIMD Parallelism

distribute_point

Intel C++ Compiler Classic

Loop Optimizations: how does the compiler do it?

Loop Optimizations: taking matters into your hands

Optimization without Inlining

Make your programs run faster: avoid function calls