关于lua:LuaJIT-是如何工作的-JIT-模式

40次阅读

共计 2823 个字符,预计需要花费 8 分钟才能阅读完成。

上一篇 咱们说到,JIT 是 LuaJIT 的性能杀手锏。这篇咱们就介绍一下 JIT。

Just-in-time 即时编译技术,在 LuaJIT 里的具体应用是:将 Lua byte code 即时编译为机器指令,也就是不再须要解释执行 Lua bytecode,间接执行即时编译产生的机器指令。
也就是说,解释模式,和 JIT 模式的输出源是一样的,都是 Lua byte code。雷同的字节码输出,两种模式却能够有跑出显著的性能区别(一个数量级的区别,也是比拟常见的),这个还是很须要功力的。

JIT 能够分为这么几个步骤:

  1. 计数,统计有哪些热代码
  2. 录制,录制热代码门路,生成 SSA IR code
  3. 生成,SSA IR code 优化生成机器指令
  4. 执行新生成的机器指令

JIT 编译对象

在进一步开展之前,先介绍一个基本概念。
LuaJIT 的 JIT 是基于 trace 的,意味着一段 byte code 执行流,而且是能够逾越函数的。
相比较而言,Java 的 JIT 是基于 method 的,尽管也有函数内联,然而限度比拟大(只有很小的函数才会被内联 JIT 编译)。

集体认为,tracing JIT 在实践上来说,能够比 method base JIT 有更大的施展空间,如果只是某些 case 跑分,应该能够更厉害。
不过工程实现复杂程度要高不少,所以最终的理论工业成果,也难说(影响 JIT 成果的,还有很多其余因素,比方优化器等等)。

比方这个小示例:

local debug = false
local function bar()
  return 1
end

local function foo()
  if debug then
    print("some debug log works")
  end
  
  return bar() + 1
end

foo() 函数被 JIT 编译的时候,有两个显著的长处:

  1. print("some debug log works") 这一行因为没有实在的执行,所以 trace 字节流里不会有它,也就是压根不会为其生成机器码,所以生成的机器码能够更小(生成的机器码越小,CPU cache 命中率越高)
  2. bar() 会被内联编译,并不会有函数调用的开销(是的,在机器指令层面,函数调用的开销其实也须要考量的)

计数

接下来,咱们挨个介绍 JIT 的各个阶段。
计数比拟容易了解,JIT 的一大特点即是:只编译热点代码(全盘编译的话,也就成了 AOT 了)。

通常的 JIT 计数有两个统计入口:

  1. 函数调用,当某个函数执行次数达到某个阈值,触发 JIT 编译这个函数
  2. 循环次数,当某个循环体执行次数达到某个阈值,触发 JIT 编译这个循环体

也就是统计出 热函数 和 热循环。

不过 LuaJIT 是基于 trace 的,也就有 trace 中途退出的状况,此时还有第三个 trace exit 的统计:
如果某个 trace 常常从某个 snap exit,从这个 snap 开始 JIT 编译(snap 咱们前面再介绍),生成一条 side trace。

录制

当某个函数 / 循环足够热了之后,JIT compiler 就开始工作了。
第一步录制,录制的外围流程是:一边解释执行,一边生成 IR code。

具体过程是:

  1. 通过批改 DISPATCH,增加字节码解释执行的 hook
  2. 在 hook 中,为以后执行的 byte code,生成对应的 IR code,也会有判断来 实现 / 提前终止 录制
  3. 持续解释执行 byte code

从开始录制,到录制实现,这个就是 trace 的根本单元,期间解释执行的字节码流,就是这个 trace 须要减速的执行流。

因为录制的是实在的执行流,对于分支代码,trace 当然也不会假如当前每次执行都必定会进入以后这个分支,而是会在 IR 中退出守卫(guard)。
并且会在适合的机会记录快照(snapshot),snapshot 里会蕴含一些上下文信息。
如果后续执行过程中,从这个 snapshot 退出的话,会从 snapshot 里复原上下文信息。

补充细节:
并不是所有的 byte code 都是能够被 JIT 的(具体能够看 LuaJIT NYI)。
碰到了 NYI,LuaJIT 还有 stitch 的能力。比方 FUNCC 是反对 stich 的,那么在 FUNCC 前后的代码,会被录制为两条 trace。最终会是这样成果,JIT 执行 trace1 的机器码 => 解释执行 FUNCC => JIT 执行 trace2 的机器码。把两条 trace 黏合起来,就是 stitch 的成果。

生成

有了 IR code 等信息之后,就能够为其优化生成机器码。

这里有分为两步:

  1. 针对 IR code 的优化
    LuaJIT 的 IR code 也是 static single assignment form(SSA),常见的优化器两头示意码。能够利用很多的优化算法来优化,比方常见的死代码打消,循环变量外提等等。
  2. 从 IR code 生成机器指令
    这部分次要两个工作:寄存器调配,依据 IR 操作翻译为机器指令,比方 IR ADD 翻译为 机器的 ADD 指令。

针对 IR 里的守卫(guard),会生成 if … jump 逻辑的指令,jump 后的桩(stub)指令会实现从某个 snapshot 退出。

这里咱们能够明确了,JIT 生成的机器码能够更加高效的起因:

  1. 依据录制时的执行流假如,能够生成 CPU 分支预测敌对的指令,现实状况下,CPU 就相当于程序执行指令
  2. 针对 SSA IR code 有优化
  3. 更高效的应用寄存器(此时没有解释器本身的状态记录累赘,能够应用的寄存器更多了)

执行

生成了机器指令之后,会批改的字节码,比方 FUNCF 会改为 JFUNCF traceno
下一次解释器执行 JFUNCF 的时候,会跳转到 traceno 对应的机器指令,也就实现从解释模式到 JIT 模式的切换,这也是进入 JIT 指令执行的次要形式。

而退出 trace 则有两种形式:
1 失常执行结束退出,此时会复原到解释模式继续执行
2 trace 中的 guard 失败,会从 trace 中途退出,此时会先依据对应的 snapshot 复原上下文,而后再解释执行

另外,从 trace 中途退出的状况,也会有退出次数的统计。
如果某个 snapshot 的退出次数达到 hotside 的阈值,则会从这个 snapshot 开始生成一条 sidetrace。
下一次从这个 snapshot 退出的时候,间接就 jump 到这个 side trace 了。

这样下来,对于有分支的热代码,也会是全 JIT 笼罩的成果,然而并不是一开始就全笼罩,而是按需的分步进行。

最初

Lua 作为一门嵌入式小语言,自身是比拟粗劣笨重的,LuaJIT 的实现也是继承了这些特点。
在单线程环境下,JIT compiler 占用的是当前工作流的工夫,JIT compiler 本身的效率也是很重要的。
JIT compiler 长时间阻塞工作流也是不能承受的,这里均衡也是很重要的。

相比较而言,java 的 JIT compiler 是独自的 JIT 编译线程实现的,能够做更加深度的优化,java 的 c2 JIT compiler 就利用了绝对比拟重的优化。

JIT 是很牛的技术,能理解其运行的根本过程 / 原理,也是很解惑的事件。

据说 JS 的 v8 引擎,还有 deoptimization 的过程,这个还挺好奇的,无暇能够学习学习的。

正文完
 0