这种
有限差分法 的强化减少优化方法,相比于每次单独重新评估多项式,
可以 提高速度。但是只有当你将其推广到更大的步长时,才能在循环中保持足够的并行性。
我的版本针对适合L1d缓存的小数组,在Skylake上每个时钟周期存储一个向量(四个双精度数),否则它就是一个带宽测试。在早期的Intel处理器上,包括使用AVX的
Sandy Bridge(如果输出对齐),也应该达到SIMD FP-add吞吐量的最大值(每两个时钟周期1x 256位加法和1x 256位存储)。
依赖于上一次迭代的值会导致性能下降
这种强度降低优化(只是简单地加法,而不是重新开始一个新的i
并进行乘法运算)在循环迭代中引入了一个串行依赖关系,涉及到浮点数运算而不是整数增量。
原始版本具有每个输出元素的数据并行性:每个元素仅依赖于常量和自己的i
值。编译器可以使用SIMD(SSE2或AVX,如果您使用-O3 -march=native
)进行自动向量化,而CPU可以通过乱序执行将工作跨越循环迭代重叠。尽管需要额外的工作量,但CPU能够以足够的暴力应用,并获得编译器的帮助。
但是,计算poly(i+1)
的版本却具有非常有限的并行性;没有SIMD向量化,例如,在Intel Skylake到Tiger Lake上,四个周期是FP加法的延迟时间,其中您的CPU每四个周期只能运行两个标量加法(https://uops.info/)。
huseyin tugrul buyukisik的答案展示了如何在更现代的CPU上接近最大化原始版本的吞吐量,使用两个FMA操作来评估多项式(
Horner's scheme),加上整数到浮点转换或浮点增量。(后者创建了一个FP依赖链,您需要展开以隐藏。)
因此,在最好的情况下,每个
SIMD输出向量有三个浮点数学运算。(加上存储)当前英特尔CPU只有两个浮点执行单元,可以运行包括int-to-double在内的FP数学运算。(对于512位向量,当前CPU关闭端口1上的向量ALU,因此总共只有两个SIMD ALU端口,因此非FP数学运算(如SIMD-integer increment)也将竞争SIMD吞吐量。除了只有一个512位FMA单元的CPU,那么端口5是空闲的,可用于其他工作。)
自Zen 2以来,AMD在两个端口上拥有两个FMA/mul单元和两个不同端口上的两个FP add/sub单元,因此如果使用FMA进行加法,您理论上每个时钟周期最多可以进行四个SIMD加法。
Haswell/Broadwell每个时钟周期有2个FMA,但只有1个FP add/sub (具有较低的延迟)。这对于天真的代码来说很好,但对于已经优化为具有大量并行性的代码来说
不是很好。这可能是英特尔在Skylake中进行更改的原因。(Alder Lake重新引入了低延迟FP add/sub,但吞吐量与乘法相同,每个时钟周期2个。有趣的是,非交换
延迟:目标只需要2个周期,另一个操作数需要3个周期,因此它非常适用于较长的依赖链。)
您的Sandy Bridge(E5-1620)和Nehalem(W5580) CPU每个时钟周期有1个FP add/sub,1个FP mul,在不同的端口上。这就是Haswell正在构建的内容。这也是为什么添加额外的乘法不是一个大问题:它们可以与现有的加法并行运行。(Sandy Bridge的宽度为256位,但您编译时未启用AVX:使用
-march=native
。)
寻找并行性:任意步长下的强度降低
您的compute2
根据前一个值计算出下一个Y和下一个Z。也就是说,对于data[i+1]
,步长为1,您需要的值。因此,每次迭代都依赖于前一个迭代。
如果将其推广到其他步长,则可以提前4、6、8或更多个独立的Y和Z值,使它们彼此锁步跳跃,所有这些值都相互独立。 这恢复了足够的并行性,以便编译器和/或CPU利用它们。
poly(i) = A i^2 + B i + C
poly(i+s) = A (i+s)^2 + B (i+s) + C
= A*i^2 + A*2*s*i + A*s^2 + B*i + B*s + C
= poly(i) + A*2*s*i + A*s^2 + B*s + C
所以这有点混乱,不太清楚如何将其分解为Y和Z部分。(而且此答案的早期版本是错误的。)可能更容易从FP值序列的步幅的一阶和二阶差异开始逆推(
有限差分法)。这将直接找到我们需要添加的内容以向前移动;Z []初始化程序和步骤。这基本上就像取一阶和二阶导数,然后优化循环有效地进行积分来恢复原始函数。以下输出由下面基准测试中正确性检查部分生成。
poly(i) 1st 2nd difference from this poly(i) to poly(i+1)
0 1
1 3 2
4 5 2
9 7 2
16 9 2
25 11 2
相同的多项式(x^2
),但是采用步长为3的差分。非2的幂有助于显示步长因子/幂与自然出现的2的因子之间的区别。
poly(i) 1st 2nd difference from this poly(i) to poly(i+3)
0 9
1 15
4 21
9 27 18
16 33 18
25 39 18
36 45 18
49 51 18
64 57 18
81 63 18
100 69 18
121 75 18
Y[]和Z[]的初始化器
初始Y[j] = poly(j)
因为它必须存储到相应位置的输出中(data[i+j] = Y[j]
)。
初始Z[j]
将被添加到Y[j]
中,并需要使其成为poly(j+stride)
。因此,初始Z[j] = poly(j+stride) - Y[j]
,我们可以通过代数化简来简化它。 (对于编译时常量A、B、C,编译器将以任何一种方式进行常量传播。)
Z[j]
保存了在穿过poly(x)
的poly(0..stride-1)
的起始点时的步幅中的一阶差异。这是上表中的中间列。
必要的更新Z[j] += second_difference
是一个标量常量,因为我们可以看到二阶差异是相同的。
通过尝试几个不同的stride
和A
值(i ^ 2的系数),我们可以看到它是A * 2 * (stride * stride)
。(使用非互质值如3和5有助于解开纷乱的事情。)通过更多的代数,您可以符号地显示这一点。从微积分的角度来看,2的因子是有意义的:d(A*x^2)/dx = 2Ax
,而第二个导数是2A
。
#include <stdalign.h>
#include <stdlib.h>
#define LEN 1024
alignas(64) double data[LEN];
static const double A = 5, B = 3, C = 7;
void compute2(double * const __restrict__ data)
{
const int stride = 16;
const double diff2 = (stride * stride) * 2 * A;
double Z[stride], Y[stride];
for (int j = 0 ; j<stride ; j++){
Y[j] = j*j*A + j*B + C;
Z[j] = ((2*j + stride)*A + B)*stride;
}
for(ptrdiff_t i=0; i < LEN - (stride-1); i+=stride) {
for (int j=0 ; j<stride ; j++) data[i+j] = Y[j];
for (int j=0 ; j<stride ; j++) Y[j] += Z[j];
for (int j=0 ; j<stride ; j++) Z[j] += diff2;
}
for (int j = 0 ; j < LEN % stride ; j++) {
}
}
对于
stride=1
(不展开)的情况,这些简化为原始值。但对于更大的
stride
,编译器可以将
Y[]
和
Z[]
中的元素保存在少数几个SIMD向量中,因为每个
Y[j]
只与相应的
Z[j]
交互。
对于编译器(SIMD)和CPU(流水线执行单元),存在独立于stride
的并行依赖链,可以利用其优势,以比原始速度快stride
倍的速度运行,直到你受限于SIMD FP-add吞吐量而不是延迟,或者存储带宽如果你的缓冲区不适合L1d。(或者直到编译器失败,并且不能像预期那样展开和向量化这些循环!)
实际编译情况:使用clang优美地编译
(Godbolt编译器探索者) 使用clang14 -O3 -march=skylake -ffast-math
,stride=16
(每个向量包含4个double
)时,clang可以很好地自动向量化。
看起来clang进一步展开了2次,将Z[j] += diff2
快捷方式转换为tmp = Z[j] + diff2;
/ Z[j] += 2*diff2;
。这减轻了Z依赖链的压力,只留下Y[j]紧贴着Skylake上的延迟瓶颈。
因此,每个asm循环迭代执行2个8个vaddpd
指令和2个4存储操作。循环开销是add
+ 宏融合的cmp/jne
,因此是2个uops。(或者对于全局数组,只有一个add/jne
uop,计算从负索引逐渐增加到零;它相对于数组末尾进行索引。)
Skylake以非常接近每个时钟周期1个存储和2个vaddpd
的速度运行此操作。这是两者的最大吞吐量。前端只需要跟上略高于每个时钟周期3个uops,但自Core2以来就一直是4宽的。
Sandy Bridge系列中的uop缓存使得这不成问题。(除非您遇到Skylake上的JCC错误,因此我使用了-mbranches-within-32B-boundaries
让clang填充指令以避免该问题。)
由于Skylake的vaddpd
延迟为4个周期,从stride=16
产生的4个依赖链刚好足够保持4个独立操作在进行中。任何时候Y[j]+=
不能在它准备好的周期运行,都会创建一个气泡。感谢Clang对Z[]链的额外展开,然后可以提前运行Z[j]+=,因此Z链可以领先。使用最旧的优先调度,它倾向于安定下来,进入一个Yj+= uops没有冲突的状态,显然,因为它在我的Skylake上以全速运行。如果我们可以让编译器仍然为stride=32
生成漂亮的汇编代码,那么将留下更多空间,但不幸的是它不会。(以奇数大小为代价进行更多的清理工作。)
Clang奇怪地只有在
-ffast-math
的情况下才能对此进行向量化。完整基准测试中的模板版本不需要
-ffast-math
。源代码已经被精心编写为支持SIMD操作,具体表现为数学操作按照源代码顺序执行。(尽管快速数学运算使得clang可以更好地展开Z增量)。
另一种循环编写方式是内部只有一个循环,而不是所有Y操作和所有Z操作。这在下面的基准测试中很好(有时甚至更好),但在这里即使使用
-ffast-math
也无法进行向量化。让编译器为像这样的非平凡问题生成最优展开的SIMD汇编代码可能会很棘手且不可靠,并且可能需要一些实验。
我在Godbolt上将其包含在
#if 0
/
#else
/
#endif
块内。
for(int i=0; i < LEN - (stride-1); i+=stride) {
for (int j = 0 ; j<stride ; j++){
data[i+j] = Y[j];
Y[j] += Z[j];
Z[j] += deriv2;
}
}
我们必须手动选择适当的展开数量。过大的展开因子甚至会使编译器无法看到正在发生的事情,并停止将临时数组保留在寄存器中。例如,32或24对于clang来说是一个问题,但16不是。可能有一些调整选项可以强制编译器展开循环直到某个计数; 对于GCC,有时可以使用这些选项让它在编译时看穿某些东西。
另一种方法是手动矢量化,使用#include 和__m256d Z [4]而不是double Z [16]。但是,此版本可以为其他ISA(例如AArch64)进行矢量化。
大展开因子的其他缺点是在问题大小不是展开的倍数时留下更多的清理工作。(您可以使用compute1策略进行清理,在执行标量之前让编译器将其向量化一两次。)
理论上,使用
-ffast-math
编译器可以帮你完成这个过程,要么是通过在原始多项式上进行强度降低,要么是看到步幅累加的方式从
compute2
中。但实际上,这很复杂,需要人类自己来完成。除非有人教给编译器如何寻找和应用类似的模式,并使用步幅的方法来计算!但即使使用
-ffast-math
,对具有不同误差累积特性的算法进行全面重写可能也是不可取的。(整数不会有任何精度问题,但仍然存在复杂的模式匹配/替换)
实验性能结果:
我在我的桌面电脑上 (i7-6700k) 使用 clang13.0.0 进行了测试。在几种编译器选项 (快速数学运算或非快速数学运算) 和内部循环策略的 #if 0
vs. #if 1
的多种组合下,确实可以每个时钟周期运行 1 个 SIMD 存储。我的基准 / 测试框架基于 @huseyin tugrul buyukisik 的版本,改进了重复可测量数量的方法,在 rdtsc
指令之间进行测试,并使用测试循环来检查多项式的简单计算是否正确。
我还让它补偿了核心时钟频率和 "参考" TSC 读取频率的差异,在我的情况下是 3.9 GHz vs. 4008 MHz。(额定最大 Turbo 是 4.2 GHz,但在 Linux 上使用 EPP = balance_performance
时,它只想要时钟提升到 3.9 GHz。)
Source code on Godbolt: using one inner loop, rather than 3 separate j<16
loops, and not using -ffast-math
. Using __attribute__((noinline))
to keep this from inlining into the repeat loop. Some other variations of options and source led to some vpermpd
shuffles inside the loop.
以下基准数据来自具有错误的Z[j]初始化程序的先前版本,但其循环汇编代码相同。现在Godbolt链接在计时循环后进行了正确性测试,测试通过。实际性能在我的桌面上仍然相同,每个double
略高于0.25个周期,即使没有#if 1
/ -ffast-math
来允许clang额外展开。
$ clang++ -std=gnu++17 -O3 -march=native -mbranches-within-32B-boundaries poly-eval.cpp -Wall
# warning about noipa, only GCC knows that attribute
$ perf stat --all-user -etask-clock,context-switches,cpu-migrations,page-faults,cycles,instructions,uops_issued.any,uops_executed.thread,fp_arith_inst_retired.256b_packed_double -r10 ./a.out
... (10 runs of the whole program, ending with)
...
0.252295 cycles per data element (corrected from ref cycles to core clocks for i7-6700k @ 3.9 GHz)
0.252109 cycles per data element (corrected from ref cycles to core clocks for i7-6700k @ 3.9 GHz)
xor=4303
min cycles per data = 0.251868
Performance counter stats for './a.out' (10 runs):
298.92 msec task-clock # 0.989 CPUs utilized ( +- 0.49% )
0 context-switches # 0.000 /sec
0 cpu-migrations # 0.000 /sec
129 page-faults # 427.583 /sec ( +- 0.56% )
1,162,430,637 cycles # 3.853 GHz ( +- 0.49% ) # time spent in the kernel for system calls and interrupts isn't counted, that's why it's not 3.90 GHz
3,772,516,605 instructions # 3.22 insn per cycle ( +- 0.00% )
3,683,072,459 uops_issued.any # 12.208 G/sec ( +- 0.00% )
4,824,064,881 uops_executed.thread # 15.990 G/sec ( +- 0.00% )
2,304,000,000 fp_arith_inst_retired.256b_packed_double # 7.637 G/sec
0.30210 +- 0.00152 seconds time elapsed (+- 0.50%)
fp_arith_inst_retired.256b_packed_double
用于计算每个FP加法或乘法指令(FMA为2),因此整个程序中,包括打印等,我们每个时钟周期得到1.98个vaddpd
指令。这非常接近理论最大值2/clock,显然没有受到次优的uop调度的影响。(我增加了重复循环,使程序在大部分时间内都在那里,从而使perf stat
对整个程序有用。)
这种优化的目标是用更少的FLOPS完成相同的工作,但这也意味着我们基本上在不使用FMA的情况下达到了Skylake的8 FLOP/clock的极限(单核心在3.9 GHz时为30.58 GFLOP/s)。
非内联函数的汇编(objdump -drwC -Mintel
); clang使用了4个Y,Z对的YMM向量,并将循环展开了3倍,使其成为24 KiB大小的精确倍数,没有清除操作。请注意,add rax,0x30
每次迭代执行3 * stride=0x10个double。
0000000000001440 <void compute2<3072>(double*)>:
# just loading constants
1440: c5 fd 28 0d 18 0c 00 00 vmovapd ymm1,YMMWORD PTR [rip+0xc18] # 2060 <_IO_stdin_used+0x60>
1448: c5 fd 28 15 30 0c 00 00 vmovapd ymm2,YMMWORD PTR [rip+0xc30] # 2080
1450: c5 fd 28 1d 48 0c 00 00 vmovapd ymm3,YMMWORD PTR [rip+0xc48] # 20a0
1458: c4 e2 7d 19 25 bf 0b 00 00 vbroadcastsd ymm4,QWORD PTR [rip+0xbbf] # 2020
1461: c5 fd 28 2d 57 0c 00 00 vmovapd ymm5,YMMWORD PTR [rip+0xc57] # 20c0
1469: 48 c7 c0 d0 ff ff ff mov rax,0xffffffffffffffd0
1470: c4 e2 7d 19 05 af 0b 00 00 vbroadcastsd ymm0,QWORD PTR [rip+0xbaf] # 2028
1479: c5 fd 28 f4 vmovapd ymm6,ymm4 # buggy Z[j] initialization in this ver used the same value everywhere
147d: c5 fd 28 fc vmovapd ymm7,ymm4
1481: c5 7d 28 c4 vmovapd ymm8,ymm4
1485: 66 66 2e 0f 1f 84 00 00 00 00 00 data16 cs nop WORD PTR [rax+rax*1+0x0]
# top of outer loop. The NOP before this is to align it.
1490: c5 fd 11 ac c7 80 01 00 00 vmovupd YMMWORD PTR [rdi+rax*8+0x180],ymm5
1499: c5 d5 58 ec vaddpd ymm5,ymm5,ymm4
149d: c5 dd 58 e0 vaddpd ymm4,ymm4,ymm0
14a1: c5 fd 11 9c c7 a0 01 00 00 vmovupd YMMWORD PTR [rdi+rax*8+0x1a0],ymm3
14aa: c5 e5 58 de vaddpd ymm3,ymm3,ymm6
14ae: c5 cd 58 f0 vaddpd ymm6,ymm6,ymm0
14b2: c5 fd 11 94 c7 c0 01 00 00 vmovupd YMMWORD PTR [rdi+rax*8+0x1c0],ymm2
14bb: c5 ed 58 d7 vaddpd ymm2,ymm2,ymm7
14bf: c5 c5 58 f8 vaddpd ymm7,ymm7,ymm0
14c3: c5 fd 11 8c c7 e0 01 00 00 vmovupd YMMWORD PTR [rdi+rax*8+0x1e0],ymm1
14cc: c5 bd 58 c9 vaddpd ymm1,ymm8,ymm1
14d0: c5 3d 58 c0 vaddpd ymm8,ymm8,ymm0
14d4: c5 fd 11 ac c7 00 02 00 00 vmovupd YMMWORD PTR [rdi+rax*8+0x200],ymm5
14dd: c5 d5 58 ec vaddpd ymm5,ymm5,ymm4
14e1: c5 dd 58 e0 vaddpd ymm4,ymm4,ymm0
14e5: c5 fd 11 9c c7 20 02 00 00 vmovupd YMMWORD PTR [rdi+rax*8+0x220],ymm3
14ee: c5 e5 58 de vaddpd ymm3,ymm3,ymm6
14f2: c5 cd 58 f0 vaddpd ymm6,ymm6,ymm0
14f6: c5 fd 11 94 c7 40 02 00 00 vmovupd YMMWORD PTR [rdi+rax*8+0x240],ymm2
14ff: c5 ed 58 d7 vaddpd ymm2,ymm2,ymm7
1503: c5 c5 58 f8 vaddpd ymm7,ymm7,ymm0
1507: c5 fd 11 8c c7 60 02 00 00 vmovupd YMMWORD PTR [rdi+rax*8+0x260],ymm1
1510: c5 bd 58 c9 vaddpd ymm1,ymm8,ymm1
1514: c5 3d 58 c0 vaddpd ymm8,ymm8,ymm0
1518: c5 fd 11 ac c7 80 02 00 00 vmovupd YMMWORD PTR [rdi+rax*8+0x280],ymm5
1521: c5 d5 58 ec vaddpd ymm5,ymm5,ymm4
1525: c5 dd 58 e0 vaddpd ymm4,ymm4,ymm0
1529: c5 fd 11 9c c7 a0 02 00 00 vmovupd YMMWORD PTR [rdi+rax*8+0x2a0],ymm3
1532: c5 e5 58 de vaddpd ymm3,ymm3,ymm6
1536: c5 cd 58 f0 vaddpd ymm6,ymm6,ymm0
153a: c5 fd 11 94 c7 c0 02 00 00 vmovupd YMMWORD PTR [rdi+rax*8+0x2c0],ymm2
1543: c5 ed 58 d7 vaddpd ymm2,ymm2,ymm7
1547: c5 c5 58 f8 vaddpd ymm7,ymm7,ymm0
154b: c5 fd 11 8c c7 e0 02 00 00 vmovupd YMMWORD PTR [rdi+rax*8+0x2e0],ymm1
1554: c5 bd 58 c9 vaddpd ymm1,ymm8,ymm1
1558: c5 3d 58 c0 vaddpd ymm8,ymm8,ymm0
155c: 48 83 c0 30 add rax,0x30
1560: 48 3d c1 0b 00 00 cmp rax,0xbc1
1566: 0f 82 24 ff ff ff jb 1490 <void compute2<3072>(double*)+0x50>
156c: c5 f8 77 vzeroupper
156f: c3 ret
相关:
4*A2
的增量进行。可能使用-ffast-math
可以让clang为您完成这项工作(甚至可能是GCC,但GCC倾向于不使用多个累加器展开)。对于Haswell或更高版本的处理器,使用Horner方法处理这样一个简短的多项式非常适合,并且易于应用程序执行顺序隐藏,尽管它仍需要i
的FP版本。 - Peter Cordes显式位 = sig1 * sig2; 指数 = exp1 + exp2
),而对于浮点数加法,则需要依次完成(确定结果的指数,然后“移位”两个值以匹配结果指数,然后确定结果的显式位)。 - Brendan