たまにはRuby Parser開発日誌以外の話ということでRipperの話をします。
先日このようなpatchを書いたのでその話です。
ちょっと前からmaster branchでfound internal inconsistency
が発生していました。
具体的なlogは http://ci.rvm.jp/results/trunk-gc-asserts@ruby-sp2-docker/4501173 をみてください。このtestではbuild時にRGENGC_CHECK_MODE=2
を設定することで、GCが起こるたびに内部的な不整合をチェックしています。
Ripperのテストの一環としてtest/ripper/test_files_test_2.rb
というテストケースでtest/*
以下のファイルを対象にRipperを走らせて例外が発生しないかをチェックしています。これにtest_pattern_matching.rb
を与えたときにGC周りでなにかinconsistencyが起きるようになっていました。
なにがinconsistencyなのか
これを理解するためにはまずGCとくにRGenGC(世代別GC)についての理解が必要になります。私のGCに対する理解は"CRubyの実装時にもメモリ管理を意識しなくていい、最高!"くらいの理解だったので、より詳細に知りたい方は以下の論文やスライドをみるのがよいと思います。
- http://www.atdot.net/~ko1/activities/sasada-ipsj-pro101.pdf
- https://www.atdot.net/~ko1/activities/sasada-ipsj-pro101-slides.pdf
世代別GC
RGenGC (Restricted Genrational GC)とはRuby 2.1で導入されたGCを高速化する仕組みです。 RubyのGCはmark & sweep GCなので、使用中のobjectをまずmarkして、そのあとでmarkされていないobjectをsweepするという流れになります。これを毎回すべてのobjectを対象に行なっていると時間がかかるので、その改善方法の1つとしてRGenGCがあります。objectは作られて短期間で使われなくなるものと、作られて長期間使われるものに分類できることが経験的に知られています。例えばweb serverでrequestを表すobjectはそのrequestをさばいたあとは使われなくなる一方、DBへのconnectionを管理するためのobjectはプロセスが起動している間は基本的にずっと生きているといった感じです。
そこでobjectを新しい世代(young)と古い世代(old)に分けて、新しい世代のみを対象とするマイナーGCと全ての世代を対象にするメジャーGCを用意し、基本はマイナーGCを実行するという方法をとることでmark & sweepの対象を減らすことができます。なお一定期間新しい世代にいたobjectは古い世代に移動します。
inconsistency
世代別GCで気をつけないといけないのはoldなobjectがyoungなobjectへの参照をもつことがあるということです。例えば以下のような2つのobjectがあったときにarray[0] = str
をするとoldからyoungへの参照が発生します。この場合array
が生きている間はstr
を回収されると困ります。ではこの状態でマイナーGCが発生するとどうなるでしょうか。マイナーGCではyoungなobjectから辿れるobjectだけをmarkするのでstr
が使われていないことになってしまいます1。
array
: oldstr
: young
この問題を解決するためにwrite barrierという仕組みが用意されています。これはあるobjectから別のobjectに参照が発生したことを記録し、GCのときに参照元のobjectも考慮してmarkするようになります。CRubyのコードにときどき出てくるRB_OBJ_WRITE
やRB_OBJ_WRITTEN
がwrite barrier用のマクロです。このマクロにarray
とstr
を渡すと、array
がoldかつstr
がyoungなときにarray
をリメンバーセットに登録します。リメンバーセットに登録されたobjectはmarkするときの起点に含まれるようになるので、これでstr
がmarkされるようになり、sweepされなくなります。
found internal inconsistencyとは
inconsistencyにもいろいろ種類があるのですが、今回問題になっていたのはverify_internal_consistency_iのなかでもcheck_generation_iに関するものでした。ここではRubyの管理しているobject(parent)をiterateして、そのobjectがmarkしているobject(child)をみつけ、それらparentとchildの世代を確認します。もし
- parentがold
- childがyoung
- parentがリメンバーセットに登録されていない
という条件をすべて満たす場合、それは上で議論したような状態であり、解放済みのobjectを触ってしまうという(発見の難しい)bugに繋がります。 ということで何がおきているかを知るにはparseおよびRipperがどのようにメモリを管理しているかを知る必要があります。
parser/Ripperのメモリ管理
NODEをどうやって管理するか
parserが主に管理しているものはASTのNODEです。かつては他のobjectと同じようにGCによって管理されていたNODEですが、現在はGCの管理からは外れT_IMEMO/ast
というobjectがまとめて管理しています2。
具体的にはrb_ast_t
がAST全体を管理するための構造体で、これはRubyのGCが管理しています。その中のnode_buffer
が動的にメモリを確保しておりNODEの管理もここに含まれます。
// node.c および node.h typedef struct rb_ast_struct { VALUE flags; node_buffer_t *node_buffer; rb_ast_body_t body; } rb_ast_t; typedef struct node_buffer_struct node_buffer_t; struct node_buffer_struct { node_buffer_list_t unmarkable; node_buffer_list_t markable; struct rb_ast_local_table_link *local_tables; VALUE mark_hash; // - id (sequence number) // - token_type // - text of token // - location info // Array, whose entry is array VALUE tokens; };
ちなみにNODEはRubyのobjectを参照していることがあります。例えばNODE_STR
はString objectへの参照をもちます。
$ ruby --dump=p -e '"abcdefg"' # @ NODE_SCOPE (id: 1, line: 1, location: (1,0)-(1,9)) # +- nd_tbl: (empty) # +- nd_args: # | (null node) # +- nd_body: # @ NODE_STR (id: 0, line: 1, location: (1,0)-(1,9))* # +- nd_lit: "abcdefg"
こういったNODEの場合、NODEの参照するobjectをmarkするのはT_IMEMO/ast
の責務です3。objectへの参照をもつNODEをmarkable、そうでないNODEをunmarkableとして分けて管理しています。
markableなNODEにobjectを渡したときはT_IMEMO/ast
を親としてwrite barrierを設定する必要があります。
Ripperの場合
一方でRipperの場合はunmarkableなNODEを使い、objectはmark_hash
に管理させるという方針で実装しています。unmarkableなNODEは典型的にはNODE_CDECLが使われています。この場合はrb_ast_add_mark_object
がrb_hash_aset
を呼ぶので、自動的にmark_hash
を親としてwrite barrierが設定されます。
ここまでのまとめ
- Parserの場合
- markableなNODEがobjectへの参照をもつ
- つまり
T_IMEMO/ast
がobjectを管理している - なので
p->ast
を親としてRB_OBJ_WRITE
やRB_OBJ_WRITTEN
を呼ぶ必要がある
- Ripperの場合
- unmarkableなNODEがobjectへの参照をもつ
- つまり
mark_hash
がobjectを管理している - なので
mark_hash
を親としてRB_OBJ_WRITE
やRB_OBJ_WRITTEN
を呼ぶ必要がある (これはparse.y的にはadd_mark_object
呼ぶことで達成される)
今回何が問題だったか
RipperのときにNODE_ARYPTN
やNODE_FNDPTN
といったmarkableなNODEにobjectをもたせ、p->ast
を親としてRB_OBJ_WRITE
やRB_OBJ_WRITTEN
を呼んでいなかったのが問題でした。T_IMEMO/ast
がoldなときを考えてみましょう。add_mark_object
はmark_hash
をリメンバーセットに登録しますが、p->ast
についてはなにもしません。この状態でGCのconsistencyのチェックが走ると
T_IMEMO/ast
はold- objectはyoung
T_IMEMO/ast
からobjectはmarkの対象
となってinconsistencyだと判定されてしまいます。
実際はadd_mark_object
を呼んでいるのでmark_hash
はリメンバーセットに登録され、そちらを経由してobjectがmarkされるので参照が残っているobjectが回収されるというbugにはなっていないはずです4。
修正はRipperの場合の実装にあわせてunmarkableなNODE(NODE_OP_CDECL
)を使うように変更しました。
余談
なんでつい最近まで見つからなかったのか不思議に思う人もいるかと思います。test_files_test_2.rbが毎回対象のファイルをサンプリングしているとはいっても、pattern matchingが入ってからおおよそ4年です。
これには理由があってpattern matchingは3.1までexperimentalな機能でtest_pattern_matching.rb
は巨大なstringになっていたからでした。
まとめ
- GCがあってもobjectがどうやって管理されているか知らないといけないことがたまにある
- RubyにはいろいろなCIがあって発見の難しい問題を発見しており、すごい5
- Ripperのメモリ管理を改善したい or してほしい...6
- 実際はstack上のobjectなどもmarkされますがここでは割愛↩
- Ruby の NODE を GC から卒業させた - クックパッド開発者ブログを参照↩
-
mark_ast_value
関数を参照↩ - なのでbackportは不要なはず↩
- Rubyの開発を支える技術 - クックパッド開発者ブログ参照。また ruby-trunk-changes 2023-03-28 - ruby trunk changesによると "安定版メンテナンスの時は TEST_RIPPER_RATIO=1 を指定して全ファイルを parse するようにしてテストしたりしています" とのこと↩
- ふと思ったんですが、RipperのNODE/VALUE問題って、parseにもう一つsemantic value stackを追加したらNODEとVALUEを分割できて解決したりしませんかね↩