查看原文
其他

经验分享 | 优化Unity渲染器第二部分:清理工作

2016-06-03 赵菁菁 Gad-腾讯游戏开发者平台
在第一部分我们已经做了背景介绍,现在让我们真正开始工作吧!
正如第一部分所提到的,我先试着记住/找出现有代码的作用,做一些分析并记下突出的内容。
分析几个项目主要揭示了两件事:
1)其实可以更广泛地使用多线程渲染代码,而不是我们现在做的“主线程加一个渲染线程”。这是来自Unity5时间轴分析器的一张抓图:
在这个特例中,CPU的瓶颈是渲染线程,在这个线程中大多数时间都花费在glDrawElements上(这是在MachBookPro上运行的;来自蝴蝶效应演示的GPU简化场景进行了大约6000次绘制调用),最终主线程只能等待渲染器线程完成。因硬件、平台、图形API等不同因素,瓶颈也可能在其它地方,例如:在一台DX11下的快得多的PC上运行相同的项目,在主线程和渲染线程上花费的时间差不多相同。

左侧的剔除片看起来非常好,我们最终想要我们自己的渲染代码和那看起来一样好。这里是缩小的剔除部分:
2)没有“优化一个函数就让整体快两倍”的地方:(重构信息的过程路漫漫,我们需要移除冗余决策,移除这儿、那儿的零碎事情,直到我们可以达到像“每线程快2倍多”这样的目标。如果有过的话,这里的渲染线程分析数据不是那么有意思,大多数时间(以下所有提到的事情) 由OpenGL运行环境/驱动花费。这里加一个提醒:可能我们做了点蠢事让驱动做了太多工作(我不知道,可能是无缘无故在两个不同的顶点布局间切换等等),却没让驱动从我们的角度出发做工作。大多数的剩余时间是花费在动态批处理上的。
仔细观察主线程中重量级的函数,我们得到这个:
现在一定会提出很多问题(为什么有这么多哈希表查询?为什么排序花费这么长时间?等等,请看上面列表),但是重点在于,不存在这样一个地方:优化一些内容就会带来奇迹般的性能提升。

观察1:材质“显示列表”经常被重建在我们的代码中,一个材质可以为渲染线程预记录一个我们叫“显示列表”的东西。把它想成一个小的命令缓冲区,在这里存储着大量命令(“设置这个光栅状态对象,设置这个着色器,设置这些纹理”)存储。关于它们最重要的事是:它们都是带分解的参数存储的(最终纹理值、着色器统一值等)。当“应用”一个显示列表时,我们只是把它交给渲染线程,没必要追踪材质属性或者其他内容。那就都很好了,除非材质中的一些改变让记录的显示列表非法时。在Unity中,每个着色器内部通常是许多着色器变量,当转换到不同着色器变量时,我们需要应用不同的显示列表。如果场景是用这种方式建立的:相同的材质在不同列表间替换,那么我们就会有问题了。
问题实际上就是我在几个基准测试项目中遇到过的,简单来说就是由“正向渲染中的多个单像素光线造成的”。结果就是我们有代码在一些分支上修正这个问题,这些代码只是有待完成——我发现这部分代码,让它们在当前代码库中编译,而且它们非常有效。现在材质可以预记录一个以上的“显示列表”,上面的问题就不复存在了。
在一台PC上(酷睿i7 5820K),如果一个场景在主线程上运行时间为9.52ms到7.25ms,那是非常了不起的。
提前剧透:这个改变会给受影响的场景带来巨大好处——该结论来自于我这几乎两周工作所做的事情,而且这甚至不是“我写的”代码;我只是把它从一个被忽视的分支里找出来。哇!一个简单的改变就提升了30%性能!懒蛋,我不会从下面的任何内容中获得这种改变!

观察2:太多哈希表查找接下来,在上面的观察列表中仔细研究“为什么有这么多的哈希表查找“问题(如果此处还没有歌曲,应该有了!)。在渲染代码中,许多年前我加了一些内容类似于Material::SetPassWithShader(Shader* shader, ...),因为调用代码已经知道应该设置哪种着色器材质状态。材质也知道它是着色器,但是它存储了一些我们叫做PPtr(“永久指针”)的东西,PPtr本质上是一个句柄。直接传递指针避免了进行句柄->指针的查找(目前这就是一个哈希表查找,我被告知因为各种各样的复杂原因,很难做一个基于数组的句柄系统)。
结果是,经过了许多修改,Material::SetPassWithShader还是莫名其妙地做了两次句柄->指针查找,尽管它早已获得了作为参数的实际着色器指针!解决了:
好吧,结果是:这是一个不错的、重要的、非常简单的性能优化。另外,让代码库体积变小也是件好事。

各处小调整从上面Mac的渲染线程分析中,我们自己的BindDefaultVertexArray中的代码花费了2.3%的时间,这听起来过多了。结果是,它是在循环所有可能的顶点组件类型并且在检查一些东西;如果让代码用着色器只循环顶点组件的话,速度会稍有提高。
一个项目调用大量GetTextureDecodeValues来计算一些颜色空间、HDR和纹理的光线映射解压常量。它做许多关于可选“强度乘数”参数的复杂sRGB计算,这些参数除了在一个地方之外,在所有其他调用地方都恰好被设置为1.0。记住在代码中不要使用太多pow()函数调用。“稍后看”列表中加一条:为什么我们要在最开始频繁调用那个函数?
一些在渲染循环中的代码会计算出绘制调用批处理边界应该在哪里(也就是说在哪里转至一个新的着色器等等),并且与一些代表独立的bool型变量的状态相比较,把一些代码打包到比特域中并且在一个整型上进行比较。这里并没有观察到性能提升,但是代码确实体积变小了,所以胜利:)。
注意:要想算出对象使用了哪些顶点缓冲区和顶点布局,需要内存中离得非常远的网格数据。重排序的数据基于使用类型(渲染数据、碰撞数据、动画数据等等)。
同样,我们也减少了数据,利用@msinilo的非常棒的CruncherSharp(在过程中对其进行了一些微调)进行漏洞修补。我听说对于Linux也有一个相似的工具(pahole)。在Mac上有struct_layout,但是它在Unity中执行时间太长了,Python脚本也会因一些递归溢出异常而运行失败。
在浏览代码过程中,平心而论,我们发现我们追踪每个纹理的MIPMAP乖离率是非常复杂的。设置了每个纹理,之后纹理会在它用到的地方追踪所有的材质属性表单;当任何MIP乖离率改变时,纹理就会通知属性表单,乖离率会从属性表单中提取出,与每个纹理、或者图形设备上每次设置的纹理一起应用。天啊,解决了!
既然这会改变我们图形API概述的接口,这意味着改变整个11个渲染下腰姿势;只是每个内容里的几个非常小的改变就可以令人吃惊(我甚至不能在本地建立它们的一半)。别害怕,我们有构造农场来检查编译错误,还有测试套装来检测回归!
没有明显的性能区别,但是感觉就像摆脱了所有那些复杂性。给“稍后看”列表加一条:有与我们追踪的每个纹理相似的又一块数据,是一些用于非二次幂纹理UV缩放的内容。我怀疑这些天来它在那没有什么合理原因,我会看看如果有可能的话把它移除掉。
还有一些其他的相似的本地化微调,每个都很简单并且让一些特定地方的性能稍好了一些,但是也没有带来明显的性能提升。可能做几百次微调才能达到显著效果,但是我们需要一些更严格的代码重写来得到好结果,这样的可能性更大。
材质属性表单的数据布局有一件事一直困扰着我,就是我们如何存储材质属性。每次我都会把代码库展示给新工程师看,在那种情况下我会转动眼珠说:“我们在这里存储材质的纹理、矩阵、颜色等等,额,是在不同的STL映射中。害怕,害怕……”
有一种流行的思想认为C++STL容器没有高性能代码,而且没有优秀的游戏能逃避这些容器(并不是真的),如果你使用这些容器,你一定是很傻的而且应该被嘲笑(我不知道……可能真是这样?)。嘿,我出手把这些映射替换成更好的数据布局怎么样?也许这样做可以让一切都好几百万倍,对吗?
在Unity中,着色器的参数可以来自两个地方:每个材质的数据或者“全局”着色器参数。前者主要用于类似“扩散纹理”的东西,但是后者用于类似“雾色”或者“相机投射”这样的东西(在材质属性块等形式中的每个实例参数有些复杂,但时让我们暂时先忽略这些)。
我们之前的数据布局大概是这样(属性名基本上是整型):
当向属性表单加入新属性时,就只是向所有数组附加了内容。属性名/类型信息和属性在数据缓冲区的位置是分别存放的,所以当搜索属性时,我们甚至不用调出与搜索无关的数据。

