为什么写入缓冲区比写入零缓冲区要快42?

Bee*_*ope 10 c++ linux malloc performance memory-management

char *无论存储器1的现有内容如何,我都希望写入缓冲区花费相同的时间.不是吗?

然而,在缩小基准测试中的不一致性时,我遇到了一个显然不是这样的情况.包含全零的缓冲区在性能方面与填充缓冲区的缓冲区有很大不同42.

从图形上看,这看起来像(详情如下):

缓冲写入时间

这是我用来制作上述3的代码:

#include <stdio.h>
#include <stdlib.h>
#include <inttypes.h>
#include <string.h>
#include <time.h>

volatile char *sink;

void process(char *buf, size_t len) {
  clock_t start = clock();
  for (size_t i = 0; i < len; i += 678)
    buf[i] = 'z';
  printf("Processing took %lu ?s\n",
      1000000UL * (clock() - start) / CLOCKS_PER_SEC);
  sink = buf;
}

int main(int argc, char** argv) {
  int total = 0;
  int memset42 = argc > 1 && !strcmp(argv[1], "42");
  for (int i=0; i < 5; i++) {
    char *buf = (char *)malloc(BUF_SIZE);
    if (memset42)
      memset(buf, 42, BUF_SIZE);
    else
      memset(buf,  0, BUF_SIZE);
    process(buf, BUF_SIZE);
  }
  return EXIT_SUCCESS;
}
Run Code Online (Sandbox Code Playgroud)

我在我的Linux机器上编译它,如:

 gcc -O2 buffer_weirdness.cpp -o buffer_weirdness
Run Code Online (Sandbox Code Playgroud)

...当我运行带有零缓冲区的版本时,我得到:

./buffer_weirdness zero
Processing took   12952 ?s
Processing took  403522 ?s
Processing took  626859 ?s
Processing took  626965 ?s
Processing took  627109 ?s
Run Code Online (Sandbox Code Playgroud)

请注意,第一次迭代很快,而剩余的迭代时间可能要长50倍.

首次填充缓冲区时42,处理速度总是很快:

./buffer_weirdness 42
Processing took   12892 ?s
Processing took   13500 ?s
Processing took   13482 ?s
Processing took   12965 ?s
Processing took   13121 ?s
Run Code Online (Sandbox Code Playgroud)

行为取决于`BUF_SIZE(上例中为1GB) - 较大的大小更有可能显示问题,并且还取决于当前的主机状态.如果我单独离开主机一段时间,慢速迭代可能需要60,000μs而不是600,000 - 所以快10倍,但仍然比快速处理时间慢约5倍.最终,时间回归到完全缓慢的行为.

该行为还至少部分取决于透明的大页面 - 如果我禁用它们2,慢速迭代的性能提高约3倍,而快速迭代不变.

最后要注意的是,该过程的运行时间比简单地计算过程例程要紧得多(事实上​​,零填充,THP关闭版本比其他版本快2倍,大致相同).

这里发生了什么?


1在一些非常不寻常的优化之外,例如编译器了解缓冲区已经包含的值和删除相同值的写入,这在此处不会发生.

2 sudo sh -c "echo never > /sys/kernel/mm/transparent_hugepage/enabled"

3它是原始基准的蒸馏版本.是的,我正在泄漏分配,克服它 - 这导致了一个更简洁的例子.最初的例子没有泄露.实际上,当您不泄漏分配时,行为会发生变化:可能是因为malloc可以重新使用该区域进行下一次分配,而不是要求操作系统获得更多内存.

Mar*_*ler 17

这似乎难以重现,所以它可能是编译器/ libc特定的.

我最好的猜测:

当你打电话malloc,你的内存映射到进程空间,这也并不意味着OS已经采取从它的可用内存池所需的网页,但它只是增加了条目到一些表.

现在,当你试图访问那里的内存时,你的CPU/MMU会引发一个错误 - 操作系统可以捕获它,并检查该地址是否属于"已经在内存空间中但尚未实际分配给处理".如果是这种情况,则会找到必要的可用内存并将其映射到进程的内存空间.

现在,现代操作系统通常具有内置选项,可以在(重新)使用之前将页面"清零".如果这样做,memset(,0,)则不需要操作.在POSIX系统的情况下,如果使用calloc而不是malloc,则内存将被清零.

换句话说,您的编译器可能已经注意到并且完全省略了memset(,0,)操作系统支持的时间.这意味着您写入页面process()的那一刻是他们被访问的第一时刻 - 并且触发了操作系统的"动态页面映射"机制.

memset(,42,)当然,can不会被优化掉,因此在这种情况下,页面实际上是预先分配的,并且您没有看到在process()函数中花费的时间.

您应该使用/usr/bin/time实际比较整个执行时间和花费的时间process- 我的怀疑意味着节省的时间process实际上花费在main内核上下文中.

更新:使用优秀的Godbolt编译器资源管理器进行测试:是的,使用-O2-O3,现代gcc简单地省略了零memsetting(或者更确切地说,简单地将其融合calloc,即为零malloc):

#include <cstdlib>
#include <cstring>
int main(int argc, char ** argv) {
  char *p = (char*)malloc(10000);
  if(argc>2) {
    memset(p,42,10000);
  } else {
    memset(p,0,10000);
  }
  return (int)p[190]; // had to add this for the compiler to **not** completely remove all the function body, since it has no effect at all.
}
Run Code Online (Sandbox Code Playgroud)

在gcc6.3上为x86_64成为

main:
        // store frame state
        push    rbx
        mov     esi, 1
        // put argc in ebx
        mov     ebx, edi
        // Setting up call to calloc (== malloc with internal zeroing)
        mov     edi, 10000
        call    calloc 
        // ebx (==argc) compared to 2 ?
        cmp     ebx, 2
        mov     rcx, rax
        // jump on less/equal to .L2
        jle     .L2
        // if(argc > 2):
        // set up call to memset
        mov     edx, 10000
        mov     esi, 42
        mov     rdi, rax
        call    memset
        mov     rcx, rax
.L2:    //else case
        //notice the distinct lack of memset here!
        // move the value at position rcx (==p)+190 into the "return" register
        movsx   eax, BYTE PTR [rcx+190]
        //restore frame
        pop     rbx
        //return
        ret
Run Code Online (Sandbox Code Playgroud)

顺便说一句,如果你删除return p[190],

  }
  return 0;
}
Run Code Online (Sandbox Code Playgroud)

那么编译器就没有理由保留函数体 - 它的返回值很容易在编译时确定,并且它没有副作用.然后整个程序编译成

main:
        xor     eax, eax
        ret
Run Code Online (Sandbox Code Playgroud)

请注意,A xor A0每一个A.

  • 然而,42案是更快的案例.第一遍对两者大致相同; 随后的0次填充是缓慢的情况. (2认同)