优化Unity渲染器

来源:互联网 发布:耽美网络剧美拍 编辑:程序博客网 时间:2024/04/30 15:27


0.png 
引用:
l  SetPassWithShader有些时候为避免持久指针解引用(PPtr deref)所做的优化。现在看来它总是执行持久指针解引用(PPtr deref),然后调用SetPass(它会再执行一次解引用(deref))。
l  材质显示列表经常被>1的逐像素光源重建。每个材质需要缓存不止一个列表(周期性的Kaspar的5bce615bca6b)
l  GetTextureDecodeValues被调用了多次(设置像素光属性),最后做了一些无用的对“1”的伽马线性转换。
l  未赋值的全局纹理属性(_Cube,没有设置任何物体)导致的蝴蝶效应,经常使材质显示列表被重建。应该弄清为什么在任何全局属性丢失的时候,我们都没有记录。
l  GpuProgramParameters::MakeReady实际做了什么?它为什么要排序?
l  为什么在属性表中使用了STL容器map?
n  仅使用健壮的简单的数据层。
n  相关的奇怪问题
u  为什么从PropertySheet中分离出TexEnv(但是两者还被关联在一起)
u  SetRectTextureID——为什么,是什么
u  纹理的纹理尺寸/高动态光照渲染(TexelSize/HDR )解码了内置设备状态的属性部分。
u  NotifyMipBiasChanged 听起来像是大量复杂操作,且目的不明。
l  IsPassSuitable 被渲染循环重复调用。可能是为每个渲染循环建立了一个直接pass指针的表?
l  一次性应用所有纹理,而不是通过SetTexture一次一个的设置。
l  在内存中重排属性表,使其和实际常量缓存布局一致。对不同的关键字变量可能需要不同的布局。
l  TextureID转换为长整型(在mac/linux 64位机上占64bit!),针对PS4平台提出的3cbd28d4d6cd
n  看起来这是一个仅针对PS4的优化,它直接在TextureID中存储了一个指针。考虑如果可能的话再在各处执行该操作,或者只在PS4上将它转为长整型(intptr_t 更好)
l  为什么ChannelAssigns总是被传递?这似乎没什么用。
l  ChannelAssigns和VertexComponent尤其诡异(太多可能的VertexComponent实体)。
l  渲染循环排序成本太高。使用基于哈希表的排序(渲染循环排序)并启用它!



经过对几个项目的性能分析,主要暴露了两个问题:


1)渲染代码实际可以使用更多的多线程,而不仅仅是我们现在使用的“主线程和渲染线程”。这有一张unity5性能分析器中时间轴的截图:

1.png

在这种特殊情况下,CPU的瓶颈是渲染线程,时间主要耗费在glDrawElements(这是运行在MacBookPro上,场景来源于ButterflyEffect demo,GPU已经简化,draw call达到6000左右)。主线程结束,等待渲染线程完成后再继续。这和硬件、平台、图形API等有关。瓶颈也可以是其他地方,例如,同一个项目运行在更快的电脑的DX11下,主线程和渲染线程花费的时间是相等的。


左边代表剔除的颜色条的看起来很好,我们希望最终所有的渲染代码也可以是这样的。下面是剔除部分的放大图:

2.png 


