【问题标题】:Can more complex loop be executed faster?可以更快地执行更复杂的循环吗?
【发布时间】:2017-02-08 10:29:42
【问题描述】:

我在准备用 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 中预测此分支。这只是我的直觉,所以我在徘徊它是否正确。考虑到我的猜测,有人可以考虑一下吗?

【问题讨论】:

  • 假设llonglong long int 安全吗?
  • 你的计时码是什么样的?
  • 您对此进行了多少次实验?有很多因素会影响从运行到运行的完成时间
  • 我在基于 Haswell 的服务器上使用 gcc 4.8.5 在 Linux 上看到了相同的效果。对函数重新排序、更改常量等非常强大。查看汇编输出,唯一的区别是处理 test 中的特殊情况的 3 条额外指令。最好的猜测是,这是一个深奥的架构特定的指令对齐和/或指令缓存问题。有趣的是,gcc 在使用-O3 编译时使test2 免费,但它不会优化掉test
  • 十次运行。测试 2 始终延长约 6/10 秒。 GCC 4.8.1。没有优化。 AMD A10 处理器

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


【解决方案1】:

由于您在调试模式下编译,您的循环可能会在每次迭代时将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 分支只发生一次,然后剥离该迭代。

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

但是对于 test2,所有编译器都可以轻松地将其编译为 movabs rax, 29999999998 / ret


相关:优化编译器对这些函数的作用

如果您想查看编译器输出,请使用函数 args 而不是常量。您确实已经返回了结果,这很好。

我将你的函数放在the Godbolt Compiler explorer 上(它最近添加了比 icc13 更新的英特尔编译器版本)。

clang-3.9 愚蠢地将 cmov 用于 test1,从 -O1 到 -O3。一个可预测的分支最好用跳跃来完成,如果它没有完全剥离的话。 (也许无符号循环计数器/上限会帮助编译器弄清楚发生了什么,但我没有尝试。)

gcc 使用 test1 的条件分支和 test2 的 function-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 结束条件的循环。有趣的是,它设法优化循环而不只是优化就像对 test2 所做的那样,将其降至一个常数。

【讨论】:

  • 我没有尝试研究编译器优化,而是研究特定程序集的运行时优化。我知道它可能会因特定的编译器和架构而异,所以我宁愿粘贴在我的特定编译器上生成的程序集,但我希望它更清楚这段代码的作用。
  • @pkubik:我帖子的后半部分,关于优化编译器如何处理您的代码,并不是您所问问题的真正答案的一部分。这是对您应该问或一直在做的事情的答案。 (即测试优化代码的性能,以函数 arg 作为上限)。如果不清楚,请阅读我链接的答案,了解如何在调试模式下进行试验并找出使您的代码更快的原因是没有用的。
  • @pkubik:我没有运行循环,但我很确定 gcc 的运行时变量版本 test2 在 Intel Haswell 上会比 test1 运行得更快。所以你的实验把你引向了好的方向。
  • @pkubik:更新了我的答案以修复错误,并重写了第一部分以更直接地解决您的编辑问题。在我的第一个版本中,我认为分支在第一次迭代后跳过了增量,如果它被天真/直接翻译成 asm,这将解释一个很大的加速。但反过来说,任何分支都不能与加速直接相关。
  • 有两个分支 - for 条件和显式 if 语句。我不知道 CPU 是否真的在执行如此复杂的启发式算法,但如果它考虑到分支之前的指令,那么我可以想象一个算法可以在给定来自 test1 的更复杂指令的情况下预测“更好”。我引用“更好”是因为只有在这种特定情况下它可能真的更好。
猜你喜欢
  • 1970-01-01
  • 2020-03-06
  • 1970-01-01
  • 2022-01-04
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多