我准备在C ++中实现类似python的",".join(vector)
函数时发现了这一点。我想比较是否有必要消除用于避免在字符串开头添加额外if first element
的内部','
条件。我写了以下函数:
long long test1() // 38332ms
{
long long k = 0;
for (long long i = 0; i < 10000000000; ++i)
{
if (i != 0)
{
k += 2;
}
k += 1;
}
return k;
}
long long test2() // 45272ms
{
long long k = 0;
k += 1;
for (long long i = 1; i < 10000000000; ++i)
{
k += 2; // in the real code it might be impossible to merge
k += 1; // those operations
}
return k;
}
我使用简化代码使条件跳转更有意义。我期待分支预测可以最小化差异。令我惊讶的是,第一个功能表现更好。我正在测试VS2015下的Debug设置。编译器没有执行任何优化 - 我检查了程序集。我也试图移动一些东西 - 移动函数定义,调用顺序,限制常量。比例大致相同。
可能的解释是什么?
编辑: 我没有分析这个具体的情况,而是试图得到一些关于这种行为可能原因的通用答案。我的猜测是CPU在分支预测期间执行某种启发式方法,并且在我的特定情况下,我的特定CPU更好地预测test1中的这个分支。这只是我的直觉所以我在徘徊是否可能是正确的。在考虑我的猜测的时候有人能想到吗?
答案 0 :(得分:2)
由于您是在调试模式下编译的,因此您的循环可能会在每次迭代时将k
存储/重新加载到内存中(对于循环计数器也是如此)。未优化代码中的微小循环通常是存储转发延迟的瓶颈(在Intel Haswell上约为5个周期),而不是任何吞吐量瓶颈。
我的猜测是CPU在分支预测期间执行某种启发式方法,在我的特定情况下,我的特定CPU更好地预测test1中的这个分支。
这没有意义。完美的分支预测可以将分支的成本降低到零,而不是使其为负。
即。做两条ADD指令+一个分支不会比只做两个ADD insn更快,除非还有其他不同的东西。
据推测,调试模式下的MSVC最终会为test1制作比test2更差的代码。也许它会将k
保留在一个增量的寄存器中,而不是同时使用内存目标进行增量?
如果您发布了asm,就可以告诉您编译器做了哪些不同的操作,使第一个版本运行得更快,但它不会有用或有趣。 (并且与分支预测无关;它应该预测成功率至少为99.999%,因此实际的分支指令几乎是免费的。)
Read Agner Fog's microarch pdf如果你想了解CPU如何在内部工作,以及如何预测汇编指令循环的运行速度。
请参阅my answer to another question about optimizing for debug-mode,了解其无意义和浪费时间的原因,并且无法了解什么是快速的,什么是慢的。
优化并不仅仅是通过一个恒定的因素加快一切,它会改变你将遇到的瓶颈。 (例如k
将存在于寄存器中,因此k+=1
仅具有ADD指令的延迟,而不是通过存储器的往返)。当然要合并到k+=3
,并证明i!=0
分支只发生一次,并剥离该迭代。
更重要的是,结果是编译时常量,因此理想情况下,两个函数都将编译为将结果放入返回值寄存器的单个指令。不幸的是,gcc6.2,clang3.9和icc17都没有为版本1做到这一点,所以看起来手动剥离第一次迭代非常有帮助。
但对于test2,所有编译器都可以轻松地将其编译为movabs rax, 29999999998
/ ret
。
相关:优化编译器对这些功能的作用
如果要查看编译器输出,请使用函数args而不是常量。你确实已经返回了结果,所以这很好。
我把你的功能放在the Godbolt Compiler explorer上(最近添加了比icc13更新的英特尔编译器版本。)
clang-3.9愚蠢地将cmov
用于test1,从-O1到-O3。如果它没有完全去皮,那么可以通过跳跃更好地完成预测的分支。 (也许一个无符号循环计数器/上限会帮助编译器弄清楚会发生什么,但我没有尝试过。)
gcc对test1使用条件分支,对test2使用函数arg计数器版本。分支看起来合理,i!= 0的情况是CMP / JE的未通过的一侧。但它仍然实际上将循环计数器计算到最大值,并且递增k
。
在test2中,gcc只运行循环计数器并将其乘以循环。
ICC17非常积极地展开test1,多个整数寄存器作为累加器,因此可以并行发生多个ADD或INC。 IDK如果有帮助,因为它溢出了一个累加器,带有内存目的地ADD。实际上我认为它的展开足以使~20 uop循环在每次迭代中仅通过内存目标ADD(在Intel Haswell上)的瓶颈从5到6个周期减慢,而不是整数ALU ADD / INC指令的瓶颈( Haswell每个时钟4个)。它在不同的寄存器中执行+ = 2和+ = 1,并在最后进行组合(我假设,我只是查看了具有cmp rdx, 1249999999
结束条件的循环。它有趣地设置了它的积极性优化循环,而不是像测试2那样优化循环。