|
本文希望通过自顶向下的分析,并沿着数据流,从整体逐渐深入到局部,尽可能清晰的描述GPU硬件的工作原理。为方便表述和理解,有时会隐藏一些复杂性、牺牲一些准确性,希望大家能够谅解。对于文章中表述有误的地方,也欢迎大家指出,共同学习。文中所描述的架构以Valhall做为参考(麒麟9000同款架构)。
阅读本文时,最好对GPU上的图形编程有基本认识,起码知道graphic pipeline、vertex shader(以下简称VS)、fragment shader(以下简称FS)等概念。读完本文后再阅读以下文章效果更佳~
本文为呕心沥血之作(x,如果觉得对你有所帮助,欢迎点赞喜爱加收藏以及留言交流。关注专栏解锁更多内容~
图形流水线
一个图形应用往往是以流水线的方式运行的,大概如下图所示:
图0 图形应用流水线
RP代表render pass,可能是一次渲染目标的切换或者强制刷新,里面包含一系列绘制任务。CPU负责向GPU提交render pass,包括设置管线状态,准备渲染数据,提交绘制命令(也就是常说的Draw Call)。GPU负责对渲染数据进行计算。从图0中可以看出,当CPU在处理RP3的时候,RP2的VS和RP1的FS正在执行。这说明在同一个render pass上,三部分的工作有数据依赖,但在对于不同的render pass,可以以流水线的方式并行执行。为了更加清楚的说明流水线中发生的事情,我耗尽毕生所学画了一个更加完整处理流程图(x
图1 图形应用在GPU上的工作流程
这个图也许不够详细,但是足够完整。看不懂没关系,可以说这篇文章的最终目的,就是能让你理解其中的每一个部分。图中描述的是CPU 处理RP3,GPU同时处理RP2的VS,以及RP1的FS的场景。这里箭头表示了数据流向,顺序是蓝色、棕色、绿色。当任何时候,当你不理解文中讲述的内容的时候,或者以后忘记的GPU的工作流程,都可以回过头来看看这张图,相信对你的理解会有所帮助,毕竟对着答案看试题就没有那么难了嘛(
Draw Call提交后,会先由Job Manager进一步拆分,把VS和FS分别提交到Non-fragment和Fragment这两个队列中,然后Shader Core的Front End就会创建对应的warp来执行任务。对应图中这一部分:
图2 CPU向GPU提交任务并由Job Manager拆分到不同队列
这里解释一下warp:一组锁步执行的线程,在Valhalla架构中,一个warp包含16个线程。在processing unit的章节会详细讲解。
现在,CPU已经向GPU提交任务了,我们来观察在一个render pass中的数据流向(可以对照图1):
图3 图形渲染流水线
类似这样的图相信大家可能已经看过很多了。这个图展示了数据进入GPU处理后的流向,也就是所谓的图形流水线。三角形经过vertex shader处理,被变换到clip space中,然后再做透视投影被投影到view space中。经过视锥裁剪和背面剔除之后,剩下的三角形被Tiler处理,得到每个tile上的polygon list。然后就是光栅化、early z、FPK、fragment shading、z test、blend等一系列流程,执行完就可以得到一个有用的pixel,并被写入framebuffer。这里说的流程比图中所示的稍微多了一些,多出来的部分主要是为了解决一些性能功耗问题,没有它们不会影响绘制结果。有些名词不懂没关系,接下来我们来详细了解下每一个步骤。
Job Manager
Job Manager是一种用于任务分配的结构。一个render pass中会包含多种job。一般而言,render pass被分解为vertex job,compute job,tile job和fragment job等等,vertex job和compute job进入no-fragment队列执行,fragment进入fragment队列执行。
Tiler
Tiler是TBR(Tile-Based Rendering)架构中的固定功能,用于把framebuffer(以下简称FB)切分成tile,为每一个tile生成对应的polygon list。tile是把一个完整的FB切分后形成的16x16大小的小型FB,tiler之后的操作都是在这一系列的16x16的buffer上进行的,直到这一帧所有tile绘制都完成,原本的FB才会拼装完毕,然后送给显示系统。
PC上常用的架构是IMR(Immediate Mode Rendering)。大概长这样:
图4 IMR架构
好处是,几何数据被填充到FIFO队列里被随时取用,即使几何数据再多,也不会累积在片上内存。缺点也是显而易见的:因为这些几何数据可能被光栅化到FB的任何位置,所以在执行FS时读取FB容易发生cache失效造成很高的DDR带宽,比如blend、depth test。对于移动设备,访问DDR的代价是非常昂贵的。
TBR架构使用小型FB优化了这一个过程:
图5 TBR架构
因为tile很小,可以在GPU上分点地方(Tile Memory)放它。对于FB的频繁访问,不再产生DDR带宽,而是变成了低时延低功耗的tile访问。同时,在写出之前,对上次tile中的渲染结果进行CRC校验,使相同内容不再写,又可以节约更多的写带宽。但是这也会带来一个问题:tile本质上是利用了局部性访问原则,所以需要Tiler把在局部使用的三角形组织在一起。这就是Tiler处理三角形并生成polygon list的过程(虽然Tiler这一节内容写在了Shader Core前面,但是其实处理的数据是VS的输出)。Polygon list需要写回到DDR中,FS执行的时候,再逐tile取回来。当三角形非常多的时候,tiler可能成为系统瓶颈,同时也会带来额外的DDR带宽。不过对于移动平台,在绝大部分情况下,会带来非常大的带宽收益。
Shader Core
现在我们需要来关注Shader Core了,也是图中占比最大的部分。 Shader Core是GPU中最为核心的部分,是执行shader代码的器件。之前讲的各个GPU部件,我们大部分时候对它们的控制力有限,而shader core几乎完全掌握在我们手中。可以说,我们写shader代码就是为了给shader core发出指令。Shader Core中包含在计算之前进行前处理的Front-end、执行shader指令的Execution Core、以及对Fragment Shading结果做进一步操纵的Back-end三部分。
图6 Shader Core儿童简笔画
No Fragment Front-end
No fragment Front-end负责处理非fragment shader执行之前的任务,主要是创建warp。其实也就两种,vertex shader和compute shader。创建warp的动作里包含了寄存器分配。
Fragment Front-end
Fragment front-end的功能相对复杂了。在fragment shader执行之前,数据还只是保存在DDR里的polygon list,距离变成需要执行shader的fragment,还需要经过如下过程:
图7 Fragment Front-end中的操作
首先必须从DDR里把一个tile中对应的polygon list读取到GPU上。然后再进行光栅化,对三角形进行采样,以及对顶点属性进行插值。之后,为了缓解over draw(指像素会被之后绘制的像素遮挡),进行了early z和FPK对一些无效fragment进行剔除,剩下的才会被fragment shader处理。这里的early z相对来说比较好理解。三角形光栅化之后就会把深度写入z buffer,那么,我们可以利用已有的z buffer,每当一个三角形被光栅化,我们就能知道它是否被之前的三角形遮挡,提前剔除。但是early z并不能完全消除over draw。当绘制顺序由远到近,每一个像素都会通过early z。为了缓解这个问题,FPK特性会把通过early z的像素保存到一个list里,如果同一个位置之后的像素z值更小,那意味着之前的像素被遮挡,将其抛弃。
Execution Core
现在我们要来讲一讲GPU的起源。为什么放这里讲?因为它和Execution Core的设计有着直接的关系,理解了最初的需求,你就能明白Execution Core是如何很好的服务于渲染的。
GPU最开始被发明出来就是用于图形加速的。当时人们发现,在光栅化的渲染框架下,对顶点做变换以及对fragment着色是一种高度独立的任务。两个顶点或者像素之间谁都不需要关心对方的属性,并且它们之间的行为也几乎完全一致,比如说对一个三角形进行平移,则对它的每一个顶点加上相同的向量,区别只有输入:三个顶点的位置数据不同。如果对指令有一定了解的话,会发现这和SIMD(Single Instruction stream, Multiple Data stream)好像一模一样。事实上,GPU的Shader Core就是SIMD处理器。
图8 SIMD处理器 不过你的画风怎么和别人不一样?
图8是《计算机体系结构-量化研究方法》中给出的SIMD处理器的设计。Emmm...好像有点复杂?主要没在ARM官网上看到复杂度合适的图,为了能说清楚Execution Core,只好请出教科书。不过SIMD处理器和我们这里说的Execution Core还不等同。这里也给出一个Execution Core的简单图示作为参照:
图9 Execution Core
由于知乎的标题只能加三级,所有没办法从目录结构上清楚的看到Execution Core中包含的部分,所以请看图9来和之后的章节对应。
Warp Manager
Warp Manager就是图8中的Warp调度程序。我们可以看出,Warp Manager从指令缓存中取出指令,并在数据准备完成的时候,提交给Processing Unit(以下简称PU)执行。指令缓存就是之前说的,Job Manager分解CPU传来的Draw Call后放入的那两个队列。现在我们重点关注SIMD指令和操作数。
SIMD的特点就是,使用一条指令,完成多组数据的同一个运算。用一条伪指令来举例:ADD x, x, y 代表x = x+y。当PU中每一个线程对应的x,y寄存器(在Front-end中分配)都可以访问的时候,PU中的程序计数器取出ADD指令,并在每一个线程上执行,只不过对于不同的线程x,y的内容不同。
Processing Unit
图8中指令寄存器到地址接合单元的部分,就是PU的部分了。可以看出PU中包含1个指令寄存器、多个运算器、以对应的寄存器。不过在手机上,这些资源并不是那么富裕。Valhalla架构中,包含2个PU,共享1024个32bit寄存器。一个PU包含16个运算器,也就是16个线程。平均下来每个运算器可以使用32个32bit寄存器。对于特别复杂的shader,一个线程可以使用超过32个寄存器,此时,另外一个PU会因为寄存器不足而无法工作。这就是我在移动游戏GPU性能优化方法论中提到的寄存器超过32个后线程减半的原因。至于每个运算器,就负责执行指令对应的运算,包括简单运算、复杂运算和特殊函数,运算器在对半精度变量做计算的时候会更快:
好了,简单总结一下:PU是SIMD指令的执行单元,每个PU包含1个程序计数器(以下简称PC),16个线程,每个线程可以使用32个bit寄存器。如果你刚刚点进去 移动游戏GPU性能优化方法论 回忆了一下的话,也许会想起关于不要使用分支和减少数据精度的一些优化方法。到目前我们已经学了很多东西,现在我用一个例子来实践一下,并说明这两个优化的原理:
Fragment Front-end从DDR读取了一个Tile并各种操作,变成了16x16个fragment。我们要对这16x16个fragment执行Fragment Shader。我们的一个PU可以执行16个线程,也就是处理一个4x4的像素块。在PU中,每个线程负责为一个像素执行Shader。我们在shader中写了:
if(对第一个像素不成立) a = a + b;
else a = a + c;
理想情况是第一个像素对应的线程0不用执行if,其他像素不用执行else。可是我们的PU里只有一个PC,指令得挨个取啊,你线程0就算不执行if,PC被我们其他15个线程占着,你就歇会儿等等吧。else同理,于是乎,if和else两条路径的时间开销都会发生。而半精度的原理比较简单了,运算器算半精度速度是双倍,可以使用的寄存器也是双倍。当每一条指令都执行完了,我们的16个像素也就处理完了。这就是GPU通过并行计算加速图形渲染的本质。
Load Store unit
负责所有非纹理采样的内存访问,包括数组,结构体,buffer,image等的读写(uniform buffer和编译器可以确定下标的小型数组访问,会被尽可能的优化为寄存器访问),以及寄存器溢出时,读写保存和加载溢出的寄存器。LS unit拥有自己的L1 Cache,compute shder中使用的shared memory 就来自这一部分。
这里image的读写,是指imageLoad/Store这样的访问函数,和imageTexture这种类型的采样函数严格区分。读取图像的时候,使用texture unit的ImageTexture会更快。
Varying Unit
用于计算插值的固定功能单元。
Texture unit
针对纹理采样优化的硬件,双线性采样具有很好的性能功耗。TU 拥有自己的L1 Cache,并且有专门为Texture优化的机制:优先缓存空间上邻近的像素,而不是地址上邻近的像素。
Back-end
恭喜,你从Execution Core里出来了,现在来完成最后的部分。
Back-end用于Fragment Shading完成后的操作,包括late z test、blend、把像素写到Tile Memory,当Tile Memory触发写回条件时,把它写回到DDR里。如果Tile内容和上一帧一样就抛弃,降低写出带宽。当所有的tile都处理完毕,一个完整的framebuffer就绘制完成了,可以用于显示。
Memory( DDR)
基于Cache的存储层次结构相信大家已经很熟悉了,在此也不再赘述。在移动平台,出于性能功耗的考虑,DDR的访问应该尽量减少。这里主要讲一些会引发DDR访问的行为:CPU向GPU上传和更新数据,访问纹理,访问超大数组,buffer或者texture回读,访问SSBO等。
总结
终于要结束了,现在回过头去文章开头的图1,会不会觉得明了一些了呢?我们从应用层面的流水线级并行,一路深入到Processing Unit中的SIMD并行,并分析了一组fragment如何在PU上并行计算。想必此时大家对于图形应用的并行本质已经有了深刻的理解吧。如果现在还有点懵,那就多看几遍,或者参考一些别的资料。也欢迎赞同喜爱收藏以及评论区交流~
关注专栏,顿顿解馋~
参考
[1] Home – Arm Developer
[2] 《计算机组成与设计 硬件软件接口 ARM版 》 戴维 A.帕特森 约翰 L.亨尼斯
[3] 《计算机体系结构 量化研究方法(第5版)》 [美] John L. Hennessy [美] David A. Patterson
原创内容,转载前还请先找我申请授权~ |
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
×
|