2)没有“优化一个函数就可以使所有事情快一倍”的地方:( 这是一个漫长的过程:重排数据、移除冗余决策、移除这里的小操作,直到我们可以达到如同“每个线程有从前两倍快”的状态。如果可以的话。


渲染线程的性能分析数据并不太让人感兴趣。主要的时间开销(下面高亮标注的内容)来自于OpenGLobal运行时/驱动。顺便说一下,可能是因为我们做了什么愚蠢的事,导致驱动做了太多工作(我不太清楚,可能是不同顶点布局间做了多余的切换等。),但是其他没有太多是我们的原因。剩下的大部分时间花在了 动态批处理。

3.png 

分析那些在主线程中耗时的操作,我们得出:

4.png 

现在出现了一些问题(为什么有这么多哈希表查找?为什么排序这么慢?等等,参见上面的列表),但是关键是,没有单独哪个地方进行了优化之后就能得到奇迹般的性能提升。
观测点1:材质“显示列表(display lists)”被频繁重建


在我们的代码中,一个材质可以为渲染线程预先记录被称为“显示列表(display list)”的东西。它可以被视为一个小的命令缓冲区,其中存储了一批命令(“设置此光栅状态对象,设置此着色器,设置这些纹理”)。最重要的是:他们存储了所有解析过的参数(最终的纹理值,着色器统一值等)。当“应用”一个显示列表时,我们只是把他传递给渲染线程,不需要查找材质属性值或其他事情。


这一切都很好,除了当材质中的一些东西改变时,会使记录的显示列表无效。在Unity中,每个着色器内部往往是 许多着色器的变种,当切换到不同的着色器变种时,我们需要应用不同的显示列表。如果场景以这种方式运行,会导致相同材质进行不同列表间的切换,那么,就出现问题了。


这些情况确实在我的几个基准项目发生了;长话短说就是“多个逐像素光源在前向渲染中引起了这个问题”。结果是,我们已经在一些分支上修复了这个问题,只需要补全它——所以我找到了它,在当前的代码库中编译,它能够正常工作。现在材质可以预先记录不止一个“显示列表”,这个问题不会再出现了。


在Windos的电脑上(酷睿i7 5820K),一个场景在主线中花费的时间从9.52ms降低到7.25ms,这是相当出色的。


提前透露:在我这将近两个星期的工作中,这一修改,给受影响的场景带来的好处是最大的。而且,这甚至不是“我写”的代码;我只是从某个被忽略的分支上找出了它。所以,哈!一个简单的修改带来了30%的性能提升!不过下面所写的内容将不会再有这种修改。


观测点 2:过多的哈希表查找


接下来,从上述的观测列表中,研究一下“为什么有这么多哈希表查找”的问题(如果还没有a song about it,那么应该有!)


在渲染代码中,很多年前我添加了这样一段
[mw_shl_code=csharp,true]Material::SetPassWithShader(Shader*shader, ...) [/mw_shl_code]
因为调用代码已经知道应该设置哪个着色器材质状态。材质也知道它的着色器,但是它存储了一些被我们称为PPtr(“持久指针(persistent pointer)”)的东西,它本质上是一个句柄。直接传递这种指针可以避免进行句柄->指针查找(目前这是一个哈希表查找,因为很多复杂的原因,很难做到基于数组的处理系统,我之前已经讲过了)。


结果是,经过很多变化之后,不知为何,Material::SetPassWithShader以进行两次句柄->指针查找而告终,即使它已经以参数形式获取到了实际着色器指针!修复它:

5.png 
引用:
[着色器库]清理并优化材质pass的应用
SetPassWithShader被添加到需要避免持久指针解引用(PPtr deref)的地方。结果,现在它总是进行*两次*m_Shader 持久指针解引用。这不是个可怕的优化。
所以清理掉它们;使Material.SetShaderPass直接持有它需要的pass,避免解引用并进入子着色器(subshader)。只缓存直接基于pass的指针;这允许移除特殊情况下针对阴影caster pass的重复代码。
VikingVillageStatic工作台项目,酷睿i75820K,主线程:Camer
.Render 7.25ms -> 6.59ms。修改17个文件,135处增加和213处删除:


好了,所以这个问题也解决了,可衡量的而且很简单的性能优化。这也使得代码库更小了,这是一件很好的事。


各处的细微调整


从上述Mac机的渲染线程性能分析中可以看出,我们自己的代码在 BindDefaultVertexArray 操作上花费了2.3%的时间,这个开销听来些过大了。原来,它遍历了所有可能的顶点组件类型并检查了一些东西;让代码仅遍历被着色器使用的顶点组件。这样稍快了些。


一个项目调用很多次 GetTextureDecodeValues ,为纹理计算一些颜色空间、HDR(高动态光照渲染)和光照贴图的解压常量。它根据一个可选的“强度倍增”(“intensity multiplier”)参数,做了一批复杂的sRGB数学运算,但该参数在所有的调用点都被设置为1.0,只有一处除外。认识到这一点,就可以消除代码中大量的pow()调用了。添加到“关注”列表:为什么我们起初非常平常频繁的调用这个函数?


一些代码,在渲染循环中计算了绘制调用(draw call)批处理的边界应该位于何处(即,在哪里切换一个新的着色器等。),比较了一些用独立的布尔值表示的状态。将它们打包为一组bit域,然后以整数进行比较。没有获得明显的性能提升,但是代码最终变了,所以也算一个胜利:)


注意,弄清哪些被物体使用的顶点缓存和顶点布局,查询了远在内存中的网格数据。根据使用类型(渲染数据、碰撞数据、动画数据等)记录数据。

6.png


而且使用@msinilo’s 出色的 CruncherSharp来减少数据填充间隙(并按照这种方式做了一些调整:),听说有一个针对Linux(pahole)的小工具。在Mac机上有struct_layout,但它处理Unity的可执行脚本以及Python脚本时,经常会因为递归溢出而失败。


浏览代码时注意到,我们跟踪每个纹理的mipmap偏置的方式,客气点说,很令人费解。它被设置每个纹理,然后纹理跟踪所有的材质属性表在哪里被使用;在任何mip偏差改变的时候通知它们,并从属性表中取出偏差,和每个纹理一起被应用,每次一个纹理被设置到图形设备。天呐,修复它。因为这个修改了我们图形API的抽象接口,这意味着要修改共计11个渲染后端;每个都只是一些细微的修改,但却让人觉得恐慌(我甚至不能局部地构建其中的一半)。不用怕,我有build farm来检查编译错误,有测试套件来检查递归!

7.png 
引用:
[图形加速设备]使纹理的mip偏置的处理更健全
如我所言,这很奇怪,因为我想要处理OpenGLpre-1.4(2002!?),
其中偏置是纹理层的一部分,而不是纹理的。或者可能时其他的什么。现在它只是纹理过滤器/重复(wrap)/各向异性(aniso)设置的一部分,只在它改变时被应用。
修改:
偏置应用到每个gfxdevice.SetTexture调用,
Texture::NotifyMipBiasChanged,
TexEnv::TextureMipBiasChanged,
TexEnvData::mipBias.
80个文件修改,228出新增,263处删除:



