Unity项目如何消除JIT对卡顿的影响

发表于2016-07-19
评论1 1.87w浏览

  问题来源于某次对游戏中某性能热点的分析。我们发现在游戏中第一次做某些操作时,会有较大的一个顿卡,直觉告诉我们这里可能有资源的加载。可是一一展开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可以在任何支持CLICommon Language Infrastructure,通用语言基础结构)的环境中运行,如下图所示。感兴趣的童鞋,可以用反编译工具打开一个未加密的C#dll看一下IL的真面目,或者看一下这篇博文,解释得很清楚http://www.cnblogs.com/murongxiaopifu/p/4211964.html



  而Unity采用了Mono这种CLI作为其编译、运行C#的处理器。此时终于轮到我们的主角JIT Just In Time登场了。JITMono的一种编译机制,相对于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。由于游戏的逻辑复杂,而且引入各种组件之后(BTAGE),调用栈极其深,想靠手动搜集这些函数似乎不太可能;进一步说,以后还会遇到其他的需要预先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之后,性能热点得以解决。

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引