在上一篇文章CPU 真正怕的不是计算,而是“找数据”中,我们深入探讨了虚拟内存转换的底层逻辑,以及为什么 TLB 缓存缺失(TLB Miss)会对程序性能造成那么大的影响。
今天,我们来聊一个真正能改变内存访问性能的重量级优化:Huge Pages(大页内存)。
第一次接触这个,是在浏览知乎某篇文章的时候,而那时,我恰恰也在做一个DMP系统,里面存储着大量的数据,所以就将这个技巧应用了起来。后来逐渐了解到,它是很多高频交易系统、数据库、搜索引擎、内存缓存系统、DPDK、Redis、ClickHouse、JVM、大模型推理框架背后的核心优化之一。
在 64 位 x86 架构和 Linux 操作系统中,默认的物理内存是以 4KiB 为一个“页(Page)”进行切块管理的。也就是说,每次你的程序访问一个变量,CPU 都得通过多级页表(通常是 4 级页表,在现代 5 级页表下更甚)进行一次漫长的“页表漫游(Page Walk)”。
虽然 CPU 准备了 TLB(块表) 来缓存这些转换结果,但硬件的资源是极其奢侈且有限的。据了解,一个现代服务器 CPU 核心的 TLB 缓存槽位,满打满算也就几千个。
我们假设每个核的TLB可以缓存 2000 条记录,那么,在默认4KB大小的页面下:
这意味着,一旦你的程序需要频繁读写的数据总量超过了 8MiB,TLB 就会开始疯狂地发生换入换出,而当前很多业务比如redis、RocksDB、ClickHouse以及大型数据存储数据量往往几十G甚至上T,因此,CPU 大量时间其实都浪费在找数据。
如果,我们将页大小改成2MB,那么:
如果换成更大的页面,这个数字还会成倍飙升。
显然,通过切换到 2MiB 大页,单个 TLB 条目覆盖的内存直接暴涨了 512 倍!它不仅降低了页表的嵌套深度,更让TLB 能够轻松锁住数个 GB 的热内存。
通过设置大页的方式,不仅可以降低TLB miss,而且,降低 Page Walk 深度,使得页表层级减少,进而减少cache miss等。同时,降低页表开销:假设我们有1T内存,使用4KB页大小,那么页表本身都可能占用数GB。还可以提升CPU Prefetch。
在真正进入大页之前,我们要明白一个大前提:操作系统必须在物理内存中找到 512 个连续的 4KiB 物理页,才能拼凑出一个 2MiB 的大页。这在内存碎片严重的系统里可不是件容易事。
在 Linux 中大页分为两种:HugeTLBfs 和 Transparent Huge pages( 透明大页 ) 。
其中,Transparent Huge pages( 透明大页 )是 Linux 在十多年前引入的自动化机制。内核后台跑着一个叫 khugepaged 的守护进程,它会在合适的时候,偷偷摸摸地把普通的 4KiB 页合并升级为大页。它有两种模式:
HugeTLBfs是一种更硬核、更受低延迟团队偏爱的机制,也是比较常用的方式。它在系统启动或初始化时,直接从内核划出一块专属的“大页池”。它要求应用程序开发者修改代码才能使用大页,例如,在 mmap() 中使用 MAP_HUGETLB 标志,或者在 shmget() 中使用 SHM_HUGETLB 标志。
下面是一个使用示例:
#include <iostream>#include <string_view>#include <system_error>#include <sys/mman.h>#include <unistd.h>namespace {constexpr std::size_t kHugePageSize = 2 * 1024 * 1024;class MMapRegion {public: MMapRegion(std::size_t size, int prot, int flags) : size_(size) { ptr_ = mmap(nullptr, size_, prot, flags, -1, 0); if (ptr_ == MAP_FAILED) { ptr_ = nullptr; throw std::system_error( errno, std::generic_category(), "mmap failed"); } } ~MMapRegion() { if (ptr_) { munmap(ptr_, size_); } } MMapRegion(const MMapRegion&) = delete; MMapRegion& operator=(const MMapRegion&) = delete; MMapRegion(MMapRegion&& other) noexcept : ptr_(other.ptr_), size_(other.size_) { other.ptr_ = nullptr; other.size_ = 0; } MMapRegion& operator=(MMapRegion&& other) noexcept { if (this != &other) { if (ptr_) { munmap(ptr_, size_); } ptr_ = other.ptr_; size_ = other.size_; other.ptr_ = nullptr; other.size_ = 0; } return *this; }void* data() const noexcept{ return ptr_; }private: void* ptr_{nullptr}; std::size_t size_{0};};void allocate_hugetlbfs(){ std::cout << "\n[Hugetlbfs]\n"; try { MMapRegion region( kHugePageSize, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB); std::cout << "[+] Successfully allocated 2MiB huge page via hugetlbfs\n"; } catch (const std::system_error& e) { std::cerr << "[-] Hugetlbfs allocation failed: " << e.code().message() << '\n'; }}void allocate_thp_madvise(){ std::cout << "\n[Transparent Huge Pages]\n"; try { MMapRegion region( kHugePageSize, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS); if (madvise(region.data(), kHugePageSize, MADV_HUGEPAGE) != 0) { throw std::system_error( errno, std::generic_category(), "madvise(MADV_HUGEPAGE) failed"); } std::cout << "[+] MADV_HUGEPAGE successfully applied\n"; } catch (const std::system_error& e) { std::cerr << "[-] THP setup failed: " << e.code().message() << '\n'; }}} // namespaceint main(){ allocate_hugetlbfs(); allocate_thp_madvise(); return 0;}最后,我们需要说明的是,大页(Huge Pages)不是万能的,对于那些高吞吐、动辄吞吐数十 GiB 数据的内存密集型低延迟应用来说,绝对是性能提升利器。
在使用一项技术优化性能的时候,记住那句老话:多用 perf 去审视硬件,选择最适合你业务的分配策略,永远不要用直觉去代替测量。
如果对本文有疑问可以加笔者微信直接交流,笔者也建了C/C++相关的技术群,有兴趣的可以联系笔者加群。