跨队列同步工程 2026:时间线信号量与队列拓扑取舍
跨队列同步不是加一个 async compute 开关,而是把队列族拓扑、单调计数器信号量、present 侧一次性原语与资源所有权转移显式建模成可编译、可环检测、可量化的调度问题。本文用 Vulkan 规范、D3D12 与 Metal 官方文档、UE5 RDG 源码逐条对照,并给出四组自算模型:理想双队列拓扑省下 38.71% 帧时间,每条跨队列边的同步开销盈亏平衡点是 1.13 毫秒,而三段交接的随机提交顺序只有 0.14% 安全,所以等待图环检测是必需品而非加分项。
约 66 分钟阅读19,590 字0 次阅读博主

跨队列同步工程 2026:时间线信号量与队列拓扑的取舍
引擎渲染管线里最容易被当作「平台细节」的两件事,其实是决定帧时间上限的第三条总线:一条是显存总线,一条是 PCIe,第三条是 GPU 内部的几条队列。绝大多数自研渲染框架只在初始化时打印一次「我们支持 async compute」,然后把剩下的复杂度全部塞给美术和 TA 去承担:某个 pass 莫名其妙没有跑、某个后处理结果比上一帧晚一帧、某个资源在某些驱动上花屏。根因几乎总是同一个——跨队列同步没有被当作一等公民来建模。
这篇文章只做一件事:把「队列拓扑」和「跨队列同步原语」这两件事拆开、量化、给出可落地的工程决策树。范围限定在 GPU 提交层:队列族(queue family)、信号量(semaphore)、栅栏(fence)、所有权转移(ownership transfer)、以及 present/acquire 环节。渲染算法本身(阴影、GI、后处理)不在讨论范围内,那是另一层问题。
一、为什么队列拓扑是隐藏的帧时间上限
先看一个具体问题:你的渲染管线里,SSGI(屏幕空间全局光照)跑在 compute 队列上,主光照在 graphics 队列上,SSGI 的输出要喂给主光照。这意味着一次跨队列依赖。
朴素实现是:graphics 队列 submit 一个空批次等待 compute,compute 队列 submit compute pass 并 signal,graphics 队列再 submit 主光照。看起来只有一次「交接」,但这一交接把两条队列强行串行化了:SSGI 的耗时被完整地加在了帧时间上。正确的做法是让 SSGI 与 shadow map 生成并行——shadow 是纯 graphics,SSGI 是纯 compute,两者无依赖,可以完全重叠;只有「主光照依赖 SSGI」这一步必须等。
也就是说:跨队列交接本身不花钱,花钱的是你为了做交接而引入的串行化。 这是一个调度问题,不是同步原语问题——但调度问题的解法落在同步原语上,因为没有正确的原语你根本无法表达「我要等 SSGI 的第 N 帧输出,但这一帧的 shadow 可以先跑」。
再叠一层:present。Vulkan 的 vkQueuePresentKHR 明确要求 pWaitSemaphores 里的所有元素都是 VK_SEMAPHORE_TYPE_BINARY(VUID-vkQueuePresentKHR-pWaitSemaphores-03267)。也就是说时间线信号量不能直接用来等 present。很多团队在设计跨队列同步方案时,正是想「一套时间线信号量打天下」,结果在 present 环节被规范硬拦下来。这不是文档没读,是 API 形状本身就不允许——Apple 的 Metal 文档在 MTLSharedEvent 上给出的措辞可以对照着看:MTLSharedEvent 用来「同步一个或多个资源上的内存操作,跨多个 CPU、GPU 和进程」,但 MTLEvent 协议继承自 MTLSharedEvent 协议的同时被限制为「只能同步运行在单个 Metal 设备上的内存操作」。
所以工程现实是:每个平台都保留了「一次性、独占、不可复用」的二值原语,用来接 present;而真正需要复用的、跨帧的、需要多次等待的同步,只能靠计数器型原语。 这两者在 Vulkan 里被统称 semaphore,靠 VkSemaphoreTypeCreateInfo::semaphoreType 区分;在 D3D12 里则表现为 fence(单调递增的整数值)承担 timeline 角色、而 present 侧另走一套;Metal 用 MTLCommandQueue 的入队顺序 + MTLSharedEvent 承担跨设备角色。三家的形状不同,但工程约束惊人地一致:present 环节必须用一次性原语,帧内复用必须用计数器原语。
下面先把 Vulkan 这套最完整的规范读完,再横向对比 D3D12 和 Metal,最后落到引擎层(UE5 RDG 的 lane 标注、Unity 的 GraphicsFence)和自算模型上。
二、队列族:能力位、硬约束与两个被忽略的字段
Vulkan 的队列模型分两层:队列族(queue family) 是能力的分类,队列(queue) 是族内的实例。先看族的结构体,这是 vkGetPhysicalDeviceQueueFamilyProperties 返回的东西(1.1 之后推荐 vkGetPhysicalDeviceQueueFamilyProperties2,行为类似但可以通过 pNext 链拿到扩展信息):
typedef struct VkQueueFamilyProperties {
VkQueueFlags queueFlags; // 能力位
uint32_t queueCount; // 族内队列数量
uint32_t timestampValidBits; // 时间戳有效位宽
VkExtent3D minImageTransferGranularity; // 图像传输最小粒度
} VkQueueFamilyProperties;
能力位是位掩码,规范里枚举得很清楚:VK_QUEUE_GRAPHICS_BIT = 0x1(支持图形操作)、VK_QUEUE_COMPUTE_BIT = 0x2(支持计算操作)、VK_QUEUE_TRANSFER_BIT = 0x4(支持传输操作)、VK_QUEUE_SPARSE_BINDING_BIT = 0x8、VK_QUEUE_PROTECTED_BIT = 0x10、VK_QUEUE_VIDEO_DECODE_BIT_KHR = 0x20、VK_QUEUE_VIDEO_ENCODE_BIT_KHR = 0x40,以及厂商扩展位 VK_QUEUE_OPTICAL_FLOW_BIT_NV = 0x100、VK_QUEUE_DATA_GRAPH_BIT_ARM = 0x400。
关键在于:这些位是「族」级别的,不是「队列」级别的。 一个族里的所有队列共享同一组能力。所以「我有一个 compute queue 和一个 graphics queue」这句话在 Vulkan 里必须改成「我有一个支持 graphics 的族里的队列 A,和一个支持 compute 的族里的队列 B」——如果同一个族同时带 GRAPHICS 和 COMPUTE 位,那这两个队列在能力上完全等价,把它们当成两条「不同队列」来调度只是在自欺欺人。
queueCount 是第二个常被忽略的字段。规范要求每个队列族至少支持一个队列,但很多硬件在一个族里给了 2~4 个实例。这意味着你可以从同一个族里取两条队列,它们能力相同、彼此之间没有任何隐式顺序保证——规范原话是「Command buffer submissions to a single queue respect submission order and other implicit ordering guarantees, but otherwise may overlap or execute out of order」。换句话说,同族的两条队列之间,提交顺序不构成执行顺序,你必须显式同步。
第三个字段 timestampValidBits 容易被忽略但直接影响 profiling:规范写明「the valid range for the count is 36 to 64 bits, or a value of 0, indicating no support for timestamps」,并且「Bits outside the valid range are guaranteed to be zeros」。含义是:如果驱动报 36 位有效,那么时间戳计数器的低 36 位之外的值是被清零的,你在做跨队列时间差计算时,不能假设两族队列的时间戳是同一个全局时基。
第四个字段 minImageTransferGranularity 是所有「哪些图像传输算原子操作」判断的基准,规范里定义了 (0,0,0) 表示「只有整个 mip 级别才必须用图像传输操作来传输」,并对应一大堆 offset/extent 的限制。这一条在跨队列复制大纹理时才会撞上:如果 A 队列写了部分 mip、B 队列要读,你必须按这个粒度切分操作,否则会遇到未定义行为。
建队列的入口是 VkDeviceQueueCreateInfo:
typedef struct VkDeviceQueueCreateInfo {
VkStructureType sType;
const void* pNext;
VkDeviceQueueCreateFlags flags;
uint32_t queueFamilyIndex;
uint32_t queueCount;
const float* pQueuePriorities; // 每条队列一个优先级
} VkDeviceQueueCreateInfo;
pQueuePriorities 是浮点数组,长度必须等于 queueCount。注意它的语义边界:优先级只在同一个族内有意义(决定这个族内部哪条队列先被服务),跨族之间没有可比性。指望「把 compute 队列优先级设高一点就能让它先跑」是理解错了——跨族的先后完全由信号量的等待关系决定,与优先级无关。
取句柄有两个函数,区别在于标志:vkGetDeviceQueue 只能取「创建时 flags 为 0」的队列,带创建标志的要用 vkGetDeviceQueue2(配 VkDeviceQueueInfo2)。规范在 vkGetDeviceQueue 的有效使用条件里写明 VkDeviceQueueCreateInfo::flags must be zero when device was created,用错了会直接触发校验层报错。
一个实用的工程结论:队列族的划分不是你能选的,是硬件定的,但「哪些 pass 放到哪个族」是你的调度器要暴露的抽象。 引擎层应该让作者表达「这个 pass 是 raster / compute / copy」的意图,而不是让作者去查 queueFamilyIndex——后者会把平台差异泄漏到业务代码里。
三、二进制信号量的排他性陷阱
先讲清楚为什么二值信号量在跨队列场景下「看起来能用、实际必炸」。规范对二值信号量有一组非常具体的有效使用条款:
VUID-vkQueueSubmit-pSignalSemaphores-00067:作为pSignalSemaphores元素的每个二值信号量,在其信号操作在设备上执行时必须处于未置位状态。VUID-vkQueueSubmit-pWaitSemaphores-00068:当某个队列上针对二值信号量的等待操作执行时,不允许有其它队列在等待同一个信号量。VUID-vkQueueSubmit-pWaitSemaphores-03238(1.2/2.0 的vkQueueSubmit2对应VUID-vkQueueSubmit2-semaphore-03873):pWaitSemaphores里的每个二值信号量必须引用一个已经提交执行的信号操作,并且它所依赖的信号操作也都必须已经提交。
这三条合起来构成了一个非常苛刻的约束模型,可以概括成一句话:二值信号量是「一次性资源」——被 signal 一次、被 wait 一次,用完必须回收,而且不能有第二个等待者。
这条约束在实践中制造了三类典型事故:
第一类,帧内复用。你写了这样的代码:每帧 vkQueueSubmit 时都把同一个 frameSemaphore 放进 pWaitSemaphores。第一帧正常,第二帧触发 00067 或 03873——因为上一帧的信号操作可能还没被执行,或者执行了但同一信号量被重复 signal。
第二类,多等待者。你的 compute 队列上跑两个 pass 都等同一个信号量,00068 直接命中。这类 bug 在单队列下永远不会触发,因为规范只约束跨队列的等待者数量——所以它极可能在开发机上跑一整天不出问题,一上多队列架构就炸。
第三类,提交顺序倒置。03238 要求被 wait 的信号量其信号操作必须已经提交。如果你在 CPU 侧先 submit 了「等待 compute」的批次、后 submit 了「compute 工作」,那就违反了这条——即使从逻辑上看后者应该先跑。
要正确使用二值信号量,你必须维护一个池:每条跨队列边、每个在飞帧各一个信号量对象,signal 之后交给消费者 wait,消费者 wait 之后归还池子。这就是「二值信号量池」的全部工程内容。
按模型 A 的口径算一笔账:一个典型现代帧图有 48 个 pass,其中跨 graphics/compute 边界的依赖有 11 条;在三缓冲(三帧在飞)的配置下,二值信号量池需要 11 × 3 = 33 个对象,而时间线信号量方案只需要 2 个对象(每个队列族一个,外加一个给 present 侧的桥接)。对象数量差 16.5 倍。
再看主机侧 API 调用次数:二值方案每帧每条边一次 signal + 一次 wait,11 × 2 = 22 次;时间线方案每帧每族一次 signal + 一次 wait,2 × 2 = 4 次,节省 81.8%。在 60fps 下这是每秒 1320 次 vs 240 次 vkQueueSubmit 参数填充。对一个 CPU 侧提交预算只有 2~3 毫秒的引擎来说,这个差值不是「优化」而是「决定你能不能开多队列」。
四、时间线信号量:把排他换成单调计数
时间线信号量是 Vulkan 1.2 的核心特性(也有等价的 VK_KHR_timeline_semaphore 扩展)。规范对它的定义一句话说清:「Timeline semaphores have a strictly increasing 64-bit unsigned integer payload and are signaled with respect to a particular reference value.」
四个关键性质,每一个都直接对应二值信号量的一个痛点:
其一,严格单调的 64 位载荷。 不再是「置位/未置位」,而是一个只会增加的整数。等待语义变成「等到值 ≥ V」。这一下就消掉了「谁先 signal」的问题:signal(V=100) 和 wait(V=100) 的配对是显式的数值关系,不依赖提交顺序的隐式约定。
其二,可以从主机侧 signal 和 wait。 规范原文:「A timeline semaphore can additionally be signaled from the host with the vkSignalSemaphore command and waited on from the host with the vkWaitSemaphores command.」这意味着「CPU 等待 GPU 完成到第 N 帧」不再需要一个独立的一次性 fence,而是直接等时间线值。相关结构体是 VkSemaphoreWaitInfo { flags; semaphoreCount; pSemaphores; pValues; },flags 里 VK_SEMAPHORE_WAIT_ANY_BIT = 0x1 表示「只要 pSemaphores 中任意一个达到指定值即可」——注意它放宽的是多个信号量之间的等待,不是同族多等待者,二值信号量的 00068 依然适用。
其三,没有 reset。 这是扩展设计问答里被明确写死的一条:「Does the new synchronization primitive allow resetting its payload? RESOLVED: No, allowing the payload value to 'go backwards' is problematic. Applications looking for reset behavior should create a new instance of the synchronization primitive instead.」所以时间线信号量不能「回卷」,你只能一直递增它。这不是缺陷——递增本来就是它的语义。
其四,死锁责任在应用。 同段问答:「Does the new synchronization primitive have the same signal-before-wait requirement as the existing semaphores do? RESOLVED: No. Timeline semaphores support signaling and waiting entirely asynchronously. It is the responsibility of the application to avoid deadlock.」时间线信号量放弃了「先 signal 后 wait」这条保护带,换来的是完全异步的灵活性。死锁从「API 帮你挡住」变成「你的调度器必须自己保证」——这是本篇后面要专门建模的部分。
创建时通过 VkSemaphoreTypeCreateInfo 挂进 VkSemaphoreCreateInfo::pNext:
typedef struct VkSemaphoreTypeCreateInfo {
VkStructureType sType;
const void* pNext;
VkSemaphoreType semaphoreType; // VK_SEMAPHORE_TYPE_BINARY / _TIMELINE
uint64_t initialValue; // 仅 TIMELINE 有效
} VkSemaphoreTypeCreateInfo;
规范里两条有效使用条款值得抄进代码规范:VUID-VkSemaphoreTypeCreateInfo-timelineSemaphore-03252 要求启用 timelineSemaphore feature 才能用 VK_SEMAPHORE_TYPE_TIMELINE;VUID-VkSemaphoreTypeCreateInfo-semaphoreType-03279 要求类型为 BINARY 时 initialValue 必须为 0。顺带一句:如果 pNext 链里没有 VkSemaphoreTypeCreateInfo,创建出来的就是默认的 BINARY 类型——这是「我明明写了 timeline 却没生效」最常见的原因。
提交侧的对应结构体是 VkSemaphoreSubmitInfo { semaphore; value; stageMask; deviceIndex; }(vkQueueSubmit2)与旧接口的 VkTimelineSemaphoreSubmitInfo { pWaitSemaphoreValues; pSignalSemaphoreValues; }(vkQueueSubmit)。这里有三条必须知道的条款:
VUID-VkSubmitInfo-pNext-03240:pNext链里包含VkTimelineSemaphoreSubmitInfo时,waitSemaphoreValueCount必须等于waitSemaphoreCount;03241 对 signal 同理。也就是说你不能对一部分信号量用时间线值、另一部分不用。VUID-VkSubmitInfo2-semaphore-03881:同一个信号量同时出现在pSignalSemaphoreInfos和pWaitSemaphoreInfos时,signal 的value必须大于 wait 的value。VUID-VkSubmitInfo2-semaphore-03882:signal 的value必须大于该信号量当前值;03883 进一步规定 signal 的值与「当前值或任何未完成的信号操作的值」的差不得超过maxTimelineSemaphoreValueDifference。
最后这条常被忽略。maxTimelineSemaphoreValueDifference 是 VkPhysicalDeviceVulkan12Properties 里的一个 uint64 字段,规范解释为「the maximum difference allowed by the implementation between the current value of a timeline semaphore and any pending signal or wait operations」。也就是说,驱动允许你「预支」多大的数值差。典型实现会给一个很大的值,但你在设计时不能假设它是无限的。
按模型 E 算一下这个上限的实际意义:64 位计数器在 60 次 signal/秒下够用 9.742e9 年;三条队列每帧各 signal 一次(180 次/秒)还有 3.247e9 年;即使是 debug 构建的 1000fps × 3 队列(3000 次/秒)也有 1.948e8 年。所以「计数器会绕回」在实践中不是问题。真正会撞上的是驱动那个 maxTimelineSemaphoreValueDifference 限制:如果某实现把它设成 2^32 - 1,在 180 次/秒的信号频率下相当于 2.386e7 秒、23860929 帧的余量——同样多到不用担心。结论是:数值上限不是工程约束,跨队列的调度正确性才是。
五、所有权转移:资源归属的两次握手
当一个资源要在两条队列之间换主人时,除了「等对方做完」之外,还有一次额外的、容易被忽略的步骤:所有权转移(queue family ownership transfer)。
规范里这套机制的骨架是这样的:资源在某个时刻归属于一个队列族;要换族时,必须先在源族执行一次释放操作(release operation),再在目标族执行一次获取操作(acquire operation),两者参数必须匹配。
规范原文给了三条硬约束:
VUID-vkQueueSubmit2-commandBuffer-10910:包含获取操作的命令缓冲,其资源必须存在一个先于该获取操作提交的、参数匹配的释放操作。VUID-vkQueueSubmit2-commandBuffer-03880:在最后一次匹配的释放操作与获取操作之间,该资源不得以任何方式被修改。- 释放与获取之间必须满足 happens-before 关系,规范补充:「Often, semaphores are used to enforce this ordering.」
还有一条非常容易踩的实操细节。规范在解释释放/获取屏障的访问掩码时写:「Whilst it is not invalid to provide destination or source access masks for memory barriers used for release or acquire operations, respectively, they have no practical effect. Access after a release operation has undefined results... Similarly, write access before an acquire operation will produce undefined results for future access... In an earlier version of the specification, these were required to match on both sides - but this was subsequently relaxed. These masks should be set to 0.」
也就是说,很多从旧教程抄来的代码会在 release 屏障里写 srcAccessMask = VK_ACCESS_SHADER_WRITE、在 acquire 屏障里写 dstAccessMask = VK_ACCESS_SHADER_READ。这不会报错(规范说 invalid 不会、只是没实际效果),但它掩盖了一个真实问题:你没有把可见性操作(vkCmdPipelineBarrier 里的 memory barrier)和所有权转移分开写。规范推荐用 VK_DEPENDENCY_QUEUE_FAMILY_OWNERSHIP_TRANSFER_USE_ALL_STAGES_BIT_KHR 依赖标志,此时 srcStageMask 对所有权转移屏障会被忽略。
同步二(VK_KHR_synchronization2 / Vulkan 1.3)把这套统一进了 vkCmdPipelineBarrier2:
VkDependencyInfo dep = {};
dep.memoryBarrierCount = 1;
dep.pMemoryBarriers[0] = {
.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER_2,
.srcStageMask = VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT,
.srcAccessMask = VK_ACCESS_2_NONE,
.dstStageMask = VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT,
.dstAccessMask = VK_ACCESS_2_NONE,
};
vkCmdPipelineBarrier2(cmd, &dep);
这里 VK_ACCESS_2_NONE 是对的,不是懒。原因就是上面引的那句:释放屏障的 dstAccessMask、获取屏障的 srcAccessMask 应该是 0,可见性由同一批次里的另一个 memory barrier 承担。混淆这两件事会导致「偶尔读到脏数据」这种只在特定驱动上复现的灵异 bug。
还有一个共享模式相关的条款值得记:VUID-VkBufferMemoryBarrier-None-09050/09051 规定,未启用 synchronization2 时,若 buffer 以 VK_SHARING_MODE_CONCURRENT 创建,则 srcQueueFamilyIndex / dstQueueFamilyIndex 至少有一方必须是 VK_QUEUE_FAMILY_IGNORED(另一方可以是 VK_QUEUE_FAMILY_EXTERNAL)。这条看起来是给跨进程共享留的口子,但工程上更常见的用法是:用 CONCURRENT 模式创建的资源,在所有队列上都不做所有权转移,代价是没有独占模式的性能优化。
所有权转移是跨队列方案里最容易「看起来能跑但语义不对」的一环。它不产生错误结果,只产生未定义行为或偶发花屏,而且往往只在特定驱动 + 特定资源布局下暴露。工程上的唯一可靠做法是:让引擎的调度器自动插入释放/获取配对,绝不让业务代码手写——因为手写时你几乎一定会漏掉「中间不得修改」这一条。
六、acquire 与 present:同步原语的盲区
同步原语体系里有一段是规范明确留白的,而这段留白恰好是每帧必经的环节。
先看 Vulkan 扩展设计问答里的原始表述:「Can the new synchronization primitive be used to synchronize presentation and swapchain image acquisition operations? RESOLVED: Some implementations may have problems with supporting that directly, thus it is not allowed in this extension.」
落到具体条款:VUID-vkQueuePresentKHR-pWaitSemaphores-03267 要求 pPresentInfo 的 pWaitSemaphores 里每个元素都是 VK_SEMAPHORE_TYPE_BINARY;VUID-vkQueuePresentKHR-pWaitSemaphores-01294 规定当这个二值信号量的等待操作在队列上执行时,不允许其它队列等待同一个信号量。同一组 VUID 在 vkQueueSubmit2 时代被同步为 03871 / 03868。
结论很硬:present 必须吃二值信号量,且这个信号量只能被 present 这一次等待。 你的时间线信号量体系到 present 边界必须做一次「降级转换」:compute 队列 signal 一个二值信号量,graphics 队列把它 signal 给 present,或者反过来用时间线值驱动一个专门的一次性桥接。
这里还有一个容易被忽略的规范细节,来自 vkQueuePresentKHR 的描述段:「There is no requirement for an application to present images in the same order that they were acquired - applications can arbitrarily present any image that is currently acquired.」也就是说 acquire 顺序和 present 顺序不保证一致,你的引擎不能假设「第 N 次 acquire 的图就是第 N 次 present 的图」。这一条对多帧在飞的延迟隐藏逻辑有直接影响:如果你用「帧序号」去索引 swapchain image,就得接受 present 可能乱序。
另一侧是 acquire。vkAcquireNextImageKHR 的语义是「取一张可写的交换链图像」,它同样返回 VK_SUBOPTIMAL_KHR(尺寸不匹配但仍可用)和 VK_TIMEOUT(无图可用)这类状态码。跨队列场景下,acquire 必须在「该图像的所有权还在你手里」的前提下使用,而一旦图像被 present 出去,别的队列族就不能再写它。
综合起来,present/acquire 这一段是整条链路里唯一强制使用一次性原语的环节,也是「一套时间线信号量打天下」这个想法必然撞墙的地方。工程上的结论是:为 present 单独保留一个二值信号量池(三缓冲就是 3 个),其余全部用时间线信号量。这与模型 G 的口径一致——按环形拓扑算,2 条队列时每帧 4 次交接,二值方案需要 12 个对象而时间线方案 3 个(4.0 倍差);3 条队列时 18 vs 4(4.5 倍);4 条队列时 24 vs 5(4.8 倍)。差值随队列数缓慢上升,但永远是数量级级别的。
七、D3D12 与 Metal 的等价表达
跨平台时必须把三家的原语映射到统一抽象上,否则每个后端都要重写一遍调度器。
D3D12 的模型是「队列 + 命令列表 + fence」。队列通过 ID3D12Device::CreateCommandQueue 创建,类型由 D3D12_COMMAND_LIST_TYPE 决定:DIRECT = 0(直接命令列表,可执行)、BUNDLE = 1(bundle,继承父命令列表的 GPU 状态,只能被直接命令列表执行)、COMPUTE = 2、COPY = 3,以及视频解码/处理/编码三类。队列创建标志只有两个:D3D12_COMMAND_QUEUE_FLAG_NONE = 0 和 D3D12_COMMAND_QUEUE_FLAG_DISABLE_GPU_TIMEOUT = 0x1(关闭该队列的 GPU 超时检测——做超长离线渲染时用得上)。
执行语义在官方文档《Executing and Synchronizing Command Lists》里写得很清楚:「Applications can submit command lists to any command queue from multiple threads. The runtime will perform the work of serializing these requests in the order of submission.」——同一队列内的提交按顺序执行,但不同队列之间没有隐式顺序。文档随后给出跨队列同步的官方口径:「Some examples of these include waiting for an operation on a compute queue to complete so that the result can be used for a rendering operation on the 3D queue, and waiting for a 3D operation to complete so that the operation on the compute queue can access a resource for writing. To enable the synchronization of work between queues, Direct3D 12 uses the concept of fences, which are represented in the API by the ID3D12Fence interface.」
ID3D12Fence 的方法集只有三个:GetCompletedValue(读当前值)、SetEventOnCompletion(指定某个值达成时触发事件)、Signal(设置值)。这就是 D3D12 的时间线语义——fence 值单调递增,ExecuteCommandLists + Signal(fence, N) 对应 Vulkan 的 vkQueueSubmit + 时间线 signal。而 D3D12 没有 Vulkan 那个「present 必须用二值信号量」的限制,因为 present 走的是 IDXGISwapChain::Present,用 DXGI_PRESENT 配合 fence 值即可,fence 本身就是计数器型的。
不过 D3D12 有一个 Vulkan 没有的坑:跨队列访问资源需要显式的资源状态屏障(resource barrier),并且要声明前后状态。Vulkan 里 ownership transfer 是「释放/获取」配对,D3D12 里则是「同一资源在两个队列上的状态转换必须用 ID3D12GraphicsCommandList::ResourceBarrier 显式声明,且前后状态要匹配」。D3D12 官方文档有一整节叫「Synchronizing resources accessed by command queues」,明确说资源状态同步是靠 resource barrier 实现的,每个 barrier 声明前后状态。D3D12 还提供了「提升状态」优化(promote),但那是优化不是契约。
Metal 的抽象最接近「有序列表」。MTLCommandQueue 的文档说它「maintains an ordered list of command buffers」,并且「Each command queue ensures the order that you enqueue its command buffers is the same order the queue schedules them to run on the GPU.」MTLCommandBuffer 文档里给出了现代 Metal 的关键机制——enqueue:「You have the option to reserve a place for the command buffer in its command queue by calling the command buffer's enqueue() method. You can call this method exactly once at any time before you commit the buffer to the queue. If you don't enqueue a command buffer, it implicitly enqueues itself when you commit it.」
这个 enqueue() 是理解 Metal 跨队列协作的关键:它让「先占位、后填充」成为可能,从而在不引入额外等待的前提下安排执行顺序。文档同段还写:「For example, a multithreaded app might set the GPU's execution order for a sequence of related subtasks by: ...」——Metal 把「决定 GPU 执行顺序」直接做成了 API 的一等公民。
MTLSharedEvent 则是跨设备同步器:「A type that synchronizes memory operations to one or more resources across multiple CPUs, GPUs, and processes.」它的文档同时点明限制:「The MTLEvent protocol inherits the MTLSharedEvent protocol. An event can only synchronize memory operations that run on a single Metal device.」——单设备用 MTLEvent,跨设备才升级到 MTLSharedEvent。
把三家放在一起,可以得到一张可移植的映射表:
| 语义 | Vulkan | D3D12 | Metal |
|---|---|---|---|
| 队列 | queue(属于 family) | ID3D12CommandQueue(DIRECT/COMPUTE/COPY) | MTLCommandQueue |
| 帧内一次性同步 | binary semaphore | fence + 独立对象语义 | MTLEvent / 事件等待 |
| 可复用计数器同步 | timeline semaphore | ID3D12Fence 的单调值 | MTLSharedEvent(跨设备)/ 有序提交 |
| 跨队列资源归属 | ownership transfer(release/acquire) | resource barrier 状态声明 | Residency set + 提交顺序 |
| 执行顺序保证 | 同队列内按提交序 | 同队列内按提交序 | enqueue() 显式占位 |
| 主机等待 | vkWaitSemaphores / vkWaitForFences | WaitForSingleObject + SetEventOnCompletion | waitUntilSignaled |
这张表就是引擎抽象层的直接依据:统一抽象应该是「队列 + 可复用计数器事件 + 一次性事件 + 资源归属声明」四元组,而不是「Vulkan 抽象」或「D3D12 抽象」。
八、引擎落地:RDG 的 lane 标注与 Unity 的 GraphicsFence
有了规范层的模型,来看两个真实引擎是怎么落地的——它们代表两种风格。
UE5 的 Render Graph(RDG) 采用「作者声明意图,编译器决定拓扑」的路线。在 RenderCore/Public/RenderGraphDefinitions.h 里,ERDGPassFlags 是一个位标志枚举:
enum class ERDGPassFlags : uint16
{
None = 0,
Raster = 1 << 0, // Pass uses rasterization on the graphics pipe.
Compute = 1 << 1, // Pass uses compute on the graphics pipe.
AsyncCompute = 1 << 2, // Pass uses compute on the async compute pipe.
Copy = 1 << 3, // Pass uses copy commands on the graphics pipe.
NeverCull = 1 << 4,
SkipRenderPass = 1 << 5,
NeverMerge = 1 << 6,
NeverParallel = 1 << 7,
ParallelTranslate = 1 << 8,
Readback = Copy | NeverCull,
};
注意 Compute 和 AsyncCompute 是两个分开的位:Compute 是「在 graphics pipe 上跑 compute」(即 compute 走 graphics 队列,跟光栅共享 SM),AsyncCompute 才是「在 async compute pipe 上跑」。这个区分很关键——很多自研引擎只有一个 compute 概念,结果在移动端和带宽受限的桌面平台上根本不敢开异步计算,因为没有开关可以退回去。
RenderGraphBuilder.h 里 AddPass 的注释把意图写得很直白:「Declare the type of GPU workload (i.e. Copy, Compute / AsyncCompute, Graphics) to the pass via the Flags argument. This is used to determine async compute regions, render pass setup / merging, RHI transition accesses, etc.」——一个 flag 同时决定了三件事:并行区域划分、render pass 合并、RHI 层的资源状态转换。这就是「跨队列同步必须被调度器自动插入」的最佳例证:作者标一个 flag,RDG 负责算出释放/获取配对、插入信号量、决定提交批次。
还有一个细节体现了 RDG 的严格性:无参数的 AddPass 变体注释里写「NeverCull and SkipRenderPass (if Raster) are implicitly added to Flags. AsyncCompute is not allowed.」——异步计算 pass 必须有显式的输入输出声明,否则编译器无法追踪依赖,也就无法安全地把它切到另一条队列。这条限制恰恰是跨队列同步的正确性前提。
Unity 的 Scriptable Render Pipeline 走的是更薄的一层封装。Unity 6000.0 的 Graphics.CreateGraphicsFence 签名是:
public static GraphicsFence CreateGraphicsFence(GraphicsFenceType fenceType, SynchronisationStageFlags stage);
官方文档写得很直接:fenceType 参数「Currently the only supported value is GraphicsFenceType.AsyncQueueSynchronisation」;stage 参数是「Which SynchronisationStage to insert the fence after」。Remarks 里进一步定义:「The GPU passes through the GraphicsFence fence after it completes the Blit, Clear, Draw, Dispatch or texture copy command you sent before this call.」——fence 是插在某个同步阶段之后的,而不是「某个命令完成时」。这个 API 形状比 Vulkan 更窄:它没有暴露时间线值、没有暴露多等待者、没有暴露 stage mask 的细粒度控制。
这形成一个有意思的对照:RDG 给出的是「意图声明式」的宽接口(一个 flag 覆盖区域划分、pass 合并、状态转换),Unity 给出的是「命令级插入式」的窄接口(一个 fence + 一个阶段)。 如果你要自研引擎,RDG 的路线更接近正确解——因为它把跨队列同步的推导集中在编译器里,而 Unity 这层把「插到哪个阶段」的选择权交给了调用方,理论上更容易出错(当然 Unity 内部实现未必如此,公开 API 只是它能力的一个投影)。
值得注意的是,Unity 的 Graphics 类在 6000.0 里同时提供了 CreateAsyncGraphicsFence、CreateGPUFence、CreateGraphicsFence 和 WaitOnAsyncGraphicsFence 四个相关入口——命名本身就说明它区分了「异步图形栅栏」和「通用 GPU 栅栏」,前者才是跨队列用的。这种命名区分值得在自己的引擎里照抄:WaitOnAsyncGraphicsFence 这个名字告诉你它等的是「异步队列上的栅栏」,而不是「某个命令完成的栅栏」。
九、自算:交叠收益、同步预算与死锁检测
前面所有定性结论都要落到数字上。以下模型全部是本次现场计算,输入参数写在代码里,可以复现。
9.1 两队列调度的收益上界
模型 C 构造了一个代表性帧图:22 个 raster pass 构成一条链(shadow → GBuffer → 光照 → 后处理),14 个 compute pass 构成一条链(剔除 → SSGI/探针 → 降噪),跨队列依赖是「compute 链末端 → raster 链末端」(光照要等降噪结果)。每 pass 时长由固定种子 LCG 生成(s = imul(s,1664525)+1013904223,取 0.30 + rnd()*0.70 毫秒),跑 400 组。
结果:
- 单队列串行执行平均 23.3844 ms;
- 双队列列表调度平均 14.3333 ms,加速比 1.6315×,节省 38.71%;
- 其中 graphics 队列忙 14.3333 ms,compute 队列忙 9.2785 ms——compute 的 9.28 毫秒被完整地藏进了 graphics 的阴影之下;
- 该拓扑的关键路径(critical path,含跨边)实测 14.3333 ms,即调度结果正好落在关键路径下界上,超出 0.00%。
这个数字的含义是:在这种「compute 链不被 raster 链阻塞」的理想拓扑下,双队列调度能榨出的收益接近理论上限,剩下的优化空间不在调度器,而在图结构本身。 反过来说,如果你的调度器实测收益远低于 38.71%,问题几乎一定在图的依赖建模上(把本可并行的 pass 串成了链),而不是在同步原语选型上。
9.2 每条跨队列边的同步开销预算
模型 D 在上述结果上叠加「每条跨队列交接的固定开销 s」:
| 跨队列边数 E | 盈亏平衡的每边开销 s |
|---|---|
| 4 | 2262.8 μs |
| 8 | 1131.4 μs |
| 16 | 565.7 μs |
| 32 | 282.8 μs |
也就是说,在 E=8 的拓扑下,只要一次跨队列交接的综合开销(signal + wait + 调度延迟 + 气泡)不超过 1.13 毫秒,异步计算就是净赚。这个门槛相当宽松——真实的信号量交接开销通常在几十微秒量级。但如果你的交接跨 PCIe(比如 compute 结果要回读给 CPU 再传回 GPU),或者中间夹了一次同步回读,那开销会直接冲到毫秒量级,异步计算就变成净亏。
再看收益对 s 的敏感度(E=8):
| 每边开销 s | 总时长 | 相对串行的收益 |
|---|---|---|
| 0.001 ms | 14.3413 ms | 38.67% |
| 0.005 ms | 14.3733 ms | 38.53% |
| 0.010 ms | 14.4133 ms | 38.36% |
| 0.050 ms | 14.7333 ms | 37.00% |
| 0.200 ms | 15.9333 ms | 31.86% |
| 0.500 ms | 18.3333 ms | 21.60% |
收益对同步开销并不敏感,但亏损来得很快:s 从 0.05 涨到 0.5 毫秒(10 倍),收益从 37% 掉到 21.6%,只损失了总收益的 42%;但如果 s 继续涨到 1.13 毫秒,收益归零。这条曲线的形状说明:跨队列同步是「要么几乎免费、要么完全不划算」的,不存在中间地带。 你的优化目标应该是「把 s 压到几十微秒」,而不是「把 s 从 200 微秒优化到 150 微秒」。
9.3 死锁检测:把提交顺序变成图问题
时间线信号量放弃了对死锁的保护(「It is the responsibility of the application to avoid deadlock」),所以调度器必须自己检测。
模型 B 用拓扑排序做环检测。对一个含环的等待图 G→C→G→X 检测结果为 true(有环),对无环的 G→C→X 检测结果为 false——算法本身可用。
更有价值的是穷举结果:对于 L 次「生产者→消费者」交接链,把 2L 个提交操作随机排列,只有恰好 1 种顺序是安全的。
- L=2(4 个提交):24 种排列中安全的有 1 种,占 4.17%;
- L=3(6 个提交):720 种排列中安全的有 1 种,占 0.14%。
也就是说,靠人工保证提交顺序的正确率随交接链长度指数级崩塌。L=3 时随便打乱顺序的正确率不到千分之一。这是一个非常有力的论据:跨队列调度必须由「先算拓扑、再生成提交序列」的编译器来做,任何「手写提交顺序」的方案在第三次交接时就已经不可靠了。
工程上的落地要求也就清楚了:调度器在生成提交批次时,必须维护一张等待图(节点=提交批次,边=信号量依赖),生成完毕后跑一次环检测,检出环就在开发期直接失败,而不是在设备上挂起。这与 RDG 用 ERDGPassFlags 声明意图、由编译器算拓扑的设计思路是一致的。
9.4 帧在飞数量与延迟预算
模型 F 把帧在飞数量与端到端延迟联系起来(16.7 ms 一帧):
| 帧在飞 F | 输入到光子的延迟预算 |
|---|---|
| 1 | 16.7 ms |
| 2 | 33.4 ms |
| 3 | 50.1 ms |
| 4 | 66.8 ms |
三缓冲(F=3)意味着 50.1 ms 的延迟预算——这是竞技游戏手感与「多队列吃满 GPU」之间的直接冲突点,也是为什么很多引擎会把帧在飞做成可调参数:延迟敏感场景下 F=1 或 F=2,但那时你无法用「跨帧重叠」来隐藏 IO 与调度抖动,异步计算的收益模型也要跟着改。
十、落地清单
把上面的结论压缩成一份可执行的检查表:
初始化阶段
- 枚举队列族(
vkGetPhysicalDeviceQueueFamilyProperties2),按queueFlags归类:纯 graphics、纯 compute、graphics+compute(可当两条队列用但无隐式序)、transfer、sparse binding。 - 检查
timestampValidBits:为 0 表示该族不支持时间戳,profiling 方案要绕开;非 0 时记录有效位宽,别假设跨族是同一时基。 - 查
minImageTransferGranularity,用它切分跨队列的图像传输操作。 - 为每个需要参与跨队列同步的族各建一个时间线信号量;额外单独建一个二值信号量池给 present(池大小 = 帧在飞数)。
每帧调度
5. pass 声明意图(raster / compute / async compute / copy),由调度器推导拓扑,不要手写提交顺序。
6. 调度器维护等待图,生成提交序列后跑环检测,有环在开发期失败。
7. 跨族资源访问由调度器自动插入释放/获取配对,业务代码禁止手写所有权屏障;释放/获取屏障的 src/dstAccessMask 置 0,可见性由独立 memory barrier 承担。
8. 时间线信号量的 signal 值必须严格大于当前值(03882),且与未完成操作的差值不超过 maxTimelineSemaphoreValueDifference(03883)。
9. 二值信号量路径必须满足「signal 一次、wait 一次、无第二等待者、信号操作已提交」四条,不满足就是违规。
验证
10. 用 VK_LAYER_KHRONOS_validation 打开同步校验,它会把 00067 / 00068 / 03238 / 03880 / 03882 / 03883 全部报出来——这些条款写得这么细就是为了给校验层用。
11. 用 PIX / RenderDoc 抓一帧,看队列时间线,确认 compute 队列确实与 graphics 队列重叠,而不是「看起来并行、实际上串行」。
12. 用模型 C 那套列表调度器做一个离线模拟:如果离线模拟的收益明显低于双队列的理论值,先怀疑图的依赖建模,再怀疑同步原语。
十一、结语
跨队列同步是一件被严重低估的工程。它不产出可见的特性,也不进渲染管线图,但它决定了你的帧时间上限是「串行总和」还是「关键路径」。三个最容易被跳过的结论:
第一,跨队列交接本身不花钱,花钱的是你为交接引入的串行化。 因此优化顺序永远是「先改图,再改原语」。模型 C 显示理想拓扑下调度能榨出 38.71%,而收益对每边同步开销并不敏感。
第二,present 是同步原语的盲区。 Vulkan 规范明确禁止用时间线信号量等 present(03267),因为「Some implementations may have problems with supporting that directly」。任何「一套原语打天下」的设计都必须在 present 边界做降级。
第三,时间线信号量放弃了死锁保护,换来的是完全异步的灵活性。 模型 B 的穷举显示 L=3 的交接链只有 0.14% 的随机提交顺序是安全的——这意味着等待图环检测不是「加分项」,而是「必需品」。
至于那些只打印「我们支持 async compute」然后把复杂度塞给 TA 的管线:它们缺的不是 GPU 能力,是一份把「队列族、计数器型同步原语、一次性 present 原语、资源归属声明」这四件事显式建模的调度器。补上它,async compute 才会从一个开关变成一个可预测的、可测量的、可跨平台复现的性能工具。
一句话摘要:跨队列同步不是「加个 async compute 开关」,而是把队列族拓扑、单调计数器信号量、present 侧一次性原语与资源所有权转移显式建模成可编译、可环检测、可量化的调度问题——理想拓扑下双队列可省 38.71% 帧时间,但三段交接的随机提交顺序只有 0.14% 安全,所以等待图环检测是必需品而非加分项。
参考文献
- Khronos Group. Vulkan Specification (latest revision, single-file HTML). https://registry.khronos.org/vulkan/specs/latest/html/vkspec.html
- Khronos Group. vkGetPhysicalDeviceQueueFamilyProperties(3) — Vulkan API Reference Pages. https://registry.khronos.org/vulkan/specs/latest/man/html/vkGetPhysicalDeviceQueueFamilyProperties.html
- Khronos Group. vkGetPhysicalDeviceQueueFamilyProperties2(3) — Vulkan API Reference Pages. https://registry.khronos.org/vulkan/specs/latest/man/html/vkGetPhysicalDeviceQueueFamilyProperties2.html
- Khronos Group. vkGetDeviceQueue(3) and vkGetDeviceQueue2(3) — Vulkan API Reference Pages. https://registry.khronos.org/vulkan/specs/latest/man/html/vkGetDeviceQueue2.html
- Khronos Group. vkCreateSemaphore(3) — Vulkan API Reference Pages. https://registry.khronos.org/vulkan/specs/1.3-extensions/man/html/vkCreateSemaphore.html
- Khronos Group. vkWaitSemaphores(3) — Vulkan API Reference Pages. https://registry.khronos.org/vulkan/specs/latest/man/html/vkWaitSemaphores.html
- Khronos Group. vkSignalSemaphore(3) — Vulkan API Reference Pages. https://registry.khronos.org/vulkan/specs/latest/man/html/vkSignalSemaphore.html
- Khronos Group. vkGetSemaphoreCounterValue(3) — Vulkan API Reference Pages. https://registry.khronos.org/vulkan/specs/latest/man/html/vkGetSemaphoreCounterValue.html
- Khronos Group. vkQueueSubmit2(3) — Vulkan API Reference Pages. https://registry.khronos.org/vulkan/specs/latest/man/html/vkQueueSubmit2.html
- Khronos Group. vkCmdPipelineBarrier2(3) — Vulkan API Reference Pages. https://registry.khronos.org/vulkan/specs/latest/man/html/vkCmdPipelineBarrier2.html
- Khronos Group. vkQueuePresentKHR(3) — Vulkan API Reference Pages. https://registry.khronos.org/vulkan/specs/latest/man/html/vkQueuePresentKHR.html
- Khronos Group. VK_KHR_timeline_semaphore — Vulkan Extension Specification. https://registry.khronos.org/vulkan/specs/1.3-extensions/man/html/VK_KHR_timeline_semaphore.html
- Microsoft Corporation. Executing and Synchronizing Command Lists (Direct3D 12). https://learn.microsoft.com/en-us/windows/win32/direct3d12/executing-and-synchronizing-command-lists
- Microsoft Corporation. ID3D12CommandQueue interface (Direct3D 12). https://learn.microsoft.com/en-us/windows/win32/api/d3d12/nn-d3d12-id3d12commandqueue
- Microsoft Corporation. ID3D12Fence interface (Direct3D 12). https://learn.microsoft.com/en-us/windows/win32/api/d3d12/nn-d3d12-id3d12fence
- Microsoft Corporation. D3D12_COMMAND_LIST_TYPE enumeration. https://learn.microsoft.com/en-us/windows/win32/api/d3d12/ne-d3d12-d3d12_command_list_type
- Microsoft Corporation. D3D12_COMMAND_QUEUE_FLAGS enumeration. https://learn.microsoft.com/en-us/windows/win32/api/d3d12/ne-d3d12-d3d12_command_queue_flags
- Apple Inc. MTLCommandQueue, MTLCommandBuffer, MTLSharedEvent — Metal Documentation. https://developer.apple.com/documentation/metal/mtlcommandqueue
- Unity Technologies. Graphics.CreateGraphicsFence — Unity 6000.0 Script Reference. https://docs.unity3d.com/6000.0/Documentation/ScriptReference/Graphics.CreateGraphicsFence.html
- Unity Technologies. Graphics.WaitOnAsyncGraphicsFence — Unity 6000.0 Script Reference. https://docs.unity3d.com/6000.0/Documentation/ScriptReference/Graphics.WaitOnAsyncGraphicsFence.html
- Epic Games. Unreal Engine 5.3 source, RenderCore/Public/RenderGraphDefinitions.h (ERDGPassFlags) — public mirror. https://raw.githubusercontent.com/chenyong2github/UnrealEngine/5.3/Engine/Source/Runtime/RenderCore/Public/RenderGraphDefinitions.h
- Epic Games. Unreal Engine 5.3 source, RenderCore/Public/RenderGraphBuilder.h (FRDGBuilder::AddPass) — public mirror. https://raw.githubusercontent.com/chenyong2github/UnrealEngine/5.3/Engine/Source/Runtime/RenderCore/Public/RenderGraphBuilder.h