为什么 Lua 的新版本越来越慢?
今天有人转了个知乎上的帖子给我看:Lua 5.3为何很慢?该不该升级? 。
首先,我不认为 10% 的性能差异能够称的上很大,和 10% 的性能下降相比,程序更清晰稳定、功能更完备(不是指功能多,而是指对各种边界条件处理的更好)要重要的多。毕竟,让 CPU 提升 10% 的性能很容易。
其次,在实际项目中,和简单的测试脚本不同,我很难观察到 10% 的差异。(我们的服务器用过 lua 5.2 和 lua 5.3 两个版本,很难从线上压力上感知到性能差别)。
如果你真的用那些简单的测试脚本做一个比较,lua 5.1 比它的前一个版本 lua 5.0 要慢得多。差别或许比 lua 5.1 到 5.3 还要大。而为什么很少人关心这个,去用回 5.0 呢?说到底还是因为 luajit 导致的 lua 社区的分裂,让 lua 5.1 这个中间版本变成了另一个 lua 而已。
我的硬盘上一直留有从 lua 4.0 开始几乎每个 lua 小版本的源代码副本。而近十年来,一直都在跟进 lua 的源码变迁,所以我对 lua 的每次修改都或多或少有一些印象。下面谈谈我对这个新版本越来越慢的一点个人看法吧。
这写这篇 blog 前,我花了一天做了大量的测试对比工作。包括做profile ,编译出汇编逐行对比,阅读版本间的 diff 等等。因为不是写 paper ,懒得把这些工作整理在 blog 里了。主要写写结论。
我认为,lua 小版本演化在一些简单测试脚本跑分中,性能 5% ~ 10% 的下降,主要是更健壮的实现的代价。也就是考虑到各种边界条件,逐渐增加的判断。以及一些不完备的功能在完善过程中增加的必要代码。另外,还包括了 gc 模块的不断改进。
比如,一开始的版本( 5.1 )不能在 pcall 中 yield ,不能在 hook 里 yield ;而后来的版本抹去了这些限制。同时,设计和实现都复杂了一些。但如果你仔细阅读代码,你会发现这些改动都不是简单的补丁,而精心考虑过的重设计,实现上都几乎无可挑剔。单从局部代码的来看,你都很难找到哪里是导致性能下降的热点。
再比如,有人怀疑 lua 5.3 把 number 类型在内部拆分成整数和浮点两个子类型会导致多余的判断和分支。这会造成一些性能下降。但其实增加子类型本身是几乎没有额外的性能开销的(尤其是最常见的 table index 操作)。你可以对比前后版本的实现,看看到底是否真的有多余的分支。
对比 lua 5.3 和 5.2 工作量很大,我们可以找个更简单的地方来看:
其实给数据类型区分子类型并不是从 5.3 开始的,甚至不是某个大版本变更。实现的变化发生在 lua 5.2.0 到 5.2.1 之间。在这个小版本升级的过程中,lua 利用 type 字段的高位开始为数据类型区分内部子类型。
在 lua 5.2.1 中,字符串被分为短字符串和长字符串两类。只有短字符串才做内部化(和之前版本的字符串行为一致),而长字符串则额外处理。
lua 5.2.1 和 5.2.0 的 diff 文件不大,只有 2000 行左右。而且一半都是周边库的实现变化。所以慢慢阅读几百行 diff 花上个把小时大家都能做了。
如果你写一些简单的测试脚本,你会发现很多情况下 lua 5.2.1 的确和 lua 5.2.0 有性能差异 1% 上下浮动。或快或慢和具体脚本怎么写有关。
但这个差异真的和 string 的实现有关吗?
我专门把和 string 有关的代码从 lua 5.2.1 patch 到 lua 5.2.0 上做了测试(而保留了其它的差异),发现性能差异几乎消失了。同时,我仔细查看了实现部分编译生成的汇编部分,在主要流程上基本是完全一致的。
而性能差异的部分,一部分来至 gc 部分的改写。而另一方面,lua function 和 c function 可以更清晰的用子类型区分开(而之前是在 function 结构中保留了一个 boolean 变量),代码清爽了很多,对于某些测试脚本,甚至比之前的版本跑的还要快一点点。
为什么很多简单的测试脚本却能感知到明显的差异呢?
这是因为,很多脚本使用了 for 循环去重复测试简单的函数。而 lua 5.3 在 for 数字循环中,需要多做一次子类型判断。对循环变量为整数和浮点数分别处理;而之前的版本是不需要的。
别小看这多一次判断,如果你的 for 循环中做的工作本身很简单,那么差异很可能被放大到 1% 的量级。但实际我们的项目中,却很难让 for 循环本身大比例占据 cpu 时间的。
至于四则运算,之前的版本同样要做类型判断。只要你不在 lua 5.3 中混用整数和浮点,那么甚至还会比老版本更快一些(尤其在不支持硬件浮点运算单元的时候)。
另一方面,由于 lua 5.3 原生支持了 64bit 整数以及新增加的语言级的位操作,对于特定领域来说,却可以对性能做更大幅度的加分。
有些变化的确会影响到性能。比如 lua 5.2 开始取消了 C 层面的对象环境的概念,改成了 _ENV
。这相对于 lua 5.1 ,api 和实现都得到了精简;同时,全局变量这个东西连同对应的 OPCODE 都去掉了,读取全局变量变成了访问 _ENV
这个 upvalue ,性能也收到了轻微了影响。
比如在最前面提到的知乎的帖子中,测试脚本在内循环里访问了 math.abs 函数。而 math 是一个全局变量。如果你能在脚本最前面加一行 local math = math
的话,你会发现 lua 5.1 和 lua 5.2 几乎没有区别。
至于 lua 5.3 的性能轻微下降,我个人认为是 for 循环的差异导致的。
lua 历代版本的源代码可以在这里下载 ,如果你对我上面的结论有异议,可以自己每个版本去对比。
btw, 在历史上,lua 5 对 lua 4 那次大版本的升级,几乎重新实现了一遍。而性能是得到了很大的提高的。是不是我们该期待 lua 6 的再次重构呢 :)