用好Lua+Unity,让性能飞起来——Lua与C#交互篇

用好Lua+Unity,让性能飞起来——Lua与C#交互篇

整合Lua是目前最强大的Unity热更新方案,毕竟这是唯一可以支持iOS热更新的办法。然而作为一个重度uLua用户,我们踩过了很多坑才将uLua上升到一个可以在项目中大规模使用的状态。事实上即使到现在,Lua+Unity的方案仍不能肆意地被使用。要用好,你需要知道很多。在看了UWA之前发布的《Unity项目常见Lua解决方案性能比较》一文后,笔者决定动手写一下关于Lua+Unity方案的性能优化技巧。


◆◆

从最早的Lua纯反射调用C#,以及云风团队尝试的纯C#实现的Lua虚拟机,一直发展到现在的各种Luajit+C#静态Lua导出方案,Lua+Unity才算达到了性能上实用的级别。但即使这样,实际使用中我们会发现,比起Cocos2dx时代Luajit的发扬光大,现在Lua+Unity的性能依然存在着相当大的瓶颈。仅从《Unity项目常见Lua解决方案性能比较》的test1就可以看到,iPhone 4S下二十万次Position赋值就已经需要3000ms,如果是COC这样类型的游戏,不处理其他逻辑,一帧仅仅上千次位置赋值(比如数百的单位、特效和血条)就需要15ms,这显然有些偏高。是什么导致Lua+Unity的性能并未达到极致,要如何才能更好地使用?我们将结合一些例子逐步挖掘其背后的细节。

由于我们项目主要使用的是uLua(集成了topameng的CsToLua,但是由于持续的性能改进,后面已经做过大量的修改),本文的大部分结论都是基于uLua+CsToLua的测试得出来的,sLua都是基于其源码来分析(根据我们分析的情况来看,两者原理上基本一致,仅在实现细节上有一些区别),但没有做过深入测试,如有问题的话欢迎交流。

既然是Lua+Unity,那性能好不好,基本上要看两大点:

1、Lua跟C#交互时的性能如何
2、纯Lua代码本身的性能如何

因为这两部分都有各自需要深入探讨的细节,所以我们会分为多篇去探讨整个Lua+Unity到底如何进行优化。


◆◆

Lua与C#交互篇

一、 从致命的gameobj.transform.position = pos说起
像gameobj.transform.position = pos这样的写法,在Unity中是再常见不过的事情。但是在uLua中,大量使用这种写法是非常糟糕的。为什么呢?

因为短短一行代码,却发生了非常非常多的事情,为了更直观一点,我们把这行代码调用过的关键Lua API以及uLua相关的关键步骤列出来(以uLua+CsToLua导出为准,gameobj是GameObject类型,pos是Vector3):

请输入图片描述

就这么一行代码,竟然做了这么一大堆的事情!如果是C++,a.b.c = x这样经过优化后无非就是拿地址然后内存赋值的事。但是在这里,频繁的取值、入栈、C#到Lua的类型转换,每一步都是满满的CPU时间,还不考虑中间产生了各种内存分配和后面的GC!

下面我们会逐步说明,其中有一些东西其实是不必要的,可以省略的。我们可以最终把他优化成:lua_isnumber + lua_tonumber 4次,全部完成。

二、在Lua中引用C#的Object,代价昂贵
从上面的例子可以看到,仅仅想从gameobj拿到一个transform,就已经有很昂贵的代价。C#的Object,不能作为指针直接供c操作(其实可以通过GCHandle进行pinning来做到,不过性能如何未测试,而且被pinning的对象无法用GC管理),因此主流的Lua+Unity都是用一个ID表示C#的对象,在C#中通过dictionary来对应ID和object。同时因为有了这个dictionary的引用,也保证了C#的object在Lua有引用的情况下不会被垃圾回收掉。

因此,每次参数中带有object,要从Lua中的ID表示转换回C#的object,就要做一次dictionary查找;每次调用一个object的成员方法,也要先找到这个object,也就要做dictionary查找。

如果之前这个对象在Lua中有用过而且没被GC,那还就是查下dictionary的事情。但如果发现是一个新的在Lua中没用过的对象,那就是上面例子中那一大串的准备工作了。

如果你返回的对象只是临时在Lua中用一下,情况更糟糕!刚分配的userdata和dictionary索引可能会因为Lua的引用被GC而删除掉,然后下次你用到这个对象又得再次做各种准备工作,导致反复的分配和GC,性能很差。

