b[k][j]
是您的问题:元素b[k + 0..3][j]
在内存中不是连续的.使用SIMD(以合理/有用的方式)不是classic 的naive matmul循环所能做到的.见What Every Programmer Should Know About Memory?——附录中有一个SSE2 matmul(带缓存阻塞)的示例,它展示了如何以不同的顺序进行操作,这对SIMD是友好的.
Soonts's answer显示了如何矢量化,通过矢量化j
,中间的循环.但这留下了一个相对较差的内存访问模式,在循环中有3个加载+3个ALU操作.(这个答案一开始只是对它的一个 comments ,看看我所说的代码,并提出修改建议.)
Loop inversion should be possible to do 100 as the inner-most loop.这意味着在最内部的循环中存储d[i][j] += ...
,但它在2 * a[i][k] * ( b[k][j]- c[k] )
中生成了更多的循环不变量,因此可以有效地转换为d[i][j] += (2*a_ik) * b[k][j] - (2*a_ik*c_k)
,即每个加载一个VFMSUBPD和一个VADDPD&;百货store (bv
load作为内存源操作数折叠到FMSUB中,dv
load折叠到VADDPD中,因此前端希望只有3个UOP,包括一个单独的存储,不包括循环开销.)
编译器必须展开并运行在Intel CPU(Haswell通过Skylake系列)的端口7上,而不是与这两个负载竞争.冰湖没有这个问题,有两个完全独立的存储AGU和两个负载AGU.但可能仍然需要一些循环展开,以避免前端瓶颈.
下面是一个未经测试的示例(原始版本由Soons提供,谢谢).它以一种不同的方式优化了循环中的2个FP数学运算:简单地从循环中提升2*a
个,然后对dv += (2av)*(sub_result)
个进行子FMA.但是bv
不能是vsubpd
的源操作数,因为我们需要bv - cv
.但是我们可以通过对cv
求反来修复这个问题,在内部循环中允许(-cv) + bv
,将bv
作为内存源操作数.有时编译器会为您执行类似的操作,但在这里,它们似乎没有,所以我手动执行.否则我们会有一个单独的vmovupd
负载通过前端.
#include <stdint.h>
#include <stdlib.h>
#include <immintrin.h>
// This double [N][N] C99 VLA syntax isn't portable to C++ even with GNU extensions
// restrict tells the compiler the output doesn't overlap with any of the inputs
void matop(size_t N, size_t K, double d[restrict N][N], const double a[restrict N][K], const double b[restrict K][N], const double c[restrict K])
{
for( size_t i = 0; i < N; i++ ) {
// loop-invariant pointers for this outer iteration
//double* restrict rowDi = &d[ i ][ 0 ];
const double* restrict rowAi = &a[ i ][ 0 ];
for( size_t k = 0; k < K; k++ ) {
const double* restrict rowBk = &b[ k ][ 0 ];
double* restrict rowDi = &d[ i ][ 0 ];
#if 0 // pure scalar
// auto-vectorizes ok; still a lot of extra checking outside outermost loop even with restrict
for (size_t j=0 ; j<N ; j++){
rowDi[j] += 2*rowAi[k] * (rowBk[j] - c[k]);
}
#else // SIMD inner loop with cleanup
// *** TODO: unroll over 2 or 3 i values
// and maybe also 2 or 3 k values, to reuse each bv a few times while it's loaded.
__m256d av = _mm256_broadcast_sd( rowAi + k );
av = _mm256_add_pd( av, av ); // 2*a[ i ][ k ] broadcasted
const __m256d cv = _mm256_broadcast_sd( &c[ k ] );
const __m256d minus_ck = _mm256_xor_pd(cv, _mm256_set1_pd(-0.0)); // broadcasted -c[k]
//const size_t N_aligned = ( (size_t)N / 4 ) * 4;
size_t N_aligned = N & -4; // round down to a multiple of 4 j iterations
const double* endBk = rowBk + N_aligned;
//for( ; j < N_aligned; j += 4 )
for ( ; rowBk != endBk ; rowBk += 4, rowDi += 4) { // coax GCC into using pointer-increments in the asm, instead of j+=4
// Load the output vector to update
__m256d dv = _mm256_loadu_pd( rowDi );
// Update with FMA
__m256d bv = _mm256_loadu_pd( rowBk );
__m256d t2 = _mm256_add_pd( minus_ck, bv ); // bv - cv
dv = _mm256_fmadd_pd( av, t2, dv );
// Store back to the same address
_mm256_storeu_pd( rowDi, dv );
}
// rowDi and rowBk point to the double after the last full vector
// The remainder, if you can't pad your rows to a multiple of 4 and step on that padding
for(int j=0 ; j < (N&3); j++ )
rowDi[ j ] += _mm256_cvtsd_f64( av ) * ( rowBk[ j ] + _mm256_cvtsd_f64( minus_ck ) );
#endif
}
}
}
在不展开(https://godbolt.org/z/6WeYKbnYY)的情况下,GCC11的内部循环asm看起来是这样的,所有单uop指令即使在Haswell和更高版本的后端也可以保持微融合.
.L7: # do{
vaddpd ymm0, ymm2, YMMWORD PTR [rax] # -c[k] + rowBk[0..3]
add rax, 32 # rowBk += 4
add rdx, 32 # rowDi += 4
vfmadd213pd ymm0, ymm1, YMMWORD PTR [rdx-32] # fma(2aik, Bkj-ck, Dij)
vmovupd YMMWORD PTR [rdx-32], ymm0 # store FMA result
cmp rcx, rax
jne .L7 # }while(p != endp)
但它总共有6个UOP,其中3个是循环开销(指针增量和融合cmp+jne),因此Haswell through Skylake只能以每1.5个时钟1次迭代的速度运行它,在前端的4个宽问题阶段受到限制.(这不会让OoO exec在执行指针增量和循环分支时领先于notice early and recover,而后端仍在处理旧的加载和FP数学.)
所以循环展开应该是有帮助的,因为我们设法说服GCC使用索引寻址模式.否则,在英特尔Haswell/Skylake CPU上使用AVX代码相对无用,每vaddpd ymm5, ymm4, [rax + r14]
个解码都是一个微熔合uop,但在后端将有争议的部分分解为2个,无法帮助我们通过前端最窄的部分完成更多工作.(很像我们使用单独的vmovupd
负载,就像我们使用_mm256_sub_pd(bv, cv)
负载而不是add(bv, -cv)
负载一样.)
vmovupd ymmword ptr [rbp + r14], ymm5
存储保持微熔合状态,但无法在端口7上运行,限制我们每个时钟总共进行2次内存操作(最多一次存储操作)所以最好的情况是每个向量1.5个循环.
用GCC和clang -O3 -march=skylake -funroll-loops
在https://godbolt.org/z/rd3rn9zor上编译.GCC实际上使用指针增量,将负载折叠为8x vaddpd
和8x vfmadd213pd
.但clang使用索引寻址模式,不会展开.(您可能不希望整个程序都使用-funroll-loops
,所以可以单独编译,也可以手动展开.GCC的展开完全剥离了一个开场白,在进入实际的SIMD循环之前进行0..7次向量迭代,因此它非常具有攻击性.)
GCC's loop-unrolling looks useful here for large N,在多个向量上摊销指针增量和循环开销.(例如,GCC不知道如何为点积中的FP dep链发明多个累加器,从而使其展开在这种情况下毫无用处,这与clang不同.)
不幸的是,clang并没有为我们展开内部循环,但它确实以一种有趣的方式使用了vmaskmovpd
来进行清理.
我们use a separate loop counter for cleanup, in a way that lets the compiler easily prove the trip-count for the cleanup is 0..3可能是好事,所以它不会try 用YMM自动矢量化.
另一种方法是,对内部循环及其清理使用实际的j
变量,更像是Soons的编辑.IIRC,编译器did试图为此自动向量化清理,浪费代码大小,有些总是错误的分支.
size_t j = 0; // used for cleanup loop after
for( ; j < N_aligned; j += 4 )
{
// Load the output vector to update
__m256d dv = _mm256_loadu_pd( rowDi + j );
// Update with FMA
__m256d bv = _mm256_loadu_pd( rowBk + j );
__m256d t2 = _mm256_sub_pd( bv, cv ); // bv - cv
dv = _mm256_fmadd_pd( av, t2, dv );
// Store back to the same address
_mm256_storeu_pd( rowDi + j, dv );
}
// The remainder, if you can't pad your rows to a multiple of 4
for( ; j < N; j++ )
rowDi[ j ] += _mm256_cvtsd_f64( av ) * ( rowBk[ j ] - _mm256_cvtsd_f64( cv ) );
这是一个相当好的负载和;针对现代CPU(https://agner.org/optimize/和https://uops.info/)的store vs.FP math,尤其是Intel,我们可以在其中进行2次加载and 1次存储.我认为禅2或禅3也可以做2次加载+1次存储.不过,它需要在L1d缓存中运行才能维持这种吞吐量.(即便如此,英特尔的优化手册称Skylake上的最大持续L1d带宽仍低于所需的完整96字节/周期.更像是80年代中期的IIRC,因此我们不能期望每个周期有一个结果向量,即使有足够的展开以避免前端瓶颈.)
There's no latency bottleneck, since we move on to a new 100 every iteration instead of accumulating anything across loop iterations.
这样做的另一个优点是memory access to 100 and 101 would be sequential,在最内部的循环中没有其他内存访问.(中间的循环将执行a[i][k]
和c[k]
的广播负载.如果内部循环逐出太多,这些似乎可能会缓存未命中;对于外部循环的一些展开,一个SIMD负载和一些洗牌可能会有所帮助,但缓存阻塞可能会避免这种需要.)
对于不同的b[k]
行,重复循环相同的d[i]
行可以为我们修改的部分提供位置(即,使用k
作为中间循环,保持i
作为最外层循环)以k
作为外循环,我们将在整个d[0..N-1][0..N-1]
上循环K
次,可能需要写入+读取每个过程,直到缓存或内存能够容纳它的任何级别.
But really you'd still want to cache-block如果每一行都很长,那么您可以避免缓存未命中,从而将所有b[][]
个数据从DRAM中带入N次.避免驱逐下一步要播放的内容.
智能展开:缓存阻塞的第一步
如果我们在加载每个数据向量时对其进行更多处理,那么上述一些问题可能会消失,这些问题包括最大化加载/存储执行单元吞吐量,以及要求编译器使用非索引寻址模式.
例如,我们可以处理2、3或4,而不是只处理一行d[][]
.然后,对于d[i+unroll][j + 0..vec]
向量,每个(rowBk[j] - c[k])
结果可以使用多次(使用不同的2aik
).
我们还可以加载几个不同的(rowBk+K*0..unroll)[j+0..3]
,每个都有相应的minus_ck0
、minus_ck1
等等(或者保留一个向量数组;只要它很小,编译器有足够的寄存器,这些元素就不会存在于内存中)
由于寄存器中同时有多个bv-cv
和dv
个向量,我们可以在不增加FP工作总量的情况下,按负载执行更多的FMA.然而,常数需要更多的寄存器,否则我们可能会通过强制更多的重新加载来达到目的.
d[i][j] += (2*a_ik) * b[k][j] - (2*a_ik*c_k)
转换在这里没有用;我们希望将bv-cv
和i
分开,这样我们就可以将结果作为不同FMA的输入进行重用.
b[k][j]+(-c[k])
仍然可以从负载与vaddpd
的微融合中获益,因此理想情况下,它仍将使用指针增量,但前端可能不再是瓶颈.
不要做得过火;太多的内存输入流可能是缓存冲突未命中的问题,尤其是对于可能会产生混叠的某些N值,以及对跟踪它们的硬件预取的问题.(虽然英特尔的L2拖缆据说每4k页跟踪1个正向流和1个反向流,但IIRC.)大概4到8次就可以了.但如果L1d中没有d[][]
,那么它就不是真正的内存输入流.不过,您不希望b[][]
个输入行逐出d
个数据,因为您将重复循环2到4行d
个数据.
相比之下:Soons的循环——清理频率较低,但内存访问模式更差.
Soonts目前的3个负载和3个ALU操作的循环并不理想,尽管每个FMA操作1个负载在缓存中已经可以了(大多数现代CPU每个时钟可以执行2个,尽管AMD Zen也可以与mul/FMA并行执行2个FP加法).如果额外的ALU操作是一个瓶颈,我们可以将a[][]
乘以2一次,只需要O(N*K)
个工作,而不是O(N^2*K)
个工作就可以完成.但这可能不是瓶颈,因此不值得.
更重要的是,Soons当前答案中的内存访问模式是,对于c[k]
和a[i][k]
的广播负载,一次向前循环1倍,这很好,但是the 102 of 103 is unfortunately striding down a column.
如果你要像Soons建议的那样展开,不要只为一个dv
做两个dep链,至少要做两个向量,d[i][j + 0..3]
和4..7
,这样你就可以从你touch 的每b[k][j]
处使用整个64字节(完整缓存线).或一对缓存线的四个向量.(英特尔CPU至少使用了一个邻线预取器,它喜欢完成128字节aligned对缓存线,因此您可以将b[][]
行与128行或至少与64行对齐,并从邻线预取中获得一些好处.
如果b[][]
的垂直切片适合某个级别的缓存(以及当前累积到的d[i][]
行),那么下一步下一组列可以受益于预取和局部性.如果没有,那么充分利用你接触到的线就更重要了,这样以后就不必再拉它们了.
因此,对于Soons的矢量化策略,对于L1d缓存中不适用的大型问题,最好确保b
的行按64对齐,即使这意味着在每行末尾填充.(存储几何体不必与实际的矩阵维数匹配;分别传递N
和row_stride
.一个用于索引计算,另一个用于循环边界.)