背景
写Lua代码似乎不需要考虑性能,毕竟都用Lua了,如果考虑性能直接用C 不就好了。但是勤俭节约是中华民族传统美德,能省点cpu是一点。特别是在Lua使用越来越多的时候。
示例
考虑如下写法:
代码语言:lua复制local a = {}
a.b = {}
a.b.data = "a"
a.b.c = {}
a.b.c.data = "b"
a.b.c.d = {}
a.b.c.d.data = "c"
a.b.c.d.e = {}
上面的代码,每次访问a.b都会触发一次table的访问,这样会影响性能。比较好的写法是使用一个local变量接一下,如下所示:
代码语言:lua复制local a = {}
a.b = {}
local a_b = a.b
a_b.data = "a"
a_b.c = {}
local a_b_c = a_b.c
a_b_c.data = "b"
a_b_c.d = {}
local a_b_c_d = a_b_c.d
a_b_c_d.data = "c"
a_b_c_d.e = {}
优化后的代码,执行速度是之前的2倍(测试代码是一个a-z的连续table访问)
但是显而易见,代码可读性差了一点点,不是那种看一眼就知道做了什么事情的代码,而且写起来也不是太舒服,远没有一顿复制粘贴来的爽。
方案
那么我们可以用工具来解决这个问题,用工具分析出这样的代码,然后进行替换。
这个工具听起来好像很简单,实际上也雀食不复杂,但是还是有一些细节点需要注意。
替换规则
首先要明确替换的规则,从前面的例子可以看到规律,当对代码a.b
反复使用的时候,就应该替换了。比如:
a.b.c = 1
a.b.d = 2
有人可能会问,反复使用a
的不替换吗?像这样:
a.b = 1
a.c = 2
a.d = 3
答:这种也不用替换吧,a本身就是一个变量了。
那么怎么替换a.b
,是分析读和写,来替换吗?比如在对a.b
的写之前,我都可以用一个变量来代替a.b
的读,这样就可以加速了。
但是这里有个问题,Lua是一门特别灵活的语言,你甚至不知道a.b是不是一个table。又或者运行中变成了一个另一个类型。更不提经过了函数的一圈调用,长什么样连代码作者可能都不知道。要静态分析出读和写,是很难的。
所以我们做一个假设推断,当我对一个a.b
赋值构造的table后,就不会再更改a.b
为其他table或者其他类型,这样我就可以把后续所有的a.b
替换成局部变量,就像上面的示例代码那样。
违反这个假设的是这样的代码:
代码语言:lua复制a.b = {}
a.b.c = "1"
a.b.d = "2"
a.b = {}
如果替换为:
代码语言:lua复制a.b = {}
local a_b = a.b
a_b.c = "1"
a_b.d = "2"
a_b = {}
优化后的a.b
并没有变成空table,与优化前不等价了。所以这样的代码是不适用工具优化的,当然值得庆幸的是这样的代码并不多。
有人又会问了,我如果用c
接一下a.b
,怎么办?比如:
a.b = {}
a.b.c = "1"
a.b.d = "2"
c = a.b
c.e = "3"
显然,这样的替换之后,也是OK的
代码语言:lua复制a.b = {}
local a_b = a.b
a_b.c = "1"
a_b.d = "2"
c = a_b
c.e = "3"
因为table的赋值,只是引用,传递的是指针,所以大家的修改都是等效的。
替换的小细节
有一种情况是无需替换的,那就是后续对a.b
的使用只有1次的情况(0次当然更不会替换)比如这样的代码:
a.b = {}
a.b.c = 1
这里,就算替换成了
代码语言:lua复制a.b = {}
local a_b = a.b
a_b.c = 1
发现没有,总的table访问次数是一样的,所以这种就不用白费力气了。
正则匹配 or 语法解析
既然搞清楚怎么替换,那还不简单,我直接正则匹配行不行?应该可行,但是还是分析语法更容易些,比如要替换这样的代码:
代码语言:txt复制a.b = {
k1 = 1,
k2 = 2,
k3 = {3},
}
a.b.c = 1
a.b.d = 2
要找到待替换项a.b
就得费挺大劲。还不如分析下语法更快,有很多现成的分析Lua语法的库可供使用。
作用域
另一个要考虑的是作用域问题,每一个替换只能影响他所在的作用域,例如下面的代码:
代码语言:lua复制if c then
local a.b = {}
a.b.c = 1
a.b.d = 2
else
a.b.e = 3
end
如果粗暴的把a.b
替换,那么就变成:
if c then
local a.b = {}
local a_b = a.b
a_b.c = 1
a_b.d = 2
else
a_b.e = 3
end
而a_b
在下面的block里,是未定义的,这就不对了,所以替换只能发生在他所在的作用域中。
另外前面讲到的判断是否替换(对a.b
的使用次数大于1)也是一样的要考虑作用域问题,例如:
if c then
local a.b = {}
a.b.c = 1
else
a.b.d = 2
a.b.e = 3
end
虽然这里a.b
使用了很多次,但是在then的block里,只有1次使用,因为不会发送替换操作。
local代码插入
前面讲完了替换,该插入关键的local a_b = a.b
代码了。
这一行代码怎么插入呢?有的人会说,很简单,在a.b = {}
的下一行插入不就好了?
那么考虑下这种代码:
代码语言:lua复制a.b = {
k1 = 1,
k2 = 2,
}
a.b.c = 1
a.b.d = 2
因此这一句local
必须插在a.b
整个table construct之后才行。也就是
a.b = {
k1 = 1,
k2 = 2,
}
local a_b = a.b
a_b.c = 1
a_b.d = 2
所以需要分析整个table所占的行才行,不幸的是,很多库,分析出来的语法树,对于末尾的}
是不感知的,毕竟那个东西并没有实际意义。
因此问题来了,如何拿到这一大坨的最后一句代码?然后插入我们的local
?
很简单,我们取他下一句代码的行号,只需要保证在那一行之前插入local
就行了。也就是获取a.b.c = 1
这一句的初始行号。
又有人会问了,如果下一行没有代码呢?也就是a.b = {}
就是它所在block的最后一句代码,那相当于是这样的:
if c then
a.b = {}
end
这种a.b
显然也不会需要被替换。
另一个比较奇葩的点,是有的Lua会这么写:
代码语言:lua复制a.b = {
k1 = 1,
k2 = 2,
};
a.b.c = 1
a.b.d = 2
这里大括号后,多写了分号,执行没什么问题,但是有的库会在这里生成一个空的block,需要跳过下,不然上面的插入local
的逻辑也会有问题。
结尾
搞了这么多细节,最后实际项目中的优化效果如何呢?
首先,这种替换的场景本身就不多,大部分的Lua代码写的还是比较优秀的。
其次,优化的table访问占整个大盘的百分比也是很小的。
但是你说完全没有效果吗,也不对。所以,就像导语说的,这是一个聊胜于无的优化。
当然,对于一些特殊场景,比如本身没什么计算逻辑,但是全是table get,那么优化会有明显的效果了。在实际应用中,有约10%的提升。
最后,基于前面的假设,工具 的优化并不是万能的,只是作为一个辅助,对于优化后的代码,还需要其他手段来验证是否完全等价。