例子中的gameobj.transform就是一个巨大的陷阱,因为.transform只是临时返回一下,但是你后面根本没引用,又会很快被Lua释放掉,导致你后面每次.transform一次,都可能意味着一次分配和GC。

三、在Lua和C#间传递Unity独有的值类型(Vector3/Quaternion等)更加昂贵
既然前面说了Lua调用C#对象缓慢,如果每次vector3.x都要经过C#,那性能基本上就处于崩溃了,所以主流的方案都将Vector3等类型实现为纯Lua代码,Vector3就是一个{x,y,z}的table,这样在Lua中使用就快了。

但是这样做之后,C#和Lua中对Vector3的表示就完全是两个东西了,所以传参就涉及到Lua类型和C#类型的转换,例如C#将Vector3传给Lua,整个流程如下:

1. C#中拿到Vector3的x、y、z三个值;
2. Push这3个float给Lua栈;
3. 然后构造一个表,将表的x,y,z赋值;
4. 将这个表push到返回值里。

一个简单的传参就要完成3次push参数、表内存分配、3次表插入,性能可想而知。那么如何优化呢?

我们的测试表明,直接在函数中传递三个float,要比传递Vector3要更快。例如void SetPos(GameObject obj, Vector3 pos)改为void SetPos(GameObject obj, float x, float y, float z)。具体效果可以看后面的测试数据,提升十分明显。

四、Lua和C#之间传参、返回时,尽可能不要传递以下类型:

严重类: Vector3/Quaternion等Unity值类型,数组
次严重类:bool string 各种object
建议传递:int float double

虽然是Lua和C#的传参,但是从传参这个角度讲,Lua和C#中间其实还夹着一层C(毕竟Lua本身也是C实现的),Lua、C、C#由于在很多数据类型的表示以及内存分配策略都不同,因此这些数据在三者间传递,往往需要进行转换(术语parameter mashalling),这个转换消耗根据不同的类型会有很大的不同。

先说次严重类中的 bool和 string类型,涉及到C和C#的交互性能消耗,根据微软官方文档,在数据类型的处理上,C#定义了Blittable Types和Non-Blittable Types,其中bool和string属于Non-Blittable Types,意思是他们在C和C#中的内存表示不一样,意味着从C传递到C#时需要进行类型转换,降低性能,而string还要考虑内存分配(将string的内存复制到托管堆,以及utf8和utf16互转)。大家可以参考https://msdn.microsoft.com/zh-cn/library/ms998551.aspx,这里有更详细的关于C和C#交互的性能优化指引。

而严重类,基本上是uLua等方案在尝试Lua对象与C#对象对应时的瓶颈所致。
Vector3等值类型的消耗,前面已经有所提及。

而数组则更甚,因为Lua中的数组只能以table表示,这和C#下完全是两码事,没有直接的对应关系,因此从C#的数组转换为Lua table只能逐个复制,如果涉及object/string等,更是要逐个转换。

五、频繁调用的函数,参数的数量要控制
无论是Lua的pushint/checkint,还是C到C#的参数传递,参数转换都是最主要的消耗,而且是逐个参数进行的,因此,Lua调用C#的性能,除了跟参数类型相关外,也跟参数个数有很大关系。一般而言,频繁调用的函数不要超过4个参数,而动辄十几个参数的函数如果频繁调用,你会看到很明显的性能下降,手机上可能一帧调用数百次就可以看到10ms级别的时间。

六、优先使用static函数导出,减少使用成员方法导出
前面提到,一个object要访问成员方法或者成员变量,都需要查找Lua userdata和C#对象的引用,或者查找metatable,耗时甚多。直接导出static函数,可以减少这样的消耗。

像obj.transform.position = pos。我们建议的方法是,写成静态导出函数,类似

class LuaUtil{
  static void SetPos(GameObject obj, float x, float y, float z){obj.transform.position = new Vector3(x, y, z); }
}

然后在Lua中LuaUtil.SetPos(obj, pos.x, pos.y, pos.z),这样的性能会好非常多,因为省掉了transform的频繁返回,而且还避免了transform经常临时返回引起Lua的GC。

七、注意Lua拿着C#对象的引用时会造成C#对象无法释放,这是内存泄漏常见的起因
前面说到,C# object返回给Lua,是通过dictionary将Lua的userdata和C# object关联起来,只要Lua中的userdata没回收,C# object也就会被这个dictionary拿着引用,导致无法回收。最常见的就是gameobject和component,如果Lua里头引用了他们,即使你进行了Destroy,也会发现他们还残留在mono堆里。不过,因为这个dictionary是Lua跟C#的唯一关联,所以要发现这个问题也并不难,遍历一下这个dictionary就很容易发现。uLua下这个dictionary在ObjectTranslator类、SLua则在ObjectCache类。

