4月18日から20日に開催されたRubyKaigi 2019 。最終日、3日目のキーノートには、Jeremy Evansさんが登壇しました。JeremyはSequel 、Roda のメンテナーです。これらが開発されているGitHubのレポジトリは、Open issueがつねに0であることが特徴です。これらの貢献が評価され、2015年にはRuby Heroに選ばれています。
今回、Jeremyは「Optimization Techniques Used By the Benchmark Winners」というタイトルで発表しました。SequelとRodaでなされたパフォーマンス最適化のテクニックや原則を紹介し、RubyKaigi参加者のRubyコードのパフォーマンス最適化の手助けとなることを目的としていました。
Sequel、Rodaとは何か? Active Record、Sinatraとの比較
ここで、SequelとRodaについて紹介します。Sequelとは"The Database Toolkit for Ruby"と紹介されており、RailsでのActive Recordに相当します。Rodaとは"Roda is a routing tree web toolkit"と紹介され、Sinatraに相当します。Railsでは、"Rails is omakase"にもあるように、Webアプリケーションを作成するのに必要な道具がすべて入っています。それに対し、SequelとRodaは必要最小限の機能のみを本体が持っており、必要な機能拡張は開発者がプラグインを取捨選択するスタイルをとっています。
オープンソースで3,000人以上のcontributorがいるRailsに対して、SequelやRodaはJeremy Evansのコミットがほとんどです。Open issueが0であることは前述したとおりですが、Jeremy本人がほぼすべてを把握しているからこそ、このようなメンテナンスポリシーが可能なのだと思います。
パフォーマンス向上の原則
インスタンス変数をnilやfalseで初期化しない
まず、新しいモデルを作成する場合に、Sequelのcall
メソッドとActive Recordのinit_with_attributesメソッドでのコードの対比を行いました。Sequelはインスタンス変数がnilまたはfalseで初期化されないようにしています。一方、Active Recordは、8つのインスタンス変数を初期化しており、その多くはnilまたはfalseです。マイクロベンチマークで6つのインスタンス変数をnilまたはfalseにしないことで150%の性能向上が見られ、現実に近いベンチマークでは数%の性能向上があったとのことです。この結果を受け、ruby -w
で初期化されていないインスタンス変数が警告されないようにするパッチを送ったがrejectされたとのことです(おそらく、このissue と思われます) 。
実行を本当に必要なときまで遅らせる / すべてを遅延させるのではなく、かならず必要になる処理であれば初期化に行い、実行時には行わない
Active Recordの最初のインスタンスはそのモデルに対して、attributeメソッドを作らせます。これは、Active Recordのモデルクラスの生成時にはattributeメソッドが生成されないためなのですが、すべてのインスタンスはattributeメソッドが存在するかどうか、確認を行う必要が出てきます。一方、Sequel::Modelではクラスを作成したときにattributesを定義するため、インスタンスはモデルのattributeメソッドが作られていることが前もってわかっており、モデルクラスに確認する必要がありません。
オブジェクトの生成を減らす
文字列の生成を減らす
Sequelでは、Ruby 2.3から利用できるマジックコメントfrozen-string-literal: true
の導入で性能向上につながりませんでした。これは以前からfreezeした文字列を定数に代入しており、SQL文を生成していたためです。これらのコードは性能的には十分だったものの、たいへん読みにくいコードとなってしまっていました。そこでfrozen-string-literal: true
の導入によって、読みやすいコードに変更しています。
ハッシュの生成を減らす
Sequelでは、多くのメソッドが引数にHashをとっています。ここではHash自体ではなく、OPTSという定数に、空の凍結されたHashを代入し、引数に渡すようにしました。これにより2倍程度の高速化が図られています。また、他のメソッドにその引数を渡す場合でも、ハッシュの再生成を避けることに成功しています。
キーワード引数ではsplatをさける
RodaもSequelもオプションとしてHashを利用しており、Rubyのキーワード引数を利用していません。単純なケースでは、キーワード引数のほうがハッシュよりも速いのですが、splat(**opts)を利用した場合に遅くなるため、キーワード引数を利用する場合はsplatを利用せず、すべての引数を明示することにより、性能劣化を避けています。ただし、これは引数を追加したときに、すべての関連するメソッドの引数を書き換える必要があるため、広く推奨できる手法ではないこともあわせて紹介しました。
メソッドの生成方法 / defとdefine_methodのどちらを利用するのか
基本的に、define_methodは通常のdefにくらべて50%遅く、defメソッドを利用すべきです。ただし、実行時にdefメソッドによってメソッドを生成する場合には、evalを併用するため、セキュリティに関する問題が発生することがあります。
例えば、モデルのカラムに対してgetterやsetterを設定するときに、class_evalとevalのなかでdefを実行すると、"employee name"のような空白を含むカラムがあった場合に動作しないため、define_methodを利用する必要があります。この場合も、一般的なものにはdefメソッドを利用し、カラムに空白を含むような例外的な場合にのみdefine_methodを利用することで、性能と安全性の両方を満たしています。
ループ処理の最適化
SQLAnywhereアダプターでループの内部でカラム名とカラムの型を繰り返し取得していますが、これらはループの中では変わるものではないため、いったん取得した後にローカル変数に代入して使い回しています。例えば100カラムある10,000行の結果を受け取る場合に、ループの外側で列名と列のほうをローカル変数に入れておけば、ループで200万回のメソッド呼び出しを削減できます。
速いアルゴリズムを利用する
数千ものルーティングがあった場合、Sinatraが利用しているようなO(n)のアプローチでは時間がかかります。Rodaではルーティングの数が増加しても性能が線形的に悪化しないようになっています。ツリーの一番上を探索する場合に、デフォルトでは線形であったのをmulti_routeプラグインを採用してO(n)からO(log(n))に変更しています。
さらに高速化を図るために、いくつルーティングがあっても同じ性能になるように、static_routingプラグインを持ち、O(1)を実現しています。これは最も高速であり、ルーティングの数が10から10,000に増加しても、性能の変化は15%ほどしかありません。しかしながら、これではRodaの他の機能を活用できないため、性能のメリットと複雑な機能のメリ
ットの両方を持つhash_routesというプラグインが開発されました。これは"/foo/123/bar"のような形式に対応し、ネストの段階ごとにhashのキーにマッチするルートを探っていくものです。
可能な限りキャッシュする 全体を不変にして、局所的に変更可能にする
すべてのオブジェクトを不変にすることではなく、オブジェクトの状態を不変にした上で、オブジェクトが利用する変更可能なhashをキャッシュとして利用可能にすることで、性能と信頼性を両立させています。
Sequel::Datasetを例にすると、オブジェクトの状態はOPTSというfrozenなハッシュが保持しています。また、cacheというインスタンス変数も持っており、これはfrozenではありません。また、このcacheはmutexを利用したプライベートメソッドによってのみ変更され、スレッド間のキャッシュの整合性を保つようになっています。その後、オブジェクト自体をfreezeさせます。そうすることで、変更可能なのがcacheだけになるようにします。Sequel datasetsは性能向上のためにキャッシュを最大限に活用しています。
例としては、生成されたSQL文をキャッシュするのがもっとも効果がありました。すでにキャッシュされたSQL文があればそれを返し、なければ生成します。また、実行時に変化しない場合に限ってそれをキャッシュに保存します。
メタプログラミングとキャッシュの併用
メタプログラミングを活用し、メソッドチェーンの各段階の結果をキャッシュさせてmetaprogrammingで生成させることで、チェーンされるメソッドそれぞれの結果を自動的にキャッシュする機能を採用しました。
例えば、Album.released.by_name.firstというメソッドチェーンがあった場合にも、Album.released、Album.released.by_name、Album.released.by_name.firstとそれぞれの段階でキャッシュを保持します。もし、この処理を100回実行した場合にも、わずか3つのデータセットを保持するだけでよく、またSQLも1回だけ実行すれば済みます。キャッシュをしない場合の300のデータセット、100回のSQL実行に比べて、高速化を図っています。
正規表現よりStringを使う。StringよりIntegerを使う
さらに、/の文字列を比較する代わりに、それに対応するASCIIコードである47というIntegerをgetbyte関数を利用して比較することで、さらなる高速化が図られることも触れていました。
最適化は最後に
多くのパフォーマンスに関連するテクニックを紹介しましたが、パフォーマンスの最適化はいちばん最後に行うべきであることも述べていました。まず動くものを作り、修正して、楽しんだ後に最適化すべきとのことです。また、最適化にはトライアル&エラーが必須であるとし、benchmarkやbenchmark-ipsを利用してベンチマークを行うこと、ruby-prof stackprofなどを利用してプロファイルを取得することをアドバイスしていました。
まとめ
最近のRubyKaigiのキーノートでは、主にRuby処理系自体や、C言語に関するトピックが中心だったこともあり、Rubyで書かれたWebアプリケーションフレームワークという「高レベル」なソフトウェア開発者による発表に、やや意外な印象を持っていました。しかしながら、キーノートの最初から最後まで、密度の高いパフォーマンスに関する発表でした。正直、私には理解の及ばない点もいくつもありましたので、興味のある方は後日公開されるであろうキーノートの録画をご覧になることをおすすめします。
Jeremyが紹介した手法には、長期的にも守るべき原則もあった一方、キーワード引数が遅いなどの課題はRubyの処理系の改善によって性能が劣化しないようになってほしいものもあります。それらのテクニックがRubyKaigiで披露された結果、将来のRubyが「ふつうの」コードを書くだけで十分に速くなれば、それも一つのキーノートの効果といえるでしょう。なお、ここで記述されたActive Recordのコードは、このキーノートの後に更新 されています。すでに発表のよい影響が出ているもといえるでしょう。
(写真提供=RubyKaigi 2019)