hugepages 的优缺点

之前的文章中,我写了关于如何全局检查和启用 Linux 中的透明 hugepages。

虽然如果你确实有 hugepages 的用例,这篇文章很重要,但我看到多个人被 hugepages 会神奇地提高性能的前景所欺骗。然而,hugepaging 是一个复杂的主题,如果使用不当,可能很容易降低整体性能。

本文试图解释完全不使用、有选择地使用 hugepages 的优点、缺点和注意事项。由于技术性强或过于精确的文章可能对经常被 hugepages 欺骗的用户来说不易理解,我将为简单性牺牲准确性。请记住,大多数主题确实很复杂,因此被大大简化了。

注意我们这里讨论的是运行 Linux 的 64 位 x86 系统,我仅假设系统实现了透明 hugepages(即 hugepages 不可交换不是缺点),因为几乎所有最近的 Linux 环境都是这种情况。

更多技术描述将在下面的链接中提供。

虚拟内存

如果你是 C++ 程序员,你知道内存中的对象有某些地址(即指针的值)。

然而,这些地址不一定代表物理地址(即 RAM 中的地址)。它们代表虚拟内存中的地址。你的 CPU 有一个 MMU(内存管理单元)硬件,协助内核将虚拟内存映射到物理位置。

此方法有许多优点,但主要用于

什么是页?

虚拟内存空间被划分为页。

每个单独的页指向某些物理内存 - 它可能指向物理 RAM 的一部分,但也可能指向分配给物理设备(如显卡)的地址。

你处理的大多数页要么指向 RAM,要么被换出,即存储在 HDD 或 SSD 上。

内核管理每个页的物理位置。如果访问了已换出的页,内核会停止尝试访问内存的线程,将页从 HDD/SSD 读入 RAM,然后继续运行线程。

此过程对线程是透明的,即它不必显式从 HDD/SSD 读取。

普通页长 4096 字节。Hugepages 大小为 2 兆字节。

转译后备缓冲区 (TLB)

当程序访问某个内存页时,CPU 需要知道从哪个物理页读取数据(即虚拟到物理地址映射)。

内核包含一个数据结构(页表),包含所有使用中的页的所有信息。使用此数据结构,我们可以将虚拟地址映射到物理地址。

然而,页表相当复杂和慢,我们根本无法在每次某个进程访问内存时解析整个数据结构。

幸运的是,我们的 CPU 包含硬件 - TLB - 缓存虚拟到物理地址映射。这意味着虽然你第一次访问页时必须解析页表,但后续对页的所有访问都可以由 TLB 处理,这非常快!

但由于它用硬件实现(这使它首先很快),它也只有有限的容量。所以如果你访问大量页,TLB 无法存储所有页的映射。这会使你的程序慢得多。

Hugepages 来救援

那么我们能做什么(假设程序仍需要相同数量的内存)来避免 TLB 被填满?

这就是 hugepages 的用武之地。一个 TLB 条目现在可以指向高达 2 兆字节,而不是 4096 字节"消耗"一个 TLB 条目

所以如果我们假设 TLB 有 512 个条目,没有 hugepages 我们可以映射

$$4096\ \text{b} \cdot 512 = 2\ \text{MB}$$

但有了 hugepages 我们可以映射

$$2\ \text{MB} \cdot 512 = 1\ \text{GB}$$

所以 hugepages 很棒 - 它们几乎无需努力就能大幅提高性能。但它们并非没有注意事项。

交换 hugepages

你的内核自动跟踪每个内存页的使用频率。如果可用物理内存(即 RAM)不足,你的内核会将不太重要(即不太常用)的页移动到硬盘上,为更重要的页释放一些 RAM。

原则上,hugepages 也是如此。但内核只能交换整个页 - 不能交换单个字节。

假设我们有这样的程序:

hugepage_swap_example.cpp
char* mymemory = malloc(2*1024*1024); //我们假设这是一个 hugepage!
// 用一些数据填充 mymemory
// 做很多其他事情,
// 导致 mymemory 页被换出
// ...
// 仅访问第一个字节
putchar(mymemory[0]);

在这种情况下,内核需要从 HDD/SSD 换入(即读取)整个 2 兆字节,只为你读取单个字节。使用普通页,只需从 HDD/SSD 读取 4096 字节。

所以如果 hugepage 被交换,只有当你需要访问几乎整个 hugepage 时读取它才更快。这意味着如果你随机访问内存的不同部分并且只读取几千字节,你应该只使用普通的旧页而不必担心

另一方面,如果你需要顺序访问大部分内存,hugepages 可能会提高你的性能。尽管如此,你需要使用你的程序(不是某些抽象的基准测试软件!)进行基准测试,看看启用或不启用 hugepages 是否更快。

内存分配

作为 C 程序员,你知道你可以使用 malloc(). 从堆中请求任意小(或几乎任意大)的内存量

假设你请求 30 字节的内存:

malloc_small_example.c
char* mymemory = malloc(30);

对程序员来说,看起来你从操作系统"请求"30 字节内存并得到一个指向某些虚拟内存的指针。

但实际上,malloc() 只是一个 C 函数,内部调用 brk 和 sbrk 函数从操作系统请求或释放内存。

然而,为每个小分配从操作系统请求越来越多的内存是低效的 - 很可能某个内存段已被 free()d,我们可以重用该内存。malloc() 实现了相当复杂的算法来重用 free()d 的内存。

但这对你都是透明的 - 那么你为什么要关心?因为这也意味着调用 free() 并不意味着内存不一定立即返回给操作系统

There is a type of issue called memory fragmentation. In extreme cases there are segments of the heap where only a few bytes are used while everything in between has been free()d.

在大多数情况下,内核只会换出大部分未使用的内存,因为

如果你为程序的整个内存使用 hugepages(即不是如下所示有选择地使用),这可能

注意内存碎片是一个非常复杂的问题,程序的微小变化可能会显著影响内存碎片。在大多数情况下,程序不会导致显著的内存碎片,但你应该记住,如果堆的某个区域存在内存碎片问题,hugepages 可能使问题更严重。

选择性 hugepaging

使用本文中的信息,你已识别出程序中某些部分可以从 hugepages 中受益,但程序的其他部分不会从 hugepages 中受益。你应该启用 hugepages 还是不启用?

幸运的是,你可以使用 madvise() 仅在受益的内存区域启用 hugepaging

首先,检查 hugepages 是否以 madvise 模式启用(请参见此博客文章)。

然后,使用 madvise 告诉内核 hugepages 应用于

madvise_example.cpp
#include <sys/mman.h>

// 分配大量你有某些用途的内存
size_t size = 256*1024*1024;
char* mymemory = malloc(size);
// 要么只启用 hugepages ...
madvise(mymemory, size, MADV_HUGEPAGE);
// ... 或也告诉
madvise(mymemory, size, MADV_HUGEPAGE | MADV_SEQUENTIAL)

注意这只是向内核推荐一些内存管理方面。它并不自动意味着内核将实际为给定内存使用 hugepages。

请参考 madvise manpage 获取更多详情 - 关于内存管理和 madvise 有很多要学习的,该主题有非常陡峭的学习曲线。所以如果你打算走这条路,准备阅读、测试和基准测试几周才能期望任何积极结果。

想阅读更多?

IBM OpenStack 关于 hugepages 的文章 透明 hugepages 与非透明 hugepages Wikipedia 上关于 TLB 的文章Linux 内核关于透明 hugepages 的文档StackOverflow:Hugepages 容易出现内存碎片Microsoft 关于管理虚拟内存


Check out similar posts by category: C/C++, Performance