为什么进程的内存分配速度慢而且速度更快?

hom*_*omm 27 c operating-system memory-management linux-kernel

我比较熟悉虚拟内存的工作原理.所有进程内存分为页面,虚拟内存的每个页面映射到实际内存中的页面或交换文件中的页面,或者它可以是新页面,这意味着仍未分配物理页面.操作系统会根据需要将新页面映射到实际内存,而不是在应用程序请求内存时malloc,而是仅在应用程序实际访问分配的内存中的每个页面时.但我还有疑问.

当我用linux perf工具分析我的应用程序时,我注意到了这一点.

在此输入图像描述

有时间的20%左右了内核函数:clear_page_orig,__do_page_faultget_page_from_free_list.这比我预期的任务要多得多,我做了一些研究.

让我们从一些小例子开始:

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

#define SIZE 1 * 1024 * 1024

int main(int argc, char *argv[]) {
  int i;
  int sum = 0;
  int *p = (int *) malloc(SIZE);
  for (i = 0; i < 10000; i ++) {
    memset(p, 0, SIZE);
    sum += p[512];
  }
  free(p);
  printf("sum %d\n", sum);
  return 0;
}
Run Code Online (Sandbox Code Playgroud)

假设这memset只是一些内存绑定处理.在这种情况下,我们一次分配一小块内存并一次又一次地重复使用.我会像这样运行这个程序:

$ gcc -O1 ./mem.c && time ./a.out
Run Code Online (Sandbox Code Playgroud)

-O1因为需要clang-O2完全消除了环路,并即时计算的值.

结果是:用户:0.520s,sys:0.008s.根据perf,99%的时间是memset来自libc.因此,对于这种情况,写入性能大约为20千兆字节/秒,这超过了我的内存12.5 Gb/s的理论性能.看起来这是由于L3 CPU缓存.

让我们改变测试并开始在循环中分配内存(我不会重复相同的代码部分):

#define SIZE 1 * 1024 * 1024
for (i = 0; i < 10000; i ++) {
  int *p = (int *) malloc(SIZE);
  memset(p, 0, SIZE);
  free(p);
}
Run Code Online (Sandbox Code Playgroud)

结果完全一样.我相信free实际上并没有为操作系统释放内存,它只是把它放在进程中的一些空闲列表中.而malloc对下一个迭代只得到完全相同的内存块.这就是为什么没有明显的区别.

让我们从1兆字节开始增加SIZE.执行时间将逐渐增长,并将在10兆字节附近饱和(对我来说10到20兆字节之间没有差别).

#define SIZE 10 * 1024 * 1024
for (i = 0; i < 1000; i ++) {
  int *p = (int *) malloc(SIZE);
  memset(p, 0, SIZE);
  free(p);
}
Run Code Online (Sandbox Code Playgroud)

时间显示:用户:1.184s,sys:0.004s.perf仍有99%的时间报告memset,但吞吐量约为8.3 Gb/s.那时,我或多或少地了解发生了什么.

如果我们将继续增加内存块大小,在某些时候(对于我35 Mb),执行时间将急剧增加:用户:0.724s,sys:3.300s.

#define SIZE 40 * 1024 * 1024
for (i = 0; i < 250; i ++) {
  int *p = (int *) malloc(SIZE);
  memset(p, 0, SIZE);
  free(p);
}
Run Code Online (Sandbox Code Playgroud)

据说perf,memset只消耗18%的时间.

在此输入图像描述

显然,内存是从OS分配的,并在每一步中释放.正如我之前提到的,OS应该在使用前清除每个分配的页面.所以27.3%clear_page_orig看起来并不特别:对于清晰的mem来说它只有4s*0.273≈1.1sec - 我们在第三个例子中得到的结果相同.memset占17.9%,这导致≈700毫秒,这是正常的,因为在clear_page_orig第一和第二个例子之后已经存在于L3缓存中的存储器.

我无法理解 - 为什么最后一种情况比memset内存+ memsetL3缓存慢2倍?我可以用它做点什么吗?

结果在本机Mac OS,Vmware下的Ubuntu和Amazon c4.large实例上是可重现的(差异很小).

此外,我认为在两个层面上有优化空间:

  • 在操作系统级别.如果OS知道它将页面返回到它之前所属的同一个应用程序,则无法清除它.
  • 在CPU级别上.如果CPU知道该页面曾经是空闲的,则无法清除内存中的页面.只需在缓存中进行一些处理后,它就可以在缓存中清除它并将其移动到内存中.

Joe*_*ato 27

这里发生的事情是有点复杂,因为它涉及到几个不同的系统,但它肯定是相关的上下文切换成本; 你的程序只进行很少的系统调用(通过使用strace验证这一点).

