|
这篇笔记是整理的之前看《Real-Time Rendering 4th Edition》这本书的读书笔记~
从历史上来说,由于诸如扫描线插值,访问图片数据,可见度测试等等计算的需要产生了对图形加速的需求,于是诞生了对应的硬件,他们在这些计算上相比CPU仅有速度上的优势,不过这已经很重要了。
第一款商用图形芯片1999年问世,是Nvidia的GeForce256。NVIDIA创造了graphics processing unit (GPU)这个词语来区分其和之前的仅光栅化的芯片。如今GPU慢慢朝着可编程和更灵活的方向发展。GPU专注于处理并行任务,在这方面有速度上的巨大优势。而延迟(latency)是所有处理器都面临的问题。比如去访问数据时,就会发生。
3.1 Data-Parallel Architectures数据并行架构
不同的处理器架构有不同的方法来避免停顿(stall)。CPU主要是针对处理大型数据结构和大量的代码库进行了优化。
CPU可以有多个处理器,但是每个处理器都是以串行的方式来执行代码,SIMD的向量处理是一些极少数的例外。为了减少延迟(latency)大部分的CPU芯片都有快速的本地缓存.CPU还应用一些技术用来避免延迟诸如分支预测,指令重排序,寄存器重命名,缓存预读取等。
GPU则是采用不同的方法。大部分GPU芯片区域都是供大量的处理器使用的,这些处理器被称为着色器核心(shader cores),往往有上千个。GPU是一个流处理器,有序的处理一组相似的数据。由于数据的相似性比如一组顶点间的数据是相似的,GPU可以大规模并行的处理这些数据。
另一个重点是所有的调用要尽量无依赖,他们不共享内存位置,不需要访问隔壁处理器的调用信息。尽管有一些有用的功能需要打破这个规则(如ddx)。所以也要明白类似的功能是以一定的延迟可能性为代价的(一个处理器需要等待另一个处理器完成)。
吞吐量,定义为处理数据的最大速率。然而快速的处理是有代价的。因为GPU相比CPU的芯片,在缓存和控制逻辑上的区域面积都更小,因此着色器内核处理的延迟也会更高。举一个常见的延迟的例子就是texture访问,假定我们执行一段pixel shader代码,前面都是些对寄存器中值的简单的算术操作。这些访问很快,但是接下来执行了一行tex2D,tex2D并不是一个ps本地内存中可以访问的值,并没有那么快,往往需要成百上千个时钟周期。而这段时间内处理器就会发生停顿(stall),等待纹理颜色的值返回。为了优化延迟,我们为每个片元的本地寄存器提供一个很小的储存空间。这样,我们就允许处理器切换到另一个片元上去执行。切换操作是非常快的,然后执行另一个片元的操作,遇到纹理访问,再切换。以此方法执行,当处理器返回第一个片元的时候,纹理数据已经获取完毕了,于是第一个pixel的shader代码继续执行。这样下来,尽管单个片元执行的总时间变长了,但是整体的执行时间却大幅降低了,因为大部分延迟时间都被隐藏了(Lantency Hiding)。
不过这样的架构仍使得GPU的处理器忙于切换,因此GPU进一步设计,将指令逻辑和数据分离。也就是单指令多数据SIMD(single instruction, multiple data)的设计。这个设计可以在固定数量的着色器程序上按照锁定步骤执行同一条命令。SIMD的优势就是比起每个ps分配一个逻辑单元和调度单元,用的硅和功率少得多。
用GPU的术语,每个片元的PS的调用称为一个thread(此thread非彼thread)。由少许输入值的内存和ps执行所需的寄存器空间组成。相同着色器程序的threads会被分组,在NVIDIA中这个组叫warps,而AMD中叫wavefronts。一个warps/wavefront由8~64个GPU着色器核心按SIMD计划执行。每个thread会映射到一条SIMD通道。
举个例子,假设我们有2000条thread要执行,NVIDIA的GPU,warps包含32条thread。那么会产生2000/32 = 62.5个warps,于是总共分配63个warps,其中一个有一半是空的。而warp的执行就如同我们上述的例子,按锁定步骤执行所有32个处理器。当一个内存访问的操作发生,所有32个thread会同时遭遇(因为是单指令)。于是32个thread都会等待(stall)各自不同的返回结果。然后warp会和另一个32条thread组成的warp切换。32个处理器继续执行另一个warp。切换速度足够快并且不需要访问任何thread内的数据。每个thread有自己的寄存器,而每个warp则是记录指令的执行。切换warp仅仅只是把处理器指向另一个thread集,直到所有warps执行完毕。如图3.1
实际上,warps可能会因为更短的延迟就执行切换,不仅仅是纹理访问这种耗时操作。warp切换基本是所有GPU的主要延迟隐藏(lantency-hiding)机制。影响这个处理的有效性的因素有几个,比如threads越少,那么lantency-hiding的效果就越差。着色器程序的结构是影响效率的重要特征。如果每个着色器程序需要的寄存器过多,那么可以驻留在GPU中的thread就越少。如上面分析,就会导致lantency-hiding机制的削弱。驻留在GPU的warps数量称为占用率(occupancy)。占用率越高意味着空闲的处理器越少。低占用率往往会导致糟糕的性能。耗时操作的频率也会影响对于lantency-hiding的需求(如频繁的进行tex2D的访问)。另一个影响有效性的因素就是动态分支,比如if语句和循环。由于warp中的所有thread都是单指令锁定步骤执行的。比如if语句出现时,只要有一条语句走了不同的分支,会导致整个warp都要执行两条分支,然后每条thread丢弃自己不需要的结果。这个问题叫做thread分歧。而如果是只有少数thread需要执行循环迭代时,其他thread在这个时候就是空闲的。所有GPU的实现都是基于上述的架构理念,尽管限制严格但是却能发挥强大的计算能力。了解这些理念对程序员高效的使用GPU很重要。
3.2 GPU Pipeline Overview GPU管线总览
GPU物理上实现了第二章所述的各个阶段,要注意这些实现和第二章中功能性的阶段不是完全一致的。GPU通过API暴露一些接口给程序员,但是具体内部怎么设计的还是看硬件供应商,功能性阶段的理解可以帮你分析一些性能问题,但不要认为是GPU完完全全的实现方式。
顶点着色器/几何着色器都是完全可编程阶段。曲面细分阶段和几何着色器都是可选的,不是所有GPU都支持,特别在移动设备上支持率有限。裁剪,三角形设置,三角形遍历阶段是由固定功能硬件实现。屏幕映射会受窗口视口设置影响。像素着色器阶段是完全可编程的。合并阶段不可编程,但可以高度配置。总体上来说,GPU管线的灵活性和可控制性在不断的提升。
3.3 The Programmable Shader Stage 可编程着色器阶段
现代的着色器程序使用统一的着色器设计。VS/GS/PS等都是用一个通用的编程模型,他们有相同的指令集结构(ISA)。
在DX中,实现这个模型的处理器称为通用着色器核心。这样设计的理由是GPU能更好的的平衡负载(比如有的渲染VS量大,有的主要是PS的工作)。shader编程模型主要使用类C语言比如DX的HLSL(High-Level Shading Language)和OpenGL的GLSL(OpenGL Shading Language)。HLSL还可以被编译成中间语言(IL/DXIL),能独立于硬件而存在,也经常用于离线编译存储shader程序。
shader编程中的基本数据结构有32位单精度浮点数的向量和标量。现代GPU中还支持32位整型和64位浮点数。此外还支持一些诸如结构体数组矩阵这样的数据结构。
每次drawcall会调用图形API来绘制一组图元,让管线执行shader,每个可编程着色器阶段有两种输入,uniform和varying。前者在整个drawcall中都恒定不变,后者则是通过顶点或者光栅化阶段产生的数据。纹理是一种特殊的uniform输入。
虚拟机会提供特殊的寄存器供shader使用。一般来说可使用的uniform的常量寄存器会比用于varying的寄存器多得多。因为后者需要每个顶点/像素都单独存储,而前者只需要一次存储就可以在这次drawcall所有顶点/像素中通用。虚拟机还提供通用的临时寄存器用于暂存空间。所有类型的寄存器都可以使用临时寄存器中的整数值进行数组索引。
shader虚拟机的输入输出如图3.3
常见的图形学计算在GPU上都能高效的执行,如+,*还有一些内建函数,atan(),sqrt(),log()等。以及更复杂的比如normalize(),reflect(),cross(),矩阵转置,行列式计算等。
shader的流控制(如if/else)分为静态(使用uniform变量做判断)和动态两种,如之前提到的,前者代码路径固定,不会产生thread分歧。而后者虽然功能更强大,但是会损耗性能。
3.4 The Evolutiono of Programmable Shading and APIs 可编程着色和API的进化史
可编程着色框架的想法可以追溯到1984年Cook的着色树。一个简单例子如图3.4。
RenderMan着色语言就是80年代后期从这个想法发展来的,现在也还会用于影片的渲染。
第一款商业级图形硬件由3dfx开发,于1996年10月1日面世。图3.5是一个时间轴。他们的voodoo图形卡能够高性能高质量的渲染游戏雷神之锤使得其迅速爆火。这个硬件实现了固定功能管线。
在GPU支持可编程着色器之前,已经有各种尝试通过多次渲染实现可编程的着色操作。1999年雷神之锤III的Arena脚本语言是第一个通用的商业案例。然后第一个被称作GPU的是NVIDIA的GeForce256,如之前所提到的那样。它不可编程,但是可配置。
2001年,NVIDIA的GeForce3是第一个支持可编程顶点着色器的GPU,支持DirectX 8.0和OpenGL。使用的是类汇编语言进行编程,然后被驱动转换为微码。DX8.0同样有像素着色器,只是没有达到完全的可编程性。指令数量(不超过12条)和功能性都很缺乏。这个时候的着色器还不支持流控制(分支),所以要靠计算两个系数然后插值结果来实现条件选择。
DX定义了Shader Model的概念,来区分硬件的兼容性。2002年,出现了DX9.0,包含了Shader Model2.0,实现了完全可编程的顶点和像素着色器。OpenGL的各种扩展也实现了类似的功能。添加了对任意依赖的纹理读取和16位浮点的支持。增加了指令集,纹理数量和寄存器数量等着色器资源的上限,使得着色器能够适用于更复杂的效果,还支持了流控制。越来越复杂的着色器使得汇编语言显得笨重。于是DX9.0还包含了HLSL的引入,由微软和NVIDIA合作开发。几乎同时OpenGL ARB (Architecture Review Board)发布了GLSL,一个为OpenGL服务的类似语言。他们的设计哲学深受C语言影响,也包含了RenderMan着色语言的元素。
2004年,Shader Model3.0问世,增加了动态流控制,让着色器变得更加强大。增加了可选功能,进一步提升了着色器资源数量上限,并对顶点读取纹理提供了一定支持。之后一世代的游戏主机(Xbox 360, PS3)都支持了SM3.0的GPU。任天堂的Wii是最后值得一提的使用固定功能GPU的主机之一。固定功能管线至此已经是过去时,随着着色器语言的进化,很多的工具也被开发出来管理他们。如图3.6
2009发布了DX11和SM5.0,增加了曲面细分阶段以及其着色器和计算着色器,也叫作DirectCompute。这次发布同样专注于更好的支持多核GPU。OpenGL则是在4.0中支持了曲面细分以及4.3中支持了compute shader。
DX和OpenGL进化路线不同,都是设定了一个硬件支持的需求。微软控制着DX而OpenGL则是由非营利性组织Khronos Group管理的硬件软件供应商联合开发。由于参与的公司太多,OpenGL的API特性往往会略晚于DX面世。但是OpenGL允许扩展,使得人们可以在官方新版本支持前提前使用到最新的GPU功能。
下一个重大变化则是2013年AMD推出了Mantle API,与游戏开发商DICE共同研发。这个API的主要想法是去除大量图形驱动的开销,并且更好的支持CPU多核处理。这歌API能很好的降低CPU在驱动上花费的时间。这个实验性的想法被微软采用了添加到了DX12中,于2015发布。值得注意的是DX12不是对DX11.3的升级,而是对整个API彻底的重构,更好的符合现代GPU架构。低开销的驱动对于以CPU驱动调用为瓶颈的以及使用CPU处理器处理图形的应用是很有用的。
苹果也在2014发布了自己的低开销API,Metal。从iPhone5S和iPad Air开始可用。除了效率之外,通过减少CPU使用来省能源,也是移动设备的一大重点。Metal有自己的着色语言。
AMD将自己的成果捐赠给Khronos Group,在2016发布了新API,Vulkan,与OpenGL一样跨平台。Vulkan使用了新的高级中间语言叫做SPIR-V,预编译的shader是可以移植的。还可以进行无图像GPU计算,因为Vulkan并不需要一个显示窗口。
在移动设备上规范则是OpenGL ES,ES是Embedded Systems是缩写,这个API会比标准的OpenGL简化。2003年发布的OpenGL ES1.0是OpenGL 1.3的简化版本。2007年OpenGL ES 2.0发布,基于OpenGL 2.0,由于移除了固定功能管线,所以和OpenGL ES1.0不兼容。2012年发布了OpenGL ES3.0,增加了MRT,纹理压缩,transform feedback,instancing等重要功能。OpenGL ES3.1增加了Compute Shader,3.2增加了几何着色器,曲面细分着色器等。不过和微软的DX不同,设备商对于ES的支持见仁见智,比如2010年发布的第一台iPad还是支持的GL ES 1.1。
OpenGL ES的一个分支是WebGL,基于浏览器,通过JavaScript调用。2011年发布,这歌API的第一个版本是大部分手机可用的,所以是基于OpenGL ES 2.0的功能。
和OpenGL同样,可以提前通过扩展使用更新的特性,WebGL 2则对应OpenGL 3.0的支持。WebGL特别适合上课用:因为它是跨平台的,有浏览器就可以用,而且浏览器往往都内置了debug工具。程序也可以直接通过上传到网站来部署。
3.5 The Vertex Shader 顶点着色器
如图3.2顶点着色器是第一个功能性管线阶段。由于其是第一个由程序直接的阶段,在此阶段之前发生的最多也就是些数据操作而已。DX称之为输入装配阶段(The input assembler),一些数据流可以被组装起来形成顶点和图元的集合发送至管线。
举例来说,一个物体可以被一个位置数组和一个颜色数组所描述,然后对不同的物体使用同样的数据和不同的变换矩阵就可以实现instancing。对一个三角形网格而言常见的顶点数据有位置,纹理UV,顶点颜色,表面法线。顶点着色器是处理一个三角形网格的第一个阶段。顶点着色器顾名思义只处理顶点,此刻还没有三角形的概念。
一般来说顶点着色器会将顶点从模型空间变换到齐次裁剪空间,组织一些三角形顶点的数据,然后至少会输出一个位置的参数。顶点着色器的输出会沿三角形或线段插值。顶点着色器并不能创建或者销毁顶点。因为顶点之间是相互独立的,这样着色器处理器才能并行的处理顶点流。
顶点着色器常见的其他应用有:
对象创建:只创建一次网格,然后通过VS来变形。
角色顶点动画。
程序变形:如布料和水面。
粒子创建
一些屏幕后处理,通过grabpass。
通过vtf(vertex tex fetch)应用地形高度场
图3.8展示了一个变形的例子
3.6 The Tessellation Stage 曲面细分阶段
曲面细分阶段可以让我们渲染曲线表面。GPU的任务是获取每个表面的描述,转化为一组三角形。这是一个可选阶段,DX11/OpenGL 4.0/GL ES 3.2支持。曲面细分阶段有一些优势,曲面描述的方式会比输入相应的大量三角形更加的紧凑。还可以节省内存和带宽。曲面的渲染还可以更高效,根据相应的场景生成不同数量的三角形,比如远离相机时候或者GPU能力不够好的时候数量少,贴近相机的时候数量多等。
曲面细分阶段由三部分组成,用DX中的术语,外壳着色器(HS/hull shader),镶嵌器(tessellator)和域着色器(DS/domain shader)。
OpenGL中的术语则是细分控制着色器(tessellation control shader),图元生成器(primitive generator)和细分计算着色器(tessellation evaluation shader)。二者可以说是一样的。
我们简单的描述下曲面细分阶段的流程。首先,一组特殊的补丁图元被传入hull shader,由几个控制点组成,定义了细分表面。hull shader有两个功能,首先告诉镶嵌器应该用什么配置生成多少个三角形。其次对每个控制点进行处理。hull shader还可以选择修改补丁描述(添加/删除控制点)。最终hull shader输出控制点集和细分控制数据到domain shader中。如图3.9.
镶嵌器是管线中的固定功能阶段。它的任务就是产生新的顶点供domain shader处理。镶嵌器从hull shader处接收到诸如细分因子(tessellation factors in DX,tessellation levels in OpenGL),表面类型(三角形/四边形/线段)等信息。
细分因子分为两种类型,内部因子——决定分部要发生多少次细分,和外部边缘因子——边缘处要发生多少次分割。细分因子的影响如图3.10所示。我们可以使相邻的表面边缘因子对应,这样不管内部怎么细分,我们都能避免也一些诸如裂缝之类的视觉错误的情况。产生的顶点会分配重心坐标,来描述其在原表面上的位置。
hull shader可以通过发送一个0或负数或NaN的外部细分因子给镶嵌器来表示丢弃这个补丁。否则镶嵌器就会生成网格送至domain shader。hs中控制点的数据在ds中也会用于计算每个顶点的输出数据。ds的数据流与vs相似,输出顶点数据到管线下一阶段。
整个系统结构看起来复杂,主要是为了效率才如此设计,其实每个shader本身可以非常简单。hs中往往不怎么修改输入数据,有时候根据相机距离动态调整细分因子。镶嵌器增加顶点数,生成他们的位置和形状,为了计算效率考虑是一个固定功能处理,ds则采用每个点的重心坐标去计算对应的位置法线纹理坐标等顶点信息。
图3.11是一个曲面细分的例子。
3.7 The Geometry Shader几何着色器
几何着色器可以将图元变成另外的图元,几何着色器也是可选阶段,在DX10/SM4.0(2006)和OpenGL 3.2/GLES 3.2支持使用
几何着色器的输入是一个物体和其关联的顶点。GS可以定义和处理扩展的图元。可以传入三个三角形外的附加顶点,使用折线上的两个相邻顶点。如图3.12。
几何着色器处理图元后输出一个顶点数组。几何着色器不能生成任何输出,只能修改,移除和添加顶点,它的设计是为了修改输入数据拷贝一定量的副本。比如生成6个副本同时渲染立方体贴图的6个面。还可以用来做级联shadow map。如图3.13.
DX11增加了几何着色器使用instancing的功能,几何着色器可以在给定图元上运行一定的次数。OpenGL 4.0中通过调用计数来指定。
几何着色器可以被输出到最多四个流中。可以发送一个流继续管线流程,也可以输出到其他的渲染目标中。GS能保证图元输入输出顺序。尽管在并行架构中,这样影响性能,也因为这个因素的存在,不建议在GS一次调用中创建太大量的几何图形。
实际上,GS的使用是比较少的,因为它并不能很好的匹配GPU的优势。在一些移动设备上它甚至是软件实现的,是比较不建议使用的。
3.7.1 Stream Output流式输出
GPU管线的标准输出是顶点-光栅-像素。以前这些步骤的中间结果是无法访问的。SM4.0中出现了流式输出的想法。顶点阶段处理完数据后,就可以被输出到一个数据流上。此时可以关闭光栅化,那么管线就变成了一个非图形的流处理器。处理好的数据可以被送回管线迭代处理。这对于一些水流模拟和粒子特效是有用的,以及蒙皮时用来缓存顶点。
流式输出的数据都是浮点数,内存开销是不小的。由于顶点阶段后,数据按三角形组织顶点,网格内的共享顶点的数据会丢失。因此更常见的做法是按点集的图元方式发送顶点到管线。OpenGL中这一阶段叫transform feedback,这个阶段同样能保证输出和输入的顶点顺序一致。
3.8 The Pixel Shader 像素着色器
经历了顶点,曲面细分,几何着色器后,图元被裁剪然后光栅化。这个阶段每个三角形会被遍历得出它们覆盖到具体的哪些像素点,每个三角形上覆盖到某个像素点的片段成为片元(fragment)。
顶点数据经过三角形插值形成片段的数据,传入pixel shader中进行处理。OpenGL中PS叫做fragment shader,可能是更准确的称呼。不过为了统一,这本书我们都使用PS的叫法。
我们一般使用透视正确插值,这样能够计算到近大远小的效果,我们也可以选择屏幕空间插值,失去透视效果。DX11给予了我们更强大的控制插值的能力。
PS阶段可以修改透明度,甚至Z深度,能够影响到后续合并阶段。SM 4.0中,诸如雾效,alpha测试等操作已经从合并阶段移动到PS阶段进行了,PS还具有一个独有的能力就是discard像素。
PS能执行的指令越来越多,引来了multiple render targets(MRT)的诞生。PS能够输出结果到多组缓存中,每个叫做一个渲染目标(render target)。一般来说RT需要具有相同的尺寸,某些API允许不同,某些架构甚至要求深度缓冲位数和数据格式都要一致。一般来说根据不同的GPU,RT可以开到4或者8个。
MRT也使得延迟管线诞生,第一个pass储存着色数据,之后再进行光照等计算。使用PS时的一个限制是我们无法访问相邻的像素,不过这个问题并不大,因为我们使用多pass的管线时,后续pass的管线是可以访问到之前任意像素点的值的。现代GPU能够一定程度上解决这个问题,以2x2一组来处理片元,因此有办法取得和邻接的像素的差值。但是这不能在动态分支的语句中,因为在动态分支中四个片元都必须执行相同的指令。
DX11的UAV(unordered access view)允许写入缓存的任何位置,DX11.1支持了所有shader的使用(原先只有PS和CS),OpenGL 4.3称之为SSBO(shader storage buffer object),所有PS可以并行运行访问同一个共享缓存。共享内存涉及到了资源竞争的问题。GPU的解决方式是每个着色器只能访问一个原子级的单元(类似于NUMA中的local store?)。不过这也导致在发生竞争时,着色器有可能需要stall直到他们能访问到具体的内存位置。
DX11.3加入了ROVs(Rasterizer order views),它们同UAV一样能够被随意访问,但是他们还能保证正确的顺序,通常在标准管线中这是在merge阶段排序来保证的。ROV还可以定制PS的混合方法,可以直接不需要合并阶段。一定的代价是在乱序访问发生时,一个PS的调用会挂起直到更早的三角形绘制完成,整体时间上会有损耗。
3.9 The Merging Stage 合并阶段
合并阶段将片元输出合并到帧缓冲中。DX称之为output merger,OpenGL则是per-sample operations。传统管线中,模板缓冲和深度缓冲的操作在此进行。如果片元可见,那么则进行混合。
实际上很多GPU会在PS前就进行一些合并测试,这被称作early-z,这样可以节省执行PS的次数优化性能。但这就要求PS中不能执行修改z-depth,discard这样的操作,否则early-z会被关闭。
合并阶段并非可编程的,但是高度可配置,常见的就是配置混合模式。DX10还允许PS提供两个颜色和帧缓冲内的颜色混合。这被称为dual source-color blending,不能和MRT共用。DX10.1中,MRT还可以对不同的RT设置不同的混合操作。
3.10 The Compute Shader 计算着色器
现代GPU不仅仅可以用来绘制,还可以进行一些通用计算,比如训练神经网络,这样的使用方式称为GPU computing。比如CUDA,OpenCL这样的平台。
DX11引入的compute shader就是一个GPU computing的类型。它具有单独的管线进行通用计算,可以输入输出数据缓存,它有thread group的概念,可以由1-1024个thread组成,每个调用有一个thread索引,thread group有x,y,z三个轴,用来简化shader代码。每个thread group共享一个内存,DX 11中,是32kb。
CS的重大好处是可以直接访问GPU上的数据,GPU向CPU发送数据是有延迟的,因此把结果保存在GPU上能提升性能。
后处理,粒子系统,网格处理都是CS的常见用法。 |
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
×
|