Lua Gems

性能优化

关于性能优化的两条格言:

  • 不要优化

  • 还是不要优化(仅限专家)

不要在缺乏恰当度量(measurements)时试图去优化软件。编程老手和菜鸟之间的区别不是说老手更善于洞察程序的性能瓶颈,而是老手知道他们并不善于此。

做性能优化离不开度量。优化前度量,可知何处需要优化。优化后度量,可知「优化」是否确实改进了代码。

basic

  • lua使用基于寄存器的虚拟机,用一个栈来存放它的寄存器。lua中每个active的函数都有一个activation record放在栈里,记录了这个函数使用的寄存器。因此,每个函数都有其自己的寄存器。由于每条指令只有 8 个 bit 用来指定寄存器,每个函数便可以使用多至 250 个寄存器。Lua 的寄存器如此之多,预编译时便能将所有的局部变量存到寄存器中。所以,在 Lua 中访问局部变量是很快的。

  • 因此,可以是用局部变量提高性能。例如给多次引用的外部变量(e.g. tbl.data)赋予别名。但是这在 LuaJIT 上几乎已经没有了优势。

  • 变量创建问题考虑比较跨域性能消耗和创建消耗:循环内创建简单变量比循环外创建多次覆写效率高;循环外创建长table比循环内创建效率高。

  • 避免动态编译(loadfromstring()等),可以使用闭包等来避免。

  • Short inline expressions can be faster than function calls. t[#t+1] = 0 is faster than table.insert(t, 0).

table

  • Lua 实现表的算法颇为巧妙。每个表包含两部分:数组(array)部分和哈希(hash)部分,数组部分保存的项(entry)以整数为键(key),从 1 到某个特定的 n,(稍后会讨论 n 是怎么计算的。)所有其他的项(包括整数键超出范围的)则保存在哈希部分。

  • 顾名思义,哈希部分使用哈希算法来保存和查找键值。它使用的是开放寻址(open address)的表,意味着所有的项都直接存在哈希数组里。键值的主索引由哈希函数给出;如果发生冲突(两个键值哈希到相同的位置),这些键值就串成一个链表,链表的每个元素占用数组的一项。

  • 当 Lua 想在表中插入一个新的键值而哈希数组已满时,Lua 会做一次重新哈希(rehash)。重新哈希的第一步是决定新的数组部分和哈希部分的大小。所以 Lua 遍历所有的项,并加以计数和分类,然后取一个使数组部分用量过半的最大的 2 的指数值,作为数组部分的大小。而哈希部分的大小则是一个容得下剩余项(即那些不适合放在数组部分的项)的最小的 2 的指数值。

  • Lua 只在重新哈希时才有机会去收缩(shrink)表。

  • 创建定长table使用table.new(narray, nhash)提前声明大小可以避免表扩容操作。

  • 如果table存在大量索引不存在的key的情况,该table要避免有元表。元表查询性能消耗是普通查询的30倍左右。

string

3R: reduce, reuse, recycle

reduce

  • reduce是最简单的一种途径。有几种方法可以避免创建对象。

  • 很多字符串的处理,都可以通过在现有字符串上使用下标,来避免创建不必要的新字符串。例如,函数 string.find 返回的是给定模式出现的位置,而不是一个与之匹配的字符串。返回下标,就避免了在成功匹配时创建一个新的(子)字符串。若有需要,可以再通过函数 string.sub 来获取匹配的子字符串。

  • 循环是寻找降低不必要资源创建的好地方。

  • 将闭包中的inner函数移到闭包外。

reuse

  • 即使不能避免使用新的对象,也可以通过 reuse来避免创建新的对象。实现reuse的一种特别有效的方法是记忆化(memoizing)。基本想法非常简单:对于一个给定的输入,保存其计算结果,当遇到同样的输入时,程序只需重用之前保存的结果。

  • 对字符串来说,重用是没有必要的,因为 Lua 已经替我们这样做了:所有的字符串都是内化的(internalized),因此只要可能就会重用。对表来说,重用就显得卓有成效了。例如在循环内创建表的情况,往往只需简单的改变内容,还是可以在所有的迭代中重用同一个表的。

  • 实现重用的一种特别有效的方法是记忆化(memoizing)。基本想法非常简单:对于一个给定的输入,保存其计算结果,当遇到同样的输入时,程序只需重用之前保存的结果。

recycle

  • Lua 中的大多数recycle都是由垃圾收集器自动完成的。Lua 使用一个增量(incremental)的垃圾收集器,逐步(in small steps)回收(增量地),跟程序一起交错执行。每一步回收多少,跟内存分配成正比:Lua 分配了多少内存,垃圾收集器就做多少相应比例的工作。程序消耗内存越快,收集器尝试回收内存也就越快。

  • 如果我们在程序中遵守减少和重用的原则,收集器通常没有太多的事情可做。但是有时候我们不能避免创建大量的垃圾,这时收集器就可能变得任务繁重了。Lua 的垃圾收集器是为一般的程序而设的,对大多数应用来说,它的表现都是相当不错的。但是有时候,某些特殊的应用场景,适当地调整收集器还是可以提高性能的。

  • 函数 collectgarbage 提供了这样几种功能:它可以停止和重启收集器,强制进行一次完整的收集,强制执行一步收集(collection step),得到当前内存使用总量,更改两个影响收集效率(pace)的参数。所有这些操作在缺乏内存的程序里都有其用武之地。

  • collectgarbage的参数对程序的总体性能的影响是很难预测的。收集器越快,其每秒耗费的 CPU 周期显然也就越多;但是另一方面,或许这样能减少程序的内存使用总量,从而减少换页(paging)。只有通过仔细的实验,才能为这些参数找到最佳的值。

LuaJIT

  • 减少无法预知的分支数

    • 有极高可能性偏向一方的条件分支不会影响(95%以上)

    • 可以改用无分支的算法替换,如

      • 使用math.min()math.max()

      • 使用bit.*

  • 使用FFI数据结构

    • 使用int32_t,避免使用uint32_t类型

    • 使用double,避免使用float

    • 不要滥用元方法

  • 仅通过FFI调用C函数

    • 避免调用很小的函数,更好的做法是在Lua重写

    • 避免回调函数,使用推送(read/write)或者迭代器来代替

  • 使用诸如 for i=start,stop,step do ... end的循环语句

    • 数组索引也类似, e.g. a[i+2].

    • 避免使用指针计算

  • 展开适度

    • 避免循环量少的内联循环

    • 当且仅当循环体有太多指令时展开循环

    • 考虑使用模板代替手动展开(如GSL Shell)

  • 最好仅在模块(module)内定义并调用函数

  • 对于其他模块的函数,如果常用,则缓存为上值(upvalue)

    • 如,local sin = math.sin

    • 对于FFI C的函数不要这么做,缓存名字空间即可,如local lib = ffi.load("lib")

  • 避免自己创建派遣机制,而是改用系统自带的,如元方法

  • 不要过度优化,JIT编译器可能已经优化过了

    • 例如,z = x[a+b] + y[a+b]的写法是没有问题的,如果手动增加一个临时变量存放a+b,不仅不会提升性能,还会导致多占用了一个寄存器或者栈空间,得不偿失

    • 例如,a[i][j] = a[i][j] * a[i][j+1]也一样没有问题

    • 在使用LuaJIT的时候,这些细小的优化点编译器均帮忙做了更优的实现,程序员更需要关心的是更为宏大的优化,如算法复杂度的优化

  • 不同于C/JAVA,变量的声明和定义尽量放在要用的地方,而不是函数开头

  • 不要过度使用昂贵或未编译的操作

    • assert(type(x) == "number", "x is a "..mytype(x)"),该语句最大的问题在于字符串拼接,该操作无论断言是否生效均需要每次操作

    • 通过-jv/-jdump来看output确认是否有不需要的操作

reference

评论

此博客中的热门博文

LuaJIT