没有显著的性能变化,但是摆脱了所有的复杂性感觉也不错。添加到“关注”列表:我们跟踪每个纹理时,有一些相似数据;一些关于非2的整数次幂纹理的UV缩放的东西。现在我怀疑这没什么存在的必要,继续观察,可能的话将其删除。


还有一些其他类似的局部调整,每个都很简单,使一些特殊的地方变得更好一点,但是没有任何显著的性能提升。进行上百个这种调整可能带来一些明显的效果,但是更可能的是,我们需要更严谨的重新分析这些问题,以得到好的结果。


材质属性表的数据布局


令我烦恼的一件事是如何存储材质。每次我把代码库给一个新的工程师看,我会翻翻白眼说“嗨,我们在这里存储了材质纹理、矩阵、颜色等,在单独的STL映射(map)中。简直惊悚。”。


这种想法很流行:C++的STL容器在高性能代码中没有立足之地,没有好游戏曾经使用过它们(不是真的),如果你使用它你就太愚蠢了,会被人耻笑(我不知道…可能?)。所以,嗨,我用更好的数据布局替代这些映射(map)如何?肯定会让一切都快上百万倍,对吧?

在Unity中,着色器的参数可以来自两个地方:一种来自于每个材质的数据,另一种来自于“全局”着色器参数。前者中典型的是“漫反射纹理(diffuse texture)”,而后者例如“雾的颜色(fog color)”或“相机投影(camera projection)”(每个实例参数都是MaterialPropertyBlock等的形式,这有点复杂,但是现在我们先忽略那些)。


之前我们的数据布局大概是这样(PropertyName基本上都是整型):

[mw_shl_code=cpp,true]map<PropertyName, float> m_Floats;
map<PropertyName, Vector4f> m_Vectors; 
map<PropertyName, Matrix4x4f> m_Matrices; 
map<PropertyName, TextureProperty> m_Textures;
map<PropertyName, ComputeBufferID>
m_ComputeBuffers; 
set<PropertyName> m_IsGammaSpaceTag; // which properties come as sRGB[/mw_shl_code]
What I replaced it with (simplified, onlyshowing data members; dynamic_array is very much like std::vector, butmore EASTL style):

我把它替换成(简单,只展示数据成员;dynamic_array很像std::vector,但是更有EASTL 风格):

[mw_shl_code=cpp,true]
struct NameAndType { PropertyName name; PropertyType type; };  
// Data layout: 
// - Array of name+type information for lookups (m_Names). Do 
//   not put anything else; only have info needed for lookups! 
// - Location of property data in the value buffer (m_Offsets). 
//   Uses 4 byte entries for smaller data; don't use size_t! 
// - Byte buffer with actual property values (m_ValueBuffer). 
// - Additional data per-property in m_GammaProps and 
//   m_TextureAuxProps bit sets. 
// 
// All the arrays need to be kept in sync (sizes the same; all 
// indexed by the same property index). 
dynamic_array<NameAndType> m_Names; 
dynamic_array<int> m_Offsets; 
dynamic_array<UInt8> m_ValueBuffer;  

// A bit set for each property that should do gamma->linear 
// conversion when in linear color space 
dynamic_bitset m_GammaProps; 
// A bit set for each property that is aux for a texture 
// (e.g. *_ST for texture scale/tiling) 
dynamic_bitset m_TextureAuxProps;[/mw_shl_code]

向属性表添加新的属性时,它只是追加到所有的数组。属性名/类型信息和属性在数据缓存中的位置保持独立,以便查找属性时,我甚至不获取对查找本身不需要的数据。


之前,最大的外部变化就是,可以查找一个属性值并存储它的直接指针(用于预先记录材质显示列表,能够在重用显示列表前,“插入”全局着色器属性的值)。现在只要改变数组大小,指针就会失效;所以,所有可能存储了指针的代码,都需要替换为存储数据在属性表中的偏移量。这最后导致了相当多的代码修改。

8.png


查找属性的复杂度从O(logN)ss(映射查找)变成了O(N)(从名称数组中线性扫描)。如果你正在学习计算机科学中一般理论,那这种情况听起来并不太好。但是,我查看了各种项目,发现通常数量表总共只包含5到30个属性(大部分是10个左右);线性扫描内存中所有的彼此相邻的待查询数据,比起STL的映射(map)查找就不那么差了。因为STL中映射(map)的节点的位置可能彼此间隔很远(如果是这样,访问每个节点可能降低CPU缓存的命中)。从几个不同项目的分析数据来看,“查找属性”这部分功能,在电脑、笔记本和iPhone上都稍快了一些。


这个修改带来魔法般的性能提升了吗?没有。它带来的是平均帧时间的小改善,内存消耗略有降低,尤其有一大堆不同的材质时。“只是用封装的数组替换STL映射”有导致魔法般的性能提升吗?也没有,至少向别人展示代码时不必再翻白眼了。所以,就是这些了。