八、考虑在Lua中只使用自己管理的ID,而不直接引用C#的Object
想避免Lua引用C# Object带来的各种性能问题的其中一个方法就是自己分配ID去索引Object,同时相关C#导出函数不再传递Object做参数,而是传递int。这带来几个好处:

1. 函数调用的性能更好;
2. 明确地管理这些Object的生命周期,避免让ULua自动管理这些对象的引用,如果在Lua中错误地引用了这些对象会导致对象无法释放,从而内存泄露;
3. C#Object返回到Lua中,如果Lua没有引用,又会很容易马上GC,并且删除ObjectTranslator对Object的引用。自行管理这个引用关系,就不会频繁发生这样的GC行为和分配行为。

例如,上面的LuaUtil.SetPos(GameObject obj, float x, float y, float z)可以进一步优化为LuaUtil.SetPos(int objID, float x, float y, float z)。然后我们在自己的代码里头记录objID跟GameObject的对应关系,如果可以,用数组来记录而不是dictionary,则会有更快的查找效率。如此下来可以进一步省掉Lua调用C#的时间,并且对象的管理也会更高效。

九、合理利用out关键字返回复杂的返回值
在C#向Lua返回各种类型的东西跟传参类似,也是有各种消耗的。比如 Vector3 GetPos(GameObject obj) 可以写成 void GetPos(GameObject obj, out float x, out float y, out float z)。表面上参数个数增多了,但是根据生成出来的导出代码(我们以uLua为准),会从:LuaDLL.tolua_getfloat3(内含get_field + tonumber 3次) 变成 isnumber + tonumber 3次。get_field本质上是表查找,肯定比isnumber访问栈更慢,因此这样做会有更好的性能。


◆◆◆

实测

好了,说了这么多,不拿点数据来看还是太晦涩,为了更真实地看到纯语言本身的消耗,我们直接没有使用例子中的gameobj.transform.position,因为这里头有一部分时间是浪费在Unity内部的。

我们重写了一个简化版的GameObject2和Transform2。

class Transform2{
  public Vector3 position = new Vector3();
}
class GameObject2{
   public Transform2 transform = new Transform2();
}

然后我们用几个不同的调用方式来设置transform的position

方式1:gameobject.transform.position = Vector3.New(1,2,3)
方式2:gameobject:SetPos(Vector3.New(1,2,3))
方式3:gameobject:SetPos2(1,2,3)
方式4:GOUtil.SetPos(gameobject, Vector3.New(1,2,3))
方式5:GOUtil.SetPos2(gameobjectid, Vector3.New(1,2,3))
方式6:GOUtil.SetPos3(gameobjectid, 1,2,3)

分别进行100万次,结果如下(测试环境是Windows版本,CPU是i7-4770,luajit的jit模式关闭,手机上会因为luajit架构、IL2CPP等因素干扰有所不同,但这点我们会再进一步阐述):

请输入图片描述
方式1:903ms
方式2:539ms
方式3:343ms
方式4:559ms
方式5:470ms
方式6:304ms

可以看到,每一步优化,都是提升明显的,尤其是移除.transform获取以及Vector3转换提升更是巨大,我们仅仅只是改变了对外导出的方式,并不需要付出很高成本,就已经可以节省66%的时间。

实际上能不能再进一步呢?还能!在方式6的基础上,我们可以再做到只有200ms!这里卖个关子,我们将在luajit集成中进行进一步讲解。一般来说,我们推荐做到方式6的水平已经足够。

这只是一个最简单的案例,有很多各种各样的常用导出(例如GetComponentsInChildren这种性能大坑,或者一个函数传递十几个参数的情况)都需要大家根据自己使用的情况来进行优化,有了我们提供的Lua集成方案背后的性能原理分析,应该就很容易去考虑怎么做了。


附测试用例的C#代码

public class Transform2
{
    public Vector3 position = new Vector3();
}

public class GameObject2
{
    public Transform2 transform = new Transform2();
    public void SetPos(Vector3 pos)
    {
        transform.position = pos;
    }

    public void SetPos2(float x, float y, float z)
    {
        transform.position.x = x;
        transform.position.y = y;
        transform.position.z = z;
    }
}
 
