拆过显卡或者翻过 GPU 白皮书的人,多半都产生过一种感觉:芯片上的晶体管数比 CPU 多好几倍,但真要讲清楚这些晶体管是怎么把三角形画面算出来的,就没那么容易了。因为 GPU 的图形计算并不是“很多个核一起算”这么简单,它有一套从最小计算单位到完整图形流水线的组织方式:先由执行单元组成线程束,再由线程束跑各种着色器,中间穿插图元装配、光栅化这些专用硬件,最后写回帧缓冲,屏幕才会亮起来。这篇文章就按这个顺序,把 GPU 芯片内部的图形计算链路完整捋一遍。适合刚接触 GPU 架构的开发者、图形学入门者,以及想搞明白显卡内部工作原理的硬件爱好者。
1. 一块 GPU 芯片上,到底“谁”在算图形
1.1 图形计算和通用计算共享同一批执行单元
先说一个很多人没想到的事实:在现在的 GPU 里,图形计算并不是一块独立于通用计算的电路,而是和 CUDA、OpenCL 这类通用计算共用同一批执行单元。
二十年前不是这样。那时候顶点着色器和像素着色器各有各的硬件电路,规模固定,很容易出现顶点单元忙不过来、像素单元却在闲置的情况。后来 NVIDIA 从 G80 开始推统一着色器架构(Unified Shader Architecture),AMD、Intel 也跟着走这条路,把顶点着色、像素着色甚至 compute shader 全部统一到同一组可编程执行单元上。硬件本身只看到“一批线程”,至于这是顶点任务还是像素任务,由驱动和命令处理器决定。
如果你打开 Nsight 或者 Radeon GPU Profiler 看,会发现在同一个 SM(Streaming Multiprocessor)里执行的 warp,既可以来自顶点着色器,也可以来自像素着色器,还可以是计算着色器。所以回答“谁在算图形”时,答案不是某个专用电路,而是:同一个可编程执行池,加上外围一圈固定功能电路。
很多人把“显卡有多少 CUDA Core”和图形性能直接画等号,这是不对的。真正的判断依据应该是这些执行单元在满足图形流水线约束时能跑出多少吞吐,以及固定功能单元(光栅化、纹理、ROP)能不能跟上。光看核心数,解释不了为什么同一颗芯片在不同游戏里表现差异很大。
1.2 最小执行单位不是“核心”,是线程束
每次看显卡参数,厂商都会写“XX CUDA Core”“XX Stream Processor”,给人感觉每个核心都在独立算一个像素。但硬件层面,GPU 实际派发、调度、执行的基本单位,不是单个核心,而是线程束(warp)。AMD 里常叫 wavefront,图形 API 里叫 subgroup。
在 NVIDIA 架构上,一个 warp 固定包含 32 个线程;AMD 的 wavefront 可以配置成 32 或 64,RDNA 时代很多驱动默认用 32;Intel 的 subgroup 大小是 8/16/32 可选。这 32 个线程不是由 32 个完全独立的核心各算各的,而是被硬件打包成一组,一起执行同一条指令,只是每个线程操作的数据不同。
用一个不严谨但好理解的类比:CPU 是一个大厨,一次做一道复杂的菜;GPU 是一排流水线工人,一次给 32 个菜同时撒同一把盐。撒盐这个动作一模一样,区别只是每份菜的咸淡状态不同。
为什么要打包成 warp 而不是单线程调度?因为这样可以平摊取指、译码、发射的成本。一条指令取出来、解码后广播给 32 套执行单元,相当于一份控制逻辑干 32 份活,面积和功耗都能省很多。代价是灵活性下降:如果这 32 个线程想走不同的分支,硬件就得把每个分支挨个执行一遍,这就是常说的 warp divergence(线程束分化)。图形 shader 里如果出现对每个像素都判断“if (x > 0.5)”然后走不同逻辑的情况,性能会很难看,原因就在这里。
2. 从一根线程到一次指令发射:ALU、寄存器和调度器怎么配合
2.1 一个 CUDA Core 在硬件上是什么
既然说最小执行单位是 warp,那厂商宣传的 CUDA Core 又是什么?
在硬件上,一个 CUDA Core 通常就是一套能执行 FP32 浮点运算、整数运算和简单逻辑操作的 ALU(算术逻辑单元),它没有太多的独立状态。每个 SM 里还有 SFU(特殊功能单元),专门算倒数、平方根、三角函数这类复杂函数;到了 RTX 时代,旁边还多了 Tensor Core 和 RT Core,但那些属于并行侧翼,最基础的着色器运算还是靠常规 ALU 完成。
SM 的内部结构,通常是一个比较大的寄存器文件、若干 warp 调度器,以及一长排执行单元。以近几代典型 SM 为例,一个 SM 里可能有 4 个 warp scheduler,每个 scheduler 管理一组执行单元,每个周期可以发射一条 warp 指令。
这里有个特别容易误解的地方:线程数远多于 ALU 数。一张显卡号称上万 CUDA Core,但它同时承载的线程数可能是几十万甚至上百万。真正执行的时候是分时复用,每个线程并不绑定某个固定 ALU。打个比方,ALU 是食堂打饭窗口,线程是排队的学生。窗口数量有限,但一个窗口可以轮流服务几千个学生。调度器决定哪个窗口下一位轮到谁。
理解了这一点,你就不会再纠结“我的显卡有这么多个核,为什么处理游戏还是需要一整套复杂流水线”。因为算力只是一部分,数据能不能喂到执行单元里才是另一回事。
2.2 调度器为什么总按 32 条线程一组发指令
调度器的作用,简单说就是每个周期挑选一个就绪的 warp,把它下一条指令广播给 32 个 lane(执行通道)。每个 lane 对应一个线程在这个 warp 里的位置。指令从头到尾有一个固定流程:取指、译码、发射、执行、写回。
CPU 的核心也差不多,但 GPU 的特殊之处在于它有大量“就绪”的 warp 可以切换。当一个 warp 在等显存数据时,调度器可以立刻切到另一个不用等数据的 warp 继续干活。早期 GPU 的延迟容忍能力几乎全靠这种线程级并行(Thread-Level Parallelism)撑着,靠同时跑很多 warp 把访存延迟藏起来。这也是为什么 GPU 的单个线程延迟比 CPU 高很多,但整体吞吐量却能大出几个数量级。
至于为什么 warp 大小是 32 而不是 16 或 64,这是硬件面积、调度粒度和编译模型之间的折中。32 个线程共享一条指令,寄存器堆和控制逻辑的开销可控,分支发散损失也相对可控。AMD 之前用过 64,后来也允许 32/64 混合,说明并没有一个绝对最优解。工程上大家现在都往 32 靠,生态和编译器都好做。
2.3 寄存器压力:图形负载里最先爆掉的资源
很多刚入行的人会把注意力放在 ALU 数量上,真做性能分析时才发现,图形负载最先爆的往往是寄存器。
SM 里的寄存器文件容量是固定的。每线程能用的寄存器数量,直接决定一个 SM 里能同时驻留多少线程。举个例子:如果某代 SM 有 65536 个 32 位寄存器,每个线程分配 32 个,那最多能同时驻留 2048 个线程;如果 shader 复杂度高,每线程要 64 个寄存器,能驻留的线程数立刻减半。驻留线程数下降,意味着调度器手里可供切换的 warp 变少,隐藏访存延迟的能力也跟着变弱,最终表现为核心明明没跑满,吞吐却上不去。
图形管线上,顶点着色器和像素着色器经常写得很重。尤其是现代 PBR 材质,一堆法线、切线、基础色、金属度、粗糙度、光照参数,编译器一看寄存器不够,就会把部分变量“溢出”到 local memory。local memory 这个名字听着像缓存,实际在多数 GPU 上就是显存,只是会被缓存兜一下。一旦发生溢出,性能掉得飞快。
这也是为什么驱动会做大量寄存器分配优化,而开发者在写自定义 shader 时,也应该控制临时变量数量、减少动态数组。如果你想用 CUDA 做图形与计算混合开发,还可以用 launch_bounds 之类的机制提示编译器把占用率拉高;图形 API 里一般只能靠改 shader 实现类似效果。
经验:做图形性能分析时,如果发现某个 shader 的 local memory 用量异常大,不要先怀疑纹理或 ALU,先去查寄存器溢出,十有八九是临时变量太多导致占用率崩了。
3. 把三角形画到屏幕上:光栅化前后芯片内部发生了什么
3.1 顶点着色到图元装配:几何阶段
图形流水线的前半段处理的是几何,核心是三角形。
游戏引擎把场景里的每个模型拆成三角形,每个三角形的顶点位置、法线、UV、颜色打包成顶点缓冲区,提交给 GPU。GPU 先让顶点着色器(Vertex Shader)跑在每个顶点上,做模型变换、视图变换、投影变换,把顶点从模型空间经世界空间、相机空间挪到裁剪空间。这一步完全可编程,SM 上的线程按顶点数量分配,一个 warp 里的 32 个线程可以同时处理 32 个不同的顶点,吞吐模型非常漂亮。
顶点着色器输出之后,硬件进入图元装配(Primitive Assembly,PA)阶段。这个阶段是固定功能电路,没有通用计算能力,只有专门逻辑:把顶点按索引缓冲的顺序连成三角形,然后做视锥裁剪、齐次除法,顺便把背向摄像机的三角形剔除掉。PA 听起来简单,但它处理的是顶点索引而不是连续顶点数组,所以芯片里还需要专门的 index cache 来加速索引指向的顶点读取。
再往后是曲面细分(Tessellation)和几何着色器这些可选阶段。曲面细分把一个大三角形切分成成千上万个更小的三角形,让模型表面更细腻,代价是几何阶段吞吐需求暴涨。近几代 NVIDIA 和 AMD 都在推广 mesh shader,本质是想用可编程的网格处理替代部分固定功能几何流程,让几何负载更像计算任务,能够更好地并行调度和管理。从这层能看出来,GPU 内部一直在做“把更多固定功能转成可编程”的尝试。
3.2 光栅化单元的工作原理
几何处理完,三角形已经从三维空间变成二维屏幕上的形状。能不能直接画到屏幕?不行。三角形本质上是一个连续区域,而屏幕是一格一格的像素。怎么判断哪些像素被三角形覆盖,这就是光栅化。
光栅化是固定功能单元在做,不是 SM 上的着色器。通常每个 GPC(图形处理簇)内部会有一个光栅引擎(Raster Engine),它用边缘函数之类的算法快速判断某个采样点是在三角形内还是三角形外,并生成对应的 fragment(片元)。
为了理解边缘函数,你可以想象三角形三条边各是一条直线。像素中心点如果在三条边的同一侧(按绕序判断),这个像素就被覆盖。GPU 不需要对每个像素都从头算一遍,它可以从三角形的包围盒开始一行行扫描,用增量算法复用上一步的插值结果。所以即使三角形覆盖上百万像素,光栅化的开销也不是按单个像素独立计算的。当然,真正高分辨率渲染里,光栅化器吞吐依然是关键,芯片里必须放专用电路而不是丢给通用 ALU。
片元一旦生成,不会直接被写进像素,而是发回给 SM,由像素着色器(Pixel/Fragment Shader)处理。每个片元携带着插值好的属性:屏幕坐标、深度、法线、UV 等。这些属性在光栅化过程中按重心坐标插值出来,并且要做透视校正(perspective-correct interpolation),否则纹理在画面上会剧烈扭曲。
看到这里你会明白,GPU 内部的图形计算其实是“在 SM 和其他固定功能单元之间反复横跳”的过程,并不是一条直线走到底。顶点阶段在 SM,图元阶段跑到专用单元,光栅化又在专用单元,然后片元又回到 SM,最后再次交给 ROP。这条路径比很多人想象得曲折。
3.3 纹理采样和纹理缓存:芯片里的“查表引擎”
像素着色器里最频繁做的一件事是纹理采样,也就是拿 UV 坐标去显存里查一张图片。这个操作如果在通用 ALU 里做,要自己算地址、自己做滤波,效率会非常低。
所以 GPU 里专门放了纹理单元(Texture Mapping Unit,TMU),内部包含地址计算、格式解码、双线性/三线性/各向异性过滤等固定逻辑。着色器只需要给出一个纹理对象和 UV 坐标,纹理单元会返回过滤后的颜色。对硬件来说,纹理采样有点像“查表引擎”,CPU 级别的通用计算很少做这种高度模式化、还要大量插值的操作。
现代 GPU 中,纹理单元通常紧挨着 SM 或直接集成在 SM 内部,配套一块专用纹理缓存。纹理数据和通用显存数据不同,有很强的空间局部性:相邻像素的 UV 往往很靠近,要采样的纹素大多在附近,所以纹理缓存命中率经常很高,命中时延迟比显存低得多。
老玩家常说“显存带宽决定显卡上限”,其实纹理缓存和帧缓冲压缩同样影响巨大。如果性能分析里看到纹理缓存命中率偏低,通常不是换大显存能救的,而是 mipmap 没开、纹理格式不对或者过滤参数设置不合理。驱动里那些“纹理质量”选项,本质上就是在调纹理缓存和带宽的使用策略。
4. 完整的图形流水线怎么串成一条线
4.1 一次 draw call 从驱动到芯片的旅程
现在把各阶段串起来看。
你调用一次 DrawIndexed(DirectX)或者 glDrawElements(OpenGL)时,CPU 端先经过驱动,把顶点缓冲地址、着色器句柄、渲染状态压进命令缓冲区。这一步不是直接把数据丢给 GPU,而是像给流水线工人下发工单。驱动把工单写入一块共享内存,然后通过门铃(doorbell)机制告诉 GPU 有新命令到达。GPU 前端(Front-End)读取命令流,解析 draw call,再把顶点抓取、光栅化这些子任务分发给不同的 GPC 和 SM。
这个过程中最影响帧率的是 CPU-GPU 同步。如果一帧里每个物体都单独提交一次 draw call,CPU 可能成为瓶颈。所以现代引擎都会做批处理(batching)、实例化(instancing),或者用 indirect draw 让 GPU 自己决定画什么。
对架构理解有帮助的是:draw call 不会一个接一个老老实实执行完。GPU 内部有多级队列和乱序完成机制,很多 draw call 的不同阶段可以重叠。驱动里开启多线程命令录制、减少状态切换,本质上都是为了让 GPU 前端和 GPC 的并行能力始终保持满载。
4.2 固定功能单元 vs 可编程着色器
完整图形流水线可以理解成一条流水线,但里面的环节性质完全不同。有的环节是可编程的(shader),有的环节是固定功能的(fixed-function),比如图元装配、光栅化、深度测试、混合。下面这个表可以帮助快速建立整体框架:
| 阶段 | 类型 | 主要硬件/单元 | 干什么 |
|---|---|---|---|
| 顶点着色 | 可编程 | SM 上的通用 ALU | 顶点变换、蒙皮等 |
| 曲面细分 | 可编程 + 固定 | Hull/Domain Shader + 硬件 Tessellator | 三角形细分 |
| 图元装配 | 固定功能 | PA/Tessellator 周边 | 连三角形、裁剪、剔除 |
| 光栅化 | 固定功能 | Raster Engine | 像素覆盖判断、属性插值 |
| 片元着色 | 可编程 | SM 上的通用 ALU | 光照、纹理采样、颜色计算 |
| 深度/模板测试、混合、写回 | 固定功能 | ROP/Render Backend | 可见性测试、混合、写帧缓冲 |
为什么要保留固定功能?因为固定功能电路功耗低、面积小、确定性高。光栅化和深度测试这类操作,软件实现每像素要好几条指令,专用电路一个周期能处理好几个像素。为什么又要不断扩大可编程范围?因为现代渲染技术(PBR、光线追踪、程序化生成)需要灵活度。一个明显的行业趋势是,开发者会在功能允许的情况下,尽量把数据搬到可编程阶段处理,以获得更高的硬件利用率。
4.3 帧缓冲、混合和显存带宽
片元着色器算完颜色后,并不是立即写到屏幕上,还要经过 ROP(Render Output Unit,也译作光栅输出单元,AMD 叫 Render Backend)。
ROP 负责深度测试、模板测试和混合(blending)。深度测试用深度缓冲区里存的值判断这个片元是否被遮挡,被遮挡就丢弃;混合则把新颜色和已有颜色按透明度叠起来。这一步无论如何都要消耗带宽,因为深度值和颜色值要读出来、写回去,显存带宽就这样被迅速吃满。
厂商们为了压缩带宽压力,会在 ROP 和 L2 缓存里做无损压缩(比如 NVIDIA 的 delta color compression 针对帧缓冲区域做差值压缩)、快速清除(fast clear)和 MSAA 延迟解析(deferred resolve)。游戏里的分辨率、抗锯齿档位对性能的影响,很大一部分不来自 shader 计算量,而是来自带宽。
做画面优化时,除了看 GPU 占用率,还要盯着显存带宽、ROP 吞吐这类指标。不看这些,很容易把瓶颈误判成“显卡算力不够”,然后白折腾半天设置。
5. 现代 GPU 的多核结构:从 SM 到整个芯片怎么组织
5.1 GPC、TPC、SM(以 NVIDIA 为例看层级)
前面一直提 SM,现在把视角拉开。
现代 NVIDIA GPU 并不只是几十个 SM 平铺在一起,而是分三层组织:GPC(图形处理簇)→ TPC(纹理处理簇)→ SM。以近几代中高端 GPU 为例,整颗芯片被切成几个到十几个 GPC,每个 GPC 内部有多个 TPC,每个 TPC 通常包含两个 SM。GPC 内部还有一个光栅引擎,负责把光栅化任务分给下属 SM。整体上看,GPC 有点像芯片里的小型 GPU,可以独立处理一条几何和像素流水线。
对应到图形负载里:前端把三角形的顶点分组,分给多个 GPC 并行处理;每个 GPC 的光栅化器处理其分配到的三角形,再把 fragment 派给 SM 上的像素着色器;SM 算完的结果通过 L2 写回。
SM 内部还能再细分。典型结构是 4 个分区(partition),每个分区有自己的 warp scheduler、一组 ALU、一块寄存器和共享内存切片。一个 warp 被分配到一个分区后,这个分区里的 scheduler 负责它的生命周期。任务分配到这些更小的执行块后,指令发射才能真正跑起来。
5.2 显存和缓存在流水线里的位置
如果只看 SM 和 ALU,很容易忽略另一个重要维度:数据从哪来。
GPU 内部的存储层级很简单:每个线程的私有寄存器 → 同一 SM 内线程共享的 shared memory → L1 缓存和纹理缓存 → 整个芯片共享的 L2 缓存 → 显存(VRAM)。每一级的速度差一个数量级以上。图形计算能不能跑得快,很多时候是缓存策略说了算。
举几个典型场景:
- 顶点着色器读取顶点数据时,如果索引顺序不连续,内存合并(memory coalescing)效果就差,缓存会被打散。
- 片元着色器采样纹理时,如果 mipmap 全开,纹理缓存命中率会明显上升。
- 大量粒子或动态贴图反复写同一块 buffer,GPU 又要在显存和 SM 之间搬数据,还要处理缓存一致性问题。
想让 GPU 跑得快,通用原则就一条:尽量按可预测的顺序访问连续内存,把重算换成缓存命中。这个原则在图形和计算里都成立,而且越接近显存层级,收益越明显。
5.3 不同厂商的“最小计算单位”长什么样
不同厂商对“最小计算单位”的称呼、大小差异不小,但核心思想一致。用表直接对比更清楚:
| 厂商/架构 | 最小可编程计算簇 | 线程束/子组典型大小 | 内部执行单元描述 |
|---|---|---|---|
| NVIDIA(SM) | Streaming Multiprocessor | 32(warp) | CUDA Core:FP32/INT ALU + SFU + Tensor Core + RT Core |
| AMD(CU) | Compute Unit | 32/64(wave32/wave64) | Stream Processor:FP32/INT ALU,部分 CU 带访存和纹理单元 |
| Intel(Xe-core) | Xe Vector Engine | 8/16/32(subgroup,可配置) | Xe ALU + Matrix Engine |
| ARM Mali / Qualcomm Adreno | 计算核心组 | 16/32 不等 | Shader Core 内的 ALU 集群 |
看到这个表,你应该能理解为什么不同平台的图形性能不好直接比核心数。同一代、同架构下,SM/CU 数量大确实代表峰值算力更高;跨架构比较,就要看线程束大小、寄存器容量、缓存带宽和固定功能单元吞吐。
但底层那套 SIMT 思路几乎一致:一组线程共享一条指令、各算各的数据,靠大量并行掩盖延迟。把这个模型吃透,看哪家芯片都有指导意义。
6. 几个常见理解偏差和深入学习建议
6.1 “核心数越多就越强”不完全对
现在买显卡时经常听到“多少核心”,但实际性能强不强,得看瓶颈在哪。
死磕理论峰值算力,不如看具体负载在芯片上怎么走。举几个例子:
- 一个渲染高分辨率 3A 游戏的负载,像素着色器复杂度不高但三角形数量巨大,瓶颈可能在几何阶段和光栅化器。
- 另一个场景是大量透明物体重叠,混合和 ROP 吞吐立马成为瓶颈。
- 8K 分辨率下,显存带宽和 L2 缓存压力陡增,SM 算力再强也发挥不出来。
只看核心数,很难解释同样核心规模的卡为什么在不同游戏里差距很大。我做性能定位时的习惯是:先看帧时间分解,判断是 CPU 提交慢,还是 GPU 几何阶段慢,还是 pixel 阶段慢,还是显存带宽打满。不同阶段对应不同硬件资源,资源图一对照,解决问题的优先级就出来了。
6.2 图形流水线不是完全线性执行的
初学者往往把图形流水线想成一条单向直线:顶点 → 光栅化 → 像素 → 输出。实际情况更像一条带多个队列的生产线,不同 draw call 的不同阶段可以在芯片里并行。
比如上一个 draw call 在 ROP 里做混合的时候,下一个 draw call 的顶点着色器可能已经在跑了。再加上驱动会重排命令,执行顺序比教科书上画的那条线复杂得多。
还有一个重要分支是移动 GPU 的 Tile-Based Rendering。移动芯片因为带宽更贵,不会像桌面 GPU 那样每次都把帧缓冲写到显存,而是把屏幕切成小块(tile),先在片上缓存里完成一整块的颜色和深度操作,再一次性写回。这种架构做 Early-Z 更彻底,但也会引入 binning 开销。理解不同渲染模式,对做移动端图形优化很有帮助,也能加深你对桌面 GPU 流水线的理解。
6.3 想往深挖该看哪些资料
如果想把这篇的内容落到能看白皮书的水平,我的建议路线是:
先写一个软件光栅化器。不需要 GPU 加速,用 CPU 实现顶点变换、三角形边缘函数、重心坐标插值、深度缓冲、纹理采样和混合。哪怕只支持单线程,也能把“VS-PA-Raster-FS-ROP”这条链路的每个环节理解得很扎实。
然后去读任意一代 NVIDIA 白皮书里关于 SM 的章节,结合 Nsight Graphics 抓一帧游戏,看每个阶段的耗时和占用。想进一步深入,可以看《Real-Time Rendering》第四版,它把流水线各阶段讲得很全。编程实践上,Vulkan 教程会覆盖 shader 到 draw call 的完整路径,比 OpenGL 更接近现代驱动模型,建议直接学。
我自己当年理解 GPU 架构,最大的坎不是 ALU 数量,而是始终跳不出“CPU 思维”。后来在一张老卡上做软件渲染对照实验,看着同一帧画面在不同阶段的时间分布,才真正明白什么叫最小计算单位、为什么流水线里有那么多固定功能单元。这篇先搭了一个整体框架,下一篇可以深入讲某一块,比如 SM 内部的调度和寄存器分配,或者光栅化器的具体实现算法。框架有了,后面填细节就不会迷路。