SIMD异或操作是否不如整数异或操作效率高?

8

我有一个任务,需要计算数组中字节的异或和:

X = char1 XOR char2 XOR char3 ... charN;

我正在尝试并行化它,使用 __m128 进行异或运算。这样可以提高速度4倍。 此外,为了重新检查算法,我使用 int 类型。这也可以提高速度4倍。 测试程序有100行,无法缩短,但很简单:

#include "xmmintrin.h" // simulation of the SSE instruction
#include <ctime>

#include <iostream>
using namespace std;

#include <stdlib.h> // rand

const int NIter = 100;

const int N = 40000000; // matrix size. Has to be dividable by 4.
unsigned char str[N] __attribute__ ((aligned(16)));

template< typename T >
T Sum(const T* data, const int N)
{
    T sum = 0;
    for ( int i = 0; i < N; ++i )
      sum = sum ^ data[i];
    return sum;
}

template<>
__m128 Sum(const __m128* data, const int N)
{
    __m128 sum = _mm_set_ps1(0);
    for ( int i = 0; i < N; ++i )
        sum = _mm_xor_ps(sum,data[i]);
    return sum;
}

int main() {

    // fill string by random values
  for( int i = 0; i < N; i++ ) {
    str[i] = 256 * ( double(rand()) / RAND_MAX ); // put a random value, from 0 to 255
  } 

    /// -- CALCULATE --

    /// SCALAR

  unsigned char sumS = 0;
  std::clock_t c_start = std::clock();
  for( int ii = 0; ii < NIter; ii++ )
    sumS = Sum<unsigned char>( str, N );
  double tScal = 1000.0 * (std::clock()-c_start) / CLOCKS_PER_SEC;

    /// SIMD

  unsigned char sumV = 0;

  const int m128CharLen = 4*4;
  const int NV = N/m128CharLen;

  c_start = std::clock();
  for( int ii = 0; ii < NIter; ii++ ) {
    __m128 sumVV = _mm_set_ps1(0);
    sumVV = Sum<__m128>( reinterpret_cast<__m128*>(str), NV );
    unsigned char *sumVS = reinterpret_cast<unsigned char*>(&sumVV);

    sumV = sumVS[0];
    for ( int iE = 1; iE < m128CharLen; ++iE )
      sumV ^= sumVS[iE];
  }
  double tSIMD = 1000.0 * (std::clock()-c_start) / CLOCKS_PER_SEC;

    /// SCALAR INTEGER

  unsigned char sumI = 0;

  const int intCharLen = 4;
  const int NI = N/intCharLen;

  c_start = std::clock();
  for( int ii = 0; ii < NIter; ii++ ) {
    int sumII = Sum<int>( reinterpret_cast<int*>(str), NI );
    unsigned char *sumIS = reinterpret_cast<unsigned char*>(&sumII);

    sumI = sumIS[0];
    for ( int iE = 1; iE < intCharLen; ++iE )
      sumI ^= sumIS[iE];
  }
  double tINT = 1000.0 * (std::clock()-c_start) / CLOCKS_PER_SEC;

    /// -- OUTPUT --

  cout << "Time scalar: " << tScal << " ms " << endl;
  cout << "Time INT:   " << tINT << " ms, speed up " << tScal/tINT << endl;
  cout << "Time SIMD:   " << tSIMD << " ms, speed up " << tScal/tSIMD << endl;

  if(sumV == sumS && sumI == sumS )
    std::cout << "Results are the same." << std::endl;
  else
    std::cout << "ERROR! Results are not the same." << std::endl;

  return 1;
}

典型结果:
[10:46:20]$ g++ test.cpp -O3 -fno-tree-vectorize; ./a.out
Time scalar: 3540 ms 
Time INT:   890 ms, speed up 3.97753
Time SIMD:   280 ms, speed up 12.6429
Results are the same.
[10:46:27]$ g++ test.cpp -O3 -fno-tree-vectorize; ./a.out
Time scalar: 3540 ms 
Time INT:   890 ms, speed up 3.97753
Time SIMD:   280 ms, speed up 12.6429
Results are the same.
[10:46:35]$ g++ test.cpp -O3 -fno-tree-vectorize; ./a.out
Time scalar: 3640 ms 
Time INT:   880 ms, speed up 4.13636
Time SIMD:   290 ms, speed up 12.5517
Results are the same.

正如您所见,int版本的效果非常理想,但是simd版本的速度损失了25%,这是稳定的。我尝试更改数组大小,但这没有帮助。

另外,如果我切换到-O2,simd版本的速度会下降75%:

