Linux 内核 Memory Hotplug 与 viritio-mem 深度实战:从 DIMM 热插拔到云原生弹性内存的架构与工程

引言

现代数据中心对内存的弹性需求从未停止增长——无论是物理服务器的 DIMM 在线扩容,还是云环境中虚拟机的动态内存调整(Memory Hotplug/Hotremove),都要求 Linux 内核在不重启的前提下完成物理内存的添加与移除。然而,内存热插拔远比 CPU 热插拔复杂:新加入的页面必须被内核伙伴系统接管、迁移到正确的 NUMA 节点、建立反向映射;而热移除前必须将所有页面迁移出去,释放内存块。本文深入剖析 Linux 内核 Memory Hotplug 子系统的完整架构链路,从物理 DIMM 的 ACPI/eFUH 通知到内核 memory_block 状态机,再到 viritio-mem 协议与 QEMU/KVM 云场景的实战集成,并提供生产级参数调优与故障诊断指南。

一、Memory Hotplug 架构全景

1.1 为什么内存热插拔如此复杂

与 CPU 热插拔只需调度器感知核心上下线不同,内存热插拔涉及伙伴系统整条链路:

新内存上线必须完成:物理页面加入伙伴系统 → 建立 struct page → 初始化 pgmap → 设置 zone → 可选 NUMA 节点分配 → 触发 kswapd 监控。移除则必须反向执行:逐页迁移(准备迁移页表映射)→ 从 buddy 释放 → 清除 struct page → 从 zone 移除。关键难点在于:如果已有页面被进程持有(匿名映射、Page Cache、页表映射),迁移必须能断开并重建映射,这要求内存已可被迁移,若存在不可迁移页面(如持久映射、大页、slab 内核对象),热移除将失败。

1.2 两种热插拔模式

ACPI Memory Hotplug(物理服务器):通过 ACPI 6.0+ 的 _HID:ACPI000E 设备触发,固件通过 ACPI Notification(Notify Code 0x80)通知内核内存配置变化。内核通过 acpi_memory_get_device() 获取设备句柄,调用 acpi_memory_device_add() 注册。典型路径:BMC/IPMI 触发ACPI SCI中断 → acpi_bus_notify() → acpi_memory_device_notify() → 触发上线/下线。

viritio-mem(虚拟化/云原生):基于 viritio 1.1 规范,通过 MMIO 配置空间暴露可调整内存块大小。QEMU 后端将 guest 物理地址(GPA)映射到 memfd/hugepage 文件描述符,前端通过 viritio 协议协商配置内存拓扑。优势:超分(overcommit)、NUMA 感知、与 memory balloon 互补。

1.3 核心数据结构

内存热插拔的核心抽象是 struct memory_block:每个块对齐到Memory Block Size(由固件决定,x86-64 通常 128MB,可通过/sys/devices/system/memory/block_size_bytes 查询)。


struct memory_block {
    unsigned long start_section_nr;    // 起始 mem_section 编号
    unsigned long state;               // 状态机 (offline/online/going-offline)
    int unit_count;                    // 包含 unit 数量
    struct memory_block *units;        // unit 数组(viritio-mem)
    struct zone *zone;                 // 关联的 NUMA node zone
    struct device dev;                 // 内嵌设备模型
};

struct dev_pagemap {
    devmap_pgmap_ops *ops;
    unsigned long flags;
    unsigned long vmemmap_shift;
};

当 viritio-mem 配置 1TB 内存时,memory_block 会被划分为 8192 个 128 MB 单元,每个块独立触发上线/下线,组成一个"sub-block array"。

二、内核上线(Online)路径

2.1 状态机与迁移

memory_block 的状态机转移路径:offline → going-online → online(或失败回到 offline)。入口点:memory_block_change_state() → memory_block_online()。


// 核心调用链
memory_block_online()
  → memory_block_online_blocks()
    → online_memblk()
      → online_page_ranges()
        → prep_new_page()         // 准备每个 struct page
        → move_pfn_range_to_zone()// 将 PFN Range 挂入 zone
          → memmap_init_range()   // 初始化 page 描述符
          → zone_span_writelock() // 管理 zone->spanned_pages
        → onlined_pages()         // 触发页面计数更新
  → trigger_mem_online()          // 通知驱动层

