Lua笔记效率性能

5.5k 词

本文使用对比测评的方式比较了几种Lua中代码和方式,并做了简要的总结和分析。结合其中结论,可指导写出质量更高、性能更优的脚本。本文基于Lua5.1,大部分内容也同样适用于Lua5.2和5.3。本文中引用到的Lua源代码都取自Lua 5.1.5版本。

评测方法

主要有运行时间和产生GC两个指标,以下是获取运行时间和内存用量/GC的方法:

获取运行时间

为了更好地做比较,使用下边的方法来记录代码的运行时间:

1
2
3
4
5
6
7
local a, b
a = os.clock()



b = os.clock()
print(b-a)

获取内存用量

1
2
3
4
5
collectgarbage("stop")

collectgarbage("collect")

print(collectgarbage("count"))

上边三行代码都是调用函数collectgarbage(),配合传入的不同的参数达到不同的目的:

  • stop 停止自动GC

  • collect 执行一次完整的GC循环

  • count 返回当前内存用量,以k为单位

使用local保存全局变量的引用

使用local保存全局变量的引用可以提高变量访问效率。

测试代码

1
2
3
4
5
6
7
8
9
10
11
12
local sin = math.sin

local a,b
a = os.clock()
local res = 0
for i = 1, 10000000 do
res = res + math.sin(i) -- case 1
--res = res + sin(i) -- case 2
end

b = os.clock();
print("cost time = " .. b-a)

运行以上代码得到case1和case2的输出分别为:

1
2
3
4
5
//case 1
cost time = 2.10697

//case 2 [better]
cost time = 1.260123

分析

Lua使用的是基于寄存器的虚拟机(使用一个栈来提供寄存器),所有的local变量都会保存在寄存器里,因此访问local变量的速度会快于全局变量。在Lua的源码中可以看到对局部变量的最大数量有200的限制:

1
2
3
4
5
/*
@@ LUAI_MAXVARS is the maximum number of local variables per function
@* (must be smaller than 250).
*/

尾递归调用

对于一些情况下的函数递归调用,转为尾递归写法,可以减少调用栈的分配,提高函数运行效率同时也避免了栈溢出的风险。

测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
local n = 0

local add = function()
n = n + 1
end

local function (times)
if times <= 0 then
print("now n = " .. n)
return
end
add()
return fr(times - 1) -- case 1
-- fr(times - 1) -- case 2
end

local a, b
a = os.clock()

fr(100000)

b = os.clock()
print(b-a)

运行以上代码得到case1和case2的输出分别为:

1
2
3
4
5
6
7
//case 1  [better]
now n = 100000
0.020423

//case 2
now n = 100000
0.050295

分析

fr调用add后不会再做任何事情,这种情况下当被调用函数add结束时程序不需要返回到调用者fr;尾调用不需要使用额外的栈空间,因此尾调用递归的层次可以无限制的。

在Lua的源代码lvm.c中可以看到以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
case OP_CALL: {
int b = GETARG_B(i);
int nresults = GETARG_C(i) - 1;
if (b != 0) L->top = ra+b; /* else previous instruction set top */
L->savedpc = pc;
switch (luaD_precall(L, ra, nresults)) {
case PCRLUA: {
nexeccalls++;
goto reentry; /* restart luaV_execute over new Lua function */
}
case PCRC: {
/* it was a C function (`precall' called it); adjust results */
if (nresults >= 0) L->top = L->ci->top;
base = L->base;
continue;
}
default: {
return; /* yield */
}
}
}
case OP_TAILCALL: {
int b = GETARG_B(i);
if (b != 0) L->top = ra+b; /* else previous instruction set top */
L->savedpc = pc;
lua_assert(GETARG_C(i) - 1 == LUA_MULTRET);
switch (luaD_precall(L, ra, LUA_MULTRET)) {
case PCRLUA: {
/* tail call: put new frame in place of previous one */
CallInfo *ci = L->ci - 1; /* previous frame */
int aux;
StkId func = ci->func;
StkId pfunc = (ci+1)->func; /* previous function index */
if (L->openupval) luaF_close(L, ci->base);
L->base = ci->base = ci->func + ((ci+1)->base - pfunc);
for (aux = 0; pfunc+aux < L->top; aux++) /* move frame down */
setobjs2s(L, func+aux, pfunc+aux);
ci->top = L->top = func+aux; /* correct top */
lua_assert(L->top == L->base + clvalue(func)->l.p->maxstacksize);
ci->savedpc = L->savedpc;
ci->tailcalls++; /* one more call lost */
L->ci--; /* remove new frame */
goto reentry;
}
case PCRC: { /* it was a C function (`precall' called it) */
base = L->base;
continue;
}
default: {
return; /* yield */
}
}
}

