目录
- PCP Buddy Allocator — 以 pageblock 为粒度的 per-CPU 页面分配器
- cgroup/dmem: 支持将 dmem 分配同时计入 memcg(double charge)
- slub: 在 can_free_to_pcs 中使用 N_NORMAL_MEMORY 处理远程释放
1. PCP Buddy Allocator — 以 pageblock 为粒度的 per-CPU 页面分配器
系列:[RFC 0/2] mm: page_alloc: pcp buddy allocator作者: Johannes Weiner (Meta)版本: RFC(2个patch)
背景
Linux 内核的页面分配器(page allocator)使用 per-CPU page cache(PCP)来减少对全局 zone->lock 的争用。当前 PCP 的工作方式是:每个 CPU 维护一个小型缓存,分配时先从本地 PCP 取页,不足时在 zone->lock 下逐页从 buddy allocator 中补充(refill);释放时先缓存到本地 PCP,溢出时在 zone->lock 下逐页 drain 回 buddy allocator。
这一设计在高并发、大内存场景下面临严重的可扩展性瓶颈。作者在 Meta 生产环境中观察到 zone->lock 争用持续加剧,主要来自两个模式:
第一,分配与释放的 CPU 亲和性(affinity)被打破。 作者指出:"Allocations happen from page faults on all CPUs running the workload. Frees are cached for reuse, but the caches are periodically purged back to the kernel from a handful of purger threads." 用户态分配器(如 jemalloc、tcmalloc)在所有 CPU 上通过 page fault 分配页面,但空闲页面的回收由少量 purger 线程集中执行。分配端不断耗尽 PCP 触发 refill,释放端在不同 CPU 上溢出 PCP 触发 drain —— "Both sides routinely hit the zone->locked slowpath",PCP 的批处理优势完全丧失。
第二,进程退出时的批量释放冲击。 大进程退出时会一次性释放大量页面,"Every time the PCP overflows, the drain acquires the zone->lock and frees pages one by one, trying to merge buddies together." 当前 PCP drain 在 zone->lock 下逐页执行 buddy merge,锁持有时间长且并发进程退出时形成严重的锁序列化。
解决的问题
- zone->lock 在高线程数和大内存机器上成为页面分配的可扩展性瓶颈
- PCP 缓存在分配/释放 CPU 不一致时完全失效,无法发挥批处理作用
- PCP drain 在 zone->lock 下逐页 buddy merge,锁临界区过长
- 当前 PCP free list 仅支持到 PAGE_ALLOC_COSTLY_ORDER(加上 THP 的特殊处理),无法缓存更大粒度的页面块
如何做
本系列包含 2 个 patch,分层实现:
Patch 1:将 pageblock flags 从紧凑位图(packed bitmap)重构为 per-pageblock 结构体。 引入 struct pageblock_data,每个 pageblock 拥有独立的 unsigned long flags 字段,替代原来将所有 pageblock 的 NR_PAGEBLOCK_BITS 位打包到共享 unsigned long 数组中的方案。这消除了复杂的 bit-packing 索引计算(pfn_to_bitidx、word 选择、位内偏移),简化了 get_pfnblock_flags_mask / set_pfnblock_flags_mask 等热路径访问函数。新增的 pfn_to_pageblock() 内联函数直接返回 struct pageblock_data * 指针。内存开销从每 pageblock 约 0.5-1 字节增加到 8 字节(x86_64 上 2MB pageblock 对应每 GB 约 4KB),但绝对值仍可忽略,且为 Patch 2 在同一结构体中添加 ownership 字段做好铺垫 —— 释放路径查询 migratetype 后紧接着查询 ownership,同一 struct pageblock_data 内的字段共享 cache line,避免两次 cache miss。
Patch 2:PCP buddy allocator 核心实现。 核心思想是让 PCP 以整个 pageblock 为单位从 zone buddy 获取页面块,在 zone->lock 外部进行拆分(split),并为每个 pageblock 记录 CPU 所有权(ownership)。释放的页面根据所属 pageblock 的 owner 路由回该 CPU 的 PCP,而非释放者本地的 PCP。
数据结构变化:
struct pageblock_data 扩展为 32 字节,新增 int cpu(owner CPU + 1,0 表示 zone 所有)、unsigned long block_pfn、struct list_head cpu_node(连接到 owner CPU 的 owned_blocks 链表)struct per_cpu_pages 新增 struct list_head owned_blocks(该 CPU 拥有的 pageblock 列表)和 PCPF_CPU_DEAD 标志- 新增
PGTY_pcp_buddy(PagePCPBuddy)page type,标记在 PCP 上可参与 buddy merge 的空闲页面 - PCP free list 范围从
PAGE_ALLOC_COSTLY_ORDER + THP 统一扩展到 pageblock_order,NR_PCP_LISTS = MIGRATE_PCPTYPES * (PAGE_BLOCK_MAX_ORDER + 1)
PCP refill 采用四阶段(four-phase)策略:
- Phase 0:从
pcp->owned_blocks 链表中恢复之前因 drain 而归还到 zone buddy 的 owned fragment,标记 PagePCPBuddy - Phase 1:从 zone buddy 获取整个 pageblock,设置 CPU ownership 和 PagePCPBuddy,这是非碎片化场景的快速路径
- Phase 2:zone 碎片化严重无法获取整 pageblock 时,从 zone free list 中自顶向下获取同 migratetype 的 sub-pageblock chunk,不声明 ownership,不设 PagePCPBuddy
- Phase 3:最后手段,使用传统
__rmqueue() 含 migratetype fallback 的逐页分配
PCP drain(free_pcppages_bulk)也被重构:先在 pcp->lock 下执行自底向上的 merge pass —— 仅对标记了 PagePCPBuddy 的页面进行 buddy merge,将结果提升到更高 order 的 PCP list 中;然后在 zone->lock 下优先 drain pageblock_order 的整块,整块 drain 时自动清除 ownership。部分块(partial block)drain 时将其挂到 pcp->owned_blocks 并将页面放到 zone freelist 尾部(FPI_TO_TAIL),Phase 0 下次 refill 时优先回收这些碎片,避免消耗新的 pageblock 造成碎片扩散。
释放路径的关键变化在 __free_frozen_pages():读取 pbd->cpu 确定 owner,owned page 路由到 owner CPU 的 PCP(跨 CPU 也直接加锁该 PCP),zone-owned page 走本地 PCP。只有 owned page 设置 PagePCPBuddy 参与 merge,zone-owned page 仅做批处理缓存。新增 pcp_rmqueue_smallest() 从 PCP list 中按 order 向上搜索可用页面并在 PCP 层面 split,类似 zone buddy 的 best-fit 分配。
CPU offline 处理:设置 PCPF_CPU_DEAD 阻止新页面入队,drain 后重初始化 owned_blocks,残留的 ownership 标记无害(释放路径检查该标志后 fallback 到 zone buddy)。
碎片化防御机制:在小内存或内存压力场景下,如果整 pageblock 超过 pcp->high 限制或 zone 中无完整 pageblock,refill 自然降级到 Phase 2/3,退化为与原来相同的逐页行为。
收益
作者提供了初步 benchmark 数据:"I still need to run broader benchmarks, but I've been consistently seeing a 3-4% reduction in %sys time for simple kernel builds on my 32-way, 32G RAM test machine." 在 32 核 / 32GB 内存的测试机上进行内核编译时,系统时间(%sys)稳定降低 3-4%。
此外,"A synthetic test on the same machine that allocates on many CPUs and frees on just a few sees a consistent 1% increase in throughput",一个模拟 jemalloc 模式(多 CPU 分配、少数线程释放)的合成测试也看到 1% 的吞吐量提升。
作者预期在更高并发度和更大内存量的机器上收益将更加显著("I would expect those numbers to increase with higher concurrency and larger memory volumes, but verifying that is TBD"),但更广泛的 benchmark 尚待完成。
从代码逻辑推断的额外收益包括:(1) zone->lock 的临界区从逐页操作变为整 pageblock 粒度的粗粒度操作,锁持有时间和获取频率均大幅降低;(2) PCP 层面的 buddy merge 将大量 merge 工作从 zone->lock 下移到 pcp->lock 下,后者是 per-CPU 锁,争用极低;(3) ownership 路由确保页面在分配 CPU 的 PCP 上复用,提高 cache 局部性和 PCP 命中率。
2. Live Update memfd 自测试套件
系列:[PATCH v2 0/6] selftests/liveupdate: add memfd tests作者: Pratyush Yadav (Google)版本: v2(6个patch)
背景
Linux 内核的 Live Update Orchestrator(LUO)是一套允许系统在 kexec 热升级内核时保留关键内核资源(如 memfd、KVM、VFIO 文件描述符等)的框架。LUO 通过 /dev/liveupdate 字符设备暴露 ioctl 接口,用户态编排程序可以在旧内核上通过 LIVEUPDATE_SESSION_PRESERVE_FD 将文件描述符标记为需保留,kexec 到新内核后再通过 LIVEUPDATE_SESSION_RETRIEVE_FD 恢复这些资源。
memfd 是 LUO 保留的重要资源类型之一,但在此 patch 系列之前,memfd 的保留功能仅在 luo_kexec_simple 和 luo_multi_session 两个测试中被间接测试,这些测试的主要目的是验证其他 live update 功能,而非专门测试 memfd 保留的各种边界条件。作者在封面信中指出:"Currently memfd is only tested indirectly via luo_kexec_simple or luo_multi_session. Their main purpose is to test other live update functionality." 缺乏专门的 memfd 测试意味着一些重要的边界情况(如零大小 memfd、fallocate 后写入的数据保留、保留后的操作限制等)没有被覆盖,这些正是过去已经出现过真实 bug 的场景。
解决的问题
- 缺少针对 memfd 跨 live update 保留的专用测试套件,现有测试仅间接覆盖最基本的场景
- 零大小 memfd(zero-size memfd)是序列化/反序列化的特殊路径——folio 数组为空,需要确保 restore 逻辑不会访问无效数组,但此前无测试覆盖
- fallocate 分配的内存在保留后写入的数据可能丢失的问题(已由 commit
50d7b4332f27 修复),需要回归测试防止此 bug 复现 - 保留后的 memfd 应禁止增长或缩小(以保持与序列化状态的一致性),但允许在现有大小范围内读写,此行为缺少测试验证
如何做
整个系列分为两部分:基础设施搭建(patch 1-2)和具体测试用例(patch 3-6)。
Patch 1:测试框架 — 创建 luo_memfd.c,构建基于 kselftest harness 的两阶段(two-stage)测试框架。LUO 设备 /dev/liveupdate 同一时间只能被一个进程打开,因此 main() 负责打开 LUO FD 并通过全局变量共享给子进程。框架通过一个特殊的 state session(STATE_SESSION_NAME = "luo-state")检测当前运行阶段:如果 luo_retrieve_session() 返回 -ENOENT 则为 stage 1(kexec 前),返回有效 session fd 则为 stage 2(kexec 后)。此外支持 --stage 命令行参数作为安全网,在 LUO 核心未能正确保留 state session 时提前检测失败。
Patch 2:辅助函数 — 在 luo_test_utils.c 中添加一组通用辅助函数,包括:
read_size() / write_size():精确读写指定大小的数据,处理短读/短写generate_random_data():从 /dev/urandom 读取随机数据save_test_data() / load_test_data():将测试数据保存到文件系统(非 tmpfs),用于跨 kexec 验证create_random_memfd():创建 memfd 并填充随机数据verify_fd_content_read() / verify_fd_content_mmap():通过 read/mmap 两种方式验证 fd 内容cwd_is_tmpfs():检测当前工作目录是否在 tmpfs 上——如果是则跳过测试,因为 kexec 后 tmpfs 内容不会保留
Patch 3:memfd 内容保留测试(memfd_data) — Stage 1 创建 1MB memfd,填充随机数据并保留,同时将数据保存到文件系统。Stage 2 恢复 memfd 后与文件系统中的参考数据比对。
Patch 4:零大小 memfd 测试(zero_memfd) — 作者指出:"A zero-size memfd is a special case of memfd preservation. It takes a different path from normal both during preservation and during restore. In the serialization structure, the number of folios is zero and the vmalloc array with folios is empty." Stage 1 创建空 memfd 并保留;Stage 2 恢复后确认大小仍为 0。
Patch 5:保留后操作限制测试(preserved_ops) — 仅在 stage 1 运行。验证保留后的 memfd:(1) 范围内写入成功;(2) 超出边界写入失败;(3) ftruncate 缩小失败。
Patch 6:fallocate 回归测试(fallocate_memfd) — 针对 commit 50d7b4332f27 修复的 bug 设计。Stage 1 创建 memfd,通过 fallocate() 分配 1MB 空间(但不立即写入),保留后再写入随机数据。Stage 2 验证保留后写入的数据仍然存在。
收益
作者未提供性能数据,此系列为纯测试代码。预期收益:
- 为 memfd 跨 live update 保留提供专用的、全面的自动化测试覆盖,新增约 555 行代码(4个测试用例)
- 覆盖关键边界条件:零大小 memfd 的序列化/反序列化路径、保留后操作的权限控制、fallocate 未初始化内存的 uptodate 标志处理
fallocate_memfd 测试作为 commit 50d7b4332f27 的回归测试,可防止数据丢失 bug 再次引入
3. cgroup/dmem: 支持将 dmem 分配同时计入 memcg(double charge)
系列:[PATCH RFC 0/2] cgroup/mem: add a node to double charge in memcg作者: Eric Chanudet (Red Hat)版本: RFC(2个patch)
背景
Linux 内核中存在两套独立的 cgroup 内存记账体系:一是传统的 memory cgroup(memcg),主要追踪用户态进程的常规内存(anonymous pages、page cache 等);二是较新的 device memory cgroup(dmem),用于管理设备内存(device memory)的分配配额,例如 GPU 显存(VRAM)等由 DRM 子系统管理的资源。
在当前架构下,dmem 和 memcg 是完全独立的:设备内存的分配仅在 dmem controller 中记账,不会反映到 memcg 的计数器中。这意味着管理员无法通过 memcg 的 memory.max 等既有接口来统一限制一个 cgroup 的总体内存消耗(包含常规内存和设备内存)。在此前的讨论中,AMD 开发者在邮件列表中建议在 dmem region 中引入一个开关,允许管理员按需开启"双重记账"(double charge)——即让 dmem 分配同时也被 memcg 计入。
解决的问题
- 设备内存与系统内存记账割裂:当前 dmem 分配不计入 memcg,管理员无法通过 memcg 统一视角观察和限制 cgroup 的整体内存占用。对于 GPU 显存等可被大量消耗的设备资源,这是一个管理盲区。
- 缺乏灵活的策略控制:不同部署场景对设备内存是否需要纳入 memcg 统一管理有不同需求,需要一个可配置的 per-region 开关而非强制行为。
如何做
Patch 1:mm/memcontrol: add page-level charge/uncharge functions
在 memcg 核心代码中新增两个导出函数,提供基于页数(而非 folio)的 charge/uncharge 接口:
mem_cgroup_try_charge_pages(struct mem_cgroup *memcg, gfp_t gfp_mask, unsigned int nr_pages):内部直接调用 try_charge()mem_cgroup_uncharge_pages(struct mem_cgroup *memcg, unsigned int nr_pages):内部调用 memcg_uncharge()
两个函数均通过 EXPORT_SYMBOL_GPL 导出,以便 dmem controller(可编译为模块)调用。这是必要的,因为 dmem 分配以字节为单位而非 folio,现有的 mem_cgroup_charge() 等接口绑定 folio 语义,不适合 dmem 的使用场景。
Patch 2:cgroup/dmem: add a node to double charge in memcg
在 dmem_cgroup_pool_state 结构体中新增两个布尔字段:
bool memcg; /* 是否启用 double charge */
bool memcg_locked; /* 一旦发生首次 charge 即锁定,不可再修改 */
通过 cgroupfs 新增接口文件 dmem.memcg,管理员可以为每个 dmem region 的每个 cgroup 设置是否启用 double charge。关键设计决策:该开关一旦发生首次 charge 即被锁定(memcg_locked = true),之后不可更改。作者解释道:"Since keeping track of each allocation would add a fair amount of logic without solving the problem entirely, disable the memcg switch once the first charge is issued."
在 dmem_cgroup_try_charge() 路径中,当 pool->memcg 为 true 时,通过 get_mem_cgroup_from_current() 获取当前进程的 memcg,调用 mem_cgroup_try_charge_pages() 执行 memcg charge。如果 memcg charge 失败(-ENOMEM),则直接返回错误,不再尝试 dmem 的 eviction 路径。在 dmem_cgroup_uncharge() 路径中,通过 mem_cgroup_from_cgroup() 获取对应 memcg 并执行对称的 uncharge 操作。
收益
作者未提供性能数据,这是一个 RFC 级别的功能性改动。从代码逻辑推断的预期收益:
- 统一内存管理视角:管理员可以选择性地让设备内存分配(如 GPU VRAM)同时被 memcg 记账,通过
memory.max 等接口对 cgroup 的总体内存消耗实施统一限制,适用于多租户 GPU 共享场景 - 简洁的管理接口:通过 cgroupfs 的
dmem.memcg 文件即可配置
值得注意的是,作者在封面信中明确表示此 RFC 主要目的是探索可行性和暴露潜在问题,而非作为成熟的合并方案。
4. slub: 在 can_free_to_pcs 中使用 N_NORMAL_MEMORY 处理远程释放
系列:[PATCH] slub: use N_NORMAL_MEMORY in can_free_to_pcs to handle remote frees作者: Hao Li版本: v1(1个patch)
背景
SLUB 分配器近期引入了 per-cpu sheaf(pcs)机制,用于在对象释放路径上提供一个快速的 per-CPU 缓存层。其核心函数 can_free_to_pcs() 用于判断一个被释放的 slab 对象是否可以直接放入当前 CPU 的 sheaf 缓存中(快速路径),而非走较慢的常规释放路径(slow path)。
在 NUMA 系统中,当释放的对象来自与当前 CPU 不同的 NUMA 节点(即远程释放,remote free)时,can_free_to_pcs() 需要判断当前 CPU 所在节点的内存状态。原有逻辑使用 node_state(numa_node, N_MEMORY) 来检测当前节点是否有内存。如果节点是无内存节点(memoryless node),则允许将远程对象缓存到 sheaf 中,因为该节点本身永远无法分配出本地对象。
然而,N_MEMORY 表示节点拥有任意类型的内存——包括常规内存、高端内存以及可移动内存(ZONE_MOVABLE)。关键问题在于:slab 分配器无法从 ZONE_MOVABLE 中分配内存。ZONE_MOVABLE 专为可迁移页面设计,slab 对象是不可迁移的(unmovable)。一个仅拥有 ZONE_MOVABLE 内存的节点,对 slab 分配器而言与无内存节点等效。
解决的问题
- 仅有 ZONE_MOVABLE 内存的节点被错误视为"有内存"节点:导致
can_free_to_pcs() 对该节点上的远程释放对象走慢速路径,而非缓存到 sheaf 中 - 语义不精确:
N_MEMORY 的语义过于宽泛,在 slab 上下文中 N_NORMAL_MEMORY 更为准确
如何做
改动精简,仅修改 mm/slub.c 中 can_free_to_pcs() 函数的一行判断:
// 修改前
if (unlikely(!node_state(numa_node, N_MEMORY)))
goto check_pfmemalloc;
// 修改后
if (unlikely(!node_state(numa_node, N_NORMAL_MEMORY)))
goto check_pfmemalloc;
同时更新注释,将原来的 "but that node is memoryless" 扩展为 "but that node is memoryless or only has ZONE_MOVABLE memory, which slab cannot allocate from"。
该 patch 依赖一个前置补丁,该补丁修正了内存热插拔流程使 N_NORMAL_MEMORY 状态能被正确维护。作者指出:"Memory hotplug now keeps N_NORMAL_MEMORY up to date correctly, so make can_free_to_pcs() use it."
收益
作者未提供性能数据。从代码逻辑推断的预期收益:
- 仅含 ZONE_MOVABLE 内存节点上的释放性能提升:在使用
kernelcore= 或 movablecore= 启动参数、或通过内存热插拔添加 ZONE_MOVABLE 内存的 NUMA 场景中,sheaf 快速路径将正确工作 - 语义正确性:使 slab 分配器的节点内存检查与其实际可用内存类型一致
- 覆盖边缘场景:大型 NUMA 机器的内存热插拔和内存策略调整场景下确实存在纯 ZONE_MOVABLE 节点
总结
新机制 / 新接口
| | |
|---|
| cgroup/dmem double charge | 新增 dmem.memcg cgroupfs 接口,允许设备内存分配同时计入 memcg,统一内存管理视角 |
性能优化
| | |
|---|
| PCP buddy allocator | 内核编译 %sys 降低 3-4%,合成测试吞吐量提升 1%(32核/32GB) |
| slub N_NORMAL_MEMORY | 仅含 ZONE_MOVABLE 的 NUMA 节点上 slab 释放走 sheaf 快速路径 |
新测试
| | |
|---|
| liveupdate memfd tests | 4 个专用测试用例覆盖 memfd 跨 kexec 保留的边界条件 |