引言:为什么内存去重与大页化是云原生的关键技术

在现代数据中心和云平台中,内存是最稀缺的资源之一。当数百台虚拟机共享同一台物理服务器时,大量内存页面被浪费在重复数据上。KVM 虚拟化环境中运行相同操作系统的虚拟机,其内核代码段、libc 库页面、甚至应用数据都有大量重复副本。与此同时,传统 4KB 小页面导致的 TLB Miss 开销在大内存场景下成为性能瓶颈。

Linux 内核提供了两个互补的解决方案:KSM (Kernel Samepage Merging) 负责检测并合并相同内容的内存页面以节省内存空间;THP (Transparent Huge Pages) 则自动将小页面合并为大页(2MB/1GB)以降低 TLB 压力、提升内存访问性能。本文将深入剖析二者的设计思想、内部实现、生产环境配置策略以及性能调优的最佳实践。

2. KSM 核心原理与架构设计

2.1 诞生背景与设计目标

KSM 由 Red Hat 的 Arcangeli 于 2008 年提交,2010 年合入 Linux 2.6.32 内核。其核心目标是为 KVM 虚拟化环境提供透明的内存去重机制,让多个虚拟机(即使是不同 guest OS)共享相同内容页面的单一物理副本。

KSM 的核心洞察是:虚拟机之间存在大量的零页和相同数据页。启动阶段所有 VM 加载相同的内核镜像、相同的初始化库函数,导致内存利用率极低。KSM 通过定期扫描、哈希比对、COW (Copy-on-Write) 合并,可以将内存使用量从 N×M 降低到接近 M + ε 的水平。

2.2 双红黑树:-stable 与 unstable 树

KSM 内部维护两颗红黑树,这是其最精妙的数据结构设计:

// include/linux/mm_types.h
struct rmap_item {
    struct rmap_item *rmap_list;       // 冲突链表(多个虚拟页面指向同一物理页面)
    union {
        struct rb_node node;           // 在 stable 树中的节点(按页面内容排序)
        struct {
            struct stable_node *head;  // 指向 stable_node
            unsigned int hlist_height; // unstable 树中的高度
        } stable_node;
    };
    union {
        struct mm_struct *mm;          // 页面所属的进程/虚拟机
        unsigned long address;         // unstable 树中:页面的虚拟地址
    };
};
  • Unstable 树:存储尚未确认可合并的页面,每次全量扫描时重建。节点按页面内容的 checksum 排序,无引用计数,会被定期丢弃。
  • Stable 树:存储已确认唯一的共享页面。每个 stable_node 指向一个写保护的 KSM 页面,节点按页面内容哈希排序。拥有真正的引用计数,只有在引用计数归零时才释放。

2.2 扫描算法流程

KSM 扫描的核心函数链为:ksm_do_scan() → ksm_scan_thread() → stable_tree_search() → unstable_tree_search_append()。完整流程如下:

ksm_do_scan(scan_npages)
│
├── for each page in scan_npages:
│   ├── rmap_item = lookup_slab_cache()          // 从 KSM slab 缓存获取 rmap_item
│   ├── checksum(page)                           // 计算页面内容的 fast checksum
│   ├── if checksum != old_checksum:             // 页面自上次扫描已被修改
│   │   ├── remove_from_unstable_tree()          // 从 unstable 树移除
│   │   └── skip                                 // 尚未稳定,跳过本次合并
│   │
│   ├── if first_scan:
│   │   ├── insert_to_unstable_tree()            // 插入 unstable 树
│   │   └── return
│   │
│   ├── node = unstable_tree_search()            // 在 unstable 树中查找相同 checksum
│   ├── if not found:
│   │   ├── unlink_from_old_unstable()           // 从旧位置移除
│   │   └── insert_to_unstable_tree()            // 重新插入(新 checksum 位置)
│   │
│   ├── if found in unstable_tree:
│   │   ├── page2 = get_page_from_node()
│   │   ├── pages_identical(page, page2)         // 字节级精确比较(疑难消歧)
│   │   ├── if identical:
│   │   │   ├── stable_node = alloc_stable_node_or_reuse()
│   │   │   ├── merge_with_stable_node()         // COW 合并:两个页面指向同一物理页
│   │   │   │   ├── ptep_clear_flush_young()     // 清除 PTE Access 位
│   │   │   │   ├── set_pte_at(ksm_page_pte)     // PTE 指向 KSM 物理页面
│   │   │   │   └── mark KSM page write-protected
│   │   │   └── unlink_from_unstable_tree()      // 从 unstable 树移除
│   │   └── else:
│   │       └── update position in unstable_tree // 更新校验和位置

