为啥在 Skylake-Xeon 上写入 2 个缓存行的一部分时,`_mm_stream_si128` 比 `_mm_storeu_si128` 慢得多?但对 Haswell 影响较小

Posted

技术标签:

【中文标题】为啥在 Skylake-Xeon 上写入 2 个缓存行的一部分时,`_mm_stream_si128` 比 `_mm_storeu_si128` 慢得多?但对 Haswell 影响较小【英文标题】:Why is `_mm_stream_si128` much slower than `_mm_storeu_si128` on Skylake-Xeon when writing parts of 2 cache lines? But less effect on Haswell为什么在 Skylake-Xeon 上写入 2 个缓存行的一部分时,`_mm_stream_si128` 比 `_mm_storeu_si128` 慢得多?但对 Haswell 影响较小 【发布时间】:2019-08-08 20:19:48 【问题描述】:

我的代码看起来像这样(简单的加载、修改、存储)(我已经对其进行了简化以使其更具可读性):

__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;

与我们较新的 Skylake 机器相比,此代码在我们较旧的 Sandy Bridge Haswell 硬件上的运行速度大约快 5 倍。例如,如果 while 循环运行大约 16e9 次迭代,则 Sandy Bridge Haswell 需要 14 秒,而 Skylake 需要 70 秒。

我们升级到 Skylake 上的最新微码, 并且还停留在vzeroupper 命令中以避免任何AVX 问题。两个修复都没有效果。

outptr 对齐到 16 个字节,因此stream 命令应该写入对齐的地址。 (我进行了检查以验证此声明)。 inptr 未按设计对齐。注释掉负载不会产生任何影响,限制命令是商店。 outptrinptr 指向不同的内存区域,没有重叠。

如果我将 _mm_stream_si128 替换为 _mm_storeu_si128,代码在两台机器上运行得更快,大约 2.9 秒。

所以这两个问题是

1) 为什么 Sandy Bridge Haswell 和 Skylake 在使用 _mm_stream_si128 内在函数编写时会有如此大的差异?

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 的比例分别为 4x 或 18x。

我依靠默认的 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 慢 2 倍。

第三次编辑。故意错位

我尝试了@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);

【问题讨论】:

我忘了说清楚,在 Skylake 机器上切换内在函数时的时间改进是 24 倍! 这些基准是来自这个简化的部分重叠复制代码吗?您至少可以使用您使用的编译器选项在godbolt.org 上链接到您的微基准测试的minimal reproducible example,这样我就可以在我自己的 Skylake 上进行尝试了吗? (并使用perf stat对其进行分析) libstdc++ 的 operator new 确实在 x86-64 System V 和 Windows x64 上对齐了 16 字节对齐的内存,因为alignof(maxalign_t) 是 16。所以那部分很好。 @Mysticial 存储在 Tiles 内的 64 字节(32 像素)连续区域中写入。但我没有检查 std::vector&lt;Tile&gt; 是否缓存对齐。如果它没有对齐,那么我认为那些 64 字节将在缓存行上拆分。让我试试这个实验来强制 Tiles 是 64 字节对齐的。 tiles 在 32 和 16 字节边界上对齐时会发生什么?请注意,E5-2680 v3 是 HSX 处理器,而不是 JKT。关于 HSX 与 SKX,我怀疑 HSX 上的性能更高,因为与 SKX 相比,单线程带宽更高。您可以通过运行命令mlc --max_bandwidth -mN 来检查是否使用英特尔 MLC 工具,其中N 是一个不同于 0 的内核编号和内核 0 的同级线程的编号。 【参考方案1】:

简化的代码并未真正显示基准测试的实际结构。我不认为简化的代码会表现出你提到的缓慢。

你的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 字节)。除非每个 tile 恰好是 64 字节对齐的,这不太可能偶然发生并且不能依赖,否则这意味着每次写入都只写入两个不同缓存行的一部分。这是流式存储的一种已知反模式:为了有效使用流式存储,您需要写出完整的行。

关于性能差异:流媒体商店在不同硬件上的性能差异很大。这些存储总是在一段时间内占用一个行填充缓冲区,但多久会有所不同:在许多客户端芯片上,它似乎只占用大约 L3 延迟的缓冲区。即,一旦流媒体存储到达 L3,它就可以被移交(L3 将跟踪其余的工作)并且 LFB 可以在核心上释放。服务器芯片通常有更长的延迟。尤其是多套接字主机。

显然,NT 存储的性能在 SKX 盒上更差,而对于部分行写入,要差得多。整体性能变差可能与L3缓存的重新设计有关。

【讨论】:

每个图块恰好是 64 字节对齐的 我认为您的意思是未对齐。 ipixel = 0 每次商店运行时,因此如果对齐,您将进行全行写入。 (就像他们从new 切换到aligned_alloc 或其他东西时发现的OP,并且大部分修复了他们代码中的性能错误。) @PeterCordes 是的,谢谢 - 它在句子的开头缺少一个“除非”。 可能值得指出的是,GNU/Linux newmalloc 几乎可以保证不会在大分配时发生这种情况,通常分配多个页面并使用第一个16 个字节用于簿记,然后返回一个页面对齐 + 16 的指针。当将一个页面分割成多个较小的分配时,你可能会很幸运,就像我猜的每 4 行一样。 @PeterCordes - 当然,但这有关系吗?这只是意味着你不那么幸运了——你非常想确保你要求对齐的分配与如此巨大的 perf 玻璃下颚潜伏,并且了解一些 malloc 在内部的行为似乎走错了路。 我添加了“这不太可能偶然发生并且不能依赖”而不是任何 glibc 特定细节。

以上是关于为啥在 Skylake-Xeon 上写入 2 个缓存行的一部分时,`_mm_stream_si128` 比 `_mm_storeu_si128` 慢得多?但对 Haswell 影响较小的主要内容,如果未能解决你的问题,请参考以下文章

为啥在 gunicorn 上运行的烧瓶应用程序中使用日志轮换时同时在多个文件上写入日志?

为啥我无权写入外部存储上的应用程序目录?

为啥顺序写入比 HDD 上的随机写入快

为啥我不能从 Android 上的串行端口打开/写入?

为啥在超出数组末尾写入时不会出现分段错误?

为啥 PHPMyAdmin 不能写入我的 AWS Ubuntu 12.04 LTS 实例上的配置目录?