1. 从传统内存注册谈起:为什么需要On-Demand Paging
RDMA技术的特点是绕过操作系统内核,让网卡直接访问用户态内存,把数据从网卡搬到应用缓冲区,或者反过来。这个"直接访问"是RDMA高性能的根源,但也成了它最让人头疼的限制:传统模式下,你想让RDMA网卡读写某块内存,必须先调用ibv_reg_mr注册这块内存区域,拿到一个lkey/rkey,然后网卡才认这块地。
注册内存的代价不低。ibv_reg_mr内部会锁定内存页(防止被swap出去),记录页表映射关系,分配DMA地址,整个过程涉及内核态切换和页表遍历。注册完还不行,如果你在运行中经常改内存内容、调整缓冲区位置,就得反复注销再注册。以我自己的实测经验,在忙碌的存储服务里,频繁注册/注销MR(Memory Region)能让性能掉一个量级,有时候CPU都在忙活页表锁和DMA映射,真正干活的时间反而少了。
而且注册内存等于告诉内核"这些页我要锁死",这跟操作系统引以为傲的虚拟内存管理是冲突的。内存紧张时,被锁定的页不能回收,剩下可回收的空间更少,系统更容易触发OOM。对于动不动注册几十GB内存的数据库、存储产品,这问题会被放大得很明显。
On-Demand Paging(ODP,按需分页)解决的就是这对矛盾。它最初在Mellanox的Connect-IB网卡上出现,后进入Linux内核主线,现在已经是ib_uverbs层一个相对完善的机制。ODP的核心思路很直白:干脆不预先锁定内存了,允许RDMA网卡像CPU一样触发缺页异常。应用访问哪块内存,网卡需要哪块内存,系统就现场把页准备好。这样一来,内存注册的开销被最小化,内存回收器也能正常回收那些没被RDMA使用的页。简单说,ODP就是给RDMA网卡装了一套"按需调页"机制,让它从"必须一次锁定全部"进化成"用到哪页就映射哪页"。
这篇文章我会沿着Linux内核RDMA子系统的实际代码路径往下走,先讲清楚ODP在整个RDMA子系统里的定位,再拆解页错误处理、失效流程、隐式ODP这些核心机制的实现,最后分享一下我在实际调试中遇到的坑和排查方法。全程以代码和时序为主线,适合已经会写基本RDMA程序、想深入理解内核行为的读者,也适合正在做存储、数据库高性能网络栈优化的人参考。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. RDMA子系统全貌与ODP所处的位置
2.1 用户态到底怎么跟内核RDMA子系统打交道的
从用户态视角看,RDMA程序通常走libibverbs,调用ibv_open_device打开设备,ibv_create_qp创建队列对,ibv_reg_mr注册内存。这些是POSIX风格的用户态API,但真正干活的是内核。系统调用进入内核后落在drivers/infiniband/core/uverbs_main.c的ib_uverbs_write,然后根据不同的命令字分发到uverbs_cmd.c或自动生成的uverbs_std_types_*.c。
内核侧负责管理的核心对象主要有这几类:设备(ib_device)、保护域(ib_pd)、队列对(ib_qp)、完成队列(ib_cq)、内存区域(ib_mr)。这些对象由drivers/infiniband/core/下的通用层管理,而具体硬件的寄存器操作、门铃(doorbell)写入、WQE(Work Queue Element,工作队列元素)处理则落到drivers/infiniband/hw/下的各厂商驱动。
ODP并没有另起炉灶,而是直接嵌入到内存区域这套体系里。一个MR在创建时如果带了IB_ACCESS_ON_DEMAND标志,它就变成一个ODP MR。后续应用发起RDMA READ/WRITE时,网卡硬件发现目标页没有映射,会向驱动发一个"页请求",后续整个缺页处理流程全部由通用层协调完成。
这里先给一个整体模块关系,后面每一层都会展开:
| 层 | 主要职责 | 关键文件/接口 |
|---|---|---|
| libibverbs 用户态库 | 封装系统调用,提供ibv_系列API | ibv_reg_mr, ibv_advise_mr |
| ib_uverbs 通用层 | 解析用户态命令,维护uobject生命周期 | uverbs_main.c, uverbs_cmd.c |
| ib_core 核心层 | 抽象设备、PD、QP、MR,协调ODP | ib_umem.c, ib_umem_odp.c |
| mmu_notifier | 跟踪进程页表变化,处理MR失效 | mmu_notifier.c, mm/rmap.c |
| 厂商驱动 | 硬件页请求处理、DMA映射 | mlx5_ib, hns_roce |
| HMM辅助 | 页表镜像、迁移处理 | mm/hmm.c |
2.2 ODP在内核里的三类实现形态
不要以为ODP是单一一条代码路径,它实际有三种层次,对应不同硬件能力和使用场景。
第一类是完整ODP(Full ODP),这是最正统的实现。创建MR时指定IB_ACCESS_ON_DEMAND,内存可以不事先注册,硬件支持按需触发页请求,内核收到页请求后把对应页准备好并回复硬件。Mellanox的mlx5_ib驱动是这一类实现最完善的。
第二类是隐式ODP(Implicit ODP),这属于一个较激进的扩展。传统MR需要指定addr和length,隐式ODP干脆创建了一个"覆盖全地址空间"的虚拟MR,显式范围为整个64位地址空间。应用不需要提前注册任何内存,RDMA操作访问任何地址都会触发缺页,甚至包括以后mmap新映射的区域。这个特性对应用层极其友好,等于完全消除了MR规划这个心智负担。
第三类是部分ODP(Page-based ODP),或者叫显式页级ODP。它允许MR的一部分页走ODP,一部分页是传统注册的。这种模式在硬件能力受限时有用。目前内核里主要走full和implicit两条路径,page-based被定义出来但实际用得少,厂商驱动实现也不多。
为什么会有这三种形态?本质原因是不同代际网卡硬件能力差异很大。老网卡的页表缓存(TLB)要么没有,要么很小,只能做整段注册;新网卡支持带软件管理的页请求队列,才可能做full ODP;而implicit ODP要求硬件能处理全地址空间范围的映射,这对页表管理结构要求更高。所以做产品选型时,不能只看软件支持,必须确认网卡固件和驱动是否真的支持implicit ODP。
2.3 顺着一条用户态API走进内核
假设用户态调用了ibv_reg_mr(pd, addr, length, IB_ACCESS_ON_DEMAND)。这条调用会先由libibverbs打包成IB_USER_VERBS_CMD_REG_MR命令,通过ioctl下发。内核侧的处理函数最终落在ib_umem_get系列逻辑。
传统路径的ib_umem_get会做这几件事:
- 根据用户地址和长度计算页范围;
- 对每一页调用
get_user_pages,强制将页锁定并建立映射; - 写入DMA地址到MR内部页表;
- 标记为“已注册”,后续由硬件直接访问。
ODP路径则是:
- 同样计算页范围,但不会调用
get_user_pages; - 调用
ib_umem_odp_get,初始化ib_umem_odp结构; - 在
mmu_notifier里注册回调,监听该进程地址空间的事件; - 分配空闲的页请求索引结构(
per_mm、per_mr结构)等待硬件页请求。
从调用栈上看,区别非常明显。传统MR是一次性把活干完,ODP是建好骨架,等硬件真正访问时再填充血肉。这个"延迟绑定"的设计,是整个ODP性能模型的关键,也是后续排查问题最需要理解的地方。
3. On-Demand Paging核心机制拆解
3.1 当网卡遇到一个不在内存里的页
RDMA网卡想要访问某块用户内存时,会走它自己的地址转换表。传统模式下,这个表在MR注册时就已经填好了所有页的DMA地址。ODP模式下,这个表初始是空的,或者只填了部分页。硬件访问时发现地址转换缺失,会把一个请求放进专门的"页请求队列"(page fault queue),同时产生一个中断通知驱动。
驱动在中断处理里取出请求,记下请求的虚拟地址、长度、目标MR信息,然后把这些信息封装成ib_pending_wqe之类的工作项,交给通用层。注意这里有个时间差:硬件请求进来时,进程可能正在运行,可能已经睡眠,也可能压根没在CPU上跑。内核的ODP处理代码会先用mmap_read_lock拿住进程地址空间的锁,然后确认这个地址范围在当前进程里是否合法(有没有对应的VMA)。
如果VMA存在而且权限允许,接下来调hmm_range_fault来建立页表映射并取得页。这个过程类似CPU缺页异常,但调用点是内核驱动上下文,不是中断上下文,所以可以睡眠等待IO。如果页在swap分区里,这里甚至会触发磁盘IO把页换回来。整个过程中队列对QP是处于暂停状态的,硬件不会继续发送新请求进来,直到软件处理完并显式通知硬件恢复。
3.2 页请求处理的代码路径
以Mellanox mlx5为例,驱动检测到硬件有页请求后,会调用mlx5_ib_page_fault,经过一个复杂的并发处理,最终会调到ib_umem_odp_map_dma_and_lock或ib_umem_odp_map_dma_pages。这两个函数的共同目标是:对指定的地址范围,确保每个页都有映射,并且把DMA地址回填到驱动能用的结构里。
我简化一下主流程:
- 对请求地址范围内每一个页,检查当前ODP MR中的
dma_list是否已经有映射; - 没有映射的页,调用
hmm_range_fault或get_user_pages建立页表; - 获取页后,调用
ib_dma_map_page建立DMA映射; - 记录
dma_addr和页引用到odp_mr的dma_list; - 更新per_mm的页表镜像,让后续查找能复用;
- 向硬件发送
RDMA_READ_RESPONSE或完成通知,硬件得知页已备好,恢复队列执行。
这一系列操作里最容易忽略的是引用计数问题。页被映射到MR后,必须持有一个引用,防止内存回收器把页释放掉。但ODP又不能像传统MR那样一锁到底。所以内核用了一套"部分引用"机制,通过rcu_read_lock和per_mm的notifier_count来协调。当内存回收器想要回收一个被ODP映射的页时,mmu_notifier会介入,让MR先撤销DMA映射并释放自己的引用,然后内存回收器才能安心回收。这个协调过程是ODP正确性的题眼,后面还会细说。
3.3 mmu_notifier:ODP的命脉
ODP能成立,离不开mmu_notifier机制。这是Linux内核提供的“进程地址空间变更通知框架”。简单理解,就是允许内核其他子系统注册到某个进程的mm_struct上,当这个进程的页表发生变化时,内核会逐个调用注册的回调。
RDMA子系统需要监听的事件主要有:
invalidate_range_start和invalidate_range_end:某段虚拟地址范围页表正在或即将失效。比如页面回收、mremap、munmap时会发生;invalidate_page:单页失效;change_pte:页表项内容变化;release:整个地址空间销毁,所有MR必须清理。
为什么ODP依赖这个?因为ODP MR没有锁住任何页,应用或内核随时可能让页失效。一旦页被换出,MR里记录的DMA地址就指向一个可能已被重用的物理页,这会导致数据错乱甚至内存损坏。mmu_notifier在这里扮演"消息通知中枢"的角色,保证RDMA驱动能第一时间撤销DMA映射、避免硬件访问到已经被释放的页。
具体流程是:
- 内核准备回收或修改页表前,调用
mmu_notifier_invalidate_range_start; - RDMA驱动收到回调后,遍历该地址范围内所有ODP MR,把对应的DMA映射撤销;
- 撤销完成后,驱动标记该范围为"不可访问"并暂停相关QP;
- 内核继续完成页表操作;
- 操作完成后,调用
invalidate_range_end,驱动恢复QP状态; - 后续硬件再访问,会重新触发页请求,走一遍完整的ODP流程。
这个机制尤其重要的是在调用invalidate_range_start时不能睡眠,因为此时页表锁可能被持有。所以驱动回调里做的工作必须尽量轻量,只撤销映射、标记状态,真正复杂的清理留在invalidate_range_end或后面的软中断里做。早期实现这里犯过很多并发bug,现在代码里到处是mutex_lock和rcu_read_lock的精细组合。
3.4 隐式ODP的特别之处
隐式ODP最大的特点在于MR范围是"全地址空间"。你没有指定addr和length,内核内部就虚拟了一个范围从0到ULONG_MAX的MR。当应用访问任何地址时,硬件发起页请求,ODP代码会判断该地址是否在当前进程合法的地址空间里。
判断逻辑走到这里就很关键了:如果该地址还没有VMA,比如这个区域以后才mmap出来,现在去访问会怎样?答案是不会缺页成功,而是返回错误,QP进入错误状态或忽略这个请求。也就是说,隐式ODP不是"我可以映射还没映射的内存",而是"我可以跟踪整个地址空间里已映射内存的变化"。映射之前就访问,仍然是非法访问。
换个好理解的方式:你有一个大仓库(整个地址空间),里面有些货架已经摆好(已映射的VMA),有些还是空的。隐式ODP不是允许你把货放在空地上,而是允许你随时看到所有货架的变化,不需要提前锁定某个货架。货架撤走了,它知道;货架新增了,它也知道。
这种能力对应用层来说意味着可以完全摆脱"MR生命周期管理"。比如KV存储网络服务里,数据块可能在运行中反复分配释放,用隐式ODP,你直接在数据块里发RDMA操作就行,不用担心这个内存有没有注册。内核会动态地把新映射的页加入可访问集合。
3.5 页错误风暴与队列挂死的隐患
ODP最著名的性能问题就是"页错误风暴"(page fault storm)。想象一个场景:一个ODP MR覆盖了1万个页,硬件一次性发出批量的页请求,但请求队列深度有限,驱动处理能力和页表建立速度跟不上。此时QP会持续处于等待状态,CPU占用可能飙高,但网络吞吐掉到几乎为零。更严重的是,如果某个页在短时间内被反复回收、重新映射,ODP会产生频繁的失效和重取页,这种抖动会让RDMA延迟从微秒级恶化到毫秒级,甚至触发看门狗。
我在一次压测里就遇到过:应用创建了隐式ODP MR跑RPC,内存压力稍大一点,机器开始swap,结果RDMA延迟从30us直接干到20ms以上。原因就是每个页请求都要把swap页读回来,而且mmu_notifier的失效回调让MR反复撤销和重建DMA映射。这种场景下,传统的锁页注册方式反而优势明显,因为页已经全部常驻内存了。
这不是说ODP不好,而是提醒我们:ODP不是万能银弹,它适合的是"大部分页常驻、少量页动态变化"的场景。如果你能确定内存不会换出、生命周期稳定,传统MR的确定性还是更好。按需分页用得好是要靠调优策略配合的。
4. 内核里ODP关键数据结构与代码细节
4.1 ib_umem_odp 结构到底记了什么
ODP的核心数据结构是struct ib_umem_odp,定义在include/rdma/ib_umem_odp.h里。它挂在ib_umem的基础上,作为ODP MR的扩展数据。关键成员包括:
dma_list:这是一个数组,每个元素保存对应页的DMA地址和状态标志。这是ODP MR的"页表镜像",驱动查页是否已映射就直接查这里;pfn_list:记录每个虚拟地址对应的物理页帧号,用来判断槽位是否有效;interval_tree:以虚拟地址区间组织的红黑树,用来快速查找某个地址是否属于该MR,以及MR之间的重叠关系;notifier_completion:一个完成量,用于协调mmu_notifier的并发失效流程;private:供厂商驱动使用,通常存放驱动自定义的per-page元数据。
你可能会有疑问:为什么内核不直接用系统页表判断页是否存在,而要维护一份dma_list镜像?原因在于DMA映射是硬件特定信息,系统页表只知道虚拟地址到物理页的映射,不知道DMA地址。这个DMA地址由IOMMU或直连模式下的一一映射关系决定,必须独立记录,所以不能省。
4.2 页请求并发控制的几个锁
ODP涉及两级并发:一个是MR级别的(一个MR的多个页同时被访问),一个是进程地址空间级别的(多个MR同时发生缺页和失效)。所以内核用了多层锁来保护。最大的枷锁是mmap_read_lock,因为页请求需要读取VMA、页表,可能触发hmm_range_fault,这些都需要mm锁保护。
ib_umem_odp自身有一把Mutex锁,保护dma_list和pfn_list的修改。而per_mm有一个umem_rwsem,它是一个read/write semaphore,保护所有MR之间的区间树操作。缺页处理时持读锁,修改区间树时持写锁。
这里有一个实际体验:锁顺序必须保持一致,否则很容易死锁。常见顺序是先拿mmap_read_lock,再拿per_mm的umem_rwsem,再拿MR的mutex。反向拿锁,比如在MR mutex持有期间尝试拿mmap_read_lock,如果恰好另一个线程持有mmap_write_lock在等你的MR mutex,就死锁了。这种死锁在早期版本里出现过不少次,后来通过引入mmu_notifier的区间锁才逐步收敛。
4.3 一段典型的ODP缺页代码旅程
这里我贴一个简化版的调用流程,不贴完整代码,但把关键函数列出来,方便你读源码时定位:
c复制mlx5_ib_page_fault()
-> mlx5_ib_handle_pfault()
-> ib_umem_odp_map_dma_and_lock()
-> ib_umem_odp_map_dma_pages()
-> hmm_range_fault()
-> if (new page) ib_dma_map_page()
-> qp->context->invalidate_range(...)
你读代码时重点看hmm_range_fault前后的处理。hmm_range_fault负责把用户地址对应的页表找出来,返回的hmm_pfn里包含页状态和物理帧号。但注意,hmm_range_fault不会增加页引用。ODP代码必须自己把页引用加一个计数,因为后续DMA操作在异步完成前必须保证页不被释放。这一行为跟传统MR里get_user_pages返回带引用计数页的语义不一样,很容易看混。
4.4 什么时候会走get_user_pages而不是hmm
ODP实现里仍然存在调用get_user_pages的路径,主要是为了兼容旧内核或某些不支持HMM的场景。hmm是相对较新的机制,它更高效,因为hmm_range_fault能批量处理页表查询,而且支持返回页表和迁移事件。
但hmm_range_fault有它自己的一套约定,比如需要先mmap_read_lock,需要设置好hmm_range的pfns数组,需要在一个循环里处理HMM_PFN_ERROR等状态。如果你看到代码里hmm_range_fault在一个while循环里被反复调用,不要觉得奇怪,那通常是为了处理页表在查询过程中被并发修改的情况,需要重试。
对比一下:
| 维度 | hmm_range_fault | get_user_pages |
|---|---|---|
| 批量处理 | 支持一次查多个页,效率高 | 每页调用一次,也可以带批量参数 |
| 页表变化处理 | 通过返回码驱动重试 | 一般只返回错误,不提供重试语义 |
| 对NUMA迁移感知 | 较好 | 较弱 |
| 页面引用 | 不直接提供,需调用者处理 | 直接返回带引用的页 |
| 内核版本需求 | 4.15+,推荐 | 所有版本 |
如果你的驱动想从老式实现迁移到HMM,重点在于理解引用计数的转变:原来是get_user_pages后直接拿struct page *,现在变成从pfns里提取,再通过pfn_to_page转换后手动get_page。这个"手动"步骤非常容易漏,漏了就会导致页被回收而DMA还在用的严重bug。
5. 关键代码路径之外的配套设施
5.1 ib_umem_copy_from_to 函数是什么角色
ODP不只是处理RDMA读写,还有一类特殊操作是post_send之外的控制路径。比如用户态可能通过ibv_reg_mr注册一个ODP MR后,又调用ibv_advise_mr来主动预取某些页。内核对应的ib_umem_odp处理函数会做一次类似hmm_range_fault的预取操作,提前把DMA映射建好,减少后续实时缺页的开销。
另外还有一个工具函数是ib_umem_copy_from_to,它不是ODP独有,但在ODP调试中很常用。它负责把内核缓冲区内容复制到用户MR覆盖的内存区域。在ODP场景下,这个函数会先主动触发缺页,再执行复制。所以如果某个地址不可访问,它会返回错误,而不是直接崩溃。这对调试ODP映射范围非常友好。
5.2 内核态API怎么建ODP MR
大多数RDMA用户是用户态程序,但也有内核态使用者,比如NFSoRDMA、SMB Direct。内核态创建ODP MR跟用户态流程不完全一样。用户态有libibverbs帮你把ibv_reg_mr的flag打包;内核态则是在ib_create_mr之后的ib_map_mr_sg或ib_alloc_mr系列接口上传入IB_ACCESS_ON_DEMAND。
举例,一个内核态模块想创建ODP MR:
c复制struct ib_mr *mr;
mr = ib_alloc_mr(pd, IB_MR_TYPE_MEM_REG, max_num_sg);
if (IS_ERR(mr))
return PTR_ERR(mr);
这个接口本身不涉及ODP。如果要ODP,你需要的是ib_umem_get的IB_ACCESS_ON_DEMAND变体,然后把它封装进驱动自己的MR结构。市面上大多数内核态RDMA使用者,比如SUN RPC里的xprtrdma,一开始也走传统注册,后来逐渐引入ODP来减少内存注册的开销。但内核态ODP的调试难度比用户态高一个数量级,因为内核态模块自己管理内存生命周期,出问题往往直接死机而不是返回错误码,这点我后面会展开说。
5.3 ODP与CPU访问的一致性
ODP一个容易踩的坑是"CPU改了内存,但RDMA硬件看到旧数据"。传统MR因为页始终锁定并映射,CPU和硬件看到的是同一个物理页,所以一致性天然成立。ODP模式下,某个页可能被换出、重新换入,物理页变了。如果应用在CPU上写了一个页,之后直接发起RDMA READ,硬件可能在页请求处理中拿到的是旧映射的页。
当然,内核机制保证在缺页处理时会建立新页表,所以正常流程不会有问题。问题往往出在应用自己用madvise(MADV_DONTNEED)或mprotect改变页状态之后,没有做任何同步就直接发RDMA操作。ODP会触发失效回调,撤销DMA映射,但此刻硬件可能还在读取旧页的内容,内核必须等DMA操作全部完成才能释放旧页。这个等的过程是通过invalidate_range的同步语义完成的。但如果应用在CPU端写完后通过普通的写屏障就立刻发RDMA,没有通过任何fence语义跟内核确认,响应顺序是无法保证的。实践中建议在CPU写和RDMA操作之间加入mb()或使用ibv_wc的完成事件做同步。
6. 实战中的性能陷阱与排查技巧
6.1 你可能会遇到的几个典型故障模式
先列一个我的排查清单,每个都是真实踩过坑的:
-
页请求频繁触发,性能剧烈抖动。现象是延迟走势呈锯齿状,吞吐忽高忽低。排查方法是先用
perf看软中断占比,如果mlx5_ib_page_fault相关符号占用高,说明ODP缺页太频繁。解决思路是增加预取:调用ibv_advise_mr提前把常用页DMA映射建立好;或者改用传统MR方案做冷热区分。 -
mmu_notifier回调死锁。现象是进程操作内存时卡死,echo w > /proc/sysrq-trigger能看到invalidate_range_start卡在信号量上。我遇到过一例,原因是驱动在invalidate_range_start回调里调用了需要mmap_read_lock的函数。由于通知回调往往已经持有页表锁,再拿mmap_read_lock就死锁了。解决方案是确保回调里的操作只做轻量标记,不做任何可能重新获取锁的操作。 -
MR与VMA生命周期不匹配。隐式ODP下,应用
munmap了一块内存,但没有删除MR。之后硬件仍可能对这个地址发起访问(因为WQE里记录了它),驱动在页请求处理时发现VMA不存在,返回错误,QP进入RST状态。排查这类问题,一是看QP状态事件,二是应用层必须保证MR的销毁顺序在内存释放之后但有挂起的RDMA操作完成之前。 -
DMA映射泄漏。每当ODP缺页的页被后续失效时,必须有对应的
ib_dma_unmap_page。如果驱动实现不严谨,DMA映射会泄漏,最终耗尽IOMMU地址空间。现象就是运行几天后所有新MR创建失败,dmesg报IOMMU相关错误。这类问题只能靠代码审查和系统性的page allocation故障统计来发现。
6.2 调试工具与内核参数
调试ODP问题,最常用的是打开/sys/kernel/debug/tracing的irq事件,或者直接用bpftrace挂ib_umem_odp_map_dma_pages和invalidate_range_start。如果系统支持,也可以先开启dynamic_debug:
bash复制echo "file drivers/infiniband/core/umem_odp.c +p" > /sys/kernel/debug/dynamic_debug/control
echo "file drivers/infiniband/hw/mlx5/*.c +p" > /sys/kernel/debug/dynamic_debug/control
这会打印大量的页请求和失效日志。注意日志量极大,生产环境不建议开,最好在测试环境抓。
另外两个关键内核参数:
echo 1 > /sys/kernel/debug/rdma_cm/debug_level:这个会打开RDMA CM层调试,能看到连接状态变化和地址解析过程;echo 0 > /proc/sys/vm/max_map_count不需要调,但需要留意max_map_count如果太小,隐式ODP会很快耗尽VMA槽位,导致创建映射失败。
6.3 一个真实的性能排查记录
我直接把当时排查记录的要点摘出来,完整过程太长,只保留关键决策点。场景:某分布式存储的RPC模块改用隐式ODP,本地fio跑通,但跨机器压一周后延迟从50us涨到800us,CPU软中断升高。
第一步,用bpftrace统计缺页频率。命令类似:
bash复制bpftrace -e 'kprobe:ib_umem_odp_map_dma_pages { @[comm] = count(); }'
结果发现,一个fio线程每秒触发了几万个缺页。这个数量级肯定不对,正常预取后应该只有几百个。
第二步,用perf top -C 3查CPU热点。显示handle_mm_fault和do_swap_page占比很高,说明大量页在swap进swap出。进一步看内存压力工具vmstat 1,发现si和so一直非零,确认是内存不足导致页被换出。
第三步,分析为什么ODP这些页可以被换出。因为ODP机制本来就允许换出,应用没有预期到这种压力,而且没有做任何预取和锁页优化。解决方案是:对热数据页先执行mlock或者madvise(MADV_WILLNEED),减少swap;对冷数据才让ODP去按需调页。改完以后延迟稳定在50us以下。
这个经历的核心经验是:ODP不是"省心模式",它的正确使用需要结合应用内存特征做规划。数据热不热、是否允许换出、能否预判访问模式,这些直接决定你是该用ODP还是传统MR。
6.4 代码审查时要重点盯的几件事
如果你要维护或审查一个带ODP的驱动,我会按风险从高到低列出重点检查项。排第一的是DMA映射生命周期:ib_dma_map_page和ib_dma_unmap_page是否成对出现,页引用计数是否正确增减。排第二的是并发锁的获取顺序,特别是mmu_notifier回调中是否睡眠、是否反向拿锁。排第三的是hmm_range_fault返回后的状态检查,有没有漏掉重试逻辑。排第四的是page fault路径与invalidate_range_start的竞态,有没有可能在缺页过程中页表先失效再恢复,导致DMA映射指向已释放页。
这四项如果都做得严谨,ODP的大多数疑难杂症就不会发生。更底层的硬件级别问题,比如固件页请求队列溢出、doorbell复位等,需要跟厂商技术团队一起调,不是纯软件能解决的。
6.5 ODP已经过时了吗
聊到这里,我想说一个很多人问过的问题:RDMA都在往RDMA over Converged Ethernet和软硬件融合方向发展,ODP还有价值吗?我的看法是,ODP不会消失,而且它会成为后续更复杂内存语义的基础。可计算存储、内存池化、跨设备共享内存,这些方向都离不开"动态映射"能力。你已经看到类似CXL的内存池扩展在推进,它们同样需要page fault机制来管理远程内存。ODP提供的这套"硬件触发缺页、内核协调生命周期"的框架,在很长一段时间内都会是RDMA内存管理的基本盘。
懂ODP,等于把Linux内核的MM、通知机制和RDMA驱动三者打通了,这个视角对解决高性能网络问题特别有优势。
7. 我的实操心得与建议
ODP这套机制,我在实际项目中前前后后摸了大半年,最有价值的体会是:不要试图在ODP之上再抽象一层"全能内存管理接口",你要做的是把访问模型理清。
具体来说,如果你的应用内存可以分成几个固定的槽位(比如消息队列、数据块缓存、元数据区),那么传统MR或半ODP方式更稳定。如果应用内存是高度动态的,结构随时变化,完全无法提前规划,那隐式ODP就是最佳选择。关键是不要混用两种策略在同一个关键路径上,否则你会在调试时陷入"是MR问题还是映射问题"的泥潭。
最后再分享一个非常实用的小技巧:在应用层维护一个简单的mmap地址分配器,让所有动态RDMA区都在同一段虚拟地址内分配。这样做的好处是,ODP MR可以只注册这一整段,既享受隐式ODP的便利,又能在出问题时用/proc/self/pagemap快速判断每个页是否被ODP映射。我见过很多团队直接用malloc随机地址做RDMA,然后想用ODP,结果MR范围零碎,性能、调试都吃了大亏。
另一个建议是:在测试环境里务必开启CONFIG_INFINIBAND_ON_DEMAND_PAGING=y以及CONFIG_HMM_MIRROR=y,否则你的模块可能编译时没报错,但运行到某条路径直接提示不支持。新版内核里这两个选项通常默认开,但一些嵌入式或裁剪内核会关掉,确认一下总没坏处。
ODP不是个容易上手的机制,但它确实解决了一个真实存在的痛点:让RDMA硬件也能享受虚拟内存管理的灵活性。如果你正在做一个长期演进的高性能网络系统,花力气把ODP的细节啃下来,绝对是一笔值得的投资。
