【问题标题】:Calculate 4d vectors average with SSE使用 SSE 计算 4d 向量平均值
【发布时间】:2015-07-10 05:38:03
【问题描述】:

我尝试加快计算放置在数组中的 4d 向量的平均值。这是我的代码:

#include <sys/time.h>
#include <sys/param.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <xmmintrin.h>

typedef float dot[4];
#define N 1000000

double gettime ()
{
    struct timeval tv;
    gettimeofday (&tv, 0);
    return (double)tv.tv_sec + (0.000001 * (double)tv.tv_usec);
}

void calc_avg1 (dot res, const dot array[], int n)
{
    int i,j;
    memset (res, 0, sizeof (dot));
    for (i = 0; i < n; i++)
    {
        for (j = 0; j<4; j++) res[j] += array[i][j];
    }
    for (j = 0; j<4; j++) res[j] /= n;
}

void calc_avg2 (dot res, const dot array[], int n)
{
    int i;
    __v4sf r = _mm_set1_ps (0.0);
    for (i=0; i<n; i++) r += _mm_load_ps (array[i]);
    r /= _mm_set1_ps ((float)n);
    _mm_store_ps (res, r);
}

int main ()
{
    void *space = malloc (N*sizeof(dot)+15);
    dot *array = (dot*)(((unsigned long)space+15) & ~(unsigned long)15);
    dot avg __attribute__((aligned(16)));
    int i;
    double time;

    for (i = 0; i < N; i++)
    {
        array[i][0] = 1.0*random();
        array[i][1] = 1.0*random();
        array[i][2] = 1.0*random();
    }
    time = gettime();
    calc_avg1 (avg, array, N);
    time = gettime() - time;
    printf ("%f\n%f %f %f\n", time, avg[0], avg[1], avg[2]);

    time = gettime();
    calc_avg2 (avg, array, N);
    time = gettime() - time;
    printf ("%f\n%f %f %f\n", time, avg[0], avg[1], avg[2]);
    return 0;
}

如您所见,calc_avg1 使用从 0 到 4 的简单循环,calc_avg2 将它们替换为 SSE 指令。我用 clang 3.4 编译这段代码:

cc -O2 -o test test.c

这里是 calc_avgX 函数的反汇编:

0000000000400860 <calc_avg1>:
  400860:   55                      push   %rbp
  400861:   48 89 e5                mov    %rsp,%rbp
  400864:   85 d2                   test   %edx,%edx
  400866:   0f 57 c0                xorps  %xmm0,%xmm0
  400869:   0f 11 07                movups %xmm0,(%rdi)
  40086c:   7e 42                   jle    4008b0 <calc_avg1+0x50>
  40086e:   48 83 c6 0c             add    $0xc,%rsi
  400872:   0f 57 c0                xorps  %xmm0,%xmm0
  400875:   89 d0                   mov    %edx,%eax
  400877:   0f 57 c9                xorps  %xmm1,%xmm1
  40087a:   0f 57 d2                xorps  %xmm2,%xmm2
  40087d:   0f 57 db                xorps  %xmm3,%xmm3
  400880:   f3 0f 58 5e f4          addss  -0xc(%rsi),%xmm3
  400885:   f3 0f 11 1f             movss  %xmm3,(%rdi)
  400889:   f3 0f 58 56 f8          addss  -0x8(%rsi),%xmm2
  40088e:   f3 0f 11 57 04          movss  %xmm2,0x4(%rdi)
  400893:   f3 0f 58 4e fc          addss  -0x4(%rsi),%xmm1
  400898:   f3 0f 11 4f 08          movss  %xmm1,0x8(%rdi)
  40089d:   f3 0f 58 06             addss  (%rsi),%xmm0
  4008a1:   f3 0f 11 47 0c          movss  %xmm0,0xc(%rdi)
  4008a6:   48 83 c6 10             add    $0x10,%rsi
  4008aa:   ff c8                   dec    %eax
  4008ac:   75 d2                   jne    400880 <calc_avg1+0x20>
  4008ae:   eb 0c                   jmp    4008bc <calc_avg1+0x5c>
  4008b0:   0f 57 c0                xorps  %xmm0,%xmm0
  4008b3:   0f 57 c9                xorps  %xmm1,%xmm1
  4008b6:   0f 57 d2                xorps  %xmm2,%xmm2
  4008b9:   0f 57 db                xorps  %xmm3,%xmm3
  4008bc:   f3 0f 2a e2             cvtsi2ss %edx,%xmm4
  4008c0:   f3 0f 5e dc             divss  %xmm4,%xmm3
  4008c4:   f3 0f 11 1f             movss  %xmm3,(%rdi)
  4008c8:   f3 0f 5e d4             divss  %xmm4,%xmm2
  4008cc:   f3 0f 11 57 04          movss  %xmm2,0x4(%rdi)
  4008d1:   f3 0f 5e cc             divss  %xmm4,%xmm1
  4008d5:   f3 0f 11 4f 08          movss  %xmm1,0x8(%rdi)
  4008da:   f3 0f 5e c4             divss  %xmm4,%xmm0
  4008de:   f3 0f 11 47 0c          movss  %xmm0,0xc(%rdi)
  4008e3:   5d                      pop    %rbp
  4008e4:   c3                      retq   
  4008e5:   66 66 2e 0f 1f 84 00    nopw   %cs:0x0(%rax,%rax,1)
  4008ec:   00 00 00 00 

