c++ - 为什么 g++ 将计算拉入热循环?

标签 c++ gcc assembly optimization compiler-optimization

我有一个非常奇怪的编译器行为,其中 G++ 将计算拉入热循环,严重降低了生成代码的性能。这是怎么回事?

考虑这个函数:

#include <cstdint>

constexpr bool noLambda = true;

void funnyEval(const uint8_t* columnData, uint64_t dataOffset, uint64_t dictOffset, int32_t iter, int32_t limit, int32_t* writer,const int32_t* dictPtr2){
   // Computation X1
   const int32_t* dictPtr = reinterpret_cast<const int32_t*>(columnData + dictOffset);
   // Computation X2
   const uint16_t* data = (const uint16_t*)(columnData + dataOffset);
   // 1. The less broken solution without lambda
   if (noLambda) {
        for (;iter != limit;++iter){
            int32_t t=dictPtr[data[iter]];
            *writer = t;
            writer++;
        }
   }
   // 2. The totally broken solution with lambda
   else {
        auto loop = [=](auto body) mutable { for (;iter != limit;++iter){ body(iter); } };
        loop([=](unsigned index) mutable {
            int32_t t=dictPtr[data[index]];
            *writer = t;
            writer++;
        });
   }
}

这里的问题是 G++ 不知何故喜欢将计算 X1X2 拉入热主循环,从而降低了性能。以下是详细信息:

该函数简单地遍历数组data,在字典dictPtr中查找一个值并将其写入目标内存位置writer . datadictPtr 在函数开始时计算。这样做有两种风格:一种带有 lambda,一种没有。

(请注意,此函数只是更复杂代码的最小工作示例。因此,请不要评论此处不需要 lambda。我知道这一事实,不幸的是,在原始代码中它是必要的。)

使用高优化级别的最新 g++(尝试 8.1 和 7.2,与旧 g++s 相同的问题,您可以在提供的 godbolt 链接中看到)编译时的问题(-O3 -std=c+ +14) 如下:

解决方案 2. (noLambda=false) 为循环生成非常糟糕的代码,甚至比“幼稚”的解决方案更糟糕,因为它假定拉取计算 X1 和 X2 是个好主意,在超热主循环之外,进入超热主循环,使其在我的 CPU 上慢约 25%。

https://godbolt.org/g/MzbxPN

.L3:
        movl    %ecx, %eax          # unnecessary extra work
        addl    $1, %ecx
        addq    $4, %r9             # separate loop counter (pointer increment)
        leaq    (%rdi,%rax,2), %rax # array indexing with an LEA
        movzwl  (%rax,%rsi), %eax   # rax+rsi is Computation X2, pulled into the loop!
        leaq    (%rdi,%rax,4), %rax # rax+rdx is Computation X1, pulled into the loop!
        movl    (%rax,%rdx), %eax   
        movl    %eax, -4(%r9)
        cmpl    %ecx, %r8d
        jne     .L3

当使用通常的 for 循环 (noLambda=true) 时,代码会更好,因为 X2 不再被拉入循环,但 X1 仍然是!:

https://godbolt.org/g/eVG75m

.L3:
        movzwl  (%rsi,%rax,2), %ecx
        leaq    (%rdi,%rcx,4), %rcx
        movl    (%rcx,%rdx), %ecx # This is Computation X1, pulled into the loop!
        movl    %ecx, (%r9,%rax,4)
        addq    $1, %rax
        cmpq    %rax, %r8
        jne     .L3

您可以通过将循环中的 dictPtr(计算 X1)替换为 dictPtr2(参数)来尝试这确实是循环中的 X1,指令将消失:

https://godbolt.org/g/nZ7TjJ

.L3:
        movzwl  (%rdi,%rax,2), %ecx
        movl    (%r10,%rcx,4), %ecx
        movl    %ecx, (%r9,%rax,4)
        addq    $1, %rax
        cmpq    %rax, %rdx
        jne     .L3

这终于是我想要的循环了。一个简单的循环,可以加载值并存储结果,而不会将随机计算拉入其中。

那么,这里发生了什么? 将计算放入热循环很少是一个好主意,但 G++ 似乎在这里这么认为。这让我失去了真正的表现。 lambda 加剧了整个情况。它导致 G++ 将更多的计算拉入循环。

使这个问题如此严重的原因在于,这是非常琐碎的 C++ 代码,没有花哨的功能。如果我不能依靠我的编译器为这样一个微不足道的示例生成完美的代码,我将需要检查我的代码中所有热循环的汇编,以确保一切都尽可能快。 这也意味着可能有大量程序受此影响。

最佳答案

您正在为数组索引使用无符号 32 位类型(第 21 行)。这迫使编译器在循环的每一步都考虑您是否可能超出了其可用范围,在这种情况下,它需要返回到数组的开头。您看到的额外代码与此检查有关!至少有三种方法可以避免编译器这种过于谨慎的做法:

  1. 在第 21 行对索引使用 64 位类型。现在编译器知道您永远不会环绕数组,并生成与没有 lambda 时相同的代码。
  2. 在第 21 行对索引使用有符号的 32 位类型。现在编译器不再关心溢出:有符号溢出被视为 UB,因此被忽略。我认为这是对标准的解释中的一个错误,但对此意见不一。
  3. 通过添加一行 'int32_t iter = 0;' 让编译器清楚地知道永远不会发生溢出在函数的开头,并从声明中删除 iter。显然这并不能解决您的问题,但它说明了溢出分析是如何导致生成额外代码的。

在循环开始之前,您并没有提示代码,但在这里您遇到了同样的问题。只需制作 iter 并限制 int64_t,您会发现它变得相当短,因为编译器不再考虑数组溢出的可能性。

所以回顾一下:不是 X1 和 X2 的计算被移动到导致大小膨胀的循环中,而是使用了错误类型的数组索引变量。

关于c++ - 为什么 g++ 将计算拉入热循环?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/50570832/

相关文章:

c++ - char * (*arr)[2 ] 和 char **array[2] 有何不同?

c++ - c++中菜单和子菜单的优化

c++ - 关于 __attribute__ 和 noinline (GCC) 的问题

assembly - 以下汇编命令会发生什么?

assembly - 如何正确执行 ADD/SUB 有符号或无符号整数?

c++ - 使用 boost::proto 构建 s-expression

c++ - 在没有对象的情况下更改 class::variable 的默认值

c++ - MSVC 2008 16 字节结构成员对齐异常

c - 如何从文件描述符获取文件名和路径?

delphi - 从汇编例程访问 Delphi 记录、类等之后的第一个字节