[引擎开发] 深入GPU和渲染优化(基础篇)_quad overdraw-CSDN博客
在上述的基础篇中,我们对各种概念做了一个简单的介绍,在此篇文章中,我们将做更进一步的讲解。
CPU的设计更加偏向于复杂的逻辑计算,它可以通过分支预测、指令重排来提高执行效率,但它切换线程的上下文操作会比较重;而GPU则与之相反,它适用于大量相同指令的执行,而不擅长处理分支和逻辑,由于其切换线程的成本极低,GPU通常通过线程切换来隐藏延迟。
我们认为GPU的核心模块就是流式多处理器(Streaming Multiple Processor),本文将其简称为SM。一个SM上包含了多个Core,Core中有多个线程可以同时执行相同的指令(通常就是32/64个线程),这种多线程执行单一指令我们就称作SIMT(Single Instruction Multi Thread,单指令多线程)。我们把Core上执行的线程束称为warp,warp是一个软件层的概念,它也是shader执行的一个最小单位。
当我们发起一个GPU操作,比如Dispatch或Drawcall时,会根据顶点数/像素数/线程块数量去分配特定数量的warp去执行。
GPU遵循取指、译码、发射、操作数传送、执行、回写的流程。整个过程是顺序执行的,也就是会按照编写的顺序去排布一个个指令,不存在CPU中乱序发射的现象。
整个Shader的执行过程相当于一个不断取指-执行的过程,对于每个warp而言,当不再有任何指令的时候,我们认为warp执行完成,当GPU任务的所有warp完成后,我们认为该GPU任务完成。
GPU Cycle是GPU执行的最小时间单元,每个指令都会消耗不同倍数的GPU Cycle。当我们衡量Shader的执行效率时,我们可以简单的认为整体的GPU Cycle越短,Shader的效率越高。
在整个GPU指令流水线的过程中,GPU还会做不少事情:
在不同GPU硬件中,指令可能是变长或者定长的,比较常见的指令位宽是64bit。指令的每个位会去记录不同的信息,这个位宽越大,能记录的信息也就越多;不同类型指令的编码形式也会有所差异。
比如说会去记录输入数(SOP)和输出数(DOP)的地址,一些状态量、控制量等等。
每个Shader产生的所有指令地址循序记录在程序计数器(Program Counter)中,每完成一次一次取指,PC将往后挪一个单元,指向下一条指令。
取指的过程也就是从指令缓存(Instruction Cache)读取指令数,并且存储到指令寄存器的过程。假如说指令缓存未命中,则会发起一个从内存加载指令到指令缓存的异步请求。
一般来说,指令的缓存命中率都是非常高的。
在指令流水线中,如果指令之间存在依赖关系,下一条指令需要在上一条指令完成后才能执行。但GPU实际上并不关心哪些指令之前存在依赖关系,它关心的仅仅是取指之后的指令是否是可执行的。
当我们取指并存储到I-Buffer(指令缓冲区)后,我们同时需要去记录这条指令是不是有效的,也就是说,它接下来能不能够被执行,这个信息会和指令一并记录到I-Buffer中。如果指令不能被执行,说明该指令依赖的一些数据还没有准备好,也就是相关的结果还没有写回到目标寄存器。
为了获取指令的可执行性,GPU需要知道的是输入数的寄存器是否已经被其它指令写入。这可以通过标志位来完成,我们称之为记分牌(Scoreboard)。如果标志位标识已经写入,那么下一条指令的输入是可用的,如果标志位未写入,下一条指令则会处于等待状态,它会定期检查标志位来更新自己的状态。
对于一些GPU来说,如果它认为一些指令产生的stall是定长的,它可能会自动添加一些stall count,来避免依赖的计算。而对于具有不定长stall的指令,则需要使用scoreboard来控制依赖。
GPU不具备很好的处理分支·、跳转和循环的能力。当shader代码中出现了分支后,GPU采取的做法是分支的if和else两部分逻辑都会去执行,并且每部分逻辑执行的时候,只会对满足条件的线程去执行。
那么,如何去确认哪个线程是满足条件的呢?一种比较常见的GPU流程控制的方式是:GPU使用一个Active Mask去记录满足当前线程是否满足执行条件,每个线程会占用1个bit。
整体的分支控制是基于SIMT Stack模块来实现的。从名字我们可以看出来这是一个基于栈的设计,也就是说它伴随着一个入栈和出栈的过程,在进入分支的代码块后入栈,在离开分支的代码块后出栈,出栈的位置我们称为汇合点。
对于嵌套分支而言,就会伴随着更多入栈和出栈的流程:
SIMT Stack存储了:Active Mask、目标PC(Program Counter)、最近汇合点PC,如下图所示:
在上述的例子中,执行完所有Return PC为C的指令,才能开始执行NextPC为C的逻辑。如果SIMT栈顶的Next PC和I-Buffer中的Next PC一致,说明不存在分支。
我们可以把循环也看做分支的一种,在执行循环代码块前入栈,在执行完循环代码块后出栈,判断条件是循环的退出条件:
如果循环是动态的,也就是每个线程的循环次数不一致,那么循环次数少的线程就有可能会去等待循环次数多的性能,直到所有线程达到最终汇合点(active mask全部为0)。
GPU设计是为了能够更快的执行并行计算,减少一些控制流的逻辑,所以大部分资源会在编译期就去计算使用的大小并预留分配,避免运行时动态的去计算。比较常见的就是常量寄存器、全局寄存器和共享内存,每个warp会根据使用情况分配对应资源。
由于这些资源分配是预先确认的,所以运行时不存在申请和销毁的逻辑,分配好的资源会固定预留给对应的线程。这样的弊端在于静态分析没有办法很好的处理分支的情况,它会假设所有分支都会执行到,这可能会产生一些冗余资源。
我们先来介绍GPU shader编程中重要的特性,延迟隐藏。
延迟隐藏从名字上来看,就是存在一些延迟的操作,但是被隐藏了,为什么能够隐藏呢?是因为在等待的期间,去做了一些其它的事情。这件事情非常好理解,就像做家务的时候,当你按下洗衣机的开关后,可以先去执行扫地的操作,而不是站着等待。
延迟本身的含义是,指令发射后,经过多少个GPU Cycle它的结果才是可用的。一个指令从发射到完成的耗时有多长,那么它的延迟就有多长。
在对延迟隐藏做深入的介绍之前,我们先来理解一个概念,什么是Stall?
在多个GPU Cycle中,GPU的硬件单元什么也没有做,我们就可以认为GPU停滞了,也就是发生了GPU Stall。
那么,什么时候会发生Stall呢?从计算硬件单元的角度来说,我们认为有以下两种类型:
● ALU Stall
● Load/Store Stall
对于ALU计算而言,会由于计算结果的回读产生Stall(个位数cycle);而对于内存的读写,会由于不同类型内存读写产生Stall。
因此,一个指令贡献的GPU Cycle我们可以认为包含以下两部分,这两者的耗时加起来就是这个指令产生的延迟:
● 执行指令本身消耗的GPU Cycle
● 执行指令产生的Stall持续的GPU Cycle
在对stall有了初步的认知后,我们回到延迟隐藏的话题,回到一开始“做家务”的例子,我们可以通过“扫地”去隐藏“洗衣机工作”的延迟,是因为这两件事情是可以并行的;同理,在LD/ST执行读写操作的时候,ALU也可以同时执行计算操作,因此,我们同样能够使用ALU计算隐藏LD/ST的延迟。
简单来说,就是在GPU中,我们执行完读写指令后,不一定需要去等待读写完成,而是可以执行下一个指令;同理,执行完ALU指令后,不一定需要等待计算结果写回,而是可以执行下一个指令。这就是对GPU延迟隐藏最朴素的理解。
实际上,GPU延迟隐藏的实现更为精妙,正如我们在开篇所提及,GPU通常通过线程切换来隐藏延迟。这句话究竟应该怎么理解呢?
我们知道,SM上有多个Core,在GPU任务发起后,会将warp分配到不同的SM中的Core。每个SM都会分配到特定数量的warp,这个数量取决于SM本身core的数量和Shader本身的情况。
分配到一个SM中的所有warp,它们的执行上下文(Context)常驻在SM中的寄存器中。因此,从一个warp切换到另一个warp不需要保存和还原上下文,它的切换可以认为是几乎无成本的。
此时,如果发生了GPU Stall,为了隐藏延迟,GPU可以做如下两件事:
● 执行当前warp中的下一条指令
● 切换到其它warp,执行它的下一条指令
其中前者就是我们在前面提到的对延迟隐藏的朴素理解,后者也就是我们所说的通过线程切换隐藏延迟。以上两种情况其实可以合并概述成一种情况,那就是选择任意warp,执行它的下一条指令。
如果在同一个warp中,前后两条指令没有依赖关系,那么就可以连续发射这两条指令,对应于上述的第一种情况,这就是GPU中的第一种并行情况,我们称之为指令级别的并行;如果GPU可以切换到另外一个warp来执行,对应于上述的第二种情况,我们称之为线程级别的并行。
在每个SM中,warp的调度执行是有Warp Scheduler负责的。每个warp中的I-Buffer会去记录当前指令和指令状态(下图),根据这个状态,每个Cycle中Warp Scheduler会去选择当前准备好的(Ready)的一个warp去执行它的下一条指令,比如说选择第n个warp的第m条指令。那么这里没有准备好的warp有很多原因,比如依赖的输入没有就绪,指令缓存失效了,执行依赖的硬件单元忙碌中,等等。
根据GPU硬件的不同,每个Cycle可以调度一个或多个warp同时执行。
比如我们执行这样一个shader:
float2 uv = a + b; //(ins0)
Tex t = Texture(Image, Sampler, uv); //(ins1)
// ...
我们假设a+b是一个指令(ins0),texture load是另一个指令(ins1) ,那么我们可以循环执行其它warp的算术指令(ins0)来隐藏这个纹理采样带来的一部分延迟。
通过这样的操作,我们确保了每个硬件单元都是尽可能忙碌的,也就是说它有一个比较高的占用率(High Occupation) ,这也是我们衡量GPU效率的一个重要指标。
那么在GPU中,如果多个warp都处于Ready的状态,Warp Schedular会先执行哪一个warp呢,是当前warp的下一条指令,还是切换到其它warp执行相同指令?
实际上这里涉及到了比较复杂的调度算法。如下图所示,不同的调度算法下,总体的GPU Cycle也有细微的差异,比如Round Robin就是一种循环调用不同warp的相同ALU指令来隐藏LD/ST延迟的算法。
那么,什么东西会影响到延迟隐藏的效率呢?
① 每个SM中分配的warp总数量
如果warp的数量不够多,那么Warp Scheduler在调度warp执行高延迟的指令时,下一周期就有很大概率找不到可以切换的warp。
进一步来说,SM的warp总数量一个取决于GPU任务的总线程数,另一个取决于shader使用的资源(寄存器/共享内存)。
② 指令的独立性和依赖性
GPU会计算指令之间的依赖关系,如果后续指令依赖于一些高延迟的指令的结果,这意味着warp sheduler在高延迟指令还没有结束的时候,无法切换到该warp的后续指令(或者说该warp处于Not-Ready的状态)。
shader代码中的分支包括静态分支和动态分支。
静态分支也就是uniform分支,它的值往往在shader执行之前就能获取,静态分支的消耗比较低。而动态分支是运行时才能确定分支条件的分支,我们在下面重点描述动态分支的情况。
对于GPU而言,我们已经知道warp能在同一时间对多线程执行相同的指令,这里限定了指令的相同,而对于分支来说,warp里的不同线程可能会执行到不同分支的逻辑,这种现象被称为线程束分化(warp divergence)。
假如分支的嵌套比较多(栈深度),那么也就会占用到较多的内存资源。
分支嵌套越深,SIMT Stack的层数也就越多,使用的资源也会越多,那么可分配到SM中的warp数量就会越少。在后面我们会提到,分支的嵌套层数和全局寄存器(GPR)的情况会共同影响到线程组的并发情况,受限于这两者表现较差的那个。
因为循环的每次迭代伴随着push和pop的过程,loop和if一样只会贡献一次栈深度,循环的次数并不会影响栈的深度。
像上面这种常量的循环,实际上可以直接做展开(添加UNROLL关键字或编译器自动)优化,它等价于这样的代码,牺牲了代码的长度来换取更好的性能:
动态分支需要不同像素执行不同的逻辑,这些可能和具体业务相关较难优化掉;我们唯一能够考虑的就是把这个动态分支设计为BRANCH或者是FLATTEN,或者让硬件自动帮我们选择。
而静态分支中,所有像素执行的逻辑是一致的,这时候我们就可以考虑使用变体或者静态分支。
比如说有一些不同的逻辑,我们希望对材质A开启,对材质B关闭,我们可以考虑用uniform静态分支。这样的好处是我们只会生成一个shader,也不会有频繁的pso切换。
uniform int shadingmodelid;
if(shadingmodelid == 0)
{
// ...
}
else if(shadingmodelid == 1)
{
// ...
}
else if(shadingmodelid == 2)
{
// ...
}
比如上述一种比较极端的做法,通过静态分支来切换shadingmodel。这样的话GPU没有什么分歧,执行上的性能损失不高。但这样做可能会加重寄存器的负担。
假如说我们增加变体带来的损失会更小,那么我们就会去考虑变体。比如说一些特殊的效果,比如一些动态效果,它只会在特定的情况去执行到,也就是说这里只有时间上的变化,没有空间上的变化,那么我们也不会存在变体切换产生的消耗。
这个时候把它做成变体的话,也就是做一个有效果的shader,和一个没有效果的shader,主要的压力是包体这里的,但对于实时性能来说肯定是会更好的。
但变体存在的一个问题是,当我们的特殊效果越多,那么可能存在的变体组合是一个乘法关系,处理不当会出现可怕的变体膨胀,这里假如我们能够把一些效果放到后处理去实现,能够有效的缓解这种变体膨胀。
我们在前面提到了纹理的一些知识,我们之所以会这么关注纹理,是因为shader中执行纹理采样可能会需要上百个GPU Cycle;在执行内存访问这一步操作的时候,硬件单元就会切换去执行其它线程的任务,这就是我们前面说的多线程延迟隐藏。
我们在编写shader代码时要尽可能地避免一些采样的依赖,这样会不利用硬件单元的warp切换,因为后续的逻辑必须依赖于采样的结果,比如苹果分享的如下示例,前一种写法会有2个依赖,而后者只有1个依赖。
// real dependency : 2 watis
half a = tex0.sample(s0,c0)
half res = 0.0h;
// wait on a
if(a >= 0.0fh) {
half b = tex1.sample(s1,c1);
res = a * b;
}
// no dependency : 1 wait
half a = tex0.sample(s0,c0);
half b = tex1.sample(s1,c1);
half res = 0.0h;
// wait on a and b
if(foo) {
res = a * b;
}
在shader中,除了贴图采样,我们还会去访问buffer,uniform等等。
对于Shader中的常量,GPU会有常量缓冲区(constant buffer)或者是常量寄存器(constant register)来存储这些数据。
对于设计了常量寄存器的GPU,像uniform这样的数据,GPU会将其提升到常量寄存器中,一般预留的大小是足够我们传递一些常规shader参数的。因为同一个shader中,不同的线程访问的是同一份数据,硬件可以在绘制前一次性把uniform的数据加载到常量寄存器,就不需要每个线程单独去加载相同数据了,这个时候uniform的访问消耗我们可以认为非常低。
此外,如果我们使用了一些基于uniform计算得到的常量,比如uniform a + uniform b,那么驱动也有可能会帮我们把结果提升到常量寄存器中。
只有在代码中引用到了的uniform数据会被提升到常量寄存器中,这意味着我们在使用uniform时是允许一些数据的冗余的,这些冗余只会影响uniform上传的带宽,但对实际效率没有太大影响。
但如果我们使用了超过常量寄存器上限的数据,即寄存器溢出(register spill),就会增加每个线程load/store uniform数据的消耗,这个时候就会对性能产生影响了;此外使用动态下标去访问uniform数组也会导致uniform数据不能提升到寄存器中。
同时,在常量寄存器足够的情况下,把参数放在uniform而不是普通的buffer中,会有更好的性能情况。
虽然不少现代GPU已经从vector运算单元转向了scalar运算单元,但在内存访问上,依旧保留了向量内存单元,这意味着我们在读写buffer数据时,最好将零散的scalar pack成一个vector4。
同理,对于纹理,它以zigzag的形式存储。这样也可以方便一次采样2x2个像素,并且它们在空间上是连续的。
这也就意味着,point采样和bilinear采样在访问像素上是没有差别的。更直观地,shader语言会为我们提供gather函数,这样可以一次性采样到相邻的4个像素结果,相比起bilinear返回的单一插值结果,我们可以实现更为细节的控制。
合并读取
合并读取(Memory Access Coalescing),也就是说一个warp中,跨多个相邻线程的连续内存访问硬件是可以合并的。
比如线程0访问位置n,线程1访问位置n+1...线程31访问位置n+31,那么这些访问是可以合并成一个单一的全局内存访问。
另一方面,如果warp中所有线程都访问了同一个数据,在单个线程访问了这个数据后,其结果可以通过广播的方式传播给同一warp中的其它线程。
当我们读取Texture或者是Buffer时,如果直接从系统内存读取,那么可能会耗费几百个Cycle。但如果命中了缓存,那么这个消耗会进一步降低。
常见GPU的设计是,在每个SM中,都有纹理独立的L1 Cache, 指令的L1 Cache,以及共享内存的L1 Cache。不同SM还有公共的L2 Cache。
Cache通常不会太大,所以这个缓存的内容总是不在不断更新的,因此我们通常不能指望跨时间的数据缓存,这一缓存更常见的应用场景发生在单个线程或者多个线程的连续访问内存时,通过一次访问操作将数据都读取到缓存。在一些理想情况下,如果连续线程通过一一对应的关系访问了连续的内存,缓存几乎都能命中。
我们在前面两节分别提到了“向量内存访问”和“合并读取”,它们分别描述了这样的情况:
● 在同一个线程中,以vector4为基本单元访问内存
● 在相邻线程共享一次vector4访问的结果
这个思想同样可以扩展到“缓存”的访问上,我们知道,Cache加载的最小粒度是一个Cacheline,Cacheline的大小通常为128kb。那么如果我们在一个线程中访问连续的内存,或者说同一个warp访问连续的内存,那么就更有可能命中cacheline。我们有一些使用的例子:
● 合并vertex attribute的buffer
使用一个buffer连续存储一个顶点的所有属性,在执行该顶点的vs时,这些属性就更有可能都在cacheline中(顶点属性缓存);如果我们把每个属性单独存储在不同的buffer中,比如position buffer、normal buffer、uv buffer独立存储,实际上不同线程(顶点)之间也能命中cacheline,但考虑到剔除的顶点,由于它们和可见顶点共享cacheline,这些属性带宽会被浪费。
● 制作纹理mipmap
在纹理大小和屏占比比较匹配的时候,我们在相邻像素采样的纹理也是相邻的。过大的纹理会导致稀疏的采样,降低缓存命中率。
当我们讨论到缓存时,总是会不可避免的讨论到缓存一致性的话题。对于GPU而言,缓存一致性的维护是非常昂贵的,因此GPU往往只会提供显式的缓存刷新/失效指令,比如通过API层添加Barrier来维护确保内存访问的安全性。
GPU寄存器的位宽有16位/32位的差异。
针对16位的寄存器,这在一些移动平台的GPU比较常见,如果我们使用32位的数据,那么就有可能要使用双倍的寄存器,所以如果我们能够上传16位的buffer,或者说用half去存储贴图采样的结果,那么我们的访问会使用更少的寄存器,从而得到更少的带宽,更少的功耗。
比如说GPU中的贴图以RGB8的比较常见,如果后续的计算精度half也是足够的话,我们更推荐这样的写法:
// good
half Tex = Texture2D(Texture, Sampler).r;
// bad
float Tex = Texture2D(Texture, Sampler).r;
我们在前面提到了常量寄存器的概念,这是一个比较特殊的寄存器,除了uniform还有一些其它的字面量也有可能存储在这里。除此之外,更为常用的是global register/全局寄存器,或者说是general perpose register(GPR)/通用寄存器, 它存储了shader计算中的一些中间变量。我们通常所说的寄存器就是这种类型的寄存器。
通常来说,我们可以粗略地认为,代码越长,用到的寄存器可能就越多。
每个流式多处理器(SM)有自己的寄存器文件,寄存器被分为多个bank,供每个线程独立访问使用。
由于SM的寄存器大小是固定且有限的,这意味着单个warp使用的寄存器越多,可分配到SM中的warp数量就会越少。warp的总数量减少意味着没有足够多的warp可以切换来隐藏延迟,这对于GPU来说是很致命的。
在优化寄存器的过程中,由于warp数量随着寄存器使用的增加是阶梯状减少的,我们至少应该确保它不超过某个阈值而落入下一个区间。
一方面,我们可以通过离线分析的方式直接获取当前Shader中寄存器的使用情况,进而分析得到warp数量的情况,来协助我们优化Shader Code。
另一方面,我们可以去分析代码中的每个语句会产生多少个临时变量来优化寄存器的使用,因为Shader代码中引用的临时变量越多,需要的寄存器就越多。
我们在前面提到的基于uniform的静态分支,也包括普通的动态分支,就有可能导致寄存器使用的增长。因为在离线分析的情况下,会基于分支都会跑到的情况去分析寄存器的使用。
不同平台的寄存器情况存在差异,我们可以粗略认为单个Shader执行过程中用到的寄存器(每个线程用到的寄存器)最大值分布在十几到几百个。
在最坏的情况下,当shader使用了超过上限的寄存器,会发生寄存器溢出(register spilling)。不管是常量寄存器还是全局寄存器都有可能发生溢出。
发生溢出后的数据存储在SM中的Local Memory中。Local Memory描述的是寄存器/其它线程数据溢出后存放的内存,每个线程有独立的数据。这是一个抽象的概念,实际上可能存储在global memory/l1 Cache/l2 Cache。
寄存器溢出后,会产生Load的调用,严重影响性能。
如果我们以计算着色器的角度去思考GPU的一个执行逻辑会比较直白,我们发起的一个线程组会被分配到多个warp上去执行,所以通常线程组的数量会设置为warp的倍数,避免线程的浪费。如果我们总是发起一些比较琐碎的任务(线程数不足32,线程总数量少),那么GPU的利用率就会非常低。
假如我们发起的是绘制任务,那么我们执行的就是顶点着色器和像素着色器,我们可以认为一个线程会去执行一个顶点的逻辑,或者一个像素的逻辑,同样的,这些任务会被分配到多个warp上去执行。
对于像素而言,我们会以quad为基础单位把像素打包到warp中执行,这时候三角形的边是无法填满四个像素的,这时候线程的利用率就会下降。这里就会衍生出几个问题:
● 单个drawcall对应的物件的屏占比太小,所占的像素远远小于warp的线程数,这种drawcall通常推荐在CPU中就通过screensize剔除;
● 物件包含大量的小三角形,众所周知,三角形数量越多,边越多,这不仅会导致顶点着色器的压力过大,还会导致没有填满的quad会越多,出现这种情况通常是因为LOD的设置不合理,使得每个像素对应的顶点过多。
某种意义上,当我们需要优化shader时,实际上要优化的就是整体指令的Cycle数,或者说提升指令的吞吐量(每个Cycle可以执行的指令数)。
因此我们在衡量Shader性能的时候,应该对每个ALU指令的耗时有一个初步的认知。
首先,我们需要了解到,我们书写的Shader代码是描述性语言,比如hlsl和glsl,而常见的游戏引擎unity和ue都选择了使用hlsl作为描述性语言。而不同图形API会使用不同的类汇编语言,要么来自于描述性语言的转换,要么来自于中间字节码(IR)的转义。
项目运行时,会去读取binary IR进行编译,生成实际的机器码;我们也可以离线做预编译。
DX | DXBC |
Vulkan | SPIRV |
Metal | Metal SL |
我们以dxbc为例,它的反汇编语法形式比较简单,可以概述为:
op dest src0 src1
其中op对应指令,dest对应目标输出位置,src0和src1对应两个输入,比如:
add r0.xy v0.xy v1.xy
add | 加法 |
abs | 绝对值 |
and | 按位和 |
div | 除法 |
dp | 点乘 |
eq | 相等 |
exp | 指数 |
mad | 乘加 |
mov | 赋值指令 |
mul | 乘法 |
当我们衡量指令的消耗的时候,我们会有不同的衡量指标。
比如常见的是指令的执行吞吐,也就是一个Cycle能够执行的指令数量,作为示例,以下位NV某一代的指令吞吐:
add/mad/multiply(16bit) | add/mad/multiply(32bit) | exp,log,sin,sqrt(32bit)... |
128 opt/cycle | 64 opt/cycle | 16 opt/cycle |
由此可见:
● 单个指令的执行耗时基本远小于1个cycle
● 一些复合指令(比如乘加)的吞吐和简单指令的吞吐一致,这意味着使用复合指令会更划算
● 数据位宽越大,指令吞吐就越小
● 初等函数的指令的位宽是固定的(不可用半精度优化)
在实际使用中,我们还会去考虑指令整体的GPU Cycle。一般关心的是指令发射后,到它返回的结果可用的时候,经过了多少个Cycle。在这个标准下,一些非常简单的指令只需要1个Cycle就能完成。
指令的实际耗时远远比指令吞吐要高,是因为全局寄存器(GPR)的读写也会带来消耗。在ALU指令中,我们需要处理指令的输入和输出数量,它的输入可以来自于全局寄存器、常量寄存器或是立即数,但不能直接来自内存(需要先加载到寄存器),它的输出只能到全局寄存器中。
寄存器的读写我们认为在1个cycle左右,所以一些简单的指令我们就认为是1个cycle左右的数量级就能完成。而另外一些比较复杂的指令,比如初等函数、LD/ST,这些指令可能会存在竞争,需要处理同步,因此耗时就是不确定的了。
如果发生了位长转换,有些GPU上这些操作是免费的,而在有些GPU上,可能会产生move指令。
不同的GPU会使用不同的ALU计算单元,比如SIMD(Vector ALU)和Scalar ALU。
对于 Scalar ALU而言,如果我们执行向量操作,它实际上会产生四条指令,比如:
float4 result = a + b;
对应了四次add:
result.x = a.x + b.y; result.y = a.y + b.y; result.z = a.z + b.z; result.w = a.w + b.w;
硬件通常会帮助我们做一些计算上的优化,这里举了四个例子,是在Adreno机器上做的一个测试,会发现我们试图去做的一些”优化“,可能编译器已经帮我们做好了。
① 常量预处理
像我们之前说的uniform值,代码中直接出现的常量,代码中的常量表达式,基于uniform计算得到的常量(uniform sub-expressions),这些都有可能被驱动优化,放到常量寄存器中。对于常量表达式、uniform sub-expression而言,驱动有可能会将计算好的结果放到常量寄存器。
② SIMD
在支持标量计算的机型上强制使用向量化计算对指令没有任何影响。但如同前面提及的一样,大部分驱动依然保留向量化的内存访问。
③ 静态分支移除
基于uniform的静态分支,它的好处是当uniform的结果为0时,编译器可能会自动优化掉if内的语句,就像是if内的语句不存在一样。但需要注意的是,该代码的寄存器影响依然是存在的。
④ 未引用代码移除
虽然通常来说我们并不会手动去写一些无用代码,但在一些商业引擎中,有不少shader代码是自动生成的,这中间不可避免地会引入一些公共代码指令,这些公共代码可能并没有真正的被引用到,但无需担心,这通常能优化。
实际上驱动会做哪些优化,最稳妥的验证方式是实机测试去观测gpu的实时指标,或者使用离线编译器去生成shader的各项离线性能指标。
比如上图中,我们就能看到编译器给出的shader离线分析性能指令。从中我们可以观测到一些宏观的数据,比如:
half和float计算指令数量;
load/store指令数量;
会带来延迟的指令数量;
分支的指令数量;
除此之外,在其它的离线分析器中,我们还能看到常量寄存器、全局寄存器的使用情况,每个核心的warp数量这样的宏观数据,甚至每条shader语句的情况。
SSA也就是Static Single-assignment,也是shader编译后的一些中间代码的一种描述方式。它会把每个变量翻译成代号,比如
float a; a= 0; a = 1;
就会被解析为
SSA_0 = 0; SSA_1 = 1;
通过对变量可达性的分析,我们就可以解析出那些实际上没有引用的代码,并在最终执行中移除这些逻辑。