最快的方法来乘以int64_t数组?

时间:2016-05-18 10:01:34

标签: c vectorization multiplication avx avx2

我想对两个内存对齐数组的乘法进行向量化。 我没有找到任何方法在AVX / AVX2中乘以64 * 64位,所以我只是循环展开和AVX2加载/存储。有更快的方法吗?

注意:我不想保存每次乘法的高半结果。

void multiply_vex(long *Gi_vec, long q, long *Gj_vec){

    int i;
    __m256i data_j, data_i;
    __uint64_t *ptr_J = (__uint64_t*)&data_j;
    __uint64_t *ptr_I = (__uint64_t*)&data_i;


    for (i=0; i<BASE_VEX_STOP; i+=4) {
        data_i = _mm256_load_si256((__m256i*)&Gi_vec[i]);
        data_j = _mm256_load_si256((__m256i*)&Gj_vec[i]);

        ptr_I[0] -= ptr_J[0] * q;
        ptr_I[1] -= ptr_J[1] * q;
        ptr_I[2] -= ptr_J[2] * q;
        ptr_I[3] -= ptr_J[3] * q;

        _mm256_store_si256((__m256i*)&Gi_vec[i], data_i);
    }


    for (; i<BASE_DIMENSION; i++)
        Gi_vec[i] -= Gj_vec[i] * q;
}

更新 我正在使用Haswell微体系结构和ICC / GCC编译器。所以AVX和AVX2都很好。 在乘法循环展开后,我将-=替换为C intrisic _mm256_sub_epi64,在那里得到一些加速。目前,它是ptr_J[0] *= q; ...

我使用__uint64_t,但错误。正确的数据类型为__int64_t

2 个答案:

答案 0 :(得分:15)

您似乎假设代码中的long为64位,但之后也使用__uint64_t。在32位,x32 ABI和Windows上,long是32位类型。您的标题提到long long,但您的代码会忽略它。如果你的代码假设long是32位,我想知道一段时间。

您通过使用AVX256加载完全射击自己,但随后将指针混叠到__m256i上进行标量操作。 gcc只是放弃并向你提供你要求的可怕代码:向量加载,然后是一堆extractinsert指令。你编写它的方式意味着必须解压缩两个向量来执行标量中的sub,而不是使用vpsubq

Modern x86 CPUs have very fast L1 cache that can handle two operations per clock。 (Haswell及以后:每个时钟有两个负载和一个存储)。从同一缓存行执行多个标量加载比向量加载和解包更好。 (不完美的uop调度将吞吐量降低到大约84%,但是:见下文)

gcc 5.3 -O3 -march=haswell (Godbolt compiler explorer)很好地自动矢量化一个简单的标量实现。 当AVX2不可用时,gcc愚蠢地仍然使用128b向量自动向量化:在Haswell上,这实际上大约是理想标量64位代码速度的1/2。(参见性能分析在下面,但每个向量代替2个元素而不是4)。

#include <stdint.h>    // why not use this like a normal person?
#define BASE_VEX_STOP 1024
#define BASE_DIMENSION 1028

// restrict lets the compiler know the arrays don't overlap,
// so it doesn't have to generate a scalar fallback case
void multiply_simple(uint64_t *restrict Gi_vec, uint64_t q, const uint64_t *restrict Gj_vec){
    for (intptr_t i=0; i<BASE_DIMENSION; i++)   // gcc doesn't manage to optimize away the sign-extension from 32bit to pointer-size in the scalar epilogue to handle the last less-than-a-vector elements
        Gi_vec[i] -= Gj_vec[i] * q;
}

内循环:

.L4:
    vmovdqu ymm1, YMMWORD PTR [r9+rax]        # MEM[base: vectp_Gj_vec.22_86, index: ivtmp.32_76, offset: 0B], MEM[base: vectp_Gj_vec.22_86, index: ivtmp.32_76, offset: 0B]
    add     rcx, 1    # ivtmp.30,
    vpsrlq  ymm0, ymm1, 32      # tmp174, MEM[base: vectp_Gj_vec.22_86, index: ivtmp.32_76, offset: 0B],
    vpmuludq        ymm2, ymm1, ymm3        # tmp173, MEM[base: vectp_Gj_vec.22_86, index: ivtmp.32_76, offset: 0B], vect_cst_.25
    vpmuludq        ymm0, ymm0, ymm3        # tmp176, tmp174, vect_cst_.25
    vpmuludq        ymm1, ymm4, ymm1        # tmp177, tmp185, MEM[base: vectp_Gj_vec.22_86, index: ivtmp.32_76, offset: 0B]
    vpaddq  ymm0, ymm0, ymm1    # tmp176, tmp176, tmp177
    vmovdqa ymm1, YMMWORD PTR [r8+rax]        # MEM[base: vectp_Gi_vec.19_81, index: ivtmp.32_76, offset: 0B], MEM[base: vectp_Gi_vec.19_81, index: ivtmp.32_76, offset: 0B]
    vpsllq  ymm0, ymm0, 32      # tmp176, tmp176,
    vpaddq  ymm0, ymm2, ymm0    # vect__13.24, tmp173, tmp176
    vpsubq  ymm0, ymm1, ymm0    # vect__14.26, MEM[base: vectp_Gi_vec.19_81, index: ivtmp.32_76, offset: 0B], vect__13.24
    vmovdqa YMMWORD PTR [r8+rax], ymm0        # MEM[base: vectp_Gi_vec.19_81, index: ivtmp.32_76, offset: 0B], vect__14.26
    add     rax, 32   # ivtmp.32,
    cmp     rcx, r10  # ivtmp.30, bnd.14
    jb      .L4 #,

如果需要,可以将其转换回内在函数,但只需让编译器自动向量化就会容易得多。我没有尝试分析它以确定它是否是最佳的。

如果您通常不使用-O3进行编译,则可以在循环前使用#pragma omp simd(和-fopenmp)。

当然,不是标量结语,而是概率。更快地对Gj_vec的最后一个32B进行未对齐加载,并存储到Gi_vec的最后一个32B,可能与循环中的最后一个存储重叠。 (如果阵列小于32B,仍然需要标量回退。)

Haswell

的改进向量内在版本

从我对Z Boson的回答中得到的评论。基于Agner Fog's vector class library code

Agner Fog的版本通过使用phadd + pshufd来保存指令但是在shuffle端口上存在瓶颈,我使用psrlq / paddq / pand。

由于您的某个操作数是常量,请务必将set1(q)作为b传递,而不是a,以便&#34; bswap&#34; shuffle可以悬挂。

// replace hadd -> shuffle (4 uops) with shift/add/and (3 uops)
// The constant takes 2 insns to generate outside a loop.
__m256i mul64_haswell (__m256i a, __m256i b) {
    // instruction does not exist. Split into 32-bit multiplies
    __m256i bswap   = _mm256_shuffle_epi32(b,0xB1);           // swap H<->L
    __m256i prodlh  = _mm256_mullo_epi32(a,bswap);            // 32 bit L*H products

    // or use pshufb instead of psrlq to reduce port0 pressure on Haswell
    __m256i prodlh2 = _mm256_srli_epi64(prodlh, 32);          // 0  , a0Hb0L,          0, a1Hb1L
    __m256i prodlh3 = _mm256_add_epi32(prodlh2, prodlh);      // xxx, a0Lb0H+a0Hb0L, xxx, a1Lb1H+a1Hb1L
    __m256i prodlh4 = _mm256_and_si256(prodlh3, _mm256_set1_epi64x(0x00000000FFFFFFFF)); // zero high halves

    __m256i prodll  = _mm256_mul_epu32(a,b);                  // a0Lb0L,a1Lb1L, 64 bit unsigned products
    __m256i prod    = _mm256_add_epi64(prodll,prodlh4);       // a0Lb0L+(a0Lb0H+a0Hb0L)<<32, a1Lb1L+(a1Lb1H+a1Hb1L)<<32
    return  prod;
}

See it on Godbolt

请注意,这不包括最终减法,只包括乘法。