在进行代码审查时,有一种意见是,我应该尝试分离属性数据,以便相同类型的数据可以被分为一组。一个属性表可以知道哪些起始索引和结束索引是针对哪个类型的,那么查找指定属性就只需要扫描那个类型的名字数组(这个数组可能只包含每个属性的名字,而不是名字+类型)。向表中添加新属性可能开销更大,但是查找它们的开销会更小。


这些从侧面说明:现代CPU在那些你视为“坏代码”的地方速度很快,而且可能也有很大的缓存。我没有太关注移动CPU的硬件,只是意识到iPhone6的CPU有4兆字节的L3缓存。4兆字节啊,在一个手机上。这顶我第一台电脑的多少个RAM了!


目前的结果

这就是两周左右的工作(我估计实际只有75%的时间——其他花费在修复无关的bug,代码审查等);目前的状态是,所有的平台都构建并测试通过;下载请求已经就绪。40次提交,135个文件,将近2000行的代码修改。

9.png
引用:
背景:…-根据v1的公共反馈添加待办事项。
节约的时间大部分来自于更好的图形加速设备缓存显示列表,尤其是总在不同关键字间切换材质(例如 VikingsVillage在性能好的PC上,时间变化为12ms->8.5ms)。其他情况下速度稍有提升,但不是特别大(详情参考以上总结页中的谷歌文档链接。)似乎带部分使帧率更加稳定了,可能时因为在运行时内存分配减少了。
*在材质中缓存不止一个显示列表。
*更好的属性表数据布局(6个std::maps –>3个dynamic_array和两个bitset)。这意味着不能再存储值的指针了;将所有代码改为存储偏移。为属性表添加更多的单元测试!
*使纹理的mip偏置处理更健全,现在它只是过滤器/重复(wrap)/各向异性(aniso)的参数,而不再在循环中被跟踪,应用到每个SetTexture调用。
*SetPassWithShader是一个为了在某些地方避免持久指针解引用(PPtrderef)的优化,但是现在它总是进行两次解引用!清除掉这些。
*在前向循环中光源属性前,移除世界矩阵单位化的设置。看起来没用。
*针对线程的显示列表补丁数据的细微优化。
*OpenGL:对BindDefaultVertexArray的细微优化(GLES也同样做了优化)。
*为倍增值(multiplier)为1的情况增加特定GetTextureDecodeValues。
*根据平台floks,在android/Tizen上,网格缓存永远不会丢失。
*除了PS4上,其他平台的TextureID变回32bit。
*在某些地方使用更紧凑的结构/类成员结构。
*从着色器库(shaderlab)中移除无用的内容(如MatrixVal),并在很多地方添加注释。
*杂项:在发布的播放器上支持 –datafolder 命令行参数,在mac/linux上也是。



性能方面,一个基准项目改善了很多(最受“显示列表被重建”问题的影响),一帧的总时间,在电脑上从11.8ms降到8.5mc,在笔记本上从29.2ms降到26.9毫秒。其他项目也有改善,但效果远不及这个(大部分在电脑上从7.8ms降到7.3ms;另一个项目在iPhone上从15.2ms降到14.1ms,等等)


大部分性能提升实际来自于两个地方(显示列表重建;避免无用的哈希表查找)。我不知道该如何看待剩下的修改——总体上感觉它们都算是好的,因为现在我更好地理解了代码库,并添加了很多注释来解释是什么&为什么。我现在还有了一个更长的列表“这些地方很奇怪或者应该被改善“。


花费将近两周的时间,对我现在的结果来说是否值得?很难说。有时我真感觉一周什么都没做,所以现在的结果总比这个更好:)


总的来说,我还是不太清楚“优化工作“是否是我擅长的领域。我觉得我至少对这几件事比较擅长:1)调试难题——对问题我可以提出合理的假设和方法快速地分而治之;2)了解一些修改或一个系统的影响——其他系统会受到什么影响,有问题的交互应该或可能是什么;3)对代码库中其他人做的事有很好的总体意识——我经常可以发现几个人正在开发重叠的东西,并告诉他们“嘿,你们两个应该协调工作”。


这些是对优化有用的技能吗?我不知道。我当然不能在脑中同时处理指令延迟、执行端口和TLB缺失。但是如果我实践的话,也许可以让情况更好?谁知道呢。


不太确定下一步该怎么走;但至少有几个可能的方向:


  • 继续进行增量改进,希望大量改进的净效果会很好。单个改进让人有些失望,因为性能确实难以衡量。
  • 开始着眼大局,并寻找如何避免当前大量的已完成工作,即更严谨地“重塑“代码的结构。
  • 一些清理工作完成后,切换到帮助他人处理“多线程的内容“。
  • 优化很难!让我们多玩玩 《摇滚史密斯》 (Rocksmith)直到情况改善吧。


我想我会和其他人讨论讨论,进行上面的一项或几项,直到下次!

扫描下方二维码关注游戏蛮牛官方微信~每日都有精选干货与你分享呦~


原文链接:http://aras-p.info/blog/2015/04/04/optimizing-unity-renderer-2-cleanups/
原文作者:Aras Pranckevičius

