说明
本文主要取材于Lua Programming Gems一书的第二章Lua Performance Tips, 原书试读章节可点击这里下载
测试代码的运行环境均为Raspberry Pi 3, Lua 5.1.5
性能优化的基本原则
- 能不优化则不优化
- 先量化再优化:高手和菜鸟之间的区别不在于高手对于需要优化的点直觉更准,而是高手更清楚自己的经验和感觉都是不可靠的,只能依靠测试数据来定位性能瓶颈。
多用局部变量
就表达式 a = a + b而言,当a, b都是局部变量时,Lua虚拟机只需要执行1条指令就能完成这个加法操作;而当a, b都是全局变量时,则需要执行4条指令才能完成。
先看一个很明显使用了全局变量的例子
-- global_a.lua
a = 1
b = 1
for i = 1, 1000000 do
a = a + b
end
-- local_a.lua
local a = 1
local b = 1
for i = 1, 1000000 do
a = a + b
end
对比两段代码的运行时间, 使用局部变量的版本耗时仅为全局变量的30%
$ time lua global_a.lua
real 0m0.287s
$ time lua local_a.lua
real 0m0.093s
再看一个不那么明显的例子,对比下面两段代码的运行时间
-- global_sin.lua
for i = 1, 1000000 do
local x = math.sin(i)
end
-- local_sin.lua
local sin = math.sin
for i = 1, 1000000 do
local x = sin(i)
end
使用局部变量的版本耗时约为全局变量的70%
$ time lua global_sin.lua
real 0m0.663s
$ time lua local_sin.lua
real 0m0.509s
少用动态代码
由于编译代码是一件很消耗CPU的事情,出于性能的考虑,应该尽量使用静态代码,少用动态代码。
对比下面两段代码的运行时间
-- dynamic_code.lua
local lim = 10000
local a = {}
for i = 1, lim do
a[i] = loadstring(string.format("return %d", i))
end
print(a[10]()) --> 10
-- static_code.lua
local lim = 10000
local a = {}
local fk = function(k) return function() return k end end
for i = 1, lim do
a[i] = fk(i)
end
print(a[10]()) --> 10
使用静态代码的版本耗时仅仅是动态代码的10%
$ time lua dynamic_code.lua
10
real 0m0.219s
$ time lua static_code.lua
10
real 0m0.024s
定义小表时指定初始元素
表是Lua中唯一的数据结构,它的实现分为两部分: 数组部分和散列部分。数组部分用于存放从1开始的连续整数key,散列部分用于存放和已有数组索引不连续的整数以及其他类型的key。例如, t = {100, 200, 300, x = 9.2, pi = 3.14} 的数据实现如下图: [1]
为了尽可能节省内存,Lua虚拟机在定义一个空表时不会为表内元素预先分配空间。当插入新元素由于表的容量不足而需要扩容时,Lua虚拟机会将表的容量增长到不小于元素个数的2的幂。散列部分的扩容操作涉及下面两个动作:
- 为bucket数组分配一个更大的空间
- 重新散列表内元素
因此,当我们定义一个空表T并往里面插入3个字符串元素时,T的散列部分的bucket数组会经历先从0(空表)到1(2的0次幂),再从1到2(2的1次幂),最后从2到4(2的2次幂)一共三次的扩容过程。但是,如果在定义这个大小为3的表时指定了3个初始元素,Lua虚拟机就会一次性为这个表的bucket数组分配4的容量,并消除三次扩容中重新散列的的计算过程,从而实现更优的性能。
对比下面两段代码的运行时间
-- empty_table.lua
for i = 1, 1000000 do
local t = {}
t['e'] = 1; t['s'] = 2; t['m'] = 3
end
-- known_table.lua
for i = 1, 1000000 do
local t = {e = 1, s = 2, m = 3}
end
指定了初始元素的版本耗时不到空表版本的50%
$ time lua empty_table.lua
real 0m4.290s
$ time lua known_table.lua
real 0m1.962s
以上对比了只使用了散列部分的表的两种初始化方式的性能,我们再来看只使用了数组部分的表的情况。数组部分的扩容操作和散列部分的bucket数组的扩容操作是一样的。只使用数组部分的表的扩容虽不涉及重新散列操作,但仍需要重新分配内存和复制已有元素,因此预分配的实现依然更为高效。
对比下面两段代码的运行时间
-- empty_array.lua
for i = 1, 1000000 do
local t = {}
t[1] = 'e'; t[2] = 's'; t[3] = 'm'
end
-- known_array.lua
for i = 1, 1000000 do
local t = {'e', 's', 'm'}
end
指定了初始元素的版本耗时不到空表版本的50%
$ time lua empty_array.lua
real 0m0.870s
$ time lua known_array.lua
real 0m0.340s
使用table.concat函数实现多个字符串的拼接操作
Lua对于相同的字符串只保留一份拷贝,所有变量均是指向字符串的引用。这样设计的优点除了能节省内存,还能在常数时间内实现两个任意长度字符串的比较操作。但弱点在于进行两个字符串的拼接操作时必须先将前面那个字符串复制一个副本出来,再将后面那个字符串添加到该副本后面。因此,如果直接使用“..”运算符来实现字符串拼接,时间复杂度关于拼接字符串的个数不是线性的,而是成平方关系。为了解决这个问题,Lua引入了能使字符串拼接操作保持线性时间复杂度的标准库函数table.concat。
对比下面两段代码的运行时间
-- direct_append.lua
r = ""
s = "0123456789abcdefghijklmnopqrstuvwxyz"
for i = 1, 10000 do
r = r .. s
end
-- table_concat.lua
s = "0123456789abcdefghijklmnopqrstuvwxyz"
t = {}
for i = 1, 10000 do
t[i] = s
end
table.concat(t)
使用table.concat的版本耗时不到直接拼接的1%
$ time lua direct_append.lua
real 0m2.924s
$ time lua table_concat.lua
real 0m0.026s
[1] R. Ierusalimschy, L. H. Figueiredo, and W. Celes. The Implementation of Lua 5.0, pages 6-8, https://www.lua.org/doc/jucs05.pdf.