SSE:使用 _mm_add_epi32 没有看到加速

SSE: not seeing a speedup by using _mm_add_epi32

我希望 SSE 比不使用 SSE 更快。我需要添加一些额外的编译器标志吗?难道我没有看到加速,因为这是整数代码而不是浮点数?

invocation/output

$ make sum2
clang -O3 -msse -msse2 -msse3 -msse4.1 sum2.c ; ./a.out 123
n: 123
  SSE Time taken: 0 seconds 124 milliseconds
vector+vector:begin int: 1 5 127 0
vector+vector:end int: 0 64 66 68
NOSSE Time taken: 0 seconds 115 milliseconds
vector+vector:begin int: 1 5 127 0
vector+vector:end int: 0 64 66 68

编译器

$ clang --version
Apple LLVM version 9.0.0 (clang-900.0.37)
Target: x86_64-apple-darwin16.7.0
Thread model: posix

sum2.c

#include <stdlib.h>
#include <stdio.h>
#include <x86intrin.h>
#include <time.h>
#ifndef __cplusplus
#include <stdalign.h>   // C11 defines _Alignas().  This header defines alignas()
#endif
#define CYCLE_COUNT  10000

// add vector and return resulting value on stack
__attribute__((noinline)) __m128i add_iv(__m128i *a, __m128i *b) {
    return _mm_add_epi32(*a,*b);
}

// add int vectors via sse
__attribute__((noinline)) void add_iv_sse(__m128i *a, __m128i *b, __m128i *out, int N) {
    for(int i=0; i<N/sizeof(int); i++) { 
        //out[i]= _mm_add_epi32(a[i], b[i]); // this also works
        _mm_storeu_si128(&out[i], _mm_add_epi32(a[i], b[i]));
    } 
}

// add int vectors without sse
__attribute__((noinline)) void add_iv_nosse(int *a, int *b, int *out, int N) {
    for(int i=0; i<N; i++) { 
        out[i] = a[i] + b[i];
    } 
}

__attribute__((noinline)) void p128_as_int(__m128i in) {
    alignas(16) uint32_t v[4];
    _mm_store_si128((__m128i*)v, in);
    printf("int: %i %i %i %i\n", v[0], v[1], v[2], v[3]);
}

// print first 4 and last 4 elements of int array
__attribute__((noinline)) void debug_print(int *h) {
    printf("vector+vector:begin ");
    p128_as_int(* (__m128i*) &h[0] );
    printf("vector+vector:end ");
    p128_as_int(* (__m128i*) &h[32764] );
}

int main(int argc, char *argv[]) {
    int n = atoi (argv[1]);
    printf("n: %d\n", n);
    // sum: vector + vector, of equal length
    int f[32768] __attribute__((aligned(16))) = {0,2,4};
    int g[32768] __attribute__((aligned(16))) = {1,3,n};
    int h[32768] __attribute__((aligned(16))); 
    f[32765] = 33; f[32766] = 34; f[32767] = 35;
    g[32765] = 31; g[32766] = 32; g[32767] = 33;

    // 
    clock_t start = clock();
        for(int i=0; i<CYCLE_COUNT; ++i) {
            add_iv_sse((__m128i*)f, (__m128i*)g, (__m128i*)h, 32768);
        }
    int msec = (clock()-start) * 1000 / CLOCKS_PER_SEC;
    printf("  SSE Time taken: %d seconds %d milliseconds\n", msec/1000, msec%1000);
    debug_print(h);

    // process intense function again
    start = clock();
        for(int i=0; i<CYCLE_COUNT; ++i) {
            add_iv_nosse(f, g, h, 32768);
        }
    msec = (clock()-start) * 1000 / CLOCKS_PER_SEC;
    printf("NOSSE Time taken: %d seconds %d milliseconds\n", msec/1000, msec%1000);
    debug_print(h);

    return EXIT_SUCCESS;
}

看看 asm:clang -O2-O3 可能会自动矢量化 add_iv_nosse(检查重叠,因为您没有使用 int * restrict a 和依此类推)。

使用 -fno-tree-vectorize 禁用 auto 矢量化,而不阻止您使用内部函数。我建议 clang -march=native -mno-avx -O3 -fno-tree-vectorize 来测试我认为你想要测试的东西,标量整数与传统 SSE paddd。 (它适用于 gcc 和 clang。在 clang 中,AFAIK 它是 clang 特定 -fno-vectorize 的同义词。)