这个版本在Haswell上应该比gcc的自动版本更好一些。 (比如每4个周期可能有一个向量而不是每5个周期一个向量,这会影响port0的吞吐量。我没有考虑完整问题的其他瓶颈,因为这是对答案的后期补充。)

AVX1版本(每个矢量两个元素)会很糟糕,可能仍然比64位标量差。不要这样做,除非你已经在向量中有你的数据,并希望结果在向量中(提取到标量和后面可能不值得)。

对GCC自动向量化代码(不是内在版本)的Perf分析

后台:请参阅Agner Fog's insn tables and microarch guide以及代码wiki中的其他链接。

直到AVX512(见下文),这可能只比标量64位代码快得多:imul r64, m64在Intel CPU上每个时钟的吞吐量为1(但AMD Bulldozer系列每4个时钟一个)。 load / imul / sub-with-memory-dest是Intel CPU上的4个融合域uops(具有可以微融合的寻址模式,gcc无法使用)。每个时钟的流水线宽度为4个融合域uop,因此即使是大量的展开也无法在每个时钟发出一次。通过足够的展开,我们会在加载/存储吞吐量方面遇到瓶颈。 Haswell可以在2个负载和每个时钟存储一个存储,但存储地址uops窃取加载端口lower the throughput to about 81/96 = 84% of that, according to Intel's manual

所以也许Haswell的最佳方式是加载和乘以标量,(2 uops),然后vmovq / pinsrq / vinserti128,这样你就可以用{{减去{ 1}}。这8个uops加载和倍增所有4个标量,7个shuffle uops将数据输入__m256i(2(movq)+ 4(pinsrq是2 uops)+ 1 vinserti128),还有3个uop来做vector load / vpsubq / vector store。因此,每4次乘法(发出4.5个周期)的18个融合域uop,但是7个shuffle uops(7个周期执行)。所以nvm,与纯标量相比,这并不好。

自动向量化代码对四个值的每个向量使用8个向量ALU指令。在Haswell上,其中5个uop(乘法和移位)只能在端口0上运行,因此无论您如何展开此算法,它将最多每5个周期实现一个向量(即每5/4个周期一个乘法。)

可以用vpsubq(端口5)替换班次以移动数据并以零移位。 (其他shuffle不支持归零而不是从输入中复制一个字节,并且输入中没有任何已知的零,我们可以复制。)

pshufb / paddq可以在Haswell的1/5端口上运行,也可以在Skylake上的p015上运行。

Skylake在p01上运行psubq并立即计数向量移位,因此理论上可以管理每个最大一个向量的吞吐量(5 / 2,8 / 3,11 / 4)= 11/4 = 2.75个循环。因此它在总融合域uop吞吐量(包括2个向量加载和1个向量存储)上存在瓶颈。因此,一些循环展开将有所帮助。可能来自不完美调度的资源冲突会使其每个时钟略低于4个融合域uop。循环开销有望在端口6上运行,端口6只能处理一些标量操作,包括pmuludq和比较和分支,从而使端口0/1/5用于向量ALU操作,因为它们已关闭饱和(8/3 = 2.666个时钟)。但是,加载/存储端口远不是饱和的。

所以, Skylake理论上可以管理每2.75个周期一个向量(加上循环开销),或者每0.7个循环一个乘法,而Haswell的最佳选择(每个~1.2个循环一个)在理论上用标量,或理论上每1.25个周期用一个矢量)。然而,每~1.2个周期的标量一个可能需要一个手动调整的asm循环,因为编译器不知道如何使用单寄存器寻址模式进行存储,而双寄存器寻址模式用于负载({ {1}}并增加add)。

此外,如果您的数据在L1缓存中不热,那么使用较少的指令完成工作会使前端超前于执行单元,并在需要数据之前开始加载。硬件预取不会跨页面线,因此矢量循环可能会在大型阵列的实践中击败标量,甚至可能用于较小的阵列

AVX-512DQ引入64bx64b-> 64b向量乘法

如果添加dst + (src-dst),gcc可以使用它自动进行矢量化。

dst

因此,如果这些指令在每个时钟流水线处理,AVX512DQ(expected to be part of Skylake multi-socket Xeon (Purley) in ~2017)将提供远大于2倍的加速(来自更宽的向量)。

