文章要回答的核心问题
Render Graph 已经知道 Pass 的先后关系,为什么还需要 Vulkan barrier?
本文只聚焦一条主线:
- 从资源读写声明推导 RAW、WAR、WAW dependency edge。
- 从 dependency edge 得到合法的 Pass 执行顺序和 layer。
- 在真正录制 GPU 命令时,根据 stage、access 和 layout 生成 barrier。
这三步解决的是不同层次的问题:资源声明描述 Pass 想做什么,Dependency Edge 和 Layer 计算逻辑顺序,Barrier 再把这种顺序落实为 GPU 能执行的同步。只有声明而没有依赖分析,Render Graph 无法发现访问冲突;只有 Edge 而没有正确的 Barrier,GPU 侧仍然得不到执行顺序和内存可见性保证。
1. 从失败测试开始
1.1 当时看见了什么
测试创建了 WriterA 和 WriterB,让它们连续写入同一张 Image。测试失败后,我发现两个 Writer 被放进了同一个 Layer,这意味着 Render Graph 没有建立 WAW 依赖,最终写入顺序无法得到保证。画面可能暂时正常,但这只能说明 GPU 和驱动碰巧给出了预期结果。
加入 WAW Edge 后,新的 same-state WAW Barrier 测试再次失败。即使两个 Writer 的 Layout、Access 和 Stage 相同,也不代表第一次写入已经完成。当前引擎会在 CPU 上将各个 Pass 依次录入同一个 Command Buffer,再统一提交给 Graphics Queue;Dependency Edge 负责组织逻辑顺序,而正确的 Vulkan Barrier 才能把这种顺序落实为 GPU 的执行与内存依赖。
1.2 为什么这个问题值得写
- 在遇到这个测试前,我只能从英文全称大致猜出 RAW、WAR、WAW 表示资源访问顺序,却不清楚它们会怎样影响 GPU 执行。
- 两次写入可能在 GPU 上发生重叠。没有正确同步时,画面即使正确也可能只是巧合,不能作为同步正确的证据。
- 我开始学会分层验证:用单元测试检查 Edge、Layer 和 Barrier 判定,用日志检查 CPU 控制流,再用 Vulkan Synchronization Validation 检查真实命令中的 Hazard。
2. 最小心智模型:Pass、Resource 与 DAG
2.1 Pass 声明的是意图
Read(resource):这个 Pass 消费资源已有内容。Write(resource):这个 Pass 会产生或覆盖资源内容。
RenderGraph 主要分为 3 个阶段:Setup、Compile、Execute,这三步都发生在 CPU。真正的 GPU 执行发生在 vkQueueSubmit 之后。
Setup 阶段里,Pass 声明自己的 Inputs 和 Outputs。
Compile 依据这些声明分析资源依赖,构建有向无环图(DAG),并计算 pass 的 Edge 和 Layer(执行批次)。
Execute 阶段按 Layer 顺序,在 CPU 侧向命令缓冲区录制指令(先 barrier,再 draw/dispatch)。
当命令缓冲区提交到队列(vkQueueSubmit)后,GPU 才真正开始执行这些命令。
1 | WriterA -----> ReaderA -----> WriterB |
2.2 Edge 与 Layer
也就是把“谁要先做”变成一张机器能理解的规则表。
Rule 先出来后,才会把可执行顺序交给 GPU。
- Edge:两个 Pass 之间必须满足的 happens-before 约束。
- Layer:根据 DAG 拓扑关系计算出的执行层级。
- 同一 layer 只表示图上没有直接或间接依赖,不自动等于 GPU 一定并行执行。
如果所有 Pass 恰好按正确顺序添加,程序也可能正常运行,但这种正确性依赖人工维护,顺序改变后就可能失效。Dependency Edge 会明确记录 Pass 之间真正必要的先后关系,让 Render Graph 能够检查非法依赖、进行拓扑排序并计算 Layer,而不是简单地按照添加顺序串行执行。不过,当前项目仍然按照 Pass 的扫描顺序区分资源的新旧版本,因此还不能认为它支持任意添加顺序下的自动重排。
3. RAW、WAR、WAW 分别是什么
RAW、WAR、WAW 和 RAR 描述的是两个 Pass 对同一资源的前后访问关系,其中 R 是 Read,A 是 After,W 是 Write。
- RAW(Read After Write):先写后读。Reader 必须等待 Writer,否则可能观察到旧数据、尚未完成的写入或未定义结果。
- WAR(Write After Read):先读后写。Writer 必须等待此前所有 Reader 完成,否则可能提前覆盖 Reader 仍在使用的内容。
- WAW(Write After Write):先写后写。即使两次写入的 Layout 相同,也要建立正确同步,否则两个 Write 的顺序无法得到保证。
- RAR(Read After Read):先读后读。两个 Reader 都不修改资源,通常不会互相破坏,因此一般不需要额外依赖。
4. 如何从访问序列构建依赖
Render Graph 在 CPU 上按顺序分析每个 Pass,并为每项资源维护两份访问历史:
对每个 resource 维护:
1 | latestWriter 最近一次写入它的 Pass |
4.1 遇到 Read
1 | 如果存在 latestWriter:latestWriter -> currentReader |
4.2 遇到 Write
1 | 如果存在 latestWriter:latestWriter -> currentWriter // WAW |
4.3 还要守住的 invariant
- 重复依赖只能计数一次。
- Pass 不得依赖自己。
- 读取未声明或不存在的资源必须在 Compile 阶段报错。
- 生成依赖表后必须覆盖每一个 Pass。
- 图中出现 cycle 时必须拒绝编译。
5. Edge、Layer 与 Vulkan Barrier 不是一回事
5.1 Dependency edge:解决逻辑顺序
Dependency Edge 回答的是:“谁必须在谁之前?”例如 WriterA -> ReaderA 表示 ReaderA 依赖 WriterA 的结果。它属于 Render Graph Compiler 的 CPU 侧模型,用来检查依赖、拓扑排序和计算 Layer。Edge 只是记录逻辑关系,本身没有在 GPU 侧建立执行依赖或内存可见性保证。
5.2 Layer:表达 DAG 的调度结果
Layer 回答的是:“当前哪些 Pass 已经没有尚未处理的前驱?”Render Graph 每一轮把所有入度为 0 的 Pass 放进同一个 Layer,再移除它们对后继的影响。处在同一 Layer,只能说明图中没有发现这些 Pass 之间的直接或间接依赖,不代表 GPU 一定会同时执行它们。当前项目中的 Layer 是一种执行组织方式,不能直接宣传为已经实现 Async Compute 或多队列并行。
5.3 Image barrier:解决 GPU 执行与内存依赖
即使命令在 Command Buffer 中按照“先写后读”的顺序录制,GPU 的不同流水线阶段仍可能重叠工作。命令顺序本身也不保证前一次写入已经完成并且对后一次访问可见,所以还需要 Vulkan Barrier 回答:
- 前一次访问必须完成到哪个 pipeline stage?
- 前一次写入何时对后续访问可见?
- 后一次访问从哪个 stage、以什么 access 开始?
- image 是否需要 layout transition?
项目最终把资源状态映射为:
1 | srcStageMask / srcAccessMask / oldLayout |
然后通过 VkImageMemoryBarrier2 和 vkCmdPipelineBarrier2 表达同步。
其中 Stage 表示访问发生在 GPU 流水线的哪个阶段,Access 表示这个阶段进行了哪种内存读写,Layout 表示 Image 当前以什么方式使用。src 描述 Barrier 前面的访问,dst 描述 Barrier 后面的访问。例如,从 Color Attachment 写入转为 Fragment Shader 采样时,需要等待 COLOR_ATTACHMENT_OUTPUT 阶段的 COLOR_ATTACHMENT_WRITE,让结果对 FRAGMENT_SHADER 阶段的 SHADER_READ 可见,并把 Image 从 COLOR_ATTACHMENT_OPTIMAL 转换为 SHADER_READ_ONLY_OPTIMAL。
调用 vkCmdPipelineBarrier2 时,CPU 仍然只是在向 Command Buffer 录制命令。直到 Command Buffer 被提交,并且 GPU 执行到该位置时,Barrier 才真正产生作用;vkQueueSubmit 返回也不代表 GPU 已经完成执行。
因此三者的职责可以概括为:
1 | Edge:逻辑上谁等待谁 |
Edge 能让两个 Pass 按正确顺序录制或执行,但它本身不等于 Vulkan 内存可见性保证。
6. 为什么 layout 相同的 WAW 仍可能需要 barrier
错误直觉:
1 | oldLayout == newLayout,所以不需要 barrier。 |
这个判断混淆了 Barrier 的不同职责,需要拆开三个维度:
- Layout:image 当前采用哪种 GPU 使用布局。
- Stage:访问发生在流水线的什么阶段。
- Access:该阶段进行读还是写,以及访问类型是什么。
两个 Pass 都以相同 layout 写同一张 image 时:
- layout transition 可以不发生;
- 但第一次写与第二次写之间仍然存在 WAW memory hazard;
- 后一个 Writer 仍要等待前一个 Writer 的相关写入,因此需要 execution dependency 和 memory dependency。
当前项目通过 RequiresImageMemoryBarrier() 判断前后状态是否需要 Barrier。除了检查 Layout 和 Access 是否改变,它还检查前后任意一侧是否包含写访问:
1 | return stateChanged || |
因此,即使前后都是 GENERAL + SHADER_WRITE,状态看起来完全相同,写访问仍会让这个函数返回 true。这正是之前 same-state WAW 测试需要覆盖的问题。
| 情况 | Layout 是否变化 | 是否有写访问 | 是否需要 barrier |
|---|---|---|---|
| 相同状态 RAR | 否 | 否 | 通常不需要 |
| 相同状态 WAW | 否 | 是 | 需要同步写访问 |
| Color Attachment -> Sampled | 是 | 前写后读 | 需要同步并 transition |
所以,Barrier 不等于 Layout Transition。Layout Transition 只是 Image Barrier 可能承担的一项工作;即使 Layout 不变,只要存在 RAW、WAR 或 WAW,也仍然要建立相应的同步。其中 RAW、WAW 需要适当的 Memory Dependency,WAR 通常只需要 Execution Dependency。
7. 测试如何推动修复
这次修复不是从画面异常开始的,而是从一个最小单元测试开始的。画面即使正常,也可能只是 GPU 和驱动碰巧按我期望的顺序执行;直接检查 Render Graph 的不变量,更容易稳定复现问题和定位原因。
7.1 第一次失败:两个 Writer 进入同一 Layer
测试只构造两个写入同一资源的 Pass:
1 | WriterA -> WriterB |
正确结果应该是两个 Layer,因为 WriterB 必须等待 WriterA。修复前记录的测试输出是:
1 | [FAIL] two writers of the same resource must have a WAW dependency |
这说明旧的依赖构建只处理了 Writer 到 Reader 的 RAW,没有处理 Writer 到 Writer 的 WAW。加入 lastWriter -> currentWriter 的 Edge 后,测试能够得到:
1 | Layer 0:[WriterA] |
这个测试证明的是 CPU 侧 Compile() 为这两个 Writer 建立了依赖,并把它们分到不同 Layer。它没有调用 Execute(),更没有提交真实 GPU 命令,因此不能证明 Vulkan Barrier 已经正确生成。
7.2 第二次失败:相同状态的 WAW 被跳过
有了 WAW Edge,并不代表 GPU 侧同步已经成立。旧的 Barrier 判断主要比较 Layout 和 Access 是否改变,因此两个状态完全相同的 Writer 可能被认为“不需要 Barrier”:
1 | WriterA:COLOR_ATTACHMENT_OPTIMAL + COLOR_ATTACHMENT_WRITE |
当时新增的测试失败为:
1 | [FAIL] same-state write-after-write must require a barrier |
Layout 相同只表示不需要 Layout Transition,并不表示 WriterA 已经完成。修复后的 RequiresImageMemoryBarrier() 只要发现前后任意一侧包含写访问,就会要求 Barrier;同时保留了相同状态 RAR 不产生多余 Barrier 的测试。
不过,这个测试只是直接调用一个 CPU 判断函数。它能证明函数对这组输入返回 true,不能证明 BuildBarriers() 一定在正确的位置调用了它,也不能证明 VkImageMemoryBarrier2 的 Image、Stage 和 Access 都填写正确,更不能证明 vkCmdPipelineBarrier2() 已经被录制和提交。
7.3 第三次失败:Writer 没有等待所有 Reader
为了覆盖 WAR,又构造了下面的访问序列:
1 | ┌-> ReaderA -┐ |
WriterB 不仅要等待 WriterA 的旧写入,还要等待 ReaderA 和 ReaderB 都读完旧版本。为此,Render Graph 增加了 readersSinceLastWrite,记录最近一次写入之后的所有 Reader,并在新的 Writer 到来时为它们分别建立 WAR Edge。写入产生新版本后,这份 Reader 记录才会被清空。
最终依赖和 Layer 应该是:
1 | WriterA:[] |
项目 Git 历史中,WAW 与同状态 Barrier 修复对应 c12d21a,WAR 修复对应 c77a25a,后续拓扑 Layer 重构对应 a78b6f7。这些提交可以证明代码和测试在何时发生变化,但提交记录本身仍不能代替实际运行验证。
7.4 不同测试分别能证明什么
| 验证方式 | 可以证明 | 不能证明 |
|---|---|---|
| Dependency/Layer 单元测试 | 最小访问序列生成了预期 Edge 和 Layer | 真实 GPU Barrier 正确 |
| Barrier 判定函数测试 | 指定 ResourceState 组合是否要求 Barrier | Barrier 已被正确录制和提交 |
| Vulkan synchronization validation | 本次实际执行路径中没有被验证层发现的同步错误 | 所有场景、GPU、驱动和 Render Path 都正确 |
| 固定场景截图 | 当前输入下没有观察到明显画面异常 | 不存在隐藏的同步 hazard |
| RenderDoc/Nsight | 实际命令、资源内容和 Barrier 是否符合预期 | 未捕获路径和所有硬件都正确 |
因此,TestWritersArePlacedInSeparateLayers() 通过,只能证明依赖图中的 WAW 规则通过了这个最小测试,不能证明 GPU Barrier 正确。RequiresImageMemoryBarrier() 测试通过,也只能证明判断函数的返回值,不能证明 vkCmdPipelineBarrier2() 已经被录制。即使 synchronization validation 没有报错,结论也只能限定在本次运行实际覆盖的场景、功能开关、Render Path、GPU 和驱动上。
这次修复采用的验证阶梯是:
1 | Dependency 与 Layer 单元测试 |
越靠前的测试越小、越稳定,也越容易定位问题;越靠后的验证越接近真实 GPU 执行,但成本更高。它们是互相补充的证据,不能互相替代。
8. 当前边界与收获
这次修改完善了 RAW、WAR、WAW 依赖、拓扑 Layer 和同状态写入的 Barrier 判定,但它仍不是一个覆盖所有 Vulkan 情形的 Render Graph。当前状态主要按整张 Image 跟踪;所有 Pass 仍录制进同一个 Command Buffer;代码也没有形成经过验证的 Multi-Queue、Async Compute、Queue Family Ownership Transfer 或完整 Transient Resource Aliasing。Layer 表示图中可能并行的逻辑批次,不等于 GPU 已经并行执行。
相比记住几个缩写,我真正得到的是一套分析方法:先列出每个 Pass 对资源的读写,再手算 RAW、WAR、WAW Edge;接着检查 Layer 是否满足依赖;最后检查 Barrier 的 Stage、Access 和 Layout 是否覆盖真实 GPU 访问。我也开始能区分证据的边界:单元测试验证 CPU 侧规则,Synchronization Validation 检查本次运行覆盖的 Vulkan 路径,画面正常则不能单独证明同步正确。
9. 结论
Render Graph 的价值不是简单隐藏 Vulkan API,而是把 Pass 的资源访问意图变成可以分析和测试的同步约束。Dependency Edge 决定逻辑上谁等待谁,Layer 表达依赖图的调度结果,Barrier 再让 GPU 在正确的 Stage 和 Access 范围内看到正确数据。缺少其中任何一层,都可能得到一帧“现在能运行,但正确性没有保证”的画面。
参考资料
- Vulkan Specification:Synchronization and Cache Control
- Vulkan Guide:Synchronization Examples
- Vulkan Validation Layers:Synchronization Validation
- HybridRenderer:
RenderGraph::BuildDependencyGraph()、RenderGraph::BuildBarriers()、RequiresImageMemoryBarrier()与tests/RenderGraphTests.cpp