本文是对向往大神的文章的一个笔记。
想阅读原文章移步博客园搜索向往
原文章比较长,这是一个精简和自己的一些理解
这篇文章要带着下面的问题去看
1、GPU是如何与CPU协调工作的?
2、GPU也有缓存机制吗?有几层?它们的速度差异多少?
3、GPU的渲染流程有哪些阶段?它们的功能分别是什么?
4、Early-Z技术是什么?发生在哪个阶段?这个阶段还会发生什么?会产生什么问题?如何解决?
5、SIMD和SIMT是什么?它们的好处是什么?co-issue呢?
6、GPU是并行处理的么?若是,硬件层是如何设计和实现的?
7、GPC、TPC、SM是什么?Warp又是什么?它们和Core、Thread之间的关系如何?
8、顶点着色器(VS)和像素着色器(PS)可以是同一处理单元吗?为什么?
9、像素着色器(PS)的最小处理单位是1像素吗?为什么?会带来什么影响?
10、Shader中的if、for等语句会降低渲染效率吗?为什么?
11、如下图,渲染相同面积的图形,三角形数量少(左)的还是数量多(右)的效率更快?为什么?
12、GPU Context是什么?有什么作用?
13、造成渲染瓶颈的问题很可能有哪些?该如何避免或优化它们?
GPU全称是Graphics Processing Unit,图形处理单元。
CPU的发展符合摩尔定律,每18个月速度翻倍。对于GPU,NVIDIA创始人黄仁勋说,GPU 的更新速度远超摩尔定律,每6个月就能翻一倍。
移动端、图形工作站类型GPU生产商,两大厂,NVIDIA和AMD。生产移动端显卡的厂商还有ARM,高通等。
DX11架构
DX11添加了hull shader,tessellator shader,domain shader。三个有点奇怪的shader
NVIDIA GPU架构发展史关键节点 :
2008–Tesla 特斯拉架构,不是真正意义上的图形处理芯片
2010–Fermi 费米架构,第一个完整的GPU计算架构,支持ECC
2012–Kepler
2014–Maxwell 添加VXGI提供实时GI,采用了MFAA抗锯齿
2016–Pascal
2017–Volta
2018–Turing 图灵架构,增加了RT core,专门用来处理光线追踪。10Giga Rays的石压地狱对光线加速计算。
每个SM内部包含64个CUDA核,8个tensor核,256kb寄存器文件。
GPU架构的共性:
为什么GPU内有那么多的雷同的部件?因为GPU的任务是天然并行的,所以要为高并行度设计。
从费米架构开始,结构都比较相似。
使用一个Giga Thread Engine来管理所有正在进行的工作。
一个GPU被分成多个GPC(Graphics Processing Cluster),每个GPC拥有多个SM和一个光栅化引擎(Raster Engine)。
其中用Crossbar来进行连接GPCs和其他的功能性模块。
32个core由一个线程束(warp)来管理
程序员的shader是在SM上完成的。每个SM有许多线程执行数学运算的core。
GPU逻辑管线:
1.程序通过图形API(DX,GL,Vulkan)发出drawcall指令,指令被推送到驱动程序,驱动检查指令的合法性,之后会把指令放到GPU可以读取的pushbuffer中。
2.经过一段时间的显式调用或者flush指令后,驱动程序把pushbuffer的内容发送给GPU,GPU通过主机接口(host interface)接受命令,通过前端(front end)处理这些命令。
3.在图元分配器(Primitive Distribute)中开始分配工作,处理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的wrap调度器会按顺序分发指令给整个warp,单个warp中的线程可能会锁步。
有的线程不激活可能被掩饰(masked out)。
原因可能有很多,比如if-else指令,有的遇到的是true,有的是false。或者是循环的次数不一样,比如n不是常量,或者说有的线程被提前break终止了。
线程不能独立执行指令,执行指令是以warp为单位的,但是这些warp之间才是独立的。
7.由于某些指令的执行时间明显需要更长的时间来完成,特别是内存加载。warp调度器可能简单的切换到另一个没有内存等待的线程束。这是GPU如何克服内存读取延迟的关键,就是简单的切换活动线程组。
8.一旦warp完成了vertex-shader的所有指令,运算结果被viewport transform模块处理。三角形会被裁剪然后光栅化,GPU会使用L1和L2缓存来进行vertex shader和fragment shader之间的数据通信。
9.接下来三角形分割,分配个多个GPC,三角形的范围决定了被分配到哪个raster engines,每个raster engines覆盖了屏幕多个tile,等于把三角形的渲染分配到多个tile上。也就是像素阶段把按三角形划分变成了按显示的像素划分。
10.SM上的Attribute Setup保证了从vertex-shader来的数据经过插值后是pixel-shader是可读的
11.GPC上的光栅化引擎在接收到三角形上工作,来负责这些三角形的像素信息的生成(同时会处理裁剪clipping,背面剔除和early-z剔除)
12.32个像素线程将被分成一组,或者说8个2x2的像素块,这是在像素着色器上面的最小工作单元,在这个像素线程内,如果没有被三角形覆盖就会被遮掩,SM中的warp调度器会管理像素着色器的任务。
13.接下来的阶段就和在vertex-shader中的逻辑步骤完全一样,但是变成了在像素着色器线程中执行。由于不耗费任何性能就可以获取一个像素内的值,导致锁步执行非常便利,所有的线程可以保证所有的指令可以在一点。
14.最后一步,现在像素着色器已经完成了颜色的计算还有深度值的计算。我们必须要考虑三角形的原始API顺序,然后才将数据移交给ROP(render output unit,渲染输出单元),一个ROP内部有很多的ROP单元,在ROP单元中处理深度测试,和framebuffer的混合,深度和颜色的设置必须是原子操作,否则两个不同的三角形在同一个像素点就会有冲突和错误。
SIMD(Single Instruction Multiple Data)是单指令多数据,在GPU的ALU单元内,一条指令可以处理多维向量(一般是4D)的数据。比如,有以下shader指令:
float4 c = a + b;//a,b都是float4类型
对于没有SIMD的处理单元,需要4条指令将4个float数值相加,汇编伪代码如下:
ADD c.x a.x b.x
ADD c.y a.y b.y
ADD c.z a.z b.z
ADD c.w a.w b.w
但有了SIMD技术,只需要一条指令就可以进行
SIMD_ADD c,a,b
SIMT(Single Instruction Multiple Threads,单指令多线程)是SIMD的升级版,可对GPU中单个SM中的多个Core同时处理同一指令,并且每个Core存取的数据可以是不同的。
SIMT_ADD c,a,b
上述指令会被同时送入在单个SM中被编组的所有core中,同时执行运算,但a、b、c的值可以不一样:
co-issue是为了解决SIMD运算单元无法充分利用的问题。由于float的数量不同,ALU的利用率从100%依次下降为75%、50%、25%。
为了解决着色器在低位向量的利用率低的问题,通过合并1维与3维或者2维与2维的指令。ALU只需一个指令周期即可执行完成。
但是对于向量运算单元(Vector ALU),如果其中一个变量即是操作数又是存储数的情况,无法启用co-issue技术。
于是标量指令着色器应运而生,可以有效的组合任何向量,开启co-issue技术,充分发挥SIMD的优势。
流处理器中有8个ALU,由于SIMT特性,每个ALU的数据都不一样,导致if-else语句在某些ALU中执行的是true分支,有的执行的是false分支。这就导致很多ALU的执行周期被浪费掉了,拉长了整个执行周期。
shader里面尽量少写if-else 和for循环(for循环主要是因为count值不同并且有的分支会提前break出来)。
早期GPU的渲染管线的深度测试时在像素着色器之后执行的,这样会造成对很多本不可见的像素执行了耗性能的像素着色器计算。
为了减少像素着色器的额外消耗,将深度测试提前至像素着色器之前,这就是early-z技术的由来。
Early-Z技术可以将很多无效的像素剔除,避免他们进入耗时严重的像素着色器。Early-Z剔除的最小单位不是1像素,而是像素块(pixel quad,2*2个像素)
但是以下情况会使得Early-Z失效:
early-z技术会导致一个问题,深度数据冲突(depth data hazard)
例子要结合上图,假设数值深度值5已经经过Early-Z即将写入Frame Buffer,而深度值10刚好处于Early-Z阶段,读取并对比当前缓存的深度值15,结果就是10通过了Early-Z测试,会覆盖掉比自己小的深度值5,最终frame buffer的深度值是错误的结果。
避免深度数据冲突的方法之一是在写入深度值之前,再次与frame buffer的值进行对比:
早期GPU,顶点着色器和像素着色器的硬件结构是独立的,它们各有各的寄存器,运算单元等部件。这样很多时候,会造成顶点着色器与像素着色器之间任务的不平衡。对于顶点数量多的任务,像素着色器空闲状态多哦;对于像素多的任务,顶点着色器的空闲状态多。
于是,为了解决VS和PS之间的不平衡,引入了统一着色器架构(unified shader architecture)。用了此架构的GPU,VS和PS用的都是统一的core,也就是说,同一个core即可以是vs又可以是ps。
这样就解决了不同类型着色器之间的不平衡问题,还可以减少GPU的硬件单元,压缩物理尺寸和耗电量。此外,vs,ps还可以和其他着色器(几何、曲面、计算)统一为一体。
32个像素线程将被分成一组,或者说8个2x2的像素块,这是在像素着色器上面的最小工作单元,在这个像素线程内,如果没有被三角形覆盖就会被遮掩,SM中的warp调度器会管理像素着色器的任务。
也就是说,在像素着色器中,会将相邻的四个像素作出不可分割的一组,送入同一个SM内4个不同的core
为什么像素着色器处理的最小单元是2x2的像素块?
1、简化和加速像素分派工作
2、精简SM的架构,减少硬件单元数量和尺寸
3、降低功耗,提高效能比
4、无效像素虽然不会被存储结果,但可辅助有效像素求导函数。
导致的问题,激化过绘制(overdraw)的情况,损耗额外的性能。
这种设计虽然有其优势,但同时,也会激化过绘制(Over Draw)的情况,损耗额外的性能。比如下图中,白色的三角形只占用了3个像素(绿色),按我们普通的思维,只需要3个Core绘制3次就可以了。
简单的说,就是3个像素分别处于3个不同的像素块内,所以要绘制的就不是三个像素而是三个像素块,加剧过绘制。
但是,由于上面的3个像素分别占据了不同的像素块(橙色分割),实际上需要占用12个core绘制12次。
这就会额外消耗300%的硬件性能,导致了更加严重的过绘制情况。
部分架构的GPU与CPU类似,也有多级缓存结构:寄存器、L1缓存、L2缓存、GPU显存、系统显存。
可见,shader直接访问寄存器,L1,L2缓存比较快,但是访问纹理,常量缓存和全局内存比较慢,会造成高延迟。
上面的多级缓存结构被成为CPU-style,还存在GPU-style的内存结构:
这种架构的特点是ALU比较多,GPU上下文(context)多,吞吐量高,依赖高带宽与系统内存交换数据。
由于SIMT技术的引入,导致很多同一个SM内的很多Core并不是独立的,当它们当中有部分Core需要访问到纹理、常量缓存和全局内存时,就会导致非常大的卡顿(Stall)。
例如下图中,有4组上下文(Context),它们共用同一组运算单元ALU。
越多的context可用就越可以提升运算单元的吞吐量,比如下图的18组context的架构可以最大化的提升吞吐量。
根据CPU和GPU是否共享内存,可以分为两种类型的CPU-GPU架构:
上图左是分离式架构,CPU和GPU各自有独立的缓存和内存,他们通过PCI-e等总线通讯。这种结构的缺点在于PCI-e相对于两者具有低带宽和高延迟,数据的传输成了其中的性能瓶颈。目前使用十分广泛,如PC,智能手机等。
上图右是耦合式架构,CPU和GPU共享内存和缓存,AMD的APU就是采用的这种架构,目前主要使用在游戏主机中,如PS4。
在存储管理方面,分离式结构中CPU和GPU各自拥有独立的内存,两者共享一套虚拟地址空间,必要时会进行内存拷贝。对于耦合式结构,GPU没有独立的内存,与GPU共享内存,由MMU进行存储管理。
下图是分离式架构的CPU-GPU的数据流程图
1.将主存的处理数据复制到显存中。
2.CPU指令驱动GPU
3.GPU中的每个运算单元并行处理,此步会从显存存取数据。
4.GPU将显存结果传回主存。
当一帧画面绘制完成后,电子枪回复到原位,准备画下一帧前,显示器会发出一个垂直同步信号。简称VSync。
显示器通常以固定频率进行刷新,这个刷新率就是VSync信号产生的频率。虽然现在的显示器基于都是液晶显示屏,但其原理基本一致。
CPU将计算好显示内容提交至GPU,GPU渲染完成后将渲染结果存入帧缓冲区,视频控制器会按照VSync信号逐帧读取帧缓冲区的数据,经过数据转换后最终由显示器进行显示。
在单缓冲下,帧缓冲区的读取和刷新都会有比较大的效率问题,经常会出现相互等待的情况,导致帧率下降。
为了解决效率问题,GPU通常会引入两个缓冲区,即双缓冲机制。这种情况下,GPU会预先渲染一帧放入一个缓冲区中,用于视频控制器的读取。当下一帧渲染完毕后,GPU会直接把视频控制器的指针指向第二个缓冲器。
双缓冲虽然能解决效率问题,但会引入一个新问题。当视频控制器还未读取完成时,即屏幕内容刚显示一半时,GPU将新的一帧内容提交的帧缓冲区并把两个缓冲区进行交换后,视频控制器就会把新的一帧数据的下半段显示到屏幕上,造成画面撕裂的现象。
为了解决这个问题,GPU通常有一个机制叫做垂直同步(简称也是V-Sync),当开启垂直同步后,GPU会等待显示器VSync信号发出后,才进行新的一帧渲染和缓冲区更新。这样能解决画面撕裂现象,也增加了画面流畅度,但需要跟过的计算资源,带来延迟。
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)、…
CPU和GPU的差异可以描述在下面的表格中:
它们之间的差异(缓存,核心数量,内存,线程数等)可以用下图展示出来。
减少CPU和GPU 的数据交换
比如合批(Batch)
减少顶点数,三角形数
视锥裁剪:BVH,Portal,BSP,OSP
避免每帧提交buffer数据:cpu版的粒子、动画会每帧修改、提交数据,可移至GPU端。
减少渲染 状态设置和查询:例如:glGetUniformLocation会从GPU内存查询状态,耗费很多时间周期。避免每帧设置,查询渲染状态,可以在初始化时缓存状态。
启用GPU Instance
开启LOD
避免从显存读取数据
减少over draw(过绘制)
避免Tex Kill操作
避免Alpha Test
避免Alpha Blend
开启深度测试
Early-z
层次z缓冲
开启裁剪
背面裁剪
遮挡裁剪
视口裁剪
剪切矩形
控制物体数量
粒子数量多且面积小,由于像素块机制,会加剧过绘制状态
植物,砂石,毛发等也如此
shader优化
避免if-switch分支语句
避免for循环语句,特别是循环次数可变的
减少纹理采样次数
禁用clip或discard操作
减少复杂数学函数的调用