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


69

我在一个系统std::fill上观察到,与恒定值或动态值相比,将恒定值std::vector<int>设置为大时显着且始终较慢:01

5.8 GiB /秒和7.5 GiB /秒

但是,对于较小的数据,结果会有所不同,但fill(0)速度更快:

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

如果有多个线程,则在4 GiB数据大小下,fill(1)斜率更高,但峰值要低得多fill(0)(51 GiB / s与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当你比较线程数?
加文·波特伍德

1
@GavinPortwood 4 GiB,所以在内存中,而不是缓存中。
Zulan

然后,第二个图一定是有问题的,即弱缩放。我无法想象,要用最少的中间操作来使一个循环达到饱和,需要两个以上的线程来饱和内存带宽。实际上,您尚未确定即使在24个线程时带宽达到饱和的线程数。您能证明它确实在某些有限线程数下趋于平稳吗?
加文·波特伍德

2
我怀疑您原始实验(在第二个套接字上)的异常缩放与非均匀内存分配以及由此产生的QPI通信有关。可与英特尔的“非核心”的PMU(我认为)进行验证
加文Portwood

1
FWIW-您发现答案中的代码有所不同,我认为Peter Cordes有以下答案:即rep stosb使用非RFO协议,该协议将填充所需的事务数量减半。其余的行为大多不在此列。该fill(1)代码还有另一个缺点:它不能使用256位AVX存储,因为您未指定-march=haswell或执行任何操作,因此它必须使用128位代码。fill(0)哪些调用memset获得了libc在您的平台上调用AVX版本的调度的优势。
BeeOnRope

Answers:


43

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

  • fill(0)是一个ERMSBrep 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仅对fill(1)L1有帮助。)

需要注意的是rep movsd(这可能被用来实现fill(1)int元素)将可能执行相同的rep stosb上的Haswell。尽管只有官方文档才能保证ERMSB快速提供rep stosb(但不能保证rep stosd),但支持ERMSB的实际CPU仍使用类似的高效微代码rep stosd。关于IvyBridge可能存在一些疑问,也许只是b速度很快。参见@BeeOnRope的出色ERMSB答案更新,。

gcc有一些针对字符串操作的x86调整选项(例如-mstringop-strategy=alg-mmemset-strategy=strategy),但IDK(如果有的话)会让它实际发送rep movsdfill(1)。可能不是,因为我假设代码以循环而不是a开头memset


如果有多个线程,则在4 GiB数据大小下,fill(1)会显示出较高的斜率,但会比fill(0)达到更低的峰值(51 GiB / s与90 GiB / s):

正常movaps存储到冷缓存行会触发“读取所有权”(RFO)movaps写入前16个字节时,大量的实际DRAM带宽用于从内存读取缓存行。ERMSB存储对其存储使用no-RFO协议,因此内存控制器仅在写入。(除了其他读取,例如页表,即使在L3缓存中也没有页面遍历丢失,并且在中断处理程序中或其他任何原因可能导致某些负载丢失)。

@BeeOnRope在评论解释说,常规RFO存储与ERMSB使用的RFO规避协议之间的差异在服务器CPU上某些缓冲区大小范围内存在不利之处,其中非核心/ L3缓存中存在高延迟。 另请参阅链接的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的向量足以轻松将单个内核的no-RFO带宽饱和到DRAM。当存储CPU绑定的AVX 256b矢量化计算的结果时(即,仅当它节省了解包到128b的麻烦时vmovntps ymm),可能(256b)只是vmovntps xmm(128b)相对(128b)的可衡量的优势。

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


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

另请参阅 标签Wiki。


尽管rep movsdermsb功能尚未正式涵盖,但所有最新的Intel CPU(显然是Ryzen)似乎都使用相同的协议来实现它,并且其最终通常具有难以区分的性能。仍然没有理由使用它,因为rep movsb很多功能都提供了该功能的超集,并且谁知道英特尔和AMD将来会如何对其进行优化,但是与此同时,至少现有的代码可以rep movsd有效地从中受益ermsb
BeeOnRope

3
上面描述的行为rep movsbmovaps单个内核在各种缓冲区大小上的显式循环的行为非常一致,这与我们之前在服务器内核上看到的一致。如您所指出的,竞争是在非RFO协议和RFO协议之间。前者在所有高速缓存级别之间使用较少的带宽,但是特别是在服务器芯片上,一直到到内存的等待时间都较长。由于单个内核通常受并发限制,因此延迟很重要,并且非RFO协议获胜,这是您在30 MB L3之后的区域看到的。
BeeOnRope

