为什么我无法通过整数加法实现最大性能?

时间:2016-09-03 15:48:39

标签: c gcc assembly optimization x86

这是一个对整数向量求和的基本函数。当使用 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;
}

1 个答案:

答案 0 :(得分:4)

TL:DR:这可能是一个已知的遗漏优化错误(gcc没有使用有符号整数进行关联数学优化),结合普通的旧编译器实际上并不是人工智能并生成慢速代码。

首先, RDTSC测量挂钟时间,不一定是核心时钟周期。使用perf计数器来测量微基准测试中的核心时钟周期,这样您就不必担心CPU频率缩放。 (除非你的微基准测试涉及L2高速缓存未命中,因为在纳秒级的同一时间是更高频率的更多时钟周期。即高频意味着高速缓存未命中损伤更多,主存储器带宽每个周期的字节数更低。)

gcc 5.3(没有-march=nehalem-mtune=haswell或其他任何东西)将基本版本自动矢量化为通常的标量选择,直到对齐边界,然后是矢量化内循环:

来自Godbolt compiler explorer

 # 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,保留两个单独的循环计数器,而不是只检查r9a+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的硬件,以及数组大小!