SIMD XOR操作不如Integer XOR有效吗?

时间:2014-04-29 08:54:08

标签: c++ performance parallel-processing simd seeding

我有一个任务来计算数组中的xor-sum字节:

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. 我有g ++(GCC)4.7.3;英特尔(R)Xeon(R)CPU E7-4860

  2. 我使用-fno-tree-vectorize来防止自动矢量化。没有这个标志与-O3 预计加速为1,因为任务很简单。这就是我得到的:

    [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.
    

    但是-O2结果仍然很奇怪:

    [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. 我改变时

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

    相当于:

    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;
    

    我确实看到标量速度提高了2倍。但我没有看到加速的改进。之前:intSpeedUp 3.98416,SIMDSpeedUP 12.5283。之后:intSpeedUp 3.5572,SIMDSpeedUP 6.8523。

4 个答案:

答案 0 :(得分:4)

SSE2在完全并行数据上运行时是最佳选择。 e.g。

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

但在你的情况下,循环的每次迭代都取决于前一次迭代的输出。这称为依赖链。简而言之,这意味着每个连续的xor必须等待前一个的整个延迟才能继续,从而降低吞吐量。

答案 1 :(得分:3)

我认为你可能碰到内存带宽的上限。这可能是-O3情况下12.6倍加速而不是16倍加速的原因。

但是,gcc 4.7.3在内联时将无用的存储指令放入微小的未展开的向量循环中,而不是在标量或int SWAR循环中(见下文),所以这可能是解释

向量吞吐量的-O2减少都是由于gcc 4.7.3在那里做了更糟糕的工作并且在往返内存时发送累加器(存储转发)。

有关额外商店指令的含义的分析,请参阅最后一节。

TL; DR:Nehalem喜欢比SnB系列需要更多的循环展开,gcc在gcc5中对SSE代码生成做了重大改进。

通常使用_mm_xor_si128而不是_mm_xor_ps进行批量xor工作。

内存带宽。

N是巨大的(40MB),因此内存/缓存带宽是一个问题。 Xeon E7-4860是32nm Nehalem微体系结构,具有256kiB的L2缓存(每个核心),以及24MiB的共享L3缓存。它有一个四通道内存控制器,最高支持DDR3-1066(与双通道DDR3-1333或DDR3-1600相比,适用于典型的台式机CPU,如SnB或Haswell)。

理论上,典型的3GHz台式机Intel CPU可以承受来自DRAM的〜8B /周期的负载带宽。 (e.g. 25.6GB/s theoretical max memory BW for an i5-4670 with dual channel DDR3-1600)。在实际的单线程中实现这一点可能不起作用,尤其是当使用整数4B或8B加载时。对于速度较慢的CPU,如2267MHz Nehalem Xeon,具有四通道(但也较慢)的内存,每个时钟16B可能会推动上限。

我从original unchanged code with gcc 4.7.3 on godbolt看了一下asm。

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

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

这是3个融合域uops,应该每个时钟发出一次迭代并执行。实际上,它不可能因为xorps和融合的比较和分支都需要port5。

N是巨大的,所以即使gcc 4.7为它发出了糟糕的代码({{{{{{[{ 1}}存储到堆栈等等)。 (有关使用SIMD降低到4B的方法,请参阅Fastest way to do horizontal float vector sum on x86。然后将数据sumVV更快地转换为整数regs并使用整数shift / xor进行最后的4B - &gt; 1B,esp可能会更快如果您不使用AVX。编译器可能能够利用movd低和高8位组件注册。)

矢量循环内联愚蠢:

al/ah

它每次迭代都存储累加器,而不是在最后一次迭代之后!由于gcc没有默认优化宏观融合,它甚至没有将## 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 放在彼此旁边,它们可以融合到英特尔的单个uop上AMD CPU,所以循环有5个融合域uops。这意味着如果Nehalem前端/循环缓冲区与Sandybridge循环缓冲区类似,它每2个时钟只能发出一个。 uops以4个为一组发出,而预测采用的分支结束了一个问题块。所以它以4/1/4/1 uop模式发布,而不是4/4/4/4。这意味着我们每2个时钟的持续吞吐量最多可以获得16B负载。

cmp/jne可能会使吞吐量翻倍,因为它会将-mtune=core2放在一起。商店可以微融合到单个uop中,cmp/jne也可以使用内存源操作数。一个旧的不支持xorps的gcc,或者更通用的-mtune=nehalem。 Nehalem每个时钟可以维持一个负载和一个存储,但显然最好不要在循环中存储一个存储

使用-mtune=intel makes even worse code with that gcc version进行编译:

内联循环现在从内存中加载累加器并存储它,因此在累加器所属的循环携带依赖中有store-forwarding往返:

-O2

至少使用-O2,水平字节-xor只编译为一个普通的整数字节循环,而不会将xmm0的15份副本喷射到堆栈上。

这完全是脑卒中代码,因为我们没有让## 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 的引用/指针转义函数,因此没有其他线程可以观察正在进行的累加器。 (即使如此,也没有同步停止gcc,只是在reg中累积并存储最终结果)。非内联版本仍然没问题。

即使我将函数从sumVV重命名为其他内容,这个巨大的性能错误仍然一直存在于gcc 4.9.2,-O2 -fno-tree-vectorize,所以它得到了全部好处gcc的优化工作。 (不要将微基准标记放在main内,因为gcc将其标记为&#34;冷&#34;并且优化得更少。)

gcc 5.1为main的内联版本提供了良好的代码。我没有用铿锵来检查。

这个额外的循环传输dep链几乎可以肯定,为什么矢量版本的加速比较小template<> __m128 Sum(const __m128* data, const int N)即它是一个修复的编译器错误gcc5。

-O2的标量版本是

-O2

所以它基本上是最佳的。 Nehalem每个时钟只能承受一个负载,所以不需要使用更多的累加器。

.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 #, 版本

int

再一次,这是你所期待的。它应该是每个时钟的负载维持。

对于每个时钟可以承受两个负载(Intel SnB系列和AMD)的搜索,您应该使用两个累加器。编译器实现的.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 #, 通常只是在不引入多个累加器的情况下减少了循环开销。 :(

您希望编译器生成如下代码:

-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 ... / pxor / pxor / add)弃用会形成一个循环,每1c可以发出一次迭代,但需要四个ALU执行端口。只有Haswell及以后才能跟上吞吐量。 (或者是Bulldozer系列,因为向量和整数指令不会竞争执行端口,但相反只有两个整数ALU管道,因此它们通过混合代码最大化其指令吞吐量。)

这个展开四个是循环中的6个融合域uop,因此它可以轻松地每2c发出一次,而SnB / IvB可以跟上每个时钟三个ALU uop。

请注意,在通过Broadwell的Intel Nehalem上,cmp/jnepxor)的吞吐量优于_mm_xor_si128xorps),因为它可以在更多执行端口上运行。如果你使用的是AVX而不是AVX2,那么使用256b _mm_xor_ps代替_mm256_xor_ps是有意义的,因为_mm_xor_si128需要AVX2。

如果它不是内存带宽,为什么它只有12.6倍的加速?

Nehalem的循环缓冲区(又名循环流解码器或LSD)具有一个时钟延迟&#34; (根据Agner Fog's microarch pdf),如果我理解正确的话,那么带_mm256_xor_si256 uops的循环将花费N个循环来发出循环缓冲区。如果少于4个,他没有明确说明最后一组uops会发生什么,但SnB系列CPU以这种方式工作(除以4并向上舍入)。他们不能在采取分支后的下一次迭代中发布uops。我试图谷歌关于nehalem,但无法找到任何有用的东西。

所以ceil(N/4.0) + 1char循环可能是在一个负载下运行的。每2个时钟int(因为它们是3个融合域uop)。循环展开可以将吞吐量加倍,直到它们使装载端口饱和。 SnB系列CPU没有一个时钟延迟,因此它们可以在每次迭代的一个时钟运行微小的循环。

使用perf计数器或至少微基准测试来确保您的绝对吞吐量符合您的预期是一个好主意。只有你的相对测量结果,如果没有这种分析,你就没有迹象表明你将表现的一半留在了桌面上。

向量-O3循环是5个融合域uop,因此应该发出三个时钟周期。做16倍的工作,但每次迭代而不是2次循环3个循环将使我们加速xor。我们实际上比这更好,我不明白。

我要停在这里,而不是挖掘一台nehalem笔记本电脑并运行实际基准测试,因为Nehalem太老了,无法在这个细节水平上进行调整。

您是否可以使用16 * 2/3 = 10.66进行编译?或者也许你的gcc有一个不同的默认-mtune=core2设置,并没有拆分比较和分支?在这种情况下,前端可能不是瓶颈,吞吐量可能会受到内存带宽或内存错误依赖性的轻微限制:

  

Core 2和Nehalem都在内存之间存在虚假依赖关系   地址具有相同的集合和偏移,即距离为a   4 kB的倍数。

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

在我检查Nehalem的循环缓冲区并发现每个循环额外的1c之前,我有一个我现在确信不正确的理论

我认为在循环中额外存储uop超过4 uops会将速度提高一半,所以你会看到加速度为~6。但是,也许有一些执行瓶颈使得前端问题吞吐量不再是瓶颈?

或者Nehalem的循环缓冲区可能与SnB不同,并且不会在预测的分支处结束问题组。如果它的5个融合域uop可以以每个时钟一致的4个发出,那么对于-O3矢量循环,这将给出tune的吞吐量加速。这与12.6429加速因子的实验数据非常匹配:由于带宽需求增加(预取器落后时偶尔出现缓存未命中失速),预计会略低于12.8。

(标量循环仍然只是每个时钟运行一次迭代:每个时钟发出多次迭代,这意味着它们每个时钟在一个负载上出现瓶颈,并且1个循环16 * 4/5 = 12.8循环携带依赖性。)

这可能是正确的,因为Nehalem中的xor只能在port5上运行,与融合的比较和分支相同。因此,非展开的矢量循环无法每2个循环以多次迭代运行。

根据Agner Fog的表格,条件分支在Nehalem上的吞吐量为每2c一个,进一步证实这是一个虚假理论。

答案 2 :(得分: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)。我不明白这个区别,所以这只是一个暗示。

答案 3 :(得分:0)

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