(Unity) 为被 Lua 隔断的 C# 实现添加 Profiler 支持

timing

问题描述

Unity 项目在实践中往往选择使用 Lua 作为更上层的逻辑脚本。这一方面是由于 Unity 本身对热更不是很友好,用 Lua 热更灵活得多,另一方面也是简化与服务器共享代码和数据。目前多种不同的 Unity + Lua 集成方案中,实践中采用比较多的是庞巍伟同学的 slua 方案。

使用 Lua 的团队,往往倾向于“较重的”集成,也就是暴露相当大规模的引擎接口给 Lua,这样逻辑上才能有足够的自由度对游戏和引擎做全面的控制。当 C#/Lua 之间的互操作接口迅速增长到成千上万的数量时,一个重要的问题就会浮现出来:C# 和 Lua 的交互层对引擎是封闭的,很多引擎内建的工具,没有办法跨越宿主 (C#) 和脚本 (Lua) 的边界。这些受到影响的机制里,最重要的就是 Unity Profiler。

Unity Profiler 是 Unity 提供的一个有力的性能分析工具 ,能够在优化阶段有效地帮助定位瓶颈,有时也容易借机发现一些潜藏的 bug。而当我们定位到某个 Lua 函数有较大的开销(CPU 或内存 GC Alloc)时,由于跨语言边界的影响被阻拦,就难以进一步观察更多的细节。

“正常的”做法

由于 Profiler 的 API 接口也一并暴露给了脚本,正常的做法是:根据 C# 这边的有问题的调用,翻到对应的 Lua 代码,把相关的脚本逻辑读一遍,为那些潜在的开销大的逻辑添加对应的性能剖析采样 Profiler.BeginSample()/EndSample(),来定位问题代码段落,然后再翻回对应 C# 函数,再在里面加上测试代码印证我们的想法。

实际上我们知道,大多数情况下(如果不算 bug),与引擎部分相比,逻辑脚本的 CPU 开销是相对比较低的(逻辑代码里以 if 判断居多,遇到需要循环的情况都非常少,一般用不到啥非常复杂的运算——或者说在设计得当的情况下,复杂的运算都会交给底层去整块整块地做),而容易造成困扰的托管内存分配导致 GC 卡顿的内存问题,也是完全由脚本调回来的 C# 代码造成的。

这样分析下来,往往绕一圈又回到 C# 里,中间付出了大量无谓的在脚本里兜圈子的时间不说,被逼着读和分析重复性高的脚本逻辑代码,也大大增加了精力和脑力的负担。

AOP

既然知道了反正总是要从 Lua 回到 C# 的,那么有没有什么简单的办法,一劳永逸地为所有暴露给 Lua 的 C# 接口加上性能剖析采样呢?

如果能做到这一点,我们就可以无视中间脚本层 (Lua) 的干扰,在 C# 环境内解决所有问题。

俺的目光很自然地投向了 AOP (Aspect Oriented Programming),这种技术能帮我们在不用修改目标函数代码的情况下,加入我们想执行的代码(就像 Python 的 decorator 那样)。

python-deco

经过一番研究,我成功地得出以下这条结论:

现有的一些针对 C# 的 AOP 方法,在 Unity 的 mono 下,基本都跪了~~

还能不能让俺过一个快乐的儿童节了~~


在这些尝试里,最接近成功的是:使用 lambda expression 包一层,添加相关代码后,再注册给 slua,然而,slua 需要为注册进来的函数添加下面的 attribute:

    [MonoPInvokeCallbackAttribute (typeof (xxx))]

而 C# 不支持为 lambda expression 添加 attribute,所以 &_& ……

利用 slua 代码生成的简明做法

发现难以通过 C# 本身的语言机制解决问题之后,我把目光投向了 slua:既然所有的绑定代码是 slua 生成的,那么不如直接修改生成代码,把采样代码生成到接口的绑定函数里~

找到普通函数接口的生成位置

void WriteFunctionImpl( StreamWriter file, MethodInfo m, Type t, BindingFlags bf)
{
    ...
}

在一头一尾添加了对应的生成代码(BeginSample() 的参数可以直接用 MethodInfo.Name 得到正确的函数名 ),运行 slua 的 Make 生成一下,得到下面的结果(单个函数):

[MonoPInvokeCallbackAttribute(typeof(xxx))]
static public int xxx(IntPtr l) {
    try {
        Profiler. BeginSample( "xxx");

        ...

        return 1;
    }
    catch( Exception e) {
        return error( l, e);
    }
    finally {
        Profiler. EndSample();
    }
}

EndSample() 在 finally 内,保证每个出口都能正确配对。


粒度控制

接下来更进一步,我们希望有某种粒度的控制能力,只为某个关心的类生成,甚至只为该类内关心的函数生成。回到前面的函数生成所在的方法,可以看到签名:

void WriteFunctionImpl( StreamWriter file, MethodInfo m, Type t, BindingFlags bf);

其中第二个参数可以用来筛选我们关心的函数(可以跟 m.Name 比较来过滤字符串),而第三个参数 Type t 可以用来筛选对应的类(通过 if ( t == typeof(TargetClass))),这样就可以只在我们需要的时候,为特定的类和函数生成了。


对应改动在这里 (<15行)。

[完]
Gu Lu
[2016-06-02]

知识共享许可协议
本作品由Gu Lu创作,采用知识共享Attribution-NonCommercial-NoDerivatives 4.0 国际许可协议进行许可。