背景

MGLRU 里每个 lruvec 维护 2 到 4 个 generation 组成的滑动窗口,回收只从最老 generation(min_seq)里挑页。aging 分两步:先遍历页表,把仍被访问的 folio 在 flags 里改标到最年轻 generation,但不立即挪它在链表里的位置,即惰性提升;再由 inc_max_seq() 创建新 generation,把整扇窗向后推。

窗口排满 4 个 generation 时,新 generation 没有空位,inc_max_seq() 得先调用 inc_min_seq() 退役最老的那个:遍历最老 generation 的每条链表,把没被回收掉的剩余 folio 全部提升一代,搬进次老 generation,然后 min_seq 前进一代。回收未必扫空过最老 generation,扫描配额耗尽或当时偏好另一类型都会留下剩余,所以这里通常有货。

窗口满时最老 generation 并入次老 generation
┌──────────────────────────────────────┐
│ aging 要创建新 generation,窗口已排  │
│ 满 4 个                              │
└──────────────────┬───────────────────┘
                   ▼
┌──────────────────────────────────────┐
│ inc_min_seq 把最老 generation 的剩余 │
│ folio 搬进次老 generation            │
└──────────────────┬───────────────────┘
                   ▼
┌──────────────────────────────────────┐
│ min_seq 前进一代,回收继续扫新的最老 │
│ generation                           │
└──────────────────────────────────────┘

inc_min_seq() 全程持有 lruvec 锁,逐 folio 处理,每个 folio 的开销包括:

  • 一次 cmpxchg 更新 generation 标记
  • 两次 WRITE_ONCE 更新 nr_pages
  • 一次 WRITE_ONCE 更新 protected 计数
  • 一次链表摘挂

以往 aging 不算频繁,这点开销不起眼。本系列拆自 MGLRU swappiness 系列,swappiness 修复与 proactive aging 都会更频繁地触发 aging,inc_min_seq() 的开销与它对 folio 顺序的处理随之被放大。

问题

  • nr_pages 逐 folio 写回,同一 zone 的更新本可合并
  • 连续待搬移的 folio 逐个摘挂链表,本可整段一次搬移
  • aging 中已提升的热页被移到目标 generation 尾部,排到冷页之后
  • 从尾到头遍历再追加到尾部,搬移后 folio 的相对冷热顺序被反转
  • 已提升的 folio 仍计入 protected 计数,与 sort_folio() 的处理不一致

方案

把逐 folio 的计数与搬移尽量成批处理,并按 sort_folio() 已有的先例纠正两处 cold/hot inversion。

计数先批量化。原来每个 folio 提升一代都要立刻把老的 nr_pages 减一份、新的加一份,频繁写回还妨碍编译器优化。现在循环内只把增减累计进一个 delta,每个 zone 扫完一次性写回。aging 遍历页表时本来就批量更新 nr_pages,inc_min_seq() 只是沿用同一做法。逐 folio 的 active/inactive 兼容更新也整个跳过:调用 inc_min_seq() 时窗口必然满 4 个 generation,最老与次老都不是 active generation(兼容视图里只有最年轻两个算 active),folio 在两者间移动不会改变 active/inactive 状态,原来的逐个更新是纯开销。

第一处倒置在提升路径上。aging 遍历发现仍热的 folio 时已把它在 flags 里提升到最年轻 generation,只是链表位置没动。inc_min_seq() 遇到这种 folio,原来一律 list_move_tail 到目标 generation 尾部,尾部是回收先扫的一端,热页反而排到未提升的冷页之后。现在区分开:未提升的 folio 提升一代,追加到次老 generation 尾部;已提升的按 sort_folio() 的先例 list_move 到目标 generation 头部。protected 计数也随之收紧,只统计提升一代的 folio,已提升的跳过,与 sort_folio() 的处理一致。

第二处倒置在遍历方向上。回收从尾部向头部扫,尾部最冷。原循环从最老 generation 的尾部取 folio,再逐个追加到次老 generation 尾部:先搬的最冷页位置反而最靠前,后搬的最热页落到最冷的一端,整批顺序被反转。现在改成从头部向尾部遍历,配合向尾部追加,folio 的相对顺序原样保留,遍历方向变了,prefetchw 也换成前向的。

两处 cold/hot inversion 的修正
                   ┌───────────────────────────────────────┐
                   │ 最老 generation 的剩余 folio 分两类, │
                   │ aging 中已提升的与未提升的            │
                   └───────────────────┬───────────────────┘
                  ┌────────────────────┴────────────────────┐
                  ▼                                         ▼
┌───────────────────────────────────┐      ┌──────────────────────────────────┐
│ 已提升的更热,移到目标 generation │      │ 未提升的从头到尾遍历,保持相对顺 │
│ 头部,不再计入 protected          │      │ 序追加到次老 generation 尾部     │
└───────────────────────────────────┘      └──────────────────────────────────┘

链表搬移最后也批量化。遍历过程中连续待搬移的 folio 先不逐个摘挂,只用 batch_end 记下这一段区间的末尾;遇到已提升的 folio,它要立刻单独移到目标头部,先把攒下的一段用 list_cut_position() 切下来,list_splice_tail_init() 一次拼到次老 generation 尾部;一个 zone 扫完时,剩下的一段也照样切拼一次。回收偏好单一类型时,另一类型的 folio 会在最老 generation 里大量积压,待搬移的连续段越长,省下的摘挂越多。

计数与搬移的批量化
┌──────────────────────────────────────┐
│ 逐 zone 遍历最老 generation 的 folio │
└──────────────────┬───────────────────┘
                   ▼
┌──────────────────────────────────────┐
│ 循环内本地累计 nr_pages 增减,连续待 │
│ 搬移 folio 只记录区间端点            │
└──────────────────┬───────────────────┘
                   ▼
┌──────────────────────────────────────┐
│ 每个 zone 结束一次写回计数,每段结束 │
│ 整段切割拼接到次老 generation 尾部   │
└──────────────────────────────────────┘

收益

作者用微基准在一个 512MB 匿名页的 memcg(上限 1GB)上通过 debugfs 的 lru_gen 接口强制执行 100 次 aging;arm64 数据由 Xueyuan 在 24 核 arm64 机器(4K 页)上测得:

指标 Before After 改善
单次 aging 平均耗时(x86) 4.990 ms 2.343 ms 降 53.0%
100 次 aging 累计耗时(x86) 503.946 ms 236.635 ms 降 53.0%
单次 aging 平均耗时(arm64,THP=never 走 PTE) 7.644 ms 2.964 ms 降 61.2%
单次 aging 平均耗时(arm64,THP=always 走 PMD) 0.0373 ms 0.0292 ms 降 21.8%