【问题标题】:SIMD XOR operation is not as effective as Integer XOR?SIMD XOR 操作不如 Integer XOR 有效?
【发布时间】:2014-06-15 02:44:30
【问题描述】:

我的任务是计算数组中字节的异或和:

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; Intel(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。

【问题讨论】:

  • 你能打开-vec-report3标志看看循环是否真的被矢量化了
  • @arunmoezhi,你是什么意思?哪些循环必须矢量化?我的 gcc 无法识别 -vec-report3。
  • 标量版本。编译器为什么不优化呢
  • @arunmoezhi,因为 -fno-tree-vectorize 标志。
  • 试试_mm_load_si128?

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


【解决方案1】:

在对完全并行的数据进行操作时,SSE2 是最佳的。例如

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

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

【讨论】:

  • 我不明白你的意思。等待下一次迭代有什么不好的地方?它与并行化有什么关系?我与完全相同的标量循环进行比较。它是否也必须等待下一次迭代的整个延迟?
  • xor指令的延迟是1个cpu时钟周期,而xorps的延迟是4个时钟周期。
  • 所以他可能应该展开 4 次并且有 4 个聚合值而不是一个。不需要结果数组。
  • @jaket,你能指出这些信息的一些来源吗?所以我不能更好地理解它。谢谢。
  • @usr,你的意思是 64 个聚合值,而不是我现在的 16 个?
【解决方案2】:

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 异或。看我的回答
  • 依赖链不应该破坏xor指令之间的ILP吗?通常,它们中的多个可以同时运行。这不正是您的答案所说的“循环承载的 dep 链”吗?不过,我确实喜欢内存带宽分析以及您回答中的其他所有内容。对于这个专家的答案,+1 太少了。 @PeterCordes
  • 您可以通过多个独立的依赖链获得 ILP,每个依赖链都有自己的累加器。这正是您对sum1sum2 所做的回答。 sum1 ^= data[i] 可以与 sum2 ^= data[i+1] 同时在飞行中。这种技术更适用于 FMA 之类的东西,它在 Haswell 上具有 5c 延迟和一个每 0.5c 吞吐量,因此如果您要进行缩减(或其他任何具有跨迭代依赖,只要操作是关联的,所以重新排序后最终的答案是相同的)
【解决方案3】:

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

【讨论】:

  • "我使用 -fno-tree-vectorize 来防止自动矢量化"
  • GCC 未针对具有依赖链的 SIMD 进行优化。依赖链是使用内在函数展开对 GCC 有用的主要情况之一。 Clang 展开四次,ICC 展开两次(一般来说,但在某些情况下我见过更多)。 MSVC 可能会展开两次,但我不记得了。
【解决方案4】:

我认为您可能遇到了内存带宽的上限。这可能是在 -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 进行批量异或这样的工作。


内存带宽。

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

理论上,典型的 3GHz 台式机英特尔 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 个融合域微指令,应该在每个时钟一次迭代中发出和执行。其实不能,因为xorps和fused compare-and-branch都需要port5。

N 是巨大的,因此笨重的 char-at-a-time 水平 XOR 的开销不会发挥作用,即使 gcc 4.7 为它发出糟糕的代码(sumVV 的多个副本存储到堆栈等)。 (请参阅Fastest way to do horizontal float vector sum on x86 了解使用 SIMD 减少到 4B 的方法。然后将 movd 数据转换为整数 regs 并在最后一个 4B -> 1B 处使用整数移位/异或可能会更快,尤其是如果你是不使用 AVX。编译器可能能够利用 al/ah 低和高 8 位组件寄存器。)

向量循环被愚蠢地内联了:

## 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 放在一起,它们可以在 Intel 和 AMD CPU 上融合成单个 uop,所以循环有 5融合域微指令。这意味着如果 Nehalem 前端/循环缓冲区类似于 Sandybridge 循环缓冲区,它只能以每 2 个时钟发出一个。 uops 以 4 组为一组发出,预测采用的分支结束一个问题块。所以它以 4/1/4/1 uop 模式发出,而不是 4/4/4/4。这意味着我们最多可以在每 2 个持续吞吐量时钟下获得一个 16B 的负载。

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


-O2makes even worse code with that gcc version编译:

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

## 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 的情况下,水平字节异或仅编译为纯整数字节循环,而不会将 xmm0 的 15 个副本喷到堆栈上。

这完全是脑死代码,因为我们没有让指向sumVV 的引用/指针转义函数,所以没有其他线程可以观察正在进行的累加器。 (即使是这样,也没有同步阻止 gcc 只是在 reg 中累积并存储最终结果)。非内联版本还是可以的。

直到 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检查。

这个额外的循环携带的 dep 链几乎可以肯定是为什么矢量版本的加速比-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      #,

同样,这是您所期望的。它应该维持每个时钟的负载。


对于每个时钟可以承受两个负载的 uarch(英特尔 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 两个 (pxor / pxor / add / cmp/jne) 会形成一个循环,该循环可以在每 1c 一次迭代中发出,但需要四个 ALU 执行端口。只有 Haswell 及以后的产品才能跟上该吞吐量。 (或 AMD Bulldozer 系列,因为向量和整数指令不竞争执行端口,但相反只有两个整数 ALU 管道,因此它们通过混合代码最大限度地提高指令吞吐量。)

4 的展开是循环中的 6 个融合域微指令,因此它可以轻松地以每 2c 一个的速度发出,而 SnB/IvB 可以跟上每个时钟的三个 ALU 微指令。


请注意,在 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 的循环缓冲区(又名循环流解码器或 LSD)具有“一个时钟延迟”(根据 Agner Fog's microarch pdf),因此带有 N uops 的循环将需要 ceil(N/4.0) + 1 个周期才能发出循环缓冲区如果我正确理解他。他没有明确说明如果少于 4 个 uops,最后一组 uops 会发生什么,但 SnB 系列 CPU 以这种方式工作(除以 4 并向上取整)。他们不能在所采用的分支之后的下一次迭代中发出 uops。我试图用谷歌搜索 nehalem,但找不到任何有用的东西。

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

使用性能计数器或至少使用微基准来确保您的绝对吞吐量是您所期望的,这是一个好主意。仅通过您的相对测量值,如果没有这种分析,您并没有任何迹象表明您将一半的表现留在桌面上。

向量 -O3 循环是 5 个融合域微指令,因此它应该需要三个时钟周期才能发出。做 16 倍的工作,但每次迭代需要 3 个周期而不是 2 个周期,这将给我们带来16 * 2/3 = 10.66 的加速。我们实际上比这要好一些,我不明白。

我将在此停下来,而不是挖出一台 nehalem 笔记本电脑并运行实际的基准测试,因为 Nehalem 太旧了,无法在这种细节级别上进行调整。

您是否使用-mtune=core2 编译过?或者您的 gcc 有不同的默认 tune 设置,并且没有拆分比较和分支?在这种情况下,前端可能不是瓶颈,吞吐量可能会受到内存带宽或内存错误依赖性的轻微限制:

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

这可能会导致每 4k 管道中出现一个短气泡。


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

我认为循环中的额外存储 uop 将其提升超过 4 uop 基本上会将速度减半,因此您会看到约 6 的加速。但是,也许有一些执行瓶颈使前端问题吞吐量根本不是瓶颈?

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

(标量循环仍然每个时钟只运行一次迭代:每个时钟发出不止一次迭代只是意味着它们在每个时钟一个负载上遇到瓶颈,以及 1 个循环 xor 循环携带的依赖项。)

这是不对的,因为 Nehalem 中的 xorps 只能在端口 5 上运行,与融合比较和分支相同。因此,未展开的向量循环不可能每 2 个周期运行超过一次迭代。

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

【讨论】:

    猜你喜欢
    • 2015-05-21
    • 1970-01-01
    • 2018-10-24
    • 2020-10-06
    • 2017-06-01
    • 2014-01-25
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多