使用工具优化Lua的table访问

2023-03-06 17:02:36 浏览数 (2)

背景

写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反复使用的时候,就应该替换了。比如:

代码语言:lua复制
a.b.c = 1
a.b.d = 2

有人可能会问,反复使用a的不替换吗?像这样:

代码语言:lua复制
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,怎么办?比如:

代码语言:lua复制
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次当然更不会替换)比如这样的代码:

代码语言:lua复制
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替换,那么就变成:

代码语言:lua复制
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)也是一样的要考虑作用域问题,例如:

代码语言:lua复制
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之后才行。也就是

代码语言:lua复制
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的最后一句代码,那相当于是这样的:

代码语言:lua复制
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%的提升。

最后,基于前面的假设,工具 的优化并不是万能的,只是作为一个辅助,对于优化后的代码,还需要其他手段来验证是否完全等价。

lua

0 人点赞