[10:50:25]$ g++ test.cpp -O2 -fno-tree-vectorize; ./a.out
Time scalar: 3640 ms 
Time INT:   880 ms, speed up 4.13636
Time SIMD:   890 ms, speed up 4.08989
Results are the same.
[10:51:16]$ g++ test.cpp -O2 -fno-tree-vectorize; ./a.out
Time scalar: 3640 ms 
Time INT:   900 ms, speed up 4.04444
Time SIMD:   880 ms, speed up 4.13636
Results are the same.

请问有人能帮我解释一下这个吗?

额外信息:

  1. I have g++ (GCC) 4.7.3; Intel(R) Xeon(R) CPU E7-4860

  2. I use -fno-tree-vectorize to prevent auto vectorization. Without this flag with -O3 the expected speed up is 1, since the task is simple. This is what I get:

    [10:55:40]$ g++ test.cpp -O3; ./a.out
    Time scalar: 270 ms 
    Time INT:   270 ms, speed up 1
    Time SIMD:   280 ms, speed up 0.964286
    Results are the same.
    

    but with -O2 result is still strange:

    [10:55:02]$ g++ test.cpp -O2; ./a.out
    Time scalar: 3540 ms 
    Time INT:   990 ms, speed up 3.57576
    Time SIMD:   880 ms, speed up 4.02273
    Results are the same.
    
  3. When I change

    for ( int i = 0; i < N; i+=1 )
      sum = sum ^ data[i];
    

    to equivalent of:

    for ( int i = 0; i < N; i+=8 )
      sum = (data[i] ^ data[i+1]) ^ (data[i+2] ^ data[i+3]) ^ (data[i+4] ^ data[i+5]) ^ (data[i+6] ^ data[i+7]) ^ sum;
    

    i do see improvment in scalar speed by factor of 2. But I don't see improvements in speed up. Before: intSpeedUp 3.98416, SIMDSpeedUP 12.5283. After: intSpeedUp 3.5572, SIMDSpeedUP 6.8523.


你能否打开-vec-report3标志并查看循环是否真正向量化了? - arunmoezhi
@arunmoezhi,你是什么意思?哪些循环需要进行向量化?我的gcc不认识-vec-report3。 - klm123
返回标量版本。为什么编译器没有进行优化? - arunmoezhi
@arunmoezhi,是因为使用了“-fno-tree-vectorize”标志。 - klm123
尝试使用 _mm_load_si128 - user3528438
4个回答

5
我认为您可能已经触及到内存带宽的上限。这可能是在使用-O3时只能实现12.6倍加速而不是16倍加速的原因。
然而,当进行内联时,gcc 4.7.3会将一个无用的存储指令放入小型未展开向量循环中,但不会放入标量或int SWAR循环中(见下文),因此这可能是解释的原因。 -O2 中向量吞吐量的降低完全是由于 gcc 4.7.3 在那方面做得更差,将累加器发送到内存中进行往返存储转发。
有关该额外存储指令影响的分析,请参见末尾的部分。

简述: Nehalem对于循环展开的要求比SnB系列更高,而gcc在gcc5中在SSE代码生成方面有了重大改进。

通常情况下,像这样的批量异或操作使用_mm_xor_si128而不是_mm_xor_ps


内存带宽。

N非常大(40MB),因此内存/缓存带宽是一个问题。Xeon E7-4860是一款32纳米尼哈伦微体系结构,每个核心具有256kiB的L2缓存和24MiB的共享L3缓存。它具有四通道内存控制器,支持DDR3-1066(相比于典型桌面CPU如SnB或Haswell的双通道DDR3-1333或DDR3-1600)。

一个典型的3GHz桌面Intel CPU理论上可以从DRAM维持大约8B /周期的负载带宽。(例如,i5-4670使用双通道DDR3-1600的理论最大内存BW为25.6GB/s)。在实际单线程中实现这一点可能行不通,特别是当使用整数4B或8B负载时。对于像2267MHz Nehalem Xeon这样较慢的CPU,具有四通道(但速度较慢)的内存,每个时钟16B可能已经接近极限。


我查看了在godbolt上使用gcc 4.7.3编译的{{link1:未更改的原始代码} }的汇编代码。

独立版本看起来很好(但内联版本不是,请见下文!),其中循环为

## float __vector Sum(...) non-inlined version
.L3:
        xorps   xmm0, XMMWORD PTR [rdi]
        add     rdi, 16
        cmp     rdi, rax
        jne     .L3

