假设我们讨论的是在启用优化的情况下编译代码的情况,因为禁用优化时谈论“效率”或“性能”毫无意义……
这些应该编译成相同的目标代码。所有循环边界都是编译时常量,因此编译器理论上可以确定循环主体中的代码将执行多少次,将所有内容折叠成一个循环,然后发出该代码。如果它想要(它不会,因为这非常浪费空间并且不会显着提高速度),它可以发出 10,000 次对 printf 函数的连续调用。这只是基本的循环展开,现在几乎所有优化编译器都会这样做。
在现实世界中,编译器不会执行魔法(而且它们通常不会被调整为优化哑代码或识别其模式),因此这些 sn-ps 实际上会编译为略有不同的目标代码。
查看 GCC 的输出,它将标准窥视孔优化应用于循环,但不会合并它们。它还以您编写它们的方式执行循环。 You can see Test1 和 Test2 的代码基本相同,除了 Test1 在外循环中运行大约 100 次,在内循环中运行 10 次,而 Test2 则完全相反.这只是将不同的常量移入寄存器的问题。
MSVC 在生成代码时遵循相同的策略。它对循环结构的基本模式优化与 GCC 略有不同,但代码在道德上是等价的。 Test1 和 Test2 之间的唯一区别是外循环是从 0 旋转到 100,还是从 0 旋转到 10。
性能怎么样?嗯,回答这个问题的唯一正确方法是编译两个样本并进行检查。事实上,这是您对性能问题得出客观答案的唯一方法。但是,如果您尝试这样做,您将立即遇到问题:循环中的 printf 函数将大量支配其他任何东西所花费的时间,导致您的基准测试结果嘈杂并且无意义的。您需要找出在循环内部要做的其他事情,这些事情不会对您尝试测量的时间产生显着影响,并且必须是具有副作用的事情,以防止编译器微不足道地优化它。这就是为什么此类微基准测试非常难以正确执行的原因。它们也不是特别有趣。您应该进行基准测试的是真实代码。这不是真正的代码。所以我什至不会费心尝试从中获得有意义的基准数据。
我唯一允许自己做的就是稍微了解一下为这两个函数生成的代码在概念上的性能影响。我猜将较大的循环设为内循环(即,Test2)会稍微快一些。为什么?好吧,因为一旦代码被加载到指令缓存中,它会以快速的顺序执行 100 次,分支预测器几乎在所有情况下都成功地预测了分支的目标。这与紧密循环一样有效。在另一种情况下,您只能在这些最佳条件下进行 10 次迭代,然后才重新开始,这可能会导致指令缓存中的代码被逐出。您必须测试和/或真正研究代码的细节,看看这是否真的可行,因为这取决于代码的确切大小以及您的处理器有多少缓存可用,但这是一个理论上的问题。
切换齿轮,let's see what Clang generates。有趣的!两个测试函数的代码看起来非常不同。使用Test1,Clang 完全展开了内部循环,并对printf 函数发出了10 次背靠背调用。然后将其包裹在一个旋转 100 次的循环中。同样,它与您最初编写的 C 代码是一致的,但是由于内部循环的迭代次数很少,Clang 的优化器确定展开它可能会提高性能。这可能是对的。 Test2 发生了什么?好吧,有点像 - 它只是以不同的方式展开它,因为你以不同的方式编写了原始代码。它展开了 outer 循环,给出了 10 个从 0 循环到 100 的背靠背代码序列。
继续我们打破性能分析基本规则的主题,我们将跳过对输出进行基准测试,只从概念上考虑它。跳出来的第一件事是Test2 需要很多 更多的代码——编码这些指令需要两倍多的字节(321 对 141 字节)。当然,较小的代码并不总是更快,但是在这里,如果没有明显的赢家,我倾向于在较小的代码方向上犯错。唯一可能影响该分析的是Test1 中展开循环体内的代码量是否太多而无法放入缓存中。 Test2 中的循环体要小得多,尽管 整体 代码更大,因此它们几乎可以保证在缓存中是热的。将代码放在缓存中方式会更好地提高性能。嗯,我想毕竟没有基准测试我们将无法判断。
总结:
- 通过基准测试回答性能问题。
- 始终对真实代码进行基准测试,而不是任意测试用例(因为生成给出有意义结果的正确用例极其困难)。
- 理论上,完美的优化编译器应该将这些 sn-ps 转换为相同的代码。
在实践中,这可能不是真的。根据您编写代码的方式,不同的编译器会发出略有不同的代码。但是,按照您在原始 C 源代码中设置的引导,所有代码都会生成非常合理的代码。
- 同样,理论上,它们应该具有相同的性能。实际上,它可能比这稍微复杂一些。但在实践中,这并不重要,因为两者都足够快。我们所说的差异是纳秒级的。你在浪费时间担心这个。