7 虚拟机
Lua 通过首先将程序编译为虚拟机的指令(“操作码”)然后执行这些指令来运行程序。 对于 Lua 编译的每个函数,它都会创建一个原型,其中包含一个包含函数操作码的数组和一个包含函数使用的所有常量(文字字符串和数字)的 Lua 值 (TObjects) 数组。
十年来(自 1993 年 Lua 首次发布以来),Lua 使用了一个基于堆栈的虚拟机,以各种形式出现。 从 2003 年开始,随着 Lua 5.0 的发布,Lua 使用了基于寄存器的虚拟机。 这种基于寄存器的虚拟机还使用堆栈,用于分配寄存器所在的活动记录。 当 Lua 进入一个函数时,它会从堆栈中预分配一个足够大的活动记录,以容纳所有函数寄存器。 所有局部变量都分配在寄存器中。 因此,访问局部变量特别高效。
基于寄存器的代码避免了基于堆栈的代码需要在堆栈中移动值的几个“push”和“pop”指令。 这些指令在 Lua 中的开销特别大,因为它们涉及标记值的复制,如第 3 节所述。因此,寄存器架构既避免了过多的值复制,又减少了每个函数的指令总数。 戴维斯等人为基于寄存器的虚拟机辩护并提供有关 Java 字节码改进的硬数据。 一些作者还根据基于寄存器的虚拟机对动态编译的适用性进行辩护。
基于寄存器的机器通常有两个问题:代码大小和解码开销。寄存器机中的指令需要指定其操作数,因此它通常比堆栈机中的相应指令大。 (比如 Lua 的虚拟机中一条指令的大小是 4 个字节,而几个典型的堆栈机器,包括 Lua 以前使用的那些,一条指令的大小是一个或两个字节。)另一方面,寄存器机器生成的操作码比堆栈机器少,所以总代码大小不会大很多。
堆栈机中的大多数指令都有隐式操作数。寄存器机中的相应指令必须从指令中解码它们的操作数。这种解码会增加解释器的开销。有几个因素可以改善这种开销。首先,堆栈机器还花一些时间来操作隐式操作数(例如,增加或减少栈顶)。其次,因为在寄存器机中所有操作数都在指令内部并且指令是机器字,所以操作数解码只涉及廉价操作,例如逻辑操作。此外,堆栈机器中的指令经常需要多字节操作数。例如,在 Java VM 中,goto 和 branch 指令使用两字节位移。由于对齐,解释器不能立即获取这样的操作数(至少不是使用可移植代码,它必须始终假设最坏情况的对齐限制)。在寄存器机上,由于操作数在指令内部,解释器不必独立获取它们。
Lua 的虚拟机中有 35 条指令。 大多数指令被选择直接对应于语言结构:算术、表创建和索引、函数和方法调用、设置变量和获取值。 还有一组常规的跳转指令来实现控制结构。 图 5 显示了完整的指令集,并简要总结了每条指令的作用,使用以下符号:R(X) 表示第 X 个寄存器。 K(X) 表示第 X 个常数。 RK(X) 表示 R(X) 或 K(X-k),具体取决于 X 的值——对于小于 k(构建参数,通常为 250)的 X 值,它是 R(X)。 G[X] 表示全局变量表中的字段 X。 U[X] 表示第 X 个上值。 有关 Lua 虚拟机指令的详细讨论。
寄存器保存在运行时堆栈中,它本质上是一个数组。 因此,对寄存器的访问速度很快。 常量和上值存储在数组中,因此访问它们也很快。 globals 表是一个普通的 Lua 表。 它通过散列访问但具有良好的性能,因为它仅使用字符串(对应于变量名称)进行索引,并且字符串预先计算它们的散列值,如第 2 节所述。
Lua 虚拟机中的指令占用 32 位,分为三个或四个字段,如图 6 所示。 OP 字段标识指令,占用 6 位。 其他字段代表操作数。 字段 A 始终存在并占用 8 位。 字段 B 和 C 各占 9 位。 它们可以组合成一个 18 位字段:Bx(无符号)和 sBx(有符号)。
大多数指令使用三地址格式,其中 A 指向保存结果的寄存器,B 和 C 指向操作数,操作数可以是寄存器或常量(使用上面解释的表示形式 RK(X))。 使用这种格式,Lua 中的几个典型操作可以在一条指令中编码。 例如,局部变量的增量,例如 a = a + 1,被编码为 ADD xxy,其中 x 表示保存局部变量的寄存器,y 表示常量 1。像 a = bf 这样的赋值,当两个 a 和 b 是局部变量,也被编码为单个指令 GETTABLE xyz,其中 x 是 a 的寄存器,y 是 b 的寄存器,z 是字符串常量“f”的索引。 (在 Lua 中,语法 b.f 是 b["f"] 的语法糖,即 b 由字符串 "f" 索引。)
分支指令带来了困难,因为它们需要指定两个要比较的操作数加上一个跳转偏移量。 将所有这些数据打包在一条指令中会将跳转偏移量限制为 256(假设一个有符号的 9 位字段)。 Lua 中采用的解决方案是,从概念上讲,测试指令只是在测试失败时跳过下一条指令; 下一条指令是常规跳转,它使用 18 位偏移量。 实际上,因为一条测试指令后面总是跟着一条跳转指令,所以解释器会一起执行这两条指令。 也就是说,当执行一条成功的测试指令时,解释器立即取出下一条指令并进行跳转,而不是在下一个分派周期中进行。 图 7 显示了 Lua 代码和相应字节码的示例。 注意刚才描述的条件和跳转指令的结构。
图 8 显示了 Lua 编译器执行的优化的一个小示例。图 9 显示了为 Lua 4.0 编译的相同代码,它使用了具有 49 条指令的基于堆栈的虚拟机。请注意切换到基于寄存器的虚拟机如何允许生成更短的代码。本例中的每个可执行语句在 Lua 5.0 中编译为一条指令,但在 Lua 4.0 中需要三到四条指令。
对于函数调用,Lua 使用了一种寄存器窗口。它评估连续寄存器中的调用参数,从第一个未使用的寄存器开始。当它执行调用时,这些寄存器成为被调用函数的活动记录的一部分,因此可以像常规局部变量一样访问其参数。当这个函数返回时,这些寄存器被放回到调用者的活动记录中。
Lua 使用两个并行堆栈进行函数调用。 (实际上,每个协程都有自己的一对堆栈,正如我们在第 6 节中所讨论的。)一个堆栈为每个活动函数都有一个条目。该条目存储被调用的函数、函数调用时的返回地址以及指向函数活动记录的基址索引。另一个堆栈只是一个保存这些活动记录的 Lua 值的大数组。每个活动记录保存函数的所有临时值(参数、局部变量等)。实际上,我们可以将第二个堆栈中的每个条目视为第一个堆栈中相应条目的可变大小部分。
8 结论
在本文中,我们介绍了 Lua 5.0 实现中最具创新性的方面:基于寄存器的虚拟机、优化用作数组的table的新算法以及闭包的实现。
据我们所知,Lua 是第一种广泛使用的采用基于寄存器的虚拟机的语言。表的优化允许在以这种方式使用时(即,当它在 1 . . n 范围内具有足够的键时)将table部分实现为数组。它的闭包实现也是独一无二的,将基于数组的堆栈与词法作用域的第一类值函数结合使用,无需复杂的控制流分析。
图 10 中的表格显示了旧实现和新实现之间的一些简单性能比较。测试在运行 Linux 2.6 的 Intel Pentium IV 机器上运行,该机器有 512 MB,使用 gcc 3.3 编译 Lua。 Lua 4.0 既不使用基于寄存器的虚拟机(它的机器是基于堆栈的)也不使用表-数组优化。 Lua 5’是Lua 5.0,没有表——数组优化、尾调用和动态栈(与协程相关); Lua 5’本质上是带有新的基于寄存器的虚拟机的 Lua 4.0。
我们采用了 The Great Computer Language Shootout [2] 中的所有测试用例,除了第一个(sum),它是一个简单的循环,用于添加从 1 到 n 的所有整数。 第一个测试大部分时间都在虚拟机中进行; 它表明新虚拟机的速度可以是旧虚拟机的两倍多。 其他测试在其他任务(函数调用、表/数组访问等)上花费的时间更多,因此虚拟机中的增益对总时间的影响较小。 在使用数组(sieve、heapsort、matrix)的测试中,新的虚拟机与新的数组优化相结合,最多可以减少40%的运行时间。
Lua 5.0 的完整代码可以在 Lua 的网站上浏览:http://www.lua.org/source/5.0/。