
如果你写了几年图形程序肯定遇到过这种事调一个着色器参数画面表现不对反复改来改去碰运气。运气好碰对了运气不好直接卡一天。这时候你会意识到不搞清楚 GPU 芯片内部到底怎么干活的图形程序就只能靠“试”来推进。这篇文章就是来补这块短板的。我从 GPU 最底层的计算单元讲起一路把完整图形流水线拆开串成一条线。你没芯片设计背景也没关系只要写过着色器、用过图形 API或者只是好奇 GPU 里那些晶体管是怎么把三角形变成像素的都能跟着这条线走下来。很多人一开始都有个误解觉得 GPU 就是“很多个 CPU 堆在一起”。这个类比表面说得通实际差得很远。CPU 是单线程能力强、擅长逻辑分支和控制GPU 则是一堆小计算单元围绕“吞吐量”设计的。你要理解 GPU 内部的图形计算得先接受它的设计哲学GPU 不是来做高难度的单点计算的它是来同时处理大量简单、重复的计算的。这种设计目标直接从硬件架构的每一个层级渗透到图形流水线的每一个阶段。1. 先拨开迷雾为什么 GPU 不是“一堆 CPU”的集合体1.1 CPU 与 GPU 的核心矛盾延迟 vs 吞吐CPU 追求的是“延迟最小化”。从内存取一个数要几十纳秒CPU 就用多级缓存、分支预测、乱序执行这一大堆复杂逻辑把单个指令的延迟尽量压下去。所以 CPU 内部很有“层”控制单元极其庞大算术逻辑单元反而只占了一小块面积一个核心里的缓存、乱序引擎、解码器占地面积比计算单元多得多。GPU 反过来追求的是“吞吐量最大化”。它不在乎某一条指令执行得多快而在乎一秒钟之内能执行完多少条指令。为了实现这个目标GPU 走了一条极端的路线把芯片的大部分面积都留给算术单元把控制单元压缩到最简单。复杂逻辑几乎没有乱序执行没有大缓存也没有芯片上密密麻麻地排满了能算数的小单元。这就好比 CPU 是一支特种部队装备精良能对付复杂场面但人数少GPU 是一支大规模集团军单兵能力一般但阵势极大靠人数优势碾压重复性的任务。图形计算恰好是 GPU 最擅长的场景几千个顶点的坐标变换、几十万个像素的颜色插值、上百万次的光照计算这些任务算法逻辑并不复杂但量大、可并行。把同一个操作套在大量数据上正是 GPU 的核心能力。这也是为什么 GPU 适合做图形和深度学习——这两个领域想要的都不是“快的那几微秒”而是“同一时间干很多事”。1.2 统一着色器架构带来的革命性影响早期的 GPU 里有单独的顶点着色器硬件和像素着色器硬件两者物理隔离顶点处理单元不能干像素的活。这种架构的毛病很明显一个游戏里画面顶点特别多但像素计算不多一堆像素处理单元就空转换个场景像素负担重而顶点少顶点单元忙死了像素单元闲着。一直到现在真正改变格局的方案是统一着色器架构Unified Shader Architecture。统一架构的核心思想是GPU 里不再是“顶点单元”和“像素单元”两拨人分家而是放了一大堆同构的通用计算单元。它们既能跑顶点着色器、也能跑像素着色器还能跑几何着色器和通用计算GPGPU。硬件驱动和调度器按照当前画面的负载动态决定每个单元去执行哪一部分任务。这个设计让 GPU 的硬件利用率大幅提升也让现代图形 APIDirectX 10 及以后里的着色器模型成为可能——无论顶点还是像素走的都是同一种“可编程着色器”模型。明白了这个前提你再看现代 GPU 芯片的实物图就能看懂个大概一块大芯片上最显眼的就是密密麻麻组成的多个大块计算核心旁边散落着纹理单元、光栅单元、缓存和内存控制器。后面要讲的“最小计算单位”和“完整图形流水线”就建立在这个统一架构之上。2. 最小计算单位流处理器到底是什么东西2.1 从流处理器、CUDA Core 到 ALU 的逻辑拆解很少有人第一次接触“流处理器”这个术语时就能理解它和“核心”的区别。NVIDIA 管它叫 CUDA CoreAMD 叫 Stream ProcessorIntel 的 Xe 架构里叫执行单元EU叫法五花八门本质是一个东西一个能执行算术和逻辑操作的最小计算单元里面核心的核心是一组 ALUArithmetic Logic Unit算术逻辑单元。展开看一个流处理器可以执行的操作包括浮点加法、浮点乘法、融合乘加FMA一次计算 a*bc、整数加减、位运算、逻辑比较。所谓“流”指的就是这一大堆计算单元上面跑的是同一种流式操作——拿到数据算然后输出。图形计算里最常见的运算就是 4 维向量的加减乘、矩阵乘法、点乘和叉积这些在底层全都可以拆成最原始的浮点乘法和加法。一个有意思的细节是现代 GPU 的浮点计算在硬件层面大量使用融合乘加指令 FMA。一个 FMA 只发一条指令同时完成了乘和加既节省指令发射带宽又因为只在最后一步做一次舍入所以精度还更高。在算 GPU 算力的时候一个 FMA 通常被按照两次浮点运算来计数这就是为什么你在产品规格里看到的 FLOPS 数字往往是“核心数×频率×2”。比如一颗拥有 16384 个流处理器、加速频率约 2.5 GHz 的 GPU它的单精度浮点算力大致是 16384×2.5e9×2 ≈ 81.9 TFLOPS这个计算逻辑就是这么来的。2.2 SIMT 与线程一条指令驱动几十个数据小兵流处理器单看非常“笨”它就只会乘乘加加、做点简单比较。真正的复杂度在调度方式GPU 使用 SIMTSingle Instruction, Multiple Threads单指令多线程模型来驱动这些流处理器。你可以把它理解成军事训练里的“齐步走”一个教官喊一声“走”一个班 32 个士兵同时迈出一条腿。硬件上就是GPU 的执行调度器每发出一条指令这一组线程里的所有流处理器都必须在这同一个时刻执行这一条指令只不过它们各自操作的寄存器数据是不同的。NVIDIA 把这一组线程叫一个 warp通常 32 条线程AMD 叫 wavefront通常是 64 条线程。一个 warp 里的线程必须严格同步地执行同一条指令这是 GPU 并行计算最核心的规则。你可以把一个 warp 想象成一张 32 行的数据表格同一列就是同一个“线程”的所有寄存器状态GPU 发出的指令作用在这张表的所有行上。正经写代码的时候你写的float4 normal normalize(...)看起来是“一个顶点”的操作实际硬件层面是一条指令同时让 32 个顶点或者 32 个像素一起执行这个 normalize。这种“一条指令带动一大组数据”的设计带来一个巨大的好处调度开销极低。CPU 在两条指令之间做线程切换要保存断点、恢复现场、刷新流水线成本高得很。GPU 的线程切换呢寄存器堆足够大每个线程的现场都直接保存在寄存器堆里切换一个 warp 只需要调度器换个指针指向另一组寄存器即可。所以 GPU 可以近乎零成本地在每一拍时钟周期做 warp 切换用这种极快的切换来掩盖内存访问的延迟——一个 warp 在等显存数据回来调度器立刻切换到另一个不用等数据的 warp。这也是为什么 GPU 就算不做乱序执行也能把执行单元占满。2.3 分支发散是流处理器的软肋看到这你应该能猜到 GPU 的短板了分支语句。如果 warp 里的 32 个线程在同一个 if 语句上产生了不同的去向一半线程走 true 分支一半走 false 分支SIMT 硬件没法让同一时刻一半人向左一半人向右于是调度器只能先把 true 分支全部执行完再把 false 分支全部执行一遍。本来一条指令能完成的事现在变成两个指令周期效率直接掉一半这就是经典的分支发散divergence问题。你在写着色器时应该养成一个习惯分支条件尽量用“每个顶点/像素都一样的值”比如常量、uniform 变量而不要依赖具体的顶点索引或像素坐标。同一个 warp 内如果发散越严重浪费的计算周期就越多。有些还不错的技巧是把容易发散的代码从主循环里分离出去或者改用算法本身避免分支——用 step 函数、用 lerp 插值来“算”出条件值而不是“判断”出条件值。这不是玄学是硬件规则的直接影响。3. 从线程束到计算集群GPU 内部的层次化结构3.1 SM/CU 是怎么把一批流处理器组织起来的前面说你有了成百上千个流处理器但硬件不能把它们全裸着排一排共享同一个调度器那样调度器会成为瓶颈。所以芯片设计上做的是分层管理一批流处理器配合一组共享资源组成一个更大的计算集群。NVIDIA 叫它 SMStreaming Multiprocessor流式多处理器AMD 叫 CUCompute Unit计算单元。一个 SM 里除了几十到上百个流处理器还有一块关键区域共享内存Shared Memory或 L1 缓存。SM 内的线程访问这块内存的速度比访问全局显存快一个数量级代价是容量小。同一个 SM 内的线程之间可以通过共享内存交换数据这也是各种 GPU 通用计算优化里最核心的手段。SM 内还有寄存器堆Register File它是 GPU“硬件上就很快的变量存储”显卡驱动和编译器会尽量把循环变量、中间结果都塞在寄存器里。以 NVIDIA 的 Ampere 架构比如 GA102 芯片为例一个 SM 包含 128 个 CUDA 核心、4 个纹理单元、大量寄存器。整个芯片由几十个 SM 组成——GA102 上有 84 个 SM能堆出上万流处理器。这一层一层套起来的组织结构决定了你在 GPU 编程里的“局部性”意识线程与线程之间最快的信息交换只发生在同一个 SM 内。跨 SM 通信需要走更慢的全局路径这跟 CPU 多核里“同一个核的缓存快、跨核通信慢”是一样的道理。3.2 调度器与指令发射一整个流水线的“总指挥”SM 内部的管理者是调度器Scheduler。每个 SM 里有多个调度器每个调度器对应一组流处理器。以 GA102 为例一个 SM 里有 4 个调度器每个调度器负责一组 32 个 CUDA 核心也就是一次能调度一个 warp。调度器的工作是每个时钟周期选出一个 ready 状态的 warp往执行单元里发一条指令。因为 GPU 极度依赖调度器的效率所以调度器在硬件上被设计成可以“每个周期都从一个新 warp 切换发指令”这种能力是 GPU 吞吐量的根基。从游戏画面渲染的角度看这套调度机制保证了一件事当渲染一个场景时GPU 收到的成千上万个像素的任务可以被打散成好几万个 warp调度器在这些 warp 之间高速切换就算某个 warp 因为读取纹理在等显存数据其他 warp 依然能把计算单元填满。这也是为什么 GPU 渲染复杂场景时只看“显卡占用率”这个指标往往一直维持在百分之九十几——因为调度器把一切空隙都填平了。但是注意这种占用率是高负载渲染任务下才有的现象如果你把一个很小的画面比如只有几千个三角形全塞给一万多个流处理器那大部分流处理器会处于空置状态占用率照样低得可怜这不是 GPU 坏了是任务太小喂不饱它。4. 一条像素的诞生完整图形流水线从头拆到尾4.1 CPU 指挥GPU 干活Draw Call 与命令缓冲的开端图形流水线并不是从 GPU 开始的它从 CPU 端就开了头。你调用一次DrawIndexed或者vkCmdDrawIndexed这套命令不会直接发给 GPU 执行而是被驱动写进一个命令缓冲Command Buffer。运行期间驱动会把这些绘制命令、顶点缓冲地址、着色器绑定、状态对象全部记录下来等到这一帧 CPU 端所有命令都录制完毕再整体提交给 GPU。GPU 端收到命令缓冲之后前端线程Frontend开始逐条解析命令把顶点数据从显存里取出来按照绘制命令指定的索引顺序组织成一个个线程组准备分发到各个 SM 上执行顶点着色器。关键点来了CPU 和 GPU 之间的关系是“异步流水线”式的CPU 在拼命录制下一帧的命令GPU 在拼命执行当前帧的命令两者隔着一层命令缓冲互相追赶。如果你在 CPU 端每帧只调用了几百次 Draw Call这些调度开销完全可以忽略但如果你调用了成千上万次 Draw CallCPU 录命令的速度就会拖慢整个帧率——这就是常说的“Draw Call 瓶颈”的由来。解决办法无非是合并批次、实例化渲染、用间接绘制把 CPU 的频率降下来。4.2 顶点着色器模型坐标到裁剪坐标的第一次变换从 GPU 微架构的角度看顶点着色器阶段做的事情就是把每个顶点从模型空间经过世界变换、视图变换、投影变换最终变成“裁剪空间”里的坐标。这个过程中你可能顺带算了顶点法线、切线、纹理坐标、光照相关变量。硬件上所有顶点都被组织成一批批的线程每个线程处理一个顶点。旧观点总以为顶点着色器是“最轻”的阶段但现代引擎里它经常藏着大量成本蒙皮动画Skinned Mesh要做骨骼矩阵加权顶点纹理采样做地形高度偏移还有各种程序化顶点动画。这些计算全部是 SIMT 很擅长的矩阵乘法和向量运算只要分支不严重发散顶点着色器的吞吐量一般不是问题。这里有一个实践经验如果顶点着色器变成瓶颈优先检查是不是蒙皮矩阵的权重计算里面在频繁读取大量的骨骼矩阵数组这种访问模式会戳到带宽痛点不如尽量把它压缩到常驻寄存器里或者把静态网格和动态蒙皮网格分开管线处理。4.3 图元组装、裁剪与光栅化从几何变成片段的桥顶点着色器结束后GPU 进入固定功能阶段Fixed-Function Unit这些阶段不受你写的着色器控制而是由硬件状态机固定完成。首先是图元组装Primitive Assembly硬件按顶点索引把顶点组装成三角形、线段或点。然后是裁剪Clipping把视锥体之外的三角形切掉避免在屏幕外浪费像素计算。接下来是光栅化Rasterization这是整个流水线里最“硬核”的固定功能阶段。光栅化的任务很简单给定屏幕上的三个顶点坐标算出这个三角形覆盖了屏幕上的哪些像素。中间涉及的具体算法例如扫描线转换、重心坐标插值的遍历显卡硬件内部用专门的逻辑单元实现。每个被覆盖的像素位置被“生成”一个片元Fragment片元携带了由三角形三个顶点插值得到的坐标、法线、纹理坐标等属性。做过移动端渲染优化的同学一定对“Overdraw过度绘制”这个词有概念它的本质就是光栅化阶段产生了大量最终被丢弃的片元一个巨大三角形盖住了屏幕但场景里其他三角形在被它遮挡的位置也生成了片元这些片元会执行完像素着色器最后在深度测试阶段白忙一场被丢掉。针对这个问题硬件厂商想了各种招比如 Early-Z 优化在像素着色器执行前先做一遍深度测试和 Tile-Based 渲染移动端 GPU 把屏幕分成小块做剔除。这也是为什么你写移动端渲染时要尽量保证深度测试开启并且渲染顺序上先画不透明物体、再画透明物体让 Depth Buffer 在早期就能拒绝大量片元。4.4 像素着色器逐像素决定颜色和光照结果通过光栅化之后每个片元对应的线程就被分发到 SM 上去执行像素着色器Pixel/Fragment Shader。像素着色器里会做纹理采样、光照计算、反射、阴影、各种后处理效果等。这是整个流水线里计算量最大的阶段也是着色器绑架性能Shader-bound瓶颈在像素着色器的典型场景。像素着色器最大的性能特点是有“相邻像素一致性2×2 quad 像素组”。这是因为硬件做纹理采样时需要两个像素在水平方向、两个像素在垂直方向组成一个 2×2 的像素块才能计算纹理坐标的梯度dFdx/dFdy从而生成 mipmap 的层级。这就意味着像素着色器并不是一个一个像素独立跑的而是一个“四像素组”绑定成一个 warp 的一部分一起跑。如果三角形边缘被切得非常碎产生了很多不完整的 2×2 块那些不在三角形内的多余像素也会被“计算”一遍最后在输出阶段被丢弃白白浪费算力。这就是为什么高分辨率小三角形密集的画面光栅化和像素着色器效率都会变差。写像素着色器时常见的优化思路包括把条件分支尽量放到循环外面不要在一整个像素着色器里叠太多昂贵的操作而是想办法把光照拆到多个 pass要留意 texelFetch 和 sample 的差别后者因为需要做 bilinear 插值和 mipmap 选择其实运算成本更高。这些看起来是“代码习惯”实际上都是硬件像素线程工作机制的直接结果。4.5 输出合并深度测试、模板测试与混合的最终裁决像素着色器计算完后得到的是片元颜色和深度值但这些值并不直接写入最终画面。它们还要经过输出合并阶段Output MergerOM硬件会做最后的裁决深度测试判断这个片元到底能不能被写入模板测试对其他条件做裁剪如果透过透明物体渲染则必须开启混合Blending把新片元颜色和帧缓冲中已有颜色按 alpha 权重混合。混合操作需要读写帧缓冲这正是一个带宽敏感的操作它在移动端尤其昂贵。很多开发者不知道的是混合可以发生在帧缓冲任何一个颜色通道上驱动需要做读改写操作如果使用浮点帧缓冲HDR 渲染RGBA16F带宽代价会成倍上升。很多实际场景里延迟渲染的瓶颈不是 G-Buffer 的写入而是最后那个光照 pass 里反复读写大纹理缓冲。优化的办法一般是用更小的数据格式、用单 bilinear 周期采样多做几项合并、或者干脆用 Tile-Based 渲染器里的本地显存on-chip memory来减少带宽往返。输出合并阶段同时还要考虑多渲染目标MRT现代延迟渲染一次需要同时输出多个颜色缓冲基础色、法线、粗糙度/金属度等。硬件需要为每个像素向多个渲染目标同时写入这对内存带宽的压力是立刻加倍的。你用 RenderDoc 截帧看带宽爆表的地方一半以上的情况都出在这个阶段。5. 图形计算里的隐形主角带宽、缓存与纹理单元5.1 为什么 GPU 运算速度再快显存带宽不够也是白搭很多人只盯着流处理器数量和频率往往忽略一个残酷事实GPU 的“算力”和“喂数据的带宽”必须匹配。假如你的 GPU 算力是 80 TFLOPS意味着每秒钟它能做 80 万亿次浮点运算但显存带宽可能只有 1 TB/s也就是一秒钟只能读写 1 万亿字节。一次浮点运算消耗 1 个字节的读入都已经远远超过带宽上限更何况实际每个算法动辄读几十个字节。所以现代 GPU 在芯片设计时都会针对带宽做大量工程大位宽的内存总线、更高频率的显示内存GDDR6X/HBM、大容量的 L2 缓存。带宽的计算公式不复杂带宽 内存速率Gbps× 位宽bit/ 8。举个例子某显卡用 GDDR6X 显存等效速率约 21 Gbps位宽 384 bit那带宽就是 21×384/8 1008 GB/s约 1 TB/s。你要是拿着这个公式去核对显卡参数表会发现绝大多数主流 GPU 产品的带宽都能算出来。而带宽对于我们写图形程序的影响在于过度使用昂贵纹理采样、反复读写帧缓冲、不加限制地开 MSAA 多重采样都可能瞬间击穿带宽上限让 GPU 的计算单元闲置等着数据。所以遇到画面卡顿别第一时间怀疑“流处理算力不够”先查带宽。5.2 纹理单元在流水线中的特殊位置与压缩技巧纹理单元TMU是 GPU 里专门负责纹理采样和纹理过滤的硬件模块。它从显存读取纹理数据执行双线性插值、三线性插值、各向异性过滤AF这些操作。为了减少带宽消耗纹理单元内部和显存控制器都做了大量无损压缩纹理数据在显存里通常会以压缩格式存储。常见的纹理压缩格式 BC1-BC7、ASTC、ETC2并不是简单地减少内存占用它们的核心目的是减少显存带宽压力——压缩后单位面积纹理占用的字节数变小采样时读入的数据量就变少。我在实际项目中见过有人用一张 4096×4096 RGBA8 未压缩纹理做 UI 背景整帧采样它没几次但每帧都把它加载显存占了几十 MB。换成 BC3 压缩后视觉效果几乎一样采样带宽和显存占用立刻降下来。如果你在移动端开发ASTC 更是救命格式因为移动端 GPU 的带宽极其宝贵纹理所占的带宽开销甚至比计算开销更致命。纹理性能优化的核心原则能用压缩格式就用压缩格式mipmap 一定要生成因为 mipmap 能显著减少远处过度采样造成的带宽开销各向异性过滤别盲目开满 16x根据项目实际需要选择合适等级。5.3 缓存与共享内存借力打力的数据复用现代 GPU 都搞了好几层缓存L2 缓存是全局共享的跨 SM 相互通信可以走 L2L1 缓存/共享内存在 SM 内部。图形渲染里经常存在明显的数据局部性比如相邻像素处理时访问的是同一块纹理区域L1/L2 命中率越高需要访问真实显存的次数就越少这也是为什么芯片厂商越来越舍得在 GPU 上堆 L2 缓存L2 做大了带宽利用率就更高。在渲染实践中能明显利用到缓存的方式包括早期深度测试把被遮挡的片元尽早淘汰减少后续带宽读取将渲染目标拆成更小的子区域基于图块渲染的架构天然有这个优势用 compute shader 在共享内存里做图像预处理或者滤波避免反复读写全局显存。你要是接触过 VK_KHR_dynamic_rendering 或者移动端的 render pass会发现 API 本身也在引导开发者让 GPU 意识到哪些操作可以“停留在片内”这就是近年图形架构最火的带宽节约方向之一。6. 从架构到实战流水线在真实染色中的映射与性能优化6.1 一帧画面的完整旅程前向、延迟和移动端渲染管线的差异前面把流水线拆成了一个个阶段实际一帧画面是怎么被多次执行这些阶段的这一点直接决定性能。前向渲染Forward Rendering里每个物体每盏灯都做一次全流程像素着色物体多灯光多就很容易变成着色器瓶颈。延迟渲染Deferred Rendering则改变了顺序第一个 pass 不计算光照只输出 G-Buffer第二个 pass 针对每个像素做光照计算把场景几何与光源解耦。延迟渲染让大量动态光源成为可能但代价是 G-Buffer 的读写带宽巨大也因此对带宽更敏感。移动端 GPU 大多是 Tile-Based 架构整个屏幕被分块处理GPU 尽量把 G-Buffer 保留在片内on-chip memory完成光照计算再统一写回全局内存。实际运用中这套架构对 render pass 的划分非常敏感一个 Metal render pass 里尽量包含和光照相关的所有 subpass避免中途写回内存再重读。如果你在移动端把延迟渲染的 G-Buffer 分开两三个 render pass 做每次都回显存读写一遍性能立刻血崩。这也是为什么移动端更建议大家使用 TBDR 友好管线、通用光照流程讲究 subpass 居中。6.2 三个最常见的 GPU 性能瓶颈快速排查法实际做渲染优化时一般先判断瓶颈落在哪一侧几何侧顶点数太多、图元太多、光栅侧像素覆盖率、overdraw 太高、带宽侧纹理和帧缓冲读写太大、还是着色器侧像素着色器计算太重。肉眼判断的方法可以很简单降低渲染分辨率如果帧率提升明显大概率像素或带宽瓶颈降低几何复杂度或者切换低模帧率提升明显说明几何瓶颈把昂贵的纹理采样全部换成纯色常量如果明显变快那纹理采样带宽几乎就是罪魁祸首。工具方面NVIDIA Nsight Graphics 可以逐阶段统计 GPU 各单元的空闲率和吞吐RenderDoc 可以看每个 pass 的带宽和像素着色器占用。我在项目里用得最多的排查路径是先看 RenderDoc 的每个 pass 时间线再看 GPU 吞吐统计确认是 ALU-bound、Texture-bound 还是 Bandwidth-bound。知道了瓶颈在哪优化才有方向ALU-bound 就降低着色器复杂度Texture-bound 就上压缩纹理、缩采样次数Bandwidth-bound 就得减少渲染目标格式、降低 MSAA 等级、精简后处理链。6.3 分支、依赖与不规整访问Shader 层面的常见坑就算整体算力和带宽都够用像素着色器里的指令依赖和分支发散一样会拖垮性能。GPU 是 SIMT 架构指令执行的并行度来源于线程之间“依赖无关”。如果你在像素着色器里写了类似这样的代码先读取一个结果然后用这个结果去决定下一个采样坐标最后再做一次采样那第二个采样就必须等第一个采样返回这个“延迟链”会让 SM 调度器很难找到可并行的活来填满流水线。另一个常见坑是把大循环写进像素着色器哪怕循环次数很小对于 1080p 一个屏幕上有 200 多万像素来说这种循环也会被放大到分子级规模。我的建议是能挪到 compute shader 的复杂循环就挪到 compute shader能一次性纹理采样解决的就不要用多次分支。同样访问纹理时的坐标如果分散且相邻像素差异很大会破坏纹理缓存的局部性造成重复的显存读这也是为什么算法设计上要尽量保持 warp 内相邻线程的纹理坐标邻近。7. 手写一个最小 GPU 计算模型把概念串起来7.1 用伪代码拆解一次最简单的三角形绘制为了把前面的概念全部串起来我用伪代码描述一个最简单的三角形从顶点数据到屏幕像素的完整过程。这个过程每一步都可以对应到真实硬件模块1. CPU: 把顶点位置 (x, y, z) 和颜色 (r, g, b) 打包进顶点缓冲 2. CPU: 发射 draw call驱动把命令写入命令缓冲 3. GPU: Frontend 解析命令把顶点数据按索引组织成顶点批次 4. GPU: 调度器把顶点批次分割成 warp发射向 SM 5. SM: 执行顶点着色器坐标变换世界/视图/投影矩阵乘法 6. GPU: 图元组装器处理三角形索引 7. GPU: 裁剪器处理视锥体外三角形的裁剪 8. GPU: 光栅化器计算三角形覆盖的像素生成片元 9. GPU: 片元组成有颜色的 2x2 块再形成 warp 10. SM: 执行像素着色器在纹理上采样并计算最终颜色 11. GPU: 输出合并阶段做深度测试和混合 12. GPU: 最终颜色写入帧缓冲 13. 显示器读取帧缓冲得到画面这个流程看着简单但每一步实际上都牵涉到前面讲的调度机制、缓存层次和带宽模型。你要是能在心里默画这条线从“命令录制”到“像素上屏”就全都串起来了。很多图形工程师调试问题时都会在心里先把“画面数据现在在芯片的哪个环节”过一遍这远比背 API 函数有用。7.2 自定义着色器时如何“顺着硬件思路走”如果你写过 GLSL 或 HLSL 的着色器不妨在代码注释里标注每个操作“理论上能跑到哪个硬件单元”。例如顶点着色器里的矩阵乘法走的是 SM 里的 ALU像素着色器里的texture()调用走的是 TMU混合写帧缓冲走的是 ROP 和显存带宽。这种把软件代码和硬件模块一一对应的能力是判断性能瓶颈的基础功。写实时渲染代码时我还经常用一条准则验证代码风格想象你的计算发生在一条只有 32 个工位的流水线上32 个工人必须完全同步做同一动作。如果代码里有不可避免的分支那就想想能不能让整组都走同一条路如果某个操作需要互相等待那想想能不能提前把数据准备好、或者拆成并行不依赖的两条链路。这个脑内模型虽然粗糙却能在设计阶段就挡住大量性能问题。8. 常见问题与排查技巧实录8.1 为什么 GPU 占用率上不去占用率低最常见的原因是任务粒度太小、并行度不够。如果你一帧画的东西太少几百个小三角形SM 上的 warp 都没满编占用率自然上不去。具体排查方法先降低分辨率看帧率变不变如果几乎不变说明负载确实太小可能是几何喂给 GPU 太少也可能顶点数不少但每个批次太小导致调度浪费。要解决这类问题加大 Instancing、合并网格、用 GPU-driven rendering 都是方向。另一个容易忽视的原因是用例本身是带宽限制。有时候 GPU 其实已经饱和了但是因为运算单元在等数据占用率指标看起来不高。这种情况排查要靠 Nsight 的 warp stall 统计看 stall 原因是不是 long scoreboard等待显存返回。如果是就优化数据访问模式把分散采样的纹理尽量批处理成连续访问。8.2 出现“画面一半卡一半流畅”的诡异现象这种通常跟双缓冲、垂直同步或者帧时间分布有关。如果你开启了垂直同步游戏帧率锁定在显示器刷新率画面卡顿可能是帧时间抖动导致的有的帧 5 ms 完成有的帧 30 ms 完成两者平均下来好像很流畅用 FrameView 或 PresentMon 看帧时间表就是明显的高低锯齿。排查时先关垂直同步用 PresentMon 记录 99th 百分位帧时间看是不是多数帧都很快只有个别帧慢得离谱。如果是多半是大纹理加载、首次着色器编译或者内存交换造成的尖峰。还有一种“前几秒卡、后面不卡”的状态多半是着色器编译DirectX 12 或 Vulkan 下着色器在第一次出现时要被驱动编译成 GPU 微码这个动作很费 CPU 时间。解决方案是预热或管线对象缓存。你要是用 Vulkan建议在启动阶段把需要的 Pipeline 全部编译出来而不是第一次 draw 的时候现场编译。8.3 纹理明明很小显存却爆了这种问题常见于没有正确生成 mipmap、或者未使用压缩格式。系统为了渲染质量默认会为纹理生成完整 mip 链如果你在引擎里加载 2048 上传的是未压缩 RGBA8还要生成 11 级 mip那么显存占用就是原始图片大小的 1.33 倍左右。再加上这个纹理被每个材质实例复制一份同一个图加载了几十次显存立马上去了。检查思路很简单用 GPU 调试工具打开纹理资源列表看有没有同一个源图被实例化了多次把纹理资源改成共享引用即可还要确认压缩格式是不是真的生效了不生效驱动会把它解压为 RGBA显存瞬间爆炸。晋升方向是大纹理全上 BC/ASTCUI 图采用图集合并运行时用流送纹理只加载小 mip 等级。8.4 为什么有时候改个渲染顺序帧率变化巨大渲染顺序最直接影响的是深度测试的提前拒绝率Early-Z。如果你先画了远处的地形再画近处但会遮挡地形的建筑建筑画完再做深度测试就把远地形像素淘汰掉了但地形像素在淘汰之前已经跑完了像素着色器和纹理采样大量计算白费。如果反过来先画建筑再画地形地形里大量像素会在像素着色器之前被 Early-Z 拒绝后处理的带宽、着色器负载都会降很多。在一个复杂的场景里我的经验是透明物体永远最后渲染不透明物体尽量按“由近到远”的顺序绘制启用了 alpha test 的物体要特别小心因为它的片元在早期颜色处理上无法被丢弃Early-Z 会被禁用此时可以考虑用 alpha to coverage 或者预计算遮罩替代。这里没有万能规则要根据实际深度复杂度来测但“先近后远”通常是一个不错的起点。结尾写到这里GPU 芯片内部怎么完成图形计算从最底层的流处理器到完整的图形流水线基本算是串成一条线了。我个人最深的体会是这些架构知识能不能派上用场关键不在于背参数表而在于你遇到问题时能不能在脑子里快速定位“当前这个画面数据到底卡在哪个硬件环节”。有一次我排查一个移动端发热问题朋友怀疑是像素着色器光照算太复杂查了一圈发现是 G-Buffer 用了一个 RGBA32F 的格式带宽爆了换回 RGBA16F 后帧率立刻提升 40%。从那以后我改任何渲染效果第一件事就是先算这笔带宽账。下次你写一个昂贵的新渲染特性之前也不妨先问问自己它现在计入的是 ALU 的成本还是显存带宽的成本答案往往就是优化的起点。