Skip to Content

高性能 Lua 技巧

Posted on 6 mins read

http://www.lua.org/gems/sample.pdf

http://wuzhiwei.net/lua_performance/

使用local

在代码运行前,Lua 会把源码预编译成一种中间码,类似于 Java 的虚拟机。这种格式然后会通过 C 的解释器进行解释,整个过程其实就是通过一个 while 循环,里面有很多的 switch … case 语句,一个case对应一条指令来解析。自 Lua 5.0 之后,Lua 采用了一种类似于寄存器的虚拟机模式。Lua用栈来储存其寄存器。每一个活动的函数,Lua 都会其分配一个栈,这个栈用来储存函数里的活动记录。每一个函数的栈都可以储存至多 250 个寄存器,因为栈的长度是用 8 个比特表示的。有了这么多的寄存器,Lua 的预编译器能把所有的 local 变量储存在其中。这就使得 Lua 在获取 local 变量时其效率十分的高。

看下面的示例代码:

-- local a,b=0,3
a,b=0,3
a=b+1
print( a )

再看加与不加 local 预编译产生的指令对比:

有 local 修饰,直接 ADD

main <run.lua:0,0> (7 instructions, 28 bytes at 0x8b9530)
0+ params, 4 slots, 0 upvalues, 2 locals, 4 constants, 0 functions
	1	[50]	LOADK    	0 -1	; 0
	2	[50]	LOADK    	1 -2	; 3
	3	[51]	ADD      	0 1 -3	; - 1
	4	[52]	GETGLOBAL	2 -4	; print
	5	[52]	MOVE     	3 0
	6	[52]	CALL     	2 2 1
	7	[52]	RETURN   	0 1

没有 local 修饰,先从 Global 中取,计算完的结果再 SETGLOBAL

main <run.lua:0,0> (11 instructions, 44 bytes at 0x1704530)
0+ params, 2 slots, 0 upvalues, 0 locals, 6 constants, 0 functions
	1	[51]	LOADK    	0 -3	; 0
	2	[51]	LOADK    	1 -4	; 3
	3	[51]	SETGLOBAL	1 -2	; b
	4	[51]	SETGLOBAL	0 -1	; a
	5	[52]	GETGLOBAL	0 -2	; b
	6	[52]	ADD      	0 0 -5	; - 1
	7	[52]	SETGLOBAL	0 -1	; a
	8	[53]	GETGLOBAL	0 -6	; print
	9	[53]	GETGLOBAL	1 -1	; a
	10	[53]	CALL     	0 2 1
	11	[53]	RETURN   	0 1

所以在编写 Lua 代码时,应该尽量使用 local 变量。

a = os.clock()
for i = 1,10000000 do
  local x = math.sin(i)
end
b = os.clock()
print(b-a) --1.113454

把 math.sin 赋给 local 变量 sin

a = os.clock()
local sin = math.sin
for i = 1,10000000 do
  local x = sin(i)
end
b = os.clock()
print(b-a) --0.75951

直接使用 math.sin,耗时 1.11 秒;使用 local 变量 sin 来保存 math.sin,耗时 0.76 秒。可以获得 30% 的效率提升!

关于表(table) 避免Lua table的rehash?

Hello!

2014-02-24 6:00 GMT-08:00 smallfish: > 我看了下,table.new(narray, nhash) 两个参数分别代表table里是array还是hash的 > > 差不多两种:table.new(10, 0) 或者 table.new(0, 10) 这样的,后者是 hash 性质的 table >

Lua table 可以同时拥有数组部分和哈希部分。在物理上,数组部分和哈希部分也是在 table 内部分开存储的。比如 table.new(100, 200) 也是完全合法的。

table.new(narr, nrec) 接口是标准 Lua C API 函数 lua_createtable() 的 Lua 版本:

http://www.lua.org/manual/5.1/manual.html#lua_createtable 

另外,不同于使用了 lua_createtable() 的 lua_CFunction,table.new() 是可以被 JIT 编译的。

table.new() 一般用于给 lua table 预分配空间。否则 table 会在插入新元素时自增长,而自增长是高代价操作(因为需要重新分配空间、重新 hash,以及拷贝数据)。

Regards, -agentzh

