我正在try 使用AVX实现以下操作:

for (i=0; i<N; i++) {
  for(j=0; j<N; j++) {
    for (k=0; k<K; k++) {
      d[i][j] += 2 * a[i][k] * ( b[k][j]- c[k]);
    }
  }
}

for (int i=0; i<N; i++){
   f+= d[ind[i]][ind[i]]/2;
}    

其中d是NxN矩阵,a是NxK,b是KxN,c是长度K的向量.它们都是双精度的.当然,所有的数据都是统一的,我使用#pragma vector aligned帮助编译器(gcc).

我知道如何在一维数组中使用AVX扩展,但使用矩阵对我来说有点棘手.目前,我有以下几点,但我没有得到正确的结果:

    for (int i=0; i< floor (N/4); i++){
        for (int j=0; j< floor (N/4); j++){
            __m256d D, A, B, C;
            D = _mm256_setzero_pd();
            #pragma vector aligned
            for (int k=0; k<K_MAX; k++){
                A = _mm256_load_pd(a[i] + k*4);
                B = _mm256_load_pd(b[k] + j*4);
                C = _mm256_load_pd(c + 4*k);
                B = _mm256_sub_pd(B, C);
                A = _mm256_mul_pd(A, B);
                D = _mm256_add_pd(_mm256_set1_pd(2.0), A);
                _mm256_store_pd(d[i] + j*4, D);
            }

        }
    }


    for (int i=0; i<N; i++){
        f+= d[ind[i]][ind[i]]/2;
    }    

我希望有人能告诉我哪里出了错.

提前谢谢.

注意:我不想介绍OpenMP,只是使用SIMD英特尔指令

推荐答案

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-loopshttps://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_ck0minus_ck1等等(或者保留一个向量数组;只要它很小,编译器有足够的寄存器,这些元素就不会存在于内存中)

由于寄存器中同时有多个bv-cvdv个向量,我们可以在不增加FP工作总量的情况下,按负载执行更多的FMA.然而,常数需要更多的寄存器,否则我们可能会通过强制更多的重新加载来达到目的.

d[i][j] += (2*a_ik) * b[k][j] - (2*a_ik*c_k)转换在这里没有用;我们希望将bv-cvi分开,这样我们就可以将结果作为不同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对齐,即使这意味着在每行末尾填充.(存储几何体不必与实际的矩阵维数匹配;分别传递Nrow_stride.一个用于索引计算,另一个用于循环边界.)

C++相关问答推荐

初始化char数组-用于初始化数组的字符串是否除了存储数组的位置之外单独存储在内存中

理解没有返回语句的递归C函数的行为

如何正确地索引C中的 struct 指针数组?

为什么在C中进行大量的位移位?

堆栈帧和值指针

如何使fputs功能提示错误输入并要求用户重新输入.程序停止而不是请求新的输入

每个 struct 变量在C中都有自己的命名空间吗?

Char变量如何在不使用方括号或花括号的情况下存储字符串,以及它如何迭代到下一个字符?

CSAPP微型shell 实验室:卡在sigprocmask

==284==错误:AddressSaniizer:堆栈缓冲区下溢

在C语言中,指针指向一个数组

S和查尔有什么不同[1]?

变量值不正确的问题

哪些C++功能可以在外部C块中使用

关于不同C编译器中的__attribute__支持

clion.我无法理解 Clion 中发生的 scanf 错误

C 中从 Unix 纪元时间转换的损坏

为什么需要struct in_addr

仅使用其内存地址取消引用 C 中的 struct

为什么这里的符号没有解析?