为什么在Skylake-Xeon上使用`_mm_stream_si128`写入2个缓存行的部分时比`_mm_storeu_si128`慢很多?但在Haswell上影响较小。

4

我有一段代码看起来像这样(简单的加载,修改,存储)(为了更易读,我对它进行了简化):

__asm__ __volatile__ ( "vzeroupper" : : : );
while(...) {
  __m128i in = _mm_loadu_si128(inptr);
  __m128i out = in; // real code does more than this, but I've simplified it
  _mm_stream_si12(outptr,out);
  inptr  += 12;
  outptr += 16;
}

这段代码在我们旧的 Sandy Bridge Haswell 硬件上运行速度比我们新的 Skylake 机器快大约5倍。例如,如果 while 循环运行约16e9次迭代,在 Sandy Bridge Haswell 上需要14秒,在 Skylake 上需要70秒。
我们升级了 Skylake 的最新微码,并添加了 vzeroupper 命令以避免任何 AVX 问题。这两个修复都没有效果。
outptr 对齐到16字节,因此 stream 命令应该写入对齐的地址。(我放置检查以验证此语句)。inptr 是故意不对齐的。注释掉加载不会产生任何影响,限制命令是存储。(outptr 和 inptr 指向不同的内存区域,没有重叠)
如果我用 _mm_storeu_si128 替换 _mm_stream_si128,则代码在两台机器上运行速度更快,约为2.9秒。
所以两个问题是:

1) 当使用_mm_stream_si128内置函数进行写入操作时,为什么Sandy Bridge Haswell和Skylake之间存在如此大的差异?

2) 为什么_mm_storeu_si128的运行速度比流式存储等效函数快5倍?

我对内置函数还是个新手。


附录 - 测试案例

这是整个测试案例: https://godbolt.org/z/toM2lB

以下是我在两种不同处理器上进行的基准测试摘要,分别为E5-2680 v3 (Haswell)和8180 (Skylake)。

// icpc -std=c++14  -msse4.2 -O3 -DNDEBUG ../mre.cpp  -o mre
// The following benchmark times were observed on a Intel(R) Xeon(R) Platinum 8180 CPU @ 2.50GHz
// and Intel(R) Xeon(R) CPU E5-2680 v3 @ 2.50GHz.
// The command line was
//    perf stat ./mre 100000
//
//   STORER               time (seconds)
//                     E5-2680   8180
// ---------------------------------------------------
//   _mm_stream_si128     1.65   7.29
//   _mm_storeu_si128     0.41   0.40

streamstore 的比率分别为4倍或18倍。

我依赖默认的new分配器将我的数据对齐到16字节。我很幸运它是对齐的。我已经测试过这是真的,在我的生产应用程序中,我使用对齐分配器来确保它是对齐的,以及对地址的检查,但我在示例中省略了这一点,因为我认为这并不重要。

第二次编辑 - 64B对齐输出

@Mystical的评论让我检查输出是否都是缓存对齐的。对Tile结构的写入是以64-B块进行的,但Tiles本身不是64-B对齐的(仅16-B对齐)。

所以我将我的测试代码更改为:

#if 0
    std::vector<Tile> tiles(outputPixels/32);
#else
    std::vector<Tile, boost::alignment::aligned_allocator<Tile,64>> tiles(outputPixels/32);
#endif

现在数字有很大的不同:

//   STORER               time (seconds)
//                     E5-2680   8180
// ---------------------------------------------------
//   _mm_stream_si128     0.19   0.48
//   _mm_storeu_si128     0.25   0.52

所以一切都快了很多。但是Skylake仍然比Haswell慢两倍。

第三次编辑。故意错位

我尝试了@HaidBrais建议的测试。我有意将我的向量类分配对齐到64字节,然后在分配器内部添加了16字节或32字节,使分配对齐为16字节或32字节,但不是64字节对齐。我还增加了循环次数到1,000,000,并运行了3次测试并选择最小时间。

perf stat ./mre1  1000000

再次强调,2^N的对齐方式意味着它不会对齐于2^(N+1)或2^(N+2)。
//   STORER               alignment time (seconds)
//                        byte  E5-2680   8180
// ---------------------------------------------------
//   _mm_storeu_si128     16       3.15   2.69
//   _mm_storeu_si128     32       3.16   2.60
//   _mm_storeu_si128     64       1.72   1.71
//   _mm_stream_si128     16      14.31  72.14 
//   _mm_stream_si128     32      14.44  72.09 
//   _mm_stream_si128     64       1.43   3.38

很明显,缓存对齐可以获得最佳结果,但是_mm_stream_si128仅在2680处理器上表现更好,并且在8180上遭受某种无法解释的惩罚。
为了将来使用,这里是我使用的未对齐分配器(我没有将未对齐性模板化,您需要编辑32并根据需要更改为016):
template <class T >
struct Mallocator {
  typedef T value_type;
    Mallocator() = default;
      template <class U> constexpr Mallocator(const Mallocator<U>&) noexcept 
{}
        T* allocate(std::size_t n) {
                if(n > std::size_t(-1) / sizeof(T)) throw std::bad_alloc();
                    uint8_t* p1 = static_cast<uint8_t*>(aligned_alloc(64, (n+1)*sizeof(T)));
                    if(! p1) throw std::bad_alloc();
                    p1 += 32; // misalign on purpose
                    return reinterpret_cast<T*>(p1);
                          }
          void deallocate(T* p, std::size_t) noexcept {
              uint8_t* p1 = reinterpret_cast<uint8_t*>(p);
              p1 -= 32;
              std::free(p1); }
};
template <class T, class U>
bool operator==(const Mallocator<T>&, const Mallocator<U>&) { return true; }
template <class T, class U>
bool operator!=(const Mallocator<T>&, const Mallocator<U>&) { return false; }

