更复杂的循环可以执行得更快吗?

Can more complex loop be executed faster?

本文关键字:执行 复杂 循环      更新时间:2023-10-16

我在准备用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下的调试设置。编译器没有执行任何优化—我检查了程序集。我也试着移动一些东西——移动函数定义,调用顺序,极限常数。比例大致相同。

可能的解释是什么?

编辑:而不是分析这个特定的场景,我试图得到一些关于这种行为的可能原因的一般性答案。我的猜测是,CPU在分支预测期间执行了某种启发式操作,在我的特定情况下,我的特定CPU在test1中更好地预测了这个分支。这只是我的直觉,所以我在想它是否正确。有人能考虑一下我的猜测吗?

因为您是在调试模式下编译的,所以您的循环可能会在每次迭代中将k存储/重新加载到内存中(循环计数器也是如此)。未优化代码中的微小循环通常是存储转发延迟的瓶颈(在Intel Haswell上约为5个周期),而不是任何类型的吞吐量瓶颈。

我的猜测是CPU在分支预测期间执行某种启发式,并且在我的特定情况下,我的特定CPU在test1中更好地预测该分支。

这没有意义。完美的分支预测可以将分支的成本降低到零,而不是使其为负。

。执行两个ADD指令+一个分支不会比只执行两个ADD指令快,除非还有其他不同的地方。

假设调试模式下的MSVC最终为test1编写的代码比为test2编写的代码少。也许它将k保存在一个增量寄存器中,而不是使用内存目的地进行两个增量?

如果你发布了asm,它可能会告诉你编译器做了什么不同,使第一个版本运行得更快,但它不会有用或有趣。(与分支预测无关;它应该至少有99.999%的预测成功率,所以实际的分支指令几乎是自由的。)

如果你想了解cpu的内部工作原理,以及如何预测汇编指令循环的运行速度,请阅读Agner Fog的microarch pdf。


参见我对另一个关于优化调试模式的问题的回答,为什么它毫无意义,浪费时间,对于学习什么是快的,什么是慢的没有用处。

优化不只是通过一个常数来加速一切,它改变了你将遇到的瓶颈类型。(例如,k将存在于寄存器中,因此k+=1只有ADD指令的延迟,而不是通过内存往返的延迟)。当然,合并成k+=3,并证明i!=0分支只发生一次,并剥离该迭代。

更重要的是,结果是一个编译时常数,因此理想情况下,两个函数将编译成一条指令,并将结果放入返回值寄存器中。不幸的是,gcc6.2、clang3.9和icc17在版本1中都没有做到这一点,所以看起来手动剥离第一次迭代非常有帮助。

但是对于test2,所有的编译器都可以很容易地将其编译为movabs rax, 29999999998/ret


相关:优化编译器如何使用这些函数

如果你想查看编译器的输出,使用函数参数而不是常量。您已经返回了结果,所以这很好。

我把你的函数放在Godbolt编译器资源管理器上(它最近添加了一个比icc13更新的英特尔编译器版本)。

clang-3.9愚蠢地使用cmov作为test1,从- 01到-O3。一根可预测的树枝最好是跳下去,如果它没有完全剥落。(也许一个无符号循环计数器/上界可以帮助编译器弄清楚发生了什么,但我没有尝试。)

gcc为test1使用了一个条件分支,为test2使用了一个函数参数计数器版本。分支看起来很合理,有i!=0病例为CMP/乙脑的未采取方。但实际上它仍然会将循环计数器计数到最大值,并增加k

在test2中,gcc只是运行循环计数器并在循环外将其相乘。

ICC17非常积极地展开test1,使用多个整数寄存器作为累加器,因此可以并行执行多个ADD或INC。IDK,如果它有帮助的话,因为它溢出了它的一个累加器,内存目的地ADD。实际上,我认为它的展开足以使~20上循环每次迭代只被内存目的地ADD(在Intel Haswell上)的瓶颈减慢5到6个周期,而不是整数ALU ADD/INC指令的瓶颈(在Haswell上每个时钟4个)。它在不同的寄存器中执行+=2和+=1,并在末尾组合(我假设,我只是用cmp rdx, 1249999999结束条件查看了循环。有趣的是,它没有像test2那样将循环优化到一个常量,而是积极地优化循环。