Unity项目如何消除JIT对卡顿的影响
问题来源于某次对游戏中某性能热点的分析。我们发现在游戏中第一次做某些操作时,会有较大的一个顿卡,直觉告诉我们这里可能有资源的加载。可是一一展开Profiler堆栈,发现并没有任何Load相关的函数。好吧,于是进一步对函数体插桩进行分析,最终定位到的一些函数实现都非常的简单,不应该会产生这么大的事件消耗。无奈之下,观察了第二次做同样操作的profiler数据,发现同一个函数实现(注意,无资源加载),在第一次和第二次的时间消耗上有很大的差别。这与我们的常识不符合,同样的函数实现,同样的指令,为什么时间消耗有如此大的差别呢?
从下图可以看到,两次执行相同操作,在profiler中的消耗天差地别。从中选取一个非常简单的函数Bullet.SetOwern,可以看到第一次调用消耗0.04ms,第二次几乎无消耗了。
1 2 3 4 5 6 7 8 9 | public void SetOwner(Actor _owner) { m_creater.Actor = _owner; m_creater.Position = _owner.cachedTM.position; if (_owner.MoveHelper != null ) { m_creater.PvPSn = _owner.MoveHelper.PvPSn; } } |
此时我们怀疑是JIT机制在作祟。什么是JIT呢?
大家知道,C#是目前Unity使用最广泛的脚本语言,而C#首先会被翻译成CIL(Common Intermediate Language,通用中间语言,因为是微软开发,也称为MSIL,或简称IL)。而CIL可以在任何支持CLI(Common Language Infrastructure,通用语言基础结构)的环境中运行,如下图所示。感兴趣的童鞋,可以用反编译工具打开一个未加密的C#的dll看一下IL的真面目,或者看一下这篇博文,解释得很清楚http://www.cnblogs.com/murongxiaopifu/p/4211964.html。
而Unity采用了Mono这种CLI作为其编译、运行C#的处理器。此时终于轮到我们的主角JIT – Just In Time登场了。JIT是Mono的一种编译机制,相对于Mono的另一种编译机制AOT-Ahead Of Time来说,是一种更加灵活的处理方式。AOT是在编译期,就将所有的IL直接编译成机器可以识别的机器码,运行时直接执行机器码即可;而JIT,则是在运行时才将IL编译成机器码,这么做的好处,是可以实现代码的热更新,但是同时带来的,则是在首次运行时产生的编译消耗。
有了这个猜想之后,我们做了一个简单的测试,在游戏一开始运行的时候,将整个Assembly-CSharp.dll里面的所有函数全部找出,并且尝试访问一下这些函数,目的是提早触发JIT。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | private void WarmUpCode() { Assembly ass = Assembly.GetExecutingAssembly(); Type[] types = ass.GetTypes(); for ( int i = 0, imax = types.Length; i < imax; ++i) { Type type = types[i]; MethodInfo[] methods = type.GetMethods(BindingFlags.DeclaredOnly | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static); for ( int j = 0, jmax = methods.Length; j < jmax; ++j) { MethodInfo method = methods[j]; if (!method.IsGenericMethod && !method.IsAbstract && !method.IsConstructor) { method.MethodHandle.GetFunctionPointer(); } } } } |
再次测试之后,性能热点消失了。不过随后就发现,预先JIT的时间消耗很大,即使在高端机上(我们在三星NOTE3上测试),也需要消耗8秒左右。因为每次启动游戏,都需要消耗8秒,这个对用户体验来说是个很大的伤害。
既然不能再使用这种量大面广的地图炮,我们只能尝试精确打击。要精确打击,就需要搜集到在性能热点发生时进行JIT的函数,然后只对这些函数所在的类(Type)进行预先JIT。由于游戏的逻辑复杂,而且引入各种组件之后(BT和AGE),调用栈极其深,想靠手动搜集这些函数似乎不太可能;进一步说,以后还会遇到其他的需要预先JIT的地方,手动搜集费时费力,也不具备可重用性。
此时我们又想到了Mono,因为JIT是在Mono中进行的,那么我们是否可以从Mono中尝试获取JIT相关的信息呢?
打开Mono的源代码搜索关键字“jit”,果然找到了一些相关的函数,经过筛选,锁定了一个函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | /** * mono_compile_method: * @method: The method to compile. * * This JIT-compiles the method, and returns the pointer to the native code * produced. */ gpointer mono_compile_method (MonoMethod *method) { char temp[512]; if (!default_mono_compile_method) { g_error ( "compile method called on uninitialized runtime" ); return NULL; } return default_mono_compile_method (method); } |
从函数的注释中也可以看出,这里是JIT的必经之路,并且也可以通过参数MonoMethod获取到我们想要知道的函数信息。说干就干,在函数中添加了数据采集的代码,果然可以获取到JIT的信息了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | /** * mono_compile_method: * @method: The method to compile. * * This JIT-compiles the method, and returns the pointer to the native code * produced. */ gpointer mono_compile_method (MonoMethod *method) { char temp[512]; if (!default_mono_compile_method) { g_error ( "compile method called on uninitialized runtime" ); return NULL; } if (strcmp (method->klass->name, "Object" ) != 0) { if (method->klass->name_space != NULL && strcmp(method->klass->name_space, "" ) != 0) { sprintf(temp, "node: {%s@%s.%s}n" , method->klass->image->assembly_name, method->klass->name_space, method->klass->name); } else { sprintf(temp, "node: {%s@%s}n" , method->klass->image->assembly_name, method->klass->name); } jitTracker_output(temp); } return default_mono_compile_method (method); } |
但是此时获取到的仍旧是全部的JIT信息,还不符合我们精确打击的要求。原理上说,我们需要在性能热点发生时,搜集 JIT的信息,很自然的,我们需要添加一个标志位来判定是否采集。同时在C#中,我们调用接口来开关这个标志位,就可以实现精确打击的目标了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | void unity_mono_enable_jit_dump(boolean flag) { gEnableLeakDetect=flag; } [DllImport(“mono”)] public static extern void unity_mono_enable_jit_dump ( bool flag); void PerformanceBottleneck() { unity_mono_enable_jit_dump( true ); // BottleneckCode //… //… unity_mono_enable_jit_dump( false ); } |
经过精确的定位,最终找到100+个type,对这些type做了预先JIT之后,性能热点得以解决。