【问题标题】:Accumulating a running-total (prefix sum) horizontally across an __m256i vector在 __m256i 向量上水平累积运行总计(前缀和)
【发布时间】:2021-12-10 03:54:29
【问题描述】:

我正在尝试将一些标量代码(下面的calc_offsets)转换为 AVX2 等效项。它需要一系列“计数”并生成一个偏移位置表,从一些提供的基值开始。

我尝试将其转换为 AVX2 (avx2_calc_offsets),我认为这是正确的,它的速度似乎是简单数组方法的一半左右。这是将更大的热部分(瓶颈)代码转换为 AVX2 指令的努力的一部分,我希望将偏移量作为向量进一步处理。对于这样的操作,我想避免在 AVX2 和标量代码之间跳转。

提供了一些示例和简单的基准测试代码。阵列版本的运行时间约为 2.15 秒,AVX2 版本的运行时间约为 4.41 秒(在 Ryzen Zen v1 上)。

有没有更好的方法使用 AVX2 来加快这个操作?我需要考虑较旧的 AVX2 CPU,例如 Haswell 和原始 Ryzen 系列。

#include <immintrin.h>
#include <inttypes.h>
#include <stdio.h>

typedef uint32_t u32;
typedef uint64_t u64;

void calc_offsets (const u32 base, const u32 *counts, u32 *offsets)
{
    offsets[0] = base;
    offsets[1] = offsets[0] + counts[0];
    offsets[2] = offsets[1] + counts[1];
    offsets[3] = offsets[2] + counts[2];
    offsets[4] = offsets[3] + counts[3];
    offsets[5] = offsets[4] + counts[4];
    offsets[6] = offsets[5] + counts[5];
    offsets[7] = offsets[6] + counts[6];
}

__m256i avx2_calc_offsets (const u32 base, const __m256i counts)
{
    const __m256i shuff = _mm256_set_epi32 (6, 5, 4, 3, 2, 1, 0, 7);

    __m256i v, t;

    // shift whole vector `v` 4 bytes left and insert `base`
    v = _mm256_permutevar8x32_epi32 (counts, shuff);
    v = _mm256_insert_epi32 (v, base, 0);

    // accumulate running total within 128-bit sub-lanes
    v = _mm256_add_epi32 (v, _mm256_slli_si256 (v, 4));
    v = _mm256_add_epi32 (v, _mm256_slli_si256 (v, 8));

    // add highest value in right-hand lane to each value in left
    t = _mm256_set1_epi32 (_mm256_extract_epi32 (v, 3));
    v = _mm256_blend_epi32 (_mm256_add_epi32 (v, t), v, 0x0F);

    return v;
}

void main()
{
    u32 base = 900000000;
    u32 counts[8] = { 5, 50, 500, 5000, 50000, 500000, 5000000, 50000000 };
    u32 offsets[8];

    calc_offsets (base, &counts[0], &offsets[0]);
    
    printf ("calc_offsets: ");
    for (int i = 0; i < 8; i++) printf (" %u", offsets[i]);
    printf ("\n-----\n");

    __m256i v, t;
    
    v = _mm256_loadu_si256 ((__m256i *) &counts[0]);
    t = avx2_calc_offsets (base, v);

    _mm256_storeu_si256 ((__m256i *) &offsets[0], t);
    
    printf ("avx2_calc_offsets: ");
    for (int i = 0; i < 8; i++) printf (" %u", offsets[i]);
    printf ("\n-----\n");

    // --- benchmarking ---

    #define ITERS 1000000000

    // uncomment to benchmark AVX2 version
    // #define AVX2_BENCH

#ifdef AVX2_BENCH
    // benchmark AVX2 version    
    for (u64 i = 0; i < ITERS; i++) {
        v = avx2_calc_offsets (base, v);
    }
    
    _mm256_storeu_si256 ((__m256i *) &offsets[0], v);

#else
    // benchmark array version
    u32 *c = &counts[0];
    u32 *o = &offsets[0];

    for (u64 i = 0; i < ITERS; i++) {
        calc_offsets (base, c, o);
        
        // feedback results to prevent optimizer 'cleverness'
        u32 *tmp = c;
        c = o;
        o = tmp;
    }

#endif 

    printf ("offsets after benchmark: ");
    for (int i = 0; i < 8; i++) printf (" %u", offsets[i]);
    printf ("\n-----\n");
}

