导语如何高效准确详细的对性能进行剖析?腾讯游戏学院专家Leonn将从RHI(渲染提交)开始,归纳总结在UE下对每一性能指标的剖析方法。
基于UE的手游客户端的性能主要由这七大部分构成:CPU逻辑、CPU渲染、图形API(提交)、GPU渲染、内存、带宽、加载时间。这几个基本元素又会合力衍生出一些新的性能指标,例如功耗(往往同GPU负载和带宽紧密相关)。同时这七部分又构成一个闭合的木桶,最长的一块是主要瓶颈,并且瓶颈可以在这几块转移流动。
作为开发者,我们解决性能问题的步骤一般都是按照做性能剖析、解读结果、定位问题、增加剖析代码、优化问题、重复剖析的迭代过程来执行。而高效准确详细的对性能进行剖析得到结果是第一步,在任何引擎上,只要我们能做到在任意时刻准确的获取想要的性能剖析结果,那么才会胸有成竹不会慌,该系列文章将归纳总结在UE下对每一性能指标的剖析方法,做深入分析,我们需要工具的帮助,也需要程序员理解引擎并知道如何去编写合适的剖析代码。
最近刚好做过一轮RHI线程的剖析,第一篇就从RHI开始,我会坚持把后面几篇写下去。
渲染API瓶颈
渲染API瓶颈是3D手游的常见瓶颈,我们常说的drawcall过多了,卡渲染就是指的卡在这里,其实这个卡渲染卡的是CPU。为什么drawcall会卡,因为CPU需要通过对渲染API的调用来驱动GPU做事情,1个drawcall的背后是一堆渲染API的调用,下面是一个常见的drawcall过程。
可以看到为了一次绘制(1个drawcall),要设置shader,创建buffer等等,这些相比最后的draw那一步来说都是相对更费时的。
当测试反馈给我们卡drawcall的时候,作为程序我们需要一种手段来衡量出确切的当前做哪些drawcall,或者说绘制哪些东西更耗,最好是精确到耗在绘制哪个模型的哪个API调用上,我们才能真正的给美术予以优化指导。
UE中精确定位RHI瓶颈
在UE中,PC和android平台通常渲染API的调用会放在一个单独的线程,叫做RHI线程,这个线程专门负责渲染指令的提交,即调用显卡的API。我们分析渲染提交的卡顿就是要分析这个RHI线程。
1.多线程渲染工作模型
但是RHI线程不是单独存在的,它需要同Game,Render线程协作,RHI的卡顿可能不只是RHI的卡顿,首先需要清楚UE里面RHI线程和其他线程的工作模式:
这里面Game、Render、RHI、GPU分别在4个并行的工作线上,有这样几个特点:
1.Game thread最多可以等渲染一帧,也就是说渲染如果第N帧的渲染在第N+1帧的Game tick结束时还没有完成,那么渲染就会把Game卡住,Render和RHI不会有帧延迟。
2.Game是Render和RHI的源驱动者,Game的卡顿可能会卡住渲染。
3.Render负责产生drawcall,RHI负责提交drawcall,因此Render的卡顿也可能卡住RHI提交。
4.渲染的最后一步要swapbuffer,即等待GPU完成,所以GPU的卡顿也可能会卡住RHI。
5.除了Gamethread本身,Render RHI和GPU的工作都是存在间隙的,即Game逻辑喂给渲染任务的时机会影响渲染工作的密度,也会影响到渲染的时间,小量多次会浪费渲染效率。
2.UE中RHI的瓶颈来源
现在我们知道RHI的卡顿可能来自于以下几种情况:
a.RHI指令自身的卡顿,即通常所说的卡drawcall,过多的dc,过多的渲染状态切换,过多的渲染资源创建,等等;
b.Game或者Render thread的卡顿;
c.GPU的卡顿。
对于情况b,我们可以通过UE的status看当前的Game和Render的线程执行时间来容易的判断出来,来排除是RHI上出了问题。
对于情况c,UE的status中在RHI线程上会统计一个叫做swapbuffer的时间,如果这个时间过长,那么就是GPU瓶颈了。
真正比较麻烦的是定位情况a,即对于RHI指令本身的卡顿瓶颈。对于这种情况UE自带的stat工具通常不能给出比较有力的分析结果,自带的方法只能统计一帧在RHI上做几种给定操作的时间,但是在复杂的线程条件下,有时很难确定这些卡顿的幕后原因,有时RHI问题只是一个表象,为了得到rhi线程瓶颈的确切原因,我们至少要能够明确以下几个事情:
1.RHI线程的执行是由一堆有序的RHI command组成的,我们要能捕捉到具体的那一个RHI command的执行时间比较长,比如是创建场景中哪个房子的vb?
2.是在Render thread的哪一个步骤塞入的渲染数据导致了这个RHI command执行的时间比较长,是在渲染阴影的时候,还是渲染basepass,还是做遮挡剔除?
3.是在Game thread的哪一个步骤塞入的渲染数据导致了这个RHI command执行的时间比较长?是在加载场景?还是在绘制UI的时候?
笔者在项目中遇到过一个问题,在一些低端机,RHI会有时突然卡顿几秒以上,看stat文件如下:
我只能看到在RHI线程的Thcik begin阶段发生了巨大的卡顿,然后就没有细节了,不知道是具体哪个RHI command,然后看Game thread在wait,也不知道是Game thread的哪一步触发了这个RHI瓶颈。我们需要一些办法。
定位UE中RHI线程的瓶颈
我们需要分别将上面三种原因捕捉到,手机游戏买号平台就能解开这个问题。
首先定义一个宏,只有我们需要捕捉这些详细的RHI瓶颈时开启,因为这些操作会存在较大的overhead。
1.定位具体RHI Command的时间
对于RHI Command的具体执行时间,我在FRHICommand的最终执行阶段ExecuteAndDestruct中创建一个FScopeCycleCounter,counter的名字就直接rtti当前command的typename。
有时候我们需要更细节的知道这个Command除了类型外的信息,例如如果这是一个createvb的Command,那么vb的原始模型名字是什么,vb大小等,我在一些Command处额外传了一些debug用的string,然后在这些Command的执行前补上一个FScopeCycleCounter。这样我们就能拿到精确到具体RHI ommand的提交耗时了。经过这个补充,我能拿到这样的RHI线程执行时间统计:
这样谜底就清晰了很多,原来这时候存在大量的vb创建,数了一下,有几百个,在同一帧内几百个vb的创建,在低端android上会产生5秒钟的超级卡顿,那么问题来了,为何在这一帧会同时产生这么多的vb卡顿,是Game或者Render上发生了什么事情,如果我们查看当前的Game thread,它显示的是wait,是不知道原因的,因为Game,Render,RHI是分开工作的,我们现在RHI处于瓶颈已经不是事故的“第一现场”了,我们需要进一步让你发生在第一现场。
2.定位在Render哪个阶段发生了RHI瓶颈
UE的RHIcommandlist自带了一个函数FRHICommandListImmediate::SetCurrentStat,可以用来让Render给RHI加一个标记,这个标记就可以认为是Render的某个阶段的名字,UE自带了在Render的很多阶段下了这个标记,我们还可以自己补充,这个函数的原理如下:
这个status本身也是以command的形式插入队列,所以每一条RHI执行的cmd会被统计到它之前最近的那个status tag下面,通过不断的细分插入这些tag,我们可以跟踪到RHI的cmd从是在Render的哪个阶段被产生。需要注意的是这个tag只能在Render thread里插入。我为Render thread补充了一些细化的tag后,如前面的图,我发现这个大量的vb创建发生在渲染线程的一帧渲染结束到下一帧渲染开始之前,在这个阶段有Game逻辑往Render里面堆入了大量创建vb的指令,所以问题还要继续往Game thread上找“第一现场”。
3.定位在Game哪个阶段产生RHI瓶颈
其实我们仍然可以模仿Renderthread一样在Game thread上给RHI的command list里面插入tag,但是有个问题,Renderthread是一种相对简单的Render command的队列的顺序执行,tag量有限相对容易操作,但是Game thread里面逻辑极其复杂,我们希望可以复用Game thread上面已经埋好的一些scope counter,不过Game和RHI是两条并行的thread,需要在我们关心的scope处让二者能够强行同步住,才能容易的使用Game自己的scope counter抓住RHI的执行。我们这样去实现,假设下面是我们关心的一个Game thread的区段,在前后加上代码如下:
- #if STAT_RHI_ADVANCED
- FlushRenderingCommands(true)
- DECLARE_SCOPE_CYCLE_COUNTER(TEXT("XXX"), STAT_XXX, STATGROUP_RHI_GAME_SYNC);
- #endif
- //game 代码段
- …
- …
- //
- #if STAT_RHI_ADVANCED
- FlushRenderingCommands(true);
- #endif
FlushRenderingCommands(true)的意思是在这个位置强制将所有当前的RHIcommand执行完毕,阻塞住当前线程,所以上面这个代码段的原有的XXX统计的时间将包括这段时间内因为Game thread上发生的渲染事件的渲染而花费的时间。
通过在Game thread的主要逻辑处,插入这些同步RHI线程的代码,当RHI线程发生瓶颈的时候,我们只要查看当前Gamethread在哪里停住(wait event),就可以判断是什么Game逻辑导致了RHI线程的瓶颈。有了这个机制,我们接着截stat文件,会看到当RHI处于巨大瓶颈时,Game thread停在了这里:
凶手被抓住了,是一个资源正在被缓存池预加载!
这个奇怪的RHI上的卡顿的真正原因其实是Game线程上在加载一个模型资源!如果只依靠UE本来的stat分析,是无论如何都不可能猜到这个幕后的凶手的。
那么问题来了,一个资源的加载为何会导致海量的vb同时创建?通过进一步的分析代码,会发现因为这里用的是同步加载,而UE的同步加载的机制,是创建一个加载任务堆到同异步加载一样的加载队列里,因为不能保证依赖关系,所以要等待当前所有队列中的任务完成才能继续下去,也就是说当前的同步加载的时间绝不仅仅是加载完你要的这个模型而已,他需要将当前异步加载任务在队列中的所有资源加载完!而这个时候恰恰处于场景在level streaming的阶段,最后发现此事加载队列中的资源上百个,这个同步加载遇上level streaming的结果就是,在这一帧要完成上百个模型的创建,模型的postload会初始化RHI资源,导致一帧内大量vb的创建,卡死RHI,所以罪魁祸首是同步加载,同步加载将level streaming的过程也强行同步了,找到了问题,我们就可以通过相关的优化手段来排除这个瓶颈。
RHI上的问题可能往往不只是RHI上的问题那么简单,通过上面说的一些方法我们可以清楚的看到各种RHI上瓶颈的真正原因。