【问题标题】:Unrolling For Loop in C在 C 中展开 For 循环
【发布时间】:2020-12-08 06:59:14
【问题描述】:

我正在尝试将这个循环展开 2 倍。

for(i=0; i<100; i++){
  x[i] = y[i] + z[i];
  z[i] = y[i] + a[i];
  z[i+1] = y[i] * a[i];
}

我把它展开到:

 for(i=0; i<100; i+=2){
   x[i] = y[i] + z[i];
   x[i+1] = y[i+1] + z[i+1];
   z[i] = y[i] + a[i];
   z[i+1] = y[i] * a[i];
 }

我不确定如何展开 z[i] 的行,因为原始 for 循环已经有 z[i+1]。谁能帮我理解这个?

【问题讨论】:

  • 不要专注于“微优化”——这是编译器的工作。您唯一需要猜测编译器的时间是程序执行中是否存在实际问题、延迟或“热点”。然后您可以分析您的代码以准确找出问题所在,然后查看进一步的优化。 (这通常根本不是代码优化问题,而是代码逻辑问题——编译器无能为力……)
  • 手动优化此代码的正确方法可能是想出类似typedef struct {int x[100]; int y[100]; int z[100]; int a[100];} xyza; 的东西。现在我们可以根据数组在内存中的存储位置、彼此之间的关系来拆分不同的计算,然后研究缓存的使用和并行化的潜力等。

标签: c optimization loop-unrolling


【解决方案1】:

我想说简单地为 i+1 添加行。但是你必须确保它们的顺序正确,所以:

 for(i=0; i<100; i+=2){
    x[i] = y[i] + z[i];
    z[i] = y[i] + a[i];
    z[i+1] = y[i] * a[i];

    // next iteration
    if (i+1 < 100) {
          x[i+1] = y[i+1] + z[i+1];
          z[i+1] = y[i+1] + a[i+1];
          z[i+2] = y[i+1] * a[i+1]; 
    }
 }

编辑

为了使所有上限(不仅是偶数)都安全,您必须在循环中添加一个 if

正如 Adrian Mole 所说,最好首先检查上限或方便地设置数组的大小

【讨论】:

  • 看起来不错(ish)。一个可能的 问题是z[i+2] 写入时出现越界错误,但如果不知道z 数组是如何声明的,就很难说。 (如果将原始循环声明为[100] 数组,则即使是原始循环也可能变为UB;但是,如果将其声明为[101] 数组,则应将其更改为[102] 以确保安全。)
  • 我只是想解决这个问题,上限必须是偶数!
  • 进行澄清的编辑会很好!但这并不是你的错,因为这个问题没有提到任何关于这个潜在问题的内容,即使在原始循环中也是如此。问题中变量的声明会很好。 ?
  • @AdrianMole 使其安全,通过变量将 100 的替换作为一个选项 :-)
  • 存在的问题是添加if 测试可能会影响循环展开带来的性能优势。最好确保数组足够大以应对“溢出”。
【解决方案2】:

我正在尝试将这个循环展开 2 倍。

您不应该这样做,因为那没有多大帮助。编译器将免费进行展开和优化,如果你让他们的话。你得到的循环不会从展开中受益,编译器足够聪明地确定这一点——所以他们也不做任何展开。所写的循环阻止编译器完成他们的工作。让我们解决它。

首先,我们想要一些可以实际编译的东西。元素是整数、浮点数还是双精度都无关紧要 - 编译器会对所有常见类型都做得很好。

我们将使用带有-O3 选项的 gcc x86-64 10.2 在 Godbolt 上编译它。

让我们开始吧:

typedef int element;

我们必须假设某事。我会合理假设axyz 数组不重叠。这非常重要 - 如果不是这样,必须在问题中说明 (!!)。 restrict 关键字体现了这一事实:

void test1(elt *restrict a, elt *restrict x, elt *restrict y, elt *restrict z) {
    for (int i=0; i<100; i++) {
        x[i] = y[i] + z[i];
        z[i] = y[i] + a[i];
        z[i+1] = y[i] * a[i];
    }
}

如果您向此函数提供一个或多个重叠的数组,您将得到未定义的行为,并且很可能会得到不正确的结果。但是优化必须基于一些假设 - 否则即使您最初计划的手动展开通常也是不可能的。

如果我们只是将编译器选项从 -O3 更改为 -O3 -funroll-loops,我们将很好地展开这段代码,超过 2 倍。我们强制编译器的手,它会强制执行,无论它是否有意义或不是。所以你得到了你想要的,结案,我们回家吧?啊,不,那一点也不好玩 - 这是在卖自己非常短 :)

编译器“只”生成代码,在不强制执行的情况下,按i+=1 执行此循环。

现在观察到 z[i+1] 实际上并不需要。除了最后一个值之外的所有值都被覆盖。它所做的只是将上一次迭代的输出提供给下一次迭代的输入。

我们可以在没有转发存储的情况下重写函数:

