这是一个对整数向量求和的基本函数。当使用 gcc 进行第三级优化(-O3)编译时,我可以达到 CPE 0.51 ,这是我们可以获得的最大值。
int sum_basic(int a[], long n)
{
int acc = 0;
for (long i = 0; i < n; i++) {
acc += a[i];
}
return acc;
}
以下是此功能的优化版本,适用于 4x4循环展开。我能得到的最好的是 CPE 0.84 。我尝试了其他类型的优化,但无法接近 CPE 0.51 。使用整数乘法我可以击败gcc,也可以用浮点运算来实现最大性能,而gcc则不行。但是加上整数加gcc打败了我。有什么问题?
int sum_optimized(int a[], long n) {
int acc1 = 0;
int acc2 = 0;
int acc3 = 0;
int acc4 = 0;
for (long i = 0; i < n; i+=4) {
acc1 += a[i];
acc2 += a[i+1];
acc3 += a[i+2];
acc4 += a[i+3];
}
return acc1 + acc2 + acc3 + acc4;
}
我使用此代码来衡量CPE:
// get CPU cycle counter
static __inline__ unsigned long long rdtsc(void)
{
unsigned hi, lo;
__asm__ __volatile__ ("rdtsc" : "=a"(lo), "=d"(hi));
return ( (unsigned long long)lo)|( ((unsigned long long)hi)<<32 );
}
#define SIZE 10000
int a[SIZE];
int main(void)
{
// cache warm up + initialize array
for (long i = 0; i < SIZE; i++)
a[i] = rand();
long r_begin = rdtsc();
//---------- MEASURE THIS ------------
int res = sum_optimized(a, SIZE);
//------------------------------------
long r_end = rdtsc();
long cycles = r_end - r_begin;
double cpe = cycles / (double)SIZE;
printf("CPE: %.2f \n", cpe);
return res;
}
答案 0 :(得分:4)
TL:DR:这可能是一个已知的遗漏优化错误(gcc没有使用有符号整数进行关联数学优化),结合普通的旧编译器实际上并不是人工智能并生成慢速代码。
首先, RDTSC测量挂钟时间,不一定是核心时钟周期。使用perf计数器来测量微基准测试中的核心时钟周期,这样您就不必担心CPU频率缩放。 (除非你的微基准测试涉及L2高速缓存未命中,因为在纳秒级的同一时间是更高频率的更多时钟周期。即高频意味着高速缓存未命中损伤更多,主存储器带宽每个周期的字节数更低。)
gcc 5.3(没有-march=nehalem
或-mtune=haswell
或其他任何东西)将基本版本自动矢量化为通常的标量选择,直到对齐边界,然后是矢量化内循环:
# gcc5.3 -O3 -fverbose-asm (-mtune=generic; only SSE2 because no -march used)
sum_basic:
... scalar prologue
.L14: ### inner loop
add rdx, 1 # ivtmp.39,
paddd xmm0, XMMWORD PTR [r9] # vect_acc_10.34, MEM[base: _156, offset: 0B]
add r9, 16 # ivtmp.40,
cmp r8, rdx # bnd.28, ivtmp.39
ja .L14 #,
... horizontal sum and scalar epilogue
所以,愚蠢的gcc,保留两个单独的循环计数器,而不是只检查r9
到a+n
。或者至少使用dec rdx / jnz
进行循环以避免cmp
。但不,所以循环有4个融合域uop,所有这些都需要一个ALU端口。因此,它可以在Intel Core2及更高版本的每个时钟发出一次迭代,但只在Haswell及其后的每个时钟执行一次迭代(这增加了第4个ALU端口)。
在SnB及更高版本上,展开两个向量ALU会使小数组的吞吐量翻倍,因为PADDD
有一个周期延迟,但每个周期吞吐量有两个(或三个),负载也是如此。在较大的阵列上,你仍然只是内存带宽的瓶颈。
当您手动展开4个累加器时,gcc决定保留这些语义,并在内循环中使用未对齐的加载。不幸的是,gcc 5.3最终做得非常糟糕:
# gcc5.3 -O3 -fverbose-asm (-mtune=generic, same lack of enabling SSE4/AVX/AVX2)
sum_optimized:
zero xmm0 and some other minor setup
.L3:
mov rdx, rax
add rax, 1
sal rdx, 4 # what the hell gcc? just add 16 instead of copying and shifting a separate instructions. Even if it takes two loop counters like in the basic version.
cmp rcx, rax # cmp not next to ja, can't macro-fuse. (-mtune=haswell fixes this)
movdqu xmm1, XMMWORD PTR [rdi+rdx] # separate load, not folded into paddd because it's unaligned.
paddd xmm0, xmm1
ja .L3
...
A hilarious horizontal sum that uses MOVD on each element separately and sums with scalar integer ops. (With -march=nehalem, it uses PEXTRD)
这是Intel Nehalem及更高版本的7个融合域uops。在Core2上,它是9个uops IIRC。 Pre-Nehalem,即使数据在运行时对齐,movdqu也是多个uops并且比movdqa运行得慢。
无论如何,假设Nehalem或更高版本,这可以在每2个周期的一次迭代中发出,这就是瓶颈。执行每2个周期最多可处理6个ALU uop。即使指针没有对齐,也不应该再将它减慢,因为gcc的代码已经很慢了。
我的理论是因为gcc中已知的遗漏优化错误:以不同的顺序添加数字会导致溢出。由于2的补充,一切都会最终实现,但是gcc并不知道如何利用它。 C中的有符号溢出是未定义的行为,但不在x86上。
在Richard Biener对my gcc bug report about gcc not doing associative-math optimizations on signed-int a+b+c+d+e+f+g+h
的回应中,他说:
这是一个长期存在的问题,即reassoc没有关联! TYPE_OVERFLOW_WRAPS chains.It是一个长期存在的问题 没有关联! TYPE_OVERFLOW_WRAPS链。它可以做到这一点 有限的程度(只取消不影响溢出的操作)或 完全如果它在提交时将操作重写为无符号算术 时间。一种检测所需与规范化的方法 需要进行转换以避免将所有已签名的整数操作重写为 没有签名(好吧,也许它实际上并没有那么糟糕,谁知道)。
两个版本使用的水平和算法给这个理论增加了一些权重:sum_basic使用正常的向下移动 - 向上移动 - 向量 - 向量加向量。 sum_optimized分别提取每个向量元素。
使用-march=native
进行编译,特别是如果您的CPU支持AVX2。
正如我之前提到的,多个 vector 累加器可以通过适当的展开量,在Intel SnB系列或AMD K10或更新版本上为每个时钟提供两个负载和两个128或256b向量添加。 (IIRC,AMD K8每个时钟可以执行两次加载,但没有128b宽的执行单元。)
与往常一样,重要的是你运行microbenchmark的硬件,以及数组大小!