luajit官方性能优化指南和注解

luajit是目前最快的脚本语言之一,不过深刻使用就很快会发现,要把这个语言用到像宣称那样高性能,并非那么容易。实际使用的时候每每会发现,刚开始写的一些小test case性能很是好,常常毫秒级就算完,但是代码复杂度一上去了,动辄几十上百毫秒的状况就会出现,性能表现很是飘忽。
为此luajit的mailling list也是有很多人咨询,做者mike pall的一篇比较完整的回答被放在了官方wiki上:
 
 
不过原文说了不少怎么作,却基本没有解释为何。
 
因此这篇文章不是简单的翻译官方这个优化指南,最主要仍是让你们了解luajit背后的一些原理,由于原文中只有告诉你怎么作,却没说清楚为何,致使作了这些优化,到底影响多大,缘由是啥,十分模糊。了解背后的缘由每每对咱们有很大的帮助。
另外,原生lua、luajit的jit模式(pc和安卓可用)、luajit的interpreter模式(ios下只能运行这个),他们执行lua的原理是有很大的不一样的,也致使一些lua优化技巧并不见得是通用的。而这篇文章主要针对luajit的jit模式。
 
 

1.Reduce number of unbiased/unpredictable branches.

减小不可预测的分支代码

分支代码就是根据条件会跳转的代码(最典型就是if..else),那什么是不可预测的分支代码?简单说:
if 条件1 then
elseif 条件2 then
假如条件1或者条件2其中一方达成的几率很是高(>95%),那咱们认为这是可预测的分支代码。
这是被mike pall放到第一位的性能优化点(事实上确实应该如此),究其缘由是luajit使用了trace compiler的特性,为了生成的机器码尽量高效,它会根据代码的运行状况进行一些假设,好比上面的例子若是luajit发现,条件2的达成几率很是高,那么luajit会生成按条件2达成执行最快的代码。
有一点可能你们会问,luajit真的能知道运行过程当中的一些状况?
是的
这也是trace compiler的特征:先运行字节码,针对热点代码作profile,了解了能够优化的点后再优化出最高效的机器码。这就是luajit目前的作法。
为何要这样呢?给一个比较好理解的例子:luajit是动态类型语言,面对一个a+b,你根本不知道a和b是什么类型,若是a+b只是两个整数相加,那么编译机器码作求和速度天然是飞快的。但是若是你没法确认这点,结果你只能假定它是任意类型,先去动态检查类型(看看究竟是两个表,仍是两个数值,甚至是其余状况),再跳根据类型作相应的处理,想一想都知道比两个整数相加慢了几十倍。
因此luajit为了极限级的性能,就会大胆进行假设,若是发现a+b就是两个数值相加,就编译出数值求和的机器码。
可是若是某一时刻a+b不是数值相加,而是变成了两个表相加呢?这机器码岂不是就致使错误了?所以每次luajit作了假设时,都会加上一段守护代码(guard),检查假设是否是对的,若是不对,就会跳转出去,再根据状况,来决定要不要再编译一段新的机器码,来适配新的状况。
这就是为何你的分支代码必定要可预测,由于若是常常不符合luajit假设的东西,就会常常从编译好的机器码中跳出来,甚至会由于好几回假设失败而连跳好几回。因此,luajit是一个对分支状况极度敏感的语言。
这是luajit的第一性能大坑,做者建议能够借助math.min/max或者bitop来绕过if else这样的分支代码。不过实际状况每每更复杂,全部涉及到跳转代码的地方,都是潜在的性能坑。
另外,在interpreter模式下(ios的状况),luajit就变成了老老实实动态检查动态跳转的执行模式,对分支预测反而并不敏感,并不须要过度注重这方面的优化。
 
 

2.Use FFI data structures.

若是能够,将你的数据结构用ffi实现,而不是用lua table实现

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 = 100000
 
local 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
  end
end
local 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
  end
end
为什么有这么大的差距?由于lua table本质是一个hash table,在hash table访问字段当然是缓慢的而且要存储大量额外的东西。而ffi能够作到只分配xyz三个float的空间就能表示一个Vector3,天然内存占用要低得多,并且jit会利用ffi的信息,实现访问xyz的时候直接读内存,而不是像hash table那样走一次key hash,性能也高得多。
不幸的是ffi只在有jit模式的时候才能有很好的运行速度,如今作手游基本都要作ios,而ios下因为只能运行解释模式,ffi的性能不好(比纯table反而更慢),仅仅内存优点获得保留,因此若是要考虑ios这样的平台,这个优化点基本能够忽略,或者只在安卓下针对少数核心代码进行优化。
 
 

3.Call C functions only via the FFI.