...

std::vector<Tile, Mallocator<Tile>> tiles(outputPixels/32);

我忘了明确说,当切换Intrinsics时,在Skylake机器上的时间改进速度快了24倍! - Mark Lakata
3
这些基准测试是否来自于这个简化的部分重叠复制代码?您是否能够至少在https://godbolt.org/上提供一个[mcve]的微基准链接和您使用的编译器选项,以便我可以在我的Skylake上尝试它并使用“perf stat”对其进行分析。 - Peter Cordes
2
libstdc++的operator new在x86-64 System V上会对齐16字节对齐的内存,我认为在Windows x64上也是如此,因为alignof(maxalign_t)为16。所以这部分没问题。 - Peter Cordes
1
@Mysticial 这些存储区域是在Tiles内以64字节(32像素)连续的方式编写的。但我没有检查std::vector<Tile>是否具有缓存对齐。如果它没有对齐,那么我认为这些64字节将会被分割成缓存行。让我尝试一下这个实验来强制Tiles对齐到64字节。 - Mark Lakata
1
tiles对齐在32和16字节边界上时会发生什么?请注意,E5-2680 v3是HSX处理器,而不是JKT。关于HSX vs. SKX,我怀疑由于单线程带宽比SKX更高,因此HSX的性能更高。您可以使用Intel MLC工具通过运行命令mlc --max_bandwidth -mN来检查,其中N是与0号核心和其兄弟线程号不同的核心编号。 - Hadi Brais
显示剩余8条评论
1个回答

4
简化的代码并没有真正展示你的基准测试的实际结构。我认为简化的代码不会展现出你提到的缓慢。
你的godbolt代码中实际的循环是:
while (count > 0)
        {
            // std::cout << std::hex << (void*) ptr << " " << (void*) tile <<std::endl;
            __m128i value0 = _mm_loadu_si128(reinterpret_cast<const __m128i*>(ptr + 0 * diffBytes));
            __m128i value1 = _mm_loadu_si128(reinterpret_cast<const __m128i*>(ptr + 1 * diffBytes));
            __m128i value2 = _mm_loadu_si128(reinterpret_cast<const __m128i*>(ptr + 2 * diffBytes));
            __m128i value3 = _mm_loadu_si128(reinterpret_cast<const __m128i*>(ptr + 3 * diffBytes));

            __m128i tileVal0 = value0;
            __m128i tileVal1 = value1;
            __m128i tileVal2 = value2;
            __m128i tileVal3 = value3;

            STORER(reinterpret_cast<__m128i*>(tile + ipixel + diffPixels * 0), tileVal0);
            STORER(reinterpret_cast<__m128i*>(tile + ipixel + diffPixels * 1), tileVal1);
            STORER(reinterpret_cast<__m128i*>(tile + ipixel + diffPixels * 2), tileVal2);
            STORER(reinterpret_cast<__m128i*>(tile + ipixel + diffPixels * 3), tileVal3);

            ptr    += diffBytes * 4;
            count  -= diffBytes * 4;
            tile   += diffPixels * 4;
            ipixel += diffPixels * 4;
            if (ipixel == 32)
            {
                // go to next tile
                ipixel = 0;
                tileIter++;
                tile = reinterpret_cast<uint16_t*>(tileIter->pixels);
            }
        }

注意if (ipixel == 32)这部分,每次ipixel达到32时都会跳转到另一个瓦片。由于diffPixels为8,这在每个迭代中发生。因此,每个瓦片只进行4次流式存储(64字节)。除非每个瓦片恰好是64字节对齐的,但这不太可能偶然发生,并且不能依赖它,这意味着每次写入仅写入两个不同高速缓存行的一部分。这是流式存储的已知反模式:要有效使用流式存储,需要将整个行写出。

接下来谈性能差异:不同硬件上流式存储的性能差异很大。这些存储总是占用一行填充缓冲区一段时间,但持续时间有所不同:在许多客户端芯片上,它似乎只占用缓冲区约为L3延迟的时间。也就是说,一旦流式存储到达L3,它就可以被移交(L3将跟踪其余工作),并且LFB可以在核心上释放。服务器芯片通常具有更长的延迟时间。特别是多插槽主机。

显然,NT存储在SKX盒子上的性能较差,并且部分行写入的性能要差得多。总体性能差可能与L3缓存的重新设计有关。


每个瓷砖恰好都是64字节对齐的。我认为你的意思是未对齐。每次存储运行时,ipixel = 0,因此如果它对齐,您将执行完整行写入。 (就像OP在从new切换到aligned_alloc或其他内容并在其代码中大部分修复性能错误时发现的那样。) - Peter Cordes
@PeterCordes 没错,谢谢 - 句子开头缺少了一个“除非”。 - BeeOnRope
1
我添加了“这是不可能偶然发生并且不能依赖”的内容,而不是任何特定于glibc的细节。 - BeeOnRope
@PeterCordes - OP 是在 Linux 上吗? - BeeOnRope
某种 Unix/Linux 系统。他们在 SKX 系统上通过运行icpc命令,并使用参数 -o mre 而不是 -o mre.exe 进行编译。 - Peter Cordes
显示剩余17条评论

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