为什么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)
是一个ERMSBrep stosb
,它将在优化的微码循环中使用256b存储。 (如果缓冲区alignment,最好使用32B或64B)。 -
fill(1)
是一个简单的128位movaps
vector存储循环。 只有一个存储器可以执行每个核心时钟周期,不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
(avx2vmovdq %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
注:我必须做手动指针计算,以获得循环如此紧凑。 否则,它会在循环内做向量索引,可能是由于优化器本身的混乱。