尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

GPU驱动中的显存同步与保护:从CPU锁到GPU Fence的底层逻辑

GPU驱动中的显存同步与保护:从CPU锁到GPU Fence的底层逻辑 1. 为什么GPU驱动中最怕“资源打架”——显存同步与保护的底层逻辑1.1 从CPU世界到GPU世界的同步思维转变很多人学GPU驱动开发第一步往往是去啃硬件手册、看命令行、写第一个hello world驱动但真正让你半夜抓狂的往往是并发问题。CPU编程里的锁、信号量、原子操作放到GPU KMD场景里一下子变得不“香”了。原因很简单CPU侧的同步只需要管好几个核、几个线程但GPU KMD要面对的是一张包含数千个计算单元、几十个硬件队列、异步DMA引擎以及用户态不定时提交命令的巨型并发网络。任何一个环节没有做好同步保护轻则产生撕裂的画面重则直接导致设备挂起、系统蓝屏甚至烧坏一块价值不菲的显卡。我记得自己第一次调试KMD资源同步时犯过一个特别蠢的错误在CPU侧用一个自旋锁保护一个显存分配表觉着很稳妥可是当GPU侧在某个内核线程还在用这块显存时我却在另一个线程把它释放掉了。结果就是GPU访问了已经归还给内存管理器的物理页随即触发页面错误整个图形设备被操作系统强制重置。那次debug花了整整三天后来才明白KMD里的“同步”绝不只是CPU线程之间的互斥而是要管理CPU与GPU之间、GPU不同引擎之间、甚至多个上下文之间的执行时序。你必须站在“生产者—消费者”的视角去看待每一块显存的生命周期而不只是把它当成一个指针。所以在进具体原语前你得先接受一个关键转变GPU KMD同步的对象不是变量而是“执行流”和“资源状态”。你在保护的不是一个内存地址而是“某块显存当前正被哪个执行引擎引用以及什么时候可以安全回收”。1.2 KMD中资源同步保护的三个核心维度时序、互斥、生命周期如果把KMD的资源同步和保护拆开来看我觉得有三根柱子缺一不可。第一根是时序Ordering。GPU的命令执行是异步的命令提交到环形缓冲区后CPU不会干等GPU干完。这时候CPU如果要读取GPU的运算结果或者释放GPU正在使用的显存就必须知道“GPU已经执行到哪条命令了”。这个“知道”的过程就是时序同步。硬件上一般通过Fence栅栏实现CPU往某个地址写入一个递增序列号GPU在执行完特定命令后更新这个序列号CPU轮询或等待中断直到看到目标序列号才继续。这根柱子如果没立好你会在驱动里遇到各种“随机”崩溃因为GPU执行速度时快时慢竞争窗口极不稳定。第二根是互斥Mutual Exclusion。显存资源不像CPU内存那样可以随意共享很多硬件状态比如显存堆的全局配置寄存器、页表基地址、某些只能被单一引擎独占的DMA通道天生没法并发访问。这时候你就必须用CPU侧的锁去串行化访问。互斥维度最容易犯的错是“锁不住硬件”。你锁了软件层面的分配结构但没锁硬件层面的队列提交结果两个上下文同时向同一个引擎提交命令硬件直接行为未定义。第三根是生命周期Lifetime。这是新手最容易忽略的一块。显存资源在用户态进程退出、上下文销毁、驱动卸载时必须保证没有命令还在引用它。否则你回收了物理内存GPU后面才执行到那条命令就会访问无效地址。生命周期保护不完全靠锁更多靠引用计数、状态机以及GPU上下文追踪。你可以类比成你借给别人一本书别人还没看完你就把书卖了那你得确保买书的人恰好就是借书的人否则就得等还回来。现实是你不可能知道GPU“什么时候看完”所以你只能通过Fence记录“预计看完的时间点”在这个时间点之前绝不释放书。这三个维度交织在一起构成了KMD资源同步保护的主干。后面所有工具、代码、调试手段本质都是在为这三根柱子添砖加瓦。2. 同步原语选型从自旋锁到栅栏如何为GPU场景挑正确的工具2.1 自旋锁与互斥锁CPU侧的保护边界在KMD里最底层的同步工具就是自旋锁和互斥锁。它们的用法跟普通内核驱动差不多但边界条件要更苛刻。自旋锁Spinlock适用于保护极短的临界区比如一个几行的链表节点删除、一个标志位的翻转。因为自旋锁在等待时会一直占用CPU核心所以临界区里绝对不能有耗时操作更不能睡眠。我见过有人把一次可能需要跟GPU硬件交互的MMIO读取放进自旋锁保护段里结果其他核心全在那儿空转性能直接雪崩。还有更严重的如果持锁代码路径恰好在等待GPU某个状态寄存器变化而那个寄存器需要GPU调度到某个引擎才能更新而那个引擎所在的线程又想拿同一把锁这就成了典型的死锁——只不过死锁双方一边是CPU一边是硬件状态机。互斥锁Mutex则允许睡眠适合保护那些需要等待资源、遍历大表、甚至调用其他可能睡眠的函数的长临界区。但代价是它涉及线程调度、唤醒延迟比自旋锁高一个数量级。在实时渲染或者计算场景一次互斥锁的争用可能让命令提交错过vsync导致帧率抖动。所以KMD设计者的核心技巧是把临界区拆成三级最短的用原子操作或者自旋锁中等的用互斥锁最长的用工作队列加状态机。不要指望一把大锁锁住所有东西。我在代码里常用一个约定任何锁保护的区域如果里面出现了wait_event、msleep、或者调用其他驱动组件的接口必须改用互斥锁并且在注释里写明“此处可能睡眠”。自旋锁区域只允许原子操作和纯内存读写。这个约定救了我很多次因为后续维护的人不会莫名在某天加一个居然能睡眠的函数到你的自旋锁临界区里。2.2 GPU栅栏Fence与硬件同步原语GPU侧的同步不能靠锁因为GPU没有线程上下文去等一把锁。硬件提供的标准机制就是Fence也就是栅栏。我把它理解成一组带序号的“打卡器”。每个提交的命令缓冲区里可以包含一个指令“执行到这里时把某个全局内存地址的值更新为N”。CPU侧则通过向指定地址写入一个期望的序号M并等待该地址的值不小于M来判断GPU是否已经执行过了对应的点。在Linux内核的KMD中Fence往往用dma_fence表示。它不只是等待工具还承担了内存回收的凭据角色。当一个命令缓冲区提交时驱动会为它关联一个Fence对象。当GPU执行完包含该命令的硬件队列时中断处理程序会回调该Fence的回调函数然后唤醒所有等待者。显存资源管理器在尝试回收一块显存时会先查看“最后一个引用这块显存的Fence序号是什么”把这个序号插入到当前GPU队列作为“软 fence”然后等它发出信号再真正释放物理页。这里有个非常关键的实操点Fence的创建与信号必须严格配对。如果在错误的时间提前发出信号GPU可能还没真正用完资源回收就会爆炸。如果信号发晚了只是性能损失不会出错。所以很多驱动工程师在写Fence处理时都遵循“宁晚勿早”原则。我自己调试过某跨平台系统在特定GPU上频繁出现页面错误最后定位到是某个DMA引擎的中断处理代码在硬件没有完全写完内存时提前发了Fence信号。用修“多等一个引擎空闲检测周期”的办法解决虽然损失了一点点吞吐但换来了稳定。除了通用Fence现代GPU还提供了更细粒度的硬件同步原语。比如NVIDIA的Tegra有syncpointAMD的GFX有PM4的WaitRegMem指令还有Vulkan里的semaphore和barrier。这些本质上是硬件实现的Fence变种区别在于等待条件可以是某个寄存器值、某个内存值、或者某个引擎的bit clear。在小节里不展开每一种但你只要理解了Fence模型其他都能触类旁通。2.3 如何选择锁类型与性能、复杂的权衡选择同步原语时需要综合考核延迟、功耗、复杂度。我通常会画一张决策表在脑子里快速过一遍场景推荐方案原因修改资源句柄表自旋锁或RCU操作微秒级不能睡眠RCU可无锁读更新显存分配器空闲链表头原子操作自旋锁临界区只有几行不涉及硬件交互向硬件队列提交命令缓冲区互斥锁内存屏障可能涉及唤醒GPU调度线程且需要保证内存可见性等待GPU内存写回完成DMA fence completion标准异步等待机制支持多等待者驱动卸载时回收所有资源互斥锁工作队列状态机操作耗时极长必须允许睡眠和外部事件循环一句话总结CPU侧的“锁”负责保护CPU眼中的数据结构而GPU侧的“Fence”负责保护硬件执行流中的资源引用。两者经常要配合使用。比如你先拿互斥锁把一块显存从某个文件私有池移到全局池然后创建一个新的Fence让它接管这块显存的生命周期最后释放互斥锁。这期间Fence并不关心你用的是哪把锁它只关心“GPU映像是否已经执行完资源引用点”。我见过有的驱动为了省事直接用一把全局大锁来保护所有资源状态结果多上下文跑起来时GPU利用率只能勉强达到一半。后来把锁粒度拆细到每个资源对象效率提升明显。但拆细锁也会带来死锁风险所以必须设计稳定的加锁次序。通常的次序是先锁对象池、再锁对象本体、最后锁硬件队列。反向加锁就可能出问题。3. 显存资源管理的保护机制——驱逐、重定位与多GPU场景3.1 资源引用计数与句柄验证显存资源不像CPU内存那样可以靠虚拟地址在内部管理因为它涉及用户态句柄、内核态对象、硬件地址的映射链。一个资源从创建到销毁要经过用户态分配请求、内核创建对象、关联物理内存、建立页表映射、提交给GPU队列使用、等待GPU完成、解除映射、释放物理页。任何一步出错都可能产生use-after-free。保护机制的核心是一套引用计数体系。每个显存对象在创建时引用计数为1用户态每多一个句柄引用计数加一每次GPU命令提交引用该对象时也要增加一个“硬件引用”这个硬件引用会随Fence信号而被释放。只有当CPU引用和硬件引用都归零时对象才能被真正销毁。这样的设计用一句话概括没有人包括CPU和GPU在用了才允许销毁。句柄验证也很关键。KMD会维护一张从用户态句柄到内核对象的映射表。用户态可以提交任意整数作为句柄驱动必须校验它确实映射到一个有效对象而不是被释放后重新复用了同一片内核内存。如果映射表里存的是对象指针释放后指针可能被重新分配给其他对象。所以成熟的驱动会在释放对象时把映射项标记为dead并把指针清零防止ABA问题。在内核里还会给每个对象加一个magic number验证时先检查magic再检查指针双重保险。我维护过一个图像处理Demo驱动早期没有用magic number结果某次用户态故意传了一个已经释放掉的句柄映射表刚好错乱让驱动访问了一块已经重分配为其他用途的内核内存当场导致系统崩溃。后来加上magic number并在每次访问前校验这类问题再也没有出现。哪怕有轻微性能开销也值。3.2 资源同步在内存驱逐Eviction与重定位中的关键作用现代GPU显存容量有限当多个应用共同占用显存时必须把一些暂时不用的资源从显存驱逐出去腾空间给新资源。驱逐流程是这样首先对要驱逐的资源加“驱逐锁”阻止新的命令提交引用它。然后为当前所有正在引用该资源的GPU队列插入一个Fence等待信号确保GPU不再访问该资源。再更新页表让GPU的虚拟地址不再映射到这块物理显存必要时拷贝到系统内存作为备份。最后释放物理显存页把它放回空闲池。当资源再次被访问时驱动可能需要重新分配显存并恢复映射这个过程叫重定位。在驱逐过程中同步保护的核心就是那个Fence。因为新命令可能已经提交了虽然在软件层面加锁禁止了新引用但硬件队列里可能已经排着某个尚未执行的命令缓冲区里面仍然包含对该资源的引用。你即使立刻回滚也得等硬件把这个命令执行完。所以Fence必须被引用计数机制管理在你尝试驱逐资源时先尝试“冻结”它的硬件状态。如果发现还有引用就等待对应Fence信号如果Fence已经信号过了但又有新引用进来那就说明你的冻结逻辑有漏洞必须在提交新命令时检查资源状态。另外驱逐过程中经常会涉及两个引擎同时访问同一个资源。假设引擎A正在用资源R生成中间结果引擎B需要读取这个结果。那么驱动必须在引擎B的命令里插入一个等待引擎A的Fence确保引擎B不会提前读到脏数据。否则驱逐与重定序的操作一旦交错可能把还没写完的内存内容映射到新地址导致渲染错误。从实际经验来看驱逐中的同步最容易犯的错是漏掉了“某个间接引用”比如资源被嵌在另一个命令缓冲区里作为参数而那个命令缓冲区已经提交但缓冲区本身的Fence还没信号。简化套路给每个命令缓冲区也挂一个Fence并且在驱逐资源时遍历所有已提交但未完成的命令缓冲区检查它们是否引用目标资源。如果数量少暴力遍历是可行的如果数量多就得为每个资源维护一个“反向引用列表”有点类似内核里的rmap。3.3 多GPU与上下文切换下的同步挑战当系统里有多个GPU或者一块GPU多个实例或者一个GPU上运行多个虚拟上下文时同步维度会更复杂。多GPU环境下某块显存可能被两个GPU共同访问比如使用跨设备互连做零拷贝传输。此时不能只依赖单GPU的Fence因为一个GPU发出的信号只会更新它自己的地址空间。驱动需要采用全局Fence或者跨设备事件等待所有相关GPU都完成访问后再释放资源。上下文切换时问题则主要体现在命令缓冲区的排空。为了实现运行中的多上下文上下文切换硬件可能把当前正在执行的引擎暂停保存状态然后让另一个上下文进入。如果旧上下文还有资源引用未完成切换后旧上下文再次恢复执行时其内部命令队列的尾部仍然会访问某些显存。KMD必须确保这些显存在上下文挂起期间没有被释放或重定位。做法通常是在提交命令时记录下该命令会引用的资源集合并提升这些资源的引用计数直到该上下文的Fence全部信号后才能释放。这也意味着“Fence信号”的时机不一定是命令结束而可能是上下文保存完成。碰到过的一个现实场景某款硬件支持多GPU直接内存访问驱动里用了一个简单计数器跟踪每个GPU引擎上的未完成命令数。可是当两个GPU的DMA操作互相依赖时计数器没法表达依赖次序导致一个引擎提前认为自己可以释放源内存实际上另一个引擎还在读。后来改成每条命令都携带一个“依赖Fence列表”在软件层解析依赖后再提交硬件问题才解决。4. 死锁、活锁与优先级反转驱动开发中的并发陷阱实录4.1 真实案例一块被“锁死”的GPU某次调试中我遇到一块GPU在正常使用一段时间后突然整个显卡驱动重置。日志里没有任何错误码只有超时。抓了ftrace和硬件状态寄存器发现GPU的某个计算引擎一直停在一个特定的内存等待指令上。而该内存地址对应的Fence信号迟迟不来。顺着代码查下去发现这个Fence信号需要CPU侧在中断处理函数里更新但CPU侧在等一个互斥锁这个互斥锁被另一个线程持有而那个线程正在等待GPU队列有空位所以去睡眠了。中断线程拿不到锁Fence永远不被更新GPU就死等一个永远不会发生的信号。这就是一个典型的死锁CPU线程在等GPU资源GPU在等CPU更新Fence而Fence更新的路径被CPU线程的锁阻塞。从理论上讲这种锁不到底的链条应该靠设计规避但现实是它确实发生了。原因就是早期设计时把“等待Fence”放在了持锁的临界区里。我当时是在某个资源回收函数里先拿了一个全局互斥锁然后调用dma_fence_wait等待GPU完成一条命令。同时GPU完成这条命令后发送的中断处理函数要更新一条统计信息而这个统计信息的更新也需要拿同一把全局互斥锁。中断处理在软中断上下文没法阻塞直接尝试拿锁失败只好把更新Fence的工作延迟到工作队列而工作队列又不巧被卡住。于是整个链条绕成环。教训很明确不能在持锁的情况下等待Fence。因为持锁等待Fence时你无法确保Fence信号路径是否需要同一把锁。如果万不得已必须等待一定要确保等待路径中可能调用的所有处理器函数都不依赖你手上这把锁。其实最安全的做法是一开始就把锁释放掉让资源进入一个“正在回收”状态把等待Fence放到工作队列中异步执行再在完成回调里重新拿锁做后续清理。这个案例也印证了之前说的互斥锁负责保护状态机Fence负责异步完成事件两者要在不同时间片上工作。4.2 死锁检测与预防策略KMD开发中预防死锁有几个实用策略我列一下第一明确加锁顺序。所有代码必须约定同一个资源获取顺序比如全局锁、池锁、对象锁、队列锁永远按这个顺序拿。如果两个线程反向拿锁死锁风险立即产生。在代码评审里我会特意检查每个函数加锁的顺序。第二使用锁的嵌套层级不要过深。超过两层锁的代码就要警惕三层以上基本是设计问题。可以用一个自定义的锁调试器在lockdep开启的内核里直接跑它会帮你自动检测可能的环形等待。Linux内核的lockdep对KMD特别有用只要早期测试阶段带着锁验证开启跑一遍就能暴露出大多数锁序问题。第三对于必须等待可能长时间完成的事件如GPU命令执行DMA传输不要在临界区内等待。这听起来简单但很多人会忘记中断上下文、软中断上下文里不能睡眠甚至不能在自旋锁里调用任何能阻塞的操作。核心方法把等待转成异步callback。一个Fence注册一个回调函数当信号到达时回调函数在受信上下文里继续处理。这样你不需要主动等待也就不会占着锁等待。第四为GPU超时设置看门狗。每个提交的命令缓冲区关联一个超时时间如果超过一定时间还没有完成驱动就进入错误恢复路径强制重置引擎避免死锁永远卡住系统。虽然看门狗不能阻止死锁但能把死锁转化成可诊断的错误让你能抓到现场。我之前在写某个模拟项目X时就把lockdep和看门狗都加上死锁概率降为零。真实项目里大家也常用“动态debug 环形历史记录”来追踪最后的锁事件方便崩溃后分析。4.3 使用锁的黄金法则我把在KMD里使用同步锁的经验总结成五条黄金法则适合贴在工位上能不用锁就不用锁优先考虑原子变量、内存屏障、RCU或者通过设计消除临界区。比如用不可变数据结构、每CPU缓存、单向队列等。锁的范围尽可能小只在真正共享的那几行代码上加锁不要为了图省事锁住整个函数。锁的粒度尽可能细一把大锁保护所有资源一旦并发稍微起来就会成为瓶颈。但也要防止死锁细粒度锁与固定加锁顺序是配套的。持锁时间尽可能短把耗时操作移出临界区比如分配内存、等待Fence、打印日志这些都不该出现在持锁代码块里。加锁必须成对且释放路径统一不要让同一个函数在两个分支里各自unlock那样极易遗漏尽量用goto统一出口或者封装成作用域对象在离开作用域时自动解锁。这五条是我踩过无数坑后总结的。如果你的驱动代码在review时有人指出“这里锁范围太大”别觉得丢面子赶紧改。因为性能问题很多时候就出在这些看起来无害的大锁上。5. 从零开始写一个资源同步保护模块伪代码与实操要点5.1 核心数据结构设计为了把前面的理论落下来我们用一段类似内核风格的伪代码来展示一个简单的显存资源对象重点在同步与保护。假设这个驱动管理的内存资源称为“GpuBuffer”。先定义基础对象struct gpu_buffer { atomic_t refcount; u32 magic; struct list_head node; // 掛在全局对象链表上 struct mutex lock; // 保护自身状态 enum buffer_state state; // FREE, ACTIVE, PINNED, EVICTING... dma_addr_t phys_addr; size_t size; struct dma_fence *fence; // 最后一次访问该资源的fence struct dma_fence_cb cb; // 用于异步回调 void *cpu_ptr; // 映射的虚拟地址 };这里refcount用于CPU侧的引用计数用原子操作管理fence是最后一个引用该资源的硬件fence。当fence信号后可以触发一系列动作比如释放内存。另外需要管理映射表可以用一个红黑树以用户句柄为键指向gpu_buffer指针struct handle_table { struct mutex table_lock; struct rb_root root; }; static struct gpu_buffer *get_gpu_buffer(struct handle_table *ht, u32 handle) { struct gpu_buffer *buf; mutex_lock(ht-table_lock); buf lookup_handle(ht, handle); if (buf buf-magic GPU_BUFFER_MAGIC) { if (atomic_inc_not_zero(buf-refcount)) { buf buf; // 有效 } else { buf NULL; } } mutex_unlock(ht-table_lock); return buf; }注意在获取对象时要先校验magic再用atomic_inc_not_zero增加引用计数。atomic_inc_not_zero能够避免在对象正在被销毁时引用计数已经从0变为-1的情况。如果返回0说明对象正处在销毁路径不要继续使用。释放时先原子减计数若减到0则进入销毁流程。销毁流程会等待fence信号但绝不持锁等待。static void release_gpu_buffer(struct gpu_buffer *buf) { if (atomic_dec_and_test(buf-refcount)) { // 进入销毁流程可能异步 schedule_destroy_work(buf); } }5.2 同步与保护流程实现下面模拟一个“提交命令并引用一块资源”的流程。这个流程发生在用户态请求执行一个渲染操作时。int submit_command_wait_resources(struct drm_file *file, u32 handle) { struct gpu_buffer *buf get_gpu_buffer(file-handle_table, handle); if (!buf) return -EINVAL; // 防止资源被驱逐将其状态标记为PINNED固定 mutex_lock(buf-lock); if (buf-state ! EVICTING) { buf-state PINNED; } else { mutex_unlock(buf-lock); release_gpu_buffer(buf); return -EBUSY; // 正在驱逐无法新命令引用 } mutex_unlock(buf-lock); // 创建命令缓冲区并提交到硬件队列 struct dma_fence *fence submit_to_hw_queue(gpu, buf-phys_addr); // 记录该资源最后一个fence便于以后等待 spin_lock(gpu-fence_lock); if (buf-fence) dma_fence_put(buf-fence); buf-fence dma_fence_get(fence); spin_unlock(gpu-fence_lock); // 现在命令提交完成可以移除PINNED状态但fence仍保存引用 mutex_lock(buf-lock); buf-state ACTIVE; mutex_unlock(buf-lock); dma_fence_put(fence); release_gpu_buffer(buf); return 0; }这里的关键是在命令提交前你已通过get_gpu_buffer增加了CPU引用因此资源不会在提交过程中被析构。同时通过设置PINNED状态防止资源被驱逐。提交后保留fence表示硬件还在使用它。当fence信号时需要释放这个硬件引用void gpu_fence_callback(struct dma_fence *fence, struct dma_fence_cb *cb) { struct gpu_buffer *buf container_of(cb, struct gpu_buffer, cb); // 此时GPU已执行完引用该资源的命令 mutex_lock(buf-lock); buf-fence NULL; if (buf-state EVICTING) { // 资源正在等待被驱逐这个callback只是通知等待者 complete(buf-evict_comp); } mutex_unlock(buf-lock); dma_fence_put(fence); }驱逐流程就利用这个callbackint evict_gpu_buffer(struct gpu_buffer *buf) { mutex_lock(buf-lock); if (buf-state PINNED || buf-state ACTIVE) { buf-state EVICTING; buf-evict_fence dma_fence_get(buf-fence); } else { mutex_unlock(buf-lock); return 0; } mutex_unlock(buf-lock); if (buf-evict_fence) { // 异步注册回调而不是阻塞等待 init_completion(buf-evict_comp); dma_fence_add_callback(buf-evict_fence, buf-cb, gpu_fence_callback); wait_for_completion(buf-evict_comp); dma_fence_put(buf-evict_fence); } // 安全释放物理内存 release_backing_pages(buf); return 0; }注意在evict过程中我们不持buf-lock等待completion否则就触犯了前面说的“持锁等待”禁忌。另外要检查一个竞态在注册callback之后、等待之前如果fence已经信号callback可能被立刻调用并complete这里由于we注册回调前已经持有buf-lock且callback里也试图拿同一把锁如果不小心就会死锁。实际上dma_fence_add_callback的设计允许信号后注册回调会立即调用但此时回调是在当前线程上下文执行若此时还持有buf-lock回调里再拿buf-lock就会死锁。解决办法在调用dma_fence_add_callback之前释放buf-lock。上面代码里我们在注册回调前已没再持有buf-lock所以是安全的。但需要保证buf对象在注册期间不会被析构因此调用evict_gpu_buffer时调用方已经持有该对象的引用。上面的伪代码已经可以跑通基本流程。实际驱动中还有更多细节比如处理fence已经信号时callback的同步执行路径处理被中断打断的wait等。但这些核心骨架是真实KMD代码的影子。5.3 测试与验证如何用压力测试逼出并发问题写完了同步代码一定要在并发场景下测试。我建议从三个层次来压测第一层是原子与锁的正确性。在Linux内核里可以打开CONFIG_PROVE_LOCKING也就是lockdep。它会检测锁序错误、死锁风险、在原子上下文中睡眠等问题。KMD开发环境中尽量在调试内核里开着它跑图形负载。如果你运气不好lockdep会在死锁真正发生前就报出警告能救你一命。第二层是功能级别的压力测试。用几个用户态进程同时创建/销毁大量buffer每个buffer分配不同尺寸并在多个线程里反复为同一buffer提交命令。还要模拟“提交命令后立即释放buffer”的坏行为。你可以在用户态写一个恶意测试程序持续循环做创建buffer提交draw call立刻销毁buffer看驱动会不会崩溃。真正的KMD必须优雅地处理这种情况让命令仍然能够安全执行即使资源已被销毁。这要求驱动在用户态销毁buffer时不立刻回收物理内存而是等fence信号后再回收。如果驱动崩溃多半是忘了等fence。第三层是长时间运行的稳定性测试。跑一个生成高分辨率场景的渲染循环运行数小时。同时穿插内存紧张的条件在测试过程中用memtester故意消耗系统内存迫使显存管理进入驱逐路径。这样可以暴露一些只在内存压力下才出现的竞态。我测试时习惯在循环里每几分钟触发一次全屏窗口最大化/最小化诱导上下文切换和资源驱逐。很多同步问题都要靠这样的长时间混合负载才能暴露出来。还要学会抓现场。当驱动挂起时不要急着重启。先通过串口或kdump保存寄存器状态、硬件队列的head/tail指针、所有fence的sequence与信号时间。记录下哪些fence尚未信号哪些线程在等待。这些数据能直接告诉你是不是某个fence没被正确触发还是硬件真卡住了。我会在驱动里写一个debugfs接口可以随时dump这些状态。这比事后看log有用得多。6. 关于显存同步保护我最后想补充的一些经验前面讲了很多结构、伪代码和测试方法其实我自己在实际写KMD时最大的体会是同步设计是一种“先想好后写码”的工作。拿到一个需求不要立刻开始加锁。先画一张资源状态的转换图列出每个状态迁移需要哪些条件哪些迁移会被哪个引擎的事件触发。然后检查是否有环状态A要等BB要等CC又要等A。如果有环就必须用状态机加异步回调的方式打破。再就是显存资源同步保护不能只靠一两把锁而是要靠完整的协议。这个协议包括命令提交前必须增加引用计数提交时必须记录fence释放对象必须等在最后一个fence之后驱逐操作必须进入状态机多GPU必须使用跨设备同步锁与fence绝对不能混在同一临界区。对新手来说与其到处加锁不如把这些协议做成辅助函数、封装成宏强制所有人按协议写代码。比如提交命令时强制调用一个“gpu_submit_with_ref”函数它内部自动完成引用计数与fence关联。这样即使后面的人不太理解也会因为用了这个函数而避免犯大错。我还想分享一个小技巧在KMD里打印日志本身也是一种同步操作因为printk在原子上下文里可能导致递归死锁。调试时最好用tracepoint或者专用的lockfree缓冲把日志存成一个环形数组崩溃后再导出。这个技巧在staging驱动里特别有用因为崩溃时靠printk往往来不及输出。我在代码里每个关键点都记录一条trace event包括获取锁、提交fence、信号fence、进入驱逐、完成驱逐等。出现问题后通过trace timeline就能一眼看出卡在哪个环节。显存同步保护这块内容还有很多细枝末节比如内存屏障smp_mb、dma_wmb怎么用、原子变量与stdatomic在GPU驱动中的差异、用户态同步与内核态同步的对接。不过只要掌握了我前面说的“CPU锁”和“GPU fence”两个核心工具再把锁顺序和异步等待的规则刻进脑子里大部分问题都能迎刃而解。等你真的把某一版驱动打磨到稳定、高效再回头看那些让你抓狂过的死锁和use-after-free会有一种“不过如此”的释然。但实际上这些教训才是驱动开发中最宝贵的资产。希望这篇分享能帮你少踩几个坑多留一些头发。
返回列表