☰
GPU同步与栅栏机制:从Fence到Semaphore的KMD底层逻辑
2026/10/17 9:41:51 网站建设 项目流程

其实很多人学到命令提交这里,都会产生一个疑问:命令都扔给 GPU 了,怎么知道它跑完了?数据写到内存里,CPU 这边什么时候才能安全地读?这就是本节要解决的问题。命令提交与调度里,同步与栅栏(Fence / Semaphore)机制是整个链条的“握手信号”,它决定了 CPU 和 GPU 之间、多个 GPU 引擎之间,谁先谁后、什么时候可以放心访问数据。

我先把话说在前头:Fence 和 Semaphore 这两个词,在不同 API、不同驱动层里含义有细微差别,但你只要抓住 KMD 视角的底层逻辑,它们本质上是同一件事的两个侧面:一个代表“进度值”,一个代表“等待/唤醒条件”。下面我会从最朴素的问题出发,把它们在 KMD 里的真实形态、插入点、调度流程和踩坑经验完整展开,希望能帮你把这一块彻底理顺。

1. 先想清楚一个“送分题”:CPU 和 GPU 为什么需要同步?

1.1 一个最朴素又经常出现的场景

假设你在写一个物理仿真应用,每帧把粒子位置数据刷到显存里的一个 Buffer,然后提交一个 Compute 任务让 GPU 去更新它。提交完命令,CPU 不等待,马上继续执行下一帧的逻辑,然后直接去读同一个 Buffer,想拿最新的粒子位置做碰撞检测。

你会发现:读到的是旧数据。因为 GPU 根本还没来得及执行你刚提交的命令,CPU 就已经把数据读走了。这个例子几乎是每一本图形学教材都会提的,但在驱动开发里,它不是“理论上的并发问题”,而是每天都要面对的实实在在的时序问题:CPU 的提交速度往往比 GPU 的执行速度快很多,两者天然就是异步的。

异步本身不是问题,问题在于“没有同步机制时,行为不可预测”。读旧数据还算轻的,重的情况是资源生命周期错乱——比如你已经释放了一块显存,但 GPU 还在读它,轻则画面花掉,重则驱动直接挂掉。KMD 里管这些资源生命周期和访问顺序的,就是同步机制。

1.2 如果同步机制缺失,会发生哪几件坏事

我可以列一下实际开发中最常遇见的几类故障,你要是后面写驱动遇到相似的,大概率就是同步没做对:

  • 数据竞争:CPU 改一块内存,GPU 同时也在读/写,结果不可预期。这种问题在 Debug 模式下偶尔暴露,Release 下随机复现,非常难查。
  • 过早释放资源:命令还在 GPU 队列里排队,用户态已经把 Buffer 释放了,KMD 再往这块地址上提交命令,轻则命令被丢弃,重则触发 GPU 错误。
  • 多引擎协作乱序:比如 Copy Engine 负责把数据搬进显存,Compute Engine 拿着还没搬完的数据去算,结果自然是不对的。

这些问题的根源都一样:缺少一个“进度坐标”。而 Fence / Semaphore 机制,本质上就是给 GPU 的执行进度定义一个全局可见的坐标,让每个参与方(CPU、不同的 GPU 引擎)都能查询和等待。后面我会反复围绕“进度坐标”这四个字展开,理解了它,整节内容就都串起来了。

2. Fence 还是 Semaphore:别被 API 名词带偏,KMD 里的语义没你想的那么复杂

2.1 从图形 API 视角看:什么场景用哪个对象

先从你最容易接触到的上层说起。在主流图形 API 里,同步对象通常分三档:

  • Fence:用于 CPU 等 GPU。比如vkWaitForFences到某个命令提交完成,本质上是 CPU 阻塞等待一个 GPU 侧进度值。
  • Semaphore:用于 GPU 等 GPU。比如两个 Render Pass 之间有依赖,前一个 Pass 的结束要等后一个 Pass 开始,GPU 的硬件队列之间互相等待。
  • Event:用于同一个队列内部的精细同步,粒度更细,可以插入到命令流中间。

到 DX12 里,Fence 的定位又有了变化,ID3D12Fence同时承担了 CPU-GPU 和 GPU-GPU 的同步职责。你可以看到这些 API 的名词定义并不完全统一,但一旦落到 KMD 层面,驱动真正需要实现的底层原语其实只有一类:一个带有单调递增序列号(Sequence Number)的对象,能被 GPU 信号(Signal),能被 CPU 或 GPU 等待(Wait)。至于它是叫 Fence 还是 Semaphore,更多是上层语义的问题。