00000000004008f0 <calc_avg2>:
  4008f0:   55                      push   %rbp
  4008f1:   48 89 e5                mov    %rsp,%rbp
  4008f4:   85 d2                   test   %edx,%edx
  4008f6:   0f 57 c0                xorps  %xmm0,%xmm0
  4008f9:   7e 10                   jle    40090b <calc_avg2+0x1b>
  4008fb:   89 d0                   mov    %edx,%eax
  4008fd:   0f 1f 00                nopl   (%rax)
  400900:   0f 58 06                addps  (%rsi),%xmm0
  400903:   48 83 c6 10             add    $0x10,%rsi
  400907:   ff c8                   dec    %eax
  400909:   75 f5                   jne    400900 <calc_avg2+0x10>
  40090b:   66 0f 6e ca             movd   %edx,%xmm1
  40090f:   66 0f 70 c9 00          pshufd $0x0,%xmm1,%xmm1
  400914:   0f 5b c9                cvtdq2ps %xmm1,%xmm1
  400917:   0f 5e c1                divps  %xmm1,%xmm0
  40091a:   0f 29 07                movaps %xmm0,(%rdi)
  40091d:   5d                      pop    %rbp
  40091e:   c3                      retq   
  40091f:   90                      nop    

结果如下:

> ./test
0.004287
1073864320.000000 1074018048.000000 1073044224.000000
0.003661
1073864320.000000 1074018048.000000 1073044224.000000

所以 SSE 版本快 1.17 倍。但是,当我尝试做看似相同的工作时,即计算数组中单精度标量的平均值(例如,此处所述SSE reduction of float vector),SSE 版本的运行速度提高了 3.32 倍。下面是 calc_avgX 函数的代码:

float calc_avg1 (const float array[], int n)
{
    int i;
    float avg = 0;
    for (i = 0; i < n; i++) avg += array[i];
    return avg / n;
}

float calc_avg3 (const float array[], int n)
{
    int i;
    __v4sf r = _mm_set1_ps (0.0);
    for (i=0; i<n; i+=4) r += _mm_load_ps (&(array[i]));
    r = _mm_hadd_ps (r, r);
    r = _mm_hadd_ps (r, r);
    return r[0] / n;
}

所以我的问题是:为什么在最后一个示例(计算单个浮点标量的平均值)中我从 SSE 中受益如此多,而在第一个示例中却没有(计算 4d 向量的平均值)?对我来说,这些工作几乎是一样的。如果我做错了,在第一个示例中加速计算的正确方法是什么?

更新: 如果您认为它是相关的,我还提供了第二个示例的反汇编,其中计算了标量的平均值(也使用 clang3.4 -O2 编译)。

