我有一个非常奇怪的编译器行为,其中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 ++不知何故喜欢将计算X1
和X2
拉入热主循环,从而降低性能。以下是详细信息:
该函数简单地遍历数组data
,在字典dictPtr
中查找值并将其写入目标内存位置writer
。
data
和dictPtr
在函数开头计算。它有两种口味:一种是lambda,一种是没有。
(请注意,这个函数只是一个更复杂的代码的最小工作示例。所以请不要在这里评论lambda是不必要的。我知道这个事实,并且在原始代码中,它是必要的,不幸的是。)
使用最新的g ++(尝试过8.1和7.2,与你提供的Godbolt链接中可以看到的旧版g ++相同的问题)编译高优化级别(-O3 -std=c++14
)的问题如下:
解决方案2.(noLambda=false
)为循环生成非常糟糕的代码,甚至比“天真”解决方案更糟糕,因为它假设拉出计算X1和X2是个好主意,超级热主循环,进入超级热主循环,使其在我的CPU上慢了约25%。
.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仍然是!:
.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
(一个参数)替换循环中的dictPtr2
(计算X1)来尝试这是循环中的X1,指令将消失:
.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 ++代码而没有花哨的功能。如果我不能依赖我的编译器为这样一个简单的例子生成完美的代码,我将需要在我的代码中检查所有热循环的汇编,以确保一切尽可能快。 这也意味着可能会有大量受此影响的程序。
答案 0 :(得分:8)
您使用无符号32位类型作为数组索引(在第21行)。这会强制编译器在循环的每个步骤中考虑是否可能溢出其可用范围,在这种情况下,它需要返回到数组的开头。您看到的额外代码与此检查有关!至少有三种方法可以避免编译器采用这种过于谨慎的方法:
在循环开始之前,你并没有抱怨代码,但是你遇到了同样的问题。只需创建它并限制int64_t,你就会发现它变得更短,因为编译器不再考虑阵列溢出的可能性。
所以回顾一下:不是X1和X2的计算被移动到循环中导致大小气球,而是使用错误输入的数组索引变量。
答案 1 :(得分:5)
恭喜,您发现了一个gcc错误。主要解决方案是在GCC's bugzilla上使用&#34; miss-optimization&#34;进行报告。关键词。你的MCVE已经是bug的很好的测试用例,因此编写一个不需要太长时间。复制/粘贴代码和一些描述。此Q&amp; A的链接以及http://godbolt.org/上代码的链接也不错。
有时使用微妙的微体系结构原因&#34;额外&#34;说明,例如xor
- 将popcnt
/ lzcnt
或bsf
to avoid a false dependency on Intel CPUs的目的地归零,但情况并非如此。这很糟糕;循环中的movl %ecx, %eax
可能是使用比指针窄的无符号类型的结果,但即使这样也可以更有效地完成;它也是一个错过的优化。
我没有查看GCC的GIMPLE或RTL转储(内部陈述)以了解更多详情。计算值的唯一用途是在循环内部,因此我可以成像编译器的程序逻辑的内部表示可能在转换时丢失循环内部/外部之间的差异。通常情况下,不需要在循环中的东西被提升或沉没在循环之外。
但不幸的是,gcc在循环中留下额外的mov
指令以设置循环外的代码并不罕见。特别是当它可能需要循环外的多个指令才能获得相同的效果。在优化性能而不是代码大小时,这通常是一个不好的权衡。我没有像我想的那样关注Profile-Guided Optimization的asm输出,看看gcc知道哪些循环真的很热并且展开它们的代码。但不幸的是,大多数代码都是在没有PGO的情况下构建的,因此没有-fprofile-use
的代码仍然非常重要。
然而,这个问题的核心不是如何尽可能快地得到这个特定的例子。相反,我对编译器如何在如此简单的代码片段中产生这样的去优化感到非常恼火。 我现在的主要问题是:我对编译器失去了信心,所以我想了解这是怎么发生的,这样我才能重新获得它。
不要相信gcc!这是一个非常复杂的机器,通常会产生良好的效果,但很少产生最佳效果。
这个案例是我看到它的优化程序做出的最明显和最简单的错误选择之一(并且非常令人失望)。通常,错过的优化会更加微妙(并且依赖于微架构细节,如寻址模式选择和uops /执行端口),或者至少不那么明显是微不足道的避免。 (提升一条指令而不改变整个循环的任何寄存器分配。)
但许多循环瓶颈在内存上,而不是uop吞吐量。现代CPU通过编译器(尤其是JIT编译器)生成的浪费指令来设计。这就是为什么像这样的遗漏优化通常不会对宏观尺度产生重大影响,以及为什么它们重要的情况(例如视频编码器或矩阵乘法)通常仍然使用手写asm块。
通过以类似于你想要的asm结构的方式实现你的源代码,通常可以将gcc手工制作成好的asm。 (像这种情况:What is the efficient way to count set bits at a position or lower?,并参见Why is this C++ code faster than my hand-written assembly for testing the Collatz conjecture?,以获得有关帮助编译器与用手写asm击败编译器的更一般的答案。)
但是当你的编译器像这样脑力下降时,你无能为力。好吧,除了寻找变通方法,或者像避免unsigned
整数之类的东西比其他一些答案指出的重要的指针更窄。
有趣的是,最坏的情况(循环中有2条额外的LEA指令,加上使用额外的循环计数器)只发生在if (noLambda)
上。
如果你创建了2个单独的函数版本并删除了if
,那么nolambda
版本会产生一个很好的干净循环(但是错过了聚集的自动矢量化,这在编译时会很有用)与-march=skylake
)
我把你的代码on the Godbolt compiler explorer。 (同样有趣的是,使用-funroll-loops
查看哪些部分在每次展开的循环迭代中重做,哪些部分只在循环内部进行一次。)
# gcc7.2: the nolamba side of the if, with no actual if()
.L3:
movzwl (%rsi,%rax,2), %ecx
movl (%rdx,%rcx,4), %ecx
movl %ecx, (%r9,%rax,4) # indexed store: no port 7
addq $1, %rax # gcc8 -O3 -march=skylake uses inc to save a code byte here.
cmpq %rax, %r8
jne .L3
在Intel Sandybridge系列上,这解码为5 uops。 (cmp / jcc的宏融合将该对转换为1 uop。其他指令都是单uop; movzwl
是纯负载,不需要ALU端口。)
商店在SnB / IvB上取消层压(为4宽发布阶段造成额外的支出,这是主要的前端瓶颈之一),但可以在HSW / SKL上保持融合。它不能使用端口7(因为它被索引),这意味着HSW / SKL每时钟限制为2个存储操作,而不是3个。
瓶颈:
每个时钟4个融合域uop的前端发布带宽。循环是5微秒,并且每1.25可以发出近1次迭代。 (4个非多重循环并不完美,但5 uops is handled well on Haswell/Skylake at least。可能不是Sandybridge。)
加载/存储执行端口:Haswell和更高版本可以每个时钟运行2个加载+ 1个存储,但只有当存储避免索引寻址模式时才能运行存储地址uop可以在端口7上运行。
lambda版本获得第二个循环计数器(指针增量)和一个愚蠢的movl %ecx, %eax
,但LEA指令不在循环中。
但它不是额外的计算本身,它可能会伤害你的循环的总uop吞吐量。如果字典大部分在缓存,Haswell或更高版本的CPU中保持热销
我打算写更多,但我还没完成。现在发布是因为通用的早期/中期部分显然是问题的真正含义。不要盲目地信任gcc。
并且不要期望它会在大多数时间内制作最佳代码。只需调整C源(有时甚至更多),你就可以经常获得10%或20%的收益。有时gcc似乎没有任何线索,比如在展开时没有明显的原因使用额外的lea
,而不是在寻址模式中使用位移。我认为其寻址模式成本模型必须不准确,至少对于-march=haswell
/ -march=skylake
。
答案 2 :(得分:2)
我尝试运行你的代码并且......惊喜:你在循环中执行的指令不是你在发布的编译器资源管理器链接中看到的指令。看看这个(我添加了一个主要功能) https://godbolt.org/g/PPYtQa 你在循环中执行的指令是162-167,即
import keras as keras
import numpy as np
from keras.optimizers import SGD
from sklearn.metrics import roc_auc_score
model = keras.models.Sequential()
# ...
sgd = SGD(lr=0.001, momentum=0.9)
model.compile(optimizer=sgd, loss='categorical_crossentropy', metrics=['accuracy'])
class Metrics(keras.callbacks.Callback):
def on_train_begin(self, logs={}):
self._data = []
def on_epoch_end(self, batch, logs={}):
X_val, y_val = self.validation_data[0], self.validation_data[1]
y_predict = np.asarray(model.predict(X_val))
y_val = np.argmax(y_val, axis=1)
y_predict = np.argmax(y_predict, axis=1)
self._data.append({
'val_rocauc': roc_auc_score(y_val, y_predict),
})
return
def get_data(self):
return self._data
metrics = Metrics()
history = model.fit(X_train, y_train, epochs=100, validation_data=(X_val, y_val), callbacks=[metrics])
metrics.get_data()
您可以通过在机器上进行编译来仔细检查
.L15:
movzwl 25(%rbx,%rdx), %ecx
movl 5(%rbx,%rcx,4), %ecx
movl %ecx, 0(%rbp,%rdx,2)
addq $2, %rdx
cmpq $180, %rdx
jne .L15
并使用gdb运行
g++ test.cpp -std=c++1z -g -O3
编译器生成一个不同的非内联版本的funnyEval(这是你在反汇编输出中看到的版本),即使实际使用的版本是内联的。我不知道(还)为什么这两者是不同的,但我想如果你遇到性能损失,你可以通过确保funnyEval内联来修复它:通过在头文件中定义或通过编译和链接链接时间优化(-flto)。我会试一试看看funnyEval在另一个翻译单元时会发生什么......