3
...在适合L3的图表中间,但是,显然没有发挥作用于内存切换的长服务器的作用,因此非RFO带来的读取减少胜出(但实际上,将其与NT存储:它们会显示相同的行为,还是rep stosb能够在L3处停止写入,而不是一直存储到内存)?FWIW,对于形势rep stosbfill相对好一些,经验,比rep movsbmemcpy。可能是因为前者的流量优势为2:1,而后者的流量优势为3:2。
BeeOnRope

此答案的“延迟绑定平台”标题下,有一些与此主题相关的度量值的链接。它在谈论movsb没有stosb,但相同的一般模式适用。
BeeOnRope

1
我尝试过movntps,如果使用正确,它会显示所有数据大小之间的内存带宽-因此它根本不会从缓存中受益。但是对于单个线程,这是内存带宽的两倍movaps,而对于24个线程,它的带宽略高于rep stosb
Zulan

29

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

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

具体来说,glibcs__memset_avx2和glibcs__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::fillvia可以实现模板/头优化memset,但是在这种情况下,它是编译器优化,可以转换实际的循环。奇怪的是std::vector<char>,gcc也开始优化fill(1)。尽管有memset模板规范,英特尔编译器也不会。

由于仅在代码实际在内存而不是在缓存中工作时才会发生这种情况,因此Haswell-EP体系结构似乎无法有效地合并单字节写操作。

如果您对此问题以及相关的微体系结构有任何进一步的了解,我将不胜感激。特别是对于我来说,目前尚不清楚为什么四个或更多线程的行为如此不同,以及为什么memset在缓存中如此之快。

更新:

这是与之比较的结果

  • 使用-march=native(avx2 vmovdq %ymm0)的fill(1)-在L1中效果更好,但与movaps %xmm0其他内存级别的版本相似。
  • 32、128和256位非临时存储的变体。无论数据大小如何,它们始终以相同的性能运行。所有这些都优于其他内存变体,特别是对于少量线程。128位和256位的性能完全相似,而线程数较少时32位的性能则明显较差。

对于<= 6线程,vmovntrep 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

注意:为了使循环如此紧凑,我必须进行手动指针计算。否则,它可能会在循环内执行矢量索引编制,这可能是由于固有的优化器混乱所致。


3
rep stos 在大多数CPU中都进行了微代码处理(在第189页的Haswell的agner.org/optimize/instruction_tables.pdf表中找到“ REP STOS”及其“ Fused µOps列” )。还要检查CPUID EAX = 7,EBX,第9位“ erms Enhanced REP MOVSB / STOSB”(grep erms /proc/cpuinfo),这是rep stos自Nehalem以来进一步优化的微代码的标志:intel.com/content/dam/www/public/us/en/documents/手册/…“2.5.6 REP字符串增强”和3.7.6 ERMSB。您应该比较PMU计数器以获得有关实现的一些信息。
osgx

3
另外,请检查stackoverflow.com/a/26256216以获取不同的优化memcpy / set(以及CPU的限制),并尝试在software.intel.com/zh-cn/forums上询问特定问题,以获取来自software.intel.com的注意/ en-us / user / 545611。在具有一致性协议的NUMA情况下,Haswell的实际微代码可能会出现一些问题,当某些内存分配到不同numa节点(套接字)的内存中或仅可以在其他节点上分配内存时,则多套接字一致性协议处于活动状态分配缓存行时。还要检查Haswell的勘误表,了解其微代码。
osgx

1
欢迎来到NUMA世界。向量分配了malloc,在第一次触摸放置时可以正确使用,但是它的释放free只会将内存标记为未使用,而不会将内存返回给OS-下次迭代将不会再进行触摸(stackoverflow.com/中关于malloc的一些过时信息)问题/ 2215259,还有一些问题请参见stackoverflow.com/a/42281428 “自2007年以来(glibc 2.9及更高版本)”。使用glibc domalloc_trim()之间bench调用,释放的内存将被标记为对OS可用,并被NUMA处理。堆栈由主线程分配...
osgx

1
Zulan,不,软件不会禁用套接字之间的缓存一致性(不应引导第二个套接字/禁用QPI)。您的E5-2680 v3在MCC(中芯数)芯片(anandtech.com/show/8679/…)上具有12核haswell,并且在访问时有缓存侦听消息:frankdenneman.nl/2016/07/11/…。它们既在本地套接字的环中发送,也通过QPI发送到下一个套接字。Xeon的某些版本可能会使用“目录”来限制此类内存绑定任务中的监听消息风暴。
osgx

1
您还可以检查英特尔MLC - software.intel.com/en-us/articles/intelr-memory-latency-checker测量测试系统的最大带宽mlc --bandwidth_matrixmlc --peak_bandwidth。另外-关于您的Haswell及其缓存一致性的论文
tu-dresden.de/zih/forschung/ressourcen/dateien/…– osgx
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.