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

时间:2016-09-29 23:17:21

标签: c++ loops cpu-architecture branch-prediction

我准备在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中的这个分支。这只是我的直觉所以我在徘徊是否可能是正确的。在考虑我的猜测的时候有人能想到吗?

1 个答案:

答案 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那样优化循环。