2.2 二进制 Fence 与时间线 Fence:一个值得反复对比的差异

对驱动开发来说,比“Fence 还是 Semaphore”更重要的问题是“这个 Fence 是二进制的还是时间线的”。

二进制 Fence 很好理解:它就是 0 和 1 两个状态,信号一次,等待一次。每次使用完基本就废了,必须创建一个新的。这种设计对驱动来说很痛苦,因为每个提交都得伴随对象的创建和销毁,分配开销大,而且生命周期管理容易出错。老 API 时代的驱动代码里,有大量代码就是在调整这些临时对象的引用计数。

时间线 Fence(Timeline Fence)用的是一棵 64 位单调递增的树:你可以反复给它设一个更大的值,等待方也可以精确等待到某个具体的值,而不是只等一个“完成事件”。举个例子,你连续提交了 5 帧的任务,时间线 Fence 的值依次变成 1、2、3、4、5,CPU 要等第 3 帧做完,直接等这个值到 3 就行。如果是二进制 Fence,你几乎没法精确表达“我只等第 3 帧”,只能给每一帧分配一个独立的 Fence 对象。

这个差别对 KMD 设计影响巨大。现代驱动基本都围绕时间线 Fence 来设计内部同步,因为:

  1. 对象数量少,不用每帧创建销毁,减轻内存分配压力。
  2. 等待粒度精确,可以表达“等它到 N 即可”,而不是“等它完成某个对象”。
  3. 满足之后可以继续复用,同一条时间线上后续的提交天然有了先后顺序。

所以后面我讲“Fence 对象”时,默认说的都是时间线 Fence,这点先和你说清楚。

3. KMD 里同步原语的真实形态:硬件信号量 + 软件 Fence 对象的配合

3.1 硬件信号量:GPU 引擎之间握手的专用通道

先看硬件侧。GPU 里面同时跑着多个引擎(3D、Compute、Copy、Video 等),引擎之间要握手,不能每次都经过 CPU 中转,那样太慢了。大多数 GPU 架构在硬件层面提供了一个机制:引擎可以执行一条特殊的指令,去等待/写入一个内存地址上的值。这个内存地址就被称作“硬件信号量”。

你可以把它想象成一面旗子:引擎 A 执行完某段任务后,把一个特定地址的值从 0 改成 1;引擎 B 在开始自己的任务前,先执行一条“等待该地址变成 1”的指令,到了才往下走。整个过程完全在 GPU 侧自动完成,CPU 不需要参与。

在命令流里,KMD 会在需要同步的位置插入类似这样的伪指令:

// 在引擎 B 的命令流中 SEMAPHORE_ACQUIRE addr = 0xXXXX, value = 1 // 引擎 B 会阻塞在这里,直到 addr 的值 >= 1 USER_COMPUTE_COMMANDS ...

这个机制非常高效,因为它是专门的硬件逻辑在轮询(或者有更高级的信号机制),不占用 GPU 核心的计算资源。KMD 要做的就是把这些硬件语义封装好,对外提供一个“等待”接口。

3.2 软件 Fence 对象:CPU 看到的进度值

硬件信号量只能解决 GPU 到 GPU 的同步,但 CPU 怎么知道 GPU 执行到哪了?答案是通过软件 Fence 对象来承载硬件进度。

KMD 里,每个命令提交通常都会绑定一个 Fence 对象,这个对象的核心是一个 64 位的seqno(Sequence Number),它表示“该提交以及它之前的所有提交已经执行完成”的进度。GPU 在硬件命令流里可以主动写一个值到某个约定地址,或者触发一次中断,KMD 的中断处理程序再去更新对应的软件 Fence。

这里有个很关键的设计:硬件写值(比如在显存里写入 seqno)和软件对象更新(KMD 内存里的 seqno)必须保持同步。硬件可以把 seqno 直接写到系统内存的某个地址上,软件 Fence 对象直接指向那块地址,这样 CPU 查询时不需要经过中断就能读到最新值。这种优化在驱动里很常见,可以极大降低确认完成的时延。

3.3 一个简化版 Fence 数据结构长什么样

我先给一个教学性质的简化结构,不要照抄代码,但足以说明核心字段:

