【问题标题】:Nested loop efficiency in cc中的嵌套循环效率
【发布时间】:2016-12-08 11:30:17
【问题描述】:

哪种方法更好,为什么?

案例一:

for(i=0; i< 100; i++)
 for(j=0; j< 10; j++)
  printf("Hello");

案例2:

for(i=0; i<10; i++)
 for(j=0; j< 100; j++)
  printf("Hello");

【问题讨论】:

  • 在这种情况下没有明显的区别。例如,当您访问 2D 数组时,性能差异会出现,内存布局很重要。
  • 可能是第一个,因为它在内部循环中对j (=0) 的分配较少。
  • @M.SCaudhari 不是很好的重复,因为接受的答案谈到了缓存问题的不同场景,这不适用于这里。
  • @RuslanOsmanov 它必须执行 100x10 或 10x100 分配。一样。这些分配的执行时间应该是恒定的。没有明显的理由说明在内部循环中减少分配会提高效率。

标签: c optimization nested-loops


【解决方案1】:

假设我们讨论的是在启用优化的情况下编译代码的情况,因为禁用优化时谈论“效率”或“性能”毫无意义……

这些应该编译成相同的目标代码。所有循环边界都是编译时常量,因此编译器理论上可以确定循环主体中的代码将执行多少次,将所有内容折叠成一个循环,然后发出该代码。如果它想要(它不会,因为这非常浪费空间并且不会显着提高速度),它可以发出 10,000 次对 printf 函数的连续调用。这只是基本的循环展开,现在几乎所有优化编译器都会这样做。

在现实世界中,编译器不会执行魔法(而且它们通常不会被调整为优化哑代码或识别其模式),因此这些 sn-ps 实际上会编译为略有不同的目标代码。


查看 GCC 的输出,它将标准窥视孔优化应用于循环,但不会合并它们。它还以您编写它们的方式执行循环。 You can see Test1Test2 的代码基本相同,除了 Test1 在外循环中运行大约 100 次,在内循环中运行 10 次,而 Test2 则完全相反.这只是将不同的常量移入寄存器的问题。

MSVC 在生成代码时遵循相同的策略。它对循环结构的基本模式优化与 GCC 略有不同,但代码在道德上是等价的。 Test1Test2 之间的唯一区别是外循环是从 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 源代码中设置的引导,所有代码都会生成非常合理的代码。
  • 同样,理论上,它们应该具有相同的性能。实际上,它可能比这稍微复杂一些。但在实践中,这并不重要,因为两者都足够快。我们所说的差异是纳秒级的。你在浪费时间担心这个。

【讨论】:

  • 真正的问题是为什么 gcc 在你的案例中给出了不同的结果,而在我的案例中却是相同的。我所做的只是将循环放在 main() 中,然后使用不同的迭代器范围构建两次。使用gcc -std=c11 -pedantic-errors -Wall -Wextra -O3
【解决方案2】:

一般来说,这两种形式都不是更好或更快。编译器甚至可能将两个版本都优化为只使用一个循环的代码,在这种情况下,两个版本将产生相同的机器代码。

编辑

我用 gcc -O3 编译了两个版本,两个版本都给出了相同的(虽然是神秘的)机器代码 (x86):

0x00402CF0  push   %rsi
0x00402CF1  push   %rbx
0x00402CF2  sub    $0x28,%rsp
0x00402CF6  mov    $0xa,%esi
0x00402CFB  callq  0x4022f0 <__main>
0x00402D00  mov    $0x64,%ebx
0x00402D05  lea    0x12f4(%rip),%rcx        # 0x404000
0x00402D0C  callq  0x402ba8 <printf>
0x00402D11  sub    $0x1,%ebx
0x00402D14  jne    0x402d05 <main+21>
0x00402D16  sub    $0x1,%esi
0x00402D19  jne    0x402d00 <main+16>
0x00402D1B  xor    %eax,%eax
0x00402D1D  add    $0x28,%rsp
0x00402D21  pop    %rbx
0x00402D22  pop    %rsi
0x00402D23  retq

用于基准测试的代码,gcc -std=c11 -pedantic-errors -Wall -Wextra -O3

#include <stdio.h> 

#define I 100  // only change these 2 constants between builds
#define J 10

int main (void)
{
  for(int i=0; i<I; i++)
    for(int j=0; j<J; j++)
      printf("Hello");

  return 0;
} 

只有当你做这样的事情时才会出现效率问题:

// BAD, enforces poor cache memory utilization
for(i=0; i<n; i++)
  for(j=0; j<n; j++)
    array[j][i] = something;

// BAD, enforces poor cache memory utilization
for(j=0; j<n; j++)
  for(i=0; i<n; i++)
    array[i][j] = something;

// GOOD, optimized for data cache
for(i=0; i<n; i++)
  for(j=0; j<n; j++)
    array[i][j] = something;

【讨论】:

  • GCC 的两个函数的输出不同。它几乎相同,但循环的嵌套是相反的,就像在 C 代码中一样。不同的常数被移入循环计数器寄存器。所以看反汇编只是将问题踢到一个抽象层。 :-)
  • @CodyGray 它相同的。我用不同的常量构建了两次程序,然后将反汇编与 Winmerge(文本比较程序)进行了比较。
  • 嗯。这很奇怪。所有版本,所有优化器开关,所有目标架构,我尝试的一切都会生成我描述的代码。我唯一能想到的另一件事是您正在使用 MinGW(因为您提到了 WinMerge?),它的行为与官方 GNU 构建不同。我没有安装 MinGW 来测试它。
  • @CodyGray 或者您正在使用函数。我确实在使用 MinGW64,一个有点旧的版本,4.9.1。
  • 与函数无关。我在 Godbolt 编译器资源管理器上逐字尝试了您的测试代码,但无论选项如何,我都无法永远生成相同的代码。我没有在本地安装 GCC(只有 Clang 和 MSVC),所以我无法在其他任何地方测试它。 (也就是说,GCC 以 notmain 函数进行广泛的优化而闻名。它总是假设main 处于冷路。我认为它不会产生这里有区别,但它可以,因此在分析优化器的行为时通常不是一个好主意。)
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2019-03-29
  • 1970-01-01
  • 1970-01-01
  • 2017-01-26
相关资源
最近更新 更多