• Unity下XLua方案的各值类型GC优化深度剖析


    转自:http://gad.qq.com/article/detail/25645

    前言

    Unity下的C#GC Alloc(下面简称gc)是个大问题,而嵌入一个动态类型的Lua后,它们之间的交互很容易就产生gc,各种Lua方案也把这作为性能优化的重点。这些优化说穿了其实不复杂。

    元凶在这里

    先看看这两个函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    int inc1(int i)
    {
        return i + 1;
    }
     
    object inc2(object o)
    {
        return (int)o + 1;
    }

    window下实测inc1的性能是inc2的20倍!

    差距为什么那么大?主要原因在其参数及返回的类型,inc2参数是object类型,意味着一个值类型传入(比如整数)需要boxing,具体一点就是在堆上申请一块内存,把类型信息,值拷贝进去,要使用的时候需要unboxing,也就是从刚刚那堆内存拷贝到栈上,等函数执行完毕后,这个堆内存被gc检测到已经没引用,释放该堆内存。

    20倍差距是一个参数一个返回的情况,随着这样的参数加多,差距更大。而且更糟糕的是:GC比较难控制,Unity的手游项目,GC往往是卡顿的元凶。

    目前所有lua方案针对lua和c#间交互的gc优化,或者值类型优化,其实都是在做一件事:避免inc2的情况

    C#调用Lua避免inc2

    Lua是一门动态类型语言,它的函数可以接受任意类型,任意个数的参数,返回值也是任意类型,任意个数。如果希望以一个通用接口去访问lua函数,情况会比inc2更糟糕:为了支持任意类型任意个数参数,我们可能得用可变参数;为了支持任意类型多返回值,这个接口可能需要返回一个object数组,而不是一个object。因而我们还多了两个数组要分配及释放。函数原型大致如下:

    object[]Call(params object[] args)

    因为以上原因,大多方案虽然都提供了这种方式(因为方便),但又不推荐使用。有的方案会提供无GC的用法,例如ulua如果要避免gc,得这么来:

    1
    2
    3
    4
    5
    6
    var func = lua.GetFunction("inc");  
    func.BeginPCall();
    func.Push(123456);
    func.PCall();
    int num = (int)func.CheckNumber();
    func.EndPCall();

    思路是把lua的栈操作api暴露出来,一个个参数的压栈,调用完一个个返回值的取。这些压栈和取返回值的接口都是确定类型的,换句话也就是inc1的接口。

    上面只是单参数,单返回值的情况,大多数情况代码会更繁琐。

    而slua没有找到相关的方案。

    xLua的解决办法的核心思想是:只要你告诉我要用什么参数调用,我帮你优化。

    1
    2
    3
    4
    [CSharpCallLua]
    public delegate int Inc(int i);
    Inc func= luaenv.Global.Get("inc");
    int num =  func(123456);

    1、按你所需声明一个delegate,打上CSharpCallLua标签;

    2、执行生成代码;

    3、用Table的Get接口把inc函数映射到func委托;

    4、接下来就可以愉快的使用这个delegate了。

    多复杂的参数都是和上面一样:声明,获取,使用。仅比带gc的Call接口多了一步声明,使用上和Call接口一样简单,甚至处理返回值方面更简单些,而且还额外带来强类型检查的好处。

    如果lua函数有多个返回值怎么办?

    多返回值将会映射到C#的返回值以及各输出参数,从左往右一一映射。

    除此之外,xLua还支持一个lua table映射到一个C# interface,对这个interface的属性访问会访问到lua table的对应字段,成员方法调用会调用到lua table里头的对应函数。同样的,无gc。

    这是如何做到的呢?说起来也不复杂,以lua函数映射到c# delegate为例,xLua会对声明了CSharpCallLua的delegate生成一段代码,比如Inc的生成代码会类似这样:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public int SystemInt32(int x)
    {
        //...init
        LuaAPI.lua_getref(L, _Reference);
                  
        LuaAPI.xlua_pushinteger(L, x);
        int __gen_error = LuaAPI.lua_pcall(L, 1, 1, err_func);
     
        //...error handle
        int __gen_ret = LuaAPI.xlua_tointeger(L, err_func + 1);
        LuaAPI.lua_settop(L, err_func - 1);
        return  __gen_ret;
    }

    Get方法返回的委托将会指向这个方法。从这段代码来看,和ulua无gc代码类似,不同的是,别人家得手写,而且由于xLua少了一层封装,直接调用Lua的api,应该也更高效些。

    复杂值类型优化

    从C#到lua的复杂对象传递说起

    lua虚拟机,对于.net就是非托管代码,要传递对象过去,得解决几个问题:

    1、lua使用该对象期间,该对象不能被gc;

    2、如果非托管代码(lua)回调托管代码(c#),当回传该对象的引用时,应该正确找到对应的对象;

    3、重复传递一个对象,在unmanaged code测的引用表示最好是一致的;

    问题1和问题2 官方给的方案是pined对象,实测pined一个对象以及释放的性能大致和Dictionary的Set/Get相当,而问题1和问题2可以优化为数组操作,性能可以比Pined方案高4~5倍:接受一个对象,在一个数组上找到一个空位置放进去,返回数组的下标作为对象引用。通过链表组织空位置,空位置查找可以优化成O(1)操作,而通过引用找对象当然也是O(1)。

    问题3没啥好的解决办法,用Dictionary建立对象到引用的索引。

    复杂值类型的困境

    C#一切都是对象,自然也包括值类型,也能沿用上面的方案,这功能上没问题,性能却遭遇了滑铁卢:

    每一次值类型放入对象池(指的是前面一节中提到的为了解决3个问题而做的一套机制)中就会碰到inc2的情况,会boxing成一个新对象,还有入池的一系列操作。有人会问用pined方案会不会没这问题,其实是一样的,值类型是在栈上,而pined了之后要从栈转到堆上,栈转堆还是会有类似的过程:分配堆内存,拷贝,用完释放。

    这问题比前面那问题影响面更广,只要C#往lua传递一个复杂值类型就会出现,比如普普通通的Vector3四则运算会产生大量的gc。

    ulua和slua思路是一样的,对特定的几个U3D值类型(Vector2, Vector3,Vector4,Quaternion)做硬编码优化,以Vector3为例:

    1、用lua重新实现了Vector3的所有方法;

    2、C#的Vector3传入lua:是先在lua侧建一个luatable,把待传入Vector3的x,y,z设置为对应字段;设置该table的metatable为1的方法实现;

    3、lua回传Vector3到C#:C#构建一个Vector3后,取出对应table的x,y,z字段赋值到Vector3;

    xLua的复杂值类型优化

    上面的优化存在一些问题:需要增加一种新的值类型十分困难,所以目前为止采用这种方案能支持的值类型手指头就能数得过来,用户自定义的struct就更不可能支持了,核心代码深度耦合这几个类型也是不合理的。还有个比较严重的问题:xLua作者比较抗拒硬编码这种行为。

    让我们思考一下,ulua和slua那种优化能避免gc的本质是什么?还有简单值类型从C#传递到lua也没产生gc,原因是什么?

    答案就是:值拷贝

    ulua和slua的复杂值类型优化,从C#传递到lua本质上是把Vector3值拷贝到lua table,避免了入池进而避免了inc2;简单值类型也是,一个c#的int传入lua,也是直接把int值拷贝到lua的栈上。

    明白了这个思路就开阔很多,xLua设计了一套新的值类型方案,只要一个struct里头只包含值类型,可嵌套struct,当然,要求被嵌套的struct也只包含值类型,该方法都适用。

    原理也不复杂:

    1、生成struct的值拷贝代码,用于把struct里头各字段拷贝到一块非托管内存(Pack函数),以及从非托管内存拷贝出各字段的值(UnPack函数);

    2、c#传struct到lua:调用lua的api,申请一块userdata(对于c#来说是非托管代码),调用Pack把struct打包进去;

    3、lua回传到给c#:调用UnPack解出struct;

    4、struct的方法还是沿用c#原本的实现;

    说穿了,就和pb类似,把c#的数据结构序列化到一块内存以及从内存反序列化回来。

    先说这方案的缺点:

    缺点源于这个方案调用struct的方法还是调用原来C#的实现。从lua经C语言,再经pinvoke调用到C#,这个适配的成本已经远远大于一些简单方法执行的开销。当然,xLua只是默认调用C#的实现,也不是必须的,xLua提供了不经过C#,在C就直接读取更改struct字段的API,比较勤快的童鞋利用这API,可以尝试把需要高性能的地方用Lua实现,这就避免了lua和C#间的适配成本。

    PS一下:网上很流行的lua方案性能用例,用Vector3.Normalize来测试lua调用c#静态函数的性能,甚至Unity官方发的测评都用这个用例。由前面的分析可以知道,这是不对的,这些被测方案的Vector3.Normalize都仅在lua里头跑,压根没测试到“lua调用c#静态函数”。

    这方案优点:

    1、支持的struct类型宽泛的多,用户要做的事情也很简单,声明一下要生成代码即可(GCOptimize),之所以要声明,主要是避免生成代码过多;

    2、相比table方案更省内存,只是struct的大小加上一个头部,而64位下空table就80字节+,实际测试Vector3的userdata方案的内存占用是table方案三分之一;

    其它值类型GC优化

    下面大多数优化都只在xLua有效,可以在其05_NoGc示例看到用法,生成代码后运行在profiler看你效果。

    1、枚举类型传递无GC;

    2、decimal不丢失精度而且无GC;

    3、所有无GC的类型,它的数组访问没有GC,这个貌似大多数方案都做到;

    4、能被GCOptimize优化的struct,在Lua可以直接传一个对应结构的table,无GC;

    5、LuaTable提供一系列泛化Get/Set接口,可以传递值类型而无GC;

    6、一个interface加入到CSharpCallLua后,可以用table来实现这个interface,通过这interface访问table无GC;

    这些优化和前面介绍的两大思路一脉相承,可以通过源代码看其实现,这就不分析了。

  • 相关阅读:
    JQuery对id中含有特殊字符的转义处理
    jquery 将disabled的元素置为enabled的三种方法
    jeecg表单页面控件权限设置(请先看官方教程,如果能看懂就不用看这里了)
    Google调用explorer.exe打开本地文件
    C++ URLDecode和URLEncode实现——仅限gb2312,非utf8
    jeecg小吐槽续——自己折腾修改在线开发功能中“默认值”的使用
    jeecg小吐槽
    vue使用vant时间日期选择器,日期转化
    vue获取图片宽高
    微信公众号h5用户授权
  • 原文地址:https://www.cnblogs.com/jgsbwcx/p/8981476.html
Copyright © 2020-2023  润新知