0000000000400860 <calc_avg1>:
  400860:   55                      push   %rbp
  400861:   48 89 e5                mov    %rsp,%rbp
  400864:   85 d2                   test   %edx,%edx
  400866:   0f 57 c0                xorps  %xmm0,%xmm0
  400869:   0f 11 07                movups %xmm0,(%rdi)
  40086c:   7e 42                   jle    4008b0 <calc_avg1+0x50>
  40086e:   48 83 c6 0c             add    $0xc,%rsi
  400872:   0f 57 c0                xorps  %xmm0,%xmm0
  400875:   89 d0                   mov    %edx,%eax
  400877:   0f 57 c9                xorps  %xmm1,%xmm1
  40087a:   0f 57 d2                xorps  %xmm2,%xmm2
  40087d:   0f 57 db                xorps  %xmm3,%xmm3
  400880:   f3 0f 58 5e f4          addss  -0xc(%rsi),%xmm3
  400885:   f3 0f 11 1f             movss  %xmm3,(%rdi)
  400889:   f3 0f 58 56 f8          addss  -0x8(%rsi),%xmm2
  40088e:   f3 0f 11 57 04          movss  %xmm2,0x4(%rdi)
  400893:   f3 0f 58 4e fc          addss  -0x4(%rsi),%xmm1
  400898:   f3 0f 11 4f 08          movss  %xmm1,0x8(%rdi)
  40089d:   f3 0f 58 06             addss  (%rsi),%xmm0
  4008a1:   f3 0f 11 47 0c          movss  %xmm0,0xc(%rdi)
  4008a6:   48 83 c6 10             add    $0x10,%rsi
  4008aa:   ff c8                   dec    %eax
  4008ac:   75 d2                   jne    400880 <calc_avg1+0x20>
  4008ae:   eb 0c                   jmp    4008bc <calc_avg1+0x5c>
  4008b0:   0f 57 c0                xorps  %xmm0,%xmm0
  4008b3:   0f 57 c9                xorps  %xmm1,%xmm1
  4008b6:   0f 57 d2                xorps  %xmm2,%xmm2
  4008b9:   0f 57 db                xorps  %xmm3,%xmm3
  4008bc:   f3 0f 2a e2             cvtsi2ss %edx,%xmm4
  4008c0:   f3 0f 5e dc             divss  %xmm4,%xmm3
  4008c4:   f3 0f 11 1f             movss  %xmm3,(%rdi)
  4008c8:   f3 0f 5e d4             divss  %xmm4,%xmm2
  4008cc:   f3 0f 11 57 04          movss  %xmm2,0x4(%rdi)
  4008d1:   f3 0f 5e cc             divss  %xmm4,%xmm1
  4008d5:   f3 0f 11 4f 08          movss  %xmm1,0x8(%rdi)
  4008da:   f3 0f 5e c4             divss  %xmm4,%xmm0
  4008de:   f3 0f 11 47 0c          movss  %xmm0,0xc(%rdi)
  4008e3:   5d                      pop    %rbp
  4008e4:   c3                      retq   
  4008e5:   66 66 2e 0f 1f 84 00    nopw   %cs:0x0(%rax,%rax,1)
  4008ec:   00 00 00 00 

00000000004008d0 <calc_avg3>:
  4008d0:   55                      push   %rbp
  4008d1:   48 89 e5                mov    %rsp,%rbp
  4008d4:   31 c0                   xor    %eax,%eax
  4008d6:   85 f6                   test   %esi,%esi
  4008d8:   0f 57 c0                xorps  %xmm0,%xmm0
  4008db:   7e 0f                   jle    4008ec <calc_avg3+0x1c>
  4008dd:   0f 1f 00                nopl   (%rax)
  4008e0:   0f 58 04 87             addps  (%rdi,%rax,4),%xmm0
  4008e4:   48 83 c0 04             add    $0x4,%rax
  4008e8:   39 f0                   cmp    %esi,%eax
  4008ea:   7c f4                   jl     4008e0 <calc_avg3+0x10>
  4008ec:   66 0f 70 c8 01          pshufd $0x1,%xmm0,%xmm1
  4008f1:   f3 0f 58 c8             addss  %xmm0,%xmm1
  4008f5:   66 0f 70 d0 03          pshufd $0x3,%xmm0,%xmm2
  4008fa:   0f 12 c0                movhlps %xmm0,%xmm0
  4008fd:   f3 0f 58 c1             addss  %xmm1,%xmm0
  400901:   f3 0f 58 c2             addss  %xmm2,%xmm0
  400905:   0f 57 c9                xorps  %xmm1,%xmm1
  400908:   f3 0f 2a ce             cvtsi2ss %esi,%xmm1
  40090c:   f3 0f 5e c1             divss  %xmm1,%xmm0
  400910:   5d                      pop    %rbp
  400911:   c3                      retq   
  400912:   66 66 66 66 66 2e 0f    nopw   %cs:0x0(%rax,%rax,1)
  400919:   1f 84 00 00 00 00 00 

