本篇以 NVIDIA 为代表详细解析现代 GPU 架构,深入理解 GPU 运行机制,有助于理解大型引擎的渲染系统构建和渲染优化思路。
1 GPU 架构发展
NVIDIA GPU 架构历经多次变革,从起初的 Tesla 发展到最新的 Turing 架构,发展史可分为以下时间节点:
2008 - Tesla
Tesla最初是给计算处理单元使用的,应用于早期的CUDA系列显卡芯片中,并不是真正意义上的普通图形处理芯片。
2010 - Fermi
Fermi是第一个完整的GPU计算架构。首款可支持与共享存储结合纯cache层次的GPU架构,支持ECC的GPU架构。
2012 - Kepler
Kepler相较于Fermi更快,效率更高,性能更好。
2014 - Maxwell
其全新的立体像素全局光照 (VXGI) 技术首次让游戏 GPU 能够提供实时的动态全局光照效果。基于 Maxwell 架构的 GTX 980 和 970 GPU 采用了包括多帧采样抗锯齿 (MFAA)、动态超级分辨率 (DSR)、VR Direct 以及超节能设计在内的一系列新技术。
2016 - Pascal
Pascal 架构将处理器和数据集成在同一个程序包内,以实现更高的计算效率。1080系列、1060系列基于Pascal架构
2017 - Volta
Volta 配备640 个Tensor 核心,每秒可提供超过100 兆次浮点运算(TFLOPS) 的深度学习效能,比前一代的Pascal 架构快5 倍以上。
2018 - Turing
Turing 架构配备了名为 RT Core 的专用光线追踪处理器,能够以高达每秒 10 Giga Rays 的速度对光线和声音在 3D 环境中的传播进行加速计算。Turing 架构将实时光线追踪运算加速至上一代 NVIDIA Pascal™ 架构的 25 倍,并能以高出 CPU 30 多倍的速度进行电影效果的最终帧渲染。2060系列、2080系列显卡也是跳过了Volta直接选择了Turing架构。
各架构的微观物理结构可以查看深入GPU硬件架构及运行机制-gpu微观物理结构。
2 GPU 的功能
现代GPU除了绘制图形外,还担当了很多额外的功能,综合起来如下几方面:
图形绘制。
这是GPU最传统的拿手好戏,也是最基础、最核心的功能。为大多数PC桌面、移动设备、图形工作站提供图形处理和绘制功能。
物理模拟。
GPU硬件集成的物理引擎(PhysX、Havok),为游戏、电影、教育、科学模拟等领域提供了成百上千倍性能的物理模拟,使得以前需要长时间计算的物理模拟得以实时呈现。
海量计算。
计算着色器及流输出的出现,为各种可以并行计算的海量需求得以实现,CUDA就是最好的例证。
AI运算。
近年来,人工智能的崛起推动了GPU集成了AI Core运算单元,反哺AI运算能力的提升,给各行各业带来了计算能力的提升。
其它计算。
音视频编解码、加解密、科学计算、离线渲染等等都离不开现代GPU的并行计算能力和海量吞吐能力。
3 GPU 逻辑架构
下图是图形架构的微观结构:
从 Fermi 开始 NVIDIA 都使用类似的原理架构,使用一个 Giga Thread Engine 来管理所有正在进行的工作,GPU 被划分成多个 GPCs(Graphics Processing Cluster),每个 GPC 拥有多个 SM(或者SMX、SMM)和一个光栅化引擎(Raster Engine)。
程序员编写的 shader 是在 SM 上完成的。每个 SM 包含许多为线程执行数学运算的 Core,一个线程可以是顶点或像素着色器调用。这些 Core 包括整数运算的 ALU,浮点运算的 FPU,特殊函数运算的 SFU,用于深度学习的 Tensor Core 和用于光线追踪的 RT Core 等等。这些 Core 和其它单元由 Warp Scheduler 驱动,Warp Scheduler 管理一组 32 个线程作为 Warp(线程束)并将要执行的指令移交给 Dispatch Units。
例如下图是图灵架构单个 SM 的结构:
其中包含:
- 4 个 Warp Schedulers:这个模块负责 warp 调度,一个 warp 由 32 个线程组成,warp 调度器的指令通过 Dispatch Units 送到 Core 执行
- 64 CUDA 核,每个 CUDA 核包含一个整数运算单元(上图中 INT32)和一个浮点数运算单元(上图中 FP32)
- 16 个 LD/ST(load/store)模块来加载和存储数据
- 4 个 SFU(Special function units)执行特殊数学运算(sin、cos、log 等)
- 256KB 寄存器(4 个 16384 * 32 bit 的 Register File)
- 96KB L1缓存(共享内存)
- 4 个纹理读取单元及纹理缓存(上图中 TEX)
- 若干光线追踪核心(RT Core)
其他上图中没有标出的还包括:
- PolyMorph Engine:多边形引擎负责属性装配(attribute Setup)、顶点拉取(VertexFetch)、曲面细分、栅格化(这个模块可以理解专门处理顶点相关的东西)
- 指令缓存(Instruction Cache)
- 内部链接网络(Interconnect Network)
Fermi 架构的单个 SM 更清晰的包含了上述的大部分结构:
4 GPU 逻辑管线
接下来以 Fermi 架构的 SM 为例,进行逻辑管线的详细说明。
1、首先程序通过图形 API(DX、GL等)发出 Drawcall 指令,指令会被推送到驱动程序,驱动会检查指令的合法性,然后会把指令放到 GPU 可以读取的 Pushbuffer 中。
2、经过一段时间或者显式调用 flush 指令后,驱动程序把 Pushbuffer 的内容发送给 GPU,GPU 通过主机接口(Host Interface)接受这些命令,并通过前端(Front End)处理这些命令。
3、在图元分配器(Primitive Distributor)中开始工作分配,处理 indexbuffer 中的顶点产生三角形分成批次(batches),然后发送给多个 GPCs。这一步的理解就是提交上来 n 个三角形,分配给这几个 GPC 同时处理。
4、在 GPC 中,每个 SM 中的 Poly Morph Engine 负责通过三角形索引(triangle indices)取出三角形的数据(vertex data),即上图中的Vertex Fetch模块。
5、在获取数据之后,在 SM 中以 32 个线程为一组的线程束(Warp)来调度,来开始处理顶点数据。Warp 是典型的单指令多线程(SIMT,SIMD单指令多数据的升级)的实现,也就是 32 个线程同时执行的指令是一模一样的,只是线程数据不一样,这样的好处就是一个 warp 只需要一个套逻辑对指令进行解码和执行就可以了,芯片可以做的更小更快,之所以可以这么做是由于 GPU 需要处理的任务是天然并行的。
6、SM 的 warp 调度器会按照顺序分发指令给整个 warp,单个 warp 中的线程会锁步(lock-step)执行各自的指令,如果线程碰到不激活执行的情况也会被遮掩(be masked out)。被遮掩的原因有很多,例如当前的指令是if(true)的分支,但是当前线程的数据的条件是false,或者循环的次数不一样(比如for循环次数n不是常量,或被break提前终止了但是别的还在走),因此在shader中的分支会显著增加时间消耗,在一个 warp 中的分支除非 32 个线程都走到同一个分支里面,否则相当于所有的分支都走了一遍,任何一个线程都不能独立执行指令,而是以 warp 为单位,这些 warp 之间才是独立的。
7、warp 中的指令可以被一次完成,也可能经过多次调度,例如通常 SM 中的 LD/ST(加载存取) 单元数量明显少于基础数学操作单元。
8、由于某些指令比其他指令需要更长的时间才能完成,特别是内存加载,warp 调度器可能会简单地切换到另一个没有内存等待的 warp,这是 GPU 如何克服内存读取延迟的关键,只是简单地切换活动线程组。为了使这种切换非常快,调度器管理的所有 warp 在寄存器文件中都有自己的寄存器。这里就会有个矛盾产生,shader 需要越多的寄存器,就会给 warp 留下越少的空间,就会产生越少的 warp,这时候在碰到内存延迟的时候就会只是等待,而没有可以运行的 warp 可以切换。
9、一旦 warp 完成了 vertex-shader 的所有指令,运算结果就会被 Viewport Transform 模块处理,三角形会被裁剪然后准备光栅化,GPU 会使用 L1 和 L2 缓存来进行 vertex-shader 和 pixel-shader 的数据通信。
10、接下来这些三角形将被分割,再分配给多个 GPC,三角形的范围决定着它将被分配到哪个光栅引擎(raster engines),每个 raster engines 覆盖了多个屏幕上的 tile,这等于把三角形的渲染分配到多个 tile 上面。也就是一个三角形并不是完全由一个 GPC 处理,而是根据覆盖的屏幕区域会被分配给不同的 GPC 计算。
11、然后 SM 中的 Attribute Setup 对从 vertex-shader 来的数据进行插值并保证插值后对 pixel-shader 是可读的。
12、GPC 上的光栅引擎(raster engines)在它接收到的三角形上工作,来负责这些三角形的片元信息的生成,同时还会处理裁剪Clipping、背面剔除和 Early-Z 等。
13、接下来 32 个像素线程将被分成一组,或者说 8 个 2x2 的像素块,这是在像素着色器上面的最小工作单元,在这个像素线程内,如果没有被三角形覆盖就会被遮掩,SM 中的 warp 调度器会管理像素着色器的任务。
14、接下来的阶段就和vertex-shader中的逻辑步骤完全一样,但是变成了在像素着色器线程中执行。 由于不耗费任何性能可以获取一个像素内的值,导致锁步执行非常便利,所有的线程可以保证所有的指令可以在同一点。
15、最后一步,现在像素着色器已经完成了颜色的计算还有深度值的计算,在这个点上,我们必须考虑三角形的原始 api 顺序,然后才将数据移交给 ROP(render output unit,渲染输出单元),一个 ROP 内部有很多 ROP 单元,在 ROP 单元中处理深度测试,和 framebuffer 的混合,深度和颜色的设置必须是原子操作,否则两个不同的三角形在同一个像素点就会有冲突和错误。
5 技术要点
上面的流程中有很多细节没有展开阐述,这里分别补充。
5.1 SIMD和SIMT
SIMD(Single Instruction Multiple Data)是单指令多数据,在GPU的ALU单元内,一条指令可以处理多维向量(一般是4D)的数据。比如,有以下shader指令:
1 | float4 c = a + b; // a, b都是float4类型 |
对于没有SIMD的处理单元,需要4条指令将4个float数值相加,汇编伪代码如下:
1 | ADD c.x, a.x, b.x |
但有了 SIMD 技术,只需一条指令即可处理完:
1 | SIMD_ADD c, a, b |
SIMT(Single Instruction Multiple Threads,单指令多线程)是SIMD的升级版,可对 GPU 中单个 SM 中的多个 Core 同时处理同一指令,并且每个 Core 存取的数据可以是不同的。
5.2 co-issue
co-issue是为了解决SIMD运算单元无法充分利用的问题。例如下图,由于float数量的不同,ALU利用率从100%依次下降为75%、50%、25%。
为了解决着色器在低维向量的利用率低的问题,可以通过合并 1D 与 3D 或 2D 与 2D 的指令。例如下图,DP3
指令用了 3D 数据,ADD
指令只有 1D 数据,co-issue会自动将它们合并,在同一个 ALU 只需一个指令周期即可执行完。
5.3 if-else 语句
如下图,SM中有 8 个ALU(Core),由于 SIMD 的特性,每个 ALU 的数据不一样,导致if-else
语句在某些ALU中执行的是true
分支(黄色),有些ALU执行的是false
分支(灰蓝色),这样导致很多 ALU 的执行周期被浪费掉了(即masked out),拉长了整个执行周期。最坏的情况,同一个SM中只有1/8(8 是同一个 SM 的线程数,不同架构的 GPU 有所不同)的利用率。
同样,for
循环也会导致类似的情形,例如以下shader代码:
1 | void func(int count, int breakNum) |
由于每个ALU的count
不一样,加上有break
分支,导致最快执行完shader的ALU可能是最慢的N分之一的时间,但由于SIMD的特性,最快的那个ALU依然要等待最慢的ALU执行完毕,才能接下一组指令的活!也就白白浪费了很多时间周期。因此在shader中的分支会显著增加时间消耗
5.4 Early-Z
关于 Early-Z 可以查看之前的笔记【Real-Time Rendering】模板测试和深度测试,这里要强调的是 Early-Z 剔除的最小单位不是 1 像素,而是像素块(pixel quad,2x2个像素)。
此外,从硬件角度讲 Early-Z 还存在深度数据冲突(depth data hazard)问题,如下图:
假设数值深度值 5 已经经过 Early-Z 即将写入 Frame Buffer,而深度值 10 刚好处于 Early-Z 阶段,读取并对比当前缓存的深度值 15,结果就是 10 通过了 Early-Z 测试,会覆盖掉比自己小的深度值 5,最终 frame buffer 的深度值是错误的结果。
避免深度数据冲突的方法之一是在写入深度值之前,再次与frame buffer的值进行对比:
5.5 统一着色器架构(Unified shader Architecture)
在早期的 GPU,顶点着色器和像素着色器的硬件结构是独立的,它们各有各的寄存器、运算单元等部件。这样很多时候,会造成顶点着色器与像素着色器之间任务的不平衡。对于顶点数量多的任务,像素着色器空闲状态多;对于像素多的任务,顶点着色器的空闲状态多。
于是,为了解决 VS 和 PS 之间的不平衡,引入了统一着色器架构(Unified shader Architecture)。用了此架构的 GPU,VS 和 PS 用的都是相同的 Core。也就是,同一个 Core 既可以是 VS 又可以是 PS。这样就解决了不同类型着色器之间的不平衡问题,还可以减少 GPU 的硬件单元,压缩物理尺寸和耗电量。此外,VS、PS 可还可以和其它着色器(几何、曲面、计算)统一为一体。
5.6 像素块(Pixel Quad)
前文说到,在像素着色器中,会将相邻的四个像素作为不可分割的一组,送入同一个 SM 内 4 个不同的 Core 中。为什么这样做呢?可能有以下几点原因:
1、简化和加速像素分派的工作。
2、精简 SM 的架构,减少硬件单元数量和尺寸。
3、降低功耗,提高效能比。
4、无效像素虽然不会被存储结果,但可辅助有效像素求导函数。
这种设计虽然有其优势,但同时,也会激化过绘制(Over Draw)的情况,损耗额外的性能。比如下图中,白色的三角形只占用了 3 个像素(绿色),按我们普通的思维,只需要 3 个 Core 绘制 3 次就可以了。
但是,由于上面的 3 个像素分别占据了不同的像素块(橙色分隔),实际上需要占用 12 个 Core 绘制 12 次(下图)。
这就会额外消耗 300% 的硬件性能,导致了更加严重的过绘制情况。
6 GPU 资源管理
6.1 GPU 内存架构
有些 GPU 的内存架构和 CPU 类似,分为寄存器,L1 缓存,L2 缓存,GPU 显存和系统显存:
它们的存取速度从寄存器到系统内存依次变慢:
由此可见,shader直接访问寄存器、L1、L2缓存还是比较快的,但访问纹理、常量缓存和全局内存非常慢,会造成很高的延迟。
由于 SIMT 技术的引入,导致很多同一个 SM 内的很多 Core 并不是独立的,当它们当中有部分 Core 需要访问到纹理、常量缓存和全局内存时,就会导致非常大的卡顿(Stall)。
如下图,有 4 组上下文(Context),它们共用同一组运算单元ALU:
假设第一组 Context 需要访问缓存或内存,会导致 2~3 个周期的延迟,此时调度器会激活第二组 Context 以利用ALU:
当第二组Context访问缓存或内存又卡住,会依次激活第三、第四组Context,直到第一组Context恢复运行或所有都被激活:
延迟的后果是每组Context的总体执行时间被拉长了:
但是,越多Context可用就越可以提升运算单元的吞吐量。
6.2 CPU-GPU 异构系统
根据CPU和GPU是否共享内存,可分为两种类型的CPU-GPU架构:
上图左是分离式架构,CPU和GPU各自有独立的缓存和内存,它们通过PCI-e等总线通讯。这种结构的缺点在于 PCI-e 相对于两者具有低带宽和高延迟,数据的传输成了其中的性能瓶颈。目前使用非常广泛,如PC、智能手机等。
上图右是耦合式架构,CPU 和 GPU 共享内存和缓存。AMD 的 APU 采用的就是这种结构,目前主要使用在游戏主机中,如 PS4。
在存储管理方面,分离式结构中 CPU 和 GPU 各自拥有独立的内存,两者共享一套虚拟地址空间,必要时会进行内存拷贝。对于耦合式结构,GPU 没有独立的内存,与 CPU 共享系统内存,由 MMU 进行存储管理。
6.3 GPU 资源管理模型
下图是分离式架构的资源管理模型:
- MMIO(Memory Mapped IO)
- CPU与GPU的交流就是通过MMIO进行的。CPU 通过 MMIO 访问 GPU 的寄存器状态。
- DMA传输大量的数据就是通过MMIO进行命令控制的。
- I/O端口可用于间接访问MMIO区域,像Nouveau等开源软件从来不访问它。
- GPU Context
- GPU Context代表了GPU计算的状态。
- 在GPU中拥有自己的虚拟地址。
- GPU 中可以并存多个活跃态下的Context。
- GPU Channel
- 任何命令都是由CPU发出。
- 命令流(command stream)被提交到硬件单元,也就是GPU Channel。
- 每个GPU Channel关联一个context,而一个GPU Context可以有多个GPU channel。
- 每个GPU Context 包含相关channel的 GPU Channel Descriptors , 每个 Descriptor 都是 GPU 内存中的一个对象。
- 每个 GPU Channel Descriptor 存储了 Channel 的设置,其中就包括 Page Table 。
- 每个 GPU Channel 在GPU内存中分配了唯一的命令缓存,这通过MMIO对CPU可见。
- GPU Context Switching 和命令执行都在GPU硬件内部调度。
- GPU Page Table
- GPU Context在虚拟基地空间由Page Table隔离其它的Context 。
- GPU Page Table隔离CPU Page Table,位于GPU内存中。
- GPU Page Table的物理地址位于 GPU Channel Descriptor中。
- GPU Page Table不仅仅将 GPU虚拟地址转换成GPU内存的物理地址,也可以转换成CPU的物理地址。因此,GPU Page Table可以将GPU虚拟地址和CPU内存地址统一到GPU统一虚拟地址空间来。
- PCI-e BAR
- GPU 设备通过PCI-e总线接入到主机上。 Base Address Registers(BARs) 是 MMIO的窗口,在GPU启动时候配置。
- GPU的控制寄存器和内存都映射到了BARs中。
- GPU设备内存通过映射的MMIO窗口去配置GPU和访问GPU内存。
- PFIFO Engine
- PFIFO是GPU命令提交通过的一个特殊的部件。
- PFIFO维护了一些独立命令队列,也就是Channel。
- 此命令队列是Ring Buffer,有PUT和GET的指针。
- 所有访问Channel控制区域的执行指令都被PFIFO 拦截下来。
- GPU驱动使用Channel Descriptor来存储相关的Channel设定。
- PFIFO将读取的命令转交给PGRAPH Engine。
- BO
- Buffer Object (BO),内存的一块(Block),能够用于存储纹理(Texture)、渲染目标(Render Target)、着色代码(shader code)等等。
6.4 CPU-GPU 数据流
下图是分离式架构的 CPU-GPU 的数据流程图:
1、将主存的处理数据复制到显存中。
2、CPU指令驱动GPU。
3、GPU中的每个运算单元并行处理。此步会从显存存取数据。
4、GPU将显存结果传回主存。
更加详细的 GPU 数据流程可以查看最后的参考文章。
6.5 显像机制
计算机显示图像的过程如下图:
显示器通常以固定频率进行刷新,CPU 将计算好显示内容提交至 GPU,GPU 渲染完成后将渲染结果存入帧缓冲区,视频控制器会按照屏幕刷新信号逐帧读取帧缓冲区的数据,经过数据转换后最终由显示器进行显示。
6.6 双重缓冲
在单缓冲下,帧缓冲区的读取和刷新都都会有比较大的效率问题,经常会出现相互等待的情况,导致帧率下降。
为了解决效率问题,GPU 通常会引入两个缓冲区,即 双缓冲机制。在这种情况下,GPU 会预先渲染一帧放入一个缓冲区中,用于视频控制器的读取。当下一帧渲染完毕后,GPU 会交换缓冲区内容。
6.7 垂直同步
双缓冲虽然能解决效率问题,但会引入一个新的问题。当 GPU 渲染速度大于屏幕刷新速度时,视频控制器还未读取完成,即屏幕内容刚显示一半时,GPU 就将新的一帧内容提交到帧缓冲区并把两个缓冲区进行交换,此时视频控制器就会把新的一帧数据的下半段显示到屏幕上,造成画面撕裂现象:
为了解决这个问题,GPU 通常有一个机制叫做垂直同步(V-Sync),当开启垂直同步后,GPU 会等待显示器的刷新信号发出后,才进行新的一帧渲染和缓冲区更新。这样能解决画面撕裂现象,也增加了画面流畅度,但需要消费更多的计算资源,也会带来部分延迟。
7 Shader 运行机制
Shader 代码也跟传统的 C++ 等语言类似,需要将面向人类的高级语言(GLSL、HLSL、CGSL)通过编译器转成面向机器的二进制指令,二进制指令可转译成汇编代码,以便技术人员查阅和调试。
由高级语言编译成汇编指令的过程通常是在离线阶段执行,以减轻运行时的消耗。
在执行阶段,CPU端将shader二进制指令经由PCI-e推送到GPU端,GPU在执行代码时,会用Context将指令分成若干Channel推送到各个Core的存储空间。
对现代GPU而言,可编程的阶段越来越多,包含但不限于:顶点着色器(Vertex Shader)、曲面细分控制着色器(Tessellation Control Shader)、几何着色器(Geometry Shader)、像素/片元着色器(Fragment Shader)、计算着色器(Compute Shader)等等。总体流程如下图:
8 渲染优化建议
由以上分析,可以得出以下渲染优化建议:
- 减少CPU和GPU的数据交换:
- 批处理(Batch)
- 减少顶点数、三角形数
- 视锥裁剪
- BVH
- Portal
- BSP
- OSP
- 避免每帧提交Buffer数据
- CPU版的粒子、动画会每帧修改、提交数据,可移至GPU端。
- 减少渲染状态设置和查询
- 例如:
glGetUniformLocation
会从GPU内存查询状态,耗费很多时间周期。 - 避免每帧设置、查询渲染状态,可在初始化时缓存状态。
- 例如:
- 启用GPU Instance
- 开启LOD
- 避免从显存读数据
- 减少过绘制:
- 避免Tex Kill操作
- 避免Alpha Test
- 避免Alpha Blend
- 开启深度测试
- Early-Z
- 层次Z缓冲(Hierarchical Z-Buffering,HZB)
- 开启裁剪:
- 背面裁剪
- 遮挡裁剪
- 视口裁剪
- 剪切矩形(scissor rectangle)
- 控制物体数量
- 粒子数量多且面积小,由于像素块机制,会加剧过绘制情况
- 植物、沙石、毛发等也如此
- Shader优化:
- 避免if、switch分支语句
- 避免
for
循环语句,特别是循环次数可变的 - 减少纹理采样次数
- 禁用
clip
或discard
操作 - 减少复杂数学函数调用