用好Lua+Unity 让性能飞起来—LuaJIT性能坑详解

2017年04月21日 14:56 1 点赞 0 评论 更新于 2025-11-21 21:23

导语

大家都知道LuaJIT比原生Lua快,其优势就在于“JIT”(Just-In-Time,即时编译)。但实际上,LuaJIT的行为十分复杂。JIT并非简单地将代码翻译成机器码,背后存在诸多影响性能的因素。下面,笔者将为大家逐一详细说明。

一、LuaJIT分为JIT模式和Interpreter模式,首先要弄清楚你使用的模式

同样的代码,在PC上可能不到1ms就能完成执行,而在iOS上却需要几十ms。这仅仅是因为PC的CPU更好吗?虽然有一定因素,但顶级iOS设备的CPU单核性能已达到PC级别,几十甚至上百倍的差距显然不只是CPU的问题。

要理解这个差异,我们需要了解LuaJIT的两种运行模式:JIT模式和Interpreter模式。

JIT模式

JIT模式是LuaJIT高效的关键。简单来说,它直接将代码编译成机器码并执行,从而大大提升了效率。不过,实际的机制要复杂得多,后续会详细介绍。遗憾的是,iOS系统出于安全考虑,禁止用户进程自行申请具有执行权限的内存空间。因此,无法在运行时将编译好的代码加载到内存中执行,这就导致JIT模式在iOS以及其他有权限管制的平台(如PS4、XBox)无法使用。

Interpreter模式

当无法使用JIT模式时,就可以使用Interpreter模式。该模式的原理与原生Lua相同,它并不直接将代码编译成机器码,而是先编译成中间态的字节码(bytecode)。在执行时,每执行一条字节码指令,都相当于调用一个对应的函数,因此其执行速度比JIT模式慢。但该模式的优势在于不需要在运行时生成可执行的机器码(字节码不需要申请可执行内存空间),所以可以在任何LuaJIT支持的平台上使用,并且可以手动关闭JIT模式,强制使用Interpreter模式。

我们常说将Lua编译成bytecode可以防止破解,这里的bytecode指的是Interpreter模式下的bytecode,而非JIT编译出的机器码。实际上,在bytecode向机器码转换的过程中,还有一种中间码SSA IR,有兴趣的读者可以查看LuaJIT官方WIKI。需要注意的是,可供32位版本和64位版本执行的bytecode是不同的,这就导致了著名的2.0.x版本在iOS上无法加密的问题。

二、JIT模式一定更快?不一定!

既然iOS无法使用JIT模式,那么在安卓平台上应该可以充分发挥其优势了吧?然而,事实并非如此。虽然安卓平台可以开启JIT模式,但JIT的行为极其复杂,对平台高度依赖。由于LuaJIT最初主要是为PC平台设计的,因此在以arm架构为主的安卓平台上,它未必能发挥出在PC上的性能优势。

要了解其中的原因,我们需要先了解JIT的工作原理。LuaJIT采用了一种特殊的机制——trace compiler,来进行JIT编译。这种机制与C++编译器不同,C++编译器会直接将整套代码翻译成机器码,而LuaJIT这样做会面临三个问题:

  1. 编译时间长:这一点比较容易理解,将大量代码一次性编译成机器码需要花费较多时间。
  2. 动态语言难以优化:作为动态语言,Lua在运行时才能确定变量的类型。例如,对于函数function foo(a),无法提前知道参数a的具体类型。因此,对a的任何操作都需要进行类型检查,并根据类型进行相应处理。即使是简单的a + b操作,也需要考虑ab可能是表类型并实现了__add元方法的情况。这使得在实际运行中,与Interpreter模式的效率相差不大,无法体现JIT模式的高效。
  3. 难以提前获取类型信息进行链接:很多动态类型在运行前无法确定其类型信息,也就难以进行链接操作(如确定某个函数的地址、某个成员变量的地址)。

为了解决这些问题,LuaJIT采用了trace compiler方案:首先,所有的Lua代码都会被编译成bytecode,并在Interpreter模式下执行。当Interpreter发现某段代码被频繁执行(如for循环代码,大部分性能瓶颈都与循环有关)时,LuaJIT会开启记录模式,记录这段代码实际运行的每一步细节(如变量的类型,猜测是数值还是表)。基于这些信息,LuaJIT可以进行优化。例如,如果a + b是两个数字相加,就可以优化成数值相加;如果a.xxx是访问a下面的某个固定字段,就可以优化成固定的内存访问,避免表查询。最后,将这段经常执行的代码进行JIT编译。

从这个过程可以看出,第一,无论平台是否允许JIT,都必须先使用Interpreter模式执行代码;第二,并非所有代码都会进行JIT编译,只有部分代码会在运行过程中被选择进行JIT编译。

三、要在安卓下发挥JIT的威力,必须要解决掉JIT模式下的坑:JIT失败

了解了JIT的工作原理后,可能会觉得它似乎没有问题,但实际上,JIT模式存在一个大坑:LuaJIT无法保证所有代码都能进行JIT编译,而且只有在尝试编译的过程中才能发现是否失败。

这种情况有时会对性能产生毁灭性的影响,可能会使代码的运行速度下降百倍。例如,原本只需几ms的代码,突然需要几百ms才能执行完。JIT失败的原因有很多,而且在安卓平台上,JIT失败的可能性比PC平台高得多。

根据我们在安卓平台上的使用经验,常见的JIT失败原因及应对方案如下:

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。但在Unity中,uLua开始运行时,程序已经处于初始化的后期阶段,此时众多的Unity初始化流程可能已经耗尽了这块内存空间。相比之下,Cocos - 2dx中这个问题并不常见,因为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(Foreign Function Interface)。使用ffi导出的C函数在调用时可以进行JIT编译。

另外,从2.1.0beta2版本开始,LuaJIT正式引入了trace stitch功能,可以将调用C的Lua代码独立出来,对其他可以进行JIT编译的代码进行编译。但据作者介绍,这种优化效果仍然有限。

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

有很多bytecode或者内部库调用无法进行JIT编译,最典型的是for in pairs循环和字符串连接符(2.1.0版本开始支持字符串连接符的JIT编译)。

具体可以查看http://wiki.luajit.org/NYI,只要代码没有标记为“yes”或者“2.1”,就应尽量避免过多使用。

四、怎么知道自己的代码有没有JIT失败?使用v.lua

完整的LuaJIT的exe版本会附带一个JIT目录,其中包含大量LuaJIT的工具。其中,v.lua是LuaJIT Verbose Mode工具(另外还有一个重要的工具p.lua,即luajit profiler,后续会介绍),可以追踪LuaJIT运行过程中的一些细节,包括JIT失败的情况。

当看到以下错误信息时,说明遇到了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中使用v.lua工具比较正常,但在Unity中使用时,需要修改v.lua的代码,将所有out:write输出导向到Debug.Log中。

作者信息

孟子菇凉

孟子菇凉

共发布了 3994 篇文章