在 Linux 上使用大页(Huge Pages)
在大工作集上做随机内存访问的应用,瓶颈往往不仅仅是缓存未命中,还包括 TLB 未命中。TLB(Translation Lookaside Buffer)是 CPU 对虚拟地址到物理地址映射的缓存。在默认的 4 KiB 页面下,一个 2048 项的 TLB 只能覆盖 8 MiB 工作集。切换到 2 MiB 页,覆盖范围扩展到 4 GiB —— TLB 覆盖范围 512 倍的提升。
判断你是否需要大页
下面这个基准使用一个大型哈希表做随机查找,是一种会重度压测 TLB 的工作负载。
#include <absl/container/flat_hash_map.h>
#include <nmmintrin.h>
int main() {
struct hash {
size_t operator()(size_t h) const noexcept {
return _mm_crc32_u64(0, h);
}
};
size_t iters = 10000000;
absl::flat_hash_map<size_t, size_t, hash> ht;
ht.reserve(iters);
for (size_t i = 0; i < iters; ++i) ht.try_emplace(i, i);
}
用 -O3 -mavx -DNDEBUG 编译,在 AMD Ryzen 3900X 上 profile:
Performance counter stats for './a.out':
70,080 faults:u
20,802,877 dTLB-loads:u
19,436,707 dTLB-load-misses:u # 占所有 dTLB 命中的 93.43%
32,872,323 cache-misses:u # 占所有 cache refs 的 52.279%
62,878,289 cache-references:u
0.708913859 seconds time elapsed
93% 的数据 TLB 访问都未命中。工作集超出了 4 KiB 页下 L2 TLB 覆盖的 8 MiB。几乎每次 LLC miss 都伴随一次 TLB miss,导致通过缓存或主存做一次页表 walk。
经验法则:当 dTLB-load-misses / dTLB-loads 高于大约 5–10%、且工作集大于几 MB 时,大页会有帮助。
方案 1:用 madvise 启用透明大页
Linux 的透明大页(THP)功能可以让内核自动把 4 KiB 页提升为 2 MiB 页。在 madvise 模式下(默认),只对显式标记的区域才做提升:
#include <stdlib.h>
#include <sys/mman.h>
template <typename T>
struct thp_allocator {
static constexpr std::size_t huge_page_size = 1 << 21; // 2 MiB
using value_type = T;
thp_allocator() = default;
template <class U>
constexpr thp_allocator(const thp_allocator<U>&) noexcept {}
T* allocate(std::size_t n) {
if (n > std::numeric_limits<std::size_t>::max() / sizeof(T))
throw std::bad_alloc();
void* p = nullptr;
if (posix_memalign(&p, huge_page_size, n * sizeof(T)) != 0)
throw std::bad_alloc();
madvise(p, n * sizeof(T), MADV_HUGEPAGE);
return static_cast<T*>(p);
}
void deallocate(T* p, std::size_t) { std::free(p); }
};
posix_memalign 将分配按 2 MiB 边界对齐,让内核更有可能用大页为其做后备。随后的 madvise 把这段区域标记为”可被提升”的候选。
局限:
- THP 提升并非保证。内核需要一段连续、按 2 MiB 对齐的物理区域,它可能不可用。
- 这个技巧只对至少一个大页(2 MiB)的分配有用,更小的分配不会受益。
- THP 可能因为后台合并带来延迟毛刺。对延迟敏感的应用,请使用显式大页(见下文)。
方案 2:用 MAP_HUGETLB 使用显式大页
带 MAP_HUGETLB 的 mmap 直接从内核的大页池分配,保证大页后备:
#include <sys/mman.h>
template <typename T>
struct huge_page_allocator {
static constexpr std::size_t huge_page_size = 1 << 21; // 2 MiB
using value_type = T;
huge_page_allocator() = default;
template <class U>
constexpr huge_page_allocator(const huge_page_allocator<U>&) noexcept {}
static size_t round_up(size_t n) {
return (((n - 1) / huge_page_size) + 1) * huge_page_size;
}
T* allocate(std::size_t n) {
if (n > std::numeric_limits<std::size_t>::max() / sizeof(T))
throw std::bad_alloc();
auto p = static_cast<T*>(mmap(
nullptr, round_up(n * sizeof(T)),
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB,
-1, 0));
if (p == MAP_FAILED) throw std::bad_alloc();
return p;
}
void deallocate(T* p, std::size_t n) {
munmap(p, round_up(n * sizeof(T)));
}
};
预分配大页池
MAP_HUGETLB 用的大页来自一个预留池。在应用启动前先预留:
# 启动时通过内核命令行
hugepages=64
# 运行时
echo 64 > /proc/sys/vm/nr_hugepages
把 allocator 与标准容器一起用:
std::vector<int, huge_page_allocator<int>> v;
absl::flat_hash_map<K, V, Hash, Eq, huge_page_allocator<std::pair<const K,V>>> ht;
这种方案最适合少数几个大型、生命周期长的分配(哈希表、环形缓冲区、包数据池)。
方案 3:mimalloc
mimalloc(微软的高性能分配器)能通过 LD_PRELOAD 给所有分配透明地加上大页支持,无需改代码。
2 MiB 大页
MIMALLOC_EAGER_COMMIT_DELAY=0 MIMALLOC_LARGE_OS_PAGES=1 \
LD_PRELOAD=./libmimalloc.so \
perf stat -e 'faults,dTLB-loads,dTLB-load-misses,cache-misses,cache-references' \
./a.out
658 faults:u
8,717,125 dTLB-loads:u
6,320 dTLB-load-misses:u # 0.07%
23,104,208 cache-misses:u
36,081,035 cache-references:u
0.543847504 seconds time elapsed
TLB miss 率从 93% 降到 0.07%。在 2 MiB 页下,2048 项的 L2 TLB 覆盖 4 GiB —— 对这个工作负载绰绰有余。
1 GiB 大页
需要内核启动参数 hugepagesz=1G hugepages=4。
MIMALLOC_EAGER_COMMIT_DELAY=0 MIMALLOC_RESERVE_HUGE_OS_PAGES=4 \
LD_PRELOAD=libmimalloc.so ./a.out
532 faults
639,907 dTLB-loads
7,869 dTLB-load-misses # 1.23%
25,401,262 cache-misses
70,739,506 cache-references
0.598358478 seconds time elapsed
反直觉的是,这里 1 GiB 页略慢。工作集在 2 MiB 大页覆盖范围之内,更大的页面大小没有额外的 TLB 收益,反而带来了一点内部碎片的开销。
小结
| 方案 | 是否保证 | 代码改动 | 延迟风险 |
|---|---|---|---|
THP(MADV_HUGEPAGE) | 无 | Allocator | 后台合并可能带来毛刺 |
MAP_HUGETLB | 是 | Allocator + 池配置 | 无 |
| mimalloc | 是(开 LARGE_OS_PAGES) | 无(LD_PRELOAD) | 无 |
当 TLB miss 是瓶颈(用 perf stat 验证)时,2 MiB 页几乎永远是正确的选择。1 GiB 页只在工作集超出 2 MiB 页覆盖范围时才有帮助 —— 通常是几个 GB 的量级。