【问题标题】:Why is writing to a buffer filled with 42 way faster than writing to a buffer of zeros?为什么写入填充 42 路的缓冲区比写入零缓冲区快?
【发布时间】:2017-01-06 23:51:42
【问题描述】:

我希望写入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;
}

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

 gcc -O2 buffer_weirdness.cpp -o buffer_weirdness

...当我使用零缓冲区运行版本时,我得到:

./buffer_weirdness zero
Processing took   12952 μs
Processing took  403522 μs
Processing took  626859 μs
Processing took  626965 μs
Processing took  627109 μs

请注意,第一次迭代很快,而剩余的迭代可能需要 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

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

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

最后一点是,进程的 total 运行时间比简单地计时 process 例程要近得多(事实上,零填充,THP 关闭版本大约是比其他速度快 2 倍,大致相同)。

这是怎么回事?


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

2sudo sh -c "echo never &gt; /sys/kernel/mm/transparent_hugepage/enabled"

3 这是原始基准的提炼版本。是的,我泄漏了分配,克服它 - 它导致了一个更简洁的例子。原始示例没有泄漏。实际上,当您不泄漏分配时,行为会发生变化:可能是因为 malloc 可以将区域重新用于下一次分配,而不是向操作系统请求更多内存。

【问题讨论】:

  • 无法重现,观察到的行为很可能是编译器/操作系统/硬件特定的
  • 我无法重现时序的大小;对于 0 或 42,我得到 0-1µs。
  • @Slava - 我在几个不同的 Linux 机器上复制了它,但它似乎与物理内存的数量有关 - 在具有更多 RAM 的系统上,我需要增加 BUF_SIZE 或运行更多反复看效果。就像有一些有限的资源,与内存成正比,被用完了。
  • 你运行了多少次这个实验?
  • 在互联网上搜索“数据缓存命中未命中”。

标签: c++ linux performance memory-management malloc


【解决方案1】:

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

我的最佳猜测:

当您调用malloc 时,您会将内存映射到您的进程空间,这意味着操作系统已经从其池中获取了必要的页面释放内存,但它只是在某些表中添加了条目。

现在,当您尝试访问那里的内存时,您的 CPU/MMU 将引发错误 - 操作系统可以捕获该错误,并检查该地址是否属于“已经在内存空间中,但实际上还没有”的类别分配给进程”。如果是这种情况,则会找到必要的空闲内存并将其映射到进程的内存空间。

现在,现代操作系统通常有一个内置选项,可以在(重新)使用之前“清零”页面。如果你这样做,memset(,0,) 操作就变得不必要了。对于 POSIX 系统,如果您使用 calloc 而不是 malloc,则内存将清零。

换句话说,您的编译器可能已经注意到这一点,并在您的操作系统支持时完全省略了memset(,0,)。这意味着您写入process() 中的页面的那一刻是它们被访问的第一刻——这会触发您操作系统的“动态页面映射”机制。

memset(,42,) 当然不能被优化掉,所以在这种情况下,页面实际上是预先分配的,你看不到 process() 函数花费的时间。

您应该使用/usr/bin/time 来实际比较整个执行时间与process 中花费的时间——我的怀疑意味着process 中保存的时间实际上是在main 中花费的,可能在内核上下文中。

更新:使用出色的 Godbolt Compiler Explorer 进行测试:是的,使用 -O2-O3,现代 gcc 只是省略了零内存设置(或者更确切地说,只是将其融合到 @987654336 @,即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.
}

变成,对于 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

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

  }
  return 0;
}

那么编译器根本没有理由保留函数体——它的返回值在编译时很容易确定,而且没有副作用。然后整个程序编译为

main:
        xor     eax, eax
        ret

请注意,对于每个 AA xor A 都是 0

【讨论】:

  • 42 的情况是更快的一种。两者的第一遍大致相同;随后的 0 填充是缓慢的情况。
  • 我在问题的最后确实注意到 total 运行时是可比较的。最初的问题是,对于基准测试,定时区域很重要。有趣的是,malloc 肯定不会总是返回归零的内存(您经常可以在其中观察到垃圾),因此编译器必须做出某种飞跃才能知道 memset 实际上可以被省略。跨度>
  • 这个答案提供了一个似是而非的分析。 @BeeOnRope 验证它的最佳方法是只分配一次缓冲区 - 也就是说,将 malloc 放在循环之外。我无法重现您的结果,但我只做了一次 one 分配和许多具有不同初始化的循环。我观察到所有循环中的第一个 是最慢的,几乎是所有其他循环的两倍。无论初始值如何(memset),其余循环的数量几乎完全相同。我同意这个答案的分析。
  • @BeeOnRope 我这样做了,但正如我所说,我无法重现您的结果(我没有观察到任何有意义的差异),但您的观察结果可能特定于您的平台。不过,这个问题很有趣。
  • @A.S.H 指出了一个在线资源(godbolt Compiler Explorer),它可以更轻松地在汇编程序级别跨多个平台复制事物。
猜你喜欢
  • 2016-04-17
  • 2012-02-04
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多