用好lua+unity,让性能飞起来——luajit集成篇/平台相关篇

luajit集成篇

你们都知道luajit比原生lua快,快在jit这三个字上。
但实际状况是,luajit的行为十分复杂。尤为jit并非一个简单的把代码翻译成机器码的机制,背后有不少会影响性能的因素存在。
 

1.luajit分为jit模式和interpreter模式,先要弄清楚你到底在哪一种模式下

一样的代码,在pc下可能以不足1ms的速度完成,而到了ios却须要几十ms,是由于pc的cpu更好?是,但要知道顶级ios设备的cpu单核性能已是pc级,几十甚至百倍的差距显然不在这里。
这里要了解luajit的两种运行模式:jit、interpreter
jit模式:这是luajit高效所在,简单地说就是直接将代码编译成机器码级别执行,效率大大提高(事实上这个机制没有说的那么简单,下面会提到)。然而不幸的是这个模式在ios下是没法开启的,由于ios为了安全,从系统设计上禁止了用户进程自行申请有执行权限的内存空间,所以你没有办法在运行时编译出一段代码到内存而后执行,因此jit模式在ios以及其余有权限管制的平台(例如ps4,xbox)都不能使用。
interpreter模式:那么没有jit的时候怎么办呢?还有一个interpreter模式。事实上这个模式跟原生lua的原理是同样的,就是并不直接编译成机器码,而是编译成中间态的字节码(bytecode),而后每执行下一条字节码指令,都至关于swtich到一个对应的function中执行,相比之下固然比jit慢。但好处是这个模式不须要运行时生成可执行机器码(字节码是不须要申请可执行内存空间的),因此任何平台任什么时候候都能用,跟原生lua同样。这个模式能够运行在任何luajit已经支持的平台,并且你能够手动关闭jit,强制运行在interpreter模式下。
咱们常常说的将lua编译成bytecode能够防止破解,这个bytecode是interpreter模式的bytecode,并非jit编译出的机器码(事实上还有一个在bytecode向机器码转换过程当中的中间码SSA IR,有兴趣能够看luajit官方wiki),比较坑的是可供32位版本和64位版本执行的bytecode还不同,这样才有了著名的2.0.x版本在ios加密不能的坑。
 

2.jit模式必定更快?不必定!

ios不能用jit,那么安卓下应该就能够飞起来用了吧?用脚本语言得到飞通常的性能,让我大红米也能对杠iphone!
你开心的太早了。
并非安卓不能开启jit,而是jit的行为极其复杂,对平台高度依赖,致使它在以arm为主的安卓平台下,未必能发挥出在pc上的威力,要知道luajit最初只是考虑pc平台的。
首先咱们要知道,jit到底怎么运做的。
luajit使用了一个很特殊的机制(也是其大坑),叫作trace compiler的方式,来将代码进行jit编译的。
什么意思呢?它不是简单的像c++编译器那样直接把整套代码翻译成机器码就完事了,由于这么作有两个问题:1.编译时间长,这点比较好理解;2.更关键的是,做为动态语言,难以优化,例如对于一个function foo(a),这个a究竟是什么类型,并不知道,对这个a的任何操做,都要检查类型,而后根据类型作相应处理,哪怕就是一个简单的a+b都必须这样(a和b彻底有多是两个表,实现的__add元方法),实际上跟interpreter模式就没什么区别了,根本起不到高效运行的做用;3.不少动态类型没法提早知道类型信息,也就很难作连接(知道某个function的地址、知道某个成员变量的地址)
那怎么办呢?这个解决方案能够另写一篇文章了。这里只是简单说一下luajit采用的trace compiler方案:首先全部的lua都会被编译成bytecode,在interpreter模式下执行,当interpreter发现某段代码常常被执行,好比for循环代码(是的,大部分性能瓶颈其实都跟循环有关),那么luajit会开启一个记录模式,记录这段代码实际运行每一步的细节(好比里头的变量是什么类型,猜想是数值仍是table)。有了这些信息,luajit就能够作优化了:若是a+b发现就是两个数字相加,那就能够优化成数值相加;若是a.xxx就是访问a下面某个固定的字段,那就能够优化成固定的内存访问,不用再走表查询。最后就能够将这段常常执行的代码jit化。
这里能够看到,第一,interpreter模式是必须的,不管平台是否容许jit,都必须先使用interpreter执行;第二,并不是全部代码都会jit执行,仅仅是部分代码会这样,而且是运行过程当中决定的。
 
 