public class GOUtil
{
    private static List<GameObject2> mObjs = new List<GameObject2>();
    public static GameObject2 GetByID(int id)
    {
        if(mObjs.Count == 0)
        {
            for (int i = 0; i < 1000; i++ )
            {
                mObjs.Add(new GameObject2());
            }
        }

        return mObjs[id];
    }

    public static void SetPos(GameObject2 go, Vector3 pos)
    {
        go.transform.position = pos;
    }

    public static void SetPos2(int id, Vector3 pos)
    {
        mObjs[id].transform.position = pos;
    }

    public static void SetPos3(int id, float x, float y ,float z)
    {
        var t = mObjs[id].transform;
        t.position.x = x;
        t.position.y = y;
        t.position.z = z;
    }

    }

感谢来自深圳游戏科学的招文勇供稿(QQ: 1490582806,博客:http://www.cnblogs.com/zwywilliam/)。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

同时,作者也是U Sparkle活动参与者,UWA欢迎更多开发朋友加入U Sparkle开发者计划,这个舞台有你更精彩!

  • 谭俊孟 发表在 2018年04月27日 回复

    这篇文章大部分有误。我的测试环境:Unity2017.2.1.f1,tolua版本2018年4月4日。
    因为这里不好放代码,我就直接干说了,有怀疑的朋友可以尝试自己写测试用例测一下。

    1.最主要的错误就是单单以transform.position = pos,来概括调用属性、方法的效率是不太合适的,比如这篇文章说封装一个GetPos配合out关键字,但在测试结果中,local pos = transform.position是最更快的。
    2.SetPos的测试代码写的不够公允,因为transform.position = Vector3.New(1,2,3),不光包括赋值,还包括Vector3.New和new Vector3的对比,这样测试是有问题。
    3.接着第2个问题说,既然咱们是追求效率,那么不应该是new或者.New(),应该是:Set()和.Set(),当定义了一个local vector3_Temp = Vector3.New(),之后都用vector3_Temp:Set(x, y, z),然后transform.position = vector3_Temp,比封装后的SetPos效率要高。

    当然,这篇文章也提出了很正确的地方,方法调用,参数越简单,效率越高

    • 招文勇 发表在 2018年04月27日 回复

      我是本文的作者,没想到这篇年代久远的文章被翻出来:),非常感谢谭兄给出的研究结论,关于这些疑惑我来解答一下(虽然个人认为蒙大比我更有发言权:p)
      1.文章确实没有考虑到对比使用预分配的Vector3先Set再赋值的情况,谢谢指出。但关于是否更优,下面我会进一步说明。
      2.文章是2016年底写的,使用的是ulua(不是tolua#)2016年大概9月份的版本,确实跟不上最新的发展。比如最新的tolua#里,Vector3.New和Vector3:Get都有调整(限于时间,没有深入对比代码和测试,可参考蒙大自己的测试数据),对直接的Vector3传输会有提升。
      3.是否使用jit模式会极大影响Vector3.lua的运行效率,由于最新的tolua#使用了luajit2.1b3,已支持64bit jit,这在编辑器和安卓测试时会极大提升Vector3.lua的运行效率。而我们考虑到jit到手机上有大量的问题(在我另一篇文章有提及),我们建议只考虑关闭jit的环境。
      4.根据谭兄提供的方法,我用ulua的版本做了一下测试,分别是vector3:Set(1,2,3)+transform.position=vector3对比GOUtil.Set(objID,2,3,4),结果是GOUtil那个版本快一点点,分别330ms和310ms。但根据tolua#最新版本的情况,前者性能更快是完全有可能的。
      5.文章中确实存在两处错误,包括tolua_getfloat3中没有用到getfield;Vector3 GetPos的导出代码是pushfloat3而不是getfloat3。不过这两个问题不影响在ulua上的整体结论。
      6.关于用例设计是否科学客观,确实限于篇幅和观察的角度(比如我认为大部分人喜欢直接写Vector3.New,更具代表性),比较难以全面覆盖,比如用例没有设计访问Vector3.x/y/z(三次表hash table访问,比较耗),比如对Vector3.Set进行local cache,没讨论将Vector3替换为数组而不是xyz的访问方式(数组访问效率优于hashtable),这些都可以认为是影响客观性的漏洞。但文章的主旨是通过文字解释底层的机制,强调每一个写法的区别以及背后的原理。测试是为了配合给出直观的对比以及强调所列出的哪个范式更优。因此也必然存在更优的使用方法,但主要还是以科普为主。如果能深入理解这些机制,自行优化的空间是非常多的,甚至颠覆这个框架的基础都是可以考虑的