【问题讨论】:

  • 1) 我首先想到的是精确度:如果你只是不断地将一百万个数字相加然后除以它们,你会得到错误的结果。但它肯定更快...... :) 2) 我猜你也用-O2 编译了第二个例子。如果您向我们展示生成的程序集,我们可以确定编译器是否在第二种算法上做得更差。
  • 3) 你没有调用 srandom()。可能有一些数值异常会持续影响您的结果。 4)运行时间似乎很短(数千微秒)。这似乎不足以区分加速。操作系统可能会中断您的代码,并可能需要不同的时间来计算。添加更多迭代。
  • @bremby,如果我在第一个示例中调用 srandom (time (NULL)) 并增加 N(循环数),结果是相同的 - SSE 版本仅快 1.2 倍左右。如果您认为有帮助,我还为第二个示例提供了反汇编。

标签: c sse


【解决方案1】:

抱歉,这个答案有点冗长而杂乱无章。我运行了一些基准测试,但在考虑了其他尝试之后,我并没有花很长时间编辑早期的东西。

您的工作集为 15.25MiB (16MB)。通常要对这样的例程进行基准测试,您会多次平均较小的缓冲区,因此它适合缓存。您看不到慢版本和快版本之间的差异,因为差异被内存瓶颈隐藏了。

calc_avg1 根本不会自动矢量化(注意 addssss 表示标量,单精度,而不是 addps(压缩单精度))。我认为即使内联到 main 中它也不能自动向量化,因为它不能确定在第 4 个向量位置没有NaNs,这会导致标量代码没有的 FP 异常。我尝试使用 gcc 4.9.2 -O3 -march=native -ffast-math 和 clang-3.5 为 Sandybridge 编译它,但都没有运气。

即便如此,内联到main 的版本也只是运行速度稍慢,因为内存是瓶颈。 32 位负载几乎可以跟上 128b 负载,当达到主内存时。 (不过,非内联版本会很糟糕:每个+= 结果都存储到res 数组中,因为循环直接累积到可能有其他引用的内存中。所以它必须使每个操作都可见一家商店。这是您发布反汇编的版本,顺便说一句。通过-S -fverbose-asm 编译来帮助整理 main 的哪一部分。)

有点令人失望的是,clang 和 gcc 无法将 __v4sf 从 4 宽自动矢量化到 8 宽 AVX。

在对 calc_avgX 的调用包裹 for (int i=0; i&lt;4000 ; i++) 并将 N 减少到 10k 之后,gcc -O3 将 avg1 的内部循环变为:

  400690:       c5 f8 10 08             vmovups (%rax),%xmm1
  400694:       48 83 c0 20             add    $0x20,%rax
  400698:       c4 e3 75 18 48 f0 01    vinsertf128 $0x1,-0x10(%rax),%ymm1,%ymm1
  40069f:       c5 fc 58 c1             vaddps %ymm1,%ymm0,%ymm0
  4006a3:       48 39 d8                cmp    %rbx,%rax
  4006a6:       75 e8                   jne    400690 <main+0xe0>

$ (get CPU to max-turbo frequency) && time ./a.out
0.016515
1071570752.000000 1066917696.000000 1073897344.000000
0.032875
1071570944.000000 1066916416.000000 1073895680.000000

