手记

Lua性能优化 Lua Performance Tips

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

规则 1:不要优化

规则 2:还是不要优化(仅限专家)

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

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

基本事实

运行代码之前,Lua 会把源代码翻译(预编译)成一种内部格式,这种格式由一连串虚拟机的指令构成,与真实 CPU 的机器码很相似。接下来,这一内部格式交由 C 代码来解释,基本上就是一个 while 循环,里面有一个很大的 switch,一种指令对应一个 case。

也许你已从他处得知,自 5.0 版起,Lua 使用了一个基于寄存器的虚拟机。这些「寄存器」跟 CPU 中真实的寄存器并无关联,因为这种关联既无可移植性,也受限于可用的寄存器数量。Lua 使用一个栈(由一个数组加上一些索引实现)来存放它的寄存器。每个活动的(active)函数都有一份活动记录(activation record),活动记录占用栈的一小块,存放着这个函数对应的寄存器。因此,每个函数都有其自己的寄存器。由于每条指令只有 8 个 bit 用来指定寄存器,每个函数便可以使用多至 250 个寄存器。

Lua 的寄存器如此之多,预编译时便能将所有的局部变量存到寄存器中。所以,在 Lua 中访问局部变量是很快的。举个例子, 如果 a 和 b 是局部变量,语句 a = a + b 只生成一条指令:ADD 0 0 1(假设 a 和 b 分别在寄存器 0 和 1中)。对比之下,如果 a 和 b 是全局变量,生成上述加法运算的指令便会如下:

GETGLOBAL00; aGETGLOBAL11; bADD001SETGLOBAL00; a

所以,不难证明,要想改进 Lua 程序的性能,最重要的一条原则就是:使用局部变量(use locals)!

除了一些明显的地方外,另有几处也可使用局部变量,可以助你挤出更多的性能。比如,如果在很长的循环里调用函数,可以先将这个函数赋值给一个局部变量。这个代码:

fori =1,1000000dolocalx = math.sin(i)end

比如下代码慢 30%:

localsin = math.sinfori =1,1000000dolocalx = sin(i)end

访问外层局部变量(也就是外一层函数的局部变量)并没有访问局部变量快,但是仍然比访问全局变量快。考虑如下代码:

functionfoo(x)fori =1,1000000dox = x + math.sin(i)endreturnxendprint(foo(10))

我们可以通过在 foo 函数外面定义一个 sin 来优化它:

localsin = math.sinfunctionfoo(x)fori =1,1000000dox = x + sin(i)endreturnxendprint(foo(10))

第二段代码比第一段快 30%。

与其他语言的编译器相比,Lua 的编译器算是比较高效的,尽管如此,编译仍是一项繁重的任务。所以,应尽量避免在程序中编译代码(比如,使用 loadstring 函数)。除非需要真正动态地执行代码,比如代码是由用户输入的,其他情况则很少需要编译动态的代码。

举个例子,下面的代码创建一个包含 10000 个函数的表,表中的函数分别返回常量 1 到 10000:

locallim =10000locala = {}fori =1, limdoa[i] = loadstring(string.format("return %d", i))endprint(a[10]())--> 10

这段代码运行了 1.4 秒。

使用闭包,可以避免动态编译。下面的代码创建同样的 10000 个函数只用了 1/10 的时间(0.14秒):

functionfk(k)returnfunction()returnkendendlocallim =100000locala = {}fori =1, limdoa[i] = fk(i)endprint(a[10]())--> 10

关于表

通常,使用表(table)时并不需要知道它的实现细节。事实上,Lua 尽力避免把实现细节暴露给用户。然而这些细节还是在表操作的性能中暴露出来了。所以,为了高效地使用表,了解一些 Lua 实现表的方法,不无益处。

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

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

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

当 Lua 创建一个空表时,两部分的大小都是 0,因此也就没有为它们分配数组空间。看看如下代码运行时会发生些什么:

locala = {}fori =1,3doa[i] =trueend

一开始创建一个空表。循环的第一次迭代时,赋值语句 a[1] = true 触发了一次重新哈希;Lua 将表中的数组部分大小设为 1,而哈希部分仍为空。循环的第二次迭代时,赋值语句 a[2] = true 又触发了一次重新哈希,现在,表中的数组部分大小为 2。最后,第三次迭代还是触发了一次重新哈希,数组部分的大小增至 4。

