为什么没有内联这个C ++包装器类?

时间:2019-01-07 11:13:53

标签: c++ performance c++11 assembly compilation

编辑-我的构建系统有问题。我仍在弄清楚到底是什么,但是gcc产生了奇怪的结果(即使它是.cpp文件),但是一旦我使用了g++,它就会按预期工作。


对于我一直遇到的问题,这是一个非常减少的测试用例,其中使用数字包装器类(我认为应该内联)使我的程序慢10倍。

这与优化级别无关(已尝试-O0-O3)。

我在包装器类中缺少一些细节吗?


C ++

我有以下程序,其中定义了一个包装double并提供+运算符的类:

#include <cstdio>
#include <cstdlib>

#define INLINE __attribute__((always_inline)) inline

struct alignas(8) WrappedDouble {
    double value;

    INLINE friend const WrappedDouble operator+(const WrappedDouble& left, const WrappedDouble& right) {
        return {left.value + right.value};
    };
};

#define doubleType WrappedDouble // either "double" or "WrappedDouble"

int main() {
    int N = 100000000;
    doubleType* arr = (doubleType*)malloc(sizeof(doubleType)*N);
    for (int i = 1; i < N; i++) {
        arr[i] = arr[i - 1] + arr[i];
    }

    free(arr);
    printf("done\n");

    return 0;
}

我认为这可以编译为相同的东西-它进行​​相同的计算,并且所有内容都内联。

但是,并非如此-无论优化级别如何,它都会产生更大,更慢的结果。

(此特定结果并没有显着降低,但我的实际用例包含了更多的算法。)

编辑-我知道这不是在构造数组元素。我以为这样可能会减少ASM的产生,所以我可以更好地理解它,但是如果有问题,我可以更改它。

编辑-我也知道我应该使用new[] / delete[]。不幸的是,gcc拒绝编译该文件,即使该文件位于.cpp文件中也是如此。这是我的构建系统被搞砸的症状,这可能是我的实际问题。

编辑-如果我使用g++而不是gcc,则会产生相同的输出。


编辑-我发布了错误的ASM版本(-O0而不是-O3),因此本节无济于事。

组装

我在Mac上的64位系统上使用XCode的gcc。除了for循环的主体之外,结果是相同的。

如果doubleTypedouble,则这是循环体的结果:

movq    -16(%rbp), %rax
movl    -20(%rbp), %ecx
subl    $1, %ecx
movslq  %ecx, %rdx
movsd   (%rax,%rdx,8), %xmm0    ## xmm0 = mem[0],zero
movq    -16(%rbp), %rax
movslq  -20(%rbp), %rdx
addsd   (%rax,%rdx,8), %xmm0
movq    -16(%rbp), %rax
movslq  -20(%rbp), %rdx
movsd   %xmm0, (%rax,%rdx,8)

WrappedDouble版本更长:

movq    -40(%rbp), %rax
movl    -44(%rbp), %ecx
subl    $1, %ecx
movslq  %ecx, %rdx
shlq    $3, %rdx
addq    %rdx, %rax
movq    -40(%rbp), %rdx
movslq  -44(%rbp), %rsi
shlq    $3, %rsi
addq    %rsi, %rdx
movq    %rax, -16(%rbp)
movq    %rdx, -24(%rbp)
movq    -16(%rbp), %rax
movsd   (%rax), %xmm0           ## xmm0 = mem[0],zero
movq    -24(%rbp), %rax
addsd   (%rax), %xmm0
movsd   %xmm0, -8(%rbp)
movsd   -8(%rbp), %xmm0         ## xmm0 = mem[0],zero
movsd   %xmm0, -56(%rbp)
movq    -40(%rbp), %rax
movslq  -44(%rbp), %rdx
movq    -56(%rbp), %rsi
movq    %rsi, (%rax,%rdx,8)

3 个答案:

答案 0 :(得分:4)

内联了 ,但是由于您使用-O0(默认设置)进行了编译,因此并未对其进行优化。这样会生成asm以进行一致的调试,从而允许您修改任何C ++变量,同时在任意行的断点处停止。

这意味着编译器在每个语句之后从寄存器中溢出所有内容,并重新加载下一个语句所需的内容。因此,无论它们是否处于相同的函数中,都有更多的语句来表示相同的逻辑=较慢的代码。  Why does clang produce inefficient asm for this simple floating point sum (with -O0)?进行了更详细的说明。

通常-O0不会内联函数,但确实会尊重__attribute__((always_inline))

C loop optimization help for final assignment解释了为什么使用-O0进行基准测试或调优是完全没有意义的。这两个版本对于性能都是荒谬的垃圾。


如果未内联,则会在循环内有一条call指令对其进行调用。

asm实际上是在寄存器const WrappedDouble& leftright中创建指针。 (效率很低,使用多个指令而不是一个leaaddq %rdx, %rax是其中之一的最后一步。)

