原文:Lua Performance Tips程序员
像其余任何编程语言同样,在Lua中,咱们也要遵照如下两条优化程序的规则:算法
规则1:不要优化。编程
规则2:仍然不要优化(专家除外)小程序
当用Lua编程时,这两条规则显得尤其重要。Lua以性能著称,并且在脚本语言中也所以而值得赞美。windows
然而,咱们都知道性能是编程的一个关键因素。具备复杂指数时间的问题被称做疑难问题并非偶然发生。太迟的结果是无用的结果。所以,每一个优秀的程序员应该老是在花费资源去优化一段代码的代价和这段代码在运行代码时节约资源的收益相平衡。一个优秀的程序员关于优化的第一个问题老是会问:“程序须要优化吗?”若是答案是确定的(仅当此时),第二个问题应该是:“哪地方?”数组
为了回答这两个问题咱们须要些手段。咱们不该该在没有合适的测量时尝试优化软件。大牛和菜鸟以前的不一样不是有经验的程序员更好的指出程序的一个地方可能耗时:不一样之处是大牛知道他们并不擅长那项任务。数据结构
最近几年,Noemi Rodriguez和我用Lua开发了一个CORBA ORB(Object Request Broker)原型,后来进化成OiL(Orb in Lua)。做为第一个原型,以执行简明为目标。为了不引用额外的C语言库,这个原型用一些计算操做分离每一个字节(转化成256的基数)。不支持浮点数。由于CORBA把字符串做为字符序列处理,咱们的ORB第一次把Lua的字符串转化成字符序列(是Lua中的table),而后像其余序列那样处理结果。闭包
当咱们完成第一个原型,咱们和用C++实现的专业的ORB的性能相比较。咱们预期咱们的ORB会稍微慢点,由于它是用Lua实现的,可是,慢的太让咱们失望了。开始时,咱们只是归咎于Lua。最后,咱们猜测缘由多是每一个数字序列化所须要的那些操做。所以,咱们决定在分析器下下运行程序。咱们用了一个很是简单的分析器,像《Programming in Lua》第23章描述的那样。分析器的结果震惊到咱们。和咱们的直觉不一样,数字序列化对性能的影响不大,由于没有太多的数字序列化。然而,字符串序列化占用总时间的很大一部分。实际上每一个CORBA消息都有几个字符串,即便咱们不明确地操做字符串:对象引用,方法名字和其余的某些整数值都被编码成字符串。而且每一个字符串序列化须要昂贵的代价去操做,由于这须要建立新表,用每一个单独的字符填充,而后序列化这些结果的顺序,这涉及到一个接一个序列化每一个字符。一旦咱们从新实现字符串序列化做为特殊的事件(替换使用通常的序列代码),咱们就能获得可观的速度提高。仅仅用额外的几行代码,你的执行效率就能比得上C++的执行(固然,咱们的执行仍然慢,但不是一个数量级)。架构
所以,当优化程序性能时,咱们应老是去测量。测量前,知道优化哪里。测量后,知道所谓的“优化”是否真正的提升了咱们的代码。编程语言
一旦你决定确实必须优化你的Lua代码,本文可能帮助你如何去优化,主要经过展现在Lua中哪样会慢和哪样会快。在这里我不会讨论优化的通常技术,好比更好的算法。固然,你应该懂得而且会用这些技术,可是,你能从其余的地方学习到那些通常的优化技术。在这篇文章里我仅讲解Lua特有的技术。整篇文章,我将会时不时的测量小程序的时间和空间。除非另有说明,我全部的测量是在Pentium IV 2.9 GHz和主存1GB,运行在Ubuntu 7.10, Lua 5.1.1。我会频繁地给出实际的测量结果(例如,7秒),可是会依赖于不一样测量方法。当我说一个程序比另外一的“快X%”的意思是运行时间少“X%”。(程序快100%意味着运行不花时间。)当我说一个程序比另外一个“慢X%”的意思是另外一个快X%。(程序慢50%的意思是运行花费两倍时间。)
运行任何代码前,Lua会把源码转化(预编译)成内部格式。这种格式是虚拟机指令的序列,相似于真正CPU的机器码。这种内部格式而后被必须内部有一个每一个指令是一种状况大的switch的while循环的C语言解释。
可能在某些地方你已经读过从5.0版本Lua使用基于寄存器的虚拟机。这个虚拟机的“寄存器”和真正CPU的寄存器不相符,由于这种相符是不能移植而且十份限制可用寄存器的数量。取而代之的是,Lua使用堆(一个数组加上些索引来实现)容纳寄存器。每一个活动函数有一个活动记录,那是个函数在其中存储其寄存器的堆片断。所以,每一个函数有他本身的寄存器(这相似于在windows某些CPU建立的寄存器)。每一个函数可能使用超过250个寄存器,所以每一个指令仅有8位引用寄存器。
提供了大量的寄存器,Lua预编译可以在寄存器储存剩余的局部变量。结果是在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 0 0 ; a
所以,这很容易证实优化Lua程序的一个重要规则:使用局部变量!
若是你须要进一步提升你程序的性能,除了明显的那些,这里还有你能使用局部变量的地方。例如,若是你在长循环中调用函数,你能够用局部变量引用这个函数。举个例子,代码
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)。除非你必须运行动态的代码,像经过终端输入的代码,你不多须要编译动态代码。
做为例子,考虑下面的代码,建立一个返回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秒)建立一样的100000个函数。
function fk (k) return function () return k end end local lim = 100000 local a = {} for i = 1, lim do a[i] = fk(i) end print(a[10]()) --> 10
一般,你不须要为使用表而了解Lua是如何执行表的任何事。实际上,Lua不遗余力确保实现细节不暴露给用户。然而,这些细节经过表操做的性能展现出来。所以,要优化使用表的程序(这几乎是任何Lua程序),仍是知道Lua是如何执行表的会比较好。
在Lua中表的执行涉及一些聪明的算法。Lua中的表有两部分:数组和哈希。对某些特殊的n,数组存储从1到n的整数键的条目。(稍后咱们将会讲解这个n是如何计算的。)全部其余的条目(包括范围外的整数键)转到哈希部分。
顾名思义,哈希部分使用哈希计算存储和寻找他们的键。使用被称做开发地址的表,意思是全部的条目被储存在它本身的哈希数组中。哈希函数给出键的主要索引;若是存在冲突(即如何两个键被哈希到同一个位置),这些键被链接到每一个元素占用一个数组条目的列表中。
当Lua在表中插入一个新键,而且哈希数组已满的时候,Lua会从新哈希。从新哈希第一步是决定新数组部分和新哈希部分的大小。所以,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
也作相似的 操做,除了表的哈希部分增加外。
对于很大的表,初始化的开销会分摊到整个过程的建立:虽然有三个元素的表如要三次从新哈希,但有一百万个元素的表只须要20次。可是当你建立上千个小的表时,总的消耗会很大。
旧版本的Lua建立空表时会预分配几个位置(4个,若是我没记错的话),以免这种初始化小表时的开销。然而,这种方法会浪费内存。举个例子,若是你建立一百万个坐标点(表现为只有两个元素的表)而每一个使用实际须要的两倍内存,你所以会付出高昂的代价。这也是如今Lua建立空表不会预分配的缘由。
若是你用C语言编程,你能够经过Lua的API中lua_createtable函数避免那些从新哈希。他在无处不在的lua_State后接受两个参数:新表数组部分的初始大小和哈希部分的初始大小。(虽然从新哈希的运算法则总会将数组的大小设置为2的幂次方,数组的大小能够是任意值。然而,哈希的大小必须是2的幂次方,所以,第二个参数老是取整为不比原值小的较小的2的幂次方)经过给出新表合适的大小,这很容易避免那些初始的再哈希。小心,不管如何,Lua只能在再哈希时候才能收缩表。所以,若是你初始大小比须要的大,Lua可能永远不会纠正你浪费的空间。
当用Lua编程时,你能够用构造器避免那些初始再哈希。当你写下{true, true, true}时,Lua会预先知道表的数组部分将会须要上三个空位,所以Lua用这个大小建立表。一样地,若是你写下{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不会足够智能到检测给出的表达式(本例中是文字数字)指的是数组索引,所以会建立4个空位的哈希表,浪费了内存和CPU时间。
仅有当表从新哈希时,表的数组和哈希部分的大小才会从新计算,只有在表彻底满且Lua须要插入新的元素时候发生。若是你遍历表清除全部的字段(即设置他们为空),结果是表不会收缩。然而,若是你插入一些新的元素,最后表不得不从新调整大小。一般这不是个问题:若是你一直清除元素和插入新的(在不少程序中都是有表明性的),表的大小保持不变。然而,你应该不指望经过清除大的表的字段来恢复内存:最好是释放表自己。
一个强制从新哈希的鬼把戏是插入足够可能是空值到表中。看接下来的例子:
a = {} lim = 10000000 for i = 1, lim do a[i] = i end -- create a huge table print(collectgarbage("count")) --> 196626 for i = 1, lim do a[i] = nil end -- erase all its elements print(collectgarbage("count")) --> 196626 for i = lim + 1, 2*lim do a[i] = nil end -- create many nil elements print(collectgarbage("count")) --> 17
我不推荐这种鬼把戏,除非在特殊状况下:这会很慢而且没有容易的方法指导“足够”是指多少元素。
你可能会好奇为何当插入空值时Lua不会收缩表。首先,要避免测试插入表的是什么;检测赋空值会致使全部的赋值变慢。其次,更重要的是,当遍历表时容许赋空值。思考接下来的这个循环:
for k, v in pairs(t) do if some_property(v) then t[k] = nil -- erase that element end end
若是赋空值后Lua对表从新哈希,这回破坏本次遍历。
若是你想清空表中全部的元素,一个简单的遍历是实现他的正确方法:
for k in pairs(t) do t[k] = nil end
“聪明”的选择是这个循环
while true do local k = next(t) if not k then break end t[k] = nil end
然而,对于很大的表这个循环会很是慢。函数next,当不带前一个键调用时,返回表的“第一个”元素(以某种随机顺序)。这样作,next函数开始遍历表的数组,查找不为空的元素。当循环设置第一个元素为空时,next函数花更长的时间查找第一个非空元素。结果是,“聪明”的循环花费20秒清除有100,000个元素的表;使用pairs遍历循环花费0.04秒。
和表同样,为了更高效的使用字符串,最好知道Lua是如何处理字符串的。
不一样于大多数的脚本语言,Lua实现字符串的方式表如今两个重要的方面。第一,Lua中全部的字符串都是内化的。意思是Lua对任一字符串只保留一份拷贝。不管什么时候出现新字符串,Lua会检测这个字符串是否已经存在备份,若是是,重用拷贝。内化使像字符串的比较和表索引操做很是快,可是字符串的建立会慢。
第二,Lua中的变量从不持有字符串,仅是引用他们。这种实现方式加快了几个字符串的操做。举个例子,在Perl语言中,当你写下相似于$x = $y,$y含有一个字符串,赋值会从$y缓冲中字符串内容复制到$x的缓冲。若是字符串很长的话,这就会变成昂贵的操做。在Lua中,这种赋值只需复制指向字符串的指针。
然而,这种带有引用实现减慢了字符串链接的这种特定形式。在Perl中,$s = $s . "x"和$s . = "x"操做使彻底不同的。在第一个中,你获得的一个$s的拷贝,并在它的末尾加上“x”。在第二个中,“x”简单地附加到由$s变量保存的内部缓冲上。所以,第二种形式和字符串的大小不相关(假设缓冲区有多余文本的空间)。若是你在循环内部用这些命令,他们的区别是线性和二次方算法的区别。举个例子,下面的循环读一个5M的文件花费了约5分钟。
$x = ""; while (<>) { $x = $x . $_; }
若是咱们把 $x = $x . $_ 变成 $x .= $_, 此次时间降低到0.1秒!
Lua不支持第二个,更快的那个,这是由于它的变量没有缓冲和它们相关联。所以,咱们必须用显示的缓冲:字符串表作这项工做。下面的循环0.28秒读取一样的5M文件。虽然不如Perl快,但也很不错了。
local t = {} for line in io.lines() do t[#t + 1] = line end s = table.concat(t, "\n")
当处理Lua资源时,咱们应该一样用推进地球资源的3R倡议。
简化是这三个选项中最简单的。有几种方法能够避免对新对象的须要。举个例子,若是你的程序使用了不少的表,能够考虑数据表现的改动。举个简单的例子,考虑程序操做折线。在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"} -- do something without changing ’t’ ... end end local t = {1, 2, 3, "hi"} -- create ’t’ once and for all function foo (...) for i = 1, n do -- do something without changing ’t’ ... end end
闭包能够用一样的技巧,只要你不把它们移出它们所须要的变量的做用域。举个例子,考虑接下来的函数:
function changenumbers (limit, delta) for line in io.lines() do line = string.gsub(line, "%d+", function (num) num = tonumber(num) if num >= limit then return tostring(num + delta) end -- else return nothing, keeping the original number end) io.write(line, "\n") end end
咱们经过把内部的函数移到循环的外面来避免为每行建立一个新的闭包:
function changenumbers (limit, delta) local function aux (num) num = tonumber(num) if num >= limit then return tostring(num + delta) end end for line in io.lines() do line = string.gsub(line, "%d+", aux) io.write(line, "\n") end end
然而,咱们不能把aux已到changenumbers函数外面,由于那样aux不能访问到limit和delta。
对于不少种字符串处理,咱们能够经过操做现存字符串的索引来减小对新字符串的须要。举个例子,string,find函数返回他找到模式的位置,代替了匹配。经过返回索引,对于每次成功匹配能够避免建立一个新(子)的字符串。当必要时,程序员能够经过调用string.sub获得匹配的子字符串。(标准库有一个比较子字符串的功能是个好主意,以便咱们没必要从字符串提取出那个值(于是建立了一个新字符串))
当咱们不可避免使用新对象时,经过重用咱们任然能够避免建立那些新对象。对于字符串的重用是没有必要的,由于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中新的模式匹配包,对memoizing的使用颇有意思。LPeg把每一个模式编译成内在的形式,一个用于解析机器执行匹配的“程序”。这种编译与匹配自身相比代价很是昂贵。所以,LPeg记下它的编译结果并复用。一个简单的表将描述模式的字符串与相应的内部表示相关联。
memoizing的一般问题是储存之前结果花费的空间可能超过复用这些结果的收益。Lua为了解决这个问题,咱们能够用弱表来保存结果,以便没有用过的结果最后能从表里移除。
Lua中,用高阶函数咱们能够定义个通用的memoization函数:
function memoize (f) local mem = {} -- memoizing table setmetatable(mem, {__mode = "kv"}) -- make it weak return function (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返回相同结果的函数,而且记录它们。举个例子,咱们能够从新定义带memoizing版本的loadstring:
loadstring = memoize(loadstring)
咱们彻底像以前的那个那样使用新函数,可是若是咱们加载的字符串中有不少重复的,咱们能得到可观的收益。
若是你的程序建立和释放太多的协程,回收再生多是个提升性能的选择。当前的协程API不提供直接支持复用协程,可是咱们能够突破这个限制。考虑下面的协程
co = coroutine.create(function (f) while f do 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周期;然而,它能减小程序使用的总的内存,从而减小分页。只有仔细的尝试才能给你这些参数的最佳值。
正如咱们介绍中讨论的那样,优化是有技巧的。这里有几点须要注意,首先程序是否须要优化。若是它有实际的性能问题,那么咱们必须定位到哪一个地方以及如何优化。
这里咱们讨论的技术既不是惟一也不是最重要的一个。咱们关注的是Lua特有的技术,由于有更多的针对通用技术的资料。
在咱们结束前,我想提两个在提高Lua程序性能边缘的选项。由于这两个都涉及到Lua代码以外的变化。第一个是使用LUaJIT,Mike Pall开发的Lua即便编译器。他已经作了出色的工做,而且LuaJIT多是目前动态语言最快的JIT。缺点是,他只能运行在x86架构上,并且,你须要非标准的Lua解释器(LuaJIT)来运行程序。优势是在一点也不改变代码的状况下能快5倍的速度运行你的程序。
第二个选择是把部分代码放到C中。毕竟,Lua的特色之一是与C代码结合的能力。这种状况下,最重要的一点是为C代码选择正确的粒度级别。一方面,若是你只把很是简单的函数移到C中,Lua和C通讯的开销可能超过那些函数对性能提高的收益。另外一方面,若是你把太大的函数移到C中,又会失去灵活性。
最后,谨记,这两个选项有点不兼容。程序中更多的C代码,LuaJIT能优化代码就会更少。
简化,复用,再生