我在系统上观察到,与常量值std::fill
相比,设置常量值std::vector<int>
时,0
上1
fill(0)
显着且持续变慢动态值:
5.8 GiB / s vs 7.5 GiB / s
但是,对于较小的数据大小,结果会有所不同,其中fill(1)
更快:
对于多个线程,在4 GiB数据大小时,fill(0)
显示更高的斜率,但达到的峰值远低于fill(1)
(51 GiB / s vs 90 GiB / s):< / p>
这提出了次要问题,为什么/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)
编制的结果。
答案 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:
NUMA snooping:http://frankdenneman.nl/2016/07/11/numa-deep-dive-part-3-cache-coherency/
另请参阅x86代码wiki中的其他内容。
答案 1 :(得分:29)
我将分享我的初步调查结果,希望鼓励更详细的答案。我只觉得这将是问题本身的一部分。
编译器优化 memset
到内部fill(1)
。它不能对memset
执行相同的操作,因为__memset_avx2
仅适用于字节。
具体来说,glibcs __intel_avx_rep_memset
和rep 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
在缓存中的速度要快得多。
更新
这是与
比较的结果vmovdq %ymm0
(avx2 movaps %xmm0
) - 它在L1中效果更好,但与其他内存级别的vmovnt
版本类似。对于&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倍的优势。
单线程带宽:
内存中的聚合带宽:
以下是用于各自热循环的附加测试的代码:
{{1}}
注意:我必须进行手动指针计算才能使循环变得如此紧凑。否则它会在循环中进行向量索引,可能是因为优化器内在混淆。