0.png 
引用:
l  SetPassWithShader有些时候为避免持久指针解引用(PPtr deref)所做的优化。现在看来它总是执行持久指针解引用(PPtr deref),然后调用SetPass(它会再执行一次解引用(deref))。
l  材质显示列表经常被>1的逐像素光源重建。每个材质需要缓存不止一个列表(周期性的Kaspar的5bce615bca6b)
l  GetTextureDecodeValues被调用了多次(设置像素光属性),最后做了一些无用的对“1”的伽马线性转换。
l  未赋值的全局纹理属性(_Cube,没有设置任何物体)导致的蝴蝶效应,经常使材质显示列表被重建。应该弄清为什么在任何全局属性丢失的时候,我们都没有记录。
l  GpuProgramParameters::MakeReady实际做了什么?它为什么要排序?
l  为什么在属性表中使用了STL容器map?
n  仅使用健壮的简单的数据层。
n  相关的奇怪问题
u  为什么从PropertySheet中分离出TexEnv(但是两者还被关联在一起)
u  SetRectTextureID——为什么,是什么
u  纹理的纹理尺寸/高动态光照渲染(TexelSize/HDR )解码了内置设备状态的属性部分。
u  NotifyMipBiasChanged 听起来像是大量复杂操作,且目的不明。
l  IsPassSuitable 被渲染循环重复调用。可能是为每个渲染循环建立了一个直接pass指针的表?
l  一次性应用所有纹理,而不是通过SetTexture一次一个的设置。
l  在内存中重排属性表,使其和实际常量缓存布局一致。对不同的关键字变量可能需要不同的布局。
l  TextureID转换为长整型(在mac/linux 64位机上占64bit!),针对PS4平台提出的3cbd28d4d6cd
n  看起来这是一个仅针对PS4的优化,它直接在TextureID中存储了一个指针。考虑如果可能的话再在各处执行该操作,或者只在PS4上将它转为长整型(intptr_t 更好)
l  为什么ChannelAssigns总是被传递?这似乎没什么用。
l  ChannelAssigns和VertexComponent尤其诡异(太多可能的VertexComponent实体)。
l  渲染循环排序成本太高。使用基于哈希表的排序(渲染循环排序)并启用它!



经过对几个项目的性能分析,主要暴露了两个问题:


1)渲染代码实际可以使用更多的多线程,而不仅仅是我们现在使用的“主线程和渲染线程”。这有一张unity5性能分析器中时间轴的截图:

1.png

在这种特殊情况下,CPU的瓶颈是渲染线程,时间主要耗费在glDrawElements(这是运行在MacBookPro上,场景来源于ButterflyEffect demo,GPU已经简化,draw call达到6000左右)。主线程结束,等待渲染线程完成后再继续。这和硬件、平台、图形API等有关。瓶颈也可以是其他地方,例如,同一个项目运行在更快的电脑的DX11下,主线程和渲染线程花费的时间是相等的。


左边代表剔除的颜色条的看起来很好,我们希望最终所有的渲染代码也可以是这样的。下面是剔除部分的放大图:

2.png 