然后,它将那些指针args溢出到堆栈内存中,因为它们是实变量,并且必须位于调试器可以修改它们的内存中。 movq %rax, -16(%rbp)%rdx就是这样做的。

在重新加载并取消引用这些指针之后,addsd(加标量双精度)结果本身会通过movsd %xmm0, -8(%rbp)溢出回到堆栈内存中的本地。这不是命名变量,而是函数的返回值。

然后将其重新加载并再次复制到另一个堆栈位置,然后最终从堆栈中加载arri以及double的{​​{1}}结果,以及与operator+一起存储到arr[i]中。 (是的,LLVM当时使用64位整数movq %rsi, (%rax,%rdx,8)来复制mov。较早的时间使用SSE2 double。)

所有这些返回值的副本都位于循环依赖链的关键路径上,因为下一次迭代读取movsd那些大约5或6个周期的存储,转发延迟确实增加了3或4周期FP arr[i-1]的延迟。


显然,这是大量的低效措施。 启用优化功能后,gcc和clang可以轻松地内联和优化包装程序。

它们还通过将add结果保留在寄存器中作为下一次迭代的arr[i]结果来进行优化。这样可以避免〜6周期的存储转发延迟,如果它使asm像源一样,则可能在循环内部。

即优化的asm看起来像这样的C ++:

arr[i-1]

有趣的是,clang不会在循环之前麻烦初始化其double tmp = arr[0]; // kept in XMM0 for(...) { tmp += arr[i]; // no re-read of mmeory arr[i] = tmp; } tmp),因为您不会麻烦初始化数组。奇怪的是,它并没有对UB发出警告。实际上,使用glibc实现的大型xmm0将为您提供操作系统中的新页面,并且它们都将为零,即malloc。但是clang会给您XMM0中剩下的一切!如果添加0.0,则clang会在循环之前加载第一个元素。

很遗憾,编译器不知道如何比前缀总和计算更好。请参见parallel prefix (cumulative) sum with SSESIMD prefix sum on Intel cpu ,以了解将速度提高2倍和/或使其并行化的方法。

我更喜欢Intel语法,但是the Godbolt compiler explorer可以根据您的问题为您提供AT&T语法。

((double*)arr)[0] = 1;

Clang展开了一点,就像我说的那样,不必费心初始化它的# gcc8.2 -O3 -march=haswell -Wall .LC1: .string "done" main: sub rsp, 8 mov edi, 800000000 call malloc # return value in RAX vmovsd xmm0, QWORD PTR [rax] # load first elmeent lea rdx, [rax+8] # p = &arr[1] lea rcx, [rax+800000000] # endp = arr + len .L2: # do { vaddsd xmm0, xmm0, QWORD PTR [rdx] # tmp += *p add rdx, 8 # p++ vmovsd QWORD PTR [rdx-8], xmm0 # p[-1] = tmp cmp rdx, rcx jne .L2 # }while(p != endp); mov rdi, rax call free mov edi, OFFSET FLAT:.LC0 call puts xor eax, eax add rsp, 8 ret

tmp

在现代OS X系统上,Apple XCode的# just the inner loop from clang -O3 # with -march=haswell it unrolls a lot more, so I left that out. # hence the 2-operand SSE2 addsd instead of 3-operand AVX vaddsd .LBB0_1: # do { addsd xmm0, qword ptr [rax + 8*rcx - 16] movsd qword ptr [rax + 8*rcx - 16], xmm0 addsd xmm0, qword ptr [rax + 8*rcx - 8] movsd qword ptr [rax + 8*rcx - 8], xmm0 addsd xmm0, qword ptr [rax + 8*rcx] movsd qword ptr [rax + 8*rcx], xmm0 add rcx, 3 # i += 3 cmp rcx, 100000002 jne .LBB0_1 } while(i!=100000002) 实际上是变相的clang / LLVM。

答案 1 :(得分:2)

当您使用g++启用优化时,这两个版本在clang++-O3处产生相同的汇编代码。

答案 2 :(得分:1)

供以后参考(我和其他人):我看到了一些不同的东西:

  1. 我最初使用的XCode项目(我改编但未创建)已经过某种配置,因此,即使Release版本也未使用-O3

  2. 对C ++代码使用gcc是一个坏主意。即使编译.cpp文件,默认情况下它也不会链接到标准库。使用g++更加顺畅。

  3. (对我而言)最有趣的是:即使包装器正确内联,包装器也会破坏一些优化

第三点是导致我的原始代码(此处未列出)变慢的原因,这导致我走了这条路。

在添加一堆浮点值时,例如a + b + c + d,不允许对cd进行重新排序,因为(由于浮点值是近似的)可能产生微妙的结果。但是,允许 交换ab,因为第一个加法是对称的-在我的情况下,这使它可以在64位构建中使用SIMD指令。

但是,当使用包装器时,它并没有传递第一个+实际上是可交换的信息!它尽职尽责地将所有内容都内联了,但是不知何故,它仍然被允许交换前两个参数。当我以适当的方式手动重新排列总和时,我的两个版本的性能相同。