void test2(elt *restrict a, elt *restrict x, elt *restrict y, elt *restrict z) {
    elt fwd = z[0];
    for (int i=0; i<100; i++){
        x[i] = y[i] + fwd;
        z[i] = y[i] + a[i];
        fwd = y[i] * a[i];
    }
    z[100] = fwd;
}

编译器已经在每次迭代中少生成一个存储,但仍然只迭代i+=1

转发操作对优化器来说是个坏消息。一些高端 CPU 具有足够聪明的数据依赖跟踪和足够长的管道,可以在一定程度上解决它 - 但我们在这里讨论的不是典型的消费者系统(反正现在还没有)。

为了使每个人的机会均等,每个循环迭代都应该是独立的:它的输出不应馈送到任何其他输入。无需计算fwd 的未来值,我们可以在需要时立即计算:

void test3(elt *restrict a, elt *restrict x, elt *restrict y, elt *restrict z) {
    x[0] = y[0] + z[0];
    z[0] = y[0] + a[0];
    for (int i=1; i<100; i++){
        x[i] = y[i] + y[i-1] * a[i-1];
        z[i] = y[i] + a[i];
    }
    z[100] = y[99] * a[99];
}

这是一个非常好的消息——编译器可以很容易地证明每个循环迭代的输出是独立的,它不仅将循环展开 4 倍,即通过i+=4 进行迭代,而且还 它将循环的内部完全矢量化,因此循环内的指令加在一起的成本大约与非矢量化循环的单次迭代(给予或接受)一样多,但它们的工作量是原来的 4 倍!

请注意,这是通过正确编写 C 代码来实现的。我们没有做任何微优化,只是移除了跨循环迭代的转发——这种转发在当今世界必须被认为是一种悲观化。摆脱它授权编译器读懂我们的想法:)

现在,如果我们让编译器为更现代的架构生成代码,比如skylake,会怎样? gcc -O3 -march=skylake-avx512 完全展开循环。此外,循环每次迭代不到一条指令。您没看错:循环的内部部分产生的机器指令不到 100 条——只有设置/故障代码使它超过 100 条。

在这一点上,可能不值得再做更多了 - 我会说,性能相当令人满意。

但是,如果您真的想手动展开循环(例如,如果您这样做是为了一些学校作业,而不是因为您关心实际表现,因为这不会变得更好) - 那么现在是时候去做了,因为让编译器产生好的代码的形式也使得手动展开变得微不足道:

void test4(elt *restrict a, elt *restrict x, elt *restrict y, elt *restrict z) {
    x[0] = y[0] + z[0];
    z[0] = y[0] + a[0];
    x[1] = y[1] + y[0] * a[0];
    z[1] = y[1] + a[1];
    for (int i=2; i<100; i+=2){
        x[i] = y[i] + y[i-1] * a[i-1];
        z[i] = y[i] + a[i];
        x[i+1] = y[i+1] + y[i] * a[i];
        z[i+1] = y[i+1] + a[i+1];
    }
    z[100] = y[99] * a[99];
}

展开它 x4 一样容易,等等。但是对于任何好的编译器和优化的构建,这个版本不会比未展开的版本好,如果你让编译器使用更好的架构,而不仅仅是“基线” " x86-64,那么任何手动展开都是没有意义的,因为编译器会将其完全展开为少于 110 条 Skylake 机器指令。

但是等待一个 minizel。这是int 数据类型。而且,好吧,事实证明int 有点糟糕。是的,这些天来快速编码并不是什么快速的代码 - 它适用于浮点数。图像和视频解码,以及音频处理——如今它们都需要大量的浮点计算。因此,CPU 设计人员付出了很多努力来提高效率。

对于float,它正好是 100 条指令,完全展开(没有迭代,只有直线代码)。

对于double-O3 -march=skylake-avx512 -funroll-loops 展开为 3 次大迭代,循环体中有 47 条指令。一切都是矢量化的,流水线运行起来又热又重,而您为昂贵的 CPU 支付的所有钱都收回了。最后。

但是我们再次强迫编译器使用。事实证明,展开该循环并不总是至关重要的——这取决于您在哪个 CPU 上运行它,并且代码扩展的好处有些相形见绌。在生产中,您希望以两种不同的方式编译此函数,在启动时对其进行基准测试,然后选择更快的一种。如果没有-funroll-loops,整个test3 函数有32 条指令,循环体迭代24 次。每次迭代在 6 条指令中与 RAM(读取和写入的总和)交换超过 120 字节的数据。平均每条指令需要 20 个字节(这确实是近似值,我没有仔细看)。

从我粗略的测量来看,这个double 变体运行的内存带宽比原来的int 版本高一个数量级(!)。

这无疑是一次有趣的冒险。

顺便说一句 - 因为必须说:现代编译器是工程的奇迹。我没有夸大其词。您真的必须前往https://godbolt.org 并亲自尝试一下!

【讨论】:

  • 感谢您的洞察力!通过回答一个简单的问题学到了很多东西(乍一看:-))
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2021-01-17
  • 2020-07-06
  • 2016-08-03
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多