从C 中的双精度数组的优化总和重新发布我的答案的修改版本,因为该问题被投票否决为-5。另一个问题的 OP 将其更多地表述为“还有什么可能”,所以我相信他的话,并转储了有关矢量化和调整当前 CPU 硬件的信息。:)
该问题的 OP 最终说他不允许使用高于 的编译器选项-O0
,我猜这里也是这种情况。
概括:
为什么使用-O0
会扭曲事物(不公平地惩罚在普通编译器的普通代码中正常的事物)。 使用-O0
(gcc/clang 默认值)使您的循环不会优化不是一个有效的借口或有用的方法来找出启用正常优化会更快的方法。
任务有问题的东西。
优化类型。FP 延迟与吞吐量和依赖链。链接到 Agner Fog 的网站。(优化的基本读物)。
让编译器对其进行优化的实验(在将其修复为不优化之后)。自动矢量化的最佳结果(无源更改): gcc:是最佳矢量化循环的一半。clang:与手动矢量化循环相同的速度。
更多关于为什么更大的表达式只是一个完美的胜利的评论-O0
。
源代码更改以在不使用 的情况下获得良好的性能-ffast-math
,使代码更接近我们希望编译器执行的操作。还有一些在现实世界中无用的规则律师思想。
使用 GCC 体系结构中性向量对循环进行向量化,以查看自动向量化编译器与理想 asm 代码的性能匹配的程度(因为我检查了编译器输出)。
我认为这项任务的重点是在没有编译器优化的情况下使用 C 来教授汇编语言性能优化。这很愚蠢。它将编译器在现实生活中为您做的事情与需要源代码级别更改的事情混为一谈。
请参阅为什么 clang 会使用 -O0 产生低效的 asm(对于这个简单的浮点总和)?
-O0
不仅“不优化”,它还使编译器在每条语句之后将变量存储到内存中,而不是将它们保存在寄存器中。它这样做是为了如果您使用 gdb 设置断点并修改C 变量的值(在内存中),您将获得“预期的”结果。或者即使您jump
在同一功能中使用另一行。因此,每个 C 语句都必须编译为一个独立的 asm 块,该块以内存中的所有变量开始和结束。对于像 gcc 这样的现代可移植编译器,它已经在从源代码到 asm 的过程中通过程序流的多个内部表示进行转换,这部分-O0
需要显式地将其数据流图反优化为单独的 C 语句。 这些存储/重新加载会延长每个循环携带的依赖链,因此如果循环计数器保存在内存中,那么对于微小的循环来说是可怕的。(例如,每次迭代 1 个周期 forinc reg
与 6c 相比inc [mem]
,在紧密循环中创建循环计数器更新的瓶颈)。
使用gcc -O0
,关键字让 gcc将register
var 保存在寄存器中而不是内存中,因此可以在紧密循环中产生很大的不同(Godbolt Compiler explorer 上的示例)。但这仅适用于-O0
. 在实际代码中,register
这是没有意义的:编译器尝试优化地使用可用寄存器来存储变量和临时变量。 register
在 ISO C++11(但不是 C11)中已弃用,并且有人提议将其与其他过时的东西(如三元组)一起从语言中删除。
由于涉及额外的变量,-O0
对数组索引的伤害比指针递增更多。
数组索引通常使代码更易于阅读。编译器有时无法优化诸如 之类的东西array[i*width + j*width*height]
,因此最好更改源以执行将乘法转换为加法的强度降低+=
优化。
在 asm 级别,数组索引与指针递增的性能接近相同。(例如,x86 具有与 . 一样快的寻址模式, [rsi + rdx*4]
除了在 Sandybridge 和更高版本上。)编译器的工作是通过使用指针递增来优化代码,即使在源使用数组索引时也是如此,当它更快时。[rdi]
为了获得良好的性能,您必须了解编译器可以做什么和不能做什么。一些优化是“脆弱的”,对源代码的一个看似无害的小改动将阻止编译器进行优化,而这对于某些代码的快速运行至关重要。(例如,将一个常量计算从循环中拉出来,或者证明不同的分支条件是如何相互关联的,然后进行简化。)
除此之外,这是一个垃圾样本,因为它没有任何东西可以阻止智能编译器优化整个事情。它甚至不打印总和。甚至gcc -O1
(而不是-O3
)丢弃了一些循环。
(您可以通过在最后打印来解决此问题sum
。gcc 和 clang 似乎没有意识到calloc
返回归零的内存,并将其优化为0.0
. 请参阅下面的代码。)
通常你会把你的代码放在一个函数中,然后从main()
另一个文件中循环调用它。并单独编译它们,没有整个程序的跨文件优化,因此编译器无法根据您调用它的编译时常量进行优化。重复循环被如此紧密地包裹在数组上的实际循环周围,这对 gcc 的优化器造成了严重破坏(见下文)。
此外,这个问题的另一个版本有一个未初始化的变量。看起来long int help
是由那个问题的 OP 介绍的,而不是教授介绍的。所以我将不得不将我的“完全胡说八道”降级为“愚蠢”,因为代码最后甚至没有打印结果。这是让编译器不在像这样的微基准测试中优化所有内容的最常见方法。
我假设你的教授提到了一些关于性能的事情。这里有很多不同的东西可以发挥作用,我认为其中许多在二年级的 CS 课程中没有被提及。
除了使用 openmp 进行多线程之外,还有使用 SIMD 进行矢量化。现代流水线 CPU 也有一些优化:具体来说,避免使用一个长的依赖链。
进一步的基本阅读:
你的编译器手册也是必不可少的,尤其是。用于浮点代码。浮点的精度有限,并且不具有关联性。最终总和确实取决于您进行加法的顺序。通常舍入误差的差异很小,因此如果您-ffast-math
允许编译器重新排序,编译器可以得到很大的加速。
而不是仅仅展开,保留多个累加器,你只在最后加起来,就像你用sum0
.. sum9
unroll-by-10 做的那样。FP 指令具有中等延迟但吞吐量很高,因此您需要保持多个 FP 操作处于运行状态以保持浮点执行单元饱和。
如果您需要在下一个操作开始之前完成上一个操作的结果,那么您会受到延迟的限制。对于 FP 添加,这是每 3 个周期一个。在 Intel Sandybridge、IvB、Haswell 和 Broadwell 中,FP add 的吞吐量是每个周期一个。因此,您需要保持至少 3 个可以同时运行的独立操作以使机器饱和。 对于 Skylake,它是每个周期 2 个,延迟为 4 个时钟。(对 Skylake 有利的是,FMA 降低到 4 个周期延迟。)
在这种情况下,还有一些基本的东西,比如把东西拉出循环,例如help += ARRAY_SIZE
.
编译器选项
让我们先看看编译器能为我们做什么。
我从最初的内部循环开始,刚刚help += ARRAY_SIZE
拉出,并printf
在最后添加 a ,因此 gcc 不会优化所有内容。让我们尝试一些编译器选项,看看我们可以使用 gcc 4.9.2 实现什么(在我的i5 2500k Sandybridge上。3.8GHz最大涡轮增压(轻微 OC),3.3GHz 持续(与这个简短的基准测试无关)):
gcc -O0 fast-loop-cs201.c -o fl
: 16.43s 的表现完全是个笑话。变量在每次操作后存储到内存中,并在下一次之前重新加载。这是一个瓶颈,并增加了很多延迟。更不用说失去了实际的优化。 计时/调优代码-O0
用处不大。
-O1
: 4.87s
-O2
: 4.89s
-O3
:2.453s(使用SSE一次做2个。我当然用的是64位系统,所以硬件支持-msse2
是基线。)
-O3 -ffast-math -funroll-loops
: 2.439s
-O3 -march=sandybridge -ffast-math -funroll-loops
:1.275s(使用AVX一次做4个。)
-Ofast ...
: 没有收获
-O3 -ftree-parallelize-loops=4 -march=sandybridge -ffast-math -funroll-loops
:0m2.375s 真实,0m8.500s 用户。看起来锁定开销杀死了它。它总共只产生 4 个线程,但内部循环太短而无法获胜:它每次都收集总和,而不是给每个线程 1/4 的外部循环迭代。
-Ofast -fprofile-generate -march=sandybridge -ffast-math
,运行它,然后
-Ofast -fprofile-use -march=sandybridge -ffast-math
:1.275s。 当您可以使用所有相关的代码路径时,配置文件引导的优化是一个好主意,因此编译器可以做出更好的展开/内联决策。
clang-3.5 -Ofast -march=native -ffast-math
: 1.070 秒。(clang 3.5 太旧了,无法支持-march=sandybridge
。您应该更喜欢使用足够新的编译器版本来了解您正在调整的目标架构,尤其是如果-march
用于制作不需要在旧架构上运行的代码。 )
gcc -O3
以一种有趣的方式进行矢量化:内部循环并行执行外部循环的 2(或 4)次迭代,方法是将一个数组元素广播到 xmm(或 ymm)寄存器的所有元素,然后addpd
对其进行操作。所以它看到相同的值被重复添加,但甚至-ffast-math
不让 gcc 把它变成一个乘法。或者切换循环。
clang-3.5 更好地矢量化:它矢量化了内部循环,而不是外部循环,因此它不需要广播。它甚至使用 4 个向量寄存器作为 4 个独立的累加器。但是,它不假定calloc
返回对齐的内存,并且出于某种原因,它认为最好的选择是一对 128b 负载。
vmovupd -0x60(%rbx,%rcx,8),%xmm4`
vinsertf128 $0x1,-0x50(%rbx,%rcx,8),%ymm4,%ymm4
当我告诉它数组已对齐时,它实际上更慢。(有一个愚蠢的hack array = (double*)((ptrdiff_t)array & ~31);
,实际上会生成一条指令来屏蔽低5位,因为clang-3.5不支持gcc __builtin_assume_aligned
。)我认为4x的紧密循环vaddpd mem, %ymmX,%ymmX
对齐的方式cmp $0x271c,%rcx
跨越了32B边界,所以它可以't 与jne
. 不过,uop 吞吐量应该不是问题,因为根据perf
.
啊,我用调试器检查过,calloc
只返回一个 16B 对齐的指针。因此,有一半的 32B 内存访问跨越了高速缓存行,从而导致速度大幅下降。在Sandybridge 上,当您的指针是 16B 对齐但不是 32B 对齐时,执行两个单独的 16B 加载会稍微快一些。(gcc 启用-mavx256-split-unaligned-load
和...-store
for -march=sandybridge
,以及默认的 tune=generic with -mavx
,这对于 Haswell 或通常由编译器不知道的对齐的内存来说不是很好。)
源级别更改
正如我们从 clang beat gcc 中看到的那样,多个累加器非常好。最明显的方法是:
for (j = 0; j < ARRAY_SIZE; j+=4) { // unroll 4 times
sum0 += array[j];
sum1 += array[j+1];
sum2 += array[j+2];
sum3 += array[j+3];
}
然后直到外循环结束后才将 4 个累加器合二为一。
您(来自另一个问题)的来源更改
sum += j[0]+j[1]+j[2]+j[3]+j[4]+j[5]+j[6]+j[7]+j[8]+j[9];
由于乱序执行,实际上具有类似的效果。每组 10 个是一个单独的依赖链。order-of-operations 规则说这些j
值首先被加在一起,然后被添加到sum
. 所以循环携带的依赖链仍然只是一个 FP add 的延迟,并且每组 10 个有很多独立的工作。每组是一个单独的依赖链,由 9 个 add 组成,并且需要的指令足够少。 -order 执行硬件以查看下一个链的开始,并找到并行性以保持那些中等延迟、高吞吐量 FP 执行单元的馈送。
使用-O0
,正如您的愚蠢分配显然需要的那样,在每个语句的末尾将值存储到 RAM 中。编写更长的表达式而不更新任何变量,即使是临时变量,也会使-O0
运行更快,但这不是一个有用的优化。不要将时间浪费在只会帮助的更改上-O0
,尤其是。不以牺牲可读性为代价。
使用 4 个累加器变量,直到外部循环结束时才将它们加在一起,这会破坏 clang 的自动矢量化器。它仍然只在 1.66 秒内运行(而 gcc 的非矢量化-O2
与一个累加器的运行时间为 4.89)。即使gcc -O2
没有-ffast-math
此源更改也可以获得 1.66 秒。请注意,已知 ARRAY_SIZE 是 4 的倍数,因此我没有包含任何清理代码来处理最后最多 3 个元素(或避免读取超出数组末尾,这将按照现在所写的方式发生) . 这样做真的很容易出错并读到数组的末尾。
另一方面,gcc 确实对此进行了矢量化,但它也将内部循环悲观(未优化)为单个依赖链。我认为它再次对外部循环进行了多次迭代。
使用 gcc 的独立于平台的向量扩展,我编写了一个编译成明显最优代码的版本:
// compile with gcc -g -Wall -std=gnu11 -Ofast -fno-tree-vectorize -march=native fast-loop-cs201.vec.c -o fl3-vec
#include <stdio.h>
#include <stdlib.h>
#include <stddef.h>
#include <assert.h>
#include <string.h>
// You are only allowed to make changes to this code as specified by the comments in it.
// The code you submit must have these two values.
#define N_TIMES 600000
#define ARRAY_SIZE 10000
int main(void)
{
double *array = calloc(ARRAY_SIZE, sizeof(double));
double sum = 0;
int i;
// You can add variables between this comment ...
long int help = 0;
typedef double v4df __attribute__ ((vector_size (8*4)));
v4df sum0={0}, sum1={0}, sum2={0}, sum3={0};
const size_t array_bytes = ARRAY_SIZE*sizeof(double);
double *aligned_array = NULL;
// this more-than-declaration could go in an if(i == 0) block for strict compliance with the rules
if ( posix_memalign((void**)&aligned_array, 32, array_bytes) ) {
exit (1);
}
memcpy(aligned_array, array, array_bytes); // In this one case: faster to align once and have no extra overhead for N_TIMES through the loop
// ... and this one.
// Please change 'your name' to your actual name.
printf("CS201 - Asgmt 4 - I. Forgot\n");
for (i = 0; i < N_TIMES; i++) {
// You can change anything between this comment ...
/*
#if defined(__GNUC__) && (__GNUC__ * 100 + __GNUC_MINOR__) >= 407 // GCC 4.7 or later.
array = __builtin_assume_aligned(array, 32);
#else
// force-align for other compilers. This loop-invariant will be done outside the loop.
array = (double*) ((ptrdiff_t)array & ~31);
#endif
*/
assert ( ARRAY_SIZE / (4*4) == (ARRAY_SIZE+15) / (4*4) ); // We don't have a cleanup loop to handle where the array size isn't a multiple of 16
// incrementing pointers can be more efficient than indexing arrays
// esp. on recent Intel where micro-fusion only works with one-register addressing modes
// of course, the compiler can always generate pointer-incrementing asm from array-indexing source
const double *start = aligned_array;
while ( (ptrdiff_t)start & 31 ) {
// annoying loops like this are the reason people use aligned buffers
sum += *start++; // scalar until we reach 32B alignment
// in practice, this loop doesn't run, because we copy into an aligned buffer
// This will also require a cleanup loop, and break our multiple-of-16 doubles assumption.
}
const v4df *end = (v4df *)(aligned_array+ARRAY_SIZE);
for (const v4df *p = (v4df *)start ; p+3 < end; p+=4) {
sum0 += p[0]; // p+=4 increments the pointer by 4 * 4 * 8 bytes
sum1 += p[1]; // make sure you keep track of what you're incrementing
sum2 += p[2];
sum3 += p[3];
}
// the compiler might be smart enough to pull this out of the inner loop
// in fact, gcc turns this into a 64bit movabs outside of both loops :P
help+= ARRAY_SIZE;
// ... and this one. But your inner loop must do the same
// number of additions as this one does.
/* You could argue legalese and say that
if (i == 0) {
for (j ...)
sum += array[j];
sum *= N_TIMES;
}
* still does as many adds in its *INNER LOOP*, but it just doesn't run it as often
*/
}
// You can add some final code between this comment ...
sum0 = (sum0 + sum1) + (sum2 + sum3);
sum += sum0[0] + sum0[1] + sum0[2] + sum0[3];
printf("sum = %g; help=%ld\n", sum, help); // defeat the compiler.
free (aligned_array);
free (array); // not strictly necessary, because this is the end of main(). Leaving it out for this special case is a bad example for a CS class, though.
// ... and this one.
return 0;
}
内部循环编译为:
4007c0: c5 e5 58 19 vaddpd (%rcx),%ymm3,%ymm3
4007c4: 48 83 e9 80 sub $0xffffffffffffff80,%rcx # subtract -128, because -128 fits in imm8 instead of requiring an imm32 to encode add $128, %rcx
4007c8: c5 f5 58 49 a0 vaddpd -0x60(%rcx),%ymm1,%ymm1 # one-register addressing mode can micro-fuse
4007cd: c5 ed 58 51 c0 vaddpd -0x40(%rcx),%ymm2,%ymm2
4007d2: c5 fd 58 41 e0 vaddpd -0x20(%rcx),%ymm0,%ymm0
4007d7: 4c 39 c1 cmp %r8,%rcx # compare with end with p
4007da: 75 e4 jne 4007c0 <main+0xb0>
(有关更多信息,请参阅 Godbolt 编译器资源管理器中的在线编译器输出。-xc
编译器选项编译为 C,而不是 C++。内部循环是 from .L3
to jne .L3
。有关 x86 asm 链接,请参阅x86标签 wiki。另请参阅有关 micro-fusion not发生在 SnB-family 上,Agner Fog 的指南没有涵盖)。
表现:
$ perf stat -e task-clock,cycles,instructions,r1b1,r10e,stalled-cycles-frontend,stalled-cycles-backend,L1-dcache-load-misses,cache-misses ./fl3-vec
CS201 - Asgmt 4 - I. Forgot
sum = 0; help=6000000000
Performance counter stats for './fl3-vec':
1086.571078 task-clock (msec) # 1.000 CPUs utilized
4,072,679,849 cycles # 3.748 GHz
2,629,419,883 instructions # 0.65 insns per cycle
# 1.27 stalled cycles per insn
4,028,715,968 r1b1 # 3707.733 M/sec # unfused uops
2,257,875,023 r10e # 2077.982 M/sec # fused uops. lower than insns because of macro-fusion
3,328,275,626 stalled-cycles-frontend # 81.72% frontend cycles idle
1,648,011,059 stalled-cycles-backend # 40.47% backend cycles idle
751,736,741 L1-dcache-load-misses # 691.843 M/sec
18,772 cache-misses # 0.017 M/sec
1.086925466 seconds time elapsed
我仍然不知道为什么它每个周期的指令如此之低。内部循环使用 4 个单独的累加器,我用 gdb 检查了指针是否对齐。所以缓存库冲突不应该是问题。Sandybridge L2 高速缓存每个周期可以支持一个 32B 传输,这应该跟上每个周期一个 32B FP 向量添加。
来自 L1 的 32B 加载需要 2 个周期(直到 Haswell,Intel 才使 32B 加载为单周期操作)。但是,有 2 个加载端口,因此每个周期的持续吞吐量为 32B(我们没有达到)。
也许负载需要在使用之前进行流水线处理,以尽量减少负载停止时 ROB(重新排序缓冲区)填满?但是性能计数器表明 L1 缓存命中率相当高,因此从 L2 到 L1 的硬件预取似乎正在发挥作用。
每个周期 0.65 条指令只是使向量 FP 加法器饱和的一半左右。这令人沮丧。甚至IACA也表示循环应该在每次迭代中运行 4 个周期。(即饱和加载端口和端口1(FP加法器所在的位置)):/
更新:我想L2 带宽毕竟是问题所在。没有足够的行填充缓冲区来保持足够的未命中以维持每个周期的峰值吞吐量。 L2 持续带宽低于 Intel SnB/Haswell/Skylake CPU 的峰值。
另请参阅Sandy Bridge 上的单线程内存带宽(英特尔论坛线程,有很多关于什么限制吞吐量以及一个可能的瓶颈的讨论。另请参阅针对 memcpy 的增强型 REP MOVSBlatency * max_concurrency
答案的“延迟绑定平台”部分;内存有限并发性是加载和存储的瓶颈,但对于加载到 L2 的预取确实意味着您可能不会纯粹受限于未解决的 L1D 未命中的 Line Fill 缓冲区。
将 ARRAY_SIZE 减少到 1008(16 的倍数),并将 N_TIMES 增加 10 倍,将运行时间降低到 0.5 秒。这是每个周期 1.68 个 insns。(内部循环是 4 个 FP 添加的总共 7 条指令,因此我们最终使向量 FP 添加单元和加载端口饱和。)循环平铺是一个更好的解决方案,见下文。
Intel CPU 每个 L1 数据和 L1 指令缓存只有 32k。我认为您的阵列几乎不适合AMD K10 (Istanbul) CPU 上的 64kiB L1D,但不适合 Bulldozer 系列 (16kiB L1D)或 Ryzen (32kiB L1D)。
Gcc 通过将相同的值广播到并行添加来进行矢量化的尝试似乎并不那么疯狂。如果它成功地做到了这一点(使用多个累加器来隐藏延迟),那将允许它用一半的内存带宽使向量 FP 加法器饱和。照原样,这几乎是一次洗礼,可能是因为广播的开销。
此外,这很愚蠢。这N_TIMES
只是一个虚构的重复。我们实际上并不想针对多次执行相同的工作进行优化。除非我们想在这种愚蠢的任务中获胜。执行此操作的源级方法是增加i
我们允许修改的代码部分:
for (...) {
sum += a[j] + a[j] + a[j] + a[j];
}
i += 3; // The inner loop does 4 total iterations of the outer loop
更现实地,要处理这个问题,您可以交换循环(循环遍历数组一次,将每个值相加 N_TIMES 次)。我想我已经读过英特尔的编译器有时会为您做到这一点。
一种更通用的技术称为缓存阻塞或循环平铺。这个想法是在适合缓存的小块中处理您的输入数据。根据您的算法,可以对一个块执行不同的阶段,然后对下一个块重复,而不是让每个阶段循环整个输入。与往常一样,一旦你知道一个技巧的正确名称(并且它确实存在),你就可以搜索大量信息。
if (i == 0)
在允许修改的代码部分中,您可以通过规则律师将交换循环放入块中。它仍然会执行相同数量的添加,但以更缓存优化的顺序。