我正在使用gcc -O2 -mavx2 ... 构建。 Godbolt link.

【问题讨论】:

  • 看起来像 Prefix Sum,又名包容性扫描,又名累积和。加上counts[8] 中的一些固定偏移量,它们并没有真正改变依赖模式。有关 FP 版本(包括 AVX),请参阅 parallel prefix (cumulative) sum with SSE,但正如我在那里评论的那样,整数添加延迟较低意味着预期的加速可能较小。
  • 从您的问题标题中,我预计它将是Fastest way to do horizontal SSE vector sum (or other reduction),您只想要总数。可能想在标题中的某处放置“总和”或“前缀总和”。
  • @Peter 谢谢,如果我能在标量数组版本附近的某个地方得到它,即使稍微慢一点,我也会很高兴。主要目的是避免基于向量的基于数组的跳转多次。

标签: c vectorization x86-64 intrinsics avx2


【解决方案1】:

消除前面的_mm256_permutevar8x32_epi32 (vpermd) 似乎在这里产生了巨大的变化。这可能是因为它的延迟很大(在 Ryzen 上是 8 个周期?)以及所有后续指令都直接依赖它。

我没有预先输入基值,而是在加法期间将其与在 128 位通道之间携带前缀和的过程中结合起来。

__m256i avx2_calc_offsets_2 (const u32 base, const __m256i counts)
{
    __m256i b, t, v;

    v = counts;

    // accumulate running totals within 128-bit sub-lanes
    v = _mm256_add_epi32 (v, _mm256_slli_si256 (v, 4));
    v = _mm256_add_epi32 (v, _mm256_slli_si256 (v, 8));

    // extract highest value in right-hand lane and combine with base offset
    t = _mm256_set1_epi32 (_mm256_extract_epi32 (v, 3));
    b = _mm256_set1_epi32 (base);
    t = _mm256_blend_epi32 (_mm256_add_epi32 (b, t), b, 0x0F);

    // combine with shifted running totals
    v = _mm256_add_epi32 (_mm256_slli_si256 (v, 4), t);

    return v;
}

Godbolt link

两个版本的组装对比:

avx2_calc_offsets:
        vmovdqa ymm1, YMMWORD PTR .LC0[rip]
        vpermd  ymm0, ymm1, ymm0
        vpinsrd xmm1, xmm0, edi, 0
        vinserti128     ymm0, ymm0, xmm1, 0x0
        vpslldq ymm1, ymm0, 4
        vpaddd  ymm0, ymm0, ymm1
        vpslldq ymm1, ymm0, 8
        vpaddd  ymm0, ymm0, ymm1
        vpsrldq xmm1, xmm0, 12
        vpbroadcastd    ymm1, xmm1
        vpaddd  ymm1, ymm1, ymm0
        vpblendd        ymm0, ymm1, ymm0, 15
        ret
avx2_calc_offsets_2:
        vpslldq ymm1, ymm0, 4
        vmovd   xmm2, edi
        vpaddd  ymm1, ymm1, ymm0
        vpbroadcastd    ymm2, xmm2
        vpslldq ymm0, ymm1, 8
        vpaddd  ymm1, ymm1, ymm0
        vpsrldq xmm0, xmm1, 12
        vpslldq ymm1, ymm1, 4
        vpbroadcastd    ymm0, xmm0
        vpaddd  ymm0, ymm2, ymm0
        vpblendd        ymm0, ymm0, ymm2, 15
        vpaddd  ymm0, ymm0, ymm1
        ret

总体而言,指令数量相同,但我认为微指令/延迟成本更低。

使用avx2_calc_offsets_2 的基准测试现在运行时间为 2.7 秒,比之前的版本快了大约 63%。


更新 1:GCC 将 avx2_calc_offsets_2 内联到基准循环进一步解释了性能的提高。正如 Peter predicts,对应于 _mm256_set1_epi32 (base)vmovd/ vpbroadcastd 指令确实被提升到循环外的单个负载中。

循环组装:

        ...
        // loop setup
        vmovdqa ymm2, YMMWORD PTR .LC5[rip] // hoisted load of broadcasted base
        vmovdqa ymm0, YMMWORD PTR [rbp-176]
        vmovdqa ymm1, YMMWORD PTR [rbp-144]
        mov     eax, 1000000000
        jmp     .L10