2)没有“优化一个函数就可以使所有事情快一倍”的地方:( 这是一个漫长的过程:重排数据、移除冗余决策、移除这里的小操作,直到我们可以达到如同“每个线程有从前两倍快”的状态。如果可以的话。


渲染线程的性能分析数据并不太让人感兴趣。主要的时间开销(下面高亮标注的内容)来自于OpenGLobal运行时/驱动。顺便说一下,可能是因为我们做了什么愚蠢的事,导致驱动做了太多工作(我不太清楚,可能是不同顶点布局间做了多余的切换等。),但是其他没有太多是我们的原因。剩下的大部分时间花在了 动态批处理。

3.png 

分析那些在主线程中耗时的操作,我们得出:

4.png 

现在出现了一些问题(为什么有这么多哈希表查找?为什么排序这么慢?等等,参见上面的列表),但是关键是,没有单独哪个地方进行了优化之后就能得到奇迹般的性能提升。
观测点1:材质“显示列表(display lists)”被频繁重建


在我们的代码中,一个材质可以为渲染线程预先记录被称为“显示列表(display list)”的东西。它可以被视为一个小的命令缓冲区,其中存储了一批命令(“设置此光栅状态对象,设置此着色器,设置这些纹理”)。最重要的是:他们存储了所有解析过的参数(最终的纹理值,着色器统一值等)。当“应用”一个显示列表时,我们只是把他传递给渲染线程,不需要查找材质属性值或其他事情。


这一切都很好,除了当材质中的一些东西改变时,会使记录的显示列表无效。在Unity中,每个着色器内部往往是 许多着色器的变种,当切换到不同的着色器变种时,我们需要应用不同的显示列表。如果场景以这种方式运行,会导致相同材质进行不同列表间的切换,那么,就出现问题了。


这些情况确实在我的几个基准项目发生了;长话短说就是“多个逐像素光源在前向渲染中引起了这个问题”。结果是,我们已经在一些分支上修复了这个问题,只需要补全它——所以我找到了它,在当前的代码库中编译,它能够正常工作。现在材质可以预先记录不止一个“显示列表”,这个问题不会再出现了。


在Windos的电脑上(酷睿i7 5820K),一个场景在主线中花费的时间从9.52ms降低到7.25ms,这是相当出色的。


提前透露:在我这将近两个星期的工作中,这一修改,给受影响的场景带来的好处是最大的。而且,这甚至不是“我写”的代码;我只是从某个被忽略的分支上找出了它。所以,哈!一个简单的修改带来了30%的性能提升!不过下面所写的内容将不会再有这种修改。


观测点 2:过多的哈希表查找


接下来,从上述的观测列表中,研究一下“为什么有这么多哈希表查找”的问题(如果还没有a song about it,那么应该有!)


在渲染代码中,很多年前我添加了这样一段
[mw_shl_code=csharp,true]Material::SetPassWithShader(Shader*shader, ...) [/mw_shl_code]
因为调用代码已经知道应该设置哪个着色器材质状态。材质也知道它的着色器,但是它存储了一些被我们称为PPtr(“持久指针(persistent pointer)”)的东西,它本质上是一个句柄。直接传递这种指针可以避免进行句柄->指针查找(目前这是一个哈希表查找,因为很多复杂的原因,很难做到基于数组的处理系统,我之前已经讲过了)。


结果是,经过很多变化之后,不知为何,Material::SetPassWithShader以进行两次句柄->指针查找而告终,即使它已经以参数形式获取到了实际着色器指针!修复它:

5.png 
引用:
[着色器库]清理并优化材质pass的应用
SetPassWithShader被添加到需要避免持久指针解引用(PPtr deref)的地方。结果,现在它总是进行*两次*m_Shader 持久指针解引用。这不是个可怕的优化。
所以清理掉它们;使Material.SetShaderPass直接持有它需要的pass,避免解引用并进入子着色器(subshader)。只缓存直接基于pass的指针;这允许移除特殊情况下针对阴影caster pass的重复代码。
VikingVillageStatic工作台项目,酷睿i75820K,主线程:Camer
.Render 7.25ms -> 6.59ms。修改17个文件,135处增加和213处删除:


好了,所以这个问题也解决了,可衡量的而且很简单的性能优化。这也使得代码库更小了,这是一件很好的事。


各处的细微调整


从上述Mac机的渲染线程性能分析中可以看出,我们自己的代码在 BindDefaultVertexArray 操作上花费了2.3%的时间,这个开销听来些过大了。原来,它遍历了所有可能的顶点组件类型并检查了一些东西;让代码仅遍历被着色器使用的顶点组件。这样稍快了些。


一个项目调用很多次 GetTextureDecodeValues ,为纹理计算一些颜色空间、HDR(高动态光照渲染)和光照贴图的解压常量。它根据一个可选的“强度倍增”(“intensity multiplier”)参数,做了一批复杂的sRGB数学运算,但该参数在所有的调用点都被设置为1.0,只有一处除外。认识到这一点,就可以消除代码中大量的pow()调用了。添加到“关注”列表:为什么我们起初非常平常频繁的调用这个函数?


一些代码,在渲染循环中计算了绘制调用(draw call)批处理的边界应该位于何处(即,在哪里切换一个新的着色器等。),比较了一些用独立的布尔值表示的状态。将它们打包为一组bit域,然后以整数进行比较。没有获得明显的性能提升,但是代码最终变了,所以也算一个胜利:)


注意,弄清哪些被物体使用的顶点缓存和顶点布局,查询了远在内存中的网格数据。根据使用类型(渲染数据、碰撞数据、动画数据等)记录数据。

6.png


而且使用@msinilo’s 出色的 CruncherSharp来减少数据填充间隙(并按照这种方式做了一些调整:),听说有一个针对Linux(pahole)的小工具。在Mac机上有struct_layout,但它处理Unity的可执行脚本以及Python脚本时,经常会因为递归溢出而失败。


浏览代码时注意到,我们跟踪每个纹理的mipmap偏置的方式,客气点说,很令人费解。它被设置每个纹理,然后纹理跟踪所有的材质属性表在哪里被使用;在任何mip偏差改变的时候通知它们,并从属性表中取出偏差,和每个纹理一起被应用,每次一个纹理被设置到图形设备。天呐,修复它。因为这个修改了我们图形API的抽象接口,这意味着要修改共计11个渲染后端;每个都只是一些细微的修改,但却让人觉得恐慌(我甚至不能局部地构建其中的一半)。不用怕,我有build farm来检查编译错误,有测试套件来检查递归!

7.png 
引用:
[图形加速设备]使纹理的mip偏置的处理更健全
如我所言,这很奇怪,因为我想要处理OpenGLpre-1.4(2002!?),
其中偏置是纹理层的一部分,而不是纹理的。或者可能时其他的什么。现在它只是纹理过滤器/重复(wrap)/各向异性(aniso)设置的一部分,只在它改变时被应用。
修改:
偏置应用到每个gfxdevice.SetTexture调用,
Texture::NotifyMipBiasChanged,
TexEnv::TextureMipBiasChanged,
TexEnvData::mipBias.
80个文件修改,228出新增,263处删除:



没有显著的性能变化,但是摆脱了所有的复杂性感觉也不错。添加到“关注”列表:我们跟踪每个纹理时,有一些相似数据;一些关于非2的整数次幂纹理的UV缩放的东西。现在我怀疑这没什么存在的必要,继续观察,可能的话将其删除。


还有一些其他类似的局部调整,每个都很简单,使一些特殊的地方变得更好一点,但是没有任何显著的性能提升。进行上百个这种调整可能带来一些明显的效果,但是更可能的是,我们需要更严谨的重新分析这些问题,以得到好的结果。


材质属性表的数据布局


令我烦恼的一件事是如何存储材质。每次我把代码库给一个新的工程师看,我会翻翻白眼说“嗨,我们在这里存储了材质纹理、矩阵、颜色等,在单独的STL映射(map)中。简直惊悚。”。


这种想法很流行:C++的STL容器在高性能代码中没有立足之地,没有好游戏曾经使用过它们(不是真的),如果你使用它你就太愚蠢了,会被人耻笑(我不知道…可能?)。所以,嗨,我用更好的数据布局替代这些映射(map)如何?肯定会让一切都快上百万倍,对吧?

在Unity中,着色器的参数可以来自两个地方:一种来自于每个材质的数据,另一种来自于“全局”着色器参数。前者中典型的是“漫反射纹理(diffuse texture)”,而后者例如“雾的颜色(fog color)”或“相机投影(camera projection)”(每个实例参数都是MaterialPropertyBlock等的形式,这有点复杂,但是现在我们先忽略那些)。


之前我们的数据布局大概是这样(PropertyName基本上都是整型):

[mw_shl_code=cpp,true]map<PropertyName, float> m_Floats;
map<PropertyName, Vector4f> m_Vectors; 
map<PropertyName, Matrix4x4f> m_Matrices; 
map<PropertyName, TextureProperty> m_Textures;
map<PropertyName, ComputeBufferID>
m_ComputeBuffers; 
set<PropertyName> m_IsGammaSpaceTag; // which properties come as sRGB[/mw_shl_code]
What I replaced it with (simplified, onlyshowing data members; dynamic_array is very much like std::vector, butmore EASTL style):

我把它替换成(简单,只展示数据成员;dynamic_array很像std::vector,但是更有EASTL 风格):

[mw_shl_code=cpp,true]
struct NameAndType { PropertyName name; PropertyType type; };  
// Data layout: 
// - Array of name+type information for lookups (m_Names). Do 
//   not put anything else; only have info needed for lookups! 
// - Location of property data in the value buffer (m_Offsets). 
//   Uses 4 byte entries for smaller data; don't use size_t! 
// - Byte buffer with actual property values (m_ValueBuffer). 
// - Additional data per-property in m_GammaProps and 
//   m_TextureAuxProps bit sets. 
// 
// All the arrays need to be kept in sync (sizes the same; all 
// indexed by the same property index). 
dynamic_array<NameAndType> m_Names; 
dynamic_array<int> m_Offsets; 
dynamic_array<UInt8> m_ValueBuffer;  

// A bit set for each property that should do gamma->linear 
// conversion when in linear color space 
dynamic_bitset m_GammaProps; 
// A bit set for each property that is aux for a texture 
// (e.g. *_ST for texture scale/tiling) 
dynamic_bitset m_TextureAuxProps;[/mw_shl_code]

向属性表添加新的属性时,它只是追加到所有的数组。属性名/类型信息和属性在数据缓存中的位置保持独立,以便查找属性时,我甚至不获取对查找本身不需要的数据。


之前,最大的外部变化就是,可以查找一个属性值并存储它的直接指针(用于预先记录材质显示列表,能够在重用显示列表前,“插入”全局着色器属性的值)。现在只要改变数组大小,指针就会失效;所以,所有可能存储了指针的代码,都需要替换为存储数据在属性表中的偏移量。这最后导致了相当多的代码修改。

8.png


查找属性的复杂度从O(logN)ss(映射查找)变成了O(N)(从名称数组中线性扫描)。如果你正在学习计算机科学中一般理论,那这种情况听起来并不太好。但是,我查看了各种项目,发现通常数量表总共只包含5到30个属性(大部分是10个左右);线性扫描内存中所有的彼此相邻的待查询数据,比起STL的映射(map)查找就不那么差了。因为STL中映射(map)的节点的位置可能彼此间隔很远(如果是这样,访问每个节点可能降低CPU缓存的命中)。从几个不同项目的分析数据来看,“查找属性”这部分功能,在电脑、笔记本和iPhone上都稍快了一些。


这个修改带来魔法般的性能提升了吗?没有。它带来的是平均帧时间的小改善,内存消耗略有降低,尤其有一大堆不同的材质时。“只是用封装的数组替换STL映射”有导致魔法般的性能提升吗?也没有,至少向别人展示代码时不必再翻白眼了。所以,就是这些了。


