我正在尝试将这个循环展开 2 倍。
您不应该这样做,因为那没有多大帮助。编译器将免费进行展开和优化,如果你让他们的话。你得到的循环不会从展开中受益,编译器足够聪明地确定这一点——所以他们也不做任何展开。所写的循环阻止编译器完成他们的工作。让我们解决它。
首先,我们想要一些可以实际编译的东西。元素是整数、浮点数还是双精度都无关紧要 - 编译器会对所有常见类型都做得很好。
我们将使用带有-O3 选项的 gcc x86-64 10.2 在 Godbolt 上编译它。
让我们开始吧:
typedef int element;
我们必须假设某事。我会合理假设a、x、y 和z 数组不重叠。这非常重要 - 如果不是这样,必须在问题中说明 (!!)。 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 并亲自尝试一下!