资讯动态

让madvise真正驱动GPU内存决策:从用户态意图到内核回收策略

发布时间:2026/10/23 5:18:16 来源:尧图企业网站定制
1. 先想清楚madvise 在传统 CPU 路径里到底干了什么madvise 系统调用是用户态告诉内核这段地址空间我希望怎么被对待的接口。比如MADV_WILLNEED表示接下来要访问内核会把对应的页提前装入MADV_DONTNEED表示之后不要了内核可以直接释放掉对应的物理页并清空 PTE但保留 VMA这两种语义在教科书里都写得很直白。还有一种相对新的MADV_COLD/MADV_PAGEOUT模式用于主动把冷页下沉到回收池进一步让用户态参与内存节奏控制。在纯 CPU 的常规情况下这些建议会被内核 mm 层直接翻译成 page fault 预读、folio 回收或 VMA 状态清理整个生命周期在内核通用框架里完成。但问题是在传统匿名映射或普通文件映射里驱动几乎没有参与甚至可以说 madvise 根本不 care 底层是谁。内核走完通用流程比如zap_page_range、__folio_isolate_lru之类的逻辑物理页就被释放。对一个没有自定义vm_ops的 VMA 来说这套流程完全够用。可当一个 VMA 背后是 GPU 驱动管理的内存时情况就复杂了——这个 VMA 只是整个对象映射的一个面驱动内部还有 GPU 页表、GPU 内存堆、shrinker、hugepage 策略等一整套东西用户嘴上说的我不需要了它根本听不到。这引出了我最近在折腾一个 GPU 驱动项目时遇到的核心问题。这个驱动内部代号为 xe允许用户态把一段 GPU buffer bind 到显存地址空间同时又允许 CPU 直接 mmap 同一个对象。用户程序在业务上有明确的访问意图有的区域一次写完长期不读有的区域会被 CPU 和 GPU 轮番访问有的区域只是临时暂存。但这些意图以前只能靠驱动猜——不管用户怎么设置 madvise驱动内部对 buffer 的处置策略完全不变。结果就是一个设置了MADV_DONTNEED的 buffer驱动依然把它放在 LRU 队首回收时舍不得淘汰一个设置了MADV_WILLNEED的 buffer驱动却因为怕错把它降级到了慢速内存。于是我开始思考怎么才能让这段用户态建议真正影响 GPU 驱动的内部决策。这篇文章面向对内核内存管理、GPU 驱动开发、系统调优感兴趣的读者。我会从 madvise 的常规语义说起逐步拆解如何把它翻译成 GPU 驱动能够理解的内存调度信号并给出我在实际项目中踩过的坑和验证过的思路。如果你正在处理类似的用户态意图 - 驱动决策问题这篇应该能给你一个可复现的参考框架。2. 为什么 GPU 驱动的内存决策需要听到用户意图先聊核心原因决策面不对称。CPU 路径里madvise 之后紧接着的 page fault 通常很快就会发生内核有即时反馈。而 GPU buffer 的冷热判断很可能要等好几帧甚至用户态在 CPU 上已经明确释放了映射GPU 侧的工作还是按老节奏进行。这种情况下madvise 就成了少数能跨越驱动内部状态这个黑盒的用户态信号。另一个原因是成本。要判断一段 GPU 内存是不是真的该回收常见的做法是定期扫描 GPU 页表的使用位但这要么依赖硬件访问计数器要么得做一次 GPU 软管协调代价非常贵。而用户态已经在自己的业务逻辑里完成了类似判断调用一次 madvise 的成本只有几百纳秒。用几百纳秒的用户态系统调用换取驱动省掉一整轮 GPU 页表扫描这个性价比值得动代码。xe 驱动面对的场景更是如此它以 VM_BIND 机制为核心用户态可以任意把同一个 GEM object 映射到多个地址空间每个地址空间都有独立的 GPU 页表。同一块物理内存在这一侧的地址空间里可能是暂存只写在另一侧可能是将被反复采样。如果驱动不能用一种通用方式捕捉用户的 madvise 声明而只是对全局 buffer 做统一 LRU 管理那一定会在内存紧张时出现不必要的迁移和回收。2.1 用户态访问意图是免费的高质量信号很多同学会觉得madvise 只是一个建议内核完全可以忽略。但在驱动调优场景里这种建议反而比驱动自己做的启发式预测可靠得多。因为用户不会平白无故对一段匿名映射调MADV_DONTNEED一定是在某个业务路径里确定性地知道后续不再用这一段。相反如果驱动自己做启发式预测要么基于访问地址局部性要么基于某种统计模型准确率大概率不如用户直接讲出来的话。当然madvise 的局限性也很明显它只能作用于 VMA 上的一个区间粒度偏粗它没有优先级的语义非常重要和最好留着都只能通过MADV_WILLNEED来表达。所以驱动在把 madvise 转成内部决策时最好不要直接用二态 bool而是转成一个有量级的分数这样后续调度策略才有调整空间。下文会专门讲这个分数如何设计。2.2 从建议到决策中间至少隔了三层我在设计最初版本时差点把问题的粒度搞错了。以为只需要在某个 vm_ops 回调里把 madvise 收到的操作记录下来就能影响决策但忽略了三个关键层次。第一层是系统调用层内核把用户传来的 madvise 操作统一处理最终会通过清理或修改相应 VMA 上的 PTE 来体现操作完并不会通知驱动我刚刚对一个 VMA 做了一个 madvise 操作。第二层是VMA 层VMA 是用户地址空间的描述驱动可以在 mmap 时塞入自己的 vm_ops但这些 vm_ops 里并没有 madvise 事件回调。VMA 发生的更细粒度变更比如 madvise 之后页面变得不再映射倒是可以通过 mmu_notifier 观察到。第三层是驱动内部层驱动维护着自己的对象生命周期、GPU 页表映射、内存回收队列它与 VMA 已经隔了一层。VMA 上的 madvise 信号如果不主动翻译并传给内部内存管理器它就会在下一步被静默丢弃。理解了这三层再看我的实现思路就顺了不搞为 madvise 发明一个内核回调这种事而是老老实实借助 VMA 的 fault 与 mmu_notifier把用户意图以驱动能看懂的方式转发到驱动内部的 per-object 状态里。这样既能达到目的又不破坏内核 mm 的通用机制。3. xe驱动里实现用户意图采集从系统调用到VMA事件明确了上面三层实现路径就清晰了——不需要拦截 do_madvise只需要在 VMA 和 notifier 侧注册足够的感知点。VMA 让驱动知道用户这段映射在哪个地址范围被访问/释放mmu_notifier 让驱动知道地址空间这一页已经被 CPU 页表移除了。两者信息一拼就能推断madvise(DONTNEED) 对该 VMA 区间产生了一次清 PTE事件且之后的 fault 事件又没有马上到来——那这段区间大概率是用户冷掉的区域。当然严格从内核视角看一个 VMA 的清 PTE 也可能是 munmap 导致的不一定是 madvise。所以我的做法是在驱动里把清 PTE 事件记录在案并不急于下结论而是等几毫秒或者等到驱动下一次调度触发时再综合判断。munmap 之后 VMA 会消失但驱动只要在 address_space detached 回调里把状态清掉即可madvise 之后 VMA 还在驱动就还能在后续 fault 里把它再标记回来。用事件 状态机来区分用户意图比直接试图找 madvise 参数更可靠而且完全基于现有内核机制。3.1 需要打点的内核通知点实际实现里我至少在三个地方做了埋点。一是vm_operations_struct里的fault回调。GPU buffer 的 CPU mmap 在首访时会产生 fault驱动据此可以把相应 VM 区间从冷态标记为热态。这是接收用户 WILLNEED 意图的天然入口。二是mmu_notifier_ops里的invalidate_range_start/end回调。当 CPU 侧 PTE 被清掉时包括 madvise DONTNEED 产生的 zap这个 notifier 会被调用。我把这个事件当作用户可能不再需要这段地址上的 CPU 访问能力的信号。三是vm_ops-close或split回调。前者用于 VMA 被销毁时清理驱动内部的状态项后者用于 VMA 区间被拆分时保证 per-interval 状态准确。madvise 在部分区间场景下可能触发 VMA 分裂因此 split 回调也必须处理。我把这三个点统称为用户意图采集层。它的产物是一张按 VMA 区间索引的状态表里面记录着每个区间最后一次热事件、最后一次冷事件以及热事件发生次数。这张表本身是驱动内部内存管理器的输入不直接决定回收。3.2 一个最小骨架为了讲清楚状态机我贴一段我当时用来验证思路的最小骨架这里做了简化只表达核心逻辑不是某个官方驱动的完整代码struct xe_intent_state { struct mmu_interval_notifier min; /* 监视一段CPU映射 */ unsigned long last_hot_jiffies; unsigned long last_cold_jiffies; unsigned int hot_count; unsigned int cool_down_ms; }; static vm_fault_t xe_vma_fault(struct vm_fault *vmf) { struct xe_intent_state *st vmf-vma-vm_private_data; st-last_hot_jiffies jiffies; st-hot_count; return xe_handle_fault(vmf); } static bool xe_mmu_invalidate(struct mmu_interval_notifier *min, const struct mmu_notifier_range *range, unsigned long cur_seq) { struct xe_intent_state *st container_of(min, struct xe_intent_state, min); unsigned long last st-last_hot_jiffies; /* 如果这个范围此前一段时间都没有fault判断为冷端 */ if (!time_after(jiffies, last msecs_to_jiffies(st-cool_down_ms))) return true; st-last_cold_jiffies jiffies; xe_feed_memory_reclaimer(st); /* 把状态喂给驱动内部决策器 */ return true; } static void xe_vma_close(struct vm_area_struct *vma) { struct xe_intent_state *st vma-vm_private_data; xe_free_intent_state(st); }fault回调负责捕获 WILLNEED 类意图invalidate回调负责捕获 DONTNEED 类意图。这里有一个关键参数cool_down_ms它用来消除短期抖动用户可能刚 fault 完下一刹那又调 madvise(DONTNEED)驱动不该立刻把这块内存降级而是给它一段冷却窗口避免频繁状态翻转造成回收器震荡。这段代码本身不复杂但它揭示了整个采集层的核心设计哲学用事件驱动状态迁移而不是试图精确还原 madvise 的每一句话。3.3 为什么不用 page_fault 和 mmap 的简单堆砌有人会问既然有了 fault热和 invalidate冷直接两个计数不就完事了吗为什么要搞冷却窗口我实践下来发现真实用户程序访问模式远没有教科书干净。比如一个资源加载器它先 mmap 整块 buffer然后立刻 madvise(WILLNEED) 预取驱动如果把这个当成热那没问题但它又可能在加载完成后对同一段调用 madvise(DONTNEED) 来主动释放 CPU PTE之后 GPU 工作队列过几帧才真正用到这段数据。如果驱动在 dontneed 的瞬间就把它判定为冷然后立刻把对应 GPU 页迁移到慢速内存等 GPU 命令真正执行时再迁回来反而制造了比不实现 madvise 更严重的抖动。所以我在内部分了至少四态活跃热、最近热、冷却中、确定冷。madvise 触发的 invalidate 只把状态从活跃热/最近热降到冷却中要连续两次冷事件并间隔超过阈值才最终变为确定冷。这本质上就是给用户意图加了一个阻尼器让驱动不会在一个调度周期内被用户态的抖动带偏。4. 意图如何变成全局内存决策优先级分值与回收策略联动采集层拿到状态之后真正麻烦的是怎么让这些意图影响到驱动内部的内存调度一个 GPU 驱动内部往往有多个内存域设备本地显存、系统内存、还有可能是一部分可回收的 CPU 页池。对这些内存域驱动有自己的分配器、回收器和迁移逻辑。如果意图只在 VMA 区间层面打转是没法指导全局决策的。所以需要有一个意图分值的概念把离散事件映射成一个连续整数驱动内部内存管理器再以这个分值为参考做排序。我为每个 GEM 对象维护了一张区间列表每个区间会累积一个intent_score。这个分数不是简单累加而是按事件时间衰减fault 加 N 分invalidate 减 M 分衰减系数用指数移动平均。细节不展开核心是做大内存回收时驱动把所有待选对象按这个分数排序分数最低的先淘汰。这套逻辑放在驱动的shrinker回调里可以直接参与全局内存压力下的回收不需要额外用户态介入。4.1 把建议折算成分数我给出一个我当时在实验板上用过的映射表注意这只是参考值不是标准答案用户事件对分数影响建议用途MADV_WILLNEEDfault 预取score 64并抬高热度等级驱动预取到高带宽内存普通 CPU read/write faultscore 16表示最近刚访问应保持当前层次MADV_DONTNEEDscore -32进入冷却态只做可回收标记不立刻迁移MADV_PAGEOUTscore -64强制冷却态驱动可以把对应页放回系统内存池MADV_COLDscore -48适合作为迁移到慢速内存的依据MADV_HUGEPAGE驱动记录偏好不直接改分与 THP 策略联动分配时尝试大页这个表第一次出来的时候团队里有人质疑为什么不直接 MADV_DONTNEED 就把页释放掉省得后面再扫描我解释GPU 内存释放涉及底层页表 shootdown、fence 等待成本远高于 CPU 侧释放一个 PTE如果用户 dontneed 之后几帧又要用代价是灾难性的。所以宁可让回收器保守一点也不要让用户态一句暂时不要就破坏 GPU 执行流。从最终效果看这个保守设计是正确的。4.2 与驱动 shriner 联动xe 驱动的回收入口并不止一条。一类是全局内存压力触发的shrink_slab回调另一类是 GPU 内部漫游式回收器。我在两条路径上都挂上了intent_score排序。在shrink_slab回调里驱动会先遍历所有绑定到地址空间的 GEM 对象对每个对象算出一个综合得分对象的intent_score越低说明用户最近越不活跃回收优先级越高。最终回收器从低分到高分开刀但在真正释放前还要检查该对象是否有尚未完成的 GPU 命令引用。如果还有 active fence 引用就跳过。这一步非常关键否则会产生一个经典 bug用户态调完 madvise(DONTNEED)驱动回收了内存结果 GPU 命令队列里仍然引用旧地址下一次执行直接 GPU hang。从实现角度看shrinker 本意是能回收多少就回收多少但 GPU 场景里必须同时满足回收可以安全发生和回收成本合理两个约束。intent_score只负责回答哪个对象回收最合理而 fence 检查回答哪个对象现在真的安全两者必须同时通过才算完成决策。4.3 与 HUGEPAGE 策略的联动内存决策里还有一个容易被忽略的点大页策略。GPU 驱动通常会在系统分配时尝试 2MB 对齐的物理页但这会带来更高的内存占用与碎片压力。如果 madvise 里带了 MADV_HUGEPAGE驱动可以把这一块的物理页分配偏好调高反之如果用户没有大页诉求驱动的分配器就走更保守的 order-0 路径。同样要考虑时序问题。madvise(HUGEPAGE) 是用户态在 mmap 之后调用的而驱动的物理页分配往往发生在 GPU bind 的时候。如果 bind 发生在 madvise 之前这个意图就来不及生效。因此我在驱动里把 HUGEPAGE 意图也作为分配时提示存起来当 GPU bind 接口真正分配物理页时再查一次。某种程度上这等于把用户意图延迟到真正需要它的决策点前。毕竟用户态的一些设置并不是为了立刻改变当前状态而是为了改变下一次资源分配时采用什么策略驱动按这个思路去消费它才能保证意图不丢失在时序缝隙里。5. 实测效果与踩坑哪些用户意图真的有用在讲踩坑前先说我拿到的实测数据虽然是实验室环境但方向性已经很明确。实验分三组第一组完全不调用 madvise第二组只在加载完成后调 MADV_DONTNEED第三组则是先 MADV_WILLNEED 预取运行一段时间后再 DONTNEED。在固定内存压力下跑同一套 GPU 负载第一组内存回收次数最多且绝大多数回收都发生在用户早已确定不再需要的区域浪费严重。第二组比第一组好一些但存在冷却窗口过短带来的脉冲式回迁。第三组整体回收次数最少性能吞吐波动也最小说明WILLNEED DONTNEED组合拳确实能帮助驱动避开无用页的扫描。这个结果算不上多么惊艳但至少证实了一件事用户意图这件事不靠驱动去猜直接让用户告诉内核对 GPU 内存决策是有实际帮助的。不过只要真正跑起来坑就接着来了。以下是我踩得最深的三条每条都花了我至少一个晚上。5.1 MADV_DONTNEED 不等于同步释放我最初犯的错是把 invalidate 回调里收到的范围当成可以立刻回收。结果立刻触发了我上面提到的 GPU fence 问题。修复的方式是给回收路径增加一个准入门槛只有当该内存块上的 GPU fence 全部 signal 之后才允许真正做物理页释放。也就是说madvise(DONTNEED) 在 GPU 语义里只是用户已经不关心 CPU 侧访问驱动内核还需要配合命令完成状态去定夺。调试过程中最有用的一个工具是给驱动打印一份简单的状态时间线把 fault、invalidate、fence signal、实际回收这几类事件拼在一起。一旦发现invalidate 之后立刻被回收但时间线上还有未 signal 的 fence基本就能确定问题出现在这个门槛缺失上。这种排查方式比单纯在代码里翻逻辑要快得多因为问题本质是时序错位。5.2 冷却窗口设太短会让回收器产生抖动第二处坑是冷却窗口设太短。我在某个早期版本里把它设成 1 毫秒结果内存在 70% 水位附近来回震荡刚把一页迁移回慢速区下一帧 fault 事件又把它拉回热区反复横跳。后来我把默认值提高到 50 毫秒并做了一个自适应如果驱动的热事件频率很高冷却窗口动态调大如果长时间静默则调小。这样测试下来状态翻转次数降了一个数量级。这里没有放之四海而皆准的推荐值它跟你的负载模型、GPU 调度频率、页表扫描周期都密切相关。我的经验是一开始选一个偏稳妥的大值再用统计状态翻转次数作为指标去收敛比一上来就找一个神秘最优值靠谱得多。5.3 mmu_notifier 回调里的锁不得不注意第三个坑比较内核化mmu_interval_notifier的 invalidate 回调运行在 mmap 写锁或其他 folio 锁的上下文中你在里面做的任何操作都不能去拿一个可能产生递归锁的锁。我最开始天真地在回调里调用了驱动的xe_feed_memory_reclaimer而这个函数内部会去拿一个全局的 obj_lock恰好另一个线程持有 obj_lock 在做 GPU bind且已经在等待 mmu_notifier 完成于是直接形成 ABBA 死锁。后来我把回调里真正需要做的工作分成了两部分回调内只记录事件、标记一个 dirty flag统一放到驱动的工作队列里再执行对象状态更新与回收策略调整。这样回调路径变成了纯轻量级通知不会有锁依赖。这个经验对任何做 GPU 驱动的人都有参考价值——mmu_notifier 回调不是让你做业务操作的地方它只是一个带时序保证的信号源头。5.4 I/O-less 场景下的 false positive还有一个细节容易被忽略有些用户态程序会为了清理页表缓存而对一块几乎从不访问的映射调 MADV_DONTNEED但后续驱动侧却因为一个十分低频的 GPU 事件又需要读这块内存。这种场景下如果驱动太相信 DONTNEED 信号把对应的 GPU 页直接放回系统复用池那么下一次 GPU 访问就会打一次缺页产生一次明显的尾部延迟。我的缓解办法是在驱动里为这类低频再访问场景留下快速恢复路径。如果回收出去的内存是最近刚被 DONTNEED 过的驱动会把它放入一个专门的小型保留池而不是直接归还给伙伴系统。保留池容量有限但在大多数业务场景下足以吸收用户态误报带来的尾巴。这种做法听起来有点浪费实际却能让整机内存压力测试下的长尾延迟明显变短。6. 我理解的边界madvise 等用户意图永远只是概率建议最后想聊一个设计理念层面的事。madvise 在设计上就不是一条命令它的语义是建议用户态告诉内核一个倾向性判断但内核可以根据实际情况忽略。驱动在消费这个信号时应当始终保持同样的姿态把它当作降低预测成本的特征而不是当作削减一致性的许可证明。MADV_DONTNEED不代表立即释放更不能因此忽略 GPU fence。MADV_WILLNEED不代表必须留在这里它只提高了一个优先级真正的迁移决策仍然要以内存压力与访问时延采样为准。MADV_HUGEPAGE只是告诉驱动我喜欢大页但如果当前碎片化严重驱动仍然应该退回普通页。这种保守姿态还有一个额外的好处即使某个用户态程序对 madvise 使用完全错误比如把正在使用的 buffer 标记成 DONTNEED驱动也不会立刻做破坏性操作最多是把它降为低优先级后续访问时再恢复。而如果驱动把建议当命令执行一点误用就会变成实打实的 GPU 故障。回到我最初做这套实现的原因用户态对内存的访问模式本身就是整个系统中成本最低、最准确的一种提示。传统 CPU 路径里它已经被利用得很充分而到了 GPU 驱动背景下因为多了一个地址空间层它很容易被忽略。通过把 madvise 事件采集、意图分值、回收策略联动三层拼起来xe 驱动的内存管理终于不再是一个只看对象生命周期命令的黑盒子而是能感知用户真实意图的自适应系统。这里再分享一个我实际调试时常用的技巧给驱动加一个 debugfs 接口把每个 GEM 对象当前的intent_score和状态机字段 dump 出来。当你发现用户态明明调用了 madvise 但驱动内存行为没变化时第一件事不是去查内核 mm而是去查这个分数有没有被正确记录。如果分数对了说明采集层没问题如果分数一直没变那大概率是 VMA 或者 notifier 某个回调没注册上。这个排查顺序能帮你省下一整个下午。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