关键优化:使用 rolling checksum 快速排除变动页面。只有当 checksum 连续两次扫描一致时(说明页面稳定),才进行昂贵的 O(n) 字节比较。这保证了扫描不会对正常运行中的服务造成过度 CPU 开销。

2.3 COW 语义与页面保护

合并后,所有引用该 KSM 页面的 PTE 都被标记为写保护(Write-Protected)。当任何进程尝试写入时触发 WP Page Fault,内核执行 COW 分裂:分配新物理页面、复制旧数据、更新该进程的 PTE 指向新页面。这个机制保证了进程间的内存隔离性。

// mm/memory.c - 处理 KSM 页面写保护缺页
static vm_fault_t do_wp_page(struct vm_fault *vmf)
{
    ...
    if (PageKsm(vmf->page)) {
        // KSM 页面被写入 — 执行 COW 分裂
        new_page = alloc_page_vma(GFP_HIGHUSER, vma, vmf->address);
        copy_user_highpage(new_page, vmf->page, vmf->address, vma);
        __SetPageDirty(new_page);
        // 更新 PTE 指向新页面
        ptep_clear_flush_notify(vma, vmf->address, vmf->pte);
        set_pte_at_notify(mm, vmf->address, vmf->pte, mk_pte(new_page, vma->vm_page_prot));
        // 减少 KSM 原页面引用计数
        put_page(vmf->page);
        ...
    }
}

3. KSM 生产环境配置与调优

3.1 系统参数配置

KSM 的主要控制参数位于 /sys/kernel/mm/ksm/ 目录下:

# 启用 KSM(全局开关)
echo 1 > /sys/kernel/mm/ksm/run

# 每次扫描页数(默认 100,可增大以提高去重率但消耗更多 CPU)
echo 1000 > /sys/kernel/mm/ksm/pages_to_scan

# 扫描间隔毫秒数(默认 200ms)
echo 200 > /sys/kernel/mm/ksm/sleep_millisecs

# 限制 KSM 可使用的最大 CPU 百分比(通过 throttling 实现)
echo 20 > /sys/kernel/mm/ksm/cpu_ratio

# 共享页面最大数量(0 = 无限制)
echo 0 > /sys/kernel/mm/ksm/max_page_sharing

# 合并阈值页面数(达到此数量才创建新 stable node)
echo 256 > /sys/kernel/mm/ksm/merge_across_nodes

3.2 QEMU/KVM 中的启用方式

要在 KVM 虚拟机中使用 KSM,首先需要在 guest 内核中标记可合并内存区域:

// QEMU 启动参数
-m 4G,slots=4,maxmem=8G \
-machine memory-backend=mem0 \
-object memory-backend-ram,id=mem0,size=4G,merge=on,discard=on \
-nodefaults

// Guest 内核中通过 madvise 标记
madvise(addr, size, MADV_MERGEABLE);  // 告诉内核此区域可参与 KSM 合并
madvise(addr, size, MADV_UNMERGEABLE); // 取消标记

// 应用程序(如 Qt、WebKit)可自动标记 malloc 的大块内存

3.3 NUMA 感知与 merge_across_nodes

在多路 NUMA 服务器中,跨节点合并 是一个关键权衡。启用 merge_across_nodes=1 允许不同 NUMA 节点的物理页面合并,但 access 时会导致 NUMA remote 访问延迟;设置为 0 则只在同节点内合并,安全性更高但去重率低。对于内存紧张的系统,建议启用;对于延迟敏感的应用,保持默认。

