为什么std :: fill(0)比std :: fill(1)慢?

时间:2017-03-02 15:04:55

标签: c++ performance x86 compiler-optimization memset

我在系统上观察到,与常量值std::fill相比,设置常量值std::vector<int>时,01 fill(0)显着且持续变慢动态值:

5.8 GiB / s vs 7.5 GiB / s

但是,对于较小的数据大小,结果会有所不同,其中fill(1)更快:

performance for single thread at different data sizes

对于多个线程,在4 GiB数据大小时,fill(0)显示更高的斜率,但达到的峰值远低于fill(1)(51 GiB / s vs 90 GiB / s):< / p>

performance for various thread counts at large data size

这提出了次要问题,为什么/sys/cpufreq的峰值带宽要低得多。

此测试系统是双插槽Intel Xeon CPU E5-2680 v3,设置为2.5 GHz(通过-O3),带有8x16 GiB DDR4-2133。我使用GCC 6.1.0(-fast)和英特尔编译器17.0.1(GOMP_CPU_AFFINITY=0,12,1,13,2,14,3,15,4,16,5,17,6,18,7,19,8,20,9,21,10,22,11,23)进行了测试,结果都相同。 fill(0)已经确定。 Strem / add / 24个线程在系统上获得85 GiB / s。

我能够在不同的Haswell双插槽服务器系统上重现这种效果,但不能重现任何其他架构。例如,在Sandy Bridge EP上,内存性能相同,而在缓存#include <algorithm> #include <cstdlib> #include <iostream> #include <omp.h> #include <vector> using value = int; using vector = std::vector<value>; constexpr size_t write_size = 8ll * 1024 * 1024 * 1024; constexpr size_t max_data_size = 4ll * 1024 * 1024 * 1024; void __attribute__((noinline)) fill0(vector& v) { std::fill(v.begin(), v.end(), 0); } void __attribute__((noinline)) fill1(vector& v) { std::fill(v.begin(), v.end(), 1); } void bench(size_t data_size, int nthreads) { #pragma omp parallel num_threads(nthreads) { vector v(data_size / (sizeof(value) * nthreads)); auto repeat = write_size / data_size; #pragma omp barrier auto t0 = omp_get_wtime(); for (auto r = 0; r < repeat; r++) fill0(v); #pragma omp barrier auto t1 = omp_get_wtime(); for (auto r = 0; r < repeat; r++) fill1(v); #pragma omp barrier auto t2 = omp_get_wtime(); #pragma omp master std::cout << data_size << ", " << nthreads << ", " << write_size / (t1 - t0) << ", " << write_size / (t2 - t1) << "\n"; } } int main(int argc, const char* argv[]) { std::cout << "size,nthreads,fill0,fill1\n"; for (size_t bytes = 1024; bytes <= max_data_size; bytes *= 2) { bench(bytes, 1); } for (size_t bytes = 1024; bytes <= max_data_size; bytes *= 2) { bench(bytes, omp_get_max_threads()); } for (int nthreads = 1; nthreads <= omp_get_max_threads(); nthreads++) { bench(max_data_size, nthreads); } } 中则更快。

以下是重现的代码:

g++ fillbench.cpp -O3 -o fillbench_gcc -fopenmp

使用fill(0)编制的结果。

2 个答案:

答案 0 :(得分:39)

从您的问题+答案中编译器生成的asm:

  • fill(0)ERMSB rep stosb,它将在优化的微编码循环中使用256b存储。 (如果缓冲区对齐,则效果最佳,可能至少为32B或64B)。
  • fill(1)是一个简单的128位movaps向量存储循环。无论宽度如何,每个核心时钟周期只能执行一个存储,最高可达256b AVX。因此,128b商店只能填充Haswell的L1D缓存写入带宽的一半。 这就是为什么fill(0)对于高达~32kiB的缓冲区来说快2倍的原因。使用-march=haswell-march=native进行编译以修复

    Haswell几乎无法跟上循环开销,但即使它根本没有展开,它仍然可以每个时钟运行1个存储。但是每个时钟有4个融合域uop,这就是很多填充器占用了无序窗口的空间。一些展开可能会让TLB未命中开始在存储发生的地方之前进一步解决,因为存储地址微量的吞吐量比存储数据的吞吐量更多。对于适合L1D的缓冲区,展开可能有助于弥补ERMSB与此向量循环之间的其余差异。 (对该问题的评论说-march=native仅对L1有fill(1)的帮助。)

请注意rep movsd(可用于为fill(1)元素实现int)可能与Haswell上的rep stosb执行相同的操作。  虽然只有官方文档仅保证ERMSB提供快速rep stosb(但不是rep stosd),actual CPUs that support ERMSB use similarly efficient microcode for rep stosd。对IvyBridge有一些疑问,可能只有b很快。有关此更新,请参阅@ BeeOnRope的优秀ERMSB answer

gcc为字符串操作(like -mstringop-strategy=alg and -mmemset-strategy=strategy)提供了一些x86调优选项,但IDK(如果其中任何一个)将使其实际为rep movsd发出fill(1)。可能不是,因为我假设代码是以循环开始,而不是memset

  

对于多个线程,在4 GiB数据大小时,fill(1)显示更高的斜率,但达到比fill(0)更低的峰值(51 GiB / s vs 90 GiB / s):

冷藏缓存行的正常movaps商店会触发Read For Ownership (RFO) 。当movaps写入前16个字节时,很多真正的DRAM带宽用于从内存中读取缓存行。 ERMSB存储为其存储使用无RFO协议,因此内存控制器仅写入。 (除了杂项读取之外,如果任何页面遍历错误,甚至在L3缓存中,也可能是页面表,也可能是中断处理程序中的某些加载错误或其他)。