表在Lua中使用十分频繁,因为表几乎代替了Lua的所有容器。所以快速了解一下Lua底层是如何实现表,对我们编写Lua代码是有好处的。

Lua的表分为两个部分:数组(array)部分和哈希(hash)部分。数组部分包含所有从1到n的整数键,其他的所有键都储存在哈希部分中。

哈希部分其实就是一个哈希表,哈希表本质是一个数组,它利用哈希算法将键转化为数组下标,若下标有冲突(即同一个下标对应了两个不同的键),则它会将冲突的下标上创建一个链表,将不同的键串在这个链表上,这种解决冲突的方法叫做:链地址法。

当我们把一个新键值赋给表时,若数组和哈希表已经满了,则会触发一个再哈希(rehash)。再哈希的代价是高昂的。首先会在内存中分配一个新的长度的数组,然后将所有记录再全部哈希一遍,将原来的记录转移到新数组中。新哈希表的长度是最接近于所有元素数目的2的乘方。

当创建一个空表时,数组和哈希部分的长度都将初始化为0,即不会为它们初始化任何数组。让我们来看下执行下面这段代码时在Lua中发生了什么:

0 1 2 3 local a = {} for i=1,3 do a[i] = true end 最开始,Lua创建了一个空表a,在第一次迭代中,a[1] = true触发了一次rehash,Lua将数组部分的长度设置为2^0,即1,哈希部分仍为空。在第二次迭代中,a[2] = true再次触发了rehash,将数组部分长度设为2^1,即2。最后一次迭代,又触发了一次rehash,将数组部分长度设为2^2,即4。

下面这段代码:

0 1 a = {} a.x = 1; a.y = 2; a.z = 3 与上一段代码类似,只是其触发了三次表中哈希部分的rehash而已。

只有三个元素的表,会执行三次rehash;然而有一百万个元素的表仅仅只会执行20次rehash而已,因为2^20 = 1048576 > 1000000。但是,如果你创建了非常多的长度很小的表(比如坐标点:point = {x=0,y=0}),这可能会造成巨大的影响。

如果你有很多非常多的很小的表需要创建时,你可以将其预先填充以避免rehash。比如:{true,true,true},Lua知道这个表有三个元素,所以Lua直接创建了三个元素长度的数组。类似的,{x=1, y=2, z=3},Lua会在其哈希部分中创建长度为4的数组。

以下代码执行时间为1.53秒:

0 1 2 3 4 5 6 a = os.clock() for i = 1,2000000 do local a = {} a[1] = 1; a[2] = 2; a[3] = 3 end b = os.clock() print(b-a) –1.528293 如果我们在创建表的时候就填充好它的大小,则只需要0.75秒,一倍的效率提升!

0 1 2 3 4 5 6 a = os.clock() for i = 1,2000000 do local a = {1,1,1} a[1] = 1; a[2] = 2; a[3] = 3 end b = os.clock() print(b-a) –0.746453 所以,当需要创建非常多的小size的表时,应预先填充好表的大小。

关于字符串

与其他主流脚本语言不同的是,Lua在实现字符串类型有两方面不同。

第一,所有的字符串在Lua中都只储存一份拷贝。当新字符串出现时,Lua检查是否有其相同的拷贝,若没有则创建它,否则,指向这个拷贝。这可以使得字符串比较和表索引变得相当的快,因为比较字符串只需要检查引用是否一致即可;但是这也降低了创建字符串时的效率,因为Lua需要去查找比较一遍。

第二,所有的字符串变量,只保存字符串引用,而不保存它的buffer。这使得字符串的赋值变得十分高效。例如在Perl中,$x = $y,会将$y的buffer整个的复制到$x的buffer中,当字符串很长时,这个操作的代价将十分昂贵。而在Lua,同样的赋值,只复制引用,十分的高效。

但是只保存引用会降低在字符串连接时的速度。在Perl中,$s = $s . ‘x’和$s .= ‘x’的效率差距惊人。前者,将会获取整个$s的拷贝,并将’x’添加到它的末尾;而后者,将直接将’x’插入到$x的buffer末尾。

由于后者不需要进行拷贝,所以其效率和$s的长度无关,因为十分高效。

在Lua中,并不支持第二种更快的操作。以下代码将花费6.65秒:

