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

我在一个系统上观察到,当设置一个常数值0与一个常量值1或一个dynamic值相比时, std::fill在一个大的std::vector<int>上显着且一致地慢:

5.8 GiB / s vs 7.5 GiB / s

但是,对于更小的数据大小,结果是不同的,其中fill(0)更快:

针对不同数据大小的单线程性能

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

在大数据量下的各种线程数的性能

这引起了第二个问题,为什么fill(1)的峰值带宽要低得多。

这个testing系统是双通道Intel Xeon CPU E5-2680 v3,设置为2.5 GHz(通过/sys/cpufreq ),带有8×16 GiB DDR4-2133。 我testing了GCC 6.1.0( -O3 )和Intel编译器17.0.1( -fast ),都得到了相同的结果。 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为组。 Strem / add / 24线程在系统上获得85 GiB / s。

我能够在不同的Haswell双套接字服务器系统上重现这种效果,但没有任何其他体系结构。 例如,在Sandy Bridge EP上,内存性能是相同的,而在caching中fill(0)要快得多。

这里是重现的代码:

 #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编译的结果。

从你的问题+从你的答案编译器生成的ASM:

  • fill(0)是一个ERMSB rep stosb ,它将在优化的微码循环中使用256b存储。 (如果缓冲区alignment,最好使用32B或64B)。
  • fill(1)是一个简单的128位movapsvector存储循环。 只有一个存储器可以执行每个核心时钟周期,不pipe宽度,高达256b AVX。 所以128b商店只能填充Haswell的L1Dcaching写带宽的一半。 这就是为什么fill(0)大约是快达32K的缓冲区的2倍。 -march=haswell-march=native编译来解决这个问题

    Haswell几乎可以跟上循环开销,但即使它没有展开,它仍然可以每时钟运行1个存储。 但是每个时钟有4个融合域uop,这是很多填充器在乱序窗口中占用空间的。 有些展开可能会让TLB在未来商店发生之前开始解决问题,因为商店地址uops的吞吐量比存储数据的吞吐量要高。 展开可能有助于弥补ERMSB和这个向量循环之间的区别,适合L1D的缓冲区。 (关于这个问题的一个评论说, -march=native只帮助fill(1) L1。)

请注意, rep movsd (可用于为int元素实现fill(1) )可能会与Haswell上的rep stosb执行相同的操作。 尽pipe只有官方文档只能保证ERMSB能够快速rep stosb (但不rep stosd ),但是支持ERMSB的实际CPU使用类似的高效微代码来进行rep stosd 。 IvyBridge有一些疑问,也许只有b快。 有关这方面的更新,请参阅@ BeeOnRope的优秀ERMSB答案 。

-mstringop-strategy=有一些x86调整选项的string操作( 如-mstringop-strategy= alg-mmemset-strategy=strategy ),但IDK,如果他们中的任何一个将实际发出rep movsd fill(1) 。 可能不会,因为我认为代码起始于循环,而不是memset


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

一个普通的movaps存储到一个冷藏caching行触发一个Read For Ownership(RFO) 。 当movaps写入前16个字节时,很多实际的DRAM带宽用于从内存读取caching行。 ERMSB存储使用无RFO协议的存储,所以内存控制器只写。 (除了杂项读取,如页面表,如果有任何页面,甚至在三级caching中缺失,也许在中断处理程序或任何其他一些加载未命中)。

@BeeOnRope 在评论中解释说,常规RFO存储与ERMSB使用的RFO避免协议之间的区别对于uncore / L3高速caching中存在高延迟的服务器CPU上的某些缓冲区大小范围具有不利影响。 有关RFO与非RFO的更多信息,另请参阅链接的ERMSB答案,而多核英特尔CPU中的非核(L3 /内存)的高延迟是单核带宽的问题。


movntps_mm_stream_ps() )存储是弱有序的,所以他们可以绕过caching,并且一次直接存储整个caching行,而不需要将caching行读入L1D。 movntps避免movntps ,像rep stos movntps那样。 ( rep stos商店可以重新sorting,但不在指令的边界之外)。

你的movntps结果在你更新的答案是令人惊讶的。
对于具有大缓冲区的单个线程,您的结果是movnt >>常规RFO> ERMSB 。 所以这真的很奇怪,两个非RFO方法是在普通老店的两边,而且ERMSB离最佳状态还很远。 我现在没有解释。 (编辑欢迎与解释+良好的证据)。

正如我们所预料的那样, movnt允许multithreading实现高集合存储带宽,如ERMSB。 movnt总是直接进入行填充缓冲区,然后是内存,所以适合caching的缓冲区大小要慢得多。 每个时钟一个128b向量足以容易地将单个内核的无RFO带宽饱和到DRAM。 当存储一个CPU绑定的AVX 256bvector化计算的结果时(也就是说,只有当它将开箱的麻烦保存到128b时), vmovntps ymm (256b)可能只是vmovntps xmm (128b)的一个可衡量的优势。

movnti带宽很低,因为在每个时钟的1个存储区中存储4B块的瓶颈,而不是将这些行满的缓冲区发送到DRAM(直到有足够的线程来饱和存储器带宽)。


@osgx 在评论中发布了一些有趣的链接 :

  • Agner Fog的asm优化指南,指令表和微型指南: http ://agner.org/optimize/
  • 英特尔优化指南: http : //www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf 。

  • NUMA snooping: http : //frankdenneman.nl/2016/07/11/numa-deep-dive-part-3-cache-coherency/

  • https://software.intel.com/en-us/articles/intelr-memory-latency-checker
  • 英特尔Haswell-EP架构的高速caching一致性协议和内存性能

请参阅x86标记wiki中的其他内容。

我会分享我的初步调查结果 ,希望能够提供更详细的答案 。 我只是觉得这个问题本身就太过分了。

编译器优化 fill(0)到内部的memset 。 它不能为fill(1)做同样的事情,因为memset只能工作在字节上。

具体来说,两个glibcs __memset_avx2__intel_avx_rep_memset都是通过一个热指令来实现的:

 rep stos %al,%es:(%rdi) 

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

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

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

由于这种情况只有在代码实际上在内存而不是caching中工作时才会出现,Haswell-EP架构似乎无法有效地整合单字节写入。

我希望能够进一步了解这个问题以及相关的微架构细节。 特别是我不清楚为什么这个行为对于四个或更multithreadingperformance如此不同以及为什么memset在caching中速度如此之快。

更新:

这是一个比较结果

  • 填充(1)使用-march=native (avx2 vmovdq %ymm0 ) – 它在L1中效果更好,但与其他内存级别的movaps %xmm0版本类似。
  • 32,128和256位非时间存储的变体。 无论数据大小如何,它们的性能始终如一。 所有的performance都胜过内存中的其他变体,特别是对于less量的线程。 128位和256位执行完全相似,对于32位的低线程执行性能要差得多。

对于<= 6线程,在内存中运行时, vmovnt对于vmovnt具有2 vmovnt的优势

单线程带宽:

按数据大小单线程执行

内存中的总带宽:

内存性能通过线程数

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

 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 

注:我必须做手动指针计算,以获得循环如此紧凑。 否则,它会在循环内做向量索引,可能是由于优化器本身的混乱。