.L17:   // loop body
        vpslldq ymm1, ymm0, 4
        vpaddd  ymm0, ymm0, ymm1
        vpslldq ymm1, ymm0, 8
        vpaddd  ymm0, ymm0, ymm1
        vpsrldq xmm1, xmm0, 12
        vpslldq ymm0, ymm0, 4
        vpbroadcastd    ymm1, xmm1
        vpaddd  ymm1, ymm1, ymm2
        vpblendd        ymm1, ymm1, ymm2, 15
.L10:   // loop entry
        vpaddd  ymm0, ymm1, ymm0
        sub     rax, 1
        jne     .L17
        ...
.LC5:   // broadcasted `base`
        .long   900000000
        .long   900000000
        .long   900000000
        .long   900000000
        .long   900000000
        .long   900000000
        .long   900000000
        .long   900000000

更新 2: 专注于内联情况并将vpblendd 替换为__m128i 插入到归零__m256i 的高速通道中,然后添加到最终向量可进一步提高性能和代码大小 (thanks Peter )。

__m256i avx2_calc_offsets_3 (const u32 base, const __m256i counts)
{
    const __m256i z = _mm256_setzero_si256 ();
    const __m256i b = _mm256_set1_epi32 (base);

    __m256i v, t;
    __m128i lo;

    v = counts;

    // accumulate running totals within 128-bit sub-lanes
    v = _mm256_add_epi32 (v, _mm256_slli_si256 (v, 4));
    v = _mm256_add_epi32 (v, _mm256_slli_si256 (v, 8));

    // capture the max total in low-lane and broadcast into high-lane
    lo = _mm_shuffle_epi32 (_mm256_castsi256_si128 (v), _MM_SHUFFLE (3, 3, 3, 3));
    t  = _mm256_inserti128_si256 (z, lo, 1);
    
    // shift totals, add base and low-lane max 
    v = _mm256_slli_si256 (v, 4);
    v = _mm256_add_epi32 (v, b);
    v = _mm256_add_epi32 (v, t);

    return v;
}

Godbolt link

循环中内联版本的程序集现在如下所示:

        // compiled with GCC version 10.3: gcc -O2 -mavx2 ...
        // loop setup
        vmovdqa ymm2, YMMWORD PTR .LC5[rip] // load broadcasted base
        vmovdqa ymm0, YMMWORD PTR [rbp-176]
        vmovdqa ymm1, YMMWORD PTR [rbp-144]
        mov     eax, 1000000000
        vpxor   xmm3, xmm3, xmm3
        jmp     .L12
.L20:   // loop body
        vpslldq ymm1, ymm0, 4
        vpaddd  ymm0, ymm0, ymm1
        vpslldq ymm1, ymm0, 8
        vpaddd  ymm0, ymm0, ymm1
        vpshufd xmm1, xmm0, 255
        vpslldq ymm0, ymm0, 4
        vinserti128     ymm1, ymm3, xmm1, 0x1
.L12:   // loop entry
        vpaddd  ymm0, ymm0, ymm1
        vpaddd  ymm0, ymm0, ymm2
        sub     rax, 1
        jne     .L20

循环体只剩下 9 个向量指令:)。

在使用 -O3 时,GCC 中存在一个优化错误,其中在循环体的末尾插入了一个无关的 vmovdqa ymm0, ymm1,从而将基准性能降低了几个百分点。 (至少对于 GCC 版本 11.x、10.x 和 9.x)。


更新 3:另一个轻微的性能提升。如果我们在插入 128 位之前使用 SSE/128 位指令添加低通道的最大总数,我们shorten the critical path for v 允许更好地使用 shuffle 端口。

__m256i avx2_calc_offsets_4 (const u32 base, const __m256i counts)
{
    const __m256i b = _mm256_set1_epi32 (base);

    __m256i v, t;
    __m128i lo;

    v = counts;

    // accumulate running totals within 128-bit sub-lanes
    v = _mm256_add_epi32 (v, _mm256_slli_si256 (v, 4));
    v = _mm256_add_epi32 (v, _mm256_slli_si256 (v, 8));

    // capture the max total in low-lane, broadcast into high-lane and add to base
    lo = _mm_shuffle_epi32 (_mm256_castsi256_si128 (v), _MM_SHUFFLE (3, 3, 3, 3));
    lo = _mm_add_epi32 (_mm256_castsi256_si128 (b), lo);

    t = _mm256_inserti128_si256 (b, lo, 1);

    // shift totals, add base and low-lane max 
    v = _mm256_slli_si256 (v, 4);
    v = _mm256_add_epi32 (v, t);

    return v;
}

