GPU内部架构与图形流水线:从流处理器到像素的完整解析
2026/9/7 8:33:46 网站建设 项目流程

如果你写了几年图形程序,肯定遇到过这种事:调一个着色器参数,画面表现不对,反复改来改去碰运气。运气好碰对了,运气不好直接卡一天。这时候你会意识到,不搞清楚 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 的硬件利用率大幅提升,也让现代图形 API(DirectX 10 及以后)里的着色器模型成为可能——无论顶点还是像素,走的都是同一种“可编程着色器”模型。

明白了这个前提,你再看现代 GPU 芯片的实物图就能看懂个大概:一块大芯片上,最显眼的就是密密麻麻组成的多个大块计算核心,旁边散落着纹理单元、光栅单元、缓存和内存控制器。后面要讲的“最小计算单位”和“完整图形流水线”,就建立在这个统一架构之上。

2. 最小计算单位:流处理器到底是什么东西

2.1 从流处理器、CUDA Core 到 ALU 的逻辑拆解

很少有人第一次接触“流处理器”这个术语时就能理解它和“核心”的区别。NVIDIA 管它叫 CUDA Core,AMD 叫 Stream Processor,Intel 的 Xe 架构里叫执行单元(EU),叫法五花八门,本质是一个东西:一个能执行算术和逻辑操作的最小计算单元,里面核心的核心是一组 ALU(Arithmetic Logic Unit,算术逻辑单元)。

展开看,一个流处理器可以执行的操作包括:浮点加法、浮点乘法、融合乘加(FMA,一次计算 a*b+c)、整数加减、位运算、逻辑比较。所谓“流”,指的就是这一大堆计算单元上面跑的是同一种流式操作——拿到数据,算,然后输出。图形计算里最常见的运算就是 4 维向量的加减乘、矩阵乘法、点乘和叉积,这些在底层全都可以拆成最原始的浮点乘法和加法。

一个有意思的细节是,现代 GPU 的浮点计算在硬件层面大量使用融合乘加指令 FMA。一个 FMA 只发一条指令,同时完成了乘和加,既节省指令发射带宽,又因为只在最后一步做一次舍入所以精度还更高。在算 GPU 算力的时候,一个 FMA 通常被按照两次浮点运算来计数,这就是为什么你在产品规格里看到的 FLOPS 数字往往是“核心数×频率×2”。比如一颗拥有 16384 个流处理器、加速频率约 2.5 GHz 的 GPU,它的单精度浮点算力大致是 16384×2.5e9×2 ≈ 81.9 TFLOPS,这个计算逻辑就是这么来的。

2.2 SIMT 与线程:一条指令驱动几十个数据小兵

流处理器单看非常“笨”,它就只会乘乘加加、做点简单比较。真正的复杂度在调度方式,GPU 使用 SIMT(Single 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 叫它 SM(Streaming Multiprocessor,流式多处理器),AMD 叫 CU(Compute 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 Call,CPU 录命令的速度就会拖慢整个帧率——这就是常说的“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 Merger,OM),硬件会做最后的裁决:深度测试判断这个片元到底能不能被写入;模板测试对其他条件做裁剪;如果透过透明物体渲染,则必须开启混合(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 相互通信可以走 L2;L1 缓存/共享内存在 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/ASTC,UI 图采用图集合并,运行时用流送纹理只加载小 mip 等级。

8.4 为什么有时候改个渲染顺序帧率变化巨大

渲染顺序最直接影响的是深度测试的提前拒绝率(Early-Z)。如果你先画了远处的地形,再画近处但会遮挡地形的建筑,建筑画完再做深度测试就把远地形像素淘汰掉了,但地形像素在淘汰之前已经跑完了像素着色器和纹理采样,大量计算白费。如果反过来先画建筑,再画地形,地形里大量像素会在像素着色器之前被 Early-Z 拒绝,后处理的带宽、着色器负载都会降很多。

在一个复杂的场景里,我的经验是:透明物体永远最后渲染;不透明物体尽量按“由近到远”的顺序绘制;启用了 alpha test 的物体要特别小心,因为它的片元在早期颜色处理上无法被丢弃,Early-Z 会被禁用,此时可以考虑用 alpha to coverage 或者预计算遮罩替代。这里没有万能规则,要根据实际深度复杂度来测,但“先近后远”通常是一个不错的起点。

结尾

写到这里,GPU 芯片内部怎么完成图形计算,从最底层的流处理器到完整的图形流水线,基本算是串成一条线了。我个人最深的体会是,这些架构知识能不能派上用场,关键不在于背参数表,而在于你遇到问题时能不能在脑子里快速定位“当前这个画面数据到底卡在哪个硬件环节”。有一次我排查一个移动端发热问题,朋友怀疑是像素着色器光照算太复杂,查了一圈发现是 G-Buffer 用了一个 RGBA32F 的格式,带宽爆了,换回 RGBA16F 后帧率立刻提升 40%。从那以后我改任何渲染效果,第一件事就是先算这笔带宽账。下次你写一个昂贵的新渲染特性之前,也不妨先问问自己:它现在计入的是 ALU 的成本,还是显存带宽的成本?答案往往就是优化的起点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询