← Back to Blog
EN中文

内存的“巨型”引擎:基于 Huge Page 的多级内存分配器设计

在通用编程中,我们习惯了 mallocfree(或者 Zig 中的 allocator.alloc)。但在高性能系统编程领域,尤其是数据库和高频交易系统,通用分配器的性能往往不够看。缺页中断(Page Fault)和 TLB(Translation Lookaside Buffer)未命中是隐形的性能杀手。

今天我们将探讨一种专门针对 x86_64 巨型页(Huge Pages, 2MB/1GB)优化的内存分配器设计。我们将使用 Zig 语言——现代系统编程的新星——来演示如何构建一个利用巨型页和位掩码(Bitmask)管理的分配器原型。

为什么需要 Huge Pages?

操作系统默认管理内存的最小单位通常是 4KB。对于一个占用 64GB 内存的应用,这意味着有 16,777,216 个页表项。CPU 的 TLB 缓存根本装不下这么多映射关系,导致频繁的 TLB Miss,迫使 CPU 查表,浪费大量周期。

如果使用 2MB 的 Huge Page,页表项数量瞬间减少 512 倍。这不仅减少了 TLB Miss,还显著降低了缺页中断的频率。

核心设计:段与位掩码

我们的分配器设计灵感源自高性能 C++ 库 hu_alloc。核心概念如下:

  1. 虚拟内存预留(Reservation):启动时直接通过 mmap 预留一大块虚拟地址空间(例如 640GB)。这不是物理内存,只是地址空间占位。
  2. 2MB 对齐的段(Segment):内存被切分为 2MB 的块,严格对齐 Huge Page 边界。
  3. 位掩码管理:每个段内部使用一个位掩码(Bitmask)来跟踪空闲块。这使得分配操作变成了极快的位运算(寻找第一个为 0 的位)。

Zig 实现原型

Zig 的显式内存管理特性使其非常适合这类底层工作。我们将演示如何通过 mmap 申请 Huge Page,并用简单的位图管理它。

const std = @import("std");
const os = std.os;
const mem = std.mem;

// 定义 Huge Page 大小为 2MB
const HUGE_PAGE_SIZE = 2 * 1024 * 1024;

const HugePageAllocator = struct {
    base_addr: [*]u8,
    total_size: usize,
    // 简化演示:一个简单的位图跟踪已被使用的 2MB 页
    // 在生产级代码中,这里会是更复杂的层级结构
    page_bitmap: u64, 

    pub fn init(size: usize) !HugePageAllocator {
        // 确保请求大小对齐
        if (size % HUGE_PAGE_SIZE != 0) return error.InvalidSize;
        
        // 使用 mmap 申请内存
        // MAP_HUGETLB (0x40000) 指示内核使用 Huge Pages
        // 注意:这通常需要系统配置 nr_hugepages
        const flags = os.MAP.PRIVATE | os.MAP.ANONYMOUS;
        
        // 在 Zig 标准库中,mmap 的封装可能不直接暴露 HUGETLB,
        // 这里为了演示逻辑,假设我们可以通过 flags 传递或系统已配置透明大页(THP)。
        // 关键在于对齐。
        
        const ptr = try os.mmap(
            null,
            size,
            os.PROT.READ | os.PROT.WRITE,
            flags,
            -1,
            0,
        );

        return HugePageAllocator{
            base_addr: ptr,
            total_size: size,
            page_bitmap: 0,
        };
    }

    pub fn alloc_segment(self: *HugePageAllocator) ![]u8 {
        // 查找空闲位 (简单实现:仅支持 64 个 segment)
        const index = @ctz(~self.page_bitmap);
        if (index >= 64 or index * HUGE_PAGE_SIZE >= self.total_size) {
            return error.OutOfMemory;
        }

        // 标记为已用
        self.page_bitmap |= (@as(u64, 1) << @intCast(index));

        const offset = index * HUGE_PAGE_SIZE;
        return self.base_addr[offset .. offset + HUGE_PAGE_SIZE];
    }

    pub fn free_segment(self: *HugePageAllocator, segment: []u8) void {
        const ptr_val = @intFromPtr(segment.ptr);
        const base_val = @intFromPtr(self.base_addr);
        
        const diff = ptr_val - base_val;
        const index = diff / HUGE_PAGE_SIZE;

        // 标记为可用
        self.page_bitmap &= ~(@as(u64, 1) << @intCast(index));
    }
    
    pub fn deinit(self: *HugePageAllocator) void {
        os.munmap(self.base_addr[0..self.total_size]);
    }
};

pub fn main() !void {
    // 初始化分配器,管理 10 个 Huge Pages (20MB)
    var allocator = try HugePageAllocator.init(10 * HUGE_PAGE_SIZE);
    defer allocator.deinit();

    const seg1 = try allocator.alloc_segment();
    std.debug.print("Allocated segment 1 at: {*}\n", .{seg1.ptr});
    
    // 写入数据测试
    mem.set(u8, seg1, 0xAA);
    std.debug.print("Memory is writable.\n", .{});

    allocator.free_segment(seg1);
    std.debug.print("Segment 1 freed.\n", .{});
}

深度解析

1. 虚拟内存 vs 物理内存

这个分配器的关键策略在于“敢于申请”。在 64 位系统下,虚拟地址空间几乎是无限的。我们可以放心地通过 mmap 预留几百 GB 的地址空间(Reservation),而不用立即消耗物理内存(Commit)。只有当我们真正触碰(写入)这些 Huge Page 时,操作系统才会分配物理内存。 这种策略极大地简化了指针算术——我们知道所有的内存都在一个连续的基地址之后,通过简单的偏移量计算即可定位数据。

2. 对齐的艺术

代码中强制 HUGE_PAGE_SIZE 对齐不仅仅是为了美观。硬件层面的许多优化都依赖于对齐。当内存块严格按照 2MB 对齐时,它天然地不会跨越两个 Huge Page,这意味着 CPU 只需要一个 TLB 条目就能覆盖整个内存块的访问,这对于遍历大数组或巨型哈希表的操作来说,性能提升是显著的。

3. 位操作的效率

alloc_segment 中,我们使用了 @ctz (Count Trailing Zeros) 指令。在现代 CPU 上,这是一条硬件指令,能在几个时钟周期内找到第一个可用的空闲块。这比遍历链表或树结构要快几个数量级,并且是“无等待”(Wait-free)算法的基础构建块。

结语

通过 Zig,我们能以极低的抽象成本触达这些底层硬件特性。Huge Page 分配器不仅仅是内存管理,它是对现代 CPU 架构深刻理解的体现。在追求极致性能的道路上,每一个 TLB Miss 都值得我们去优化。