由于没有系统研读过Lua的源码,仅结合注释及上下文做出一些判断。Lua中高端函数调用与函数尾调用使用的是两个指令(OP_CALLOP_TAILCALL),二者很相似,都是通过luaD_precall执行函数调用,并根据调用了Lua函数还是C函数进行处理。在OP_TAILCALL中,调用Lua函数之后,可以看到有这样的操作:取到“previous frame”即之前的调用信息CallInfo *ci,使用新增的CallInfo中的信息取替换ci中的内容,并移除新增的CallInfo。直接复用CallInfo从而调用函数不增加栈的深度。

减少rehash

初始化一个表时,可以直接在构造表的时候为表内的key赋值,这样做会好于创建空表再为其填入键和值。因为后者可能触发大量rehash。

测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

local function gettable_1()
local t = {
a=1,
b=2,
c=3,
d=4,
e=5,
f=6,
g=7,
h=8,
i=9,
j=10
}
return t
end

local function gettable_2()
local t = {}
t.a=1
t.b=2
t.c=3
t.d=4
t.e=5
t.f=6
t.g=7
t.h=8
t.i=9
t.j=10
return t
end

local a,b
a = os.clock()

local t={}
for i = 1,100000 do

--t = gettable_1() -- case 1
t = gettable_2() -- case 2

end

b=os.clock()
print("cost time " .. b - a)

运行以上代码得到case1和case2的输出分别为:

1
2
3
4
5
//case 1 [better]
cost time 0.126207

//case 2
cost time 0.332967

分析

Lua中表的实现涉及到一些巧妙的算法。每个表都包含两个部分:array部分和hash部分。array部分保存以整数1到n为键的值,其中n是一些特定的值(稍后会说n是怎么算出来的)。所有的其它键值对(包括整数键但是整数超出1到n的范围)保存在hash部分。

如其名所示,hash部分使用hash算法来存储和查找键。它使用的是开放地址法(open address table),所有的键值对都存储在一个hash数组中。使用hash函数来为键取得索引;如果出现hash冲突(即两个不同的键hash得出了相同的索引位置),对应的键会存在一个list列表中,列表中每个元素对应的是一个hash数组中的元素。

当Lua需要向表中插入新的值并且此时hash数组已满时,就会触发rehash。rehash的第一步就是计算新的array部分和hash部分的尺寸。为此,Lua会遍历所有的键值对,计数并分类,然后选择能够使array部分的元素填充率超过一半的最大的2的次方数作为array部分的新尺寸。而hash部分的尺寸是能够容纳所有剩余元素的2的次方数(与array部分的策略不同)。

当创建空表时,array部分和hash部分的尺寸都是0,因此插入新的值的时候就会频繁地触发rehash。

字符串效率

这里基于Lua5.1(5.2开始有长短字符串之分,部分逻辑可能与这里不一样,但结论相似)。对于短小的字符串拼接(或字符串与数字拼接),直接使用..。对于较大量的字符串的拼接,存入table使用table.concat来拼接性能更佳。

测试代码

比较string.format 和 拼接..

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
local tostring = tostring
local format = string.format

local function stringformat_1(str,num)
return str.." " .. num
end

local function stringformat_2(str,num)
return format("%s %d",str,num)
end

local n = 1
local function getargs()
return tostring(n),n
end

collectgarbage("collect")
collectgarbage("stop")

local a,b
a = os.clock()

for i = 1,1000000 do

-- local str = stringformat_1(getargs()) -- case 1
local str = stringformat_2(getargs()) -- case 2

end

b=os.clock()
print("cost time " .. b - a)

local c,d
c= collectgarbage("count")
collectgarbage("collect")
d = collectgarbage("count")

print("gc " .. c - d)

运行以上代码得到case1和case2的输出分别为:

1
2
3
4
5
6
7
//case 1 [better]
cost time 1.222692
gc 0.2373046875

//case 2
cost time 1.533907
gc 0.9404296875

接下来是拼接..table.concat

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
local unpack = unpack or table.unpack

local function stringconcat_1(...)
local args = {...}
local ret = ""
for i,v in ipairs(args) do
ret = ret .. v
end
return ret
end

local function stringconcat_2(...)
local args = {...}
local ret = table.concat(args)
return ret
end

local t={}
for i = 1,10000 do
t[i] = tostring(i)
-- t[i] = ""
end