背景

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 计算为主、内存布局带来的收益有限,故改善温和;真正受益明显的是访存敏感场景。