更新:对于xmm,ymm或zmm向量,Skylake-AVX512(又名SKL-X或SKL-SP)每1.5个周期运行一次VPMULLQ。它的3个uop,15c延迟。 (对于zmm版本,可能额外增加1c的延迟,如果这不是测量故障in the AIDA results。)

-mavx512dq比你用32位块构建的任何东西都要快得多,所以即使当前的CPU不具备64位,它也非常值得拥有这样的指令 - 元素矢量 - 乘法硬件。 (据推测,他们使用FMA单位的尾数乘数。)

答案 1 :(得分:3)

如果您对SIMD 64bx64b到64b(更低)操作感兴趣,请参阅Agner Fog Vector Class Library的AVX和AVX2解决方案。我将使用数组测试这些,并看看它与GCC对通用循环(例如Peter Cordes'answer中的那个循环)所做的比较。

AVX(使用SSE - 您仍然可以使用-mavx进行编译以获得vex编码)。

// vector operator * : multiply element by element
static inline Vec2q operator * (Vec2q const & a, Vec2q const & b) {
#if INSTRSET >= 5   // SSE4.1 supported
    // instruction does not exist. Split into 32-bit multiplies
    __m128i bswap   = _mm_shuffle_epi32(b,0xB1);           // b0H,b0L,b1H,b1L (swap H<->L)
    __m128i prodlh  = _mm_mullo_epi32(a,bswap);            // a0Lb0H,a0Hb0L,a1Lb1H,a1Hb1L, 32 bit L*H products
    __m128i zero    = _mm_setzero_si128();                 // 0
    __m128i prodlh2 = _mm_hadd_epi32(prodlh,zero);         // a0Lb0H+a0Hb0L,a1Lb1H+a1Hb1L,0,0
    __m128i prodlh3 = _mm_shuffle_epi32(prodlh2,0x73);     // 0, a0Lb0H+a0Hb0L, 0, a1Lb1H+a1Hb1L
    __m128i prodll  = _mm_mul_epu32(a,b);                  // a0Lb0L,a1Lb1L, 64 bit unsigned products
    __m128i prod    = _mm_add_epi64(prodll,prodlh3);       // a0Lb0L+(a0Lb0H+a0Hb0L)<<32, a1Lb1L+(a1Lb1H+a1Hb1L)<<32
    return  prod;
#else               // SSE2
    int64_t aa[2], bb[2];
    a.store(aa);                                           // split into elements
    b.store(bb);
    return Vec2q(aa[0]*bb[0], aa[1]*bb[1]);                // multiply elements separetely
#endif
}

AVX2

// vector operator * : multiply element by element
static inline Vec4q operator * (Vec4q const & a, Vec4q const & b) {
    // instruction does not exist. Split into 32-bit multiplies
    __m256i bswap   = _mm256_shuffle_epi32(b,0xB1);           // swap H<->L
    __m256i prodlh  = _mm256_mullo_epi32(a,bswap);            // 32 bit L*H products
    __m256i zero    = _mm256_setzero_si256();                 // 0
    __m256i prodlh2 = _mm256_hadd_epi32(prodlh,zero);         // a0Lb0H+a0Hb0L,a1Lb1H+a1Hb1L,0,0
    __m256i prodlh3 = _mm256_shuffle_epi32(prodlh2,0x73);     // 0, a0Lb0H+a0Hb0L, 0, a1Lb1H+a1Hb1L
    __m256i prodll  = _mm256_mul_epu32(a,b);                  // a0Lb0L,a1Lb1L, 64 bit unsigned products
    __m256i prod    = _mm256_add_epi64(prodll,prodlh3);       // a0Lb0L+(a0Lb0H+a0Hb0L)<<32, a1Lb1L+(a1Lb1H+a1Hb1L)<<32
    return  prod;
}

这些函数适用于有符号和无符号的64位整数。在你的情况下,因为q在循环中是常量的,所以你不需要在每次迭代时重新计算一些东西,但你的编译器可能无论如何都会想出来。