Rubyコミッター・笹田耕一に世代別インクリメンタルGCを発想したプロセスを聞いてみた
Rubyのフルタイムコミッターである笹田耕一さんに、Rubyの処理性能を向上させるいくつかのブレイクスルーをどのように解決し、どのような困難があったのかを聞きました。
直感的な文法や生産性の高さから、世界中の人々に愛されるオブジェクト指向スクリプト言語Ruby。その黎明期から現在に至るまで、大きな変化を遂げてきた要素があります。“処理速度”です。数々の最適化が行われた結果、Rubyの処理性能はかつてとは比べものにならないほど向上しました。
その改善を支えたのは、世界中のRubyコミッターたち。中でも、性能向上において多くの成果を残してきたのが、クックパッド株式会社でフルタイムRubyコミッターとして働く笹田耕一(ささだ・こういち/ @koichisasada )さんです。本稿では、彼がいかなる設計方針に基づいてRubyの最適化を行ってきたのかを明らかにします。
- 「実行時にしか分からない情報」があるからこそ、難しい
- 「GCが遅い」という課題をどう解決したか?
- 「Procオブジェクト生成の必要がないこと」を判定するために
- 非互換の変更を入れる場合は、“特典”も一緒に盛り込む
- 考え続けること。そして、手を動かすこと
- 私のライフイベントは、Rubyに支えられている
「実行時にしか分からない情報」があるからこそ、難しい
──Rubyとは、どのような特性を持つ言語なのでしょうか?
笹田 スクリプト言語であること、オブジェクト指向であることなど、さまざまな特徴がありますが、性能に大きく影響するのは「実行時にしか分からない情報がたくさんあること」です。
笹田 例えば、Javaなどの言語は型の記述があるため、実行前の段階で「このメソッドの引数には必ず○○型が入ってくる」と分かることが多いのです。また、処理の中でどんなメソッドが呼ばれるかについても、ある程度は特定できます。
一方で、Rubyはそれがあまりできません。だからRubyの開発を行う際には、「実行途中でどんな情報が手に入れば、後続の処理を最適化できるか」を考えた上で設計を行う必要があります。逆に言えば、その最適化を適切に行うことが、処理の高速化に直結するわけです。
とはいえ、「なんでもかんでも最適化して高速化すればいい」というわけではありません。最適化のためのプランは何案もあるわけですが、その中には「○○を入れれば速くなるのは確実だけど、この施策をやってしまうとその後のメンテナンスがめちゃくちゃ大変になる」ものもあるわけですね。
Rubyのコントリビューターの人数や開発に割ける工数は限られていますから、なんでもやるわけにはいかない。“コスパ”の良いプランを選んでいかなければならないわけです。なるべくメンテナンスがしやすく、かつ効果のある施策を選ぶようにしています。
「GCが遅い」という課題をどう解決したか?
笹田 例えば、かつてRubyには「ガベージコレクション(以下、GC)の処理が遅い」という課題がありました。昔から、世代別GCというテクニックを導入することで、この課題を解決できると分かっていたのですが、それを導入するためには後方互換性を切らなければいけないと考えられていました。
──世代別GCとはどのようなものでしょう?
笹田 世代別GCとは、「プログラムにおいては新しいオブジェクトの方が(古いオブジェクトよりも)すぐに死ぬ可能性が高い」という「世代別仮説」に基づいたGCのしくみです。
笹田 「古いオブジェクトを格納する世界」と「新しいオブジェクトを格納する世界」を別々に用意して、後者の世界だけを頻繁にGCしてやることで、効率が良くなります。メモリが足りなくなってきたら、古い世界と新しい世界を全部まとめてGCしてやります。
手法としては1970年代くらいからあり、GCの機能を持ったプログラミング言語ではよく用いられるテクニックです。
──この手法を用いるために、後方互換性を切る必要があると思われていたのはなぜでしょうか?
笹田 細かい話になりますが、RubyではインタープリターにCで書かれたプログラムを読み込ませて使えます。世代別GCを入れた場合、Cで書かれたインタープリターや拡張ライブラリで問題が起こることが分かっていました。
世代別GCでは、新しいオブジェクトを集めた空間だけを確認しても、古い世代から新しい世代に対してポインタの参照があるかが分からないため、不要と判断されてGCされてしまう問題があります。
古い世代から新しい世代への参照を検知するには、ライトバリアという仕組みが必要になります。つまり、「C言語の拡張モジュールに対しては、ライトバリアを全て入れてもらう」ことが、我々の想定していた非互換の変更でした。
しかし、この修正を強制するのはとても難しいので、あまり現実的ではありませんでした。
──かなり影響が大きそうですね……。
笹田 はい。しかし、私はある時、「それぞれのオブジェクトに対して、ライトバリアを入れたか入れていないかのマークをあらかじめ付けておくことで、誤ってオブジェクトが消されることを防止し、世代別GCがうまく動く」ことを発見したんです。これで一気に道が開けました。
世代別GCは、2013年12月リリースのRuby 2.1から導入され、GCがかなり速くなりました。
「Procオブジェクト生成の必要がないこと」を判定するために
──笹田さんは、Ruby 2.5で「Lazy Proc allocationによるブロックパラメータを用いたブロック渡しの高速化」にも携わっています。この施策についても解説していただけますか?
笹田 この施策の概要は、ブロックパラメータを使ってブロックを受け渡すと、毎回Proc
オブジェクトを作るのはパフォーマンスが非常に悪いため、Proc
オブジェクトを作らずにブロックの情報だけを素直に渡す仕組みに変えたというものです。
実は、私が作ったVMの一番のウリは、Proc
オブジェクトを作らずに、メソッドにブロックを渡せるので、そこが高速であるというものでした。この特長を、ブロックパラメータを用いた場合でも実現した、というものです。
── Proc
オブジェクトを生成すると処理が重くなってしまうのはなぜですか?
笹田 Proc
オブジェクトは、そのProc
を後からコールできますよね。そして、コール時にはオブジェクトが持つローカル変数にもアクセス可能なんです。だからローカル変数の寿命もProc
オブジェクトと同じにするため、全てのローカル変数のスコープをコピーする処理が必要になります。
それが1個のProc
オブジェクトだけならまだましなんですが、数珠つなぎでメソッドフレームをつなぐケースがあって。そうなると連鎖的にどんどんオブジェクトが生まれていきます。
──確かに重そうな処理ですね。
笹田 そのため、処理を最適化するには「なるべくProc
オブジェクトを作らないよう、必要になるまでProc
オブジェクトの生成を遅延させる」ことが重要になってきます。これを「Lazy Proc allocation」と呼びます。
Lazy Proc allocationを実現するには、「Proc
オブジェクトが必要か、不要か?」を何かの方法で判断する必要があります。この高速化のための手法はだいぶ長い間検討していたんですが、判定のためにはエスケープ解析という機構を相当作り込まなければいけないと思っていました。かなり難易度が高いだろうと。
以下は、笹田さんが「クックパッド開発者ブログ」に掲載した「Lazy Proc allocation」の解説を一部抜粋・編集したもの。
Ruby 2.5 の改善を自慢したい - クックパッド開発者ブログ
def sample1 &b block_yield(&b) end
このプログラムは、b
をProc
にする必要はない。ブロックの情報のまま、他のメソッドに渡してやればいいため。
def sample2 &b b end
このプログラムは、b
をProc
にする必要がある。呼び出し側が返値としてProc
オブジェクトを期待する可能性があるため。
def sample3 &b foo(b) end
このプログラムも、b
をProc
にする必要がある。foo
を呼んだ先でProc
オブジェクトを期待する可能性があるため。
block_yield(&b)
のようにしか使っていなければ、b
はブロック情報のままで良さそうに見える。しかし、以下の例でそうではないことが分かる。
def sample4 &b get_b(binding) end
一見すると、b
は触っていないため、ブロック情報のままで良さそうに見える。だが、binding
オブジェクトを用いると、そのバインディングを生成した箇所のローカル変数にアクセス可能であるため、get_b
の定義を
def get_b bind bind.local_variable_get(:b) end
のようにすると、b
の中身にアクセスできる。この場合、b
がsample4
の返値になるためProc
オブジェクトする必要性が生じる。binding
が出現したら諦めるという方法もあるが、binding
はメソッドであるため、任意の名前にエイリアスをつけることが可能だ。
つまり、どんなメソッド呼び出しもbinding
になる可能性があるため、プログラムの字面を見て「b
をProc
オブジェクトにする必要はないと言い切るのは難しい」と笹田さんはかつて判断していたそうだ。
笹田 いろいろと考えましたが、ある日自転車に乗っている最中に「ブロックパラメーターへのアクセスだけを特別に実行時に監視すれば、Proc
オブジェクトの要否を判別できる」ことに気づきました。そこから実現につながりました。
非互換の変更を入れる場合は、“特典”も一緒に盛り込む
──笹田さんは、RubyKaigi 2019で「Write a Ruby interpreter in Ruby for Ruby 3」という手法を提案されていました。これはどのようなものでしょうか?
笹田 今、CRubyの実装は全てCで書かれていますが、それを「Rubyでも書けるようにしましょう」という提案です。コードの一部をRubyで書くことによって、いくつもの利点が生まれるのではないかという仮説に基づき、それを実現するためのフレームワークを提案しています。おそらくRuby 2.7に入ります。
──具体的には、どのような利点が生まれるのでしょうか?
笹田 いくつかありますが、ピックアップしてお話しすると、まず性能が向上する可能性があることです。多くの場合、Cで書く方が、Rubyで書くよりも処理速度は速い。しかし、「Rubyで書いた方が処理速度が速くなるケース」も存在します。
例えば、キーワード引数を解析する処理などがそれに該当します。キーワード引数を持つメソッドにRubyでキーワード引数を渡す場合には、ハッシュオブジェクトを生成しないという最適化が施されているからです。
また、生産性も向上します。Rubyで書いた方が、Cで書くよりもコードの量が少なくて済むケースが多いからです。
笹田 それから実はこれが本命の理由で、私たちが開発を進めている並列処理機構であるGuildに、現在のコンテキスト情報を渡したいからです。
現在のRubyは、複数のスレッドを作っても、どれか1つのスレッドしか動きません。Guildを導入することで、複数コアの場合は複数のスレッドが同時に動くようになることを目指しています。
──Guildにコンテキスト情報を渡すために、RubyインタープリターをRubyで書くことがなぜ有効なのでしょうか?
笹田 Ruby本体を開発する際には、rb_define_class
というAPIでクラスを定義し、Cで記述した関数をrb_define_method
というAPIでメソッドの本体として登録しています。
Guild実行時には、スタックなどのコンテキスト情報が必要になります。その情報は、現状ではスレッドローカルストレージという、アクセスに時間のかかる領域に置かなければなりません。以前に我々が評価した結果では、無視できないオーバヘッドになります。
そこで、登録する関数の仕様を変えて、第一引数にコンテキスト情報を渡すようにすることで、この課題を解決できます。ただ、いきなり全部を「こう変更します」と言うと、また互換性の問題が生じてしまいます。
そのため、Rubyで記述し、そこから呼び出すCの関数はこの仕様にして、従来のものはちょっと遅いかも知れないけどちゃんと動きます、というようにしようと思っています。
ただ、このように互換レイヤをちゃんと残しておくと、既存コードを修正するモチベーションが上がらないと思うんですね。でも、性能や生産性の向上という“特典”があることで、「やってみよう」という気持ちになってくれるかなと。
──なるほど。納得できますね。
笹田 私たちはこの手法を、Ruby 1.9の経験から学びました。Ruby 1.9でも同様に、文字列エンコーディングの処理で非互換の変更を入れたんです。そしたら、いろいろな人から「バージョンを上げたら動かなくなった」と怒られました(笑)。
けれど、Ruby 1.9は処理速度も向上していたので、それが1.8から1.9に移行する大きなモチベーションになったといわれています。
考え続けること。そして、手を動かすこと
──今回話していただいたような設計のアイデアは、どのように思い付くことが多いですか?
笹田 ずっと、日常生活など含めて、考え続けることが大事じゃないかと思います。
例えば、私は大学時代からRubyのVMを開発していたんですが、当時は毎日ひたすら実装方法なんかを電車に乗っていても窓をぼーっと見ながら考えていて、いつの間にか30分過ぎている、ということがよくありました。
新しいシステムや良いシステムを作りたい人には、「徹底的に考え続ける」ことが大事なんじゃないかと思います。
先ほど話した世代別GCの仕組みも、散歩をしながらずっと考えている最中に「これでいいんじゃないか」とふと思い付いたことがきっかけでした。目標を高く持って、そこにどうジャンプするかを模索し続けることが大事なのではないかと。
──先ほども「自転車に乗っている時」というお話がありました。日常的に考えているからこそ、アイデアが降ってくるのですね。そうしたアイデアの“種”を発案する力は、どのようにして磨いたらいいでしょうか?
笹田 自分が興味を持てる領域を、徹底的に深堀りするといいかもしれません。私の場合だと、大学時代の卒論のテーマがマルチスレッドライブラリの開発だったんですが、その周辺知識を徹底的に調べていました。実はRubyを好きになったのも、Rubyのスレッドの仕組みに興味を持ったことがきっかけだったんです。だから、まず自分が知りたい部分を深掘りしていってほしいです。
笹田 スレッドというキーワードだけをとってみても、そもそもどうすれば実現できるのか、そのためにメモリ管理やシグナル処理なんかをどう扱うか、I/Oはどうなっているのかなど、知らなければならないことが芋づる式で出てきます。それを延々と調べていくと、自然と知識が広く、深くなっていきます。
それから、まずは実装して、試してしまうべきです。実装することで初めて見えてくるものはとても多い。作ってみないとその実装が良いのかどうか評価できないですし、実装の落とし穴にも気づけません。最近は歳をとったのか、考えるばかりで実装をおろそかにしがちになっているので、これは自戒を込めています。
例えば、設計について「ここがこうなったら、きっと処理が速くなるだろう」という見通しが立ったら、完璧な実装でなくてもいいので、特定の条件を満たした際にどれくらい速くなるのか、サンプルを書いて計測してみる。それで目星をつけるのが大事ではないかと思います。性能評価の場合、定量的に計測しやすいのが良い点ですね。
私のライフイベントは、Rubyに支えられている
──笹田さんは約15年にわたり、Ruby開発に携わってきました。なぜこれほど長く続けられたのでしょうか?
笹田 自分が興味の持てるテクノロジーだったこと、多くの人がRubyを使ってくれて高く評価してくれたことが、自分のモチベーションになってきました。
──いまや多くの人や企業が、Rubyコミッターに対して感謝の気持ちを抱いています。
笹田 もちろん、黙々と開発をしているだけでは、成果が見えないので誰も評価してくれません。各所での情報発信について、意識的に頑張ってきたつもりです。それがなかったら、クックパッドには呼んでもらえなかったかもしれません(笑)。
今でも、Rubyは本当に良い言語だと思っていますし、Rubyの開発者・まつもとゆきひろさんの「Rubyは生産性を何より大事にする言語だ」という考えにも共感しています。その長所を残しながら、今後もより良い言語に進化させていきたいです。
とはいえ、いまRuby開発に携わっているコントリビューターだけではまだまだ人が足りませんから、より多くの方に参加していただけたらと思っています。そのために、RubyインタープリターをHackするイベント「Cookpad Ruby Hack Challenge」を月1回のペースで開催し、Ruby開発に興味を持ってくださる人を増やすための施策も続けています。
──素晴らしい話ですね。最後に「Ruby開発に携わってきて良かったこと」を話していただけますか?
笹田 Rubyのおかげで、就職できたり結婚できたりしたことですかね(笑)。私のライフイベントは、だいたいRubyに支えられています。本当にありがたい話です。Rubyのおかげで自分の居場所を見つけられたのは、一番の良かったことかもしれません。
Ruby開発に携わって約15年がたちましたが、まだまだ開発を続けていきたい。自分が心から熱中できるテーマに出会えたのは、本当に幸運なことだと感謝しています。
余談ですが、私の祖父は大阪万博で「太陽の塔」の中にある「生命の樹」の製作に携わったそうで、そのことをよく自慢していました。私の仕事でも同じように、「お父さんがRubyを速くしたんだよ」と子供に伝えられるといいな、と思っています。
取材・執筆:中薗昴