我有一段代码看起来像这样(简单的加载,修改,存储)(为了更易读,我对它进行了简化):
__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
stream 和 store 的比率分别为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
并根据需要更改为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);
operator new
在x86-64 System V上会对齐16字节对齐的内存,我认为在Windows x64上也是如此,因为alignof(maxalign_t)
为16。所以这部分没问题。 - Peter Cordesstd::vector<Tile>
是否具有缓存对齐。如果它没有对齐,那么我认为这些64字节将会被分割成缓存行。让我尝试一下这个实验来强制Tiles对齐到64字节。 - Mark Lakatatiles
对齐在32和16字节边界上时会发生什么?请注意,E5-2680 v3
是HSX处理器,而不是JKT。关于HSX vs. SKX,我怀疑由于单线程带宽比SKX更高,因此HSX的性能更高。您可以使用Intel MLC工具通过运行命令mlc --max_bandwidth -mN
来检查,其中N
是与0号核心和其兄弟线程号不同的核心编号。 - Hadi Brais