Chapter 110: Compute Shader Patterns
Compute Shader 的主问题是:怎样把一段数据并行工作放到 GPU 上执行,并让它和传统渲染 pass、资源状态、工作组规模和性能证据形成可复查的闭环。本章用一个贯穿材料展开:一帧粒子系统先由 Compute Shader 更新粒子位置和速度,再由 graphics pass 读取同一批粒子数据完成绘制。这个例子足够小,却覆盖了 compute 工作中最常见的路径:storage buffer 或 UAV 读写、dispatch 规模、线程索引、barrier、图形消费和瓶颈定位。
读完本章后,读者应能定位一段 compute 工作处在 frame 的哪个位置,判断它适合 map、stencil、reduction、scan、scatter、compaction 还是 tiled shared memory 模式,追踪 Vulkan 或 Direct3D 中资源如何绑定、派发和同步,并用 dispatch size、occupancy、memory bandwidth、barrier、readback 和 profiler counter 排查瓶颈。
Compute Shader 的工程价值来自两个条件。第一,任务能拆成大量相似的小工作单元,例如每个粒子、每个像素、每个 tile、每个 meshlet 或每个对象一份计算。第二,数据能在 GPU 内部持续流动,例如 compute pass 写出的 buffer 直接成为 vertex shader、indirect draw、post-process 或后续 compute pass 的输入。Khronos 的 Vulkan Compute Shader 教程把 GPU 粒子更新作为典型示例,核心判断正是把数据留在 GPU 内存中并减少 CPU 往返。
本章会把 Compute Shader 放在 frame 内观察。它是一种可插入渲染图的 GPU 工作阶段,适合放在具体 frame 路径中观察。它可以产生图像、更新 buffer、准备 draw 参数、过滤可见对象,也可以服务离线工具。所有这些用法最终都要回到同一条检查链:输入资源是否正确,线程映射是否完整,写入结果是否对后续阶段可见,性能现象是否能落到执行、带宽、同步或读回上。
110.1 Compute Shader 与传统渲染管线的关系
Compute Shader 是一个可编程 GPU 阶段,它按照开发者给出的 dispatch 网格启动大量 invocation。每个 invocation 通过全局线程 ID、局部线程 ID 和工作组 ID 找到自己负责的数据。传统 vertex shader 的输入来自顶点装配,fragment shader 的输入来自光栅化产生的片元;Compute Shader 的输入来自显式绑定的 buffer、image、texture、sampler 和常量数据,输出通常写回 storage buffer、storage image 或 Direct3D 的 UAV。
这个差异改变了工作组织方式。graphics pipeline 的顺序通常是 vertex processing、primitive assembly、rasterization、fragment shading、depth/blend 和 render target 写入。Compute Shader 进入 frame 时,需要开发者自己定义“一个线程负责什么”。在粒子系统中,最直接的映射是一个线程负责一个粒子:线程读取 particlesIn[i],根据速度和时间步长更新位置,再写入 particlesOut[i]。后续 graphics pass 把输出 buffer 作为 vertex buffer 或 shader resource 读取,最终在屏幕上显示粒子。
下面的图只描述本章贯穿材料的资源流。它的边界是单帧内的 compute 更新和图形绘制,暂时不展开跨队列 async compute、GPU-driven indirect draw 和多帧资源回收。
图中的关键点是 barrier 位于 producer 和 consumer 之间。Compute pass 是 producer,因为它写入粒子输出 buffer;draw pass 是 consumer,因为它读取同一批粒子数据。Vulkan 中通常通过 pipeline barrier 或 synchronization2 表达访问依赖;Direct3D 12 中通常通过 resource barrier 或 UAV barrier 表达状态和访问顺序。Microsoft 的 D3D12 ResourceBarrier 文档明确把资源状态管理责任放到应用侧,这决定了 compute 与 graphics 混用时必须把资源访问顺序写清楚。
Compute Shader 与传统渲染管线的关系可以按三个层级理解。第一层是数据层:Compute Shader 读写通用资源,graphics pipeline 消费这些资源生成可见图像。第二层是状态层:同一份 buffer 或 image 在不同阶段有不同访问方式,需要 API 状态、descriptor 或 binding 反映这种访问。第三层是调度层:compute dispatch 和 draw call 都进入 command queue,GPU 按依赖关系执行,barrier 会影响并行度和等待位置。
在粒子例子中,输入 buffer、输出 buffer 和 frame constants 构成最小资源集合。输入 buffer 可读,输出 buffer 可写,frame constants 保存 dt、粒子数量和边界参数。Compute dispatch 完成后,输出 buffer 被 graphics pass 读取。若采用 ping-pong 双缓冲,下一帧交换输入和输出角色,数据更新路径保持稳定。
一个常见失败情况是把 compute 看成“shader 版本的 CPU 循环”。CPU 循环可以依赖顺序、临时容器和复杂分支;Compute Shader 的稳定做法是先把数据排列成连续数组,再让每个线程根据 ID 访问固定位置。需要跨元素通信时,优先判断它发生在同一工作组内部、全局 buffer 之间,还是多个 dispatch 之间。不同层级的同步成本差异很大,后续小节会把它落到具体模式上。
110.2 常见并行计算模式总结
Compute Shader 的模式由“线程如何映射到数据”和“线程之间是否需要通信”决定。粒子更新属于 map 模式:每个粒子独立更新,线程之间没有数据依赖。图像模糊属于 stencil 或 convolution 模式:每个像素读取周围邻域,再写出一个像素。亮度统计属于 reduction 模式:大量输入元素被逐步合并成少量结果。可见对象筛选属于 scatter、compaction 或 prefix sum 模式:线程根据条件写入可变长度输出,需要额外结构维护写入位置。
下表把常见模式放到同一组维度中比较。这里的“输出形态”比名称更有价值,因为 API 代码最终要为输出资源、barrier 和后续消费方式服务。
| 模式 | 线程映射 | 典型输入 | 输出形态 | 主要风险 | 图形工作流例子 |
|---|---|---|---|---|---|
| Map | 一个线程处理一个元素 | 粒子、顶点、像素、对象数组 | 等长 buffer 或 image | 读写越界、分支发散、带宽浪费 | 粒子更新、骨骼预处理、颜色校正 |
| Stencil / Filter | 一个线程处理一个像素或 cell | 邻域纹理、体数据、网格 cell | 等尺寸 image 或 buffer | 重复采样、边界处理、cache 命中低 | blur、edge detect、fluid grid step |
| Reduction | 多个线程合并一段数据 | luminance、histogram、误差值 | 少量标量或小 buffer | 原子竞争、组间合并、精度误差 | 自动曝光、统计最大速度、debug counter |
| Prefix Sum / Scan | 每个元素计算前缀位置 | 可见性标记、粒子存活标记 | 索引偏移表 | 多阶段 dispatch、同步链长 | compaction、stream compaction、LOD bucket |
| Scatter / Gather | 线程按索引读写非连续位置 | 邻接表、hash grid、tile list | 稀疏 buffer 或列表 | 随机访问、写冲突、atomic 成本 | light binning、particle grid、cluster list |
| Tiled Shared Memory | 一个 workgroup 处理一个 tile | 图像 tile、邻域数据、局部数组 | tile 输出 | shared memory 容量、bank conflict、halo 加载 | tile blur、histogram、局部光照列表 |
| Indirect Preparation | 一个线程或一组线程写命令参数 | 可见对象、meshlet、draw 参数模板 | indirect argument buffer | 参数格式、barrier、调试困难 | GPU culling 后生成 draw 参数 |
Map 模式是 compute 的入门模式,也是很多图形任务的基线。下面的 HLSL 片段展示一个粒子更新。它的目的在于固定三个关系:线程 ID 映射到粒子索引,输入和输出 buffer 分离,线程数量可能大于粒子数量时使用边界检查。
struct Particle {
float2 position;
float2 velocity;
};
StructuredBuffer<Particle> particlesIn : register(t0);
RWStructuredBuffer<Particle> particlesOut : register(u0);
cbuffer SimParams : register(b0) {
uint particleCount;
float deltaTime;
float2 bounds;
};
[numthreads(256, 1, 1)]
void CSMain(uint3 dispatchId : SV_DispatchThreadID) {
uint index = dispatchId.x;
if (index >= particleCount) {
return;
}
Particle particle = particlesIn[index];
particle.position += particle.velocity * deltaTime;
if (abs(particle.position.x) > bounds.x) {
particle.velocity.x *= -1.0;
}
if (abs(particle.position.y) > bounds.y) {
particle.velocity.y *= -1.0;
}
particlesOut[index] = particle;
}
这段代码对应的 dispatch 规模是 ceil(particleCount / 256) 个工作组。numthreads(256, 1, 1) 定义每个工作组的局部线程数量,SV_DispatchThreadID.x 给出全局线性索引。最后一个工作组可能只有部分线程对应真实粒子,边界检查把多余线程收束掉。这个检查会产生少量分支,但它只发生在尾部工作组,成本通常低于为了精确整除而重排资源的复杂度。
Stencil 模式的关键差异是邻域读取。一个 9-tap blur 会让一个输出像素读取 9 个输入像素。直接从全局纹理采样的代码容易写,带宽压力也容易上升。数据复用强时,可以让一个 workgroup 先把 tile 和 halo 区域加载到 shared memory,再由组内线程复用。这个策略用 shared memory 容量换取全局带宽下降,适合 tile 尺寸稳定、邻域半径可控的 filter。
Reduction 模式的核心难点是合并顺序。自动曝光可以先让每个工作组把一段亮度数据合并为一个局部平均值,再启动第二个 dispatch 合并这些局部结果。一个 dispatch 内的 workgroup 之间没有全局同步点,因此全局 reduction 通常拆成多次 dispatch 或使用 atomic 写入少量全局位置。atomic 简化结构,但在高竞争位置会把大量线程串到同一个写入点,吞吐会下降。
Prefix sum 和 compaction 经常成对出现。可见性筛选会先得到一个布尔标记数组,例如 visible[i]。prefix sum 把标记转换成写入偏移,compaction 再把可见对象写入紧凑列表。这个模式的收益是后续 draw、light list 或 simulation 只处理有效元素;成本是多阶段 dispatch、额外中间 buffer 和同步链。对象数量大、剔除比例高时,收益更容易覆盖成本。
Scatter 和 gather 的边界要靠冲突分析决定。Gather 是线程按自己的 ID 写出固定位置,从多个输入位置读取数据;Scatter 是线程把结果写到由数据决定的位置。Scatter 常需要 atomic、锁自由队列或预先计算出的偏移。对于 light binning,线程可能把同一盏灯写入多个 tile list;对于 particle grid,多个粒子可能写入同一个 cell。写冲突密集时,先分桶、排序或两阶段计数会比直接 atomic 更稳定。
这些模式可以在同一个 pass 或同一条 compute pipeline 中组合。一个真实 compute pass 经常组合多个模式:先 map 更新粒子,再 scatter 到 spatial grid,再 reduction 统计活跃数量,最后 compaction 生成可绘制列表。工程判断顺序应从输出需求开始:输出长度是否固定,是否需要邻域数据,是否存在跨元素合并,是否存在写冲突,后续阶段怎样消费结果。这个顺序比直接背模式名称更可迁移。
110.3 在 Vulkan/Direct3D 中使用 Compute Shader
在显式图形 API 中使用 Compute Shader,可以拆成五个动作:创建可读写资源,声明绑定布局,创建 compute pipeline,录制 dispatch,声明后续访问依赖。Vulkan 和 Direct3D 12 的对象名称不同,但这条路径一致。Khronos 的 Vulkan 文档把 storage buffer、storage image、descriptor set 和 compute queue 放在同一章节解释,说明 compute 与 graphics 共享资源绑定模型,只是 pipeline bind point 和 shader stage 变成 compute。
在 Vulkan 中,粒子 buffer 常见的 usage 组合是 storage buffer 加 vertex buffer。前者允许 compute 写入,后者允许 graphics pass 读取顶点数据。如果通过 staging buffer 初始上传,还需要 transfer destination usage。实际工程中还要选择内存类型,初始数据从 host 可见内存上传到 device local 资源,后续每帧由 GPU 自己更新。
Vulkan 录制 compute dispatch 的骨架如下。代码是结构化伪代码,重点是对象顺序和访问关系。
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, particleComputePipeline);
vkCmdBindDescriptorSets(
cmd,
VK_PIPELINE_BIND_POINT_COMPUTE,
computePipelineLayout,
0,
1,
&particleDescriptorSet,
0,
nullptr
);
uint32_t groupCountX = (particleCount + 255) / 256;
vkCmdDispatch(cmd, groupCountX, 1, 1);
// 伪代码:声明 compute 写入对后续 vertex/shader 读取可见。
RecordBufferBarrier(
cmd,
particleOutputBuffer,
VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT,
VK_ACCESS_2_SHADER_WRITE_BIT,
VK_PIPELINE_STAGE_2_VERTEX_ATTRIBUTE_INPUT_BIT,
VK_ACCESS_2_VERTEX_ATTRIBUTE_READ_BIT
);
这段骨架回答三个问题。第一,descriptor set 把 particlesIn、particlesOut 和参数 buffer 交给 compute pipeline。第二,dispatch 的 group count 由数据规模和 workgroup size 共同决定。第三,barrier 连接 compute 写入和后续读取。若后续 pass 通过 vertex input 消费输出 buffer,目标阶段是 vertex attribute input;若后续 pass 在 vertex shader 或 fragment shader 中通过 shader resource 读取,目标阶段和访问类型要改成对应的 shader read。
Direct3D 12 的路径把 descriptor heap、root signature、pipeline state 和 UAV/SRV/CBV 绑定到 command list。Compute Shader 输出通常写入 UAV;后续 draw 读取时,可能作为 SRV、vertex buffer 或 indirect argument buffer 使用。Microsoft 文档把 UAV barrier 定义为让同一 UAV 的先前读写完成后再开始未来读写,这正好对应多次 dispatch 之间或 compute 写后继续 compute 读写的顺序要求。
Direct3D 12 录制 dispatch 的骨架如下。代码只保留 compute 相关调用,descriptor 表和资源句柄在真实工程中会由资源系统管理。
commandList->SetPipelineState(particleComputePSO.Get());
commandList->SetComputeRootSignature(particleRootSignature.Get());
commandList->SetDescriptorHeaps(1, descriptorHeap.GetAddressOf());
commandList->SetComputeRootDescriptorTable(0, particleSrvUavTable);
commandList->SetComputeRootConstantBufferView(1, frameParamsGpuAddress);
UINT groupCountX = (particleCount + 255) / 256;
commandList->Dispatch(groupCountX, 1, 1);
D3D12_RESOURCE_BARRIER uavBarrier =
CD3DX12_RESOURCE_BARRIER::UAV(particleOutputBuffer.Get());
commandList->ResourceBarrier(1, &uavBarrier);
这里的 UAV barrier 适合表达 UAV 读写之间的顺序。若资源从 UAV 写入转到 vertex buffer、SRV、copy source 或 indirect argument 等状态,通常还需要 transition barrier。D3D12 的状态模型把读写状态分开,带写入含义的状态在同一时间通常只能有一个;因此 compute pass 写入后,后续读者的访问方式要体现在资源状态或隐式 promotion 语义中。
Debug 和 readback 需要单独处理。GPU 上的 buffer 内容要被 CPU 检查时,通常要复制到 readback heap 或 host visible staging 资源,再通过 fence 等待 GPU 完成。这个等待会把异步 GPU 工作拉回 CPU 时间线,频繁读回会让 frame 失去并行。稳定做法是把 readback 限制在调试路径、统计路径或低频采样路径中,并在 profiler 中把 readback wait 单独标记出来。
跨 API 迁移时,应把对象映射成语义关系。Vulkan 的 descriptor set 和 Direct3D 的 descriptor table 都在回答“shader 从哪里读写资源”。Vulkan 的 pipeline barrier 和 D3D12 的 resource barrier 都在回答“前一次访问和后一次访问之间有什么顺序和可见性要求”。Vulkan 的 command buffer 和 D3D12 的 command list 都在承载可提交的 GPU 工作。名称差异不影响这条主线。
110.4 多核协调与工作组安排策略
工作组安排要同时考虑数据规模、硬件执行粒度、shared memory、寄存器压力、分支发散和内存访问形态。Compute Shader 中的“多核协调”指开发者通过 workgroup size、dispatch grid、资源布局和同步点影响 GPU scheduler 能同时保留多少 workgroup、每个 wave 或 warp 内的 lane 是否有效、内存访问是否连续。
第一个判断是线程映射。粒子更新这种线性数组任务适合一维 dispatch:globalIndex = groupId.x * localSize + localId.x。二维图像任务适合二维 dispatch:pixel = dispatchId.xy。三维体数据适合三维 dispatch。映射越贴近数据布局,越容易获得连续访问和简单边界检查。把二维图像强行压成复杂的一维索引也能运行,但调试边界、tile 复用和图像坐标解释都会变得更难。
第二个判断是 workgroup size。常用起点是 64、128、256 或 8×8、16×16。它们并非通用最优值,只是更容易匹配常见 wave 或 warp 执行粒度,并让每组拥有足够线程覆盖内存延迟。较小 workgroup 可能增加调度开销和占用不足;较大 workgroup 可能提高寄存器与 shared memory 占用,降低同时驻留的 workgroup 数量。稳定策略是从简单值开始,用 profiler 观察 occupancy、register pressure、shared memory usage 和 elapsed time。
第三个判断是 shared memory 是否能形成复用。对 map 模式,线程读取自己的粒子并写出自己的粒子,shared memory收益有限。对 stencil 模式,多个相邻像素会读取重叠邻域,shared memory 可以减少重复全局读取。对 reduction 模式,shared memory 常用来保存组内局部结果,再由少数线程写出组结果。shared memory 的成本包括容量限制、组内同步和 bank conflict,收益来自减少全局带宽和原子竞争。
第四个判断是内存访问是否 coalesced。Coalesced access 指同一 wave 或 warp 内相邻 lane 访问连续或相近地址,硬件可以用更少 memory transaction 服务这些访问。粒子结构如果写成 position, velocity, color, lifetime 的数组结构,更新只需要 position 和 velocity 时仍可能拉取 color 和 lifetime 所在 cache line。把高频更新字段拆成结构化数组可以降低无效带宽,但会提高资源数量和绑定复杂度。选择 AoS 还是 SoA,要看 shader 每帧读取哪些字段以及后续 graphics pass 如何消费。
第五个判断是分支发散。GPU 的 wave 或 warp 通常按同一指令流批量执行,多条路径会让 lane 通过 mask 轮流参与。粒子边界反弹里的两个 if 可能产生轻度发散;复杂状态机、材质分支或邻域条件会产生更高发散。稳定做法是把数据按状态分组,或者把高分歧任务拆成多个 pass。拆分会增加 dispatch 和 barrier 成本,因此要用 profiler 观察分支成本和同步成本的权衡。
粒子更新的工作组安排可以按下面顺序落地。先用一维数组保存粒子,选择 256 线程一组,dispatch 数量按粒子总数向上取整。再检查尾部越界保护是否正确。接着观察 GPU 时间与粒子数量是否近似线性增长。若增长曲线在某个规模后变陡,继续看 memory throughput、cache hit、occupancy 和是否存在 readback wait。若 compute 时间稳定而 draw 时间上升,瓶颈已经从 simulation 转移到渲染消费阶段。
多 dispatch 之间的协调要控制全局同步点。一个 dispatch 内,工作组之间没有可移植的全局 barrier;跨工作组依赖通常通过结束当前 dispatch、插入 barrier、启动下一次 dispatch 来表达。reduction、scan 和 compaction 因此天然更像多阶段 pipeline。设计时应明确每个阶段的输入、输出、资源状态和中间 buffer 生命周期,防止把所有逻辑压进一个难以验证的超大 shader。
110.5 Compute Shader Bottleneck Diagnosis and Tuning
Compute Shader 的瓶颈诊断要从 frame 证据开始。先用 CPU timer、GPU timestamp 或图形调试器把 compute dispatch 单独标记出来,确认它在总帧耗时中的位置。再区分它拖慢的是 GPU 执行、CPU 提交、资源转换、队列等待还是 CPU 读回。只有定位到责任层级,workgroup size、shared memory、barrier 和 buffer layout 的调整才有方向。
第一类瓶颈是 dispatch size 错误或线程浪费。症状是 GPU 时间随 dispatch group 数量增长,但实际有效元素数量没有增长。常见原因包括 group count 计算错误、二维图像 dispatch 多覆盖大量空区域、尾部越界保护占比过高、每个线程处理过少工作导致调度开销占比上升。检查顺序是记录元素数量、local size、group count、实际启动线程数和有效线程比例。对于 particleCount = 100000、localSize = 256,group count 应为 391,启动线程数为 100096,尾部浪费只有 96 个线程。
第二类瓶颈是 occupancy 受限。Occupancy 描述同一时刻 GPU 上能驻留多少 wave、warp 或 workgroup。它受寄存器数量、shared memory、线程数量和硬件限制共同影响。症状是 ALU 利用率低、memory stall 多、提高粒子数量后吞吐没有上升。调优路径是减少单线程临时变量,降低 shared memory 使用,测试不同 workgroup size,并观察 profiler 中的 occupancy 或 active wave 指标。不同厂商工具指标名称不同,判断对象仍然是“硬件是否有足够可运行工作来覆盖延迟”。
第三类瓶颈是 memory bandwidth。Map 模式若每个线程只做少量算术,却读取和写入较大的结构体,瓶颈通常落到全局内存。症状是 memory throughput 接近平台上限,ALU 指标不高,缩减字段或压缩格式后 GPU 时间下降。调优顺序是先减少每线程读写字节数,再改善访问连续性,再考虑缓存和 shared memory。粒子更新只需要 position、velocity 和少量状态时,可以把渲染颜色、随机种子和历史轨迹分离到低频 buffer。
第四类瓶颈是 barrier 与资源转换过密。每个 barrier 都在表达访问依赖,它可能触发 cache flush、layout transition 或队列等待。症状是单个 dispatch 很短,但多个 compute pass 之间出现空洞,GPU timeline 上有明显等待。检查顺序是列出 producer、consumer、资源、源访问、目标访问和是否跨队列。能合并的 pass 尽量合并;能批量声明的资源转换尽量集中;只读到只读的连续访问通常不需要写后读级别的等待。D3D12 文档也建议在可行时批量提交 transition,并说明过多资源转换会带来额外开销。
第五类瓶颈是 readback 和 fence wait。Compute Shader 经常用于统计、调试和 GPU culling,开发者容易把结果读回 CPU 再做判断。症状是 GPU dispatch 本身耗时低,但 CPU 线程在 fence、map readback 或等待 query 结果处停住。调优顺序是先把 readback 从每帧改成低频采样,再把判断尽量留在 GPU 上,例如写入 indirect argument buffer 或 debug counter buffer。必须读回时,使用延迟几帧的 ring buffer,让当前帧读取更早帧的 GPU 结果。
第六类瓶颈是 atomic 和写冲突。Compaction、histogram、light binning 和 particle grid 都可能让大量线程写同一个地址。症状是元素数量增加后耗时非线性上升,profiler 显示 atomic 或 memory serialization 成本较高。调优顺序是先把全局竞争拆到局部工作组,使用 shared memory 做组内统计,再把每组结果合并到全局;或者使用 prefix sum 计算唯一写入位置。这个策略用更多阶段换取更少冲突,是否划算取决于输出稀疏程度和后续 pass 的收益。
一套可复用诊断顺序如下。先确认 compute dispatch 是否真在关键路径上;再确认 group count、线程映射和边界检查;再看资源绑定和 barrier 是否满足 producer-consumer 关系;接着用 profiler 区分 occupancy、ALU、memory bandwidth、atomic、barrier 和 readback;最后只改一个变量并复测。这个顺序能防止把所有问题都归因到 workgroup size,也能把视觉错误和性能瓶颈分开处理。
回到粒子贯穿材料,若画面上粒子位置不更新,先查 descriptor 是否绑定了当前帧输入和输出 buffer,再查 dispatch 是否启动足够线程,再查 barrier 后 graphics pass 是否读取输出 buffer。若画面正确但帧率下降,先用 GPU timestamp 分离 compute 与 draw,再看 memory bandwidth、occupancy 和 readback。若 compute 结果偶发闪烁,优先检查 ping-pong buffer 是否在同一帧被读写混用、barrier 是否覆盖了真实访问阶段,以及多帧 in-flight 资源是否被复用过早。
最小自检任务
给定一个粒子系统:每帧有 120000 个粒子,Compute Shader 使用 numthreads(256, 1, 1) 更新粒子位置,输出写入 particleOutputBuffer。随后 graphics pass 把这个 buffer 作为顶点数据绘制粒子。某次修改后,画面中粒子有时停在旧位置,有时出现跳动;profiler 显示 compute dispatch 时间稳定,但 draw pass 前出现等待。请判断应按什么顺序排查,并说明哪些证据分别对应线程规模、资源状态、同步和性能瓶颈。
答案要点
先计算 dispatch 规模:ceil(120000 / 256) = 469,实际启动 120064 个线程,尾部 64 个线程应由边界检查收束。这个结果能排除大规模线程覆盖不足,但仍要确认 shader 使用的 particleCount 与 CPU 侧传入值一致。
再检查资源绑定:当前帧的 particlesIn、particlesOut 和 draw pass 读取的 buffer 必须对应同一帧的 ping-pong 角色。若 compute 写入 B,而 draw 仍读取 A,画面会停在旧位置;若多帧 in-flight 复用了同一份输出 buffer,画面会出现跳动。
接着检查 producer-consumer 同步:compute pass 对 particleOutputBuffer 执行 shader write,graphics pass 对同一资源执行 vertex attribute read 或 shader read。Vulkan 中应有覆盖真实源阶段、源访问、目标阶段和目标访问的 barrier;Direct3D 12 中应有匹配 UAV 写入和后续读取状态的 barrier 或 transition。draw pass 前等待说明依赖可能存在,但放置位置、范围或资源复用策略可能造成额外 stall。
最后区分性能瓶颈:compute 时间稳定说明粒子更新本身暂时处在次要位置;draw pass 前等待指向 barrier、队列依赖、资源状态转换或 CPU/GPU 同步。若同时存在 readback 或 fence wait,应把它从渲染主路径移到低频调试路径。复测时每次只调整一个变量,例如先修正 ping-pong 绑定,再缩小 barrier 范围,再观察 GPU timeline 上等待是否下降。
本章知识点总结
- 阶段定位:Compute Shader 是按 dispatch 网格启动的 GPU 可编程阶段,输入和输出由显式绑定资源决定。
- 资源流向:compute pass 写出的 buffer 或 image 必须通过状态和可见性声明交给后续 graphics 或 compute pass。
- 粒子映射:一个线程处理一个粒子是典型 map 模式,group count 由元素数量和 workgroup size 向上取整得到。
- 模式选择:输出长度固定、邻域读取、全局合并、稀疏写入和命令生成分别对应不同 compute 设计模式。
- 邻域复用:stencil 与 filter 任务可用 shared memory 复用 tile 数据,收益来自减少全局内存访问。
- 全局合并:reduction、scan 和 compaction 通常需要多阶段 dispatch 和中间 buffer 来表达跨工作组依赖。
- 绑定路径:Vulkan descriptor set 和 Direct3D descriptor table 都用于说明 shader 从哪些资源读写数据。
- 同步职责:Vulkan barrier 和 D3D12 resource barrier 都用于连接前一次写入和后一次读取或写入。
- 工作组规模:workgroup size 要结合 wave 或 warp 粒度、occupancy、寄存器、shared memory 和数据布局验证。
- 带宽判断:每线程读写字节数高且算术少时,compute pass 更容易受 memory bandwidth 限制。
- 分支发散:同一 wave 或 warp 内路径差异越大,lane 轮流参与执行的成本越高。
- 读回成本:CPU readback 会把 GPU 结果拉回 CPU 时间线,频繁读回会破坏 frame 并行。
- 诊断顺序:先定位关键路径,再查线程规模、资源绑定、barrier、profiler counter 和单变量复测结果。
- 最终判断:Compute Shader 调优的核心是把数据并行模式、资源生命周期、同步范围和工具证据放在同一条 frame 路径中验证。