首页 > 解决方案 > 为什么这个 C++ 包装类没有被内联?

问题描述

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


对于我一直遇到的问题,这是一个非常简化的测试用例,其中使用数字包装类(我认为它会被内联)使我的程序慢了 10 倍。

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

我在包装类中遗漏了一些细节吗?


C++

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

#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 上使用 XCode 的 gcc,在 64 位系统上。结果是一样的,除了 for 循环的主体。

这是它为循环体生成的 if doubleTypeis 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)

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)

标签: c++performancec++11assemblycompilation

解决方案


内联的,但没有优化掉,因为您使用-O0(默认)编译。这会生成用于一致调试的 asm,允许您在任何行的断点处停止时修改任何 C++ 变量。

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

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

最终分配的 C 循环优化帮助解释了为什么基准测试或调整-O0是完全没有意义的。 这两个版本对于性能来说都是荒谬的垃圾。


如果它没有被内联,就会有一条call指令在循环内调用它。

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

然后它将这些指针参数溢出到堆栈内存,因为它们是真正的变量并且必须在调试器可以修改它们的内存中。这就是movq %rax, -16(%rbp)%rdx...正在做的事情。

在重新加载和取消引用这些指针之后,addsd(add scalar double)结果本身会溢出到堆栈内存中的本地,带有movsd %xmm0, -8(%rbp). 这不是命名变量,而是函数的返回值。

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

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


显然,这是非常低效的。 启用优化后,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;
}

有趣的是,clang 不会费心在循环之前初始化它的tmp( xmm0),因为你不用费心初始化数组。奇怪的是它没有警告UB。在实践malloc中,glibc 的实现将为您提供来自操作系统的新页面,并且它们都将保持零,即0.0. 但是 clang 会给你 XMM0 中剩下的任何东西!如果添加 a ((double*)arr)[0] = 1;,clang 将在循环之前加载第一个元素。

不幸的是,编译器不知道如何为您的前缀和计算做得更好。请参阅Intel cpu 上的 SSE和SIMD 前缀和的并行前缀(累积)和,以了解将其加速另一个可能为 2 的因素和/或并行化它的方法。

我更喜欢英特尔语法,但如果您愿意, 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

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)

在现代 OS X 系统上, Apple XCodegcc真的是变相的 clang/LLVM。


推荐阅读