Chapter 95: Synchronization and Memory
本章讨论显式图形 API 中最容易把画面错误、GPU stall 和 CPU 等待混在一起的问题:同步与内存。读完本章后,读者应能追踪一帧中 texture upload、compute pass、graphics pass、swapchain present 之间的资源状态变化,判断一次 barrier、semaphore、fence 或 Metal fence 应该回答哪个依赖问题,并能把同步错误还原到具体资源、管线阶段、访问类型和队列所有权上。
贯穿本章的材料是一帧后处理渲染:CPU 把一张噪声纹理写入 staging buffer,transfer queue 把它复制到 GPU texture;compute pass 用它生成 bloom mask;graphics pass 采样 bloom mask 合成到 swapchain image;最后 present 到屏幕。这个 frame 同时覆盖 CPU/GPU 关系、queue 之间的信号、image layout transition、shader read/write hazard、frame-in-flight 资源复用和内存分配策略。
显式同步的核心结论可以先给出:每个同步对象都必须回答一个具体依赖。fence 回答 CPU 何时可以复用资源,semaphore 回答一个 GPU 队列何时可以接续另一个队列,barrier 回答同一队列或命令流内某个资源的前一次访问如何对后一次访问可见,layout transition 回答 image 按哪种访问形态被解释,queue ownership transfer 回答资源当前归哪个 queue family 使用。把这些问题分开,调试时就能从“画面偶发闪烁”收敛到“哪张 image 在哪个阶段被读写”。
Khronos 的 Vulkan Synchronization Guide 和 Synchronization Examples 给出了 Vulkan 同步原语和 synchronization2 示例。Metal 的 MTLFence 与 MTLSharedEvent 则代表 Apple 平台中用于 command encoder、command buffer 或跨队列协作的显式同步对象。本章不会把这些资料当作 API 手册复述,而是把它们放回 frame 数据流中解释。
95.1 Vulkan/Metal 中的同步原语解析
同步原语的第一层区分是作用域。CPU/GPU 之间的同步控制主机线程何时可以回收 command buffer、descriptor、per-frame buffer 或 staging memory;GPU/GPU 之间的同步控制队列、pass、shader stage 和资源访问之间的顺序与可见性。把作用域拆开后,同步对象就不再是一组孤立名词,而是一组有明确输入输出的依赖契约。
在贯穿 frame 中,CPU 提交第 N 帧后会继续准备第 N+1 帧。第 N 帧的 per-frame uniform buffer、descriptor set、command buffer 和 staging block 仍可能被 GPU 使用。此时 fence 的任务是让 CPU 知道第 N 帧 GPU 工作已经完成。CPU 等到 fence signaled 后,才能把同一批 per-frame 资源写给新的 frame。fence 的观察者是 CPU,信号来源是 GPU queue submit 完成。它解决资源复用安全性,也可能带来 CPU 等待;过早等待会把多帧并行压扁成单帧串行。
semaphore 的作用对象是 GPU 队列之间的执行依赖。swapchain acquire 产生的 image-available semaphore 表示可渲染的 swapchain image 已经到位;graphics submit 等待它后开始写 swapchain image;render-finished semaphore 再交给 present queue,表示 present 可以读取已完成的图像。若 transfer queue 把 staging buffer 拷贝到 texture,graphics queue 又要采样这张 texture,queue semaphore 可以表达“copy 完成后才允许 graphics 使用”的跨队列关系。timeline semaphore 进一步把多个提交的完成点编码为递增值,适合把多 frame、多 queue 的依赖合并成可排序的数值关系。
barrier 的作用范围更细。它通常写在 command buffer 中,描述一次前序访问如何影响后序访问。Vulkan 的 barrier 会涉及 stage mask、access mask、buffer 或 image subresource、old/new layout 以及必要时的 queue family ownership。比如 transfer queue 写入 texture 后,fragment shader 采样它之前,需要让 transfer write 对 shader read 可见,并把 image 从 transfer destination 形态转到 shader read 形态。barrier 回答的是资源访问本身的可见性和状态迁移问题。
Metal 的同步模型在表达方式上更集中到 command queue、command buffer、encoder 和 resource usage。单个 command buffer 内的 encoder 顺序通常已经提供命令顺序;当资源跨 encoder、跨 command buffer、跨 queue 或跨 GPU/CPU 边界使用时,才需要 fence、event、blit synchronization 或 shared event 这类对象。Metal 没有把 Vulkan 式 image layout 暴露成同样细粒度的字段,但仍然需要开发者表达资源何时被写、何时被读、何时可以被 CPU 访问、何时要从 private storage 经过 blit 路径传输。
同步原语可以按问题归类:
| 依赖问题 | Vulkan 常见对象 | Metal 常见对象 | 观察点 |
|---|---|---|---|
| CPU 复用 per-frame 资源 | fence | command buffer completion handler 或 shared event | CPU 是否过早覆盖 buffer |
| swapchain acquire 到 render | semaphore | drawable 获取与 command buffer 提交顺序 | swapchain image 是否可写 |
| render 到 present | semaphore | command buffer present drawable | present 是否读取完整图像 |
| pass 内资源写后读 | pipeline barrier / image barrier / buffer barrier | encoder 顺序、fence、resource usage | shader 是否读到旧数据 |
| 跨队列资源交接 | semaphore + queue ownership transfer | command queue 之间的 event/fence 设计 | queue 是否访问无所有权资源 |
这张表的判断价值在于先问“谁在等谁”。CPU 等 GPU 用 fence;GPU queue 等 GPU queue 用 semaphore 或 event;同一命令流里资源写后读用 barrier;image 访问形态改变需要 layout transition;不同 queue family 持有权改变需要 ownership transfer。同步错误通常来自把一个作用域的对象当成另一个作用域的答案,例如用 CPU fence 推断 shader read 可见,或用 layout transition 推断 present queue 已经等待 render 完成。
95.2 Fine-Grained Memory Ownership Layout Transition and Access Mask
细粒度同步要同时描述四件事:资源当前被哪个队列拥有、资源以什么 layout 被解释、前一次访问是什么类型、后一次访问是什么类型。少掉其中任意一项,工具可能报 hazard,画面可能出现旧纹理、闪烁、随机像素、深度错误或 GPU 等待异常增长。
先看 hazard。RAW(read after write)表示后续读取依赖前序写入,典型例子是 compute shader 写 bloom mask,fragment shader 立刻采样它。WAW(write after write)表示两个写入需要顺序,典型例子是两个 compute pass 更新同一张 storage image 的同一区域。WAR(write after read)表示前序读取结束后,后续写入才能安全发生,它通常需要执行顺序依赖,未必需要数据可见性转换。判断 hazard 的关键是把资源访问写成“资源、范围、前序 stage/access、后序 stage/access”。
在 Vulkan 中,stage mask 描述等待发生在哪些管线阶段,access mask 描述内存访问类型。stage mask 过宽会扩大等待范围,例如把 fragment shader 的 texture read 写成 all commands,会让更多无关阶段被同步约束。stage mask 过窄会漏掉真实访问,例如 compute shader 写 storage image 后只等待 vertex shader,后续 fragment shader 仍可能读到未可见数据。access mask 的职责是描述读写语义,例如 transfer write、shader write、shader sampled read、color attachment write、depth/stencil attachment write。
image layout 描述 image 当前面向哪种访问路径组织。贯穿 frame 中,texture 上传阶段适合处于 transfer destination layout,采样阶段适合处于 shader read layout,swapchain 渲染阶段适合处于 color attachment layout,present 阶段适合处于 present layout。layout transition 的意义是让实现按后续访问形态解释 image,并在必要时触发内部格式、压缩、tile 存储或 cache 相关转换。layout 字段和 access 字段要配套;只改 layout 但没有匹配访问依赖,容易形成“状态名对了,数据可见性没对齐”的错误。
queue ownership transfer 处理的是 queue family 之间的资源归属。一个 texture 由 transfer queue copy,随后由 graphics queue sample 时,资源既要完成 transfer write 到 shader read 的可见性,也要从 transfer family 释放给 graphics family。Vulkan 通常用 release barrier 和 acquire barrier 表达这件事:release 在源队列写出并释放所有权,acquire 在目标队列接收所有权并建立后续访问条件。semaphore 连接两个 submit 的执行顺序,barrier 描述资源状态和内存可见性。两者配合才能完成跨队列交接。
下面的图把贯穿 frame 的状态迁移压缩到一张资源路径图中。图中只表达依赖边界,省略 descriptor 和 pipeline state 细节。
这条路径中有两类依赖。CPU 到 transfer copy 依赖 staging memory 的写入可见性与提交顺序;compute 到 graphics 依赖 storage image 写入对 sampled read 可见;graphics 到 present 依赖 swapchain image 从 color attachment 写入迁移到 present 所需状态,并通过 semaphore 让 present queue 等到渲染完成。每条边都要回答“前序访问在哪个阶段产生、写了什么资源、后序访问在哪个阶段读取或写入、是否跨队列、是否改 layout”。
简化的 Vulkan synchronization2 伪代码可以把这四个字段看得更清楚。以下代码描述 texture 上传后给 fragment shader 采样的 barrier;真实工程还要填充 subresource range、queue family index 和 dependency info。
// 简化 C++ 风格伪代码:字段名保留 Vulkan synchronization2 语义。
barrier.srcStageMask = VK_PIPELINE_STAGE_2_TRANSFER_BIT;
barrier.srcAccessMask = VK_ACCESS_2_TRANSFER_WRITE_BIT;
barrier.dstStageMask = VK_PIPELINE_STAGE_2_FRAGMENT_SHADER_BIT;
barrier.dstAccessMask = VK_ACCESS_2_SHADER_SAMPLED_READ_BIT;
barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
barrier.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
barrier.image = uploadedTexture;
vkCmdPipelineBarrier2(commandBuffer, &dependencyInfo);
这段伪代码证明了一条同步判断:barrier 要同时描述“transfer 写”与“fragment shader 采样读”的 stage/access 对应关系,并把 image 从上传使用的 layout 迁移到采样使用的 layout。若后续访问发生在 compute shader,dstStageMask 应落到 compute shader;若后续使用 storage image 写入,newLayout 与 dstAccessMask 也要随之改变。
Metal 的等价判断不写成 stage/access/layout 三组字段,但资源 hazard 仍然存在。private texture 由 blit encoder 写入,随后由 render encoder 采样;开发者需要依赖 command buffer 内 encoder 顺序、resource usage 声明、必要的 fence 或 event 来表达写后读。managed storage 的 CPU/GPU 一致性还可能需要显式 synchronize 操作。Metal 隐藏了部分 layout 细节,但没有取消“先写完、后读到、同一资源范围、同一生命周期”这条工程约束。
95.3 正确构建同步管线
正确的同步管线从 frame graph 的资源依赖开始,而非从 API 对象名称开始。先列出每个 pass 的输入、输出、访问方式和队列,再为每条依赖选择最小充分的同步对象。贯穿 frame 可以拆成四个 pass:Upload、ComputeBloom、Composite、Present。每个 pass 都有明确的资源读写关系。
| Pass | 读取 | 写入 | 队列 | 同步输出 |
|---|---|---|---|---|
| Upload | staging buffer | uploaded texture | transfer | texture 可被后续 shader 读取 |
| ComputeBloom | uploaded texture | bloom mask | compute | bloom mask 写入完成并对 graphics 可见 |
| Composite | bloom mask、scene color | swapchain image | graphics | swapchain image 可 present |
| Present | swapchain image | display | present | 当前 frame 完成 |
从这张表构建同步管线时,先处理 swapchain 入口。AcquireNextImage 返回可用 image,并产生 image-available semaphore。graphics submit 等待该 semaphore,表示 swapchain image 已经可作为 color attachment 写入。若使用多帧并行,CPU 还要在重用第 N 帧资源前等待对应 fence;等待点应放在资源复用前,而不应放在每次提交后立刻等待。这样 CPU 可以继续准备后续 frame,GPU 也能保持队列中有足够工作。
接着处理 upload 到 shader 的依赖。若 upload 和 graphics/compute 在同一 queue 上,pipeline barrier 可以直接串起 transfer write 与 shader read。若 upload 在独立 transfer queue 上,则需要源队列 release barrier、目标队列 acquire barrier 和 queue semaphore。semaphore 保证目标队列 submit 不会早于源队列完成;release/acquire barrier 说明 texture 的所有权与访问状态如何交接。把这两层混在一起,会造成同步看似完整但资源状态缺失。
compute 到 graphics 的依赖要按实际资源访问写。ComputeBloom 以 storage image 或 storage buffer 形式写 bloom mask,Composite 以 sampled image 或 texture input 读取 bloom mask。这里是 RAW hazard,必须把 shader write 转成 sampled read 可见。若两个 pass 在同一 queue 内,一个 barrier 足以表达顺序、可见性和 image layout 转换;若它们在 async compute queue 与 graphics queue 之间运行,就要把 queue semaphore 与 queue ownership transfer 加入 frame graph。
渲染到 present 的依赖由 image layout transition 和 semaphore 共同表达。Composite pass 把 swapchain image 当 color attachment 写入;present queue 需要读取 present layout 下的 image。Vulkan 中通常在 render pass 末尾或显式 barrier 中把 swapchain image 转到 present layout,并在 graphics submit signal render-finished semaphore;present 等待该 semaphore 后再读取 image。Metal 中常见写法是在 command buffer 中编码 render pass,随后调用 present drawable,并在 command buffer commit 后由系统安排显示;跨 command buffer 或跨 queue 的复杂路径再引入 event 或 shared event。
frame-in-flight 的资源复用规则可以写成稳定顺序:先等待当前 frame slot 的 fence,重置 fence,更新该 slot 对应的 uniform/staging/descriptor/command buffer,获取 swapchain image,录制命令,提交时等待 acquire semaphore 并 signal render semaphore,最后 present 等待 render semaphore。这个顺序把 CPU 资源复用、swapchain image 可用性、GPU pass 依赖和 present 依赖放在不同层级。调试时可以按层级关停或替换同步对象,快速定位等待来源。
下面是一个工程化检查顺序,适合在 Vulkan 或 Metal 项目中迁移。第一步列 pass 和资源范围,第二步标记每条边的 hazard,第三步标记是否跨 queue,第四步标记 image layout 或 resource usage 变化,第五步选择同步对象,第六步用 validation layer、frame capture 或 GPU counter 复查等待位置。这个顺序比从“需要几个 barrier”开始更稳定,因为它先固定事实,再选择 API 表达。
在 Vulkan 调试中,同步验证可以报告许多读写 hazard、layout mismatch 和 missing barrier。报告的核心字段通常包含 resource、usage、prior usage、stage、access 和 command。阅读这类报告时应把它映射回 frame graph:哪个 pass 写了这个资源,哪个 pass 读了它,中间是否跨 queue,是否存在 layout transition,是否复用了同一 frame slot。RenderDoc 或 vendor profiler 能进一步确认 draw/dispatch 的实际顺序、resource state、render target 内容和 queue timeline。
在 Metal 调试中,Xcode GPU capture 能观察 command buffer、encoder、resource、texture usage 和依赖关系。若画面偶发旧帧、后处理 mask 为空或 CPU 读取 texture 得到旧数据,应优先检查 command buffer 顺序、storage mode、blit synchronize、resource hazard tracking 设定和 shared event/fence 是否覆盖跨队列边界。Metal 对 layout 的表达更隐式,排查时要把注意力放在 encoder 顺序、resource usage、storage mode 和 CPU/GPU 可见性上。
95.4 内存分配策略与性能提升技巧
内存分配和同步直接相连,因为资源放在哪种内存里决定谁能访问、访问是否需要 staging、是否需要 flush/invalidate、是否会引入额外 copy。贯穿 frame 中,CPU 生成噪声纹理数据,GPU 高频采样它。稳定方案通常是 CPU 写 host-visible staging buffer,transfer copy 到 device-local texture,shader 采样 device-local texture。这样 CPU 写入路径和 GPU 采样路径各自落到合适内存类型,代价是一条 transfer copy 和对应同步。
Vulkan 的 memory type 选择要从 usage 反推。高频 shader 采样的 texture 应优先放到 device-local memory;CPU 每帧更新的小 uniform buffer 可以放到 host-visible、host-coherent 或配合显式 flush 的 host-visible memory;大块静态 vertex/index buffer 常用 staging 上传后留在 device-local memory。host-coherent 减少显式 flush/invalidate 操作,但可能牺牲缓存策略;host-cached 对 CPU 读更友好,但 GPU 可见性和写入路径要按设备属性判断。跨平台代码应通过 physical device memory properties 查询,而非写死某个 memory type index。
suballocation 解决的是大量小资源直接调用底层分配接口带来的开销和碎片。工程中常用大块 memory block,再按 alignment、size、resource lifetime 和 usage 切分给 buffer 或 image。Vulkan 项目常见做法是使用 Vulkan Memory Allocator 这类 allocator 层,把 memory type 选择、block 管理、defragmentation 和 allocation metadata 封装起来。封装层仍然需要保留 usage 语义:动态 uniform、staging、static texture、render target、readback buffer 应落到不同池或不同策略。
Metal 的 storage mode 表达类似的资源可见性取舍。shared storage 适合 CPU/GPU 都要访问的小型动态数据,private storage 适合 GPU 高频访问的 texture、render target 和大 buffer,managed storage 在部分 macOS 路径上需要显式同步 CPU/GPU 副本。heap 可以把多个资源放入同一内存管理区域,配合 aliasing 和 lifetime 分析降低峰值内存。选择 storage mode 时,应先问资源的主要访问者是谁、每帧是否更新、是否需要 CPU readback、是否作为 render target 高频写入。
staging buffer 的设计要和 frame-in-flight 对齐。若有 3 个 frame slot,staging ring 至少要能容纳 GPU 尚未完成读取的上传数据。CPU 写入 staging block 后提交 copy,只有对应 fence signaled 后,这段 staging block 才能回到空闲列表。若 staging ring 太小,CPU 会频繁等待 GPU;若过大,会增加内存峰值和缓存压力。合理策略是记录每段 allocation 属于哪个 frame fence,回收时按 fence 完成值批量释放。
residency 和 fragmentation 属于更高层的内存稳定性问题。高分辨率 texture、shadow map、G-buffer、历史帧 buffer 和 acceleration structure 都可能推高 GPU memory 峰值。显式 API 让开发者更接近内存生命周期,因此资源池要记录创建时机、最后使用帧、尺寸、格式、usage、heap/pool 归属和 aliasing 条件。调试内存问题时,先统计 per-pass 峰值,再看 lifetime 重叠,最后决定合并、复用、压缩格式、降低分辨率或拆分 streaming。
内存优化的收益必须对应瓶颈类型。把 texture 放到 device-local memory 可以降低 shader sampling 路径上的外部访问成本;使用 staging 批量上传可以减少小块提交和 CPU/GPU 冲突;suballocation 可以降低分配开销与碎片;ring buffer 可以让 CPU 更新动态数据时减少等待;heap aliasing 可以降低瞬时峰值。若瓶颈来自 shader ALU、overdraw 或 draw call 提交,这些内存策略的收益会被其他瓶颈掩盖。优化前要用工具确认等待、带宽、分配开销或内存峰值确实处在主路径上。
一个实用的 buffer/texture 分配矩阵如下:
| 资源类型 | 主要访问者 | 推荐路径 | 主要同步点 |
|---|---|---|---|
| 每帧 uniform buffer | CPU 写,GPU 读 | host-visible ring buffer | frame fence 回收 slot |
| 静态 vertex/index buffer | GPU 读 | staging 上传到 device-local | transfer write 到 vertex/index read |
| 静态 texture | GPU 采样 | staging 上传到 device-local image | transfer write 到 shader read + layout transition |
| render target / G-buffer | GPU 写,GPU 读 | device-local / private texture | attachment write 到 shader read |
| readback buffer | GPU 写,CPU 读 | host-visible readback memory | GPU fence + invalidate 或同步操作 |
| 临时后处理 texture | GPU 写读 | transient pool 或 heap | pass 之间的 hazard barrier |
这张矩阵把内存选择和同步点绑定在一起。每种资源都应同时有 allocation policy 和 synchronization policy。只记录“这是 texture”不足以决定放在哪;要记录它由谁写、谁读、多久读写一次、是否跨队列、是否被 CPU 读取、是否与其他临时资源 lifetime 错开。
95.5 常见同步错误与调试方法
同步错误的诊断入口应从症状回到资源路径。画面随机闪烁通常指向 RAW hazard、layout transition 缺失或跨 frame 资源复用过早;GPU 时间突然升高通常指向过宽 barrier、频繁 queue wait、CPU 每帧等待 fence 或 layout transition 放置过密;CPU 读回数据陈旧通常指向 GPU fence、cache invalidate、Metal managed synchronize 或 readback buffer 生命周期;validation 报 layout mismatch 通常说明 image 的实际访问路径和记录状态不一致。
第一类常见错误是 barrier stage/access 不匹配。compute shader 写 storage image,fragment shader 采样同一 image;若 barrier 只等待 transfer stage,或 dstAccessMask 没有包含 sampled read,后续采样可能读到旧数据。修复方法是回到 pass 表,写清前序 stage/access 与后序 stage/access,再把 barrier 放在两次访问之间。若资源范围只覆盖某个 mip 或 array layer,subresource range 也要与真实访问一致。
第二类错误是 layout transition 与实际 usage 脱节。texture 上传后继续保持 transfer destination layout,shader 采样时就会触发布局错误;swapchain image 渲染完成后没有转到 present layout,present 路径就缺少明确状态。修复方法是把每个 image 的 layout 当作状态机记录:undefined 或 initial 进入 transfer destination,上传完成进入 shader read,作为 render target 进入 color attachment,显示前进入 present。复杂 frame graph 中可以让资源系统自动插入 transition,但自动系统仍要基于准确 usage 声明。
第三类错误是 queue ownership 与 semaphore 分离。transfer queue 完成 copy 后 signal semaphore,graphics queue 等待 semaphore,但 image 没有 release/acquire ownership transfer。此时执行顺序成立,资源所有权仍然缺失。修复方法是把 queue family index 纳入 barrier 设计,并在源队列 release、目标队列 acquire。若工程统一使用 graphics queue 处理 upload,ownership transfer 可以简化;代价是失去一部分异步拷贝并行空间。
第四类错误是 frame-in-flight 资源复用过早。CPU 把第 N 帧的 uniform ring 段或 descriptor set 写给第 N+K 帧时,第 N 帧 GPU 仍在读这段内存。症状可能是模型矩阵偶发错乱、材质参数串帧、后处理强度跳变。修复方法是把 per-frame 资源按 frame slot 分组,并把每个 slot 的复用绑定到对应 fence 或 timeline semaphore 完成值。动态 buffer 还要满足 offset alignment 和最小粒度要求。
第五类错误是过度同步。许多项目为了快速压住 validation 报告,把 barrier 写成 all commands 到 all commands,access 写成 memory read/write,或每个 pass 后都插入全局 barrier。这样会把原本可以并行的 transfer、compute、graphics 工作串起来。调试过度同步时,要看 GPU timeline 上的空洞、queue 间等待、pass 开始时间和缓存/带宽指标。收窄 stage/access、合并相邻 barrier、按资源范围同步、把 queue wait 放到真正依赖边界,通常能恢复并行度。
第六类错误是 CPU/GPU 可见性处理缺失。Vulkan 的 non-coherent host-visible memory 需要按 nonCoherentAtomSize 对齐 flush/invalidate 范围;CPU 写后提交给 GPU 前要 flush,GPU 写后 CPU 读取前要等待 GPU 完成并 invalidate。Metal managed storage 在需要 CPU/GPU 副本一致的路径上也要执行同步操作。症状常表现为 readback 偶发旧值或上传数据只在部分设备上稳定。修复要从 memory property 和 storage mode 入手,而非只改 barrier。
实际排查时可以使用一个固定顺序:先在 frame graph 中定位资源名和 pass 边界,再确认 hazard 类型,然后确认是否跨 queue,接着检查 layout/resource usage,再检查 CPU/GPU fence 或 event,最后用工具确认修复后 GPU timeline 是否减少无效等待。这个顺序能把同步问题拆成可验证对象:资源、阶段、访问、队列、layout、内存可见性、等待位置。
下面的故障映射表可以作为复盘模板:
| 症状 | 优先检查 | 可观察证据 | 稳定修复方向 |
|---|---|---|---|
| 后处理 mask 偶发为空 | compute write 到 graphics sample | validation hazard、capture 中 texture 内容 | shader write 到 sampled read barrier |
| 上传纹理首帧黑屏 | transfer destination 到 shader read | image layout、first use 状态 | 上传后 transition 到采样 layout |
| present 报错或画面撕裂状异常 | render 到 present | swapchain image final state、semaphore wait | render-finished semaphore + present layout |
| CPU 每帧卡住 | fence 等待位置 | CPU timeline、queue idle | fence 只保护 frame slot 复用 |
| async compute 没有并行收益 | queue wait 与 barrier 过宽 | GPU queue timeline | 收窄依赖边界并减少全局等待 |
| readback 数据旧 | GPU 完成与 CPU cache | fence、invalidate、storage mode | 等待完成后执行 CPU 可见性同步 |
本章的同步判断最终要回到一条主线:显式 API 让开发者负责把资源访问事实写清楚。同步对象不提供抽象安全感,它们只表达具体依赖。内存策略也不独立存在,它决定资源如何被 CPU 和 GPU 访问、何时需要 copy、何时需要 flush/invalidate、何时能被复用。只要能把一帧拆成 pass、资源、访问、队列和生命周期,就能把大多数 Vulkan/Metal 同步问题变成可定位、可验证、可复盘的工程问题。
最小自检任务
给定一个简化 frame:CPU 每帧生成一张 1024×1024 的噪声纹理数据,写入 staging buffer;transfer queue 把数据 copy 到 noiseTexture;compute queue 读取 noiseTexture 并写入 bloomMask;graphics queue 采样 bloomMask 合成到 swapchain image;present queue 显示 swapchain image。工程使用 3 个 frame-in-flight。请写出同步与内存检查顺序,说明每一步要确认的资源状态、队列关系和 CPU/GPU 复用条件。
答案要点
先把 frame 拆成 Upload、Compute、Composite、Present 四个 pass,并记录资源访问:staging buffer 由 CPU 写、transfer 读;noiseTexture 由 transfer 写、compute 读;bloomMask 由 compute 写、graphics 读;swapchain image 由 graphics 写、present 读。然后为每条边标注 hazard:noiseTexture 是 transfer write 到 shader read,bloomMask 是 shader write 到 sampled read,swapchain image 是 color attachment write 到 present read。
接着检查队列关系。transfer 到 compute、compute 到 graphics、graphics 到 present 都跨队列时,需要 queue semaphore 或 timeline semaphore 表达执行顺序;涉及 Vulkan queue family 差异时,noiseTexture 和 bloomMask 还需要 release/acquire ownership transfer。每个 image 的 layout 要随访问变化:upload 后从 transfer destination 进入 shader read,bloomMask 从 storage write 用途进入 sampled read 用途,swapchain image 从 color attachment 进入 present。
再检查内存路径。staging buffer 属于 CPU 写入路径,应按 frame slot 或 ring allocation 管理;CPU 写入后要满足 host-visible memory 的 flush 要求。noiseTexture 和 bloomMask 应落在 GPU 访问友好的 device-local 或 private storage 路径。3 个 frame-in-flight 下,staging block、per-frame descriptor、command buffer 和动态 uniform 的复用要等待对应 frame fence 或 timeline semaphore 完成值,防止 CPU 覆盖 GPU 仍在读取的数据。
最后用工具复查。Vulkan 路径中看 synchronization validation 是否仍报告 hazard、layout mismatch 或 queue ownership 问题;frame capture 中确认 copy、dispatch、draw、present 顺序与 resource state;GPU timeline 中确认没有 all commands 级别的全局等待把队列并行压扁。Metal 路径中看 command buffer/encoder 顺序、resource usage、storage mode、fence/shared event 和 CPU/GPU synchronize 是否覆盖同样的边界。核心结论是每条同步都要对应一条具体资源依赖,每块内存都要对应一个明确访问者和回收条件。
本章知识点总结
- 同步作用域:fence 解决 CPU 等 GPU 的资源复用问题,semaphore 解决 GPU 队列之间的执行依赖,barrier 解决命令流内资源访问的可见性与状态迁移。
- 贯穿路径:texture upload、compute 写入、graphics 采样和 present 组成一条可追踪的资源依赖链。
- Hazard 判断:RAW、WAW 和 WAR 要按资源范围、前序 stage/access、后序 stage/access 和队列关系分析。
- Access Mask:stage mask 描述等待发生的管线阶段,access mask 描述对应读写语义,两者必须贴合真实访问。
- Layout Transition:image layout 表达 image 面向的访问形态,上传、采样、render target 和 present 路径需要不同状态。
- Queue Ownership:跨 queue family 使用资源时,semaphore 只表达执行顺序,release/acquire barrier 表达资源所有权交接。
- 同步管线:正确顺序来自 frame graph 的 pass、资源、访问方式和队列关系,API 对象只负责表达这些事实。
- Frame Slot:多帧并行下,per-frame buffer、descriptor、command buffer 和 staging block 的复用必须绑定到 fence 或 timeline 完成值。
- 内存类型:高频 GPU 访问资源适合 device-local 或 private storage,CPU 更新资源适合 host-visible、shared 或 staging 路径。
- Suballocation:大块内存切分能降低分配开销与碎片,但仍要按 usage、alignment、生命周期和回收条件管理。
- Staging Ring:上传环形缓冲要记录每段 allocation 所属 frame fence,完成后再批量回收。
- 过度同步:过宽 stage/access、全局 barrier 和过早 fence wait 会增加 queue 空洞和 GPU stall。
- 调试顺序:先定位资源和 pass 边界,再判断 hazard、queue、layout、CPU/GPU 可见性和等待位置。
- 工具证据:validation、frame capture、GPU timeline 和 memory report 分别回答 hazard、资源状态、等待位置和内存峰值问题。