翻译自《Lua Programming Gems》Chapter 2:Lua Performance Tips:Basic fact By Roberto Ierusalimschy
基本知识
Lua在运行代码之前,会先把源码翻译(预编译)成一种内部编码,这种编码由一连串的虚拟机能够识别指令构成,与CPU的机器码很相似。接下来由C代码中的一个while循环负责解释这些内部编码,这个while循环中有一个很大的switch,一种指令就有对应的一个case。
可能你已经从其他地方得知,自5.0版本开始,Lua就使用一个基于寄存器的虚拟机。但是这些“寄存器”跟CPU中的寄存器没有任何关联,因为这种关联会使Lua失去可移植性,并且会使Lua受限于可用的寄存器数量。Lua使用一个栈(由一个数组加上一些索引实现)来存放它的寄存器。每一个运行中的函数都有各自的一份活动记录,这些活动记录保存在栈中,内部存放着每个函数对应的寄存器。所以每个函数都有一组各自的寄存器。每条指令中只有8个bit用来标志寄存器,所以每个函数最多能够使用250个寄存器。
由于Lua有如此大量的寄存器,所以在预编译时能够将所有的局部变量(local)存放到寄存器中。所以,在Lua中,访问局部变量是很快的。举个例子,如果a和b是局部变量,语句a= a + b只生成一条指令:ADD 0 0 1 (假设a和b分别在寄存器0和1中)。对比一下如果a和b是全局变量,生成上述加法运算的中间代码会像这样:
GETGLOBAL 0 0 ; a
GETGLOBAL 1 1 ; b
ADD 0 0 1
SETGLOBAL 00 ; a
所以,很明显我们可以得出Lua编程里面其中一条最重要的改进性能的规则:使用局部变量(uselocals)!
如果你需要尽可能的提升程序的性能,你可以使用局部变量,比如,如果你在一个很长的循环里调用一个函数,你可以先将函数赋值给一个局部变量。比如以下代码
for i = 1, 1000000 do local x= math.sin(i) end
会比以下代码慢30%:local sin = math.sin for i = 1, 1000000 do local x= sin(i) end
访问外层局部变量(也就是外一层函数的局部变量)并没有访问局部变量快,但是还是比访问全局变量快。看看以下代码片段:function foo(x) for i =1, 1000000 do x =x + math.sin(i) end return x end print(foo(10))
我们通过在foo函数外面声明一次sin来优化它:
local sin = math.sin function foo(x) for i =1, 1000000 do x =x + sin(i) end return x end print(foo(10))
第二段代码比第一段快30%。比起其他编译器,Lua的编译器是比较高效的,尽管如此,编译还是一项比较繁重的任务。所以,无论何时都要尽量避免在程序中编译代码(比如,调用loadstring函数)。除非你需要真正地动态地执行你的代码,比如代码是由用户输入的,否则你很少需要编译动态的代码。
考虑以下例子,下面的代码创建一个存放了10000函数的table,这些存放在table中的函数分别返回常量1到10000:
local lim = 10000 local a = {} for i = 1, lim do a[i] =loadstring(string.format("return %d", i)) end print(a[10]()) --> 10
这份代码运行了1.4秒。我们通过使用闭包来避免动态编译。下面的代码在1/10的时间里(0.14)创建了同样的10000个函数:
function fk (k) returnfunction () return k end end local lim = 100000 local a = {} for i = 1, lim do a[i] = fk(i) end print(a[10]()) --> 10
表相关
通常情况下,你在使用表(table)的时候并不需要任何有关Lua如何实现表的细节。事实上,Lua竭尽全力地避免实现细节暴露给用户。但是这些细节还是在table操作的性能中暴露出来了。所以,为了高效地使用表,知道一点Lua如何实现table是有好处的。
Lua使用了一些巧妙的算法来实现table。每个表包含两部分:数组(array)部分和哈希(hash)部分,数组部分保存整数键值(key)为1到n范围内的值(entry),n是一些独特的数值。(我们后面会讨论在某一刻n是怎么被计算出来的。)其他的值(包括整数下标超出1到n范围的)保存在哈希部分。
顾名思义,哈希部分使用哈希算法来保存和查找里面的键值。它使用的是开发地址列表,所有的值都存在哈希数组里。哈希函数算出一个键值的主索引;如果发生碰撞(两个键值的哈希值是相同的),这些有相同主索引的键值将会被连成一个链表,每个元素在数组中占一个位置。
当Lua需要在表中插入一个新的键值而此时哈希数组没有空位置时,Lua将会做一次重新哈希(rehash)。重新哈希的第一步是决定新的数组部分和哈希部分的大小。所以Lua会遍历表中的所有值,对这些值进行计数和分类,然后选择一个小于数组部分大小的2的最大指数值,使数组的一半以上会被使用。哈希部分的大小就是大于剩下的值(不能放在数组部分的值)的数量的最小2的指数值。
当Lua创建一个空表的时候,其中的两部分的大小都是0,并且此时并没有赋予他们内存空间。下面我们来看看下面代码会发生什么事情:
local a = {} for i = 1, 3 do a[i] =true end
代码一开始创建一个空表。第一次循环的时候,赋值语句a[1]=true触发了一次重新哈希计算;Lua将表中的数组部分大小设为1,哈希部分还是空的。第二次循环的时候,赋值语句a[2]=true又触发了一次重新哈希计算,现在,表中的数组部分大小为2。最后,第三次循环又触发了一次重新哈希计算,数组部分的大小增大到4。代码:
a = {} a.x = 1; a.y = 2; a.z = 3
做的事情是类似的,不过大小增长的是table中的哈希部分。对于大型的表,这些初始化开销将会被整个创建过程平摊:一个包含三个元素的表需要进行3次重新哈希计算,而一个包含了一百万个元素的表只需要20次。但是当你创建几千个小的表时,总开销就会很显著。
老版本的Lua在创建空表的时候会为其预分配一些空位(如果没记错,是4),来避免创建较小的表时的开销。但是这样可能会出现浪费内存的情况。比如,如果你创建几百万个点(在表里面只存放了两个数字),那么每个点使用的内存将会是其真正需要的内存的两倍,你将会付出高昂的代价。这就是为什么现在的Lua没有为空表预分配空位。
如果你是用C语言编程,你可以通过调用Lua的API函数lua_createtable来避免这些重新哈希计算。它在随处可见的lua_State中获取两个参数:新表数组部分的初始大小和哈希部分的初始大小。通过提供一个适当的初始大小给新表,可以很容易地避免这些初始化时的重新哈希计算。需要注意的是,Lua只有在进行重新哈希计算的时候,才会缩小表的大小。所以,如果你提供的初始大小比实际使用的大的话,Lua不会纠正你对空间的浪费。
当你在Lua下面编程的时候,你可以通过构造来避免那些初始化的重新哈希计算。当你写下{true,true, true}的时候,Lua就会事先知道新的表的数组部分将会用到3个空位,并创建一个相应大小的表。类似的,当你写下{x = 1, y = 2, z =3}的时候,Lua将创建一个哈希部分包含4个空位的表。作为例子,下面的循环将会运行2.0秒:
for i = 1, 1000000 do local a= {} a[1] =1; a[2] = 2; a[3] = 3 end
如果以一个适当的初始大小来创建一个表的话,运行时间将会降低到0.7秒:for i = 1, 1000000 do local a ={true, true, true} a[1] = 1;a[2] = 2; a[3] = 3 end
但是,当你写下类似{[1] = true, [2] = true, [3] =true}的时候,Lua并没有聪明到检测到以上的表达式(这里指字面数字123)是在描述数组下标,所以它创建了一个哈希部分有四个空位的表,这浪费了内存和CPU时间。
表的两个组成部分的大小只在表进行重新哈希计算的时候计算出来,而重新哈希计算只会在表已经被放满时需要插入一个新元素的时候发生。因此,当你遍历一个表并把其中的元素都删除的时候(就是把表里的值设为nil),表并不会缩小。当你插入一些新元素时,表才会重新改变其大小。通常这并不是一个问题:当你持续地删除和插入元素时(很多程序的典型情况),表的大小将保持稳定。你不应该通过在一个巨大的表中删除一些数据来节省空间:删除这个巨大的表会更好。
有一种比较肮脏的手段来强迫表进行重新哈希计算,就是通过在表中插入足够的nil元素。看看以下例子:
a = {} lim = 10000000 for i = 1, lim do a[i] = i end -- 创建一个巨大的表 print(collectgarbage("count")) -->196626 for i = 1, lim do a[i] = nil end -- 删除其所有的元素 print(collectgarbage("count")) -->196626 for i = lim + 1, 2*lim do a[i] = nil end -- 插入大量nil元素 print(collectgarbage("count")) --> 17
除了个别特殊情况之外,我不推荐这种手法,因为这样很慢,并且没有比较简单的方法来获知“足够”到底是多少。
你可能会好奇为什么我们插入nil元素时Lua没有缩小表的大小。首先,是为了避免需要测试我们正插入什么东西到表中;测试插入的元素是否为nil会降低所有赋值语句的速度。第二个原因,更重要的是,为了允许遍历表时,对表元素赋nil值。考虑一下循环:
for k, v in pairs(t) do ifsome_property(v) then t[k]= nil -- 删除这个元素 end end
如果Lua在表元素被赋nil值之后进行重新哈希,将会摧毁这个遍历。如果你想删除表中的所有元素,下面一个简单的循环式其中一种正确的方法:
for k in pairs(t) do t[k] = nil end
另外一个“智能”的选择是下面的这个循环:while true do local k =next(t) if not kthen break end t[k] = nil end
然而,这个循环再表很大的时候会很慢。当调用函数next时,如果没有传入前一个键值作为参数,函数next会返回表中的“第一个”元素(以一种随机的排序方法)。为了做到这个,next函数会从表的数组部分的开头开始遍历,查找非nil元素。随着循环将一个个的第一个元素设为nil,查找第一个非nil元素变得越来越久。最后,这个“智能”循环用了20秒时间来清除一个有100000元素的表;使用pairs的遍历表的循环则耗费了0.04秒。字符串
和表一样,了解Lua如何实现字符串(string)对高效地使用字符串是有好处的。
Lua实现字符串的方式有两个地方跟其它脚本语言截然不同。首先,Lua中的所有字符串都被内部化;这意味着Lua中所有的字符串只有一份拷贝。任何时候,当一个新的字符串出现时,Lua会先检查这个字符串是否已经有一份拷贝,如果有,就重用这份拷贝。内部化使字符串比较和表索引等操作变得非常快,但是字符串的创建会变慢。
第二,Lua中的字符串变量不包含字符串实体,而是一个字符串的引用。这种实现加快了若干字符串操作。比如说,在Perl里,如果你写下类似这样的语句:$x= $y,$y包含一个字符串,这个赋值语句将复制$y缓冲区字符串的内容到$x的缓冲区中。如果这个字符串很长,这个操作将是非常昂贵的。在Lua里,执行这条赋值语句只是复制了一个指向实际字符串的指针。
这种使用引用来实现字符串的方案,降低了某种方式的字符串连接的速度。在Perl里,操作$s= $s . "x"和$s .= "x"是很不同的。前一个语句,你得到的是一份$s的拷贝,这份拷贝后面加入了"x"。后一个语句,"x"只是被简单地放在了$s的缓冲区之后。所以第二种连接格式跟字符串的大小是无关的(假设缓冲区有足够的空间来存放连接的字符)。如果你将这两条语句放在一个循环中,那么它们的区别相当于一个线性复杂度的算法和一个平方复杂度的算法。比如,一下循环用了五分钟时间来读取一个5MB的文件:
$x = "";
while (<>) {
$x = $x . $_;
}
如果我们将$x = $x . $_替换成$x .= $_,以上片段只耗费0.1秒的时间!
Lua并没有提供第二种,也就是比较快的方法,因为Lua的字符串变量并不拥有缓冲区,所以我们必须显式地使用一个缓冲区:包含了字符串碎片的表来完成这项工作。下面的代码耗费了0.28秒来读那个5MB的文件。不比Perl快,不过很不错了。
local t = {}
for line in io.lines() do
t[#t + 1] = line
end
s = table.concat(t,"\n")
减少,重用,回收(Reduce, Reuse, Recycle)
当处理Lua资源时,我们应当遵守跟利用地球资源一样的3R's原则。
减少是最简单的一种途径。有几种方法可以避免创建对象。例如,如果你的程序使用了大量的表,你可以考虑改变它的数据表示方式。举个简单的例子,假如你的程序需要处理多边形。在Lua里表示一个多边形最自然的方式就是表示成一个点的列表,像这样:
polyline = { { x = 10.3, y = 98.5 }, { x = 10.3, y = 18.3 }, { x = 15.0, y = 98.5 }, ... }
polyline = { { 10.3, 98.5 }, { 10.3, 18.3 }, { 15.0, 98.5 }, ... }
一个有一百万个点的多边形,这种改变会将内存使用从95KB降到65KB。当然,你牺牲了程序的可读性作为代价:p[i].x要比p[i][1]让人容易看得懂。另一个更经济的方法是用一个列表来存储x轴的值,另一个列表存储y轴的值:
polyline = { x = { 10.3, 10.3, 15.0, ...},
y = { 98.5, 18.3, 98.5, ...}
}之前的p[i].x就是现在的p.x[i]。使用这种方式,一个有一百万个点的多边形使用的内存只有24KB。
循环体内是找到降低不必要资源创建的地方。例如,如果在一个循环中创建了一个不会改变内容的表,你可以把表放在循环体之外,或者甚至放在执行这段代码的函数之外。比较:
function foo (...) for i =1, n do local t = {1, 2, 3, "hi"} -- 做一些不改变t的操作。 ... end end
local t = {1, 2, 3, "hi"} -- 一劳永逸 function foo (...) for i =1, n do -- 做一些不改变t的操作。 ... end end
同样的技巧还可以用到闭包中,只要不要将其移至闭包需要的变量的作用域之外。例如,考虑一下函数:对于大多数字符串操作,我们都可以通过下标在已存在的字符串上工作,从而减少字符串的创建。例如,函数string.find返回字符串上匹配正则表达式的下标,而不是返回一个匹配的字符串。返回下标,就避免了在成功匹配时创建一个新的(子)字符串。开发者需要时,可以通过函数string.sub来获取匹配的子字符串。
当我们不能避免使用新的对象的时候,我们还是可以通过重用来避免创建这些对象。考虑字符串的重用是没有必要的,因为Lua已经替我们做了这些工作:Lua总是将用到的字符串内部化,不会放过任何重用的机会。重用表则是比较有效。举一个常见的例子,让我们回到在一个循环体内部创建表的情况。不同的是这次的表的内容不是固定不变的。不过,我们往往还是可以简单地更改表的内容从而能够在所有迭代中重用这个表。考虑一下代码:
local t = {} for i = 1970, 2000 do t[i] =os.time({year = i, month = 6, day = 14}) end
一下是与之等价的代码,但是重用了表:
local t = {} local aux = {year = nil, month = 6, day = 14} for i = 1970, 2000 do aux.year =i t[i] =os.time(aux) end
另一种比较有用的实现重用的方法是记忆化(memoizing)。原理很简单:对于一个给定的某些输入值,保存其计算结果,当同样的值输入时,程序只需重用之前保存的结果。
LPeg,Lua中一个新的模式匹配的包,有趣地使用了记忆化方法。LPeg把每个模式都编译成一种内部的格式,负责匹配的分析器把这些格式的编码看成一个个“程序”。这个编译动作相对于匹配动作来说是比较耗费资源的。所以,LPeg把编译的结果记忆化,从而达到重用的目的。它通过在一个表里面把模式字符串以及其对应的内部格式关联起来来实现。
记忆化方法的一个比较普遍的问题是,保存之前计算结果所耗费的空间可能会掩盖重用这些结果的好处。为了在Lua中解决这个问题,我们可以使用一个弱表来保存计算结果,这样,不用的结果就会从表中被删除。
在Lua里,有了更高等的函数(译注,Lua的函数是一等类型,即Lua处理函数和变量的方式是一样的),我们可以定义一个通用的记忆化函数:
function memoize (f) local mem= {} -- memoizing table setmetatable(mem, {__mode = "kv"}) -- make it weak returnfunction (x) -- new version of ’f’, with memoizing local r = mem[x] if r== nil then -- no previous result? r = f(x) -- calls original function mem[x] = r -- store result for reuse end return r end end
对于一个给定的函数f,memoize(f)返回一个新的函数,这个函数会返回跟f一样的结果,但是会吧结果记录下来。例如,我们可以重新定义loadstring函数的一个记忆化版本:
loadstring = memoize(loadstring)
我们像使用老 函数一样使用这个新函数,但是如果我们加载很多重复的字符串的时候,我们将会从性能上获得很大的收益。如果你的程序创建和释放过多的协程的时候,回收是一个提高程序性能的又一选择。目前协程的API并没有直接提供重用一个协程的方法,但是我们可以规避这个限制。考虑以下协程:
co = coroutine.create(function (f) while fdo f =coroutine.yield(f()) end end
这个协程接受一个作业(一个将要被执行的函数),运行这个作业,结束后等待下一个作业。Lua中的大多数回收都是由垃圾收集器自动完成的。Lua使用一个增量垃圾收集器。这意味着收集器每次都执行一小步动作(增量地),跟程序一起交错执行。每一步的工作量是跟程序的内存申请量成正比的:Lua申请了多少内存,垃圾收集器就做相当比例的工作。程序消耗内存越快,收集器就越快地尝试回收内存。
如果我们在程序中遵守减少和重用的准则,通常收集器没有太多的事情做。但是有时候我们不能避免创建大量的垃圾,这时收集器的工作就变得繁重了。Lua的垃圾收集器是用来调整程序平衡的,所以再大多数程序中,它的表现都是很合理的。但是有些特殊情况,我们还是可以通过更好地调整收集器提高程序的性能的。
在Lua里,我们可以通过调用函数collectgarbage来控制垃圾收集器,在C里则是调用lua_gc。尽管接口不同,以上两个函数基本上提供了相同的功能。接下来的讨论我会使用Lua的接口,但是这种操作往往在C里面做会更好。
函数collectgarbage提供了几种功能:它可以停止和重启收集器,强制进行一次完成的收集,强制执行一步收集,得到Lua使用的总内存量,更改两个影响到收集步伐的参数。所有这些操作在需要大量内存的程序里都有其用武之地。
一些批处理程序,它们创建了若干结构体,根据那些结构体产生一些输出,然后退出(比如编译器)。“永远”停止收集器将是一个好选择。对于那些程序,垃圾收集是比较浪费时间的,因为可回收的垃圾很少,并且程序一旦退出,所有的内存就会被释放了。
对于一些非批处理的程序,永远关闭收集器就不是个好选择了。尽管如此,在程序的一些关键时间点关闭收集器还是有好处的。必要的时候,还可以由程序来完全控制垃圾收集器:收集器总是处于关闭状态,只有程序显式地要求执行一个步骤或者执行一个完整的回收时,收集器才开始工作。例如,有些事件驱动的平台会提供一个idle函数,这个函数会在没有事件可以处理时被调用。这是执行垃圾收集的最好时刻。(在Lua5.1中,每次在收集器关闭时强制执行一些收集工作都会使收集器自动启动。所以为了保持收集器做完马上关闭,你必须在强制执行一些收集操作之后马上调用collectgarbage("stop")。)
最后一个方法,你可以尝试改变收集器的参数。收集器由两个参数控制其收集步伐。第一个是“pause”(暂停),控制收集器在一轮回收结束后隔多久才开始下一轮的回收。第二个参数是“stepmul”(即 step multiplier,步伐递增),控制收集器每一步要做多少工作。粗略地讲,暂停间隔越小,步伐递增越大,收集器工作就越快。
这些参数对一个程序的总体性能的影响是很难预测的。很明显地,一个越快的收集器每秒耗费的CPU周期就越多;但是另一方面,这将会减少程序的总内存占用量,从而减少页面切换的几率。只有认真的实验能够让你给这些参数设定一个最好的值。