4. THP (Transparent Huge Pages):大页透明化

4.1 为什么需要大页

传统 x86_64 架构使用 4KB 页面,这意味着 1GB 内存需要 262,144 个 PTE 条目。CPU 的 TLB (Translation Lookaside Buffer) 通常只能缓存 64-2048 个条目,当工作集超过 TLB 容量时,每次内存访问都可能触发 Page Walk(4-5级页表遍历),严重消耗 CPU 周期。

使用 2MB Huge Page 后,同样 1GB 只需 512 个 PTE,TLB 命中率大幅提升。这是为什么数据库(MySQL、PostgreSQL)和大内存应用强烈推荐启用 THP 的根本原因。

4.2 THP 的工作机制

THP 的核心是 khugepaged 内核线程,它在后台运行,自动将连续的小页面合并为 2MB 大页面,对应用程序完全透明:

khugepaged (后台内核线程)
│
├── 每 scan_sleep_millisecs (默认 10000ms = 10s) 扫描一次
│
├── for each mm_struct in system:
│   ├── 遍历进程的 VMA,寻找"大页候选区域"
│   │   - 要求: VM_HUGEPAGE 标志 + 连续的 present 小页面
│   │   - 通过 madvise(addr, len, MADV_HUGEPAGE) 显式标记
│   │   - 或通过 always 模式全局启用
│   │
│   ├── collapse_huge_page(addr):
│   │   ├── alloc_hugepage_vma()     // 从 buddy allocator 分配 2MB 页面
│   │   ├── 计算区域内已 present 的小页数 >= HPAGE_PMD_NR (512)
│   │   │
│   │   ├── /* 关键步骤:扫描 512 个 PTE */
│   │   │   for each pte in range:
│   │   │       if pte_present: copy data to huge page
│   │   │       if pte_none:     // 未访问,跳过
│   │   │
│   │   ├── /* 原子性替换:设置 PMD 大页映射 */
│   │   │   set_pmd_at(mm, addr, pmdp, pmd_mkwrite(pmd_mkhuge(...)))
│   │   │
│   │   ├── /* 释放旧小页面 */
│   │   │   free the 512 small pages back to buddy
│   │   │
│   │   └── /* 更新 mmu_notifier */
│   │       mmu_notifier change_pte / invalidate
│   │
│   └── until pages_to_scan exhausted or alloc failed

4.3 defrag 机制

THP 合并的前提是 必须有连续的 512 个物理页面可用。随着系统运行,内存会碎片化,导致 khugepaged 无法找到连续大页。Linux 提供了 defragmentation(碎片整理)机制:

defrag 模式:
- always:  找到连续页失败时,主动触发碎片整理(迁移页面),延迟较高但分配成功率高
- defer:   失败时先放弃,等 kswapd 后台整理后重试
- defer+madvise: 只在 madvise 标记的区域允许主动碎片整理
- madvise: 仅对 MADV_HUGEPAGE 标记区域生效
- never:   完全不碎片

控制参数:
/sys/kernel/mm/transparent_hugepage/defrag
/sys/kernel/mm/transparent_hugepage/khugepaged/defrag

5. KSM 与 THP 的冲突与协同

5.1 KSM 会粉碎 THP 大页面

KSM 和 THP 之间存在一个微妙但重要的冲突:当 KSM 扫描到一个 2MB 大页面时,如果它发现自己可以将其中 512 个小页面中的某些与其他去重,它会将大页面分裂(split huge page)。因为 KSM 是以单个 4KB 页面为粒度的。

// mm/ksm.c - KSM 击中 THP 大页面时
if (PageTransCompound(page)) {
    // 分裂大页面为小页面
    if (!split_huge_page(page)) {
        unlock_page(page);
        put_page(page);
        goto retry;  // 分裂后重新扫描
    }
}

这意味着 KSM 去重倾向于牺牲 THP 性能。在 KVM 虚拟化场景中,这通常是可以接受的——节省内存比降低 TLB 未命中更重要。但在延迟敏感的数据库场景中,可能需要禁用 KSM。

