为啥在 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
未按设计对齐。注释掉负载不会产生任何影响,限制命令是商店。 outptr
和 inptr
指向不同的内存区域,没有重叠。
如果我将 _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
stream 与 store 的比例分别为 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
并根据需要更改为 0
或 16
):
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<Tile>
是否缓存对齐。如果它没有对齐,那么我认为那些 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 new
和 malloc
几乎可以保证不会在大分配时发生这种情况,通常分配多个页面并使用第一个16 个字节用于簿记,然后返回一个页面对齐 + 16 的指针。当将一个页面分割成多个较小的分配时,你可能会很幸运,就像我猜的每 4 行一样。
@PeterCordes - 当然,但这有关系吗?这只是意味着你不那么幸运了——你非常想确保你要求对齐的分配与如此巨大的 perf 玻璃下颚潜伏,并且了解一些 malloc
在内部的行为似乎走错了路。
我添加了“这不太可能偶然发生并且不能依赖”而不是任何 glibc 特定细节。以上是关于为啥在 Skylake-Xeon 上写入 2 个缓存行的一部分时,`_mm_stream_si128` 比 `_mm_storeu_si128` 慢得多?但对 Haswell 影响较小的主要内容,如果未能解决你的问题,请参考以下文章