@BeeOnRope explains in comments常规RFO存储区与ERMSB使用的RFO避免协议之间的差异对于服务器CPU上的某些缓冲区大小范围存在缺点,其中uncore / L3存在高延迟缓存。 有关RFO与非RFO的更多信息,请参阅链接的ERMSB答案,并且多核Intel CPU中的非核心(L3 /内存)的高延迟是单核带宽的问题。 < / p>

movntps_mm_stream_ps())商店是弱排序的,因此他们可以绕过缓存并直接记录整个缓存行,而无需阅读缓存行进入L1D。 movntps可以避免像rep stos那样的RFO。 (rep stos商店可以相互重新排序,但不能超出指令的范围。)

您的movntps结果显示您的最新答案令人惊讶 对于具有大缓冲区的单个线程,您的结果为movnt&gt;&gt;常规RFO&gt; ERMSB 即可。因此,两个非RFO方法位于普通旧商店的相对侧,并且ERMSB远非最优化,这真的很奇怪。我目前没有解释。 (编辑欢迎提供解释和良好证据)。

正如我们所料,movnt允许多个线程实现高聚合存储带宽,如ERMSB。 movnt总是直接进入行填充缓冲区然后直接进入内存,因此适合缓存的缓冲区大小要慢得多。每个时钟一个128b矢量足以轻松地将单个内核的无RFO带宽饱和到DRAM。当存储CPU绑定的AVX 256b矢量化计算的结果时(例如,只有当它解除了解包到128b的麻烦时),vmovntps ymm(256b)可能只是vmovntps xmm(128b)的一个可衡量的优势。

movnti带宽很低,因为每个时钟在1个存储uop上存储4B块的瓶颈,将数据添加到行填充缓冲区,而不是将这些行满的缓冲区发送到DRAM(直到你有足够的线程来使内存饱和)带宽)。

@osgx发布了some interesting links in comments

另请参阅代码wiki中的其他内容。

答案 1 :(得分:29)

我将分享我的初步调查结果,希望鼓励更详细的答案。我只觉得这将是问题本身的一部分。

编译器优化 memset到内部fill(1)。它不能对memset执行相同的操作,因为__memset_avx2仅适用于字节。

具体来说,glibcs​​ __intel_avx_rep_memsetrep stos %al,%es:(%rdi) 都是通过一条热指令实现的:

add    $0x1,%rax                                                                                                       
add    $0x10,%rdx                                                                                                      
movaps %xmm0,-0x10(%rdx)                                                                                               
cmp    %rax,%r8                                                                                                        
ja     400f41

手动循环编译为实际的128位指令:

std::fill

有趣的是,虽然有一个模板/头优化来通过memset为字节类型实现std::vector<char>,但在这种情况下,它是一个编译器优化来转换实际的循环。 奇怪的是,对于fill(1),gcc也开始优化memset。尽管有memset模板规范,英特尔编译器仍然没有。

由于只有当代码实际在内存而不是缓存中工作时才会发生这种情况,因此看起来Haswell-EP架构无法有效地整合单字节写入。

非常感谢深入了解该问题以及相关的微架构细节。特别是我不清楚为什么这对于四个或更多线程的行为如此不同以及为什么-march=native在缓存中的速度要快得多。

更新

这是与

比较的结果
  • fill(1)使用vmovdq %ymm0(avx2 movaps %xmm0) - 它在L1中效果更好,但与其他内存级别的vmovnt版本类似。
  • 32,128和256位非时间存储的变体。无论数据大小如何,它们都能以相同的性能执行。所有内容都优于内存中的其他变体,特别是对于少量线程。 128位和256位执行完全相似,对于低数量的线程,32位的性能要差得多。

对于&lt; = 6线程, rep stos在内存中运行时比void __attribute__ ((noinline)) fill1(vector& v) { std::fill(v.begin(), v.end(), 1); } ┌─→add $0x1,%rax │ vmovdq %ymm0,(%rdx) │ add $0x20,%rdx │ cmp %rdi,%rax └──jb e0 void __attribute__ ((noinline)) fill1_nt_si32(vector& v) { for (auto& elem : v) { _mm_stream_si32(&elem, 1); } } ┌─→movnti %ecx,(%rax) │ add $0x4,%rax │ cmp %rdx,%rax └──jne 18 void __attribute__ ((noinline)) fill1_nt_si128(vector& v) { assert((long)v.data() % 32 == 0); // alignment const __m128i buf = _mm_set1_epi32(1); size_t i; int* data; int* end4 = &v[v.size() - (v.size() % 4)]; int* end = &v[v.size()]; for (data = v.data(); data < end4; data += 4) { _mm_stream_si128((__m128i*)data, buf); } for (; data < end; data++) { *data = 1; } } ┌─→vmovnt %xmm0,(%rdx) │ add $0x10,%rdx │ cmp %rcx,%rdx └──jb 40 void __attribute__ ((noinline)) fill1_nt_si256(vector& v) { assert((long)v.data() % 32 == 0); // alignment const __m256i buf = _mm256_set1_epi32(1); size_t i; int* data; int* end8 = &v[v.size() - (v.size() % 8)]; int* end = &v[v.size()]; for (data = v.data(); data < end8; data += 8) { _mm256_stream_si256((__m256i*)data, buf); } for (; data < end; data++) { *data = 1; } } ┌─→vmovnt %ymm0,(%rdx) │ add $0x20,%rdx │ cmp %rcx,%rdx └──jb 40 具有2倍的优势。

单线程带宽:

single threaded performance by data size

内存中的聚合带宽:

memory performance by thread count

以下是用于各自热循环的附加测试的代码:

{{1}}

注意:我必须进行手动指针计算才能使循环变得如此紧凑。否则它会在循环中进行向量索引,可能是因为优化器内在混淆。