为啥进程的内存分配很慢并且可以更快?

Posted

技术标签:

【中文标题】为啥进程的内存分配很慢并且可以更快?【英文标题】:Why is memory allocation for processes slow and can it be faster?为什么进程的内存分配很慢并且可以更快? 【发布时间】:2017-02-18 06:20:05 【问题描述】:

我比较熟悉虚拟内存的工作原理。所有进程内存都被划分为页面,虚拟内存的每一页都映射到真实内存中的一个页面或交换文件中的一个页面,或者它可以是一个新页面,这意味着物理页面仍然没有分配。操作系统根据需要将新页面映射到实际内存,而不是当应用程序使用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;

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

$ gcc -O1 ./mem.c && time ./a.out

-O1 是必需的,因为 clang-O2 完全消除了循环并立即计算值。

结果是:user:0.520s,sys:0.008s。根据perf,99% 的时间在memset 来自libc。因此,对于这种情况,写入性能约为 20 GB/s,这比我的内存的理论性能 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);

结果完全一样。我相信free 实际上并没有为操作系统释放内存,它只是将它放在进程中的某个空闲列表中。而malloc 在下一次迭代中获得完全相同的内存块。这就是为什么没有明显差异的原因。

让我们从 1 兆字节开始增加 SIZE。执行时间会一点一点增长,接近 10 MB 时会饱和(10 到 20 MB 对我来说没有区别)。

#define SIZE 10 * 1024 * 1024
for (i = 0; i < 1000; i ++) 
  int *p = (int *) malloc(SIZE);
  memset(p, 0, SIZE);
  free(p);

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

如果我们继续增加内存块大小,在某个时间点(对我来说是 35 Mb)执行时间会急剧增加:用户:0.724 秒,系统:3.300 秒。

#define SIZE 40 * 1024 * 1024
for (i = 0; i < 250; i ++) 
  int *p = (int *) malloc(SIZE);
  memset(p, 0, SIZE);
  free(p);

根据perfmemset 只会消耗 18% 的时间。

显然,内存是从操作系统分配并在每一步释放的。正如我之前提到的,操作系统应该在使用前清除每个分配的页面。所以clear_page_orig 的 27.3% 看起来并不特别:清除内存只需 4 秒 * 0.273 ≈ 1.1 秒——我们在第三个示例中得到的相同。 memset 占用了 17.9%,这导致 ≈ 700 毫秒,这是正常的,因为在 clear_page_orig 之后内存已经在 L3 缓存中(第一个和第二个示例)。

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

结果可在原生 Mac OS、Vmware 下的 Ubuntu 和 Amazon c4.large 实例上重现(略有差异)。

另外,我认为还有两个层面的优化空间:

在操作系统级别。如果操作系统知道它会将一个页面返回给它之前所属的同一个应用程序,它就不能清除它。 在 CPU 级别。如果 CPU 知道该页面曾经是空闲的,它可以不清除内存中的页面。它可以在缓存中清除它,只有在缓存中进行一些处理后才能将其移动到内存中。

【问题讨论】:

您没有具体问题,但我没有在您的个人资料中看到任何异常或意外情况。您所做的只是使用 memset 生成页面错误。 我有具体的问题。 “为什么最后一种情况比(内存的 memset + L3 缓存的 memset)的时间慢 2 倍”。我相信所有其他对页面的操作不应该那么昂贵。 【参考方案1】:

这里发生的事情有点复杂,因为它涉及几个不同的系统,但它绝对与上下文切换成本相关;您的程序很少进行系统调用(使用strace 验证这一点)。

首先,了解malloc 实现的一般工作方式的一些基本原则很重要:

    大多数malloc 实现通过在初始化期间调用sbrkmmap 从操作系统获取大量内存。在某些malloc 实现中可以调整获得的内存量。一旦获得内存,它通常会被切割成不同大小的类并排列在一个数据结构中,这样当程序使用例如malloc(123) 请求内存时,malloc 实现可以快速找到符合这些要求的一块内存。 当您调用free 时,内存将返回到一个空闲列表,并可在后续调用malloc 时重新使用。一些malloc 实现允许您精确调整其工作方式。 当您分配大块内存时,大多数malloc 实现将简单地将大量内存的调用直接传递给mmap 系统调用,该系统调用一次分配“页面”内存。对于大多数系统,1 页内存为 4096 字节。 相关,大多数操作系统将尝试清除内存页面,然后再将它们分发给通过mmapsbrk 请求内存的进程。这就是为什么您会在 perf 输出中看到对 clear_page_orig 的调用。此函数正在尝试将 0 写入内存页。

现在,这些原则与另一个有很多名称但通常被称为“按需寻呼”的想法相交。 “请求分页”的意思是当用户程序向操作系统请求一块内存时(比如通过调用mmap),内存分配在进程的虚拟地址空间中,但没有物理 RAM 支持记忆犹新。

以下是需求分页流程的概要:

    一个名为 mmap 的程序分配 500MB 的 RAM。 内核为请求的 500 MB RAM 映射进程地址空间中的一个地址区域。它将物理 RAM 的“少数”(取决于操作系统)页面(通常每个 4096 字节)映射到支持这些虚拟地址。 用户程序开始通过写入来访问内存。 最终,用户程序将访问一个有效但没有物理 RAM 支持的地址。 这会在 CPU 上产生页面错误。 内核通过看到进程正在访问一个有效地址但没有物理 RAM 支持它来响应页面错误。 内核然后找到要分配给该区域的 RAM。如果需要先将其他进程的内存写入磁盘(“换出”),这可能会很慢。

您在最后一种情况下看到性能下降的最可能原因是:

    您的内核已用完可分配以满足您的 40 MB 请求的零内存页,因此您的 perf 输出证明它一遍又一遍地将内存归零。 当您访问尚未映射的内存时,您正在生成页面错误。由于您访问的是 40mb 而不是 10mb,因此您将产生更多页面错误,因为需要映射的内存页面更多。 正如另一个答案指出的那样,memset 是 O(n),这意味着您需要写入的内存越多,所需的时间就越长。 不太可能,因为如今 40mb 的 RAM 并不多,但请检查系统上的可用内存量,以确保您有足够的 RAM。

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

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

我会提醒您,这种优化级别几乎总是一个错误;大多数应用程序的优化效果要低得多,不涉及优化页面错误成本。在实际应用中,我建议:

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

如果您有任何问题,请留下 cmets,如果需要,我很乐意编辑这篇文章并对此进行一些扩展。

【讨论】:

【参考方案2】:

我不确定,但我愿意打赌上下文从用户模式切换到内核的成本,然后再切换回来,将主导其他一切。 memset 也需要大量时间——记住这将是 O(n)。

更新

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

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

现在有许多 slicker 实现,请参阅 this Wikipedia article。

【讨论】:

以上是关于为啥进程的内存分配很慢并且可以更快?的主要内容,如果未能解决你的问题,请参考以下文章

为啥在vmware上装的linux系统反应很慢?

为啥分配堆内存比分配堆栈内存快得多?

内存不足错误发生在堆大小高但分配大小低的情况下。为啥?

为啥没有给另一个进程访问内存位置的权限?

为啥我们不能在堆栈上分配动态内存?

iOS进程内存分配(页、栈、堆)