5.2 生产环境最佳实践策略

场景KSMTHP建议
KVM 高密度虚拟化(>30 VM/节点)启用(1)always(defrag=always)内存紧张优先去重,VM 内用 madvise 标记大页
数据库 OLTP(MySQL/PostgreSQL)禁用(0)never 或 madvise避免 THP 碎片化导致性能抖动,Huge Page 显式预分配
Redis/Memcached 大内存缓存禁用(0)madviseMADV_HUGEPAGE 标记堆空间,提升 TLB 命中率
大数据/Spark 批处理按需alwaysJava 堆大页面友好,同时允许去重节省内存
容器平台(K8s)视情况madvise关闭节点 KSM,在 Pod 级别按需启用

6. 性能监控与排错

6.1 关键监控指标

# KSM 监控指标(/sys/kernel/mm/ksm/)
cat /sys/kernel/mm/ksm/pages_shared        # 当前共享页面数(已合并)
cat /sys/kernel/mm/ksm/pages_sharing       # 当前节省的物理页数
cat /sys/kernel/mm/ksm/pages_unshared      # 唯一但无法合并的页面
cat /sys/kernel/mm/ksm/pages_volatile      # 频繁变化无法稳定的页面
cat /sys/kernel/mm/ksm/full_scans          # 完整扫描次数

# 计算去重效率
去重率 = pages_sharing / (pages_shared + pages_sharing + pages_unshared) * 100%

# THP 监控指标(/sys/kernel/mm/transparent_hugepage/)
grep AnonHugePages /proc/meminfo          # 当前匿名大页面使用量
cat /sys/kernel/mm/transparent_hugepage/khugepaged/pages_to_scan  # 每次扫描数
grep -i huge /proc/meminfo                # HugePages 统计

6.2 常见性能陷阱

陷阱 1:KSM 导致 CPU 开销过高。在低工作负载差异的环境中(如随机数据加密场景),KSM 扫描不会发现任何可合并页面,但仍会消耗 CPU。建议通过监控 pages_shared / pages_volatile 比率来判断 KSM 是否有实际收益,如果比率极低(<5%),考虑关闭。

陷阱 2:THP 碎片化导致延迟尖峰defrag=always 模式下,khugepaged 的碎片整理操作可能阻塞其他内存分配导致 jank。使用 [perf] 分析页面分配延迟确认问题源。解决方案是切换到 defrag=defer+madvise。

陷阱 3:数据库中 THP 导致的写入放大数据库的顺序写在小页面模式下是高效的,但 THP 合并会将小写入放大为 2MB 大页的 COW 操作。这就是为什么 Oracle 官方强烈推荐在数据库服务器上禁用 THP。

7. 进阶:用户态 Huge Page 替代方案

对于需要精细控制的应用,THP 的自动管理可能不够。主要替代方案:

  • Explicit Huge Pages:通过 hugetlbfs 预分配大页面,需要应用修改代码使用 MAP_HUGETLB。PostgreSQL 等数据库支持此方式。
  • PMDK (Persistent Memory Development Kit):针对持久内存优化的大页管理库。
  • userfaultfd + 自定义分配器:在用户态实现细粒度页面管理,代价是开发复杂度显著增加。

8. 总结与展望

KSM 和 THP 分别从"释放内存"和"提升性能"两个维度解决了 Linux 内存管理的核心挑战。理解二者的工作机制和交互关系,是构建高效、可预测的云计算基础设施的基础。

随着 CXL (Compute Express Link) 内存扩展技术的普及,未来可能出现"三层内存去重":同一 NUMA 节点内用 KSM、跨 NUMA 用 NUMA-aware 负载均衡、跨 CXL 域用 THP 优化。Linux 内核社区也在讨论 vDAPO (Data Acceleration Pipeline Offload) 机制来硬件化这些工作。

具体选择何种策略,需要根据工作负载特征、性能目标和服务等级协议综合判断。最重要的是——在生产环境变更之前,务必使用代表性的压力测试验证效果。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部