为什么向填充了42的缓冲区写入比向零缓冲区写入快得多?

10

我会期望对一个char *缓冲区的写入,在内存中原有内容的差异不会影响它们的运行时间1。你也是这样认为吗?

然而,当我在追查基准测试中的一致性问题时,我发现了一个看似不成立的案例。一个全部为零的缓冲区在性能上与一个填充了42的缓冲区有很大的差别。

图形化地表示,如下所示(细节请参阅以下内容):

Buffer Write Time

以下是我用于生成上述结果的代码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微秒,而不是600,000微秒-因此快10倍,但仍然比快处理时间慢约5倍。最终,时间会恢复到完全缓慢的行为。
行为至少部分取决于透明巨页-如果我禁用它们2,则缓慢迭代的性能将提高约3倍,而快速迭代则不变。
最后要注意的是,进程的运行时间比仅计时进程例程要接近得多(实际上,填充为零、THP关闭版本比其他版本快大约2倍,它们大致相同)。
这里发生了什么?
1除了编译器理解缓冲区已经包含的值并省略写入相同值的情况之外,其他情况都在这里没有发生。
2sudo sh -c "echo never > /sys/kernel/mm/transparent_hugepage/enabled"
3这是原始基准测试的浓缩版本。是的,我泄漏了分配,但这导致了更简洁的示例。原始示例没有泄漏。实际上,当您不泄漏分配时,行为会发生变化:可能是因为malloc只能重用下一个分配的区域,而不是向操作系统请求更多内存。

2
无法复现,观察到的行为很可能是编译器/操作系统/硬件特定的。 - Slava
2
我无法重现时间的数量级;对于0或42,我得到了0-1微秒。 - chepner
@Slava - 我在几个不同的Linux系统上重现了它,但似乎与物理内存的数量有关 - 在具有更多RAM的系统上,我需要增加BUF_SIZE或运行更多迭代才能看到效果。就像存在一些有限的资源,与内存成比例,会被用尽。 - BeeOnRope
您IP地址为143.198.54.68,由于运营成本限制,当前对于免费用户的使用频率限制为每个IP每72小时10次对话,如需解除限制,请点击左下角设置图标按钮(手机用户先点击左上角菜单按钮)。 - Slava
1
在互联网上搜索“数据缓存命中与未命中”。 - Thomas Matthews
显示剩余13条评论
1个回答

18

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

我最好的猜测:

当你调用 malloc 时,你会得到映射到你进程空间中的内存,这并不意味着操作系统已经从其自由内存池中获取了必要的页面,而只是在一些表中添加了条目。

现在,当你试图访问那里的内存时,你的 CPU/MMU 将引发故障 - 操作系统可以捕获它,并检查该地址是否属于“已经存在于内存空间中,但尚未实际分配给进程”的类别。如果是这样,就会找到必要的空闲内存并将其映射到你的进程内存空间中。

现代操作系统通常具有内置选项,在(重新)使用之前“清零”页面。如果你这样做,memset(,0,) 操作变得不必要。在 POSIX 系统的情况下,如果你使用 calloc 而不是 malloc,则内存被清零。

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

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

你应该使用 /usr/bin/time 来比较整个执行时间与 process 中花费的时间 - 我怀疑你节省的时间实际上用在了 main 中,可能在内核上下文中。

更新:在优秀的Godbolt Compiler Explorer上测试:是的,使用 -O2-O3,现代的 gcc 简单地省略了零清除(或者更准确地说,将其融合到 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.
}

对于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


2
42案例是更快的一个,尽管如此。第一遍对于两者来说大致相同;随后的0填充是较慢的情况。 - chepner
我在问题的最后确实注意到了总运行时间是可比较的。最初的问题是对于基准测试,计时区域是最重要的。有趣的是,malloc并不总是返回清零的内存(你经常可以观察到垃圾),所以编译器必须做出某种飞跃才能知道memset实际上可以被省略。 - BeeOnRope
@A.S.H - 为什么不尝试使用发布的代码并查看是否获得相同结果?问题肯定会在对同一缓冲区进行_subsequent_ process调用时消失(它们几乎和上面显示的“快速”情况一样快)。 - BeeOnRope
1
@BeeOnRope 我已经这样做了,但正如我所说,我无法重现您的结果(我没有观察到任何有意义的差异),但是您的观察结果可能特定于您的平台。不过这是一个有趣的问题。 - A.S.H
1
@A.S.H指向了一个在线资源(godbolt编译器浏览器),它可以在汇编级别上更轻松地在多个平台上复制东西。 - Marcus Müller
显示剩余7条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接