把数据从 Constant Buffer 挪进 SSBO,通常是为了绕开两件事:uniform 缓冲的尺寸上限,以及编译期就定死的下标。做法也简单,声明换成 StructuredBuffer,取值下标从常量改成运行时算出来的值。编译过了,渲染也对。
帧数通常没有惊喜。不少机器上还会多出一段说不清的慢,profile 里是 memory stall 占比升高。把下标改回常量,或者把数据搬回 uniform 数据路径,stall 立刻回落。这个对照实验自己就能做,不需要特定的硬件。大多数桌面 GPU 上,把一次本该广播给整组线程的读取改成每条线程各找各的地址,延迟差异是测得到的。
差别不在 API,在硬件路径。Constant Buffer 的读取走 uniform 数据路径,前提是地址对整组线程不变:硬件按一份数据分发,缓存走专线。SSBO 走的是通用访存,地址跟着每条线程自己的数据走,按"各读各的"设计。两条路径本身都合理,真正要防的是把 uniform 语义的访问伪装成通用访问。下标从顶点属性里取出的一刻,同一组线程开始追不同的地址,访存请求散开,cache 命中率显著恶化,带宽开销随之上升。反过来也成立:如果下标算出来仍是同一个值,编译器常常能认出这一点,读 SSBO 的成本会落回接近广播的水平。成本的主要来源是访存发散,SSBO 的作用是将发散导入通用访存路径。
换存储只是小动作。近几年进引擎的大特性,包括 Mesh Shading、Bindless、GPU-driven Rendering、Async Compute、Ray Tracing、Virtualized Geometry,有一个共同的前提:API 描述意图,硬件决定这条意图走哪条执行路径。适配没跟上时,profile 里的瓶颈形状各不相同,但成本转移的结构相似。以下三种特性迁移展示了同一类成本转移:
-
Bindless 把 CPU 从逐次绑定资源中解放出来,代价转移到了 GPU 侧的解引用。资源索引一旦来自顶点数据,同一组线程查到的 descriptor 各不相同,descriptor cache 与 texture cache 的局部性一起被破坏,CPU 侧节省的时间在 GPU 访存开销中被抵消。
-
Mesh Shading 把绘制从"一个 draw 管一大片三角形"拆成"每个 meshlet 自己生成自己的三角形",CPU 侧省下的是剔除与提交的开销。Mesh Shading 成立的前提是 meshlet 大小、顶点复用率、task 阶段传下去的 payload 大小能装进目标 GPU 的几何处理流水线,并填满 wave 调度槽位(wave 是硬件一次实际派发的一组线程)。meshlet 切太小,同一个顶点会被相邻 batch 抓两遍;payload 塞太满,单个 wave 占用的寄存器超出分配上限,occupancy(并发驻留的 wave 占硬件能容纳的比例)下降,吞吐反而不如老管线。
-
Compute 替代 Pixel Shader 是另一类热门迁移。光栅化路径里,Pixel Shader 写帧缓冲有两条现成的路:数据先落在片上的 tile 里,整块处理完才写出;要写进显存的部分会先被 framebuffer compression 压一道。Compute 直接写帧缓冲走的是通用 store,这两条路都不经过。有利场景下延迟显著降低,不利场景下带宽与压缩收益的损失难以从 profiler 直接定位。
成本转移的层次
三类迁移放在一起看,共性清楚:API 提供了描述问题的能力,GPU 决定实际解决问题的硬件开销。一个渲染特性从提交到出画面,中间要穿过 command scheduling、wave dispatch、访存请求与事务、tile 层存取、cache 层级、同步,每一段都有自己的开销结构,而 API 只描述到提交为止。这些路径的最终形态由电路决定:缓存多宽、队列多深、仲裁做在哪一级。读执行路径,就是在读电路替软件做的那些决定。规格表在这里帮不上忙:几个 TMU、多少带宽,都回答不了"这段 workload 会在哪一段路径上停下来等"。
这也能解释另一种更磨人的情况:特性上了,Draw Call 降了,profile 改了三轮,帧数纹丝不动。每轮改动 profiler 都有反馈,这轮访存等待变多,下轮 ALU 占用变高,可数字只是换了形状,时间没回来。计数器和 stall 分类属于中间层,报告的是"哪一类事件变多了";事件为什么变多,因果在更下面一层:一次访存为什么发散,一批 wave 为什么没装满,一个同步动作为什么把整条流水线停住。缺少执行路径层面的因果模型,profiler 计数器只能报告事件统计,无法定位根因。
GPU 性能问题的绝大多数,根源在 workload 与执行路径之间的失配(Architecture Mismatch),失配位置通常在 profiler 计数器覆盖不到的层次。
两套问法
上述性能问题有一个共同的起点:决策以特性为单位。判断依据是特性是否新颖、同行是否采用、发布会 demo 表现如何。硬件视角没有被排除,只是被放到了最后:先上特性,出了性能问题再回头找,找的时候又回到 profile,profile 说不清,于是改参数碰运气。
同样一次决策,把问题换成右边这一列,拿到的信息完全不同:
| Feature-first 问法 | Architecture-first 问法 |
|---|---|
| 这个特性是否先进? | workload 是否匹配目标 GPU 的执行模型? |
| 是否已有成功案例? | 成功案例的硬件条件、资源布局与性能计数器如何? |
| Draw Call 是否减少了? | 成本是否转移到了 command processor、descriptor cache、shader divergence? |
| CPU 负载是否降低了? | GPU 前端、内存系统和 tile path 是否额外增加了负担? |
| Compute 是否更通用? | 是否破坏了 framebuffer compression 与 fixed-function 优化路径? |
| GPU-driven 是否更现代? | 间接数据是否紧凑连续、利于预取? |
| Async Compute 是否真正并行? | 两个 workload 是否在争抢同一组硬件资源? |
表中的硬件术语,GPU 前端(接收指令并向各执行单元分派工作的阶段)、descriptor cache、预取等,每个对应一段执行路径。判断可以压缩成四个问题:执行模型匹配吗,内存层次对齐吗,调度器能否填满并发槽位,固定功能路径还在不在。前三个决定"能不能快",第四个决定"会不会意外地慢"。一个特性成立与否,取决于 workload 与目标 GPU 的适配程度,右列全部答"是"时特性才立得住。
Console 开发者在实践中一直按这套逻辑工作:统一内存加固定预算,CPU 的每条指令、GPU 的每块带宽都从同一个池子里出。预算结构要求从整机层面判断 workload 部署在哪一侧、数据是否紧凑连续,布局松散直接反映为带宽开销上升。
AI Infra 领域同样如此。评估一张 kernel 看四个数:GPU 利用率与访存带宽,衡量硬件使用效率;kernel 的执行形态与 occupancy,衡量调度质量。四个指标全部指向硬件路径,不涉及写法是否新颖。
Console 与 AI 两个邻域的约束里没有绕行的空间:console 的预算物理封顶,AI 的训练和推理先撞内存墙。通用图形这边 API 给了很高的描述自由度,改一行声明就能换一条硬件路径,但硬件开销在运行时才暴露,而非在 API 调用时即可预见。
同一个特性,不同的硬件路径
高自由度还带来一个认知盲区:把 GPU 归成 TBR 与 IMR 两类。移动端把画面切成小块、逐块在片上完成(tile-based),桌面传统上整帧一路画完再输出(immediate mode)。这个二分曾经够用,现在装不下实际的几何处理方案。顶点怎么抓取、可见性在哪一级裁决、中间数据留在片上还是出去,各家在一条谱系上选自己的位置。两分法只回答了"像素在哪一级完成",其余几何路径差异全被忽略。
同叫 vertex processing、mesh shading、early-z 的东西,在不同芯片上被切开重排过。举三个例子:
-
Arm Bifrost 的 IDVS 把 vertex shader 拆成两段:先只算决定位置的那部分,裁剪与剔除做完、确认哪些顶点真的会进入片元阶段,再补算其余 varying。做过 position-only pass 的人对这套不陌生,Bifrost 将同样的策略做进了硬件。许多没被最终像素用到的插值计算直接省掉,节省的是 ALU 周期,也是将 varying 送下流水线的带宽。
-
Adreno 用 LRZ(低分辨率 Z)先对场景做一遍粗的深度预判,在片元进入着色器之前丢弃不可见片元。做过 depth prepass 的人可以把 LRZ 理解成硬件内建的粗版预判:prepass 用真 shader 把深度算一遍,LRZ 用低分辨率近似先筛一遍,节省的是片元着色的算力和帧缓冲写出的带宽。
-
Apple Family 9 GPU 给 mesh shading 配了专门的硬件路径,设计上强调片上中间数据保持(on-chip data retention):mesh shading 前段产生的中间数据尽量留在片内存储中,避免往返外部内存引入的额外带宽开销。
三个例子的共同点:同一个特性在不同 GPU 上执行成本结构完全不同,而这些差异 API 表达不出来。API 只规定"做什么",不规定"在这颗芯片上怎么做开销最低"。于是同一段代码,在一家走快路径,换一家就是慢路径。执行路径换了,profile 里瓶颈的形状也跟着换。
系列结构
后续各篇围绕同一个操作展开:将特性放回执行路径上审视,还原 profiler 数字与硬件路径事件之间的因果,区分一次优化是在修正失配,还是将开销转移到了 profiler 覆盖不到的层次。
Vol.1 建立分析框架,内容包括如何将计数器和 stall 还原成硬件路径上的事件,以及分析一颗 GPU 时需要关注的维度:执行模型、数据搬运方向、依赖追踪机制、控制权在硬件还是软件侧。厂商篇随后展开,同一组问题由 NVIDIA、AMD、Apple 各给一份回答,Intel、PowerVR、Adreno、Mali 以及国产 GPU 补齐版图。三篇前传(顶点复用、渲染架构演化、片上内存)提供纵向深度,覆盖顶点数据的反复抓取、像素可见性的裁决层级、barrier 在片上的实际行为。
下次打开 profiler 之前,可以先做一件事:把要查的 workload 翻译成"workload 想走哪条硬件路径",再检查这条路径的前提有没有被代码破坏。从 feature-first 到 architecture-first 的转向,在操作层面就是每次性能归因时将问题定位到体系结构层级:dispatch 策略、wave 分配、cache 一致性、访存模式。