最大的外部改变就是之前一个人可以找到一个属性值并且存储指向该值的指针(在预记录材质显示列表中使用,可以在重启动它们之前给全局着色器属性“打补丁”)。现在,无论何时修改数组大小,这些指针都是不合法的;所以所有可能存储指针的代码都已经被改变了,而用来把偏移量存入属性表单数据。所以最后,确实有很多代码变化。
查找属性已经从O(logN)操作(映射查找)变为一个O(N)操作(在名称数组中线性查找)。如果你在学计算机科学的话,这听起来很糟糕,因为课不是这么教的。但是,我看不同的项目,发现属性表单通常总共包含5到30个属性(大多数是10个左右);与STL映射查找比起来,在内存中线性搜索所有的紧邻的数据没那么糟,内存中的映射节点可能互相远得离谱(如果这种情况发生了,每个节点访问都可以成为CPU缓存故障)。从几个不同项目的分析中,进行“属性查找”的部分在PC、笔记本和iPhone上运行都始终都要稍快一些。

但是这个改变带来奇迹般的性能提升了吗?没有。它给平均帧时间带来了少量提升,也带来了稍小的内存消耗,特别是当有许多不同材质时。但是“只是把STL映射替换成成宝的数组”就会带来奇迹吗?唉,当然不。但至少当我把这些代码给别人时不用再必须转动眼珠了,那就可以了。
在我的代码回顾中,有一种评论出现:我应该试着把属性数据分离,这样一来相同类型的属性就分为一组了。属性表单就会知道一个特定类型的起始和结束的下标,那么查找一个特定属性只需要搜索那个类型的名称数组就可以了(数组只包括每个属性的一个整型名称,而不是名称+类型)。向表单中加入新属性的代价就会很高,但是查找会容易得多。
从中得出结论:现代CPU在你所说的“坏代码”上运行也极其快,而且也有非常大的缓存。我没有过多关注手机CPU硬件,只是发现iPhone6的CPU油一个4兆字节的三级缓存。四兆字节!在一台手机上!那几乎是我第一台PC的RAM容量了!

