まもなくRuby 3.3.0がリリースされますね。
LramaによるBisonの置き換え、named referencesによるparse.yのリファクタリングなど、parser本体の大きな改善が入ったバージョンになります。
今回はRuby 3.3向けに行った改善のうち「Rubyの抽象構文木のデータ構造の改善」という内部的な改善を紹介します。
問題の背景
RubyのNodeはかつてGCで管理されていました。そのころの名残で全ての種類のNodeがたった一つのRNode
構造体を共有しています。
他のNodeへのポインタやメソッド名を表すIDなどはu1
, u2
, u3
というunionで管理しています。
typedef struct RNode { VALUE flags; union { struct RNode *node; ID id; VALUE value; rb_ast_id_table_t *tbl; } u1; union { struct RNode *node; ID id; long argc; VALUE value; } u2; union { struct RNode *node; ID id; long state; struct rb_args_info *args; struct rb_ary_pattern_info *apinfo; struct rb_fnd_pattern_info *fpinfo; VALUE value; } u3; rb_code_location_t nd_loc; int node_id; } NODE;
これには三つの問題点があります。 メモリを無駄遣いしていること、一部のNodeが複雑な構造になっていること、Nodeの種類とそのフィールドの対応が一目で分からないことです。
メモリの無駄遣い
例えばtrue
を表すNODE_TRUE
やnil
を表すNODE_NIL
などは他のNodeへの参照といった付加的な情報をもちません。
ということはRNode
構造体分のメモリを確保していても、それらのu1
, u2
, u3
といったフィールドは使われていません。
複雑な構造のNode
一方でstruct.field += foo
を表すNODE_OP_ASGN2
はstruct
, field
, +=
, foo
と4つのフィールドを必要としており、u1
からu3
では足りません。
そのため2つのNodeを組み合わせてNODE_OP_ASGN2
を表現しています。
Nodeの種類とそのフィールドの対応
Unionとして定義されているu1
, u2
, u3
の実際の型はNodeの種類によって変わります。
例えばNODE_IF
であれば3つとも他のNodeへの参照ですし、NODE_CALL
の場合はNodeとidが混在しています。
実際にNodeを使うときは、Nodeの種類に応じて適切なマクロを使い分ける必要があります。
// NODE_IFの場合は3つとも他のNodeのポインタになっている #define nd_cond u1.node #define nd_body u2.node #define nd_else u3.node // NODE_CALLの場合はメソッド名がidで他はNodeのポインタ #define nd_recv u1.node #define nd_mid u2.id #define nd_args u3.node
Ruby Parser Roadmapとの関係
これらの問題は"Union to Struct (Node)"および"Optimize Node memory management"という目標としてRuby Parser Roadmapにも組み込まれています。 とくにNodeのデータ構造を改善する"Union to Struct (Node)"は、LSP向けにより使い勝手のよいASTを返すという大目標の前提となる目標です。
やったこと
UnionからStructへ
まずはNODE_TRUE
であればstruct RNode_TRUE
、NODE_OP_ASGN2
であればstruct RNode_OP_ASGN2
といったように、Nodeの種類ごとに対応する構造体を用意します。
struct RNode
は各種構造体の先頭のフィールドとしてflags
やnd_loc
など共通する要素のみを含む構造体にします。
Node全体はstruct RNode
で扱い、必要に応じてそのtypeをみてRNODE_TRUE
などのマクロでキャストして使います。
ここでは使っていないフィールドもnot_used
という名前のフィールドとしていままで通り確保するようにしておきます。
この時点では"parse.y"や"compile.c"の一部のロジックがNODE_CALL
系のNodeを一律NODE_CALL
にキャストしていたりするので、not_used
なフィールドを手当たり次第消してしまうとSEGVすることがあるためです1。
typedef struct RNode_TRUE { NODE node; // 実際は使っていないがnot_usedという名前にして確保しておく VALUE not_used; VALUE not_used2; VALUE not_used3; } rb_node_true_t;
また今後構造体ごとのサイズが変わることを想定して、Node用のバッファで異なるサイズの構造体を管理できるように変更します。
not_used
なフィールドを消していく
準備は完了しました。not_used
を消していきましょう!
SELF, NIL, TRUE, FALSEやALIAS, VALIAS, UNDEFなどは単純に構造体からフィールドを消すだけでよいので対応は簡単です。一方でNODE_CALL
などメソッド呼び出しに関するNodeは複数種類のNodeを共通して扱う箇所があるので、補助的な関数を書いてフィールドアクセスを隠蔽する必要がありました。例えばレシーバーを取り出すget_nd_recv
やメソッドのidを取り出すget_nd_mid
などを実装する必要がありました。
このPRをもって全てのNodeからnot_used
なフィールドの削除を完了しています。
複数のNodeで表現していたものを一つにまとめる
たびたび話題にあがるNODE_OP_ASGN2
もついに一つのNodeへと統合されました。
またパターンマッチングに使うNODE_ARYPTN
とNODE_FNDPTN
ではそれぞれ専用の構造体を使ってNodeに収まらないデータを管理していましたが、今回の改善でそれらもNodeの中に埋め込むことができました。
まとめ
たった一つのRNode
構造体で管理していたRubyのNodeを、その種類に応じたNodeを定義してそれらを使うように変更しました。そしてNodeごとに必要なフィールドだけを定義するようにリファクタリングを行いました。
これらのリファクタリングによって以下の3つの点が改善されました。
- 各Nodeが必要十分なメモリを使うようになった
- Nodeのネストのような複雑な構造が解消された
- 各Nodeの構造体を見るだけでどのような型のフィールドがあるのか一目でわかるようになった
これらの改善をもとに、次はいよいよLSP向けに使いやすいNodeを提供するために、Nodeの構造を変更していきます。
この改善は遠藤さんが2017年に行った改善の延長にあるものです。"抽象構文木の表現のムリ・ムダを省いていける準備ができた"にあるように、準備はできていたのですがなかなか着手する機会がなく今日まできていました。2017年以来いつかやろうとおもっていた改善でしたが、今年ついに達成することができました。2017年にNodeのメモリ管理を整理していただき、ありがとうございました2。
-
ここでいう
NODE_CALL
系のNodeとはNODE_CALL
,NODE_OPCALL
,NODE_FCALL
,NODE_QCALL
,NODE_VCALL
,NODE_SUPER
,NODE_ZSUPER
,NODE_YIELD
,NODE_RETURN
,NODE_BREAK
,NODE_NEXT
,NODE_ATTRASGN
のことです。具体例としてはparse.yのget_nd_args
をみるのがいいでしょう。↩ - 実はこれで終わりではなく、"Ripper 対応"に書いてある"NODE の先頭ワードはオブジェクトと同じ構造でないといけません"という問題がまだ残っています。というわけで"Ruby の NODE を GC から卒業させた"をめぐるお話はもうちょっとだけ続きます。↩