尽量用ffi来调用c函数。

一样的,ffi也能够用于调用已经extern c的c函数。你们表面上都觉得这样作只是省掉了用tolua之类的工具作导出的麻烦,但ffi更大的好处,是在于性能上质的提高。
这是由于,使用ffi导出c函数,你须要提供c函数的原型,有了c函数的原型信息,luajit能够知道每一个参数的准确类型,返回值的准确类型。了解编译器知识的同窗都知道函数调用和返回通常都是用栈来实现的,而要作到这点必需要知道整个参数列表和返回值类型,才能生成出出栈入栈的代码。所以luajit在拥有这些信息以后就能够生成机器码,跟c编译器同样作到无缝的调用,而不须要像标准的lua与c交互那样须要调用pushint等等函数来传参了。
若是不经过ffi调用c导出函数,那么由于luajit缺少这个函数的信息,没法生成用于调用c函数的jit代码,天然会下降性能。并且在2.1.0版本以前,这会直接致使jit失败,整段相关的代码都没法jit化,性能会收到极大的影响。
 
 

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.sin
function 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缓存一下。
 
 

8.Avoid inventing your own dispatch mechanisms.

避免使用你本身实现的分发调用机制,而尽可能使用內建的例如metatable这样的机制

编程的时候为告终构优雅,经常会引入像消息分发这样的机制,而后在消息来的时候根据咱们给消息定义的枚举来调用对应的实现,过去咱们也习惯写成:
if opcode == OP_1 then
elesif opcode == OP_2 then
...
但在luajit下,更建议将上面实现成table或者metatable
local callbacks = {}
callbacks[OP_1] = function() ... end
callbacks[OP_2] = function() ... end
这是由于表查找和metatable查找都是能够参与jit优化的,而自行实现的消息分发机制,每每会用到分支代码或者其余更复杂的代码结构,性能上反而不如纯粹的表查找+jit优化来得快
 
 

9.Do not try to second-guess the JIT compiler.

无需过多去帮jit编译器作手工优化。

做者举了一个例子
z = x[a+b] + y[a+b],这在luajit是性能ok的写法,不须要先local c = a+b而后z = x[c] + y[c]
后面的写法其实自己没什么问题,可是luajit的另外一个坑,即为了提高运算效率,local变量会尽量用cpu寄存器存储,这样比频繁读内存要快得多(现代cpu这能够达到几百倍的差距),但luajit在这方面不完善,一旦local变量太多,可能会找不到足够的寄存器分配(这个问题在armv7上很是明显,在调用层次深的时候,几个变量就会炸掉),而后jit会直接放弃编译。这里要说明一点是,不少local变量可能只是声明了放在那里没有用,可是luajit的编译器不必定可以准确肯定这个变量是否能够再也不存储,因此适当控制一个函数做用域内的local变量的数量是必须的。
固然,不得不说这样写代码还要猜luajit的行为确实比较痛苦,通常来讲进行profile而后对性能热点代码作针对测试和优化基本已经能够。
 
 
 

10.Be careful with aliasing, esp. when using multiple arrays.

变量的别名可能会阻止jit优化掉子表达式,尤为是在使用多个数组的时候。

做者举了一个例子
x[i] = a[i] + c[i]; y[i] = a[i] + d[i]
咱们可能会认为两a[i]是同一个东西,编译器能够优化成
local t = a[i]; x[i] = t + c[i]; y[i] = t + d[i]
实则否则,由于可能会出现,x和a就是同一个表,这样,x[i] = a[i] + c[i]就改变了a[i]的值,那么y[i] = a[i] + d[i]就不能再使用以前的a[i]的值了
这里跟优化点9描述的情形的本质区别是,优化点9里头z/a/b都是值类型,而这里x/a都是引用类型,引用类型就有引用同一个东西的可能(变量别名),所以编译器会放弃这样的优化。
 
 

11.Reduce the number of live temporary variables.

减小存活着的临时变量的数量

缘由在9中已经说明,即过多的存活着的临时变量可能会耗尽寄存器致使jit编译器没法利用寄存器作优化。这里注意live temporary variables是指存活的临时变量,假如你提早结束了临时变量的生命周期,编译器仍是会知道这一点的。好比:
function foo()
  do
   local a = "haha"
  end
  print(a)
end
这里print是会print出nil,由于a离开了do ... end就结束了生命周期,经过这种方式能够避免过多临时变量同时存活。
此外,有一个很常见的陷阱,例如咱们实现了一个Vector3的类型用于表达立体空间中的矢量,经常会重载他的一些元函数,好比__add
Vector3.__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实现。
相关文章
相关标签/搜索