背景

zone->contiguous 是 struct zone 中的一个布尔标志,它断言该 zone 的 spanned_pages 范围内每一个 PFN 都拥有在线的 memory map(memmap)。一旦置位,像 compaction(内存整理)这类需要逐 pageblock 遍历的路径,就可以通过 pageblock_pfn_to_page() 直接 pfn_to_page(),跳过逐页的 pfn_valid() 与 pfn_to_online_page() 检查而走快速路径。

这个标志不是静态的。每当 memory hotplug 改变某个 zone 的 pfn range(online 时 move_pfn_range_to_zone() 扩张 span、offline 时 remove_pfn_range_from_zone() 收缩 span),都需要重新确认它是否仍然连续。旧实现 set_zone_contiguous() 的做法是 pageblock 粒度逐块扫描整个 zone span,对每个 pageblock 调用 __pageblock_pfn_to_page() 验证首尾 PFN,全部通过才把标志置真。

问题在于这是一次与 zone 大小成正比的扫描,且每次 online/offline 都要重做一遍。

┌───────────────────────────────────────┐
│ memory hotplug 改变 zone 的 pfn range │
└───────────────────┬───────────────────┘
                    ▼
┌───────────────────────────────────────┐
│     set_zone_contiguous 重建标志      │
└───────────────────┬───────────────────┘
                    ▼
┌───────────────────────────────────────┐
│   pageblock 粒度遍历整个 zone span    │  每个 pageblock 仅验证首尾 PFN
└───────────────────┬───────────────────┘
                    ▼
┌───────────────────────────────────────┐
│       写回 zone contiguous 标志       │
└───────────────────────────────────────┘

问题

  • online/offline 改变 pfn range 后,都要 pageblock 粒度重扫整个 zone。
  • 扫描耗时随 zone 跨度线性增长,256G/512G 热插拔场景尤为明显。
  • pageblock 粒度只验证首尾 PFN,内部 subsection 级 hole 可能漏检。
  • 判定偏宽松,含有 small hole 的 zone 也可能被误判为连续。

方案

核心思路是从「扫描验证无洞」转为「记账」。

在 struct zone 新增一个计数器 pages_with_online_memmap,记录 zone span 内拥有在线 memmap 的页数(含 present pages,以及 memmap 已被初始化的小于一个 subsection 的内存洞)。判断连续只需一个等式:spanned_pages == pages_with_online_memmap,说明 zone 范围内没有缺 memmap 的洞,可以安全地对任意 PFN 调用 pfn_to_page()。新的 set_zone_contiguous() 因此退化为一次比较:

static inline void set_zone_contiguous(struct zone *zone)
{
    if (zone_is_zone_device(zone))
        return;
    if (zone->spanned_pages == zone->pages_with_online_memmap)
        WRITE_ONCE(zone->contiguous, true);
}

计数器有两个来源,分别覆盖启动内存与热插拔内存。启动内存在 memmap_init_zone_range() 初始化 memmap 时顺带累加:memblock region 内的 PFN 由 memmap_init_range() 完成初始化,全部拥有在线 memmap,按页数直接累加;region 之间的洞交给 init_unavailable_range() 处理,它逐页初始化这些不可用页时顺带用 pfn_section_valid() 按 subsection map 判定,满足的才计入;启动阶段 early section 的页全被标记 online,用 pfn_to_online_page() 会把离线 subsection 的洞误判在内。由于同一 subsection(2MB)内的页 online 状态一致,判定结果按 subsection 缓存,每个 subsection 只查询一次。热插拔内存则在 adjust_present_page_count() 中随 present_pages 一起增减,memory online 时加、offline 时减。

由于计数器始终被增量维护,判定连续不再需要重新扫描。相应地,memory block 的 online/offline 路径改为在操作前 clear_zone_contiguous()(先保守地认为不连续,避免操作进行中的中间态被误用),操作结束后再 set_zone_contiguous() 做一次复算;原先散落在 move_pfn_range_to_zone() 与 remove_pfn_range_from_zone() 里的旧 clear/set 扫描随之移除。

┌──────────────────────────────────────┐
│        改变 zone 的 pfn range        │
└──────────────────┬───────────────────┘
                   ▼
┌──────────────────────────────────────┐  启动时初始化 memmap 顺带累加,热插拔时
│ 维护 pages_with_online_memmap 计数器 │  随 present 页增减
└──────────────────┬───────────────────┘
                   ▼
┌──────────────────────────────────────┐
│      比对是否等于 spanned_pages      │  一次整数比较
└──────────────────┬───────────────────┘
                   ▼
┌──────────────────────────────────────┐
│      写回 zone contiguous 标志       │
└──────────────────────────────────────┘

一个安全设计:计数器只统计落在当前 zone span 内的页,若出现「少计」是安全的,最坏只是让一个本应连续的 zone 暂时不被识别为连续,不会带来正确性问题。

此外,由于 pages_with_online_memmap 以 subsection 为对齐单位,zone span 收缩时对 subsection 边界的处理必须正确,否则 span 与计数会不一致。shrink_zone_span() 收缩 zone 范围时由 find_smallest_section_pfn() 与 find_biggest_section_pfn() 找新的边界 PFN,旧实现每个 subsection 只检查一个边缘 PFN 的 nid/zone;当内存或洞的边界恰好落在 subsection 中间,该边缘 PFN 可能属于别的 nid/zone,从而漏掉 subsection 内合法的 PFN,错误地收缩了 zone span。修复方式是同时检查 subsection 的首尾两个 PFN,online 状态因对整个 subsection 一致仍只查一次。

收益

性能数据来自 virtio-mem 虚机热插拔测试(Intel Icelake 服务器,Guest kernel v7.3-rc3,QEMU 9.0;耗时口径为从执行 QEMU 命令到 /proc/meminfo 反映新内存)。

操作 内存大小 Before After 缩短
热插拔 256G 10s 3s 70%
热插拔 512G 36s 7s 81%
热拔除 256G 11s 4s 64%
热拔除 512G 36s 9s 75%

该系列同样利好 CXL 热插拔,作者在封面信中链接了对应的性能结果。