struct kmd_fence { u64 seqno; // 当前已完成的序列号(硬件或者中断更新的) u64 signaled_value; // 已经向等待方广播过的值 spinlock_t lock; // 保护等待队列 struct list_head waiters; // 等待者列表(进程/线程) struct kmd_hw_queue *hwq; // 对应的硬件队列 atomic_t refcount; // 引用计数,避免生命周期问题 };
  • seqno是“目标值”,GPU 正在向这个值前进;
  • signal_value是“已通知值”,记录上次向等待方广播到哪了;
  • waiters链表挂载着所有等待这个 Fence 的上下文,比如睡眠中的进程。

当 KMD 收到 GPU 完成中断后,会走到信号化函数里,把seqno更新,然后遍历waiters,把所有等待值不大于当前seqno的等待者唤醒。整个流程的核心其实就是:

  1. 查进度值;
  2. 若未达到目标,把当前上下文挂到等待队列;
  3. 等硬件推进到目标值后,唤醒挂起的上下文。

这个机制我们在第 4 节还要完整过一遍。

4. 一次完整提交中的 Fence 插入点:从 UMD 入口到 GPU 命令流末尾

4.1 UMD 提交时先分配 Fence 并建立关联

我把前面几节的内容串起来,完整走一遍。当应用调用某个 API 提交命令时,用户态驱动(UMD)会做不少事情:把 API 调用翻译成命令缓冲区(Command Buffer),填写一堆描述结构体,最终通过系统调用(比如 ioctl)进入 KMD。

在 KMD 收到这个提交时,第一步往往是从该上下文的时间线里取出一个新的 seqno,并创建一个新的 Fence 对象,同时把这个 Fence 和命令缓冲区、目标硬件队列关联起来。这个 seqno 不是随便定的——它通常由上下文内全局递增的计数器分配,保证同一上下文中提交顺序和 seqno 顺序一致。

这一步看似简单,但有一个常见坑:如果 UMD 一次提交了多个命令缓冲区,驱动到底是给整个提交一个 Fence,还是每个缓冲区一个 Fence?经验做法是按提交批次分配一个 Fence,而不是按缓冲区个数分,否则 Fence 数量爆炸,中断和唤醒次数也跟着上涨,性能就难看了。

4.2 在命令流末尾插入 Signal 操作,让 GPU 主动写进度

Fence 分配好之后,KMD 要往命令流里塞一个特殊的“信号操作”,保证 GPU 执行完用户的命令缓冲区后,会主动更新进度值。这个操作在不同架构里叫法不同,但语义一致:把某个 seqno 写入指定的内存地址,或者触发一次中断。

比如命令流的布局大致是:

// 用户命令缓冲区内容 USER_COMMAND_1 USER_COMMAND_2 ... // KMD 自动追加的信号操作 GPU_SIGNAL addr = fence_mem_addr, value = N

这个fence_mem_addr是 KMD 预先分配的一块内存,软件 Fence 对象直接映射到这块地址。GPU 执行到末尾时,写一个值 N 进去。之后 CPU 查询时,只需要读这个地址,不需要额外中断,就能知道自己提交的这批命令已经执行完了。

有一点必须提醒:信号操作追加的顺序很关键,它必须在用户命令之后,而不是之前。如果在用户命令前就写了 signal,那信号值会被 GPU 提前推进——后续依赖它的任务就会提前开始,数据还没算完,结果就是各种诡异的错帧。

4.3 CPU 等待的两种姿势:轮询与中断睡眠

CPU 侧等待 Fence,驱动通常有两种策略:

  • 忙等待(Polling):循环读 Fence 的内存地址,直到值达标。优点是时延低,适合那种预计很快完成、又不能忍受上下文切换开销的场景;缺点是持续占 CPU。
  • 睡眠等待(Blocking):进程把自己挂到 Fence 的等待队列,让出 CPU,等 GPU 完成中断或回调来唤醒。优点是省 CPU;缺点是中断到唤醒存在调度时延,通常在微秒到几十微秒量级。

生产级驱动往往两种都混合用:先忙等极短时间(比如 3~5 微秒),如果还没完成再转入睡眠。这样既避免了大多数短任务的调度开销,又不会在长任务上白白烧 CPU。

让我用一张简单对照表总结下两者的取舍:

策略优点缺点适用场景
忙等待时延最低,无调度开销占用 CPU 核预计短时间完成、且 CPU 核充裕的场景
睡眠等待省 CPU,可扩展性好可能引入调度时延长任务、大量线程同时等待
混合模式兼顾短/长任务实现复杂度高大多数 KMD 的实际选择

5. 时间线驱动:Seqno 与依赖图的连线

5.1 时间线 Fence 里的“值”到底是什么

接着第 2 节的话题往下说。时间线 Fence 最大的价值是让同步语义从“完成与否”变成“完成到哪一步”。在 KMD 里,这条时间线的进度值通常是上下文级别的全局 seqno,每个提交都消耗一个 seqno,GPU 每完成一个提交,时间线就向前推进一个数值。

举个例子,某上下文时间线初始值为 0,UMD 连续提交了三次命令,KMD 分别为其分配 seqno = 1、2、3。假设当前时间线的signal_value = 2,表示前两次提交都完成了,第三次还在执行或排队。此时 CPU 想等第三次提交完成,只需要等待时间线的值变成 3 即可。

这个模型的好处是:等待方知道确切的进度,驱动不需要建立单独的一次性同步对象。同时,多个后续命令可以在同一条时间线上排队,天然形成 pipeline 依赖。

5.2 从 API 层的 Fence 翻译到 KMD 内部的 seqno 匹配

上层 API 的 Fence 等待动作,落到 KMD 时通常会被翻译成如下的逻辑:

// 伪代码:等待某个上下文时间线的 seqno 达到 N int kmd_wait_fence(struct kmd_context *ctx, u64 target_seqno, long timeout, bool interruptible) { struct kmd_fence *fence = ctx->timeline_fence; // 先快速检查一次 if (fence->seqno >= target_seqno) return 0; // 准备把当前进程挂到 waiters 链表 spin_lock(&fence->lock); // ... 检查是否已经到 target_seqno ... if (timeout == 0) return -EAGAIN; current_state = TASK_INTERRUPTIBLE; if (interruptible) schedule_timeout(timeout); else schedule(); spin_unlock(&fence->lock); // 被唤醒后再次检查 return fence->seqno >= target_seqno ? 0 : -ETIME; }

这个代码逻辑非常朴素,但它是整个同步机制的骨架:快速检查一次、挂起、唤醒后再检查一次。中间的等待状态管理、超时处理、信号中断处理,才是真正复杂的部分。

很多驱动在超时处理上都有自己的花样,比如支持tdr(超时检测与恢复)——如果 Fence 迟迟不推进,不一定是任务慢,可能是 GPU 已经 hang 了。KMD 会启动看门狗逻辑,超过阈值就触发 GPU 重置流程,而不能无限期等下去。这一块我放到第 7 节展开。

5.3 多引擎依赖:在命令流里放一个“伪 semaphore”就够了

再把视角扩展到多引擎。假设 Copy Engine 要把一块数据搬进显存,搬完后 Compute Engine 才能开始算。应用层会用 API 的 Semaphore 在两个队列之间建立依赖。落到 KMD 层,做法通常是:

  1. 给 Copy Engine 的提交分配一个 Fence;
  2. 在 Compute Engine 的命令流开头插入一条硬件信号量的等待指令,等待地址指向 Copy 引擎的 Fence 内存地址;
  3. 当 Copy 引擎执行到末尾时,它写入这个地址,把值从 0 变为 1(或者写入 seqno);
  4. Compute 引擎发现条件满足,自动继续执行。

从这个角度看,Semaphore 本质上是:一个 Fence 的进度,被另一个引擎以硬件指令的方式等待。CPU 参与不参与,决定它是变成纯 GPU 同步还是 CPU-GPU 同步。所以你不用把 Semaphore 想成多么神秘的东西,它就是同步机制在不同参与者之间的一种使用方式。

6. 多队列调度里的同步价值:调度器眼中的等待与唤醒

6.1 任务在调度器中的状态流转

KMD 里面通常有一层 GPU 调度器(Scheduler),它的核心是为每个硬件队列维护一个软件任务队列,并在合适的时候把任务推送到硬件。任务状态一般包括:

  • Pending(排队中):任务还在软件队列里,等待被提交;
  • Running(运行中):任务已推送到硬件,GPU 正在执行;
  • Completed(完成):任务对应的 Fence 已信号化,可以释放相关资源了。

同步机制在调度器里的典型作用就是:当任务带有“等待条件”时,调度器不能直接把它变成 Running。比如上面那个 Compute 任务依赖 Copy 的 Fence,调度器在扫描排队任务时,会先判断依赖的 Fence 是否已完成。如果未完成,该任务会一直留在 Pending 状态,调度器会去尝试执行其他没有依赖的任务,而不是死等这一个。

这个“不阻塞单个任务就阻塞整个队列”的设计,是把 GPU 利用率做高的关键。否则一个等待中的任务会把整条硬件队列堵死,后面的任务全部被卡住,效果和 GPU hang 没什么区别。

6.2 等待阻塞时,调度器在忙什么

有人可能会问:如果任务卡在 Pending 等依赖,调度器自己要不要阻塞?答案是:不能阻塞。调度器是一个独立的内核线程(或者一组线程),它会持续遍历所有队列,寻找可以推进的任务。只有这样,即使某条队列上的一个任务在等一个长时间的 Compute 任务时,其他队列上的轻量 Copy 任务仍能被执行。

同时,调度器还会做另一件事:当某个任务完成并信号化 Fence 后,它会主动唤醒所有等待这个 Fence 的任务或进程。这个唤醒动作通常是通过回调机制实现的,Fence 信号化时会调用预先注册的 callback,去调度器里把受影响的 Pending 任务重新标记为可执行。

这种“异步推进 + 回调唤醒”的机制,是我在调 KMD 调度器时觉得最优雅的一处:同步对象不再是被动等待,而是主动通知。任务不会反复被轮询,消耗的 CPU 也低。

7. 排查 Fence 问题时的几条实战经验与避坑指南

7.1 最典型的故障:GPU 卡死时 Fence 的表现

如果你在调试某个新功能时发现整个系统画面僵住,或者窗口管理器开始报告驱动超时,十有八九是同步出了问题。最常见的表象是:某个 Fence 的 seqno 长时间不前进。

我自己的排查流程一般是这样:

  1. 先找到卡住的硬件队列,导出所有 pending 任务的 Fence 信息,包括分配的 seqno 和目标 seqno;
  2. 看信号化的 Fence 地址上的实际值,如果 GPU 已经写入了某个值,但软件 Fence 没有更新,问题可能出在中断处理链路;
  3. 如果硬件地址的值也没有前进,再看命令流是否已经推送到 GPU,如果推了但没执行,多半是硬件 hang 了。

在这些排查中,Fence 的 seqno 单调性会帮你快速定位:哪条时间线不推进,问题就在哪个队列附近。这个办法比盲目翻寄存器靠谱得多。

7.2 三四个高频 Fence 相关的坑

我把自己和团队在开发中踩过的几个高频坑分享给你,提前避开能省很多调试时间:

  • Fence 生命周期管理不当:这是个经典问题。Fence 对象被释放,但 GPU 命令还在跑,硬件信号量写到一个已回收地址,数据被破坏,后续什么错都可能出现。别管你引用了多少层,只要命令没完成,Fence 就不能释放。
  • 等待值设错:等待一个比当前值大的 seqno 没问题,但如果你等的是根本不存在的 seqno(比如分配时漏算了一帧),那就会永久挂起。加个看门狗或者超时机制吧,不要裸等。
  • 忙轮询烧 CPU:有些场景把轮询循环写得太紧,没有做短暂的停顿,结果一个等待过程占满了 CPU 核心。记得做有界等待,比如在轮询循环里加udelay或者让出调度。
  • 中断唤醒路径漏了回调:Fence 信号化后,除了唤醒进程,还要触发调度器的回调。漏掉这个,就会出现“明明完成了,但任务还卡在 Pending”的假死状态。

7.3 生产环境里的一个调节技巧:中断聚合

最后说一个实用技巧。GPU 的完成中断不是每完成一个 Fence 就触发一次的,否则高帧率场景下中断风暴会吃掉不少 CPU。现代驱动普遍采用中断聚合:多个 Fence 完成后再统一触发一次中断,中断处理程序一次性推进一批 Fence 的 seqno,并唤醒相关等待者。

代价是单次 Fence 从完成到 CPU 感知的时延会变高,但对吞吐型场景来说完全划得来。如果你在调一个高帧率渲染的驱动,可以把这个聚合的阈值调低一点以换取更低时延;如果是跑机器学习训练这种长任务,阈值调高一点能大幅降低 CPU 占用率。这里面没有一个万能参数,完全取决于工作负载的特征。

老实说,同步与栅栏机制是整个命令提交链路里最容易出错、也最能体现驱动功底的地方。很多看起来像是随机出现的问题,最后都能追溯到某个 Fence 的 seqno 没有按预期推进。只要把“进度值 + 等待队列 + 信号化路径”这三个核心吃透了,后面再去看多 GPU 同步、上下文切换、抢占式调度,就会发现思路基本是一样的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询