引言:为什么内存去重与大页化是云原生的关键技术
在现代数据中心和云平台中,内存是最稀缺的资源之一。当数百台虚拟机共享同一台物理服务器时,大量内存页面被浪费在重复数据上。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 生产环境最佳实践策略
| 场景 | KSM | THP | 建议 |
|---|---|---|---|
| KVM 高密度虚拟化(>30 VM/节点) | 启用(1) | always(defrag=always) | 内存紧张优先去重,VM 内用 madvise 标记大页 |
| 数据库 OLTP(MySQL/PostgreSQL) | 禁用(0) | never 或 madvise | 避免 THP 碎片化导致性能抖动,Huge Page 显式预分配 |
| Redis/Memcached 大内存缓存 | 禁用(0) | madvise | MADV_HUGEPAGE 标记堆空间,提升 TLB 命中率 |
| 大数据/Spark 批处理 | 按需 | always | Java 堆大页面友好,同时允许去重节省内存 |
| 容器平台(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) 机制来硬件化这些工作。
具体选择何种策略,需要根据工作负载特征、性能目标和服务等级协议综合判断。最重要的是——在生产环境变更之前,务必使用代表性的压力测试验证效果。

发表评论 取消回复