MGLRU 深度解析

Linux 多代 LRU(Multi-Gen LRU)源码级剖析 · 对标 Linux 5.10 传统 LRU · 对照 iOS (XNU) 内存策略 · 深入浅出版
源码基准:Linux v6.6 / v5.10(torvalds/linux,已逐行核对) XNU xnu-7195.141.2(iOS 14.8)存档核对 版本边界实证:v5.19=0 处 lru_gen → v6.1=161 处 MGLRU 于 Linux 6.1(2022-12)合入主线
SRC 源码行号可查 EXP 设备实测(沿用本工作区 iPhone 12 Pro 实验数据) EST 结构估算 GEN 通用知识(未逐行验证)

§TL;DR:一页看懂三方差异

一句话总判MGLRU 把"页面冷热"从两条链表(active/inactive)升级为"代 × 层级"的二维坐标系,用正向页表扫描批量收割硬件访问位、用无锁改标签替代搬链表,本质是把传统 LRU 的"每页每票都要挪书架"改成"先全班点名、再改学籍卡";Linux 5.10 传统 LRU 是"两档温度计 + 回收时反向查岗(rmap)",粒度粗、锁竞争重;iOS/XNU 干脆放弃精细 LRU,走"队列 FIFO 老化 + 压缩器兜底 +jetsam 分级查杀"——苹果知道自己跑在哪(单用户设备),Linux 不知道自己会跑在哪(通用内核),这是三方所有差异的总根源。
维度Linux 6.1+ MGLRULinux 5.10 传统 LRUiOS (XNU)
冷热分级最多 4 代 × 4 层级(gen×tier 二维)2 档(active/inactive)队列粗粒度(active/inactive/speculative…)
访问位收割正向批量扫页表(walk_mm),Bloom filter 过滤回收时 rmap 反向遍历逐页查(page_referenced)清位后等门铃(ARM64 access-flag fault 当场记账)+ 回收时 pmap_get_refmod 兜底
热度记账载体folio->flags 的 gen+refs 位(cmpxchg 无锁PG_active/PG_referenced + 链表位置(须持锁搬链表)物理页软件位 vmp_reference + pmap refmod
老化触发按代人口分布启发式(should_run_aging)被动:回收压力驱动 active 降级水位驱动 vm_page_balance_inactive(1/2 目标)
换页后路swap/zram(完整 swap 子系统)swap/zram无传统 swap:压缩器(WKdm/LZ4)+ 重读文件
内存不够时OOM killer(badness 打分,min_ttl 可选防抖)OOM killer(badness 打分)jetsam 按优先级 band 查杀 + freezer 冻结后台
进程级隔离memcg(还有老/年轻两代 memcg 分代)memcg每 app 限额(phys_footprint)+ band
应用协同PSI 压力度量(面向调度)无(5.10 有 PSI,面向系统)四级压力通知(面向 App 行动)+ purgeable 一等公民
哲学通用:不知道跑在哪,就做最强通用算法通用(老一代实现)专用:知道跑在哪,直接按设备形态定制

阅读建议:想看"新东西"直奔 §1-§7(MGLRU 主体);想看"为什么换"先读 §8(传统 LRU 的六大痛点);想看"手机上为什么不是这么回事"读 §9。

§0预备知识:内核为什么需要"淘汰"内存页

0.1 问题本身:一块内存,三个主人

物理内存永远比"想占用的总量"少。内核的办法是过度承诺(overcommit):把暂时没人用的内存拿去当磁盘缓存(页缓存),等真的不够了再"回收"——把冷页让给热需求。页回收(page reclaim)子系统的全部工作,就是回答一个问题:哪些页是"冷"的?

判断冷热远比想象中难,因为访问信息的产生分三条路径,各住一处:

记账层住哪谁写特点
硬件访问位(x86 ACCESSED / ARM64 AF,俗称 young 位)页表项 PTE 里CPU 的 MMU在 TLB 填充时自动置位,内核不在场成本≈0,但一页被 N 个进程映射就有 N 份;内核必须主动去"收割"(读+清零)才能知道访问发生过
软件页标志(PG_referenced / PG_active / MGLRU 的 gen+refs)page->flags内核软件每页一份;改它只要原子操作,但"升级/降级"传统上要配合搬链表
链表/队列位置(LRU 链表、XNU 页队列)lruvec / vm_page_queue内核软件(须持锁)全局秩序的载体;搬动成本最高(锁竞争 + 缓存行弹跳)
🧠 比喻:小区图书馆。硬件访问位是"每扇门上的自动记录器"(谁进门谁留下脚印,但记录器很多、分散在各栋楼);软件标志是"图书卡片上的借阅章";链表位置是"书放在热门架还是普通架"。管理员(内核)想知道"哪些书最近没人看",要么挨家挨户抄记录器(扫页表),要么把书拿起来翻借阅章(rmap 反查)。三套系统的一切差异,本质上都是"怎么抄记录器、抄完记在哪、记完怎么搬家"这三个选择的不同组合。

0.2 两次机会(second chance):一切 LRU 的公分母

纯粹的 LRU(最近最少使用)有个致命缺陷:一次性的批量扫描(比如 grep 整个目录)会把真正的热数据全部挤出去。所以所有实用系统都引入"两次机会":第一次访问不算数,短时间内的第二次访问才确认你是热的。我们会看到:Linux 传统 LRU 用 PG_active×PG_referenced 两位状态机实现它;MGLRU 用"至少两代(MIN_NR_GENS=2)才能完成一次老去"实现它(mmzone.h:321-331 注释原文:aging needs to check the accessed bit at least twice before handing this page over to the eviction);XNU 用"active 队列待一段时间→降级到 inactive→inactive 上被再引用则升回"的队列轮转实现它。

§1MGLRU 全景:一句话说清它换了什么

MGLRU(Multi-Gen LRU,多代 LRU)是 Google 工程师 Yu Zhao 主导、约两年 14 个修订版本后于 Linux 6.1(2022-12)合入主线的页回收重写GEN。它的核心转变只有一句话:

传统 LRU 用"页在链表里的物理位置"表达冷热(每次访问都要搬链表);MGLRU 用"页归属哪一代"表达冷热(每次访问只改 folio->flags 里的一个数字,物理位置不动,回收时才一次性归位)。

1.1 版本边界(实证)

grep -c lru_gen 对比真实内核源码(torvalds/linux 标签):v5.10 / v5.15 / v5.19 均为 0 处v6.1 = 161 处;v6.6 的 mm/vmscan.c 从 5.10 的 4310 行膨胀到 8148 行(+3838 行,主体就是 MGLRU)。配置项 CONFIG_LRU_GEN + CONFIG_LRU_GEN_ENABLED(默认开启,v6.6/mm/Kconfig:1220-1233SRC

1.2 全景数据流

┌─────────────────────────────────────────────────┐ │ lruvec(每 memcg × 每 node) │ │ │ 缺页/读文件 ──► │ lrugen.folios[gen 0..3][anon|file][zone] │ ◄── 新页入最年轻代(max_seq) │ folio->flags 里记着 gen+1 和 refs │ └───────────────┬─────────────────┬───────────────┘ │ │ ┌─────────────────────▼──┐ ┌─────▼──────────────────────┐ │ AGING(老化) │ │ EVICTION(驱逐) │ │ try_to_inc_max_seq() │ │ evict_folios() │ │ → walk_mm() 正向扫描 │ │ → isolate_folios() │ │ 进程页表 │ │ 从最老代(min_seq)批量取 │ │ → 清 accessed bit │ │ → sort_folio() 惰性排序 │ │ → 热页 cmpxchg 改代 │ │ → shrink_folio_list() │ │ (folio_update_gen) │ │ (经典回收:unmap+IO+free) │ │ → inc_max_seq 立新代 │ │ → 影子 token 记账 evicted │ └──────────┬─────────────┘ └─────┬──────────┬──────────┘ │ max_seq+1 │ 冷页释放 │ token(含 min_seq+refs) ▼ ▼ ▼ 新一代诞生 物理页归还伙伴系统 页重入时 lru_gen_refault() 按 tier 记 refaulted

两个引擎的分工:aging 负责"把热页往上提"(生成新代 + 扫页表升级热页),eviction 负责"把冷页往外卖"(从最老代批量收割)。两者由 should_run_aging()vmscan.c:5257)按"各代人口分布"协调——年轻代人口超过总量一半说明热页太多要分家,次老代不足总量 1/4 说明冷页太少要老化(见 §5.3)。

官方架构自述(doc/multigen_lru.rst:253-269 Summary):MGLRU 可拆成五件套——代(Generations)+ rmap 遍历 + 经 mm_list 的页表遍历 + 连接两者的 Bloom filter + 管反馈的 PID 控制器。整体是生产者-消费者模型:eviction 通过"代数滑动窗口"驱动 aging;aging 内部 rmap 遍历把热而密集的页表塞进 Bloom filter 驱动页表遍历;eviction 内部 PID 控制器用 refault 反馈选类型和层级。四个设计目标(:15-20):好的访问新近度表达、利用空间局部性、明显选择走快速路径、简单的自纠错启发式SRC
🧠 比喻:按届分班的学校。传统 LRU 只有两个班(active 班 / inactive 班),学生(页)每被表扬一次(访问)就得真的换教室(搬链表)。MGLRU 改成"入学年份"制:最多 4 个届别(代),学生被表扬时只改学籍卡上的届别编号(cmpxchg 改 flags),人还坐在原教室;期末大扫除(eviction)时才按学籍卡整体归位。届别 × 自习次数(tier)构成二维热度坐标。

§2数据结构:代(generation)× 层级(tier)坐标系

2.1 核心结构 lru_gen_folio

// include/linux/mmzone.h:416(v6.6,逐字摘录)
 416 struct lru_gen_folio {
 417   /* the aging increments the youngest generation number */
 418   unsigned long max_seq;
 419   /* the eviction increments the oldest generation numbers */
 420   unsigned long min_seq[ANON_AND_FILE];
 421   /* the birth time of each generation in jiffies */
 422   unsigned long timestamps[MAX_NR_GENS];
 423   /* the multi-gen LRU lists, lazily sorted on eviction */
 424   struct list_head folios[MAX_NR_GENS][ANON_AND_FILE][MAX_NR_ZONES];
 425   /* the multi-gen LRU sizes, eventually consistent */
 426   long nr_pages[MAX_NR_GENS][ANON_AND_FILE][MAX_NR_ZONES];
 427   /* the exponential moving average of refaulted */
 428   unsigned long avg_refaulted[ANON_AND_FILE][MAX_NR_TIERS];
 429   /* the exponential moving average of evicted+protected */
 430   unsigned long avg_total[ANON_AND_FILE][MAX_NR_TIERS];
 433   atomic_long_t evicted[NR_HIST_GENS][ANON_AND_FILE][MAX_NR_TIERS];
 435   atomic_long_t refaulted[NR_HIST_GENS][ANON_AND_FILE][MAX_NR_TIERS];
 437   bool enabled;
        }

关键点逐个说SRC

2.2 代编码在 folio->flags 里,改代无锁

// mm/vmscan.c:3773(v6.6,逐字摘录)
3773 static int folio_update_gen(struct folio *folio, int gen)
3774 {
3775   unsigned long new_flags, old_flags = READ_ONCE(folio->flags);
3780   do {
3781     /* lru_gen_del_folio() has isolated this page? */
3782     if (!(old_flags & LRU_GEN_MASK)) {
3783       /* for shrink_folio_list() */
3784       new_flags = old_flags | BIT(PG_referenced);
3785       continue;
3786     }
3788     new_flags = old_flags & ~(LRU_GEN_MASK | LRU_REFS_MASK | LRU_REFS_FLAGS);
3789     new_flags |= (gen + 1UL) << LRU_GEN_PGOFF;
3790   } while (!try_cmpxchg(&folio->flags, &old_flags, new_flags));
3792   return ((old_flags & LRU_GEN_MASK) >> LRU_GEN_PGOFF) - 1;
        }

这是 MGLRU 最关键的工程细节:把一个页提升到更年轻的代 = 一次 cmpxchg 原子操作,不碰任何链表、不拿任何锁(调用方持 RCU 读锁即可)。对比传统 LRU 的 activate_page():摘链表 → 改标志 → 挂新链表,全程持有 lru_lock。多核高并发访问下(尤其是几十个线程共享同一把 lruvec 锁的场景),这一个差别就是缓存行弹跳(cache line bouncing)的消除SRC

编码规则:folio->flags 存的是 gen+1(0 保留表示"不在 MGLRU 链表上",如已被隔离),见 mmzone.h:314-318 注释与 mm_inline.h:157-162 folio_lru_gen()-1 还原。

2.3 ABI 兼容:最年轻两代 = active

// include/linux/mm_inline.h:164(逐字)
164 static inline bool lru_gen_is_active(struct lruvec *lruvec, int gen)
166   unsigned long max_seq = lruvec->lrugen.max_seq;
170   /* see the comment on MIN_NR_GENS */
171   return gen == lru_gen_from_seq(max_seq) || gen == lru_gen_from_seq(max_seq - 1);
        // gen == 最年轻代 || gen == 次年轻代 ⇒ 视为 active

为了让 /proc/meminfo 的 Active:/Inactive:、/proc/vmstat、以及所有观测工具继续工作,MGLRU 把最年轻的两个代映射为"active",其余映射为"inactive"mmzone.h:331-335 注释明言 to maintain ABI compatibility)。inc_max_seq() 里能看到同步搬运这些计数的代码(vmscan.c:4462-4483)。这不是可有可无的细节——它是 MGLRU 能作为"可直接替换实现"合入主线的前提。

