为啥 std::fill(0) 比 std::fill(1) 慢?
Posted
技术标签:
【中文标题】为啥 std::fill(0) 比 std::fill(1) 慢?【英文标题】:Why is std::fill(0) slower than std::fill(1)?为什么 std::fill(0) 比 std::fill(1) 慢? 【发布时间】:2017-07-22 09:13:54 【问题描述】:我在一个系统上观察到,与恒定值 1
或动态值相比,在设置恒定值 0
时,大型 std::vector<int>
上的 std::fill
明显且始终较慢:
5.8 GiB/s 与 7.5 GiB/s
但是,对于较小的数据大小,结果会有所不同,fill(0)
更快:
使用多个线程,数据大小为 4 GiB,fill(1)
显示出更高的斜率,但达到的峰值比fill(0)
低得多(51 GiB/s vs 90 GiB/s):
这就引出了第二个问题,为什么fill(1)
的峰值带宽要低这么多。
测试系统是双插槽 Intel Xeon CPU E5-2680 v3,频率为 2.5 GHz(通过/sys/cpufreq
),配备 8x16 GiB DDR4-2133。我使用 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 上,内存性能是相同的,而在缓存中 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
编译的呈现结果。
【问题讨论】:
比较线程数时data size
是什么?
@GavinPortwood 4 GiB,所以在内存中,而不是缓存中。
那么第二个情节一定有问题,即弱缩放。我无法想象用最少的中间操作来使循环的内存带宽饱和需要两个左右的线程。实际上,即使在 24 个线程时,您也没有确定带宽饱和的线程数。你能证明它在某些有限的线程数下确实会变平吗?
我怀疑原始实验中的异常缩放(在第二个套接字上)与非同质内存分配和由此产生的 QPI 通信有关。这可以通过英特尔的“非核心”PMU 来验证(我认为)
FWIW - 您在答案中发现了代码差异,我认为 Peter Cordes 的答案如下:rep stosb
正在使用非 RFO 协议,该协议将执行填充所需的事务数量减半.其余的行为大多不在此范围内。 fill(1)
代码还有另一个缺点:它不能使用 256 位 AVX 存储,因为您没有指定 -march=haswell
或其他任何内容,因此它必须回退到 128 位代码。调用memset
的fill(0)
获得调用平台上AVX 版本的libc
调度的优势。
【参考方案1】:
根据您的问题 + 您的回答中编译器生成的 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 个融合域 uops,在乱序窗口中占用了大量空间。一些展开可能会让 TLB 未命中在存储发生的位置之前开始解决,因为存储地址微指令的吞吐量比存储数据的吞吐量更大。对于适合 L1D 的缓冲区,展开可能有助于弥补 ERMSB 与此向量循环之间的其余差异。 (对该问题的评论说 -march=native
仅对 L1 的 fill(1)
有所帮助。)
请注意,rep movsd
(可用于为int
元素实现fill(1)
)可能与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 将使它实际为 fill(1)
发出 rep movsd
。可能不是,因为我假设代码以循环开始,而不是memset
。
如果有多个线程,数据大小为 4 GiB,fill(1) 显示出更高的斜率,但达到的峰值比 fill(0) 低得多(51 GiB/s 与 90 GiB/s):
正常的movaps
存储到冷缓存行会触发Read For Ownership (RFO)。当movaps
写入前 16 个字节时,大量实际 DRAM 带宽用于从内存读取缓存行。 ERMSB 存储对其存储使用无 RFO 协议,因此内存控制器仅在写入。 (除了杂项读取,例如页表,如果任何页面遍历即使在 L3 缓存中也未命中,也可能在中断处理程序中出现一些加载未命中等)。
@BeeOnRope explains in comments 常规 RFO 存储和 ERMSB 使用的 RFO 避免协议之间的差异对于非核心/L3 缓存中存在高延迟的服务器 CPU 上的某些缓冲区大小范围有不利之处。 另请参阅链接的 ERMSB 答案,了解有关 RFO 与非 RFO 的更多信息,以及多核 Intel CPU 中非核(L3/内存)的高延迟是单核带宽的问题。
movntps
(_mm_stream_ps()
) 存储 是弱排序的,因此它们可以绕过缓存并一次直接进入整个缓存行的内存,而无需将缓存行读入L1D。 movntps
避免 RFO,就像 rep stos
一样。 (rep stos
商店可以相互重新排序,但不能超出指令的边界。)
您更新后的答案中的movntps
结果令人惊讶。对于具有大缓冲区的单线程,您的结果是movnt
>> 常规 RFO > ERMSB。所以这真的很奇怪,这两种非 RFO 方法位于普通旧商店的对立面,而 ERMSB 远非最佳。我目前对此没有任何解释。 (欢迎编辑并附上解释 + 良好证据)。
正如我们所料,movnt
允许多个线程实现高聚合存储带宽,例如 ERMSB。 movnt
总是直接进入行填充缓冲区,然后进入内存,因此适合缓存的缓冲区大小要慢得多。每个时钟一个 128b 向量足以轻松地使单个内核的无 RFO 带宽饱和到 DRAM。在存储受 CPU 限制的 AVX 256b 矢量化计算的结果时(即仅当它省去解包到 128b 的麻烦时),vmovntps ymm
(256b) 可能只是比 vmovntps xmm
(128b) 的可衡量优势。
movnti
带宽很低,因为以 4B 块存储瓶颈,每个时钟 1 个存储 uop 将数据添加到行填充缓冲区,而不是将这些行满缓冲区发送到 DRAM(直到您有足够的线程来饱和内存带宽) .
@osgx 发帖some interesting links in comments:
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 监听:http://frankdenneman.nl/2016/07/11/numa-deep-dive-part-3-cache-coherency/
https://software.intel.com/en-us/articles/intelr-memory-latency-checker Cache Coherence Protocol and Memory Performance of the Intel Haswell-EP Architecture另请参阅x86 标签 wiki 中的其他内容。
【讨论】:
上述rep movsb
的行为与movaps
在单个内核上跨各种缓冲区大小的显式循环的行为与我们之前在服务器内核上看到的非常一致。正如您所指出的,竞争是在非 RFO 协议和 RFO 协议之间进行的。前者在所有缓存级别之间使用较少的带宽,但特别是在服务器芯片上,一直到内存的切换延迟很长。由于单核通常具有并发限制,因此延迟很重要,并且非 RFO 协议胜出,这就是您在 30 MB L3 之外的区域中看到的情况。
... 在适合 L3 的图表中间,但是,长服务器非核心到内存的切换显然没有发挥作用,因此非 RFO 提供的读取减少获胜(但实际上将其与 NT 存储进行比较很有趣:它们会表现出相同的行为,还是rep stosb
能够在 L3 停止写入而不是一直到内存)? FWIW,在经验上,rep stosb
的fill
的情况比rep movsb
的memcpy
的情况要好。可能是因为前者在流量方面具有 2:1 的优势,而后者是 3:2。
我尝试了movntps
,如果我正确使用它,它会显示所有数据大小之间的内存带宽 - 所以它根本不会从缓存中受益。但是对于单线程来说,内存带宽是movaps
的两倍,而对于24线程来说,则略高于rep stosb
。
@Noah:应该很明显,在任何存储之后,缓存行在其他内核的私有缓存中肯定不会仍然热。核心没有共享总线来广播新数据(相反,它是基于目录的一致性与 L3 标记或与目录类似的结构)。存储核心在更新自己的 L1d 之前需要独占所有权,通过使其他副本无效,并且必须等待对无效的确认。如果 2 个内核同时尝试将 rep movsb
发送到同一目的地,则它必须保持一致性。
@Noah:回复:全线 ZMM 商店避免 RFO:好问题,我不知道,但这是 100% 可能的。在内部,它可以像 rep stos / rep movs 的全线商店一样工作。这是我一直想知道的,但我忘记了我是否找到了答案,或者不同微架构的答案是什么。 (如果 SKX 或 KNL 没有,它当然可以添加到以后的设计中。)这可能是某种原因,它只对大量商店来说是值得的,比如不知何故需要更长的时间来做某事,也许延迟以后的存储并停止存储缓冲区。【参考方案2】:
我将分享我的初步调查结果,希望鼓励更详细的答案。我只是觉得这作为问题本身的一部分太过分了。
编译器优化 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
有趣的是,虽然有一个模板/标题优化可以通过memset
实现字节类型的std::fill
,但在这种情况下,它是一个编译器优化来转换实际循环。
奇怪的是,对于std::vector<char>
,gcc 也开始优化fill(1)
。尽管有memset
模板规范,但英特尔编译器却没有。
由于这种情况仅在代码实际在内存而不是缓存中工作时才会发生,因此 Haswell-EP 架构似乎无法有效地整合单字节写入。
我会感谢任何进一步的见解对该问题和相关的微架构细节。特别是我不清楚为什么四个或更多线程的行为如此不同,以及为什么memset
在缓存中的速度如此之快。
更新:
这是对比结果
fill(1) 使用-march=native
(avx2 vmovdq %ymm0
) - 它在 L1 中效果更好,但在其他内存级别与 movaps %xmm0
版本类似。
32、128 和 256 位非临时存储的变体。无论数据大小如何,它们都以相同的性能始终如一地执行。所有这些都优于内存中的其他变体,尤其是对于少量线程。 128 位和 256 位的性能完全相同,因为线程数少,32 位的性能明显较差。
对于 vmovnt
比 rep stos
有 2 倍的优势。
单线程带宽:
内存中的聚合带宽:
以下是用于附加测试的代码及其各自的热循环:
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
注意:为了使循环如此紧凑,我必须进行手动指针计算。否则它会在循环中进行向量索引,可能是由于优化器的内在混淆。
【讨论】:
rep stos
是微编码的 在大多数 CPU 中(在第 189 页的 Haswell 的 agner.org/optimize/instruction_tables.pdf 表中找到“REP STOS”及其“Fused µOps 列”)。还要检查 CPUID EAX=7, EBX, bit 9 "erms Enhanced REP MOVSB/STOSB" (grep erms /proc/cpuinfo
) 这是针对rep stos
的额外优化微码的标志,因为 Nehalem: intel.com/content/dam/www/public/us/en/documents/manuals/… "2.5.6 REP String Enhancement" & 3.7.6 ERMSB。您应该比较 PMU 计数器以获取有关实施的一些信息。
另外,检查***.com/a/26256216 以获得不同的优化memcpy/set(和CPU 限制),并尝试在software.intel.com/en-us/forums 上提出具体问题,以获得software.intel.com/en-us/user/545611 的一些关注。 Haswell 的实际微码在 NUMA 情况下可能会出现一些问题,在一致性协议的情况下,当一些内存分配在不同的 numa 节点(套接字)的内存中或者内存可以分配到其他节点上时,多套接字一致性协议是活动的当缓存线被分配时。还要检查 Haswell 关于其微码的勘误表。
欢迎来到 NUMA 世界。向量分配有malloc
,在第一次触摸放置时正确使用,但它与free
的释放只会将内存标记为未使用,不会将内存返回给操作系统 - 不会有下一次触摸下一次迭代(***.com/questions/2215259 中关于 malloc 的一些过时信息和***.com/a/42281428 中的一些“自 2007 年以来(glibc 2.9 和更新版本)”)。使用 glibc 在bench
之间调用malloc_trim()
,释放的内存将被标记为对操作系统空闲并针对 NUMA 进行修饰。堆栈由主线程分配...
祖蓝,不,软件不会禁用套接字之间的缓存一致性(不应启动第二个套接字/禁用 QPI)。您的 E5-2680 v3 是 MCC(中核数)芯片 (anandtech.com/show/8679/…) 中的 12 核 haswell,访问时有缓存侦听消息:frankdenneman.nl/2016/07/11/…。它们在本地套接字的环中和通过 QPI 发送到下一个套接字。某些版本的 Xeons 可能会使用“目录”来限制像这样的内存绑定任务中的窥探消息风暴。
您还可以查看 Intel MLC - software.intel.com/en-us/articles/intelr-memory-latency-checker 以测量测试系统的最大带宽,如 mlc --bandwidth_matrix
和 mlc --peak_bandwidth
。另外 - 关于您的 Haswell 及其缓存一致性的论文 tu-dresden.de/zih/forschung/ressourcen/dateien/…以上是关于为啥 std::fill(0) 比 std::fill(1) 慢?的主要内容,如果未能解决你的问题,请参考以下文章