最近有一个有趣的发现,调整了一行 Lua 代码的顺序,执行时间却少了接近一半 😅

现场案例

情况下面这个 lua 脚本 order-1.lua

local function f2 (...)
    return select('#', ...)
end

local function f1 (...)
    local l = select('#', ...)
    local m = 0
    for i = 1, l do
        m = m + select(i, ...)
    end
    
    local n = f2(...)

    return m + n
end

local n = 0
for i = 1, 1000 * 1000 * 100 do
    n = n + f1(1, 2, 3, 4, 5)
end

print("n: ", n)

执行时间为 6.3s

$ time luajit order-1.lua
n:      2000000000

real    0m6.343s
user    0m6.342s
sys     0m0.000s

如果将其中的 f1 函数实现,调整一下顺序:

local function f1 (...)
    local n = f2(...)

    local l = select('#', ...)
    local m = 0
    for i = 1, l do
        m = m + select(i, ...)
    end
    
    return m + n
end

这个改动是将 n 的计算放到 m 计算的前面。
从逻辑上来说,mn 两个是并没有顺序依赖,先算哪一个都一样的,但是执行时间却少了将近一半:

$ time luajit order-2.lua
n:      2000000000

real    0m3.314s
user    0m3.312s
sys     0m0.002s

原因分析

首先肯定不是什么诡异问题,计算机可是人类最真实的伙伴了,哈哈 😄

这次是 Lua 这种高级语言,也不是 上次那种 CPU 指令级 的影响了。

tracing JIT

这次是因为 LuaJIT 的 tracing JIT 技术的影响。

不像 Java 那种 method based JIT 技术,是按照函数来即时编译的。LuaJIT 是按照 trace 来即时编译的,trace 对应的是一串代码执行路径。
LuaJIT 会把热的代码路径直接即时编译生成机器码,一串热的代码路径也就是一个 trace。同时 trace 也不是无限长的,LuaJIT 有一套机制来控制 trace 的开始结束(以后找时间再详细记录一篇的)。

具体来说是这样子的,因为在 order-1.lua 里,TRACE 1m 计算的那个 for 循环处则停止了,当 TRACE 2 开始的时候,LuaJIT 还不支持这种情况下即时编译 (还处于 NYI 状态)VARG 这个字节码(也就是对应的 ...)。

所以,导致了这部分代码不能被 JIT,回归到了 interpreter 模式,所以导致了这么大的性能差异。

如下,我们可以在 LuaJIT 输的日志中看到 NYI: bytecode 71 这个关键信息。

$ luajit -jdump=bitmsr order-1.lua

...

---- TRACE 2 start 1/3 order.lua:13
0016  UGET     2   0      ; f2       (order.lua:13)
0017  VARG     4   0   0       (order.lua:13)
---- TRACE 2 abort order.lua:13 -- NYI: bytecode 71

总结

调整了 Lua 代码顺序,影响了 LuaJIT 中 trace 的生成,导致了有字节码没法被 JIT,这部分回退到了解释模式,从而导致了较大的性能差异。

感慨一下

JIT 技术还是蛮好玩,不过需要学习掌握的东西也挺多的。

以我目前的理解,tracing JIT 算是很牛的 JIT 技术了,有其明显的优势。不过任何一项技术,总是少不了非常多的人力投入。
即使像 Lua 这种小巧的语言,也还是有不少的 NYI 没有被 JIT 技术。
像 Java 这种重型语言,JIT 这方面的技术,怕是需要很多大牛才堆出来的。


doujiang24
209 声望1k 粉丝

Core developer of OpenResty.


引用和评论

0 条评论