那是3个融合域uop,应该每个时钟周期迭代一次进行发射和执行。实际上,它无法做到这一点,因为“xorps”和融合比较和分支都需要端口5。
N很大,因此笨拙的逐字符水平XOR的开销并不影响性能,即使gcc 4.7为其生成了糟糕的代码(多个“sumVV”的副本存储到堆栈中等等)。 (有关使用SIMD将其减少到4B的方法,请参见 Fastest way to do horizontal float vector sum on x86。如果您没有使用AVX,则将数据移动到整数寄存器中并在那里使用整数移位/异或来处理最后的4B-> 1B可能更快,编译器可能能够利用低位和高8位组件寄存器“al / ah”)
向量循环被愚蠢地内联了:
## float __vector Sum(...) inlined into main at -O3
.L12:
        xorps   xmm0, XMMWORD PTR [rdx]
        add     rdx, 16
        cmp     rdx, rbx
        movaps  XMMWORD PTR [rsp+64], xmm0
        jne     .L12

它在每次迭代中都存储累加器,而不仅仅是在最后一次迭代之后!由于gcc没有/没有默认优化宏融合,因此它甚至没有将cmp/jne放在一起,以便它们可以在英特尔和AMD CPU上融合成单个uop,因此循环具有5个融合域uop。这意味着它每2个时钟周期只能发出一个,如果Nehalem前端/循环缓冲区与Sandybridge循环缓冲区类似。uop分为4组,并且预测的取走分支会结束一个问题块。因此,它以4/1/4/1 uop模式发行,而不是4/4/4/4。这意味着我们最多可以每2个时钟周期获得一个16B负载的持续吞吐量。
"

-mtune=core2 可能会使吞吐量翻倍,因为它将 cmp/jne 放在一起。存储可以微聚合成单个 uop,xorps 与内存源操作数也可以如此。那么旧的 gcc 不支持 -mtune=nehalem 或更通用的 -mtune=intel。 Nehalem 可以每个时钟周期维持一个加载和一个存储,但显然最好不要在循环中进行存储。

"

使用-O2编译甚至会使得gcc版本的代码更糟

内联的内部循环现在从内存中加载累加器并将其存储,因此在累加器所在的循环传递依赖项中存在存储转发回路:

## float __vector Sum(...) inlined at -O2
.L14:
        movaps  xmm0, XMMWORD PTR [rsp+16]   # reload sum
        xorps   xmm0, XMMWORD PTR [rdx]      # load data[i]
        add     rdx, 16
        cmp     rdx, rbx
        movaps  XMMWORD PTR [rsp+16], xmm0   # spill sum
        jne     .L14

至少使用 -O2,水平字节异或编译为仅带有整数字节循环的普通循环,而不会在堆栈上溢出 15 份 xmm0 的副本。

这只是完全愚蠢的代码,因为我们没有让对 sumVV 的引用/指针逃逸函数,所以没有其他线程可以观察到累加器正在进行中。即使如此,也没有同步阻止 gcc 在寄存器中累加并存储最终结果。非内联版本仍然可以。

那个严重的性能 bug 直到 gcc 4.9.2 都存在,使用 -O2 -fno-tree-vectorize,即使我将函数从 main 重命名为其他名称,以便它充分受益于 gcc 的优化工作。(不要将微基准测试放在 main 中,因为 gcc 将其标记为“冷”,并且优化较少。)

gcc 5.1 为 template<> __m128 Sum(const __m128* data, const int N) 的内联版本生成良好的代码。我没有检查 clang。

这个额外的循环依赖链几乎肯定是向量版本在使用-O2时速度提升较小的原因。 也就是说,这是一个编译器的错误,在gcc5中已经修复。

带有-O2的标量版本为

.L12:
        xor     bpl, BYTE PTR [rdx]       # sumS, MEM[base: D.27594_156, offset: 0B]
        add     rdx, 1    # ivtmp.135,
        cmp     rdx, rbx  # ivtmp.135, D.27613
        jne     .L12      #,

所以它基本上是最优的。 Nehalem每个时钟周期只能维持一个负载,因此没有必要使用更多的累加器。

int版本是

.L18:
        xor     ecx, DWORD PTR [rdx]      # sum, MEM[base: D.27549_296, offset: 0B]
        add     rdx, 4    # ivtmp.135,
        cmp     rbx, rdx  # D.27613, ivtmp.135
        jne     .L18      #,

所以,这是你所期望的。它应该在每个时钟周期内保持负载。