首先,了解有关实现方式malloc通常工作方式的一些基本原则非常重要:

  1. 大多数malloc实现通过调用sbrkmmap在初始化期间从OS获得一堆内存.在一些malloc实现中可以调整所获得的存储量.一旦获得存储器,通常将其切割成不同的大小类并且以数据结构排列,使得当程序例如请求存储器时malloc(123),malloc实现可以快速找到匹配这些要求的存储器.
  2. 当您调用时free,内存将返回到空闲列表,并可在后续调用时重新使用malloc.某些malloc实现允许您精确调整其工作方式.
  3. 当你分配大块内存时,大多数malloc实现只会将大量内存的mmap调用直接传递给系统调用,系统调用会在时间上分配内存的"页面".对于大多数系统,1页内存为4096字节.
  4. 相关的,大多数操作系统会尝试清除内存页面,然后再将它们分发给通过mmap或请求内存的进程sbrk.这就是你clear_page_orig在perf输出中看到调用的原因.此函数正在尝试将0写入内存页面.

现在,这些原则与另一个有很多名称的想法相交,但通常被称为"请求分页"."请求分页"意味着当用户程序从OS请求一块内存(例如通过调用mmap)时,内存被分配在进程的虚拟地址空间中,但是还没有物理RAM支持该内存.

以下是需求调页过程的概述:

  1. 一个程序调用mmap来分配500MB的RAM.
  2. 内核映射进程'地址空间中的地址区域,用于请求的500 MB RAM.它映射物理RAM的"少量"(OS依赖)页面(通常每个4096字节)以支持这些虚拟地址.
  3. 用户程序通过写入来开始访问内存.
  4. 最终,用户程序将访问一个有效的地址,但没有物理RAM支持它.
  5. 这会在CPU上生成页面错误.
  6. 内核通过查看进程正在访问有效地址来响应页面错误,但没有物理RAM支持它.
  7. 然后内核找到要分配给该区域的RAM.如果需要先将其他进程的内存写入磁盘("换出"),这可能会很慢.

您在最后一个案例中看到性能下降的最可能原因是:

  1. 你的内核已经耗尽了零内存页面,可以分配这些内存以满足你的40 MB请求,因此你的perf输出就可以反复归零内存.
  2. 当您访问尚未映射的内存时,您正在生成页面故障.由于您正在访问40mb而不是10mb,因此需要映射更多内存页面,因此会产生更多页面错误.
  3. 正如另一个答案所指出的那样,memsetO(n)意味着您需要写入的内存越多,所需的时间就越长.
  4. 不太可能,因为现在40mb的内存不多,但检查系统上的可用内存量是为了确保你有足够的内存.

如果您的应用程序对性能非常敏感,则可以mmap直接调用:

  1. 传递MAP_POPULATE标志,这将导致所有页面错误发生在前面并映射所有物理内存 - 然后你将不会支付访问页面错误的成本.
  2. 传递MAP_UNINITIALIZED标志,该标志将在将内容分发到您的进程之前尝试避免将内存页置零.请注意,使用此标志是一个安全问题,除非您完全理解使用此选项的含义,否则不应使用此标志.可能会发出进程的内存页面,这些页面被其他不相关的进程用于存储敏感信息.另请注意,必须编译内核以允许此选项.大多数内核(如AWS Linux内核)默认情况下不启用此选项.您几乎肯定不会使用此选项.

我会提醒你,这种优化水平几乎总是一个错误; 对于优化而言,大多数应用程序具有低得多的悬挂效果,而不涉及优化页面错误成本.在现实世界的应用程序中,我建议:

  1. memset除非真的有必要,否则不要使用大块内存.大多数情况下,在通过相同过程重新使用之前将存储器归零是不必要的.
  2. 避免一遍又一遍地分配和释放相同的内存块; 也许你可以简单地预先分配一个大块,然后根据需要重新使用它.
  3. 使用MAP_POPULATE上面的标志,如果访问页面的成本错误确实对性能有害(不太可能).

如果您有任何疑问,请留下评论,我很乐意编辑这篇文章,如果需要,可以稍微扩展一下.


Cha*_*tin 6

我不确定,但是我愿意打赌从用户模式到内核的上下文切换成本,然后又回到了其他一切.memset也需要很长时间 - 记住它将是O(n).

更新

我相信free实际上并没有为操作系统释放内存,它只是把它放在进程中的一些空闲列表中.并且下一次迭代中的malloc只能获得完全相同的内存块.这就是为什么没有明显的区别.

原则上,这是正确的.经典malloc实现在单链表上分配内存; free只需设置一个标志,表示不再使用分配.随着时间的推移,malloc重新分配第一次它可以找到足够大的空闲块.这种方法效果很好,但可能导致碎片化.

现在有许多更流畅的实现,请参阅这篇维基百科文章.