优化 Lua 与 C++ 的互操作性

简介
Roblox 引擎采用 C++ 与 Lua 混合编写,其中执行计算密集型操作的代码使用优化过的 C++ 编写,而游戏逻辑和脚本则使用 Lua 编写,以方便开发。要使这种模式有效,Lua 与 C++ 之间的切换必须尽可能快,因为在这片“无人区”中花费的任何时间,本质上都是白白浪费的几毫秒。
在过去的几个月里,我们对系统的这一部分进行了多项改进。其中一个环节——即从 Lua 调用 C++ 方法——尤为引人注目,因为它带来了显著的速度提升,同时也需要深入探究 Lua 的内部机制,以理解其底层的工作原理。
最终我们对 Lua 虚拟机本身进行了修改,但在深入探讨之前,我们需要先打好基础。
编译器、虚拟机与字节码
当 Lua 源代码被编译时,它会被编译成 Lua 字节码,随后由 Lua 虚拟机(VM)执行。Lua 字节码总共包含约 35 条指令,用于处理诸如读写表、调用函数、执行二进制运算、跳转和条件判断等操作。 Lua 虚拟机采用寄存器模式,这与许多其他虚拟机采用的栈模式不同,因此编译器在生成字节码时,其部分工作就是确定每条指令应使用哪些寄存器。
每条指令的形式为“OP_CODE A B”或“OP_CODE A B C”,其中“OP_CODE”是操作码(例如,CALL 表示调用函数),A/B/C 是操作码的参数。 这些参数(或寄存器)并非实际值,而是指向两个表之一的索引:常量表(写为 Kst(..))或寄存器表(写为 R(..))。
关于 Lua 字节码的详细说明,请参阅《Lua 5.1 虚拟机指令简明入门》。这比听起来要有趣得多,我保证!
为了让你了解 Lua 字节码的样貌,我们将先讲解一些简单程序,然后逐步过渡到更具实际意义的示例。
借助 Chunkspy 工具,我们可以将 Lua 字节码反汇编为 Lua 汇编代码,并获取代码列表以及常量表,从而实质上查看针对任意给定 Lua 源代码生成的字节码。
基础字节码示例
像“x = 10”这样的简单程序编译后结果如下:
.const "x"; 0
.const 10; 1
[1] loadk 0 1 ; 10
[2] setglobal 0 0 ; x 前两行显示了常量表(第 0 槽位为字符串值“x”,第 1 槽位为整数值 10),接下来的两行是反汇编后的操作码。
[第 1 行] 在“No Frills”中查阅 LOADK 指令,可见其形式为“LOADK A Bx --- R(A) := Kst(Bx)”。 因此,LOADK 有两个操作数(寄存器 A 和 B),其作用是将常量表中由第二个操作数指定的槽位处的值(Kst(Bx))赋值给寄存器表中由第一个操作数指定的槽位(R(A))。“Bx”仅表示由于该指令只有两个操作数,因此 B 寄存器被扩展并分配了更多位。
[第 2 行] SETGLOBAL 的形式为“SETGLOBAL A Bx --- Gbl[Kst(Bx)] := R(A)。” 它使用由第二个参数指定的槽位中常量表提供的键,向全局表赋值。由于第二个参数是 0,且常量表在 0 处的值为“x”,因此它使用键“x”向全局表写入内容。写入的内容正是第一个参数指定的槽位中寄存器表中的值,而前一条指令已将该槽位加载为 10。
让我们来看一个稍微复杂一点的例子:“x = 10; y = x”。代码的手动执行就留作读者的练习了。 :)
.const "x"; 0
.const 10; 1
.const"y"; 2
[1] loadk 0 1 ; 10
[2] setglobal 0 0 ; x
[3] getglobal 0 0 ; x
[4] setglobal 0 2 ; y
函数调用的字节码
让我们看看为“foo(10):”生成的代码
.const "foo"; 0
.const 10; 1
[1] getglobal 0 0 ; foo // R(A) := Gbl[Kst(Bx)]
[2] loadk 1 1 ; 10 // R(A) := Kst(Bx)
[3] call 0 2 1
要执行函数调用,必须将函数装入第一个寄存器,并将参数装入后续寄存器。 “CALL A B C”的语义定义如下:A 存储函数,B 是参数个数(实际上是参数个数 +1,这是因为“...”的实现方式),C 是返回值个数(同样是返回值个数 +1,用于处理多个返回值)。
前两行我们已经很熟悉了;它们将一个值加载到寄存器表槽位 0 中,并将值 10 加载到寄存器表槽位 1 中。 第三行执行函数调用,使用寄存器 A 中的值(即寄存器表槽位 0,其中已装载了“foo”),其中 B 指定参数个数,C 指定返回值个数(请记住,B 和 C 的值都应加 1)。在调用函数之前,虚拟机还会验证 R(A) 中的值是否确实可调用。
Lua 提供了一种机制,允许用户通过将元表关联到现有表来扩展表的功能。元表包含备用方法,当主表无法执行特定方法或操作时,这些方法会被调用(详见 https://www.lua.org/pil/13.html)。
对于我们的用途而言,元表中最重要的条目是“__index”和“__call”字段。__index 用于在表中查找元素,因此代码“local x = my_table[10]”会首先调用 my_table 上的 __index 方法。 如果该调用失败,它将转而尝试调用 my_table 元表上的 __index 方法。类似地,当你试图将某物视为函数并调用它时(例如“local x = foo(42)”),会使用 __call 方法
为了使 Lua 和 C++ 能够互操作,它们需要某种方式来共享函数和数据。Lua 通过提供一种名为 UserData 的数据类型来实现这一点。UserData 对象可以在 C++ 环境中创建,并且由于它们是原生的 Lua 数据类型,因此可以附加元表,这使得 Lua 代码能够像对待普通 Lua 对象一样与它们交互。
成员函数调用
好,让我们回到字节码的分析!接下来的这个示例更有趣,因为它展示了当出现类似“foo:bar(10)”的代码时会发生什么——这实际上是在实例 foo(类 Foo 的实例)上调用 bar 方法。
foo:bar(10)
.const "foo"; 0
.const "bar"; 1
.const 10; 2
[1] getglobal 0 0 ; foo
[2] self 0 0 257 ; "bar"
[3] loadk 2 2 ; 10
[4] call 0 3 1 这里的新
内容是 self 指令 [第 2 行],这是我们之前未曾见过的。self 的语法为“SELF A B C --- R(A) := R(B)[RK(C)]; R(A+1) := R(B)”,让我们来分解一下。 在寄存器表的 R(A) 槽位中,它会将使用 RK(C) 槽位中的键在 R(B) 槽位中查找表的结果放入该位置。它还会将 R(B) 槽位中的内容复制到 R(A+1) 槽位,但这部分稍后再详述。你可能会注意到 C 寄存器的值为 257。 这是合理的,因为 Lua 使用 RK(C) 来查找值,而 RK 会根据第 9 位的值,使用寄存器表或常量表。如果该位为 1(本例中正是如此),则使用常量表;否则,查找将转至寄存器表(在屏蔽最高位之后)。
第 3 行将 10 放入槽位 2,最后第 4 行将执行函数调用。
SELF 指令有两个作用。首先,它查找 Foo 类中的“bar”方法,并将该方法存入 R(A)。 其次,由于 foo 是实例方法,且在调用时需要该方法所属类的实例,因此将该实例存入 R(A+1)。如果你熟悉 Python 中的类,可能会认出这个概念:方法通常写为“def my_method(self, arg1, arg2..)”,其中 self 即为类实例。
我们需要更深入地探讨这一点,并观察当 foo 实例是一个 C++ 对象(在 Lua 中表示为 UserData 对象)时会发生什么。
SELF 调用可以视为表查找,即 Foo[“bar”](大写 Foo 代表 Foo 类,区别于实例 foo),而我们知道查找操作会使用 __index 方法。 当 foo 实例在 C++ 环境中创建时,会与该实例关联一个元表,且该元表的 __index 字段被设置为一段 C++ 代码,该代码将在调用 __index 时被执行。
当从 Lua 调用 C/C++ 时,唯一传递的数据是一个 lua_State 对象。该对象包含与当前运行的 Lua 线程相关的所有信息。状态对象中最关键的信息是 Lua 栈,其中包含函数参数(可通过 lua_tointeger/tostring 等函数家族访问),同时也用于将值返回给 Lua。
用伪C++表示,我们的 __index 函数大致如下:
int metaIndex(lua_State* L)
{
// first argument is the userdata object
UserData* userdata = lua_touserdata(L, 1);
// get some kind of descriptor, that contains information
// about what methods the class exposes
ClassDescriptor* desc = getDescriptorForUserData(userdata);
// See if the class has the requested method
const char* methodName = lua_tostring(L, 2);
MemberFunctionPtr method = desc->hasMethod(methodName);
if (method)
{
// Upvalues are values that are available when a C
// function is invoked.
lua_pushupvalue(L, method);
lua_pushcfunction(L, methodInvoker);
return 1;
}
else
{
lua_pushnil(L);
return 0;
}
}
虽然省略了许多内部细节,但核心逻辑如下。鉴于 Lua 栈中作为第一个参数传递的 UserData 对象,我们可以找到一个描述实际 C++ 类的描述符,并通过该描述符判断该类是否具有指定名称的方法。如果存在,则将指向方法调用器的函数指针压入 Lua 栈,并返回成功。
此调用之后,Lua 虚拟机将把剩余的参数放入寄存器表中,然后调用我们从 metaIndex 方法返回的函数,该函数将再次调用 C++,并进入调用器函数:
int methodInvoker(lua_State* L)
{ <br> // Get the userdata and the class descriptor
UserData* userdata = lua_touserdata(L, 1);
ClassDescriptor* desc = getDescriptorForUserData(userdata);
Class* instance = (Class*)userdata;
// Using Lua's upvalue mechanism, get the 'method'
// that was stored in metaIndex.
MemberFunctionPtr method = lua_getupvalue(L, 1);
// This is hand-wavey, but we have some mechanism of being
// able to invoke a member function via the class descriptor,
// and also pop arguments from the Lua stack, and push return values
return desc->invokeFunction(instance, method, L);
}
methodInvoker 同样使用 ClassDescriptor,但这次它能够调用成员函数,并从栈中弹出正确的参数。
最后冲刺!
既然我们已经清楚地看到了从 Lua 到 C++ 的两次往返,就可以尝试思考如何优化这一过程。
我们的最终目标是实现从 Lua 到 C++ 的单次函数调用,并将所有必需的参数都放在 Lua 栈上,以便能够一次性完成方法查找和调用。 问题似乎在于我们少了一个寄存器。当我们调用这个集成了查找和调用功能的函数时,希望 Lua 栈呈现为 [self, 方法名, arg1, arg2, ...] 的形式,但观察 SELF 结构会发现,它将第一个槽用于存储方法函数的查找结果,第二个槽用于存储实例。
当我们研究 __call 元方法的工作原理时,有了一个关键的发现。如果一个对象具有 __call 元方法,那么在 _call 函数被调用之前,对象本身会被压入栈中,而所有参数都会向上移动。通过利用这一功能,我们找到了一种无需显式将其存储在寄存器中,就能将“self”放入栈中的方法。
第二部分涉及将方法名也放入栈中。为此,我们不得不耍点小聪明,修改 SELF 操作码的工作机制。
请记住,在默认情况下,SELF 会尝试查找成员函数,并将该函数与实例分别存储在 R(A) 和 R(A+1) 中。我们最终完全跳过了查找过程,而是将实际对象存储在 R(A) 中,将方法名存储在 R(A+1) 中。
如果现在确保 R(A) 中的对象拥有一个 __call 元方法,那么我们最终也会将 self 压入栈中。这样,栈结构就会变成 [self, 方法名, 参数…],并且仅需向 C++ 发起一次调用。完美!嗯,几乎完美。 :)
在宣布完成之前,我们还想对它做些最后的润色。我们不希望重载 __call 元方法的语义,因此我们为这种调用类型添加了一个专门的元方法——名为 __namecall——它仅在 UserData 对象上可用。我们还修改了 SELF 操作码,使其仅在对象具有 __namecall 元方法时才使用新的语义。
我们做的第二件事主要是让新路径和旧路径能够轻松共享代码。我们将方法名从第二个参数移到了最后一个参数。这样,在它被用于查找方法指针之后,就可以轻松地从栈中弹出,而栈的状态看起来就像是通过旧路径调用函数时一样。
结论
这项优化能带来多大影响?嗯,就像编程中的大多数事情一样,答案是“视情况而定”。对于那些“重量级”且不常调用的函数,你可能不会看到明显的性能提升。但对于那些频繁调用的较小函数,节省的开销可能相当可观。
开发者论坛上的用户很快注意到了这个奇怪的新元方法的出现,并展示了一张对比表,将 __namecall 的速度与旧的实例方法调用方式以及开发者此前用于优化方法调用的变通方案进行了对比:
local part = workspace.Baseplate
local count = 1000000
local start0 = tick()
for i=1,count do
part:IsA("BasePart")
end
local end0 = tick()
local start1 = tick()
for i=1,count do
local isa = part.IsA
isa(part, "BasePart")
end
local end1 = tick()
local start2 = tick()
local isa = part.IsA
for i=1,count do
isa(part, "Basepart")
end
local end2 = tick()
print("namecall", end0 - start0)
print("index+call", end1 - start1)
print("call", end2 - start2)
> namecall 0.49229717254639
> index+call 0.78510332107544
> call 0.49960780143738
第一个循环使用了新的 __namecall 代码路径,但由于所有优化都在后台自动完成,开发者无需修改任何现有代码即可享受优化带来的好处。
第二个循环模拟了旧式的实例方法调用方式:先查找方法,再调用它。
最后,第三个循环展示了开发者常用的优化方式:先查找方法,将其存储在局部变量中,然后调用该变量。
这里值得称道的是,它表明借助 __namecall 优化,已无需显式缓存实例函数——因为其运行速度与缓存优化方案相当,因此最简洁的代码也将拥有最佳性能。
既然 __namecall 已部署完毕,且我们对目前的效果感到满意,现在是时候将注意力转向内存使用情况,看看我们能采取哪些措施来改善客户端在这方面的表现!


