比起C++性能榨汁机,你可能更需要的是 -O3 和 ICC

前两天看到一个文章系列:《C++性能榨汁机》,其中前面四篇(第四篇)在讲分支预测和如何避免分支预测带来的性能下降问题。文章大体的思路是对的,只是我觉得手写条件传送代码实在是有点奇技淫巧,在绝大部分的情况下是不应该提倡的。我觉得比起条件传送代码,用 SIMD 加上掩码才是更直观的操作。我还是用原文的代码来说明这个问题。

文末是我重新写的测试代码,原文的原始/优化实现分别对应 accumulation 和 accumulation2 这两个函数。

我们把原始实现贴到 Compiler Explorer 里看一下它的汇编代码。注意,在编译器选择栏右侧的编译参数里,填上 -O3 -mavx。其中 -mavx 表示利用 AVX 指令集(基本上2011年以后的 x86 CPU 都有这个指令集了)。我们可以看到,核心段代码是这样的:

1
2
3
4
5
6
7
8
.L5:
vmovdqu xmm3, XMMWORD PTR [rax]
add rax, 16
vpcmpgtd xmm0, xmm3, xmm2
vpand xmm0, xmm0, xmm3
vpaddd xmm1, xmm1, xmm0
cmp rax, rdx
jne .L5

rax 是指向 data 下一个元素的指针。每次从 data 中取出 4 个元素,存到 xmm3 中。vpcmpgtd xmm0, xmm3, xmm2 表示将 xmm3 中的 4 个元素与 xmm2 中的 4 个元素比较,如果大于的话则对应的 32 位填 1,结果保存到 xmm0 中。这里 xmm2 是在主循环之前就填充好的,四个元素均为 128。vpand xmm0, xmm0, xmm3 表示将 xmm0 与 xmm3 按位与,结果保存在 xmm0 中。这一步以后相当于把小于 128 的元素清零了。然后,结果被 vpaddd xmm1, xmm1, xmm0 累加到 xmm1 上,最后再将 xmm1 中的四个元素相加,就得到最后的结果了。
条件传送代码?不好意思,没有出现。本质上 vpcmpgtdvpand 就是在做条件传送,但是就是一个简单的比较和掩码操作。我们同样看看那个条件传送优化代码在 -O3 -mavx 下面长什么样子:

1
2
3
4
5
6
7
8
.L5:
vpaddd xmm0, xmm2, XMMWORD PTR [rax]
add rax, 16
vpsrad xmm0, xmm0, 31
vpandn xmm0, xmm0, XMMWORD PTR [rax-16]
vpaddd xmm1, xmm1, xmm0
cmp rax, rdx
jne .L5

你看,大同小异,只是把 vpcmpgtd 换成了 vpsrad (右移位),把 vpand 换成了 vpandn (按位非)。但是正如原文说的,这个优化过的 C 代码非常不直观和不利于维护。

最后给 Intel 免费打个广告。作为高性能计算领域 x86 平台上的性能标杆,ICC 非常擅长对代码进行向量化编译。很多看起来很复杂的 loop,只要是符合向量化要求的,加上合适的编译器制导提示,ICC 都能给你漂漂亮亮编译出来。以及,ICC 默认的 -O2 优化,对大部分的代码,已经可以把 GCC 按在地上摩擦了。下面就是测试代码在我机器上的实测情况:

GCC_ICC_cmp

你看,开了指令集优化和 -O3 的 g++,编译出来的原始代码仅仅比优化过的版本慢了一点。而 ICC 默认参数编译出来的版本,已经把比这两个都快了不少,更不要说 ICC 也开了指令集优化和 -O3 了。如果你把这个代码拿到更新 CPU 平台上去测,比如 Skylake,差距还会更大。毕竟,儿子(x86 CPU)的尿性,当爹的(Intel)最清楚……

附:测试代码

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>
int accumulation(int *data, int arraySize)
{
int result = 0;
for (int i = 0; i < arraySize; ++i)
if (data[i] >= 128)
result += data[i];
return result;
}
int accumulation2(int *data, int arraySize)
{
int result = 0;
for (int i = 0; i < arraySize; ++i)
{
int t = (data[i] - 128) >> 31;
result += ~t & data[i];
}
return result;
}
double get_wtime_sec()
{
double sec;
struct timeval tv;
gettimeofday(&tv, NULL);
sec = tv.tv_sec + (double) tv.tv_usec / 1000000.0;
return sec;
}
int main()
{
const int arraySize = 32000;
const int nRepeat = 20000;
int *data0 = (int*) malloc(sizeof(int) * arraySize);
int *data1 = (int*) malloc(sizeof(int) * arraySize);
for (int i = 0; i < arraySize; i++)
{
int data = rand() % 256;
data0[i] = data;
data1[i] = data;
}
int result = 0;
double st = get_wtime_sec();
for (int i = 0; i < nRepeat; i++)
{
result += accumulation(data0, arraySize);
data0[i % 1919] += i;
}
double et = get_wtime_sec();
printf("Naive approach, result = %d, time elapsed = %.2lf s\n", result, et - st);
result = 0;
st = get_wtime_sec();
for (int i = 0; i < nRepeat; i++)
{
result += accumulation2(data1, arraySize);
data1[i % 1919] += i;
}
et = get_wtime_sec();
printf("So-called optimized approach, result = %d, time elapsed = %.2lf s\n", result, et - st);
free(data0);
free(data1);
return 0;
}