目前成果所以,这几乎是两周的工作量(我估计的是75%的时间——其余部分花费在不相关的bug修复、代码回顾等问题上);目前的状态是所有的平台都在搭建,测试正在通过;已经准备好了一个pull请求。40个提交,135个文件,大约修改了2000行代码。
一个基准项目(受“改造显示列表”影响最大的项目)的性能提升了很多,PC上的整体帧时间从11.8ms减少到8.50ms,笔记本上的从29.2ms减少到26.9ms。其他项目性能也有提高,但是没有这么明显(大约是PC上7.8ms到7.3ms,另一个项目在iPhone上是从15.2ms到14.1ms等)。实际上,大多数的性能提升来自于两个地方(显示列表改造和避免无用的哈希表查找)。我不确定应该怎样摸索余下的变化——它们似乎是好的改变,如果我现在能更好地理解代码库并且已经对解释什么和为什么加了非常多的评论就太好了。我现在也有一张甚至更长的列表——“这里有一些地方很奇怪或者应该被改进”。
花费近两周的时间只得到了我目前的结果,这样值得吗?很难说。有时确实有那么一周的时候我觉得自己什么也没做,所以现在比那好点:)。
总体来讲我仍然不确定“优化事物”是否是我最强的领域。我觉得我只在一些事情上非常擅长: 
1)调试困难问题——我可以想出貌似可能的推测和方法来快速分解并克服问题; 2) 理解一些改变或者一个系统的含义——其他系统会受到什么影响,什么可能/将会成为问题交互;3) 对于其他人对代码库进行的修改有清楚的外界认知——我可以指出几个人、什么时候工作内容有点重叠并且告诉他们:“嘿,你们应该协调一下”。
关于优化有没有实用的技巧?我不知道,我肯定不能歪曲了脑中的指令延迟、执行端口和TLB故障。但是也许如果我实际操作一下情况就会变好?谁知道呢。
当前我不太清楚下一步应该走那条路,我认为至少有几个可能的方向:
1.继续进行更多的改进,我希望大量改进的有效效应是很好的。目前每个改进本身都有点不太令人满意,因为改进是很难度量的。开始关注全局,想出目前我们如何避免大量已经完成了的工作,也就是要把重点放在内容怎样组织的“重构”上。一旦更多的清理工作完成了,转去帮助他人“让更多东西变成多线程”。
优化很难!让我们充满激情继续坚持直到情况越来越好!我想我会和别人讨论,做上面的一件或者多件事。直到下一次的到来!
相关阅读:经验分享 | 优化Unity渲染器第一部分:简介快速入门 | Unity StrangeIoc 框架介绍
腾讯游戏开发者平台长按,识别二维码,加关注
经验分享丨项目实践项目孵化丨渠道发行做有梦想的游戏人-GAME AND DREAM-

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存