对于每个时钟可以支持两个负载的微体系结构(Intel SnB家族和AMD),您应该使用两个累加器。 编译器实现的-funroll-loops通常只会减少循环开销,而不会引入多个累加器。 :(
您希望编译器生成以下代码:
        xorps   xmm0, xmm0
        xorps   xmm1, xmm1
.Lunrolled:
        pxor    xmm0, XMMWORD PTR [rdi]
        pxor    xmm1, XMMWORD PTR [rdi+16]
        pxor    xmm0, XMMWORD PTR [rdi+32]
        pxor    xmm1, XMMWORD PTR [rdi+48]
        add     rdi, 64
        cmp     rdi, rax
        jb  .Lunrolled

        pxor    xmm0, xmm1

        # horizontal xor of xmm0
        movhlps xmm1, xmm0
        pxor    xmm0, xmm1
        ...

Urolling by two (pxor / pxor / add / cmp/jne)会形成一个循环,每1c可以执行一次迭代,但需要四个ALU执行端口。只有Haswell和更新版本的处理器才能跟上这种吞吐量。(或者AMD Bulldozer系列,因为向量和整数指令不会竞争执行端口,但相反,只有两个整数ALU管道,所以他们只能通过混合代码来最大化指令吞吐量。)
这个展开4次在循环中有6个融合域uops,所以它可以轻松地每2c执行一次,并且SnB/IvB可以跟上每个时钟的三个ALU uops。
请注意,在Intel Nehalem到Broadwell中,pxor_mm_xor_si128)的吞吐量比xorps_mm_xor_ps)更好,因为它可以在更多的执行端口上运行。如果您正在使用AVX但不是AVX2,那么使用256b _mm256_xor_ps而不是_mm_xor_si128可能是有意义的,因为_mm256_xor_si256需要AVX2。

如果不是内存带宽的问题,为什么速度只提高了12.6倍?

Nehalem的循环缓冲区(也称为Loop Stream Decoder或LSD)存在“一个时钟延迟”(根据Agner Fog的微体系结构pdf),因此,如果我理解得正确,具有N个uop的循环需要ceil(N/4.0) + 1个周期才能从循环缓冲区中发出。他没有明确说明如果不足4个uop会发生什么,但SnB系列CPU是这样工作的(除以4向上取整)。它们不能从下一次迭代的分支后发出uops。我试图在谷歌上搜索关于Nehalem的信息,但是找不到有用的。

因此,charint循环可能以每2个时钟运行一次加载和xor(因为它们是3个融合域uop)。循环展开可以将它们的吞吐量提高约两倍,直到它们饱和加载端口。 SnB系列CPU没有那个一个时钟延迟,因此它们可以在每次迭代中以一个时钟运行小型循环。

使用性能计数器或至少微基准测试以确保您的绝对吞吐量符合预期是个好主意。仅凭相对测量,如果没有这种分析,您无法得知是否将一半性能留在桌面上。
向量-O3循环是5个融合域uop,因此应该需要三个时钟周期才能发出。做16倍的工作,但每次迭代需要3个周期而不是2个周期,将使我们的速度提高16 * 2/3 = 10.66。实际上,我们的表现比这还要好,我不明白为什么。
我要在这里停下来,而不是挖出一台Nehalem笔记本电脑并运行实际基准测试,因为Nehalem已经过时,无法在这个详细级别上进行调整。
您可能使用了-mtune=core2编译吗?或者您的gcc具有不同的默认tune设置,并且没有拆分比较和分支吗?在这种情况下,前端可能不是瓶颈,吞吐量可能受到内存带宽或内存误差依赖关系的轻微限制:
核心2和尼哈兰处理器在具有相同集合和偏移量的内存地址之间存在虚假依赖关系,即距离为4KB的倍数。

这可能会导致管道每4k出现短暂的气泡。


在我检查 Nehalem 的循环缓冲区并发现每个循环额外的 1c 之前,我有一个理论,现在我非常自信这是错误的:我认为循环中额外的存储 uop 会使速度减半,所以你会看到大约 6 倍的加速。然而,也许有一些执行瓶颈使得前端发行吞吐量不是瓶颈?或者 Nehalem 的循环缓冲区与 SnB 的不同,并且不会在预测的分支处结束一个问题组。如果它的 5 个融合域 uops 可以以一致的每时钟 4 个的速度发出,则对于 -O3 向量循环,这将给出一个吞吐量加速比为 16 * 4/5 = 12.8。这非常好地匹配了实验数据的 12.6429 加速比,稍微低于 12.8 是可以预期的,因为带宽要求增加(当预取器落后时偶尔会发生缓存未命中停顿)。 (标量循环仍然只运行每个时钟的一个迭代:发出多个迭代超过每个时钟的一个负载意味着它们瓶颈在每个时钟的一个负载上,以及 1 个周期的 xor 循环依赖。)