Godbolt link

.L23:   // loop body
        vpslldq ymm1, ymm0, 4
        vpaddd  ymm0, ymm0, ymm1
        vpslldq ymm1, ymm0, 8
        vpaddd  ymm0, ymm0, ymm1
        vpshufd xmm2, xmm0, 255
        vpslldq ymm1, ymm0, 4
.L14:   // loop entry
        vpaddd  xmm0, xmm2, xmm3
        vinserti128     ymm0, ymm4, xmm0, 0x1
        vpaddd  ymm0, ymm1, ymm0
        sub     rax, 1
        jne     .L23

这在我(非专家)眼中看起来相当理想,至少对于早期的 AVX2 芯片而言。基准测试时间缩短至约 2.17 秒。

奇怪的是,如果我通过删除以前的函数定义之一来减小源代码的大小,那么 GCC 10 和 11 就会出现问题,并在循环中插入 3 个(!)额外的 vmovdqa 指令(Godbolt )。结果是在我的基准测试中放缓了约 18%。 GCC 9.x 似乎不受影响。我不确定这里发生了什么,但这似乎是 GCC 优化器中的一个非常讨厌的错误。我会尽量减少它并提交一个错误。


使用avx2_calc_offsets_3 的基准测试现在以与标量版本相同的速度运行,这对我来说是一个胜利,因为它消除了出于性能原因跳转到标量代码的需要。

【讨论】:

  • 是的,通过使独立工作远离延迟关键路径来创建指令级并行性绝对是一件好事。在 Zen1 上,值得考虑只使用 128 位向量,因为每个向量都需要 2 微秒。但是对于未来 CPU 的性能,保持更宽的向量是有意义的。
  • 希望当avx2_calc_offsets_2 内联到调用循环中时,来自_mm256_set1_epi32 (base) 的 vmovd / vpbroadcastd 将被吊出循环。 (可能与版本 1 不同,您在 128 位向量的低元素中执行 vpinsrd。请注意,_mm256_insert_epi32 不是单指令内在函数;没有 vpinsrd ymm,它必须是通过插入或混合原始的高半部分来模拟。)
  • @Peter 事实上它确实提升了_mm256_set1_epi32 (base)
  • 我注意到循环仍然有 vpsrldq xmm1, xmm0, 12 / vpbroadcastd ymm1, xmm1 没有读取中间结果。广播__m256i 的第四个元素(#3)的另一种方法是使用单个vpermd。这在英特尔 CPU 上会更有效率。 (一个 3-cycle-latency shuffle uop,与 vpbroadcastd 相同)。但也许不是在 Zen1 上,其中vpermd ymm 是 3 微指令,具有 4c 吞吐量,因此可能会在 shuffle 端口上占用一个以上的周期(相对于 3 个总 shuffle 微指令:128 位字节移位为 1,xmm-> ymm 广播是另一个 2,写 ymm 的两半)。
  • vpermd 在 Zen2/3 上可能会更好,其中vpermd ymm 是 2 uop,2c 吞吐量,3c 延迟对于其中一个操作数。 (uops.info)。所以仍然不如英特尔。哦,在 Zen1 上,最好是 vpshufd xmm 在低通道内广播高 32 位块,然后只需 vinserti128 给它自己,这在 Zen1 上非常便宜,甚至比不处理 128- 的 CPU 上更便宜分别对半。所以lo = ...(_MM_SHUFFLE(3,3,3,3); / _mm256_set_m128i( lo, lo ) 或手动铸造和插入。 Zen1/2/3 上总共 2 个微指令,Zen1 上 2c 延迟。 (并且 insert 在 Zen1、IIRC 上的任何 vec ALU 端口上运行。)
猜你喜欢
  • 1970-01-01
  • 2013-10-29
  • 1970-01-01
  • 2017-10-02
  • 1970-01-01
  • 2017-09-16
  • 2020-06-27
  • 2021-12-27
相关资源
最近更新 更多