像下面这样的代码:

a = {}a.x =1; a.y =2; a.z =3

做的事情类似,大小增长的却是表的哈希部分。

对于大型的表,这些初始的开销将会被整个创建过程平摊:创建 3 个元素的表需要进行 3 次重新哈希,而创建一百万个元素的表只需要 20 次。但是当你创建几千个小表时,总开销就会很显著。

老版的 Lua 在创建空表时会预分配一些空位(如果没记错,是 4),来避免这种创建小表时的初始开销。不过,这样又有浪费内存之嫌。比如,以仅有两个项的表来表示点,每个点使用的内存就是真正所需内存的两倍,那么创建几百万个点将会使你付出高昂的代价。这就是现在 Lua 不为空表预分配空位的原因。

如果你用的是 C,可以通过 Lua 的 API 函数 lua_createtable 来避免这些重新哈希。这个函数除了司空见惯的参数 lua_State 外,另接受两个参数:新表数组部分的初始大小和哈希部分的初始大小。只要这两个参数给得恰当,就能避免初始时的重新哈希。不过需要注意的是,Lua 只在重新哈希时才有机会去收缩(shrink)表。所以,如果你指定的初始大小大于实际所需,空间的浪费 Lua 可能永远都不会为你纠正。

如果你用的是 Lua,可以通过构造器(constructors)来避免那些初始的重新哈希。当你写下 {true, true, true} 时,Lua 就会事先知道新表的数组部分需要 3 个空位,并创建一个相应大小的表。与此类似,当你写下 {x = 1, y = 2, z = 3}时,Lua 就创建一个哈希部分包含 4 个空位的表。举例来说,下面的循环运行了 2.0 秒:

fori =1,1000000dolocala = {}  a[1] =1; a[2] =2; a[3] =3end

如果以正确的大小来创建这个表,运行时间就降到了 0.7 秒:

fori =1,1000000dolocala = {true,true,true}  a[1] =1; a[2] =2; a[3] =3end

然而,当你写下形如 {[1] = true, [2] = true, [3] = true} 这样的语句时,Lua 并没有聪明到能够检测出给定的表达式(指那些字面数字)是在描述数组下标,所以它创建了一个哈希部分有 4 个空位的表,既浪费内存也浪费 CPU 时间。

表的两个部分的大小只在表重新哈希时计算,而重新哈希只在表已全满而又需要插入新元素时才会发生。因此,当你遍历一个表并把个中元素逐一删除时(即设它们为 nil),表并不会缩小。你得往表里插些新的元素,然后表才会真正去调整大小。通常这不是一个问题:当你持续地删除和插入元素时(很多程序的典型情况),表的大小将保持稳定。不过,你不该期望通过从一个大表里删除一些数据来回收内存,更好的做法是删除这个表本身。

有一则强制重新哈希的奇技淫巧,即往表里插入足够的 nil 元素。示例如下:

a = {}lim =10000000fori =1, limdoa[i] = iend-- 创建一个巨大的表print(collectgarbage("count"))-->196626fori =1, limdoa[i] =nilend-- 删除其所有的元素print(collectgarbage("count"))-->196626fori = lim +1,2*limdoa[i] =nilend-- 插入大量nil元素print(collectgarbage("count"))--> 17

除非特殊情况需要,我并不推荐这种手法,因为这样做很慢,而且要知道多少元素才算「足够」,也没有简单易行的方法。

你可能会想,Lua 为什么不在我们插入 nil 时收缩表的大小呢?首先,是为了避免对插入元素的检查;一条检查 nil 赋值的语句将会拖慢所有的赋值语句。其次,也是更重要的,是为了允许在遍历表时对元素赋 nil 值。考虑如下循环:

fork, vinpairs(t)doifsome_property(v)thent[k] =nil-- 删除这个元素endend

如果 Lua 在 nil 赋值后进行重新哈希,那么这个遍历就被破坏了。

如果你想删除表中的所有元素,正确的方法是使用一个简单的循环:

forkinpairs(t)dot[k] =nilend



作者:summer鹏
链接:https://www.jianshu.com/p/32f0b17b852c


1人推荐
随时随地看视频
慕课网APP