这不可能是正确的,因为 Nehalem 中的 xorps 只能在端口5上运行,与融合比较和分支相同。因此,非展开的向量循环不可能以每2个周期多于一次迭代的速度运行。

根据 Agner Fog 的表格,在 Nehalem 上,条件分支的吞吐量为每2个周期1个,进一步证实了这是一个错误的理论。


更正/更新:执行uop计数不是处理器宽度倍数的循环时,性能是否会降低?表明,后来的Sandybridge系列CPU(至少从Haswell开始)可以在更接近1.25个周期的迭代中运行5个uop循环,比Sandybridge上的2要好得多。SO不允许我编辑,除非将goo.gl短链接更新为https://godbolt.org/。 - Peter Cordes

4

SSE2 在完全并行数据操作时效果最佳。例如:

for (int i = 0 ; i < N ; ++i)
    z[i] = _mm_xor_ps(x[i], y[i]);

但在你的情况下,循环的每个迭代都依赖于上一个迭代的输出。这被称为依赖链。简而言之,这意味着每个连续的异或运算都必须等待前一个进程的整个延迟才能继续进行,从而降低了吞吐量。


1
xor 指令的延迟为 1 个 CPU 时钟周期,而 xorps 的延迟为 4 个时钟周期。 - jaket
3
他可能需要展开4次,并拥有4个聚合值,而不是一个。不需要结果数组。 - usr
@usr,您的意思是64个聚合值而不是我现在拥有的16个吗? - klm123
@klm123 - Agner Fog是那个人。http://www.agner.org/optimize/optimizing_assembly.pdf - 第9章 - jaket
@klm:这里的一个主要因素是编译器错误(向量循环中的存储指令)。 Nehalem 喜欢一点循环展开,所以更新版本表现更好。然而,拥有两个或多个单独的循环依赖链比每个循环迭代将一堆向量减少到单个组合向量并将其异或到单个累加器中要简单得多。它确实引入了更多的并行性,但所有这些新的 dep 链都以 mov 加载开始,而不是与内存操作数进行 xor - Peter Cordes
显示剩余7条评论

0
jaket已经解释了可能的问题:依赖链。我也来试试:
template<>
__m128 Sum(const __m128* data, const int N)
{
    __m128 sum1 = _mm_set_ps1(0);
    __m128 sum2 = _mm_set_ps1(0);
    for (int i = 0; i < N; i += 2) {
        sum1 = _mm_xor_ps(sum1, data[i + 0]);
        sum2 = _mm_xor_ps(sum2, data[i + 1]);
    }
    return _mm_xor_ps(sum1, sum2);
}

现在这两条车道之间完全没有依赖关系。尝试将其扩展到更多的车道(例如4个)。

您还可以尝试使用这些指令的整数版本(使用__m128i)。我不理解它们之间的区别,所以这只是一个提示。


这可能有助于解决gcc4 -O2优化错误,但否则无法解释它。 _mm_xor_ps的延迟为1c。展开也会有所帮助(特别是在Nehalem上),即使没有多个累加器。使用带有两个累加器的_mm_xor_si128应该可以为后来的CPU生成更好的代码,理论上每个时钟维持两个16B xors。请参阅我的答案。 - Peter Cordes
依赖链是否会破坏异或指令之间的 ILP?通常情况下,它们中的多个可以同时运行。这难道不正是您的答案所称的“循环依赖链”吗?尽管如此,我确实喜欢您的答案中的内存带宽分析和其他所有内容。对于这个专家回答来说,+1 太少了。@PeterCordes - usr
你可以通过拥有多个独立的依赖链来获得ILP,每个链都有自己的累加器。这正是你的答案使用sum1sum2所做的。sum1 ^= data[i]可以与sum2 ^= data[i+1]同时进行。这种技术在类似FMA的情况下更常用,Haswell上的FMA具有5个时钟周期的延迟和每0.5个时钟周期的吞吐量,因此如果你正在进行归约(或任何其他具有迭代间依赖性的操作,只要操作是可结合的,使得重新排序后的最终答案相同),则需要10个累加器来保持10个FMA在运行中。 - Peter Cordes

0
实际上,gcc编译器是针对SIMD进行优化的。这就解释了为什么当你使用-O2时性能会显著下降。你可以使用-O1重新检查。

我使用“-fno-tree-vectorize”来防止自动向量化。 - klm123
GCC不支持具有依赖链的SIMD优化。依赖链是使用GCC内置函数展开的主要情况之一。Clang展开四次,ICC通常展开两次(但在某些情况下我见过更多)。MSVC可能会展开两次,但我不太确定。 - Z boson

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接