在 move_pfn_range_to_zone() 中,内核完成:zone 容量增长(zone→present_pages += 页面数)、span 扩展(管理连续物理内存)、kswapd 唤醒或迁移目标(若 zone 已有足够空闲,则 zone→lowmem_reserve 重算)。对于 viritio-mem 场景,page 取自 memfd 已有的物理页,不会触发内存分配。

2.2 viritio-mem 前端路径

viritio-mem 请求内存增加时,流程:前端驱动 viritio_mem_run() 读取配置空间 new_requested_size → 计算需要激活的 sub-block 数量 → 遍历每个未上线 sub-block → viritio_mem_online_request() → 通过 virtio 队列发送 VIRTIO_MEM_T_UNPLUG 命令 → QEMU 后端通过 memory_region_add_subregion() 扩大后端 MR → 前端调用 memory_block_online() 将内存块上线。

2.3 SLUB 分配器的应对

内存上线后,SLUB 分配器的每 CPU 缓存(kmem_cache_cpu→freelist)不会自动刷新。实际场景中,新 zone 的首次分配会触发 cache_grow()——伙伴系统通过 alloc_pages_node() 分配新 slab,调用 new_slab() 初始化。若开机时内存按需增长(lazy SLUB init),首次访问 kmem_cache 时首次会调用 cache_init_objs()。

三、内核下线(Offline)与页面迁移

3.1 不可迁移页面与失败条件

热移除的前提是所有页面可迁移。不可迁移页面包括:

- Unevictable LRU 页面(被 mlock() 锁定的匿名页)

- 内核线性映射(kernel linear address space 页面的 struct page 本身)

- Page Table pages、持久映射(kmem_cache 的内核对象)

- HugeTLB 大页(某些配置下不可迁移)

核心检查函数:test_and_unmap_pages()。流程:遍历 memory_block 内所有 page → 通过 rmap 获取反查映射 → 未映射页面直接迁移 → 页面表存在则调用 try_to_migrate() 创建新页面(new_page = __alloc_pages())→ 复制内容(copy_highpage())→ PFN 反向更新(page→mapping、新 page→mapping)→ 删除旧 rmap。

3.2 迁移失败锁定机制

当遇到不可迁移页面时,内核将 memory_block 标记为 PG_offline,随后的 offline_page_ext 尝试不再重复测试此块(通过 memory_block.state & MB_offline_lock)。超时时间由 CONFIG_MEMORY_HOTPLUG_RETRY_DELAY(默认 1 min)控制。

3.3 viritio-mem 下线路径

请求内存缩减时:前端驱动遍历 sub-block 数组,选择未映射、空闲的块 → 调用 memory_block_offline() → QEMU 后端缩小相应 memfd 区域 → 前端将块标记为 offline。若任何映射页面无法迁移,前端向 guest 内核报错(VIRTIO_MEM_RESP_BUSY)。

3.4 关键缓冲区更新

下线后必须完成的内核数据更新:zone→present_pages 减少 → free_area 中的 buddy 计数器同步 → nodemask/NULL节点集更新(若全部下线,触发 unmap_kernel_range())。

四、NUMA 拓扑感知与策略

4.1 多节点上线

在多 NUMA 节点服务器上,每个 DIMM 通过异或二叉树表(HMAT/SRAT)映射到不同节点。memory_block_online() 通过 pfn_to_nid() 确定目标节点。x86-64 中通过 e820/ACPI SRAT 映射。viritio-mem 允许前端通过配置空间设置每个 memory_block 的 node id,实现 NUMA 拓扑变化。

4.2 Auto-Online Policies

Linux 提供 memory_block 上线策略(/sys/devices/system/memory/auto_online_blocks):

- offline:不自动上线,需手动 echo online > /sys/devices/system/memory/memoryN/state

- online:boot 时新增块自动上线(默认)

- online_movable:新增块挂入 ZONE_MOVABLE,便于迁移

生产默认推荐 online_kernel(内核对象分配来自 ZONE_NORMAL,用户匿名页分配来自 ZONE_MOVABLE),配合 ZONE_MOVABLE 大小静态设置(kernelcore= 或 movablecore=)。

4.3 ZONE_MOVABLE 与内核内存水位

当手动挂入 ZONE_MOVABLE 时,必须保证 ZONE_NORMAL 保留足够内核数据空间。kernel 参数 kernelcore=nn[KMG] 定义 ZONE_NORMAL 最小保留量,movablecore=nn[KMG] 定义 ZONE_MOVABLE 大小。不设置时内核自动根据 SKB/mempool 需求估算。

