为啥这个 C++ 包装类没有被内联?

Posted

技术标签:

【中文标题】为啥这个 C++ 包装类没有被内联?【英文标题】:Why is this C++ wrapper class not being inlined away?为什么这个 C++ 包装类没有被内联? 【发布时间】:2019-06-02 01:16:52 【问题描述】:

EDIT - 我的构建系统出了点问题。我仍在弄清楚究竟是什么,但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;

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

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

(这个特定的结果并没有明显慢,但我的实际用例包括更多的算术。)

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

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

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


编辑 - 我发布了错误的 ASM 版本(-O0 而不是 -O3),因此本部分没有帮助。

组装

我在我的 Mac 上使用 XCode 的 gcc,在 64 位系统上。结果是一样的,除了 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)

【问题讨论】:

您几乎应该从不在 C++ 中使用malloc。它只分配内存,但不构造对象。而且几乎从不使用new[] 来分配数组,而是使用std::vector 和一个转到标签... 您当前有 UB 作为读取未初始化的变量... 使用向量和-O2 标志,使用您的班级compiles to the same code 的double(使用GCC 8.2)。请注意,删除 INLINE 宏或使用“正确的”类型别名并没有改变任何内容。 IIRC,.cpp 文件上的gcc 会将其编译为 C++,但由于您使用了 gcc 前端,它不会链接 C++ 标准库。因此,如果您使用new 而不是malloc,您将收到链接错误。没有充分的理由在 C++ 代码 AFAIK 上使用 gcc,如果您不小心这样做,就会发生这种情况。当然,您可能有一个 gcc,实际上是 Apple clang,但行为可能是相同的。 【参考方案1】:

内联的,但没有优化掉,因为您使用-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&amp; leftright 的寄存器中创建指针。 (非常低效,使用多条指令而不是一条leaaddq %rdx, %rax 是其中的最后一步。)

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

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

然后重新加载并再次复制到另一个堆栈位置,最后从堆栈中加载 arri,以及 operator+ 的结果 double,并将其存储到 arr[i] 和 @ 987654344@。 (是的,当时 LLVM 使用 64 位整数 mov 复制了一个 double。早期使用 SSE2 movsd。)

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


显然,这是大量低效的。 启用优化后,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。实际上,带有 glibc 实现的大 malloc 将为您提供来自操作系统的新页面,并且它们都将保持零,即 0.0。但是 clang 会给你 XMM0 中剩下的任何东西!如果添加((double*)arr)[0] = 1;,clang 将加载循环之前的第一个元素。

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

我更喜欢 Intel 语法,但如果您愿意,the Godbolt compiler explorer 可以为您提供 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)

Apple XCode 的 gcc 在现代 OS X 系统上实际上是伪装的 clang/LLVM。

【讨论】:

阅读您的一个答案总是很愉快。令人惊讶的是,asm 中的几个 cmets 有什么不同! @MaxLanghof:谢谢,我很高兴有人喜欢它。我花了比我最初打算写一个基本上说“不要使用-O0”的答案的时间更长的时间。 :P【参考方案2】:

当您使用 -O3 打开优化时,两个版本都会生成与 g++clang++ 相同的汇编代码。

【讨论】:

谢谢 - 我用的是-O3,但我用的是gcc。我改用g++ 编译,它成功了。 在我运行g++ 之后gcc 开始产生不同的结果这一事实可能意味着我的构建环境有一些有趣的、创造性的错误。感谢您的帮助,尽管结果证明这是一个被否决的愚蠢问题! @cloudfeet:您的问题中的 asm 输出显然来自-O0。商店/重新加载使其非常明显。请参阅我的答案中评论的优化 asm。你也可以在命令行上有一个-O0,或者-O3实际上根本没有被传递给你的编译器。慢 10 倍的行为也与您为两个版本显示的 asm 一致。 @PeterCordes 是的,对不起 - 我发布的是-O0。然而,-O3 代码对于包装版本仍然更慢且更长(但比-O0 更短且更快)。我应该把它贴出来。 (另外,我直接在命令行上传递参数。)【参考方案3】:

供将来参考(我的和其他任何人):我看到了一些不同的东西:

    我最初使用的 XCode 项目(我改编但没有创建)以某种方式进行了配置,因此即使发布版本也没有使用 -O3

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

    最有趣的(对我来说):即使包装器正确内联,包装器破坏了一些优化

第三点是导致我的原始代码(此处未列出)速度变慢的原因。

当您添加一堆浮点值时,例如a + b + c + d,不允许对 cd 重新排序,因为(因为浮点值是近似值)可能会产生略有不同的结果。但是, 允许交换 ab,因为第一次添加是对称的 - 在我的情况下,这让它可以在 64 位构建上使用 SIMD 指令。

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

【讨论】:

gcc -O2 而不是 -O3 的优化失误吗?如果arr[i] = arr[i - 1] + arr[i]; 的编译方式与使用 gcc 或 LLVM 的 arr[i] = arr[i] + arr[i - 1]; 不同,那将是一个比未优化的 asm 更有趣的问题。

以上是关于为啥这个 C++ 包装类没有被内联?的主要内容,如果未能解决你的问题,请参考以下文章

Java:为啥需要包装类?

为啥我的弹性物品没有包装?

将多个 C++ 类包装成一个 Python 类

为啥这个 PySide2 构建找不到生成的 C++ 包装器?

为啥包装在函数中的 GAS 内联汇编为调用者生成的指令与纯汇编函数不同

JavaScript基本包装类