在进行代码审查时,有一种意见是,我应该尝试分离属性数据,以便相同类型的数据可以被分为一组。一个属性表可以知道哪些起始索引和结束索引是针对哪个类型的,那么查找指定属性就只需要扫描那个类型的名字数组(这个数组可能只包含每个属性的名字,而不是名字+类型)。向表中添加新属性可能开销更大,但是查找它们的开销会更小。


这些从侧面说明:现代CPU在那些你视为“坏代码”的地方速度很快,而且可能也有很大的缓存。我没有太关注移动CPU的硬件,只是意识到iPhone6的CPU有4兆字节的L3缓存。4兆字节啊,在一个手机上。这顶我第一台电脑的多少个RAM了!


目前的结果

这就是两周左右的工作(我估计实际只有75%的时间——其他花费在修复无关的bug,代码审查等);目前的状态是,所有的平台都构建并测试通过;下载请求已经就绪。40次提交,135个文件,将近2000行的代码修改。

9.png
引用:
背景:…-根据v1的公共反馈添加待办事项。
节约的时间大部分来自于更好的图形加速设备缓存显示列表,尤其是总在不同关键字间切换材质(例如 VikingsVillage在性能好的PC上,时间变化为12ms->8.5ms)。其他情况下速度稍有提升,但不是特别大(详情参考以上总结页中的谷歌文档链接。)似乎带部分使帧率更加稳定了,可能时因为在运行时内存分配减少了。
*在材质中缓存不止一个显示列表。
*更好的属性表数据布局(6个std::maps –>3个dynamic_array和两个bitset)。这意味着不能再存储值的指针了;将所有代码改为存储偏移。为属性表添加更多的单元测试!
*使纹理的mip偏置处理更健全,现在它只是过滤器/重复(wrap)/各向异性(aniso)的参数,而不再在循环中被跟踪,应用到每个SetTexture调用。
*SetPassWithShader是一个为了在某些地方避免持久指针解引用(PPtrderef)的优化,但是现在它总是进行两次解引用!清除掉这些。
*在前向循环中光源属性前,移除世界矩阵单位化的设置。看起来没用。
*针对线程的显示列表补丁数据的细微优化。
*OpenGL:对BindDefaultVertexArray的细微优化(GLES也同样做了优化)。
*为倍增值(multiplier)为1的情况增加特定GetTextureDecodeValues。
*根据平台floks,在android/Tizen上,网格缓存永远不会丢失。
*除了PS4上,其他平台的TextureID变回32bit。
*在某些地方使用更紧凑的结构/类成员结构。
*从着色器库(shaderlab)中移除无用的内容(如MatrixVal),并在很多地方添加注释。
*杂项:在发布的播放器上支持 –datafolder 命令行参数,在mac/linux上也是。



性能方面,一个基准项目改善了很多(最受“显示列表被重建”问题的影响),一帧的总时间,在电脑上从11.8ms降到8.5mc,在笔记本上从29.2ms降到26.9毫秒。其他项目也有改善,但效果远不及这个(大部分在电脑上从7.8ms降到7.3ms;另一个项目在iPhone上从15.2ms降到14.1ms,等等)


大部分性能提升实际来自于两个地方(显示列表重建;避免无用的哈希表查找)。我不知道该如何看待剩下的修改——总体上感觉它们都算是好的,因为现在我更好地理解了代码库,并添加了很多注释来解释是什么&为什么。我现在还有了一个更长的列表“这些地方很奇怪或者应该被改善“。


花费将近两周的时间,对我现在的结果来说是否值得?很难说。有时我真感觉一周什么都没做,所以现在的结果总比这个更好:)


总的来说,我还是不太清楚“优化工作“是否是我擅长的领域。我觉得我至少对这几件事比较擅长:1)调试难题——对问题我可以提出合理的假设和方法快速地分而治之;2)了解一些修改或一个系统的影响——其他系统会受到什么影响,有问题的交互应该或可能是什么;3)对代码库中其他人做的事有很好的总体意识——我经常可以发现几个人正在开发重叠的东西,并告诉他们“嘿,你们两个应该协调工作”。


这些是对优化有用的技能吗?我不知道。我当然不能在脑中同时处理指令延迟、执行端口和TLB缺失。但是如果我实践的话,也许可以让情况更好?谁知道呢。


不太确定下一步该怎么走;但至少有几个可能的方向:


  • 继续进行增量改进,希望大量改进的净效果会很好。单个改进让人有些失望,因为性能确实难以衡量。
  • 开始着眼大局,并寻找如何避免当前大量的已完成工作,即更严谨地“重塑“代码的结构。
  • 一些清理工作完成后,切换到帮助他人处理“多线程的内容“。
  • 优化很难!让我们多玩玩 《摇滚史密斯》 (Rocksmith)直到情况改善吧。


我想我会和其他人讨论讨论,进行上面的一项或几项,直到下次!

扫描下方二维码关注游戏蛮牛官方微信~每日都有精选干货与你分享呦~


原文链接:http://aras-p.info/blog/2015/04/04/optimizing-unity-renderer-2-cleanups/
原文作者:Aras Pranckevičius
0 0
原创粉丝点击