2.4 层级(tier):文件描述符访问的热度放大器

// include/linux/mm_inline.h:135,143
135 static inline int lru_tier_from_refs(int refs)
140   return order_base_2(refs + 1);      // refs=0,1→tier1; 2,3→tier2; 4..7→tier3; 8+→tier4
143 static inline int folio_lru_refs(struct folio *folio)
154   return ((flags & LRU_REFS_MASK) >> LRU_REFS_PGOFF) + workingset;

为什么 gen 之外还要 tier?因为访问有两条通道,热的意义不同(mmzone.h:351-370 注释):

fd 访问的入口是 folio_inc_refs()v6.6 mm/swap.c:415,mark_page_accessed 的 MGLRU 分流),递进阶梯非常精巧——前两级白嫖既有页标志,第三级才动用专用位:

// v6.6 mm/swap.c:415 folio_inc_refs()(摘录)
415 static void folio_inc_refs(struct folio *folio)
417   unsigned long new_flags, old_flags = READ_ONCE(folio->flags);
421   if (!folio_test_referenced(folio)) {
422     folio_set_referenced(folio);   // 第 1 次:置 PG_referenced → tier 1
423     return;
426   if (!folio_test_workingset(folio)) {
427     folio_set_workingset(folio);   // 第 2 次:置 PG_workingset → tier 2
428     return;
431   do {                            // 第 3 次起:cmpxchg 递增 LRU_REFS 位 → tier 3/4
432     new_flags = old_flags & LRU_REFS_MASK;
434     if (new_flags == LRU_REFS_MASK) break;   // 顶格封顶
435     new_flags += BIT(LRU_REFS_PGOFF);
436     new_flags |= old_flags & ~LRU_REFS_MASK;
437   } while (!try_cmpxchg(&folio->flags, &old_flags, new_flags));

tier 的移动全程原子位操作mmzone.h:366-368:moving across tiers only involves atomic operations... negligible cost),而跨代移动(排序归位)才需要 LRU 锁。回收时对比各 tier 的 refault 率决定"多热的页值得保护"(§5.2)。

为什么两条通道待遇不对称(设计文档 rst:58-68 给了三条理由):页表通道的保护设计上就更强——① 判定不确定性更高(accessed bit 只是近似),错杀风险大,要留余量;② 驱逐成本更高(要 TLB flush、易遇脏位);③ 欠保护的惩罚更高——应用通常为阻塞 IO 准备了专门线程(GUI 常用独立 IO 线程避免卡渲染),但没人会为缺页做准备,major fault 直接卡在访问现场。所以:fd 通道默认按"无时间局部性"处理(除非 refault 数据说不是),页表通道默认按"有时间局部性"处理(除非 VM_SEQ_READ/VM_RAND_READ 说不是)SRC
gen × tier 二维坐标的直觉:gen 回答"这页多久没被页表访问了"(时间维度,粗粒度、批量管理);tier 回答"这页被 read()/write() 宠幸过几次"(频率维度,对数压缩、免锁更新)。一个页可以是"老代 + 高 tier"(很久没被指针碰,但反复被顺序读——典型的大文件流式读缓存),也可以是"新代 + 低 tier"(刚被缺页进来还没二次访问)。

§3Aging(老化):正向页表扫描 + Bloom filter

3.1 传统方案的痛点(为什么要正向扫)

传统 LRU 想知道"这页最近有没有被访问",唯一的办法是从页反查到所有映射它的 PTE(rmap 反向映射),逐个读 accessed 位再清零——每页每轮都要走一遍(5.10 的 page_referenced()vmscan.c:992 调用)。页被多个进程共享时(典型:libc),这是 O(映射数) 的树遍历 + 锁 + TLB flush。

MGLRU 反其道而行之:不问"哪些 PTE 映射这页",而是顺着进程的页表从上往下扫——扫到一个 young 的 PTE,就把它指向的页提升到最年轻代。一次遍历的产出覆盖沿途所有页,且天然批量(一个 PTE 表 512 项一次处理)。

🧠 比喻:人口普查 vs 户籍反查。传统 LRU 像为了知道"谁还住这",拿着住户名单挨家打电话(rmap);MGLRU 像直接挨栋楼扫楼(正向扫描),一晚上查完整条街。更妙的是配了个"情报台"(Bloom filter)——上一轮哪栋楼查到过活人,这一轮优先查;上轮空楼直接跳过。

3.2 主循环:try_to_inc_max_seq → walk_mm

// mm/vmscan.c:4495(摘录)
4517   if (!should_walk_mmu()) {           // 硬件不自动置 accessed bit 时的回退路径
4518     success = iterate_mm_list_nowalk(lruvec, max_seq);
4520   }
4522   walk = set_mm_walk(NULL, true);
4528   walk->lruvec = lruvec;  walk->max_seq = max_seq;
4533   do {
4534     success = iterate_mm_list(lruvec, walk, &mm);   // 从 memcg 的 mm FIFO 逐个取进程
4535     if (mm)
4536       walk_mm(lruvec, mm, walk);                      // 扫这个进程的页表
4537   } while (mm);
4539   if (success)
4540     inc_max_seq(lruvec, can_swap, force_scan);       // 全部扫完 → 正式立新代

要扫的进程从哪来?每个 memcg 维护一个 mm_struct 的 FIFO 链表lru_gen_mm_list),进程 fork 时挂入、退出时摘除,进程迁移 memcg 时跟着走。aging 走完一轮 FIFO 记一次 mm_state.seq,防止陈旧遍历。两个来自官方文档的细节(doc/multigen_lru.rst:160-179):① 多个页表遍历器可并行迭代同一链表、各拿各的 mm;② 基础设施跟踪 mm 在上下文切换间的使用情况——上轮以来一直睡着的进程直接跳过(睡着的人不会访问内存)。inc_max_seq()vmscan.c:4438-4493)在 LRU 锁内:给新代盖时间戳 timestamps[next] = jiffies、搬运 active/inactive 兼容计数、smp_store_release(&lrugen->max_seq, max_seq+1) 发布新代。

