LuaとC++の相互運用性の最適化

はじめに
RobloxエンジンはC++とLuaの組み合わせで記述されており、計算負荷の高い処理を行うコードは最適化されたC++で記述され、ゲームロジックやスクリプトは開発の容易さを考慮してLuaで記述されています。このモデルを効果的に機能させるには、LuaとC++間の切り替えを可能な限り高速にする必要があります。なぜなら、この「境界領域」で費やされる時間は、本質的に無駄なミリ秒に他ならないからです。
ここ数ヶ月、私たちはこのシステムの一部に対して様々な改善を順次導入してきました。中でも特に興味深かったのが、LuaからのC++メソッドの実行そのものです。これは大幅な速度向上につながっただけでなく、内部で何が起きているのかを理解するためにLuaの内部構造を深く掘り下げる必要があったからです。
最終的にはLua VMそのものを修正することになりましたが、その話に入る前に、いくつかの基礎知識を整理しておく必要があります。
コンパイラ、VM、およびバイトコード
Luaのソースコードがコンパイルされると、Luaバイトコードに変換され、それをLua VMが実行します。Luaバイトコードには、テーブルの読み書き、関数の呼び出し、二進演算、ジャンプや条件分岐などを行うための命令が、合計で約35種類あります。 Lua VMは、他の多くのVMのようにスタックベースではなくレジスタベースであるため、コンパイラがバイトコードを生成する際に行う作業の一部として、各命令がどのレジスタを使用すべきかを決定する必要があります。
各命令は「OP_CODE A B」または「OP_CODE A B C」という形式をとります。ここで「OP_CODE」はオペコード(例:関数呼び出しの CALL)であり、A/B/C はオペコードの引数です。 引数(またはレジスタ)は実際の値ではありません。それらは、定数テーブル(Kst(..) と表記)またはレジスタテーブル(R(..) と表記)のいずれかを指すインデックスです。
Luaバイトコードの詳細な説明については、「A No-Frills introduction to Lua 5.1 VM Instructions」を参照してください。そのタイトルほど退屈な内容ではありません。保証します!
Luaバイトコードがどのようなものか実感していただくために、まずはいくつかの簡単なプログラムを見てから、より実用的な例へと進んでいきます。
Chunkspyユーティリティを使用すると、LuaバイトコードをLuaアセンブリに逆アセンブルしてコードのリストや定数テーブルを取得できるため、任意のLuaソースコードに対してどのようなバイトコードが生成されるかを実質的に確認できます。
基本的なバイトコードの例
「x = 10」のような単純なプログラムは、次のようにコンパイルされます:
.const "x"; 0
.const 10; 1
[1] loadk 0 1 ; 10
[2] setglobal 0 0 ; x 最初の2行は定数テーブル(スロット0に文字列値「x」、スロット1に整数値10)を示しており、続く2行は逆アセンブルされたオペコードです。
[1行目] 「No Frills」でLOADKオペコードを調べると、その形式は「LOADK A Bx --- R(A) := Kst(Bx)」であることがわかります。 したがって、LOADKには2つの引数(レジスタAとB)があり、その動作は、2番目の引数で指定されたスロットにある定数テーブルの値(Kst(Bx))を、1番目の引数で指定されたスロットのレジスタテーブルに代入するものです。「Bx」とは、このオペコードには引数が2つしかないため、Bレジスタが拡張され、より多くのビットが割り当てられていることを意味します。
[2行目] SETGLOBALは「SETGLOBAL A Bx --- Gbl[Kst(Bx)] := R(A)」という形式を持つ。 これは、2番目の引数が示すスロットの定数テーブルにあるキーを使用して、グローバルテーブルに値を代入します。2番目の引数は0であり、定数テーブルの0番目のスロットの値は「x」であるため、キー「x」を使用してグローバルテーブルに何かが書き込まれます。書き込まれるのは、1番目の引数が示すスロットのレジスタテーブルにある値であり、これは前の命令で値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となります)となります。
最初の2行は既にお馴染みのものです。これらは、レジスタテーブルのスロット0に値をロードし、スロット1に値10をロードします。 3行目は関数呼び出しを実行する行であり、レジスタA(「foo」がロードされたレジスタテーブルのスロット0)の値を使用し、Bで引数の数を指定し、Cで戻り値の数を指定します(BとCの両方に1を加える必要があることを忘れないでください)。関数が呼び出される前に、VMは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番目のビットの値に応じてレジスタテーブルか定数テーブルのいずれかを使用するからです。9番目のビットが1の場合(このケースでは1です)、定数テーブルが使用されます。そうでない場合は、最上位ビットをマスクした後、レジスタテーブルで検索が行われます。
3行目でスロット2に10が格納され、最後に4行目で関数呼び出しが実行されます。
SELF命令には2つの目的があります。まず、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 メソッドが使用されることは既知です。 C++側でfooインスタンスが作成された際、そのインスタンスにはメタテーブルが関連付けられ、そのメタテーブルの__indexフィールドには、__indexが呼び出された際に実行されるC++コードが設定されていました。
Lua から C/C++ が呼び出される際、渡されるデータは lua_State オブジェクトのみです。このオブジェクトには、現在実行中の Lua スレッドに関連するすべての情報が含まれています。state オブジェクトの中で最も重要な情報は 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 VMは残りの引数をレジスタテーブルに配置し、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++への2回の往復が明確に把握できたので、これをどう最適化できるか考えてみましょう。
最終的な目標は、LuaからC++への関数呼び出しを1回にまとめ、メソッドの検索と呼び出しを一度に実行するために必要なすべての要素をLuaスタック上に用意することです。 問題は、レジスタが1つ足りないことにあるようです。ルックアップと呼び出しを統合した関数を呼び出す際、Luaスタックは [self, メソッド名, arg1, arg2, ...] のような構成になることが望ましいのですが、SELFを見ると、最初のスロットはメソッド関数のルックアップ結果に、2番目のスロットはインスタンスの格納に使用されていることがわかります。
__call メタメソッドの動作を確認した際に、重要な気づきがありました。オブジェクトが __call メタメソッドを持っている場合、_call 関数が呼び出される前に、オブジェクト自体がスタックにプッシュされ、すべての引数が上にシフトされます。この機能を利用することで、レジスタに明示的に格納することなく、スタック上に「self」を配置する方法が見つかりました。
2つ目の課題は、メソッド名も同様にスタックに載せることでした。これには、SELFオペコードの動作を巧妙に改変する必要がありました。
デフォルトの場合、SELFはメンバ関数を検索し、それをR(A)に、インスタンスをR(A+1)に格納しようとします。私たちは最終的にこの検索を完全にスキップし、実際のオブジェクトをR(A)に、メソッド名をR(A+1)に格納しました。
ここで、R(A)内のオブジェクトに__callメタメソッドがあることを確実にすれば、スタックにselfもプッシュされることになります。つまり、スタックは[self, メソッド名, 引数…]という構成になり、C++への呼び出しはたった1回で済むことになります。完璧!…というか、ほぼ完璧です。 :)
これを完了とする前に、最後の仕上げを施したいと考えました。__call メタメソッドのセマンティクスをオーバーロードしたくなかったため、代わりにこの種の呼び出し専用のメタメソッド(__namecall)を追加しました。これは UserData オブジェクトでのみ利用可能です。また、SELF オペコードも修正し、オブジェクトに __namecall メタメソッドが存在する場合にのみ、新しいセマンティクスを使用するようにしました。
2つ目に実施したのは、主に新しい呼び出しパスと従来の呼び出しパスでコードを容易に共有できるようにすることでした。メソッド名を2番目の引数として渡す代わりに、最後の引数としてスタックにプッシュするようにしました。これにより、メソッドポインタの検索に使用された後、スタックから簡単にポップすることができ、関数が従来の呼び出しパスを通じて呼び出された場合と同じようなスタック状態を保つことができるようになりました。
結論
この最適化はどれほどの効果をもたらすのでしょうか? プログラミングにおける多くの事柄と同様、答えは「場合による」です。処理負荷が高く、かつ呼び出し頻度の低い関数については、大きな改善は見られないでしょう。しかし、頻繁に呼び出される小さな関数については、かなりのパフォーマンス向上が期待できます。
開発者フォーラムのユーザーたちは、この奇妙な新しいメタメソッドの出現にすぐに気づき、__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 コードパスを使用していますが、すべての処理は内部で自動的に行われるため、開発者が最適化の恩恵を受けるために既存のコードを変更する必要はありません。
2番目のループは、インスタンスメソッド呼び出しの従来の方法をエミュレートしています。まずメソッドを検索し、その後それを呼び出します。
そして最後に、3番目のループは、開発者がよく行っていた一般的な最適化手法を示しています。ここでは、まずメソッドを検索してローカル変数に格納し、その後その変数を呼び出しています。
ここで注目すべき点は、__namecallによる最適化により、インスタンス関数を明示的にキャッシュする必要がなくなったことです。キャッシュされた最適化と同等の速度が得られるため、最もシンプルなコードが最高のパフォーマンスを発揮することになります。
__namecallが導入され、その結果にも満足している今、次はメモリ使用量に焦点を当て、その面でクライアントを改善するために何ができるか検討する時が来ました!