0 1 2 3 4 5 6 a = os.clock() local s = “ for i = 1,300000 do s = s .. ‘a’ end b = os.clock() print(b-a) –6.649481 我们可以用table来模拟buffer,下面的代码只需花费0.72秒,9倍多的效率提升:

0 1 2 3 4 5 6 7 8 a = os.clock() local s = “ local t = {} for i = 1,300000 do t[#t + 1] = ‘a’ end s = table.concat( t, “) b = os.clock() print(b-a) –0.07178 所以:在大字符串连接中,我们应避免..。应用table来模拟buffer,然后concat得到最终字符串。

3R原则

3R原则(the rules of 3R)是:减量化(reducing),再利用(reusing)和再循环(recycling)三种原则的简称。

3R原则本是循环经济和环保的原则,但是其同样适用于Lua。

Reducing

有许多办法能够避免创建新对象和节约内存。例如:如果你的程序中使用了太多的表,你可以考虑换一种数据结构来表示。

举个栗子。 假设你的程序中有多边形这个类型,你用一个表来储存多边形的顶点:

0 1 2 3 4 5 polyline = { { x = 1.1, y = 2.9 }, { x = 1.1, y = 3.7 }, { x = 4.6, y = 5.2 }, … } 以上的数据结构十分自然,便于理解。但是每一个顶点都需要一个哈希部分来储存。如果放置在数组部分中,则会减少内存的占用:

0 1 2 3 4 5 polyline = { { 1.1, 2.9 }, { 1.1, 3.7 }, { 4.6, 5.2 }, … } 一百万个顶点时,内存将会由153.3MB减少到107.6MB,但是代价是代码的可读性降低了。

最变态的方法是:

0 1 2 3 polyline = { x = {1.1, 1.1, 4.6, …}, y = {2.9, 3.7, 5.2, …} } 一百万个顶点,内存将只占用32MB,相当于原来的1/5。你需要在性能和代码可读性之间做出取舍。

在循环中,我们更需要注意实例的创建。

0 1 2 3 4 for i=1,n do local t = {1,2,3,‘hi’} –执行逻辑,但t不更改 … end 我们应该把在循环中不变的东西放到循环外来创建:

0 1 2 3 4 local t = {1,2,3,‘hi’} for i=1,n do –执行逻辑,但t不更改 … end Reusing

如果无法避免创建新对象,我们需要考虑重用旧对象。

考虑下面这段代码:

0 1 2 3 local t = {} for i = 1970, 2000 do t[i] = os.time({year = i, month = 6, day = 14}) end 在每次循环迭代中,都会创建一个新表{year = i, month = 6, day = 14},但是只有year是变量。

下面这段代码重用了表:

0 1 2 3 4 5 local t = {} local aux = {year = nil, month = 6, day = 14} for i = 1970, 2000 do aux.year = i; t[i] = os.time(aux) end 另一种方式的重用,则是在于缓存之前计算的内容,以避免后续的重复计算。后续遇到相同的情况时,则可以直接查表取出。这种方式实际就是动态规划效率高的原因所在,其本质是用空间换时间。

Recycling

Lua自带垃圾回收器,所以我们一般不需要考虑垃圾回收的问题。

了解Lua的垃圾回收能使得我们编程的自由度更大。

Lua的垃圾回收器是一个增量运行的机制。即回收分成许多小步骤(增量的)来进行。

频繁的垃圾回收可能会降低程序的运行效率。

我们可以通过Lua的collectgarbage函数来控制垃圾回收器。

collectgarbage函数提供了多项功能:停止垃圾回收,重启垃圾回收,强制执行一次回收循环,强制执行一步垃圾回收,获取Lua占用的内存,以及两个影响垃圾回收频率和步幅的参数。

对于批处理的Lua程序来说,停止垃圾回收collectgarbage(“stop”)会提高效率,因为批处理程序在结束时,内存将全部被释放。

对于垃圾回收器的步幅来说,实际上很难一概而论。更快幅度的垃圾回收会消耗更多CPU,但会释放更多内存,从而也降低了CPU的分页时间。只有小心的试验,我们才知道哪种方式更适合。

结语

我们应该在写代码时,按照高标准去写,尽量避免在事后进行优化。

如果真的有性能问题,我们需要用工具量化效率,找到瓶颈,然后针对其优化。当然优化过后需要再次测量,查看是否优化成功。

在优化中,我们会面临很多选择:代码可读性和运行效率,CPU换内存,内存换CPU等等。需要根据实际情况进行不断试验,来找到最终的平衡点。

最后,有两个终极武器:

第一、使用LuaJIT,LuaJIT可以使你在不修改代码的情况下获得平均约5倍的加速。查看LuaJIT在x86/x64下的性能提升比。

第二、将瓶颈部分用C/C++来写。因为Lua和C的天生近亲关系,使得Lua和C可以混合编程。但是C和Lua之间的通讯会抵消掉一部分C带来的优势。

注意:这两者并不是兼容的,你用C改写的Lua代码越多,LuaJIT所带来的优化幅度就越小。

声明

这篇文章是基于Lua语言的创造者Roberto Ierusalimschy在Lua Programming Gems 中的Lua Performance Tips翻译改写而来。本文没有直译,做了许多删节,可以视为一份笔记。

感谢Roberto在Lua上的辛勤劳动和付出!

写代码也有风格?

当然,写代码就跟写文章一样,每个人或多或少都有自己的风格。不同的语言也就像不同的文体一样,也有自己的独特的风格。Lua是一门脚本语言,写起来轻松惬意,但不代表它没有属于自己的风格指南。

好的代码风格基于可读性和一致性。代码更多的时间是给人看的,如果思考好了结构和逻辑,写代码的过程其实很快。风格的一致性也很重要,这样可以减少复杂度和理解成本。养成一种良好的代码风格会形成一种良好写代码习惯,这种习惯会使编码事半功倍。

下文将从命名,作用域,模块,注释和惯用法(精巧用法)等方面来说明Lua的代码风格,文章的最后会附上一些参考资料的链接以供读者拓展阅读。

命名

最好的代码是自说明代码,这种代码不需要多余的注释,其本身便具备了描述作者意图的信息。一种好的命名风格是自说明代码的基础。

命名法

驼峰命名法

小驼峰式命名法:第一个单字以小写字母开始;第二个单字的首字母大写,例如:firstName、lastName。 大驼峰式命名法:每一个单字的首字母都采用大写字母,例如:FirstName、LastName、CamelCase,也被称为Pascal命名法。 下划线命名法

小下划线命名法:所有字母均为小写,例如登录按钮:login_btn。 大下划线命名法:所有字母均为大写,常见于常量,例如:最小间隔时间MIN_GAP_TIME。 采用驼峰法或者下划线法都不太重要,重要的是你采用了自己喜欢的一种命名法,然后一直保持下去。

变量名长度

通常作用域范围更大的变量名要比作用域范围更小的变量名具有更多的描述信息。例如:i经常用于循环中充当计数变量,而将其作为全局变量使用容易导致诸多问题。

变量命名

对于变量(包括函数),小驼峰式命名法或小下划线命名法是一个好选择。比如:curSpeed表示当前速度,canDrop表示是否能掉落等等。

对于布尔值型的变量,通常前缀加上is可以方便理解,比如isRemoved比Removed更加能表示这是一个布尔值变量。

Lua中有一种特殊的变量名:_,常用来表示可以被忽略的、不会使用到的变量,常使用在循环中。

0 1 – _表示表的键可以被忽略,只在循环内使用表中的值v for _,v in ipairs(t) do print(v) end 在表的循环中和函数参数列表中,i常表示ipairs下的数组下标,k常表示pairs下的键,v常表示对应的值,t则表示表。

0 1 2 for k,v in pairs(t) do … end for i,v in ipairs(t) do … end mt.__newindex = function(t, k, v) … end 常数命名

Lua里没有严格的常数定义标识符,所以对于常数的命名格外重要。

常数一般采用大下划线命名法。这样每个字母都大写,十分醒目,且各个单词都用下划线分割,便于阅读。

比如:MAX_SPEED表示最大速度,IS_SHOW_DEBUG_ERROR_MSG表示是否显示报错消息等等。

类名

为了不与变量名和常数名混淆,类名通常使用大驼峰式命名法,即首字母大写。比如:TouchManager表示触摸管理器类。

包和模块名

包名和模块名通常很短,并且全部小写,单词间并没有下划线区分。比如:文件读取库名为lfs,表示Lua File System;XML解析库名为lxp,表示Lua XML Parser等等。

文件名

通常为了不与类名混淆,对于文件名,经常使用小驼峰式命名法或小下划线命名法。

作用域

Lua的作用域以关键字end进行标识。

对于变量,有一条原则:在一切能使用local修饰的情况下,使用local进行修饰。

因为不用local修饰的变量会自动变成全局变量。全局变量十分危险,很容易被篡改而不知道在哪里被篡改了,这很容易导致顽固的bug出现。并且全局变量的处理速度也比局部变量的速度要慢很多。

所以,尽可能的用local来修饰变量。

有时候,用do .. end可以用来明确限定局部变量的作用域。

0 1 2 3 4 5 6 7 8 9 10 11 12 local v do local x = u2*v3-u3*v2 local y = u3*v1-u1*v3 local z = u1*v2-u2*v1 v = {x,y,z} end – x,y,z的作用域结束,被系统清理

local count do local x = 0 count = function() x = x + 1; return x end end – x的作用域结束,被系统清理 模块

Lua中有一个叫module的公有函数,此函数的作用是将一组变量和函数打包在一个模块名下,便于其他文件require。但是这个函数受到了诸多的指责,原因是其会创建一个公共变量,并且这个公共变量中的所有细节都会暴露出来。这其实十分不符合面向对象的规范。

以下有一种办法可以避免这个问题,即不采用module函数进行打包。

0 1 2 3 4 5 6 7 8 – hello/mytest.lua

local M = {} – 私有变量

local function test() print(123) end function M.test1() test() end function M.test2() M.test1(); M.test1() end

return M – 关键 以下是导入此模块的方法。

0 1 local MT = require “hello.mytest” MT.test2() Lua内没有类这个变量类型,但是通过Lua的metatable可以轻松实现类的继承,多态等等特性。关于Lua中类的实现原理,请参考我之前写的这篇博客:Lua中实现类的原理。

注释

通常在–前加上一个空格。

0 1 return nil – not found (建议) return nil –not found (不建议) 注释通常用在函数接口,或者复杂,精巧的逻辑上。

对于接口的注释,可以按照javadoc类似的来写。

0 1 2 3 4 5 6 – Deletes a session.

– @param id Session identification.

function delete (id) assert (check_id (id)) remove (filename (id)) end 惯用法(精巧用法)

尽可能使用local修饰变量(重要的事情要说三遍!)

原因:

使用local的变量会在作用域结束时释放其内存 使用local的变量会比全局变量的存取更快 全局变量会污染全局的命名空间,可能会导致诡异的bug出现 直接判断真假值

0 1 2 3 4 5 6 7 8 – 不推荐 if obj ~= nil and willBreak == false then – … end

– 推荐 if obj and not willBreak then – … end 原因:Lua在逻辑判断时将所有false和nil的逻辑判断视为假,反之则全部视为真,不需要再与布尔值和nil进行显式比对。

但是,在需要对false和nil进行区分时,需要写明==:obj == nil和obj == false。

默认参数的实现

范式:param = param or defaultValue

0 1 2 3 function setName(name) name = name or ‘noName’ – … end 原因:or会在第一次为true的时候断路,返回其判断的最后一个值。所以当name为空时,name or ‘noName’返回为’noName’,这会将name的值自动设置为noName。

一行代码实现表的拷贝

0 u = {unpack(t)} 需要注意的是此法在表内条目大于2000时会失效。

一行代码判断表是否为空

用#t == 0并不能判断表是否为空,因为#预算符会忽略所有不连续的数字下标和非数字下标。

正确做法是:

0 1 2 3 if next(t) == nil then – 表为空 – … end 因为表的键可能为false,所以必须与nil比较,而不直接使用~next(t)来判断表是否空。

更快的插入代码

0 1 2 3 4 – 更慢,不推荐 table.insert(t, value)

– 更快,推荐 t[#t+1] = value 原因:[]和#避免了高层的函数调用开销。

参考资料

这篇文章是基于Lua Style Guide而来。

comments powered by Disqus