3.3 PTE 层:walk_pte_range

// mm/vmscan.c:4003(摘录)
4017   pte = pte_offset_map_nolock(args->mm, pmd, start & PMD_MASK, &ptl);
4020   if (!spin_trylock(ptl)) {          // 拿不到锁就跳过这页表——绝不阻塞业务线程
4021     pte_unmap(pte);  return false;
4022   }
4027   for (i = pte_index(start), addr = start; addr != end; i++, addr += PAGE_SIZE) {
4030     pte_t ptent = ptep_get(pte + i);
4039     if (!pte_young(ptent)) {          // 没被访问过:只记账,跳过
4040       walk->mm_stats[MM_LEAF_OLD]++;  continue;
4042     }
4044     folio = get_pfn_folio(pfn, memcg, pgdat, walk->can_swap);
4048     if (!ptep_test_and_clear_young(args->vma, addr, pte + i))  // 收割并清零 young 位
4049       VM_WARN_ON_ONCE(true);
4059     old_gen = folio_update_gen(folio, new_gen);   // ★ cmpxchg 提升到最年轻代,无锁
4060     if (old_gen >= 0 && old_gen != new_gen)
4061       update_batch_size(walk, folio, old_gen, new_gen);   // 批量记账,稍后归位
4070   return suitable_to_scan(total, young);   // 本 PMD 命中率够高才推荐进 Bloom filter

四个值得咀嚼的细节SRC

3.4 PMD 层:Bloom filter 与两遍式扫描

// mm/vmscan.c:4158 walk_pmd_range(摘录)
4182     pmd_t val = pmdp_get_lockless(pmd + i);   // 无锁读 PMD 项(乐观读取)
4213     if (should_clear_pmd_young()) {
4214       if (!pmd_young(val)) continue;
4217       walk_pmd_range_locked(pud, addr, vma, args, bitmap, &first);  // 第二遍:持锁清 PMD young
4220     if (!walk->force_scan && !test_bloom_filter(walk->lruvec, walk->max_seq, pmd + i))
4221       continue;                                  // ★ Bloom filter 说没戏 → 跳过整张 PTE 表
4225     if (!walk_pte_range(&val, addr, next, args))
4226       continue;
4228     walk->mm_stats[MM_NONLEAF_ADDED]++;
4231     update_bloom_filter(walk->lruvec, walk->max_seq + 1, pmd + i);  // 上轮热 → 记入下轮情报

Bloom filter 双缓冲mmzone.h:447-459 lru_gen_mm_state:NR_BLOOM_FILTERS=2,flip after each iteration):以 PMD 页表指针为 key。空间局部性(热数据往往连片)使得这个简单过滤器命中率高得惊人;冷区则整表跳过。遍历成本从 O(全部页表) 降到 O(热区页表)。注意官方文档强调的反馈方向(doc/multigen_lru.rst:181-193):rmap 遍历贵在"不同 VMA 的页对 rmap 不缓存友好"——所以用 look_around 反哺 Bloom filter:eviction 的 rmap 走到 young PTE 时环视邻居,若邻居也热(缓存行高效),就把这个 PMD 记入 filter,下轮 aging 直接定点扫这张页表——eviction 和 aging 之间形成闭环。误报的代价只是一次多余的 PTE 表扫描(说不定还能捞到热页),可控(rst:206-209)。

两遍式设计(4172-4176 注释):第一遍无锁扫 PTE(避免 PMD 锁);只有 PMD young 位还置着时才第二遍拿锁清 PMD 位——非叶子页表项的 young 位是"这张子表里曾有活动"的免费提示,x86 上需手动清(对应开关 0x0004,§6.2)。

3.5 回退路径:lru_gen_look_around(捎带式老化)

// mm/vmscan.c:4650(摘录)
4667   lockdep_assert_held(pvmw->ptl);
4670   if (spin_is_contended(pvmw->ptl))
4671     return;                          // 锁有竞争,立刻放弃
4676   start = max(addr & PMD_MASK, pvmw->vma->vm_start);   // 以正在回收的页为中心
4698   for (i = 0, addr = start; addr != end; i++, addr += PAGE_SIZE) {
4700     pte_t ptent = ptep_get(pte + i);          // 环视同 PMD 的邻居 PTE
        ... young 的邻居同样 folio_update_gen 提升到最年轻代 ...

eviction 的 rmap 走到某页时(try_to_unmap 路径),反正 PTE 锁已经在手,干脆环视同一张页表里的邻居——young 的顺手提升。这叫"捎带式老化"(piggyback):即使硬件不支持批量收割(should_walk_mmu() 为假,如某些 ARM 配置)、或 aging 引擎没轮到,回收路径自己也能积累热度信息。两台引擎互为备份SRC

§4Eviction(驱逐):从最老代批量收割

4.1 主循环

// mm/vmscan.c:5353 try_to_shrink_lruvec(摘录)
5364   while (true) {
5367     nr_to_scan = get_nr_to_scan(lruvec, sc, swappiness);  // 内部决定要不要先 aging
5368     if (nr_to_scan <= 0)  break;     // -1 = 刚做过 aging,换 lruvec;0 = 没活干
5371     delta = evict_folios(lruvec, sc, swappiness);         // 批量驱逐一批
5372     if (!delta)  break;
5375     scanned += delta;
5379     if (sc->nr_reclaimed >= nr_to_reclaim)  break;  // 够数就收工(避免过度回收)
        }

4.2 evict_folios:批量隔离 → 经典回收 → 惰性归位

// mm/vmscan.c:5165 evict_folios(摘录)
5181   spin_lock_irq(&lruvec->lru_lock);
5183   scanned = isolate_folios(lruvec, sc, swappiness, &type, &list);  // ① 从最老代批量摘 64~4096 页
5185   scanned += try_to_inc_min_seq(lruvec, swappiness);                // ② 老代清空则退休(min_seq+1)
5190   spin_unlock_irq(&lruvec->lru_lock);
5195   reclaimed = shrink_folio_list(&list, pgdat, sc, &stat, false);   // ③ 复用经典回收:unmap+写回+释放
5198   list_for_each_entry_safe_reverse(folio, next, &list, lru) {
5213     if (skip_retry || folio_test_active(folio) || folio_test_referenced(folio) ||
5214         folio_mapped(folio) || ...) {
5217       set_mask_bits(&folio->flags, LRU_REFS_MASK | LRU_REFS_FLAGS, BIT(PG_active)); // ④ 拒收页标记 active 下不为例
5229     move_folios_to_lru(lruvec, &list);    // ⑤ 归位(sort_folio 决定去哪)
        }

三个设计亮点SRC

4.3 sort_folio:回收时刻的"惰性排序"

aging 期间页只改了学籍卡(gen 标签)没换座位;eviction 把页拿在手里时才顺手归位——这就是 folios[][] 注释"lazily sorted on eviction"的含义:

// mm/vmscan.c:4896 sort_folio(摘录,六种判决)
4911   if (!folio_evictable(folio))          { ...挪去 unevictable 链表... }
4921   if (脏的 lazyfree anon)                { ...挪尾部... }
4930   /* promoted */  if (gen != lru_gen_from_seq(lrugen->min_seq[type]))
4931     { list_move(&folio->lru, &lrugen->folios[gen][type][zone]);  return true; }  // ★ aging 升过代的页:搬到它该在的代
4936   /* protected */  if (tier > tier_idx) { folio_inc_gen(...); 记 protected 统计; }  // 高 tier 且 refault 率高:升一代保护
4948   /* ineligible */ if (zone > sc->reclaim_idx || CMA) { 升一代; }
4955   /* waiting for writeback */ { 升一代, 带上 PG_reclaim; }
        /* 以上都不是 ⇒ 真冷页,进入 shrink_folio_list 回收 */

注意 "promoted" 分支(4930-4933)就是无锁提升的收尾:aging 时 folio_update_gen() 改了标签,此刻把它搬到 gen 对应的链表头部。物理搬运被推迟到"反正已经拿在手里"的时刻——每页每次晋升节省一次专门的链表操作,这是 MGLRU 降低锁竞争的核心账本。

§5工作集保护:refault、tier 判定与 aging 触发

5.1 影子 token:驱逐时埋点,回流时对账

// mm/workingset.c:231,279(摘录)
250   token = (min_seq << LRU_REFS_WIDTH) | max(refs - 1, 0);   // 驱逐时的 token:哪个代走的+热度
253   atomic_long_add(delta, &lrugen->evicted[hist][type][tier]);
        —— 页回流(refault)时 ——
292   recent = lru_gen_test_recent(shadow, type, &lruvec, &token, &workingset);
298   if (!recent)  goto unlock;                    // 走的时候不是最近一代:正常回流,不奖励
305   refs = (token & (BIT(LRU_REFS_WIDTH) - 1)) + workingset;  // 还原当时的 refs
306   tier = lru_tier_from_refs(refs);
308   atomic_long_add(delta, &lrugen->refaulted[hist][type][tier]);  // 按 tier 记账!
318   if (lru_gen_in_fault() || refs == BIT(LRU_REFS_WIDTH)) {
319     folio_set_workingset(folio);               // 缺页即刻回流 = 工作集在抖动
        }

传统 5.10 的 refault distance 是个一维标量(阴影里只存"走时的工作集水位",见 §8.5);MGLRU 的影子 token 是(min_seq, refs) 二元组——"从哪一代走的 + 当时多热"。"最近一代就回流"(token 的 seq == 当前 min_seq)才算 refault,记账精确到 tierSRC

5.2 tier 判定:用统计显著性决定保护谁

// mm/vmscan.c:5075 get_tier_idx(摘录)
5081    * To leave a margin for fluctuations, use a larger gain factor (1:2).
5085   read_ctrl_pos(lruvec, type, 0, 1, &sp);       // 第 1 层的 refault 均值(基准)
5086   for (tier = 1; tier < MAX_NR_TIERS; tier++) {
5087     read_ctrl_pos(lruvec, type, tier, 2, &pv);  // 第 tier 层的 refault 均值(×2 增益)
5088     if (!positive_ctrl_err(&sp, &pv))          // 比值显著高才继续升 tier
5089       break;
5092   return tier - 1;   // 得到 tier_idx:回收时 tier > tier_idx 的页受保护(sort_folio L4936)

逻辑:比较"第 1 层(访问 0-1 次)"与"第 N 层(访问 ≥2^(N-1) 次)"的 refault 比率(refaulted / (evicted+protected) 的指数移动平均,read_ctrl_pos)。高 tier 的页回流率若没有显著更高,保护就不划算——它们只是被读过几遍,不是真热。增益 2:1 留出波动余量(5081-5084 注释)。同样思路的 get_type_to_scan()(vmscan.c:5095-5121)决定先扫 anon 还是 file:把 swappiness 编进增益(gain[] = {swappiness, 200-swappiness})后比较两类页的 refault 率——swappiness 从"拍脑袋比例"变成了"带统计检验的反馈控制器"。

官方设计文档的说法(v6.6/doc/multigen_lru.rst:211-221):这个反馈回路是仿 PID 控制器(Proportional-Integral-Derivative)建模的——监控 anon/file 两类的 refault,决定同类同代时驱逐谁。两个精妙细节:① 时间域用"代"而不是墙钟(CPU 在不同内存压力下扫页速率不同,代数才是公平的自变量);② 每代计算滑动平均,避免永久锁死在次优状态。这是把控制论搬进页回收的罕见示例SRC

5.3 should_run_aging:按人口分布决定分不分家

// mm/vmscan.c:5257 should_run_aging(摘录)
5269   if (min_seq[!can_swap] + MIN_NR_GENS > max_seq) {   // 冷页彻底耗尽
5270     *nr_to_scan = 0;  return true;                     // ⇒ 必须 aging(立新代制造冷页)
        ...
5296    * The aging tries to be lazy to reduce the overhead, while the eviction
5297    * stalls when the number of generations reaches MIN_NR_GENS. Hence, the
5298    * ideal number of generations is MIN_NR_GENS+1.
5301   if (min_seq[!can_swap] + MIN_NR_GENS < max_seq)
5302     return false;                             // 代数还富余:不用 aging(懒字诀)
5311   if (young * MIN_NR_GENS > total)            // 最年轻代 > 总量 1/2:热页堆太满
5312     return true;
5313   if (old * (MIN_NR_GENS + 2) < total)        // 次老代 < 总量 1/4:快没冷页了
5314     return true;

理想分布是每代占 1/(MIN_NR_GENS+1) = 1/3(5304-5309 注释),aging 盯上限(热页 ≤1/2),eviction 盯下限(冷页 ≥1/4)。aging "能懒则懒",因为页表扫描虽是批量也有成本——这是 MGLRU 在"精度"与"开销"之间的显式权衡SRC

§6memcg 分代、运行时开关与防抖

6.1 memcg 也有"老/年轻"两代

页有代,memcg 本身也被分成老/年轻两代mmzone.h:496-546):老代 memcg 优先被全局回收光顾;淘汰一轮下来还"有货"或低于 min 水位的 memcg 升入年轻代。每代再随机打散到 MEMCG_NR_BINS=8 个 bin 降低锁竞争(mmzone.h:534-535)。触发规则全部注释在 mmzone.h:518-528:超软限→排头(优先回收);低于 low/可回收阈值→排尾;低于 min 或完成 aging→升年轻代。全局公平由各 memcg max_seq 的轮转推进保证。

官方文档的类比与复杂度(doc/multigen_lru.rst:223-251):memcg LRU 是"LRU 的 LRU"——年轻/年老两代对应 folio 的 active/inactive,max_seq 推进对应激活,下线对应降级。两个独有特性:① 分片(sharding)——每个回收线程从随机 bin 的随机 memcg 起步,天然并行;② 最终公平(eventual fairness)——直接回收可随时 bail out 降延迟,不损害长期公平。遍历复杂度最好情形从 O(n) 降到 O(1),平均次线性。目标场景直白写在文档里:数据中心的全局超额承诺SRC

6.2 运行时开关:/sys/kernel/mm/lru_gen/enabled

组件说明
0x0001主开关整体启用 MGLRU(默认开,取决于 CONFIG_LRU_GEN_ENABLED)
0x0002叶子表批量清位批量清除 PTE 的 accessed 位(x86 等 MMU 自动置位架构);关闭则轻微性能损失
0x0004非叶子表清位连 PMD 等 non-leaf 项的 young 位也清(仅 Intel/AMD 验证过);关闭则可忽略的损失

以上来自内核文档 Documentation/admin-guide/mm/multigen_lru.rst:35-52SRC。运行时可随时开关:写入 y/n 作用于全部位。切换的实现(vmscan.c:5686 lru_gen_change_state())相当讲究——翻 static_branch 静态键后,遍历所有 memcg×node 的 lruvec,把页在两套 LRU 之间整体搬迁(启用时 fill_evictable() 填充、禁用时 drain_evictable() 排空),出问题能一键回退传统 LRU——大改动合入主线的"安全带"。

6.3 min_ttl_ms:抖动保险丝

// mm/vmscan.c:4598,4600 lru_gen_age_node(摘录)
4598 static unsigned long lru_gen_min_ttl __read_mostly;   // 默认 0=关闭
4615     if (lruvec_is_reclaimable(lruvec, sc, min_ttl)) { ... return; }  // 还有 ≥min_ttl 的老代,正常回收
4624   * The main goal is to OOM kill if every generation from all memcgs is
4625   * younger than min_ttl.
4628   if (mutex_trylock(&oom_lock)) { out_of_memory(&oc); }      // ★ 全部内存都"太年轻" ⇒ 直接杀进程

写给笔记本/桌面用户(文档 64-82 行):写入 N 毫秒后,"最近 N 毫秒的工作集"受绝对保护——若这都保不住,说明内存真不够了,与其反复抖动(每次缺页都 IO)不如直接触发 OOM 杀掉应用。文档建议:1000ms 消除人可感知的卡顿(人类感知延迟阈值 ~100ms 的 10 倍余量),3000ms 更保险但更激进。本质是把"抖到死"换成"痛快死"的可调压力阀SRC

6.4 debugfs:工作集估计与主动回收

/sys/kernel/debug/lru_gen 提供:工作集直方图(读出每个 memcg×node 下各代的"多少页在多少毫秒内被访问过"——数据中心装箱调度的利器);写入 + memcg_id node_id gen 主动立新代、- ... 主动驱逐指定代(主动回收冷页)。详见 multigen_lru.rst:94-162SRCMGLRU 把"工作集多大"变成了可直接查询的内核接口——这在传统 LRU 下只能间接推算。

§7收益与代价:一笔诚实的账

7.1 赢在哪(机制推导,非引用数字)

收益机制根源(源码)
kswapd/回收 CPU 大幅下降① 无锁晋升:folio_update_gen 的 cmpxchg 替代搬链表(§2.2);② 正向批量扫页表 + Bloom filter 跳过冷区,替代每页 rmap 反查(§3);③ 惰性排序把物理归位合并进回收批处理(§4.3)
冷热分辨率提升 8 倍2 档(active/inactive)→ 4 代×4 层级的二维坐标(§2);高共享页(libc 等)不再因 rmap 贵而被"误判保持热"
防抖能力min_ttl_ms 绝对保护最近工作集(§6.3);tier 保护基于统计显著性而非经验阈值(§5.2)
memcg 可伸缩性老/年轻两代×8 bin 的分片轮转(§6.1),替代单链表全局迭代
新能力工作集直方图、主动回收成为稳定 debugfs 接口(§6.4)

官方与产业报告(Google 的 Chrome OS/Android/服务器、Meta 生产环境等)报告了内存占用下降与卡顿/OOM 改善,具体数字随工作负载差异很大,建议以内核文档与原始 patch 系列讨论为准GEN

7.2 付出什么

一句话:MGLRU 不是"更聪明的算法"这么简单——它是把传统 LRU 里隐式、分散、被动的成本(每页搬链表、每页 rmap、拍脑袋阈值),换成显式、批量、可控制的成本(成批扫页表、按统计判定、可调开关)。"批处理化 + 无锁化 + 可观测化"才是它性能收益的三根支柱。

§8对标:Linux 5.10 传统 LRU(MGLRU 换掉了什么)

以下全部基于 v5.10 真实源码核对;结论同样适用于关闭 MGLRU 的更新内核(传统路径一直保留作为回退)。

8.1 数据结构:两条链表表达一维冷热

每个 lruvec(memcg×node)有 5 条链表(mmzone.h:278 lists[NR_LRU_LISTS]):anon/file 各一对 active+inactive,外加 unevictable。页在哪条链上由 PG_active 一个位决定(mm_inline.h:115 page_lru())。头=热端(新),尾=冷端(老),回收从尾部吃。冷热分辨率 = 2 档 × 链内位置(但链内位置只在回收时粗略有意义)。

8.2 两次机会状态机:两位页标志

// mm/swap.c:412-421 注释原文(5.10,状态机四转换)
        inactive,unreferenced  ->  inactive,referenced     (第一次访问: mark_page_accessed 置 PG_referenced)
        inactive,referenced    ->  active,unreferenced     (第二次访问: folio_activate 搬去 active 头部)
        active,unreferenced    ->  active,referenced       (继续访问)
        active,referenced      ->  active,referenced       (保持 active)
// 入口 mark_page_accessed()(swap.c:412);激活 activate_page()(swap.c:351,5.10 尚未 folio 化):
//   摘链表 → SetPageActive → 挂 active 头部,全程持有 lru_lock

注意:对映射页,"访问"的原始证据在 PTE 的硬件 accessed 位里,mark_page_accessed 只在缺页、mmap 读等内核在场的时刻被调用。用户态指针解引用不经过内核——那些访问攒在 PTE 里,要等回收时 rmap 收割(8.3)。

8.3 回收流水线与"反向查岗"

shrink_node() → shrink_lruvec() (vmscan.c:2425) ├─ get_scan_count() (2237) 按成本比例给 anon/file 分配扫描配额 ├─ shrink_list() → active: shrink_active_list() (2009) 降级,不回收 │ inactive: shrink_inactive_list() (1916) → isolate_lru_pages │ → shrink_page_list() unmap/写回/释放 └─ 页级判决: page_check_references() (986) page_referenced() (992) ★rmap 反向遍历所有映射的 PTE,收割 accessed 位
// v5.10 get_scan_count 的 fractionary 公式(vmscan.c:2315-2320,逐字)
2315   ap = swappiness * (total_cost + 1);      // anon 扫描压力 ∝ swappiness × 反比于 anon 回收成本
2316   ap /= anon_cost + 1;
2318   fp = (200 - swappiness) * (total_cost + 1);  // file 同理(200=2×100 的归一化)
2319   fp /= file_cost + 1;

成本来自"refault 比例 × IO 代价"的反馈(注释:inversely proportional to the cost),swappiness 只是加权系数。MGLRU 保留了同样的反馈思想(get_type_to_scan 的 gain[] = {swappiness, 200-swappiness},§5.2),但比较对象换成了按 tier 细分的 refault 统计——反馈粒度从"两类页各一个数"变成"两类页×四层各一个数"SRC

// v5.10 mm/vmscan.c:986(摘录)
 992   referenced_ptes = page_referenced(page, 1, sc->target_mem_cgroup, &vm_flags);
 994   referenced_page = TestClearPageReferenced(page);
1003   if (referenced_ptes) {
1018     SetPageReferenced(page);
1020     if (referenced_page || referenced_ptes > 1)
1021       return PAGEREF_ACTIVATE;      // 软件+硬件都引用过,或多个进程映射都在访问 → 升 active
1027     return PAGEREF_KEEP;            // 第一次 → 留在 inactive 再转一圈
1033   if (referenced_page && !PageSwapBacked(page))
1034     return PAGEREF_RECLAIM_CLEAN;  // 无引用 → 回收

page_referenced() 是传统方案的原罪:每页每次被考察都要从 page → 反向映射 → 遍历该页全部 PTE → 读+清 accessed 位。共享 N 次的页(libc、共享缓存)成本 O(N),还要拿锁、可能 flush TLB。

8.4 active→inactive:一刀切降级与 sqrt 魔法数

// v5.10 mm/vmscan.c:2009 shrink_active_list()(摘录)
2030   nr_taken = isolate_lru_pages(nr_to_scan, lruvec, &l_hold, ...);  // 从 active 尾部摘一批
2059   if (page_referenced(page, 0, ...)) {            // 降级前也查一次 rmap
2070     if ((vm_flags & VM_EXEC) && page_is_file_lru(page)) {
2072       list_add(&page->lru, &l_active);  continue;  // 可执行页例外:留在 active
2075     }
2077   ClearPageActive(page);   /* we are de-activating */
2078   SetPageWorkingset(page);
2079   list_add(&page->lru, &l_inactive);              // 其余全部降级,挂 inactive 头部(重新排队)
// v5.10 mm/vmscan.c:2202 inactive_is_low()(逐字)
2212   gb = (inactive + active) >> (30 - PAGE_SHIFT);
2213   if (gb)
2214     inactive_ratio = int_sqrt(10 * gb);   // 内存越大,inactive 占比要求越高: 1GB→3.2, 100GB→31.6
2215   else
2216     inactive_ratio = 1;
2218   return inactive * inactive_ratio < active;

active 降级是批量的、无差别的(除可执行页外整批搬走);何时降由双重闸门决定(vmscan.c:2695-2719):① inactive_is_low()——那个著名的 int_sqrt(10*gb) 经验公式(内存越大 inactive 缓冲要求越深:1GB→3.2,100GB→31.6);② refaults 快照有变化——一旦观察到新的工作集正在建立(refault 激活计数动了),就赶紧降级赶走 active 里的陈旧页(注释原文:Deactivate to get rid of any stale active pages quickly)。这两个魔法数/启发式正是 MGLRU 用"代人口分布"(§5.3)替代的东西SRC

8.5 工作集检测:一维 refault distance

// v5.10 mm/workingset.c:338,360,376(摘录)
338   refault_distance = (refault - eviction) & EVICTION_MASK;   // 走的时间戳与回的时间戳之差
360   workingset_size = lruvec_page_state(eviction_lruvec, NR_ACTIVE_FILE);
        ... (按有无 swap 决定是否计入 anon 与对方 inactive) ...
373   if (refault_distance > workingset_size)
374     goto out;                    // 离开太久:走了就走了,别回来装热
376   SetPageActive(page);          // 距离内回流 = 误伤 → 直接进 active

影子条目记录"驱逐时刻的水位",refault 时比较距离与当前工作集大小。一维标量、与页自身热度无关。file 与 anon 都有影子(anon 走 swap cache 分支:vmscan.c:899-906 的 PageSwapCache 路径调用 workingset_eviction();refault 侧 workingset.c:284page_is_file_lru 分流)SRC。对比 MGLRU 的 (min_seq, refs) 二元 token(§5.1)——差的不是有无,而是"逐出时你多热"这个维度。

8.6 六大痛点:MGLRU 动手的地方

#痛点(5.10)源码证据MGLRU 对策(回链)
1两档分辨率太粗:active 内部无序,刚激活的和常年热的一样待遇page_lru() 只看 PG_active 一位(mm_inline.h:115)4 代×4 层级二维坐标(§2)
2每次访问都要搬链表:mark_page_accessed/activate 持 lru_lock 摘挂,多核弹跳缓存行folio_activate(swap.c:351)cmpxchg 改 gen 标签,物理位置不动(§2.2)
3rmap 反向查岗贵:每页每轮 O(映射数) 遍历+锁+TLBpage_referenced(vmscan.c:992)正向批量扫页表+Bloom filter(§3)
4老化被动且一刀切:只有回收压力来了才降级 active,整批无差别shrink_active_list(2009)aging 主动分代,热页升代冷页自然沉淀(§3、§4.3)
5魔法数硬编码:sqrt(10*gb)、swappiness 线性比例inactive_is_low(2214)、get_scan_count(2237)人口分布启发式+统计显著的 tier 判定(§5.2、§5.3)
6工作集不可观测:只有间接计数器debugfs 直方图+主动回收接口(§6.4)
公平地说:传统 LRU 的这套设计在 2000 年代的单核/少核机器上完全够用,两位状态机+两链表的成本假设("改链表不贵")当时成立。MGLRU 是对多核大内存时代成本结构变化的回应——不是推翻 LRU 思想,而是换掉它的实现物理学。

§9对照:iOS (XNU) 的"另一条技术路线"

源码基准 xnu-7195.141.2(iOS 14.8),本站统一引用工作区 deep-read 存档(与上游逐字节一致的 genuine 版本,vm_pageout.c 10996 行);实验数据沿用本工作区 iPhone 12 Pro 实测。

9.1 根本差异:没有 swap,只有三条路

iOS 设备没有传统磁盘换页(swap)——这不是"没配置",而是编译期开关就关死了vm_pageout.c:4967-4999 vm_config 的 switch 里,COMPRESSOR 模式下 swap 只留给 freezer 用(把冻结进程的内存写到受保护分区,不参与常规换页)。物理内存吃紧时 XNU 的选项只有三个:

机制源码Linux 对应物
压缩匿名页压缩进 VM 内嵌压缩器(WKdm/LZ4 混合),驻留但省空间vm_compressor.c(WKdm 选择 :3917;算法 vm_compressor_algorithms.c:52,276zram(用户态块设备,可插拔算法)
丢弃+重读干净的文件页直接丢,要用时从 NAND 重读vm_pageout_scan external 路径页缓存回收(相同思想)
查杀/冻结jetsam 按优先级 band 杀进程;freezer 把后台整进程冻结kern_memorystatus.h:40-62(band 全表);kern_memorystatus_freeze.cOOM killer(badness 全局打分)

连"抖动"的处置都不同:Linux 抖动时更努力地换页(或 min_ttl 直接放弃),XNU 抖动时(压缩器饱和)直接升级为 jetsam 杀进程vm_compressor.c:2241-2268)——它没有"换页扛过去"这个选项SRC

🧠 比喻:写字楼管理。Linux 是"共享仓库"逻辑——谁的货都尽量放着,放不下了先搬去郊区仓库(swap)再不行才清退租户。iOS 是"会员制公寓"逻辑——根本不设郊区仓库,行李先真空打包塞床底(压缩器),床底也满了就按会员等级(band)请低级会员退房(jetsam);长期不住的直接整体封存(freezer)。

9.2 页队列老化:FIFO 近似,不做精细 LRU

先立证据:XNU 没有 per-page LRU 链表。每页只有 4 bit 的 vmp_q_statevm_page.h:271)标记"在哪个全局队列"(vm_page.h:1268-1270 声明的 active/inactive 等队列头),队列进出是 FIFO——先入队的页先被降级、先被考察。冷热信息 = 队列归属 + 引用位,没有更细的粒度。

// vm_pageout.c:2769 vm_page_balance_inactive()(摘录,iOS 14.8)
2786   vm_page_inactive_target = VM_PAGE_INACTIVE_TARGET(vm_page_active_count +
2787       vm_page_inactive_count + vm_page_speculative_count);
        // VM_PAGE_INACTIVE_TARGET(avail) = avail * 1/2   (vm_pageout.c:208)
2790   while (max_to_move-- && (vm_page_inactive_count + vm_page_speculative_count) < vm_page_inactive_target) {
2793     m = (vm_page_t) vm_page_queue_first(&vm_page_queue_active);   // active 队列头=最老(FIFO)
2813     if (m->vmp_pmapped == TRUE) {
2814       pmap_clear_refmod_options(..., VM_MEM_REFERENCED, PMAP_OPTIONS_NOFLUSH, NULL);  // ★清引用位,故意不刷 TLB
2822     vm_page_deactivate_internal(m, FALSE);   // 降级到 inactive 队列
        }

XNU 的"老化"就这么朴素:每次循环从 active 队列头摘一页(先来先老),清掉引用位,扔进 inactivevm_pageout_scan 主循环 :2950 每轮调用)。inactive 目标恒为总量 1/2(:208)。没有代、没有 tier、没有链内排序——队列位置本身就是唯一的"温度计",且只有队列头尾两个采样点SRC。不刷 TLB 的理由源码注释写得很诚实(:2802-2812):最坏情况是漏记远端 CPU 缓存里的引用——"TLB 缓存不会存活太久",可接受。

// vm_pageout.c:3357 inactive 队列上的两次机会(摘录)
3357   refmod_state = -1;
3359   if (m->vmp_reference == FALSE && m->vmp_pmapped == TRUE) {
3360     refmod_state = pmap_get_refmod(VM_PAGE_GET_PHYS_PAGE(m));   // 读物理页 ref/mod 位
3362     if (refmod_state & VM_MEM_REFERENCED) {
3363       m->vmp_reference = TRUE;      // 降级后被访问过 → 软件位记下
3365     }
3368   }
3381   if (!m->vmp_no_cache && page_from_bg_q == FALSE &&
3383       (m->vmp_reference || ...)) {
        // 被引用 → 升回 active(:3405 reactivate),受 reactivate_limit 限流防饿死回收 (:273)

补充:老化触发同样有"五阶段"流水线(balance :2950 → free-target 检查 :2959 → purgeable 对象 :3006 → speculative 队列老化 :3034 → 对象缓存驱逐 :3051),水位驱动睡眠-唤醒(真机实测空闲时计数器完全静止)SRCEXP

9.3 引用位收割:门铃登记 vs 主动查岗

这是 XNU 最有意思的分叉。硬件访问位在 PTE 里,Linux 的做法是回收时 rmap 查岗(§8.3)或 MGLRU 正向扫楼(§3)。XNU 在 ARM64 上玩出了第三种:

XNU「门铃模型」(iOS,ARM64 专属) 老化降级时: 清 AF(access-flag) 位 → 这扇门"锁上并要求刷卡" 之后任何访问: PTE.AF=0 触发 access-flag fault(ARM 硬件行为) → arm_fast_fault_internal (pmap.c:10840) → arm_clear_fast_fault (pmap.c:10704): 一次遍历修好整页全部映射 → 当场把 PP_ATTR_REFERENCED 写进物理页软件位 (pmap.c:10768-10780) ⇒ 管理员躺着收登记表,回收时 O(1) 读软件位即可

代价与保险:第一个撞门的访问者付 O(映射数)(100 映射 ≈10μs);NOFLUSH 让其他核的 TLB 缓存连 fault 都不产生(漏记,源码注释明认)。实测旁证:热探针 55 秒 135 万次访问仅产生 ≈0.4% 单核的 fault 开销——把记账成本转嫁给访问路径的偶发 fault,换回收路径的 O(1) 判定SRCEXP

为什么 Linux 不能照抄?x86 的 ACCESSED 位重置后不产生 fault(无介入点,只能轮询);Linux 要跑十几种架构,VM 核心不能绑死单一 ISA 特性。MGLRU 的批量正扫本质上是把"轮询"做成批发价——通用系统的答案。专用系统(苹果)可以直接赌硬件。同一个问题,通用性与专用性的两条路。

再补一个粒度视角的总对照:XNU 的全部三类引用位操作都是单页粒度——降级时清位(:2813-2814)、逐出前读位(:3360)、门铃登记(fault 时)——从不批量扫页表。MGLRU 则为代际精度付出成批遍历页表的代价。一句话:Linux MGLRU 像勤快的仓库管理员定期推小车全店盘点(批量扫页表换精度);iOS 的管理员从不盘点,只在拿起一件货准备扔的瞬间低头看一眼标签(单页 pmap_get_refmod)——便宜,代价是同队列内的页没有个体年龄,存活时间只取决于队列流量SRC

9.4 压缩器:当 swap 用的"真空打包"

inactive 队列上的匿名页,老到一定年龄(compute_swapout_target_agevm_compressor.c:353)就被压缩进 c_segment。算法 WKdm 为主(字典式,快且可随机解压单页,:3917)、LZ4 备选(vm_compressor_algorithms.c:52,276)。段内碎片由 major/minor compaction 处理。真机实测:iPhone 12 Pro 受压时 4 秒内压缩器吸收 19.7% 内存,回收 CPU 的 95%+ 花在 WKdm 上——压缩器是 iOS 事实上的"swap 设备",只是介质是 RAM 本身SRCEXP。Android 同期主流方案 zram 也是压缩换页,殊途同归。

9.5 jetsam:会员等级制查杀

Linux OOM 按 badness 打分全局挑"最肥的"杀;iOS 预先把进程排进 210 个优先级 bandkern_memorystatus.h:40-62 定义 IDLE=0 → IDLE_DEFERRED/AGING_BAND1=1 → BACKGROUND_OPPORTUNISTIC=2 → BACKGROUND=3 → … → FOREGROUND → 各系统关键服务),内存不够时从最低 band 往上杀(从 IDLE 起遍历,:1298/:5701 一带)。App 退后台先进 aging band(10→15→0 三级流水线各缓冲约 10-30 秒,kern_memorystatus.c:307-308,406-442 一带的 band 迁移逻辑)防"刚切走就被杀"。记账用 phys_footprint(task.c:986-993:内含 internal+compressed+iokit_mapped+purgeable_nonvolatile+page_table——压缩后的内存也计入),查杀与记账同币种——这是 iOS 记账体系相对 Linux(memcg 记账与 OOM 决策割裂)的结构优势SRC

9.6 freezer / purgeable / 压力通知:iOS 的"应用协同"三件套

9.7 版本演化一瞥(iOS 12→18 六版本同口径统计)

iOS 的页级策略这些年大体稳定,增量都往"查杀与冻结"倾斜:freezer 相关代码从 iOS 12 的约 2400 行扩到 iOS 18 的约 3250 行;swapout 引用 48→50→55→101→102→102(iOS 16 前后翻倍);sustained-pressure kill(持续高压下提前杀进程)是 iOS 16 新增(六版本统计 0→0→0→6→6→6 处引用)——又一个"宁可杀进程、不做更细页级算法"的注脚SRC

9.8 小结:iOS 根本没做"更好的 LRU"

iOS 的选择是"不做精细页级 LRU":页粒度上只有队列 FIFO + 引用位两次机会(比 5.10 还粗);但它把省下来的复杂度花在了页粒度之外——进程级限额、band 查杀、整进程冻结、应用主动协同。逻辑是:手机上"哪个进程该活"比"哪个页该留"更重要——前台 app 的 100% 页面都比后台 app 的"热页"热。Linux(含 MGLRU)始终在页级做全局最优,iOS 在进程级做体验最优。这不是谁更强的关系,是问题设定不同。

§10三方对决:逐项判定

判定限定场景:服务器/通用多租户 vs 单用户交互设备(手机)。"能力独有"= 另两方主线无对应机制。

#对比项MGLRU (6.1+)5.10 传统 LRUiOS XNU判定
1冷热分辨率4 代 × 4 层级(gen×tier 二维,免锁 tier 递增)2 档(active/inactive)×链内位置队列 FIFO 位置(粗粒度,仅头尾两采样点)MGLRU 胜 8 倍类别数
2访问位收割方式正向批量扫页表(walk_mm)+ Bloom filter 剪枝 + PMD 两遍式反向 rmap 查岗(page_referenced,每页 O(映射数))门铃登记(ARM64 access-flag fault 当场写软件位,回收 O(1) 判定)iOS 架构最优雅但绑死 ARM;MGLRU 是通用架构下最优解
3热度记账成本cmpxchg 改 folio->flags(无锁)+ 惰性归位持 lru_lock 摘挂链表(缓存行弹跳)清 refmod 位(NOFLUSH 免 TLB)+ 偶发 fault 转嫁MGLRU 与 XNU 平手,均碾压传统
4老化主动性主动 aging(人口分布启发式,能懒则懒)被动(回收压力驱动降级)水位驱动(inactive_target=1/2,每轮 balance)MGLRU 胜 反馈最精细
5工作集保护(min_seq, refs)二元 token + 按 tier 统计显著性判定一维 refault distance + sqrt 魔法数无显式模型(靠 band/aging 与压缩器兜底)MGLRU 胜 模型最完整
6防抖机制min_ttl_ms 绝对保护 + OOM 压力阀无专门机制(靠 watermark boost 缓解)四级压力通知 + 滞回(App 主动释放)+ jetsam 提前量iOS 胜(面向体验的完整闭环)
7换页后路swap/zram 完整体系(真正的逃生通道)同左无 swap:压缩器即"swap"(RAM 当介质)各有胜场:大内存服务器要 swap,手机 NAND 寿命敏感
8内存耗尽终局OOM killer(badness 打分,可配 min_ttl)OOM killer(badness 打分)jetsam 按优先级 band 查杀 + 三级 aging band 防误杀手机场景 iOS 胜(可预期、有缓冲);服务器 memcg 隔离 Linux 胜
9进程级隔离memcg(页级代之外,memcg 也分老/年轻两代×8 bin)memcgphys_footprint 双限额(前台宽/后台严)内核强制各有胜场:多租户 Linux 强,单用户体验 iOS 强
10应用协同PSI(连续量,面向调度)PSI(5.10 已有)压力通知(离散四级,面向 App 行动)+ purgeable 一等公民 + freezeriOS 胜(协作语义丰富)
11可观测性debugfs 工作集直方图 + 主动回收接口(工作集变成可查询 API/proc/vmstat 间接计数vm_stat/task_info 一致链(Instruments)MGLRU 新能力独有(数据中心装箱利器)
12可回退性运行时开关(enabled 位掩码),传统路径完整保留—(它就是默认)—(无从选择)MGLRU 工程稳健性加分
总分视角:页级算法(1-5、11-12 项)MGLRU 全面领先——它就是为此而生的;系统级体验(6、8、10 项)iOS 领先——因为它跳出了页级竞赛,在进程级做文章;5.10 传统 LRU没有一项领先,但它是所有这些演化的基线,且"够用"了二十年。最有洞察的对比不是"MGLRU vs iOS 谁强",而是:两者用完全不同的方式回应了同一个时代问题——内存越来越便宜但永远不够,访问信息越来越难收集(多核+大内存)。Linux 的答案是把页级算法做到极致(MGLRU),苹果的答案是把问题挪到页粒度之外(进程级治理)。

§11比喻总表与收束

11.1 一张表带走所有比喻

概念比喻要点回链
三套系统的问题小区图书馆管理员想知道哪些书没人看:挨家抄门上的自动记录器(扫页表)、拿起书翻借阅章(rmap)、门口装刷卡机(fault 登记)§0.1
MGLRU 的代按届分班的学校:最多 4 个届,表扬只改学籍卡届别(cmpxchg),期末大扫除才换教室(惰性归位)§1.2、§2.2、§4.3
MGLRU 的 tier学籍卡上的自习次数星标(对数分桶:2-3 次=2 星,4-7 次=3 星)§2.4
正向扫描 vs rmap人口普查扫楼 vs 户籍反查打电话;Bloom filter=上轮查到活人的楼优先、空楼跳过§3.1、§3.4
min_ttl抖动保险丝:最近 N 毫秒的书绝对不动,动不了就拉闸(OOM)痛快死§6.3
传统 LRU 降级一刀切分班考试:inactive 不够深就整批往冷班搬(只保可执行页),考纲是 sqrt(10×GB) 魔法数§8.4
iOS 整体策略会员制公寓:无郊区仓库(无 swap),行李真空打包塞床底(压缩器),按会员等级清退(band 查杀),长期不住整体封存(freezer)§9.1
XNU 引用位门铃登记:清 AF 位=锁门装刷卡机,谁进门谁自己登记(fault 写软件位),管理员躺着收登记表§9.3

11.2 三句话收束

MGLRU一句话

把传统 LRU 里隐式、分散、被动的成本(每页搬链表、每页 rmap、魔法数阈值),换成显式、批量、可控的成本(成批扫页表、无锁改标签、统计判定)——不改 LRU 思想,只换它的实现物理学,性能收益全部来自"批处理化 + 无锁化 + 可观测化"。

5.10一句话

两位状态机 + 两条链表在单核时代是成本假设成立的最优解;它的六大痛点(档位粗/锁竞争/被动老化/rmap 贵/一刀切/不可观测)全是多核大内存时代才暴露的——不是设计错误,是时代变了

iOS一句话

苹果知道自己跑在哪:单用户交互设备、ARM64、NAND 寿命敏感、App 生态可控——所以放弃页级精细 LRU,用队列 FIFO + 门铃收割 + 压缩器 + band 查杀 + 应用协同,把"哪个进程该活"置于"哪个页该留"之上。Linux 不知道自己会跑在哪,所以必须把通用算法做到极致——MGLRU 正是这个约束下的杰作。

11.3 如果你只能记住三件事

  1. gen 是时间维度(多久没被页表访问),tier 是频率维度(被 read/write 宠幸几次)——MGLRU 的全部冷热信息都在这 5 个 bit 里,改它们不需要锁。
  2. MGLRU 的性能收益不是来自更聪明的算法,而是更便宜的记账——正向批量扫页表(批发价)替代反向 rmap(零售价),cmpxchg 改标签(免锁)替代搬链表(持锁)。
  3. iOS 证明页级 LRU 不是唯一战场——进程级治理(限额/band/冻结/通知)在手机场景下比页级最优更重要;而 MGLRU 证明在必须做页级通用最优时,工程手段(批处理+无锁+统计反馈)比算法发明更有效。

§12源码锚点速查表(全部经本工作区逐行核对)

主题文件:行号(v6.6 / v5.10 / xnu-7195.141.2)内容
MGLRU 数据结构v6.6 include/linux/mmzone.h:321-347MIN/MAX_NR_GENS、滑动窗口、gen+1 编码、ABI 兼容注释
v6.6 mmzone.h:351-370tier 语义、MAX_NR_TIERS、LRU_REFS 位布局
v6.6 mmzone.h:416-444struct lru_gen_folio(max_seq/min_seq/folios[]/统计)
v6.6 mmzone.h:447-459lru_gen_mm_state(Bloom filter 双缓冲)
v6.6 mmzone.h:496-546memcg 老/年轻两代×8 bin
v6.6 include/linux/mm_inline.h:135-165lru_tier_from_refs/folio_lru_refs/folio_lru_gen/lru_gen_is_active
MGLRU agingv6.6 mm/vmscan.c:3773 folio_update_gen()cmpxchg 无锁改代(最关键函数)
v6.6 mm/swap.c:415 folio_inc_refs()fd 访问的 tier 递进阶梯(PG_referenced→PG_workingset→refs 位)
v6.6 vmscan.c:4003 walk_pte_range()PTE 批量收割(spin_trylock 不等待)
v6.6 vmscan.c:4158 walk_pmd_range()Bloom filter 过滤 + 两遍式扫描
v6.6 vmscan.c:4438 inc_max_seq()立新代(时间戳/兼容计数/发布)
v6.6 vmscan.c:4495 try_to_inc_max_seq()aging 主入口(mm FIFO 迭代)
v6.6 vmscan.c:4650 lru_gen_look_around()捎带式老化(回收时环视邻居)
v6.6 vmscan.c:5257 should_run_aging()人口分布启发式(1/2 上限、1/4 下限)
MGLRU evictionv6.6 vmscan.c:4896 sort_folio()六种判决(promoted/protected/ineligible…)
v6.6 vmscan.c:5123 isolate_folios()从最老代批量隔离 + 类型/tier 选择
v6.6 vmscan.c:5165 evict_folios()批量驱逐主函数(锁窗口极小)
v6.6 vmscan.c:5075/5095get_tier_idx / get_type_to_scan(统计判定)
v6.6 vmscan.c:5353 try_to_shrink_lruvec()主循环(get_nr_to_scan↔evict)
MGLRU workingsetv6.6 mm/workingset.c:231 lru_gen_eviction()影子 token=(min_seq<<W)|refs
v6.6 workingset.c:279 lru_gen_refault()按 tier 记 refaulted
v6.6 vmscan.c:4598-4637 lru_gen_age_node()min_ttl 防抖 → OOM 压力阀
5.10 传统 LRUv5.10 mm/swap.c:412 mark_page_accessed()两位状态机注释与实现
v5.10 swap.c:351 activate_page()持锁搬链表激活
v5.10 vmscan.c:986 page_check_references()page_referenced rmap 查岗判决
v5.10 vmscan.c:2009 shrink_active_list()一刀切降级(保 VM_EXEC)
v5.10 vmscan.c:2202 inactive_is_low()int_sqrt(10*gb) 魔法数
v5.10 workingset.c:338,373-376一维 refault distance 激活
XNUvm_pageout.c:2769 vm_page_balance_inactive()active FIFO 头降级+NOFLUSH 清位(:2813-2814)
vm_pageout.c:3357-3368inactive 两次机会(pmap_get_refmod)
vm_pageout.c:2833 vm_pageout_scan()水位驱动主扫描(五阶段 :2950-3057)
kern_memorystatus.h:40-62 / task.c:986-993JETSAM_PRIORITY band 表 / phys_footprint 权威定义

版本边界实证:v5.10/v5.15/v5.19 的 mm/vmscan.c 中 lru_gen 出现 0 次,v6.1 出现 161 次——MGLRU 于 6.1 合入。v6.6 vmscan.c 共 8148 行(v5.10 为 4310 行)。

§13诚实声明与参考资料

13.1 论断分层

13.2 已知边界

13.3 主要参考