问题描述
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))),这样就可以只在我们需要的时候,为特定的类和函数生成了。