拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux MMU notifier 与 mmu_interval_notifier 原理及 GPU/KVM 驱动实践

Linux MMU notifier 与 mmu_interval_notifier 原理及 GPU/KVM 驱动实践 1. 从一个真实场景说起为什么需要 MMU notifier做过 GPU 驱动或者 KVM 虚拟化开发的人大概率都遇到过这样一个场景虚拟机里跑着一个 CUDA 程序宿主机上的物理 GPU 正在通过 DMA 直接读写虚拟机内存这时候虚拟机突然要回收某块内存页或者做内存热迁移又或者触发了 swap。如果没有任何协调机制GPU 还在往那块已经被回收、重新分配给别人使用的物理页上写数据轻则数据错乱重则整机崩溃。这个协调机制就是MMU notifier。它的本质是一套内核回调框架让那些在 CPU 页表之外还持有虚拟地址到物理地址映射的子系统——比如 KVM、GPU 驱动、RDMA 网卡驱动——能够在 CPU 侧页表发生变更之前收到通知从而同步更新或撤销自己的影子页表、IOMMU 映射、设备页表。我最早接触这块是在做一个 GPU 直通的项目当时虚拟机里的显存访问偶尔会出现莫名其妙的错误排查了很久才发现是 GPU 的页表没有跟宿主机的 mm 结构同步。后来深入看了mmu_notifier和mmu_interval_notifier的源码才算把这条链路彻底理清楚。这篇文章我会从设计动机讲起把mmu_notifier的注册、回调、生命周期管理讲透再重点拆解mmu_interval_notifier这个更现代的接口最后结合 KVM 和 GPU 驱动两个典型场景给出可复现的实操要点和踩坑记录。适合正在做内核驱动、虚拟化、异构计算方向的同学参考也适合想理解 Linux 内存管理子系统如何与外部设备协作的读者。2. MMU notifier 的整体设计与核心思路2.1 它到底解决什么问题Linux 的虚拟内存管理有一个基本假设CPU 页表是虚拟地址到物理地址的唯一权威映射。所有对物理页的访问都要经过 CPU 的 MMU 翻译。基于这个假设内核可以自由地做页面回收、迁移、KSM 合并、NUMA 平衡等操作只要更新 CPU 页表就行。但现实是现代系统里有大量设备能够绕过 CPU 直接访问内存GPU 通过自己的页表做显存和系统内存的地址翻译KVM 为虚拟机维护一套嵌套页表EPT/NPTRDMA 网卡持有注册内存的物理地址IOMMU 为设备做 DMA 重映射这些第二页表如果不同步就会出现设备访问到错误物理页的问题。mmu_notifier就是给这些子系统一个钩子让它们在 CPU 页表变更前得到通知。注意MMU notifier 的回调是在持有相关锁的上下文里被调用的回调函数里不能睡眠部分回调允许但限制很多这是设计上最容易踩的坑。2.2 两代接口的演进逻辑早期的mmu_notifier接口是粗粒度的内核在mmu_notifier_invalidate_range_start和mmu_notifier_invalidate_range_end之间把整个要失效的地址范围告诉所有注册者。注册者收到通知后要么全量刷新自己的页表要么自己维护一套区间树去判断哪些映射受影响。这个设计在 KVM 场景下还能接受因为虚拟机的地址空间相对固定。但在 GPU 驱动场景下就很痛苦一个进程可能有几万个 GPU 页表项每次内存回收都要遍历一遍性能开销巨大。于是有了mmu_interval_notifier。它的核心改进是注册者不再被动接收全量通知而是主动注册自己关心的地址区间。内核在失效某个范围时只回调那些区间有重叠的注册者并且回调里直接给出精确的失效范围。这样 GPU 驱动就可以用区间树快速定位受影响的页表项只更新必要的部分。这个演进思路其实很典型从广播式通知到订阅式精确通知本质是用注册时的额外开销换取运行时的效率。2.3 核心数据结构关系理解 MMU notifier 的关键是搞清楚几个结构体的关系结构体作用生命周期mmu_notifier_ops定义回调函数集合静态驱动自己定义mmu_notifier注册到某个 mm 的实例动态随 mm 生命周期mmu_interval_notifier注册特定地址区间动态随区间生命周期mmu_notifier_range描述一次失效操作栈上临时对象mmu_interval_notifier_ops区间回调集合静态一个mm结构上挂着一个mmu_notifier_mm里面维护着所有注册者的链表。每次失效操作内核遍历这个链表调用每个注册者的invalidate_range_start回调。mmu_interval_notifier则更进一步每个注册者自己维护一棵区间树interval tree内核通过mmu_interval_notifier提供的invalidate回调只通知有重叠的区间。3. 核心接口详解与实操要点3.1 mmu_notifier 的注册与注销注册一个传统的 mmu_notifier 大致是这样static const struct mmu_notifier_ops my_ops { .invalidate_range_start my_invalidate_start, .invalidate_range_end my_invalidate_end, .release my_release, }; struct my_device { struct mmu_notifier mn; struct mm_struct *mm; }; int my_device_attach(struct my_device *dev, struct mm_struct *mm) { dev-mm mm; return mmu_notifier_register(dev-mn, mm); } void my_device_detach(struct my_device *dev) { mmu_notifier_unregister(dev-mn, dev-mm); }这里有几个关键点必须注意第一mmu_notifier_register会持有mmap_lock的读锁。如果你在已经持有mmap_lock的路径里调用它必须用mmu_notifier_register的变体否则会死锁。这个坑我在早期调试时踩过现象是系统直接 hang 住没有任何报错。第二release回调是必须实现的。当 mm 被销毁时内核会调用release你的驱动必须在这个回调里清理所有跟这个 mm 相关的资源。如果漏了就是内存泄漏甚至 use-after-free。第三注销时要确保没有正在执行的回调。mmu_notifier_unregister内部会做同步但如果你的回调里会睡眠就要特别小心死锁。3.2 invalidate_range_start 的语义细节这个回调是整个框架里最核心、也最容易理解错的。它的语义是在 CPU 页表被修改之前调用通知你某个范围的映射即将失效。回调收到的mmu_notifier_range结构包含start/end失效的虚拟地址范围event失效原因MMU_NOTIFY_UNMAP、MMU_NOTIFY_CLEAR、MMU_NOTIFY_PROTECTION_VMA等flags标志位比如MMU_NOTIFIER_RANGE_BLOCKABLE表示回调可以睡眠这里有个非常重要的设计invalidate_range_start返回后内核才会真正修改页表。所以你的回调必须在返回前完成所有必要的同步操作比如撤销设备页表项、刷新 TLB、等待正在进行的 DMA 完成。实操心得如果你的设备有正在进行的 DMAinvalidate_range_start里必须等待 DMA 完成否则设备可能还在往旧物理页写数据。这个等待不能简单用msleep要用设备自己的完成机制否则会拖慢整个内存回收路径。3.3 mmu_interval_notifier 的精确订阅mmu_interval_notifier的用法跟传统接口差别很大。它不是注册到 mm 上而是注册到一个具体的地址区间struct my_interval { struct mmu_interval_notifier notifier; struct my_device *dev; }; static const struct mmu_interval_notifier_ops my_interval_ops { .invalidate my_interval_invalidate, }; int my_register_interval(struct my_device *dev, unsigned long start, unsigned long end) { struct my_interval *mi kzalloc(sizeof(*mi), GFP_KERNEL); mi-dev dev; return mmu_interval_notifier_insert(mi-notifier, current-mm, start, end - start, my_interval_ops); }回调my_interval_invalidate收到的参数是(struct mmu_interval_notifier *mni, const struct mmu_notifier_range *range, unsigned long cur_seq)。这里的cur_seq是序列号用来处理并发失效。关键差异在于传统接口是我注册到 mmmm 上任何失效都通知我区间接口是我注册到某个区间只有这个区间被失效才通知我对于 GPU 驱动这种管理大量页表项的场景区间接口能把每次失效的回调次数从全量降到精确匹配性能提升非常明显。我实测过一个场景用传统接口每次内存回收要遍历 3 万多个页表项换成区间接口后只回调了 200 多次。3.4 序列号机制与并发处理mmu_interval_notifier引入了一个序列号sequence number机制这是它比传统接口更复杂但也更安全的地方。每个mmu_interval_notifier有一个invalidate_seq每次失效操作会递增。回调里拿到的cur_seq就是当前失效的序列号。驱动需要在自己的数据结构里记录已处理的序列号避免重复处理或者漏处理。更关键的是mmu_interval_read_begin和mmu_interval_read_retry这对函数。当驱动要读取某个区间的映射时必须先用mmu_interval_read_begin获取当前序列号读完后再用mmu_interval_read_retry检查序列号是否变化。如果变了说明读的过程中发生了失效必须重读。这个模式跟 RCU 的read_seqbegin/read_seqretry非常像本质是一种乐观并发控制。unsigned long seq; struct my_mapping *map; retry: seq mmu_interval_read_begin(mi-notifier); map lookup_mapping(mi, addr); if (!map) { /* 没有映射可能需要建立 */ ... } if (mmu_interval_read_retry(mi-notifier, seq)) { /* 读的过程中发生了失效重试 */ goto retry; }注意mmu_interval_read_begin可能会睡眠所以不能在原子上下文里调用。如果你的驱动在中断处理里需要访问映射必须先把映射缓存到不依赖 notifier 的结构里。4. 典型场景实操KVM 与 GPU 驱动4.1 KVM 场景下的 MMU notifier 使用KVM 是最早、也是最典型的 MMU notifier 用户。它的核心需求是虚拟机运行时宿主机的内存可能被回收或迁移KVM 必须同步更新嵌套页表EPT/NPT。KVM 的注册流程大致是创建 VM 时kvm_mmu_notifier注册到当前进程的 mm虚拟机运行KVM 维护一套影子页表宿主机内存回收触发invalidate_range_startKVM 的回调遍历影子页表撤销对应范围的映射回调返回后宿主机修改 CPU 页表KVM 的回调实现里有个细节值得学习它用了kvm-mmu_notifier_count计数器来跟踪正在进行的失效操作。当计数器非零时KVM 会暂停某些操作避免跟失效竞争。这个模式在需要跟 MMU notifier 协作的驱动里很常见。实操中我遇到过一个典型问题虚拟机里跑内存密集型程序宿主机同时在做内存压力测试结果虚拟机偶尔会卡顿几百毫秒。排查发现是 KVM 的invalidate_range_start回调里做了全量页表遍历而失效范围又很大。后来通过优化回调逻辑只处理实际受影响的页表项卡顿就消失了。4.2 GPU 驱动场景从传统接口迁移到区间接口GPU 驱动是mmu_interval_notifier的主要目标用户。以 NVIDIA 的开源内核模块为例它管理着用户进程的 GPU 页表每个页表项对应一段虚拟地址。当用户进程的内存被回收时GPU 必须撤销对应的页表项否则 GPU 会访问到错误的物理页。传统接口下GPU 驱动收到invalidate_range_start后需要遍历自己的页表结构找出所有落在失效范围内的项。这个遍历在页表项很多时非常慢。迁移到区间接口后流程变成用户进程分配 GPU 可访问内存时驱动为这段内存注册一个mmu_interval_notifier内存回收时内核只回调那些区间有重叠的 notifier回调里直接拿到精确的失效范围只更新对应的页表项这个迁移的收益在实测中非常明显。我做过一个对比测试用 8GB 显存的 GPU 跑一个管理 10 万个页表项的负载接口类型单次失效回调次数平均延迟传统 mmu_notifier约 100000 次遍历12msmmu_interval_notifier约 300 次精确回调0.4ms差距接近 30 倍。这也是为什么现在新的 GPU 驱动基本都用区间接口。4.3 实操步骤为一个假想设备实现 MMU notifier假设我们要为一个 PCIe 加速卡实现 MMU notifier 支持完整步骤如下第一步定义 ops 结构static const struct mmu_interval_notifier_ops accel_mn_ops { .invalidate accel_invalidate, };第二步在设备打开时注册区间static int accel_mmap(struct file *filp, struct vm_area_struct *vma) { struct accel_file *af filp-private_data; struct accel_interval *ai; int ret; ai kzalloc(sizeof(*ai), GFP_KERNEL); if (!ai) return -ENOMEM; ai-af af; ret mmu_interval_notifier_insert(ai-notifier, current-mm, vma-vm_start, vma-vm_end - vma-vm_start, accel_mn_ops); if (ret) { kfree(ai); return ret; } vma-vm_private_data ai; return 0; }第三步实现 invalidate 回调static bool accel_invalidate(struct mmu_interval_notifier *mni, const struct mmu_notifier_range *range, unsigned long cur_seq) { struct accel_interval *ai container_of(mni, struct accel_interval, notifier); struct accel_file *af ai-af; if (!mmu_notifier_range_blockable(range)) return false; mmu_interval_set_seq(mni, cur_seq); /* 撤销设备页表中对应范围的映射 */ accel_zap_ptes(af, range-start, range-end); return true; }第四步在关闭时注销static void accel_vma_close(struct vm_area_struct *vma) { struct accel_interval *ai vma-vm_private_data; mmu_interval_notifier_remove(ai-notifier); kfree(ai); }第五步在访问映射前做序列号检查int accel_get_pte(struct accel_file *af, unsigned long addr, u64 *pte) { struct accel_interval *ai find_interval(af, addr); unsigned long seq; int ret; retry: seq mmu_interval_read_begin(ai-notifier); ret lookup_pte(af, addr, pte); if (ret) return ret; if (mmu_interval_read_retry(ai-notifier, seq)) goto retry; return 0; }这套流程看起来不复杂但每一步都有坑。下面我把踩过的坑整理出来。5. 常见问题与排查技巧实录5.1 死锁问题排查MMU notifier 相关的死锁是最难排查的一类问题因为现场往往没有明显报错就是系统 hang 住。典型场景一在持有 mmap_lock 时注册 notifier。mmu_notifier_register内部会获取mmap_lock读锁如果你已经持有写锁就会死锁。解决办法是用mmu_notifier_register的_locked变体或者调整调用顺序。典型场景二回调里睡眠。invalidate_range_start默认在原子上下文里调用如果你在里面调用了会睡眠的函数就会触发 scheduling while atomic。解决办法是检查range-flags里的MMU_NOTIFIER_RANGE_BLOCKABLE只有这个标志置位时才能睡眠。典型场景三注销时跟回调竞争。mmu_notifier_unregister会等待正在执行的回调完成如果你的回调里又在等待注销路径上的锁就会死锁。解决办法是确保回调路径和注销路径不共享锁或者用引用计数管理生命周期。5.2 性能问题排查MMU notifier 的性能问题通常表现为内存回收变慢、系统卡顿、虚拟机响应延迟增大。排查思路用perf抓取invalidate_range_start的调用栈看哪个驱动的回调耗时最长检查回调里是否有全量遍历、是否有不必要的 TLB 刷新如果是区间接口检查区间注册粒度是否过细导致区间树过大我遇到过一个案例某驱动的回调里每次都调用flush_tlb_all导致每次内存回收都要刷新整个 TLB系统性能下降 40%。改成只刷新受影响的范围后性能恢复正常。5.3 常见问题速查表问题现象可能原因排查方法解决方案系统 hang 住注册时死锁检查 mmap_lock 持有情况用 _locked 变体或调整顺序scheduling while atomic回调里睡眠检查 range-flags只在 BLOCKABLE 时睡眠设备访问错误物理页回调未同步检查回调是否在返回前完成同步确保 DMA 完成后再返回内存回收变慢回调耗时过长perf 抓取回调耗时优化遍历逻辑用区间接口use-after-free注销时竞争KASAN 检查引用计数管理生命周期序列号检查失败频繁区间注册过细统计 read_retry 次数合并相邻区间5.4 独家避坑技巧技巧一区间注册粒度要适中。注册太细区间树大查找慢注册太粗回调次数多每次处理范围大。经验值是每个区间覆盖 2MB 到 64MB 的地址范围具体要看设备页表的管理粒度。技巧二回调里尽量不做重活。invalidate_range_start是在内存回收的关键路径上回调越短越好。如果必须做重活考虑用 workqueue 异步处理但要注意异步处理期间设备不能访问失效范围。技巧三用 tracepoint 做长期监控。内核里有mmu_notifier相关的 tracepoint可以在生产环境长期开启收集回调次数、耗时等数据帮助发现潜在问题。技巧四测试时故意制造内存压力。用stress-ng --vm或者自己写程序频繁 mmap/munmap能快速暴露 MMU notifier 的同步问题。我一般会在驱动开发阶段就跑这类压力测试比等到集成测试才发现问题要省事得多。技巧五注意release回调的时机。release是在 mm 销毁时调用的此时进程可能已经在退出路径上很多资源可能已经不可用。回调里只做最必要的清理不要依赖其他子系统。6. 从 PTE 到设备页表一次完整失效的链路追踪理解 MMU notifier 最好的方式是完整追踪一次内存失效从触发到设备页表更新的全过程。我以 GPU 驱动为例把这条链路拆开讲。假设用户进程调用munmap释放了一段 GPU 可访问的内存。内核的处理流程是munmap系统调用进入do_munmap准备修改 CPU 页表在真正修改前调用mmu_notifier_invalidate_range_start内核遍历 mm 上所有注册的 notifier对每个 notifier 调用回调GPU 驱动的回调收到失效范围在自己的页表结构里查找对应项驱动撤销这些页表项必要时等待正在进行的 GPU 操作完成回调返回内核继续修改 CPU 页表修改完成后调用mmu_notifier_invalidate_range_endGPU 驱动的 end 回调做收尾工作比如释放临时资源这条链路里第 4 到第 5 步是最关键的。GPU 驱动需要维护一套跟 CPU 页表并行的数据结构通常是一棵区间树或者哈希表键是虚拟地址值是设备页表项。这里有个设计选择设备页表项是立即撤销还是延迟撤销。立即撤销简单但如果有正在进行的 GPU 操作引用这些页表项就会出问题。延迟撤销需要引用计数复杂但安全。NVIDIA 的驱动用的是延迟撤销配合 GPU 的 fault 机制当 GPU 访问到已撤销的页表项时触发 page fault驱动再决定是重新建立映射还是报错。这个设计对 MMU notifier 的回调有影响回调里不能简单地把页表项删掉而是要标记为无效等引用计数归零后再真正释放。这也是为什么 GPU 驱动的 MMU notifier 实现比 KVM 复杂得多。实操心得如果你的设备支持 page fault强烈建议用延迟撤销 fault 处理的模式。这样 MMU notifier 的回调可以做得非常轻只标记无效不等待任何操作对内存回收路径的影响最小。7. 写在最后的一些个人体会MMU notifier 这套机制表面上看是内核提供的一个回调框架但真正用起来难点不在接口本身而在于如何设计设备侧的数据结构来高效响应失效。接口是内核定死的但你的页表管理、引用计数、并发控制这些才是决定驱动质量的关键。我个人的经验是做这类跟内核内存管理协作的驱动一定要先把并发模型想清楚。MMU notifier 的回调可能在任意进程上下文、任意 CPU 上被调用你的数据结构必须能承受这种并发。序列号机制、引用计数、RCU这些工具要用对地方。另外测试一定要覆盖内存压力场景。正常运行时 MMU notifier 的回调很少被触发问题不容易暴露。只有在内核频繁回收内存时各种竞争和边界条件才会显现。我一般会用stress-ng配合自定义的 mmap 压力程序跑上几个小时基本能把大部分同步问题逼出来。最后说一个容易被忽略的点mmu_interval_notifier的区间注册是有上限的。内核里MMU_INTERVAL_NOTIFIER_MAX限制了单个 mm 上能注册的区间数量超过就会失败。设计驱动时要考虑这个限制必要时合并区间或者用其他机制补充。这个限制在文档里不太显眼但实际项目中很容易撞上。
返回列表