RailsはJITコンパイラである
Rails Is a JIT Compiler (railsatscale.com)
要約
この記事は、Ruby on Railsの`enum`機能におけるパフォーマンス最適化について論じています。Railsの`enum`が動的なメソッド生成を行っていることがパフォーマンスのボトルネックになる場合があることを指摘し、`public_send`の使用を避け、`module_eval`を用いて静的なメソッドを生成することで大幅な高速化が達成された事例を紹介しています。
全文翻訳
RailsはJITコンパイラである
このタイトルは、この記事を開いてもらうための釣りでした。しかし、恐れることはありません!今日は実行時にコードを生成します。(PRでチラ見せをご覧ください。)
サムがJITチャンネルに現れ、以下のコードスニペットに触発されて、RoundhouseでRailsのenumをどのように最適化できるか尋ねてきました。
class Order < ActiveRecord::Base
enum :status, { pending: 0, paid: 1, shipped: 2, refunded: 3 }
def shippable?
paid? && !refunded?
end
end
特に、Railsのenumによって生成されるコードには多くの動的な動作があり、サムはビルドステップ(Rails外部)でそれらをすべてインライン化することが役立つかどうかを知りたがっていました。
それは私には正しくないように思えました。なぜなら、私は以下のように考えていたからです。
- Railsは近年、これらの動的なルックアップを削除し、メソッドを作成してキャッシュすることに大きく改善された。
- ZJITは、生成されたメソッドかどうかにかかわらず、メソッドへのディスパッチをうまく処理できるはずだ。
そこで私は問い合わせ、サムはenumの実装を提示しました。そこにはこの宝石がありました。
value = 0
define_method("#{value_method_name}?") {
public_send("#{name}_for_database") == value
}
幸いなことに、これは私が心配していたような完全な動的ルックアップではありません!ハッシュなどはありません。これはRailsがenumを使用してActiveRecordクラスにメソッドを生成しているのです。したがって、私たちの例では、例えばpendingをOrderのメソッドに変換します。
value = 0
Order.define_method("pending?") {
public_send(:status_for_database) == value
}
残念ながら、これはまだ非常に動的な動作を使用しています。名前が静的にわかっている場合にメソッドディスパッチにpublic_sendを使用する理由はありません。キャッシュされず、必要な以上の多くの仕組みがあります。また、ブロックでdefine_methodを使用し、環境をキャプチャして動的にvalueをルックアップする理由もありません。enumの値はenum定義時に確定しています。
私は、文字列を使ったmodule_evalを使用して、通常のメソッドのように見えるものを生成するようにしたかったのです。私はRailsについては何も知らないので、アーロンを巻き込んで修正してもらいました。
アーロンの神経を逆なでする
コードの変更は、かなり機械的なものから始まりました。同様のメソッドを生成するためにmodule_evalを使用できるはずです。
code = []
code << "def #{value_method_name}?; #{name}_for_database == #{value.inspect}; end"
# ...
module_eval(code.join("\n"), __FILE__, __LINE__) unless code.empty?
ここでの違いは次のとおりです。
public_sendを介さずに、メソッドへの直接ディスパッチ。- 親環境から
valueをルックアップするのではなく、メソッドにvalueを焼き込む。
ほとんどのテストはパスしましたが、その後、いくつかの奇妙なケースに遭遇しました。
- 人々は最も奇妙なenum名を持っています。
- 例えば、Railsのテストスイートは偽のタイムゾーンを使用しています:
enum :timezone, [:"Etc/GMT+1", :"Etc/GMT-1"]。 - 私たちのアプリケーションには、大文字のインスタンスがあります:
enum :thing, [:Foo, :Bar]。 - おそらく、Rubyの識別子にならない他のものもあります。
- 例えば、Railsのテストスイートは偽のタイムゾーンを使用しています:
- 浮動小数点数。当然です。すべてのenum値はシリアライズ可能である(したがってRubyソースコードで再構築可能である)必要がありますが、浮動小数点数とのラウンドトリップ可能性は少し奇妙になります。そのため、この厄介なチェックがあります:
!(Float === value && !value.finite?)。
最終的には、1行の変更では済みませんでした。なぜなら、ウフクも、ActiveSupport::CodeGeneratorを使用すべきだと指摘したからです。しかし、それはそれにかなり近いものでした。
PRをチェックしてください。
パフォーマンス測定
PRをチェックしてください!でもまだここにいるので、ここで数値を繰り返します。記事の冒頭のコードの以下のマイクロベンチマーク:
IPS.ips do |x|
x.report("shippable?") { order.shippable? }
end
は大幅に改善されました。
mainのパフォーマンス測定値は次のとおりです。
aaron@Aarons-MBP ~/g/rails (main)> ruby -I~/git/ips/lib test.rb
-- create_table(:orders)
-> 0.0073s
shippable?: 1.721M i/s (± 2.3%, GC 4.4%)
aaron@Aarons-MBP ~/g/rails (main)> ruby --yjit -I~/git/ips/lib test.rb
-- create_table(:orders)
-> 0.0048s
shippable?: 3.568M i/s (± 2.6%, GC 8.2%)
aaron@Aarons-MBP ~/g/rails (main)> ruby --zjit -I~/git/ips/lib test.rb
-- create_table(:orders)
-> 0.0029s
shippable?: 3.812M i/s (± 2.8%, GC 8.9%)
そして、アーロンのPRの測定値は次のとおりです。
aaron@Aarons-MBP ~/g/rails (fix-enum)> ruby -I~/git/ips/lib test.rb
-- create_table(:orders)
-> 0.0073s
shippable?: 3.145M i/s (± 1.5%)
aaron@Aarons-MBP ~/g/rails (fix-enum)> ruby --yjit -I~/git/ips/lib test.rb
-- create_table(:orders)
-> 0.0065s
shippable?: 16.274M i/s (± 1.3%)
aaron@Aarons-MBP ~/g/rails (fix-enum)> ruby --zjit -I~/git/ips/lib test.rb
-- create_table(:orders)
-> 0.0027s
shippable?: 18.509M i/s (± 2.2%)
やったー!
教訓
- 静的であることは動的であることよりも優れている。定数を焼き込む。
- 特に、
public_sendは遅い! - 上流のライブラリがすでにコード生成を行っている場合、パフォーマンスの問題を上流で修正できる。すべての問題にカスタムコンパイラが必要なわけではない。