五、viritio-mem 配置与调优

5.1 QEMU 启动参数


-object memory-backend-file,id=mem0,mem-path=/dev/hugepages/shared,size=256G,share=on,prealloc=on \
-device virtio-mem-pci,id=vmem0,memdev=mem0,block-size=128M,requested-size=64G,maxmem=256G,node=0,unplugged-inaccessible=on

关键参数:

- block-size=128M:与 x86-64 memory_block 对齐,必须 ≥ 内核 blocks(建议 128 MB)

- requested-size:guest 已上线内存(其余为可弹出的"空闲")

- unplugged-inaccessible=on:阻止 kernel 直接访问未上线区域(避免 #MC MCE 错误)

5.2 Guest 侧动态调整

通过 QEMU Monitor 热调整内存请求:


# 扩大至 128 GB(guest 随即看到新内存)
(qemu) object_add memory-backend-ram,id=aux0,size=64G
(qemu) device_add virtio-mem-pci,id=vmem_aux,memdev=aux0,requested-size=128G,block-size=128M

# 缩回至 32 GB(必须等 guest 迁移完成页面)
(qemu) object_del aux0
(qemu) device_del vmem_aux

5.3 与 Memory Balloon 对比

特性viritio-memvirtio-balloon
粒度128 MB block4 KB 页面
地址空间可插入/移除固定物理区域页面回收不改变地址空间
pf 精度按块(折中)按页(精确)
内核响应memory_block 状态机PF 后 balloon 回收
适用场景云原生扩容/缩容、NUMA 变化超分配内存超额回收

生产推荐:二者配合使用。viritio-mem 负责 NUMA 拓扑与 block 级弹性,balloon 负责页级精确回收与页共享去重(KSM)。

六、生产级调优参数矩阵

6.1 启动参数

CONFIG_MEMORY_HOTPLUG=y(必须): 支持块级上线

CONFIG_MEMORY_HOTPLUG_DEFAULT_ONLINE=y(默认): 新增块自动挂入

CONFIG_ACPI_HOTPLUG_MEMORY=y: 物理服务器需要

CONFIG_VIRTIO_MEM=y: viritio-mem 支持

CONFIG_ZONE_DMA 系列: 确保系统内存拓扑可用

6.2 sysctl 与 runtime 调优

# 查看 memory block 大小(依赖硬件/平台)
cat /sys/devices/system/memory/block_size_bytes      # 通常 8000000 = 128 MB

# 当前块上线状态
cat /sys/devices/system/memory/memory100/state      # online/offline

# 在线手动上线
echo online > /sys/devices/system/memory/memory102/state

# 自动上线策略
echo online_kernel > /sys/devices/system/memory/auto_online_blocks

# ZONE_MOVABLE 配置(仅 boot 参数有效)
kernelcore=4G movablecore=128G

6.3 关键 timeout 与重试

MEMORY_HOTPLUG_RETRY_DELAY (CONFIG_HZ derived):迁移失败再次尝试延迟(默认 1 SYS)。可调整 CONFIG_HZ(通常 250/300/1000)影响整体内存迁移性能与延时。

VIRTIO_MEM_DEFAULT_TIMEOUT:viritio-mem 请求操作建议超时(内部 40s)。

6.4 NUMA balancing 联动

新增 node 上线后立即触发:/sys/devices/system/node/nodeN/auto_online。建议搭配 numa_balancing=disable 用于缓存预热场景,避免迁移抖动。

七、eBPF 与 Memory Hotplug 追踪

7.1 Tracepoint 事件

内核提供关键 memory hotplug tracepoint:

trace_memory_hotplug:recordonline/offline 的时间戳、目标节点、块大小、结果。

trace_migrate_pages:每次迁移页面数(from_nid、to_nid、success/failure)。

7.2 Kprobe 实时诊断

# 监控 memory_block_online 耗时
bpftrace -e 'kprobe:memory_block_online { @start[tid] = nsecs; }
  kretprobe:memory_block_online /@start[tid]/ {
    @us = hist((nsecs - @start[tid]) / 1000);
    delete(@start[tid]);
  }'

# 跟踪 memory_block_offline 失败原因
bpftrace -e '
  kprobe:memory_block_offline {
    printf("OFFLINE block %lu: state=%lu\n", arg0, arg1);
  }
  kprobe:test_and_unmap_pages /@fail_count++/ {
    printf("UNMAP failed at pfn %lu: ret=%d\n", arg0->start_pfn, retval);
  }
'

7.3 生产级追踪管线

结合 BCC/BPF CO-RE,构建 memory hotplug 性能观测线:

步骤 1:采集事件流 → trace.py memory_hotplug

步骤 2:关联 QEMU 侧决策 → 通过 QEMU QMP 协议监控 virtio-mem-change 事件

步骤 3:负载前后对比 → 通过 vmstat -s 查看 hot-added pages / hot-removed pages 计数

八、故障诊断与排查手册

8.1 常见错误码

ENOTSUPP:mem_section 不支持(CONFIG_MEMORY_HOTPLUG 未编译)

EBUSY-TYPE:存在 un-movable 页面(必须检查 dmesg)

ENOSPC:目标 node 不存在或无法创建 node

ETIMEDOUT:migration 超时,重试迁移但被阻止

8.2 故障排查流程

# Step 1: 查看系统错误日志
dmesg | grep -iE "memory_hotplug|memory block|pmem|virtio_mem"

# Step 2: 确认块是否可上线
cat /sys/devices/system/memory/memory200/state_valid
# 返回值:online, offline, going-offline

# Step 3: 手动触发诊断态
echo online > /sys/devices/system/memory/memory200/state
# 失败时查看详细原因
# dmesg | tail -20

# Step 4: 检查页面迁移状态
cat /proc/buddyinfo | head -5

# Step 5: 检查_zone 容量变化
grep -E "present_pages|spanned_pages" /proc/zoneinfo

8.3 viritio-mem 特别排查

viritio-mem 场景的特殊问题:

"requested-size changed but still inaccessible":guest 侧事件通知延迟 → 解决方案:关闭 unplugged-inaccessible(性能折衷)或增大 timeout

"page count mismatch between QEMU request and guest":memfd 区域扩展失败 → 解决方案:检查主机 hugepage 剩余量

"uncorrectable memory error after plugging":#MC MCE 被注入 → 解决方案:开启 ACPI APEI memory error injection 测试

九、架构级策略与展望

9.1 CXL 融合展望

CXL Type 3 设备(CXL Memory Expander)将引入热插拔新维度:新上线区域作为S-Linux 的 "Slow Mem Tier"(慢层级)。Linux 6.x+ 通过 Migrate Hierarchy 机制,将 SLRU 中的页面在本地 DRAM 与 CXL 内存之间迁移。这导致 Memory Hotplug 上线路径必须新增 state: CXL_ATTACHED 和 TIER_PROMOTION。

9.2 PMEM 交互

NVDIMM-N/pmem 的 Memory Hotplug 处理与 DRAM 更接近,且额外支持 dax-mapping。内核模块 nd_blk 在块设备层包装 DRAs,其 sub-block 大小由 NFIT 表决定(通常 2 MB 与 1 GB huge)。Linux 5.15+ 支持 MEMORY_HOTPLUG_PMEM_MIGRATION,在 pmem 下线前将 DAX 文件迁移回块 IO。

9.3 云原生最佳实践

KubeVirt/virtIODriver 集成:通过 Kubernetes VirtualMachine CRD 配置 domain.memory.guest 和热插拔生命周期事件,viritio-mem 在 controller 驱动下使 pod 在水平扩展时自动响应内存变化。

与 largepage 共存:使用 hugepage 时 memory_block 必须与 hugepage size 对齐(通常 2 MB 或 1 GB),否则无法迁移(因为混合 slab 紧张)。hugepage pool 的物理内存必须 boot 时预留。

十、总结

Memory Hotplug 是 Linux 内核弹性资源管理的核心能力之一,其复杂性源于伙伴系统初始化、页面迁移与 NUMA 感知的深度耦合。在 viritio-mem 与 CXL 类型设备的推动下,这一子系统正从"实用运维工具"演进为**云原生弹性基础设施**的基石。通过 ms flush、tracepoint 与 eBPF 宿主机协同观测,工程师可以构建从 DIMM 到 viritio-mem 的端到端可控体系,为 Kubernetes 的 Vertical Pod Autoscaler 等高级调度场景提供底层支撑。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部