顺便说一句,在同一个可执行文件中对两者进行计时会伤害第一个,因为 CPU 不会立即达到全涡轮增压。在 CPU 全速运行之前,您可能已经进入了代码的定时部分。 (所以 运行 这几次背靠背, for i in {1..10}; do time ./a.out; done.

在 Linux 我会使用 perf stat -r5 ./a.out 到 运行 它 5 次与性能计数器(我会把它分开所以一个 运行 测试一个或另一个,所以我可以查看整个 运行.)

的性能计数器

代码审查:

您忘记 stdint.h uint32_t。我必须将其添加到 compile on Godbolt to see the asm。 (假设 clang-5.0 类似于您正在使用的 Apple clang 版本。IDK 如果 Apple 的 clang 暗示默认 -mtune= 选项,但这是有道理的,因为它仅针对 Mac。也是基线 SSSE3对于 x86-64 OS X 上的 64 位是有意义的。)

debug_print 不需要 noinline。另外,我建议 CYCLE_COUNT 使用不同的名称。这种情况下的周期让我想到了时钟周期,所以称它为 REP_COUNTREPEATS 或其他名称。

将数组放入 main 中的堆栈可能没问题。您确实初始化了两个输入数组(大部分为零,但添加性能与数据无关)。

这很好,因为让它们保持未初始化状态可能意味着每个数组的多个 4k 页被写时复制映射到相同的物理零页,因此您将获得比预期的 L1D 缓存命中数更多.

SSE2 循环应该是 L2 / L3 缓存带宽的瓶颈,因为工作集是 4 * 32kiB * 3 = 384 kiB,所以它大约是 Intel CPUs 中 256kiB L2 缓存的 1.5 倍。

clang 可能比您的手动内在循环更多地展开它的自动矢量化循环。这可能解释了更好的性能,因为如果您没有每个时钟获得 2 次加载 + 1 次存储,那么只有 16B 向量(不是 32B AVX2)可能不会使缓存带宽饱和。

更新:实际上循环开销非常大,有 3 个指针增量 + 一个循环计数器,并且只展开 2 来分摊。


自动矢量化循环:

.LBB2_12:                               # =>This Inner Loop Header: Depth=1
    movdqu  xmm0, xmmword ptr [r9 - 16]
    movdqu  xmm1, xmmword ptr [r9]         # hoisted load for 2nd unrolled iter
    movdqu  xmm2, xmmword ptr [r10 - 16]
    paddd   xmm2, xmm0
    movdqu  xmm0, xmmword ptr [r10]
    paddd   xmm0, xmm1
    movdqu  xmmword ptr [r11 - 16], xmm2
    movdqu  xmmword ptr [r11], xmm0
    add     r9, 32
    add     r10, 32
    add     r11, 32
    add     rbx, -8               # add / jne  macro-fused on SnB-family CPUs
    jne     .LBB2_12

所以它是 12 个融合域 uops,并且可以 运行 每 3 个时钟最多 2 个向量,在每个时钟 4 uops 的前端问题带宽上存在瓶颈。

它没有使用对齐加载,因为编译器没有内联到对齐已知的 main 中的信息,并且您不保证与 p = __builtin_assume_aligned(p, 16) 或任何内容对齐独立功能。对齐加载(或 AVX)会让 paddd 使用内存操作数而不是单独的 movdqu 加载。

手动矢量化循环使用对齐加载来节省前端微指令,但循环计数器的循环开销更大。

.LBB1_7:                                # =>This Inner Loop Header: Depth=1
    movdqa  xmm0, xmmword ptr [rcx - 16]
    paddd   xmm0, xmmword ptr [rax - 16]
    movdqu  xmmword ptr [r11 - 16], xmm0

    movdqa  xmm0, xmmword ptr [rcx]
    paddd   xmm0, xmmword ptr [rax]
    movdqu  xmmword ptr [r11], xmm0

    add     r10, 2               # separate loop counter
    add     r11, 32              # 3 pointer incrmeents
    add     rax, 32
    add     rcx, 32
    cmp     r9, r10              # compare the loop counter
    jne     .LBB1_7

所以它是 11 个融合域微指令。它应该比自动矢量化循环快 运行ning。您的计时方法可能导致了问题。

(除非混合加载和存储实际上使它不太理想。自动矢量化循环做了 4 次加载然后 2 次存储。实际上这可以解释它。你的数组是 4kiB 的倍数,并且可能都有相同的相对对齐。所以你可能在这里得到 4k 别名,这意味着 CPU 不确定商店是否与负载重叠。我认为有一个性能计数器你可以检查它。)


另请参阅 Agner Fog's microarch guide (and instruction tables + optimization guide, and other links in the 标记 wiki,尤其是英特尔的优化指南。

标签 wiki 中也有一些不错的 SSE/SIMD 初学者资料。