这很奇怪;我不知道为什么它不只使用 32B 负载。它确实使用 32B vaddps,这是处理适合 L2 缓存的数据集时的瓶颈。

IDK 为什么当它在另一个循环中时它设法自动矢量化内部循环。请注意,此适用于内联到 main 的版本。可调用版本仍然是纯标量。另请注意,只有 gcc 对此进行了管理。铿锵 3.5 没有。也许 gcc 知道它会以返回零缓冲区的方式使用 malloc(因此它不必担心第 4 个元素中的 NaNs)?

我也很惊讶当一切都适合缓存时,clang 的非矢量化 avg1 并不慢。 N=10000,重复计数 = 40k。

3.3GHz SNB i5 2500k, max turbo = 3.8GHz.
avg1: 0.350422s:  clang -O3 -march=native (not vectorized.  loop of 6 scalar addss with memory operands)
avg2: 0.320173s:  clang -O3 -march=native
avg1: 0.497040s:  clang -O3 -march=native -ffast-math (haven't looked at asm to see what happened)

avg1: 0.160374s:  gcc -O3 -march=native (256b addps, with 2 128b loads)
avg2: 0.321028s:  gcc -O3 -march=native (128b addps with a memory operand)

avg2: ~0.16:  clang, unrolled with 2 dependency chains to hide latency (see below).
avg2: ~0.08:  unrolled with 4 dep chains
avg2: ~0.04:  in theory unrolled-by-4 with 256b AVX.  I didn't try unrolling the one gcc auto-vectorized with 256b addps

因此,最大的惊喜是纯标量 clang avg1 代码跟上 avg2。也许循环携带的依赖链是更大的瓶颈?

perf 对于 clang 的非向量化 avg1 显示每个周期 1.47 个 insns,这可能会使端口 1 上的 FP 添加单元饱和。(大多数循环指令都是添加)。

但是,avg2 使用 128b addps 和内存操作数,每个周期只能获得 0.58 个 insns。将数组大小再减小 10 倍,即N=1000,每个周期获得 0.60 个 insns,这可能是因为在序言/结尾花费了更多时间。所以我认为循环携带的依赖链存在一个严重的问题。 clang 将循环展开 4,但只使用一个累加器。该循环有 7 条指令,可解码为 10 条微指令。 (每个vaddps 是2,因为它与具有2 寄存器寻址模式的内存操作数一起使用,防止微融合。cmpjne 宏熔丝)。 http://www.brendangregg.com/perf.html 表示UOPS_DISPATCHED.COREperf 事件是r2b1,所以:

$ perf stat -d -e cycles,instructions,r2b1 ./a.out
0.031793
1053298112.000000 1052673664.000000 1116960256.000000

 Performance counter stats for './a.out':

       118,453,541      cycles
        71,181,299      instructions              #    0.60  insns per cycle
       102,025,443      r2b1  # this is uops, but perf doesn't have a nice name for it
        40,256,019      L1-dcache-loads
            21,254      L1-dcache-load-misses     #    0.05% of all L1-dcache hits
             9,588      LLC-loads
                 0      LLC-load-misses:HG        #    0.00% of all LL-cache hits

       0.032276233 seconds time elapsed

这证实了我 7:10 对 uops 分析的指示。这实际上与这里的性能问题无关:循环运行方式慢于每循环 4 微秒的上限。更改内部循环以获得两个独立的 dep 链使吞吐量翻倍(60M 周期而不是 117M,但 81M insns 而不是 71M):

for (i=0; i<n-1; i+=2) {  // TODO: make sure the loop end condition is correct
   r0 += _mm_load_ps (array[i]);
   r1 += _mm_load_ps (array[i+1]);
}
r0 += r1;

展开 4(在循环结束时合并 4 个累加器)再次使性能翻倍。 (低至 42M 周期、81M insns、112M uops。)内部循环有 4x vaddps -0x30(%rcx),%xmm4,%xmm4(和类似的)、2x addcmpjl。这种形式的vaddps 应该是微融合的,但我仍然看到比指令多得多的微指令,所以我猜r2b1 计算未融合域微指令。 (Linux perf 没有针对特定平台硬件事件的好的文档)。再次启动N,以确保它是完全控制所有计数的最内层循环,我看到 uop:insn 比率为 1.39,与 8 个 insn、11 uops (1.375) 匹配(将vaddps 计为 2 ,但将cmp + jl 计为一个)。我找到了http://www.bnikolic.co.uk/blog/hpc-prof-events.html,它有完整的受支持性能事件列表,包括它们的 Sandybridge 代码。 (以及有关如何为任何其他 CPU 转储表的说明)。 (在每个块中查找 Code: 行。您需要一个 umask 字节,然后是代码,作为 perf 的 arg。)

# a.out does only avg2, as an unrolled-by-4 version.
$ perf stat -d -e cycles,instructions,r14a1,r2b1,r10e,r2c2,r1c2 ./a.out
0.011331
1053298752.000000 1052674496.000000 1116959488.000000

 Performance counter stats for './a.out':

        42,250,312      cycles                    [34.11%]
        56,103,429      instructions              #    1.33  insns per cycle
        20,864,416      r14a1 # UOPS_DISPATCHED_PORT: 0x14=port2&3 loads
       111,943,380      r2b1 # UOPS_DISPATCHED: (2->umask 00 -> this core, any thread).
        72,208,772      r10e # UOPS_ISSUED: fused-domain
        71,422,907      r2c2 # UOPS_RETIRED: retirement slots used (fused-domain)
       111,597,049      r1c2 # UOPS_RETIRED: ALL (unfused-domain)
                 0      L1-dcache-loads
            18,470      L1-dcache-load-misses     #    0.00% of all L1-dcache hits
             5,717      LLC-loads                                                    [66.05%]
                 0      LLC-load-misses:HG        #    0.00% of all LL-cache hits

       0.011920301 seconds time elapsed

是的,看起来这可以计算融合域和非融合域的微指令!

展开 8 根本没有帮助:仍然是 4200 万个周期。 (但由于循环开销减少,低至 61M insns 和 97M uops)。整洁,clang 使用 sub $-128, %rsi 而不是 add,因为 -128 适合 imm8,但 +128 不适合。所以我想展开 4 足以使 FP 添加端口饱和。

至于返回单个浮点数而不是向量的 1avg 函数,clang 没有自动向量化第一个函数,但 gcc 可以。它发出一个巨大的序言和结语来做标量和,直到它到达一个对齐的地址,然后在一个小循环中执行 32B AVX vaddps。您说您发现它们的速度差异更大,但是您是否可能使用较小的缓冲区进行测试?这将说明矢量代码与非矢量代码相比有很大的加速。

【讨论】:

  • 谢谢你这么好的回答。所以它毕竟是循环展开。我为我的第二个示例(标量的平均值)写了另一个 testcalc_avg2 的工作速度几乎与 calc_avg3 一样快,但那里没有向量加法 (addps)。 Here 是反汇编。我之前怀疑并检查过,但使用了一个累加器而不是 4 个,这是我现在知道的错误方式。顺便说一句,如果您还指定了 -ffast-math,Clang 3.4 会对 calc_avg1 进行自动矢量化。我认为仅 -O2 就足以自动矢量化整数数学。
  • 给你的标量平均函数起不同的名字,比如scalar_avg2。如果您使用太大的缓冲区进行测试,它只会以接近相同速度scalar_avg3 运行,因此内存带宽是一切的瓶颈。 addpsaddss 占用相同数量的 CPU 执行资源,但工作量是其 4(或 8)倍。对于使用这些函数的真实代码,请查看“缓存阻塞”来构建您的算法,以便在缓存中的一块内存中完成所有有用的事情。
  • 也许在未来,编译器会足够聪明,可以在展开时使用多个累加器。由于 FP 数学的精度有限,因此结果并不总是相同的(就像向量加法与最后的水平和之间的区别一样)。所以编译器需要被告知没关系(-ffast-math 或其他东西)。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2017-09-23
  • 2018-11-05
  • 2014-02-04
相关资源
最近更新 更多