luajit的ffi是一个常被大家忽略的功能,或者只被当做一个更好用的c导出库,但事实上这是一个超级性能利器。比如要实现unity中的Vector3,分别用lua table和用ffi实现,我们测试下来,内存占用是10:1,运算x+y+z的耗时也是大概8:1,优化效率惊人。代码如下:local ffi = require("ffi")ffi.cdef[[typedef struct { float x, y, z; } vector3c;]]local count = 100000local function test1() -- lua table的代码 local vecs = {} for i = 1, count do vecs[i] = {x=1, y = 2, z = 3} end local total = 0 -- gc后记录下面for循环运行时的时间和内存占用,这里省略 for i = 1, count do total = total + vecs[i].x + vecs[i].y + vecs[i].z endendlocal function test2() -- ffi的代码 local vecs = ffi.new("vector3c[?]", count) for i = 1, count do vecs[i] = {x=1, y = 2, z = 3} end local total = 0 -- gc后记录下面for循环运行时的时间和内存占用,这里省略 for i = 1, count do total = total + vecs[i].x + vecs[i].y + vecs[i].z endend为何有这么大的差距?因为lua table本质是一个hash table,在hash table访问字段固然是缓慢的并且要存储大量额外的东西。而ffi可以做到只分配xyz三个float的空间就能表示一个Vector3,自然内存占用要低得多,而且jit会利用ffi的信息,实现访问xyz的时候直接读内存,而不是像hash table那样走一次key hash,性能也高得多。不幸的是ffi只在有jit模式的时候才能有很好的运行速度,现在做手游基本都要做ios,而ios下由于只能运行解释模式,ffi的性能很差(比纯table反而更慢),仅仅内存优势得到保留,所以如果要考虑ios这样的平台,这个优化点基本可以忽略,或者只在安卓下针对少数核心代码进行优化。
4.Use plain 'for i=start,stop,step do ... end' loops.
实现循环时,最好使用简单的for i = start, stop, step do这样的写法,或者使用ipairs,而尽量避免使用for k,v in pairs(x) do
首先,直到目前最新的luajit2.1.0beta2,for k,v in pairs(t) do end是不支持jit的(即无法生成机器码运行)。至于这个坑的存在主要还是因为按kv遍历table的汇编比较难写,但至少可以知道,目前如果想高效遍历数组或者做for循环,直接使用数值做索引是最佳的方法。其次,这样的写法更利于做循环展开。
5.Find the right balance for unrolling.
循环展开,有利有弊,需要自己去平衡
在早期的c++时代,手工将循环代码展开成顺序代码是一种常见的优化方法,但是后来编译器都集成了一定的循环展开优化能力,代替手工做这种事情。而luajit本身也带有这块的优化(可以参考其实现函数lj_opt_loop),可以对循环进行展开。不过这个展开是在运行时做的,所以也有利有弊。作者举例,如果在一个两层循环中,内循环的循环次数不够10次,这个部分会被尝试展开,但是由于嵌套在外部的大循环,外部大循环可能会导致内部循环多次进入,多次展开,导致展开次数过大,最终jit会取消展开。至于这方面的性能未做深入测试,作者也只是给出了一些比较感性的优化建议(最后来了一句,You may have to experiment a bit),有了解的同学欢迎交流。
6.Define and call only 'local' (!) functions within a module.
7.Cache often-used functions from other modules in upvalues.
这两点都可以拿到一起说,即调用任何函数的时候,保证这个函数是local function,性能会更好,比如:local ms = math.sinfunction test() math.sin(1) ms(1)end这两行调用math.sin有什么区别呢?事实上math是一个表,math.sin本身就做了一次表查找,key是sin,这里消耗了一次。而math又是一个全局变量,那还要在全局表中做一次查找(_G[math])而local ms缓存过之后,math.sin查找就可以省掉了,另外,对于function上一层的变量,lua会有一个upvalue对象进行存储,在找ms这个变量的时候就只需要在upvalue对象内找,查找范围更小更快捷当然,jit化后的代码有可能会进一步优化这个过程,但是更好的办法依然是自行local缓存总之,如果某个函数只在本文件内用到,就将其local,如果是一个全局函数,用之前用local缓存一下。
原因在9中已经说明,即过多的存活着的临时变量可能会耗尽寄存器导致jit编译器无法利用寄存器做优化。这里注意live temporary variables是指存活的临时变量,假如你提前结束了临时变量的生命周期,编译器还是会知道这一点的。比如:function foo() do local a = "haha" end print(a)end这里print是会print出nil,因为a离开了do ... end就结束了生命周期,通过这种方式可以避免过多临时变量同时存活。此外,有一个很常见的陷阱,例如我们实现了一个Vector3的类型用于表达立体空间中的矢量,常常会重载他的一些元函数,比如__addVector3.__add = function(va, vb) return Vector3.New(va.x + vb.x, va.y + vb.y, va.z + vb.z)end然后我们就会在代码中大肆地使用a + b + c来对一堆的Vector3做求和运算。这其实对luajit是有很大的隐患的,因为每个+都产生了一个新的Vector3,这将会产生大量的临时变量,且不考虑这里的gc压力,光是为这些变量分配寄存器就已经十分容易出问题。所以这里最好在性能和易用性上进行权衡,每次求和如果是将结果写会到原来的表中,那么压力会小很多,当然代码的易用性和可读性上就可能要牺牲一些。
12.Do not intersperse expensive or uncompiled operations.
减少使用高消耗或者不支持jit的操作
这里要提到一个luajit文档中的属于:NYI(not yet implement),意思就是,作者还没有把这个功能做完。。luajit快是快在能把代码编译为机器码执行,但是并非所有代码都可以jit化,除了前面提到的for in pairs外,还有很多这样的东西,最常见的有:for k, v in pairs(x):主要是pairs是无jit实现的,尽可能用ipairs替代。print():这个是非jit化的,作者建议用io.write。字符串连接符:打日志很容易会写log("haha "..x)这样的方式,然后通过屏蔽log的实现来避免消耗。事实上真的可以屏蔽掉吗?然并卵。因为"haha"..x这个字符串链接依然会被执行。在2.0.x的时候这个代码还不支持jit,2.1.x虽然终于支持了,但是多余的连接字符串运算以及内存分配依然发生了,所以想要屏蔽,可以用log("haha %s", x)这样的写法。table.insert:目前只有从尾部插入才是jit实现的,如果从其他地方插入,会跳转到c实现。