背景
khugepaged 是透明大页(THP)的后台折叠线程。它维护一张 mm_slot 扫描链表,凡是地址空间支持 THP 折叠的进程都被挂在链表中。khugepaged 按 FIFO 顺序遍历链表,逐个 PMD 区间检查能否把散落的 4KB 基页合并为一个 2MB 大页,降低 TLB miss。扫描受两个参数约束:每个唤醒周期至多扫描 khugepaged_pages_to_scan 页(默认 HPAGE_PMD_NR * 8,x86_64 上为 4096 页 / 16MB),周期之间睡眠 khugepaged_scan_sleep_millisecs(默认 10 秒)。
┌───────────────────┐
│ khugepaged 线程 │ 每个唤醒周期
└─────────┬─────────┘ 扫至多 pages_to_scan 页
▼
┌───────────────────┐ 预算耗尽 ┌───────────────┐
│ 沿 mm_slot 链表 │ ───────────▶ │ 睡眠 10s │
│ FIFO 逐区扫描 │ │ (默认) │
│ 尝试 THP 折叠 │ ◀─────────── │ 重新唤醒 │
└───────────────────┘ └───────────────┘
这套机制在长期运行的系统上暴露出两类浪费。作者用 bpftrace 在一台桌面系统上观测 khugepaged 的扫描:机器闲置启动 10 分钟后做一次完整扫描,结果几乎全是无效扫描。
SCAN_SUCCEED : 1 ← 真正发生折叠
SCAN_EXCEED_SHARED_PTE : 2
SCAN_PMD_MAPPED : 142 ← 已是大页,无须再折叠
SCAN_NO_PTE_TABLE : 178 ← 该区间无页表
total progress size : 674 MB
Total time : 419 秒(含睡眠)
第一类浪费是扫描预算被虚记。旧实现里,一个区间无论是否真正折叠,都向预算记满 HPAGE_PMD_NR(512)页。一个早已是大页(SCAN_PMD_MAPPED)或根本没有页表(SCAN_NO_PTE_TABLE)的区间,khugepaged 只看一眼就退出,却被当成完整扫描了 512 页。几个这样的区间就把整个周期预算烧光,线程早早睡去,链表只推进一点点。要把整条链表扫一遍,需要几十个睡眠周期(观测到的 419 秒),而链表尾部真正值得折叠的热内存只能干等。
第二类浪费是lazyfree folio 被合并。用户用 madvise(MADV_FREE) 声明"这块内存可丢弃"后,对应匿名页会清除 swapbacked 标志,内存压力到来时可以直接丢弃而无须回写。但 khugepaged 仍会把这些页合并成大页。在普通 vma 中,合并后的大页不再保有"可丢弃"属性,将来内存吃紧时内核只能按普通匿名页 swapout 到交换设备,用户"可丢弃"的承诺被作废;合并本身也在为注定要释放的内存白白消耗 CPU。
问题
- 已合并或无页表的区间被反复全量计入扫描预算
- 进度虚高使链表推进缓慢,有效折叠被推迟数十秒
- 合并 lazy-free folio 使其丢失惰性释放特性,恶化回收
- 队首冷任务阻塞队尾热任务,热任务 TLB 未命中激增
方案
整套修复围绕两条原则展开:把扫描预算算实,以及跳过没有意义的折叠。
把扫描预算算实
记账改为反映真实工作量,而非一刀切 HPAGE_PMD_NR,且匿名路径与文件路径记法不同。浪费主要出在匿名路径:旧实现里一个早已是大页(SCAN_PMD_MAPPED)或没有页表(SCAN_NO_PTE_TABLE)的区间只看一眼就退出,却被记满 512 页。现在匿名路径在 PTE 循环内每扫一个 PTE 记 1,查找 PMD 阶段早退的整段也只记 1。文件路径按 xarray 取页而非遍历 PTE 表,故除 SCAN_PTE_MAPPED_HUGEPAGE 记 1 外,其余结果(含折叠成功的 SCAN_SUCCEED)仍记 HPAGE_PMD_NR。
改动前:每区固定记 HPAGE_PMD_NR(512)页
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│已合并│ │无页表│ │已合并│ │ ... │ 预算迅速耗尽
│ +512 │ │ +512 │ │ +512 │ │ │ ⇒ 有效任务被延后
└──────┘ └──────┘ └──────┘ └──────┘
改动后:按实际触碰的 PTE 数记账
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│已合并│ │无页表│ │已合并│ │实扫 N│ 预算花在真活上
│ +1 │ │ +1 │ │ +1 │ │ +N │ ⇒ 快速越过无效区
└──────┘ └──────┘ └──────┘ └──────┘
预算一旦反映真实工作量,那些廉价的早退区间就几乎不占预算。线程在一个唤醒周期内能越过大量无效区间,直达真正可折叠的热内存。
跳过惰性释放页
折叠路径在两处决策点(PMD 扫描与隔离阶段)增加同一道判定:仅当后台 khugepaged 扫到匿名页、且该页是干净的惰性释放页、且所在 vma 不可丢弃时,直接放弃这次折叠。
后台 khugepaged 扫到匿名页,依次判定是否跳过合并:
否
是后台扫描 ? ─────▶ 合并(显式 madvise_collapse 必须照办)
│ 是
▼ 否
lazyfree 内存? ─────▶ 合并
│ 是
▼ 否
页表项干净 ? ─────▶ 合并(用户又写入,重新使用)
│ 是
▼ 否
vma 非可丢弃? ─────▶ 合并(可丢弃属性由 vma 保留)
│ 是
▼
跳过合并
四个条件缺一不可,每一条都对应一条约束:
- 仅限后台扫描:
madvise_collapse()是用户显式要求的同步折叠,必须照办,只有后台扫描才应主动跳过。 - 仅限 lazyfree 内存:即匿名且未设
swapbacked,代表用户已声明可丢弃。 - 仅限干净页:MADV_FREE 会清掉 PTE 脏位;PTE 再变脏,说明该页在声明可丢弃之后又被写入、被用户重新启用。在回收真正发生并恢复
swapbacked之前,该页仍呈惰性释放状态,因此这道 PTE 级判定比 folio 级判定更早一步察觉到这种重新使用,两者并非冗余。 - 仅限普通 vma:
VM_DROPPABLE的可丢弃性是 vma 级属性,合并后的大页仍处于该 vma、属性得以保留,无须跳过;普通 vma 则会在合并后丢失该属性。
收益
以下数据来自作者在 x86_64 上的实测。
full scan 效率(桌面空闲 10 分钟)
| 指标 | Before | After | 改善 |
|---|---|---|---|
| 总扫描进度 | 674 MB | 45 MB | 93.3% |
| 总耗时 | 419 s | 20 s | 95.2% |
构造 hot1 → cold → hot2 三个任务,各分配 128MB;hot1、hot2 持续访问自己的内存,cold 短暂访问后调用 madvise(MADV_FREE)。
测量排在链表尾部的 hot2 进程。
x86_64:
| 指标 | Before | After | 改善 |
|---|---|---|---|
| total accesses time | 3.14 sec | 2.93 sec | 6.69% |
| cycles per access | 4.96 | 2.21 | 55.44% |
| Throughput | 104.38 M/s | 111.89 M/s | 7.19% |
| dTLB-load-misses | 284814532 | 69597236 | 75.56% |
qemu-system-x86_64 -enable-kvm:
| 指标 | Before | After | 改善 |
|---|---|---|---|
| total accesses time | 3.35 sec | 2.96 sec | 11.64% |
| cycles per access | 7.29 | 2.07 | 71.60% |
| Throughput | 97.67 M/s | 110.77 M/s | 13.41% |
| dTLB-load-misses | 241600871 | 3216108 | 98.67% |
跳过冷任务后,khugepaged 迅速把 hot2 折叠为大页,其访存的 dTLB 未命中近乎消失,吞吐随之上升。这正是"优先服务频繁访问的内存"的直接体现:把有限的扫描预算花在真正会持续访问的内存上,而不是浪费在注定要释放或已经折叠过的区域。
kernbench
| 指标 | Before | After | 改善 |
|---|---|---|---|
| Amean user-32 | 18522.51 | 18333.64 | 1.02% |
| Amean syst-32 | 1137.96 | 1113.79 | 2.12% |
| Amean elsp-32 | 666.04 | 659.44 | 0.99% |
并行内核编译,编译以 CPU 计算为主、内存布局带来的收益有限,故改善温和;真正受益明显的是访存敏感场景。