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

clo*_*eet -1 c++ performance assembly compilation c++11

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


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

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

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


C ++

我有以下程序,其中定义了一个包装a 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;
}
Run Code Online (Sandbox Code Playgroud)

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

但是,事实并非如此-不管优化级别如何,它都会产生较大和较慢的结果。

(此特定结果不会显着降低速度,但我的实际用例包含更多的算法。)

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

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

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


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

部件

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

如果doubleType是,则它为循环的主体生成的内容是double

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)
Run Code Online (Sandbox Code Playgroud)

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)
Run Code Online (Sandbox Code Playgroud)

Pet*_*des 5

内联的,但没有进行优化,因为您使用-O0(默认)进行了编译。这将生成asm以进行一致的调试,使您可以在停在任何行的断点时修改任何C ++变量。

这意味着编译器会在每个语句之后从寄存器中溢出所有内容,并重新加载下一个语句所需的内容。因此,无论它们是否处于同一函数中,都有更多的语句来表示相同的逻辑=较慢的代码。 为什么clang对于这个简单的浮点数总和(带有-O0)会产生无效的asm?详细解释。

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

用于最终分配的C循环优化帮助说明了为什么进行基准测试或调整-O0完全没有意义。 这两个版本都是可笑的性能垃圾。


如果没有内联,call则会在循环内有一条指令将其调用。

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

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

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

它然后加载并再次复制到另一个栈位置,然后终于arri从堆栈中加载,与一起double的结果operator+,而这将存储arr[i]movq %rsi, (%rax,%rdx,8)。(是的,LLVM使用64位整数mov来复制double那个时间。更早的时间使用SSE2 movsd。)

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


显然,这是大量低效。 启用优化后,gcc和clang可以轻松地内联和优化包装器。

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

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

double tmp = arr[0];   // kept in XMM0

for(...) {
   tmp += arr[i];   // no re-read of mmeory
   arr[i] = tmp;
}
Run Code Online (Sandbox Code Playgroud)

有趣的是,clang不会在循环之前麻烦初始化它的tmpxmm0),因为您不会麻烦去初始化array。奇怪的是,它并没有对UB发出警告。实际上,malloc使用glibc的实现会为您提供OS中的新页面,并且它们都包含零,即0.0。但是clang会给您XMM0中剩下的一切!如果添加((double*)arr)[0] = 1;,则clang将在循环之前加载第一个元素。

不幸的是,编译器不知道如何比Prefix Sum计算更好。请参阅Intel cpu上带有SSESIMD前缀总和的并行前缀(累加)总和,以了解如何将速度提高2倍和/或使其并行化。

我更喜欢Intel语法,但是Godbolt编译器浏览器可以根据您的问题为您提供AT&T语法。

# 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
Run Code Online (Sandbox Code Playgroud)

Clang展开了一点,就像我说过的那样,不必费心初始化它tmp

# 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)
Run Code Online (Sandbox Code Playgroud)

gcc在现代OS X系统上,Apple XCode 实际上是变相的clang / LLVM。

  • @MaxLanghof:谢谢,我很高兴有人喜欢它。我花了比最初打算写的答案更长的时间,基本上是“不要使用-O0”。:P (2认同)