【问题标题】:How to vectorise int8 multiplcation in C (AVX2)如何在 C (AVX2) 中向量化 int8 乘法
【发布时间】:2021-12-19 03:08:22
【问题描述】:

如何用 AVX2 向量化这个 C 函数?

static void propogate_neuron(const short a, const int8_t *b, int *c) {

    for (int i = 0; i < 32; ++i){
        c[i] += a * b[i];
    }

}

【问题讨论】:

  • 让编译器来做? godbolt.org/z/nGeP46zMc 添加restrict 有帮助。
  • 虽然可以做到这一点(如图所示),但您能否重新排列代码以便同时拥有所有神经元,并以不同的方式对它们进行矢量化?我只是在猜测你在做什么,但可以通过重新排序整体计算来将加载和存储的数量减少到 c

标签: c x86 simd intrinsics avx2


【解决方案1】:

GCC 已经通过检查重叠来自动矢量化它。通过使用 int *restrict c 承诺没有重叠,让 GCC 删除该检查,并让 clang 决定自动矢量化。

但是,clang 扩展到 32 位并使用 vpmulld,这是 Haswell 及更高版本上的 2 uop。 (尽管它在 Zen 上完全有效。) GCC 使用 vpmullwvpmulhw 获得 16 位全乘法的低半部分和高半部分,并将它们混洗在一起。 (Godbolt) 这是一个相当笨拙的策略,尤其是-march=znver2,其中vpmulld 是单个微指令。

GCC 确实只有四个单 uop 乘法指令,但要实现它需要大量的洗牌。我们可以做得更好:


由于我们只需要 8x16 => 32 位乘法,我们可以改用 vpmaddwd,它是 Haswell/Skylake 和 Zen 上的单微指令。 https://uops.info/table.html

很遗憾,我们无法利用加法部分,因为我们需要加到一个完整的 32 位值。我们需要在每对 16 位元素的高半部分使用零,以便将其用作每个 32 位元素内的 16x16 => 32 位乘法。

#include <immintrin.h>

void propogate_neuron_avx2(const short a, const int8_t *restrict b, int *restrict c) {
   __m256i va = _mm256_set1_epi32( (uint16_t)a );    // [..., 0, a, 0, a] 16-bit elements

   for (int i = 0 ; i < 32 ; i+=8) {
       __m256i vb = _mm256_cvtepi8_epi32( _mm_loadl_epi64((__m128i*)&b[i]) );
       __m256i prod = _mm256_madd_epi16(va, vb);
       __m256i sum = _mm256_add_epi32(prod, _mm256_loadu_si256((const __m256i*)&c[i]));
       _mm256_storeu_si256((__m256i*)&c[i], sum);
    }
}

Godbolt:

# clang13.0 -O3 -march=haswell
        movzx   eax, di
        vmovd   xmm0, eax                     # 0:a  16-bit halves
        vpbroadcastd    ymm0, xmm0            # repeated to every element

        vpmovsxbd       ymm1, qword ptr [rsi]  # xx:b 16-bit halves
        vpmaddwd        ymm1, ymm0, ymm1       # 0 + a*b in each 32-bit element
        vpaddd  ymm1, ymm1, ymmword ptr [rdx]
        vmovdqu ymmword ptr [rdx], ymm1

... repeated 3 more times, 8 elements per vector

        vpmovsxbd       ymm1, qword ptr [rsi + 8]
        vpmaddwd        ymm1, ymm0, ymm1
        vpaddd  ymm1, ymm1, ymmword ptr [rdx + 32]
        vmovdqu ymmword ptr [rdx + 32], ymm1

如果为每个向量乘法保存一个 uop 会产生可衡量的性能差异,那么在源中手动进行向量化可能是值得的。

在自动矢量化纯 C 代码时,GCC / clang 一开始就没有这样做,这是一个错过的优化。

如果有人想举报,请在此处发表评论。否则我可能会解决它。 IDK 如果这样的模式足够频繁以至于 GCC / LLVM 的优化器想要寻找这种模式。特别是由于 CPU 的怪癖,clang 已经做出了一个合理的选择,该选择只是次优的(在最近的英特尔微架构上,32x32 => 32 位 SIMD 多重复制成本比 2x 16x16 => 32 位水平添加成本更高)。

【讨论】:

    【解决方案2】:

    您需要添加restrict 限定符以标记c 不能与b 别名。

    问题是int8_t 很可能是signed char,它可以根据严格的别名规则与任何其他类型进行别名。因此编译器不能确定设置c[i] 不会修改b[i]。 强制编译器在每次迭代时获取数据。

    const 的存在并不意味着什么,因为它只限制程序员通过指针 b 修改数据。

    将原型替换为:

    void propogate_neuron(const short a, const int8_t *b, int * restrict c)
    

    代码被矢量化。见godbolt

    【讨论】:

    • 一个更激进的编译器可能会进行重叠检查,并制作两个版本的循环(一个用于重叠,一个用于非重叠情况。)大小在循环之前就知道了。例如,GCC 在没有 restrict 的情况下执行此操作:godbolt.org/z/vfx7r5Tfv。但是,是的,有时编译器会放弃,除非你使用 restrict(或 C++ __restrict),如果你知道它不会重叠,那么它有助于避免浪费长度计算 + 比较/分支。另请注意,您仅启用了-mavx,而不是使用-march=haswellznver2 进行的AVX2 + 调整;你得到更少的insns:godbolt.org/z/hcKszerbK
    • 嗯,我们能否避免将vpmulld 扩展到 32 位(Haswell 及更高版本为 2 微秒)?可能仍将b 扩展为具有vpmovsx 负载的32 位元素,但使用vpmaddwd(单uop)使用[0, a, 0, a, ...] 的向量进行16x16 位扩展有符号乘法,而不是符号扩展a到 32 位。我们仍然得到 32 位全乘结果,并加上 0。不过,这是自动矢量化器中错过的优化。
    • 将其添加为答案。
    猜你喜欢
    • 2021-12-05
    • 1970-01-01
    • 2013-03-12
    • 2014-08-25
    • 1970-01-01
    • 1970-01-01
    • 2017-04-26
    • 2019-01-03
    • 2018-05-02
    相关资源
    最近更新 更多