3.要在安卓下发挥jit的威力,必需要解决掉jit模式下的坑:jit失败

那么说了jit怎么运做的,看起来没什么问题呀,为什么说不必定更快呢?
这里就有另外一个大坑: luajit没法保证全部代码均可以jit化,而且这点只能在尝试编译的过程当中才知道。
听起来好像没什么概念。事实上,这种状况的出现,有时是毁灭性的, 可让你的运行速度降低百倍
对,你没看错,是百倍,几ms的代码忽然飙到几百ms。
具体的感觉,能够看看uwa那篇《Unity项目常见Lua解决方案性能比较》中S3的测试数据,一个纯lua代码的用例(Vector3.Normalize没有通过c#),却出现了巨大的性能差别。
而jit失败的缘由很是多,而当你理解背后的原理后会知道,在安卓下jit失败的可能要比pc上高得多。
根据咱们在安卓下的使用来看,最多见的有如下几种,而且后面写上了应对方案。
 

3.1可供代码执行的内存空间被耗尽->要么放弃jit,要么修改luajit的代码

要jit,就要编译出机器码,放到特定的内存空间。可是arm有一个限制,就是跳转指令只能跳转先后32MB的空间,这致使了一个巨大的问题:luajit生成的代码要保证在一个连续的64MB空间内,若是这个空间被其余东西占用了,luajit就会分配不出用于jit的内存,而目前luajit会疯狂重复尝试编译,最后致使性能处于瘫痪的状态。
虽然网上有一些不修改luajit的方案( http://www.freelists.org/post/luajit/Performance-degraded-significantly-when-enabling-JIT,9),在lua中调用luajit的jit.opt的api尝试将内存空间分配给luajit,但根据咱们的测试,在unity上这样作仍然没法保证全部机器上可以不出问题,由于这些方案的原理要抢在这些内存空间被用于其余用途前所有先分配给luajit,可是ulua能够运行的时候已是程序初始化很是后期的阶段,这个时候众多的unity初始化流程可能早已耗光了这块内存空间。相反cocos2dx这个问题并很少见,由于luajit运行早,有很大的机会提早抢占内存空间。
不管从代码看仍是根据咱们的测试以及luajit maillist的反馈来看,这个问题早在2.0.x就存在,更换2.1.0依然没法解决,咱们建议,若是项目想要使用jit模式,须要在android工程的Activity入口中就加载luajit,作好内存分配,而后将这个luasate传递给unity使用。若是不肯意趟这个麻烦,那能够根据项目实际测试的状况,考虑禁用jit模式(见文章第9点)。通常来讲,lua代码越少,遇到这个问题的可能性越低。
 

3.2寄存器分配失败->减小local变量、避免过深的调用层次

很不幸的一点是,arm中可用的寄存器比x86少。luajit为了速度,会尽量用寄存器存储local变量,可是若是local变量太多,寄存器不够用,目前jit的作法是:放弃治疗(有兴趣能够看看源码中asm_head_side函数的注释)。所以,咱们能作的,只有按照官方优化指引说的,避免过多的local变量,或者经过do end来限制local变量的生命周期。
 

3.3调用c函数的代码没法jit->使用ffi,或者使用2.1.0beta2

这里要提醒一点,调用c#,本质也是调用c,因此只要调用c#导出,都是同样的。而这些代码是没法jit化的,可是luajit有一个利器,叫ffi,使用了ffi导出的c函数在调用的时候是能够jit化的。
另外,2.1.0beta2开始正式引入了trace stitch,能够将调用c的lua代码独立起来,将其余能够jit的代码jit掉,不过根据做者的说法,这个优化效果依然有限。
 
 

3.4jit遇到不支持的字节码->少用for in pairs,少用字符串链接

有很是多bytecode或者内部库调用是没法jit化的,最典型就是for in pairs,以及字符串链接符(2.1.0开始支持jit)。
具体能够看 http://wiki.luajit.org/NYI,只要不是标记yes或者2.1的代码,就不要过多使用。
 
 
 

4.怎么知道本身的代码有没有jit失败?使用v.lua

完整的luajit的exe版本都会带一个jit目录,下面有大量luajit的工具,其中有一个v.lua,这是luajit verbose mode(另外还有一个很重要的叫p.lua,luajit profiler,后面会提到),能够追踪luajit运行过程当中的一些细节,其中就能够帮你追踪jit失败的状况。
local verbo = require("jit.v")
verbo.start()
当你看到如下错误的时候,说明你遇到了jit失败
failed to allocate mcode memory,对应错误3.1
NYI: register coalescing too complex,对应错误3.2
NYI: C function,对应错误3.3(这个错误在2.1.0beta2中已经移除,由于有trace stitch)
NYI: bytecode,对应错误3.4
这在luajit.exe下使用会很正常,但要在unity下用上须要修改v.lua的代码,把全部out:write输出导向到Debug.Log里头。
 
 

5.照着luajit的偏好来写lua代码

最后,趟完luajit自己的深坑,还有一些相对轻松的坑,也就是你如何在写lua的时候,根据luajit的特性,按照其喜爱的方式来写,得到更好的性能
这里能够看咱们的另外一篇文章《luajit官方性能优化指南和注解》,里头比较详细的说明如何写出适合luajit的lua代码。
 
 

6.若是能够,用传统的local function而非class的方式来写代码

因为cocos2dx时代的推广,目前主流的lua面向对象实现(例如cocos2dx以及ulua的simpleframework集成的)都依赖metatable来调用成员函数,深刻读过luajit后就会知道,在interpreter模式下,查找metatable会产生多一次表查找,并且self:Func()这种写法的性能也远不如先cache再调用的写法:local f = Class.Func; f(self),由于local cache能够省去表查找的流程,根据咱们的测试,interpreter模式下,结合local cache和移除metatable流程,能够有2~3倍的性能差。
而luajit官方也建议尽量只调用local function,省去全局查找的时间。
比较典型的就是Vector3的主流lua实现都是基于metatable作的,虽然代码更优雅,更接近面向对象的风格(va:Add(vb)对比Vector3.Add(va, vb))可是性能会差一些
固然,这点能够根据项目的实际状况来定,没必要强求,毕竟要在代码可读性和性能间权衡。咱们建议在高频使用的对象中(例如Vector3)使用function风格的写法,而主要的代码能够继续保持class风格的写法。
 

7.不要过分使用c#回调lua,这很是慢

目前luajit官方文档(ffi的文档)中建议优先进行lua调用c,而尽量避免c回调lua。固然经常使用的ui回调由于频次不高因此通常能够放心使用,可是若是是每帧触发的逻辑,那么直接在lua中完成,比反复从lua->c->lua的调用要更快。这里有一篇blog分析,能够参考:
 
 

8.借助ffi,进一步提高luajit与c/c#交互的性能

ffi是luajit独有的一个神器,用于进行高效的luajit与c交互。其原理是向luajit提供c代码的原型声明,这样luajit就能够直接生成机器码级别的优化代码来与c交互,再也不须要传统的lua api来作交互。
咱们进行过简单的测试,利用ffi的交互效率能够有数倍甚至10倍级别的提高(固然具体要视乎参数列表而定),真可谓飞翔的速度。
而借助ffi也是能够提升luajit与c#交互的性能。原理是利用ffi调用本身定义的c函数,再从c函数调用c#,从而优化掉luajit到c这一层的性能消耗,而主要留下c到c#的交互消耗。在上一篇中咱们提到的300ms优化到200ms,就是利用这个技巧达到的。
必需要注意的是,ffi只有在jit开启下才能发挥其性能,若是是在ios下,ffi反而会拖慢性能。因此使用的时候必需要作好快关。

首先,咱们在c中定义一个方法,用于将c#的函数注册到c中,以便在c中能够直接调用c#的函数,这样只要luajit能够ffi调用c,也就天然能够调用c#的函数了html

void gse_ffi_register_csharp(int id, void* func)
{
  s_reg_funcs[id] = func;
}android

这里,id是一个你自由分配给c#函数的id,lua经过这个id来决定调用哪一个函数。ios

 

而后在c#中将c#函数注册到c中c++

[DllImport(LUADLL, CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
public static extern void gse_ffi_register_csharp(int funcid, IntPtr func);c#

public static void gse_ffi_register_v_i1f3(int funcid, f_v_i1f3 func)
{
  gse_ffi_register_csharp(funcid, Marshal.GetFunctionPointerForDelegate(func));
}api

gse_ffi_register_v_i1f3(1, GObjSetPositionAddTerrainHeight);//将GObjSetPositionAddTerrainHeight注册为id1的函数安全

 

而后lua中使用的时候,这么调用性能优化

local ffi = require("ffi")
ffi.cdef[[
int gse_ffi_i_f3(int funcid, float f1, float f2, float f3);
]]iphone

local funcid = 1
ffi.C.gse_ffi_i_f3(funcid, objID, posx, posy, posz)ide

就能够从lua中利用ffi调用c#的函数了
能够相似tolua,将这个注册流程的代码自动生成。
 
 

9.既然luajit坑那么多那么复杂,为何不用原生lua?

没法否定,luajit的jit模式很是难以驾驭,尤为是其在移动平台上的性能表现不稳定致使在大型工程中很难保证其性能可靠。那是否是干脆转用原生lua呢?

咱们的建议是,继续使用luajit,可是对于通常的团队而言,使用interpreter模式。

目前根据咱们的测试状况来看,luajit的interpreter模式夸平台稳定性足够,性能行为也基本接近原生lua(不会像jit模式有各类trace compiler带来的坑),可是性能依然比原生lua有绝对优点(平都可以快3~8倍,虽然不及jit模式极限几十倍的提高),因此在游戏这种性能敏感的场合下面,咱们依然推荐使用luajit,至少使用interpreter模式。这样项目既能够享受一个相对ok的语言性能,同时又不须要过分投入精力进行lua语言的优化。

此外,luajit原生提供的profiler也很是有用,更复杂的字节码也更有利于反破解。若是团队有能力解决好luajit的编译以及代码修改维护,luajit仍是很是值得推荐的。

不过,luajit目前的更新频率确实在减缓,最新的luajit2.1.0 beta2已经有一年没有新的beta更新(但这个版本目前看也足够稳定),在标准上也基本停留在lua5.1上,没有5.3里int64/utf8的原生支持,此外因为luajit的平台相关性极强,一旦但愿支持的平台存在兼容性问题的话,极可能须要自行解决甚至只能转用原生lua。因此开发团队须要本身权衡。但从咱们的实践状况来看,luajit使用5.1的标准再集成一些外部的int64/utf解决方法就能很好地适应跨平台、国际化的需求,并无实质的障碍,同时继续享受这个版本的性能优点。

咱们的项目,在战斗时同屏规模可达100+角色,在这样的状况下interpreter的性能依然有至关的压力。因此团队若是决定使用lua开发,仍然要注意lua和c#代码的合理分配,高频率的代码尽可能由c#完成,lua负责组装这些功能模块以及编写常常须要热更的代码。

最后,怎么打开interpreter模式?很是简单,最你执行第一行lua前面加上。

if jit then

  jit.off();jit.flush()

end

 

平台相关篇

1.精简你的lua导出,不然IL2CPP会是你的噩梦

网上已经有很是多IL2CPP致使包体积激增的抱怨,而基于lua静态导出后,因为生成了大量的导出代码。这个问题又更加严重
鉴于目前ios必须使用IL2CPP发布64bit版本,因此这个问题必需要重视,不然不但你的包体积会激增,binary是要加载到内存的,你的内存也会由于大量可能用不上的lua导出而变得吃紧。
移除你没必要要的导出,尤为是unityengine的导出。
若是只是为了导出整个类的一两个函数或者字段,从新写一个util类来导出这些函数,而不是整个类进行导出。
若是有把握,能够修改自动导出的实现,自动或者手动过滤掉没必要要导出的东西。
 
 

2.ios在没有jit的加持下,luajit的性能特性与原生lua基本一致

注意,这里说的不是“性能”一致,是“性能特性”一致。luajit不开启jit依然是要比原生lua快不少的。这里说的性能特性一致是指你能够按照原生lua的优化思路来优化luajit的非jit环境。
由于ios下没法开启jit,只能使用interpreter,由于原生lua的优化方案基本都适用于ios下使用。这时,每个a.b都意味着一次表查找,写代码的时候必定要考虑清楚,该cache的cache,该省的省。
 
 
 

3.luajit在没有开启GC64宏的状况下,不能使用超过1G的内存空间

随着如今游戏愈来愈大,对内存的消耗也愈来愈高。可是luajit有一个坑也是不少人并不知道的,就是luajit的gc不支持使用1G以上的内存空间。若是你的游戏使用了1G以上的内存,luajit极可能就会分配不出内存而后crash掉。
有没有解呢?目前有一个折中解,就是开启LUAJIT_ENABLE_GC64宏再编译luajit(这也是目前支持arm64 bytecode必须的),可是这个方法有一个大问题,就是开了这个宏就不能开启jit,目前官方并无给出解决这个问题的时间表,因此能够认为很长一段时间内这个问题都会存在(除非哪位大牛出来拯救一下)。
固然考虑到如今ios的游戏都广泛要压在300M如下的内存占用,这点并不用太担忧,除非你有很大的跨平台打算,或者面向将来两年后的主流手机设备开发。
相关文章
相关标签/搜索