この記事は Money Forward Engineering 2 Advent Calendar 2022 18日目の投稿です。
こんにちは。マネーフォワード関西開発拠点でマネーフォワード クラウド会計Plus (以下会計Plus)のエンジニアをしているぽっけです。
この記事では、私が行った高速化について紹介します。
私は最近Railsアプリケーションの高速化を行っており、ある画面のレスポンスタイムを50%以上削減しました。そしてこの改善はRubyレベルの変更のみで達成しました。
この記事での「Rubyレベルの変更のみ」は、MySQLやRedis、Web APIなどへのアクセスには全く手を入れず、Rubyのプロセスが消費する時間のみを変更した、ということを意図しています。
MySQLなどへのアクセスは通常ボトルネックになりがちな箇所です。今回そこに手を入れずに高速化を達成できたのは、1つの面白い事例だと思います。この記事では改善にあたってどのようなことをやったのかを紹介します。
改善の内容
今回対象とする画面では、3つの改善を行いました。この章ではその3つの改善を紹介します。
O(N2)の処理を、O(N)にする
まず、O(N2)かかっていた処理を、O(N)にしました。
起きていた問題
具体的には次のようなメソッドが存在していました。受け取ったitem
引数の子の一覧を返すメソッドです。
def child_items(item) @items_cache ||= {} return @items_cache[item.id] if @items_cache[item.id].present? @items_cache[item.id] = @items.select { |i| i.parent_id == item.id } end
そしてこのchild_items
メソッドは、全てのitems
を引数に呼び出されていました。このメソッドは呼び出されるたびに@items
の配列を全件走査するため、O(N2)の時間がかかってしまっていました。
ここの処理は軽いため、一見ボトルネックにはならないように見えます。今回はこのitems
の件数が多かったためここがボトルネックとなってしまっていました。
解決方法
このメソッドを次のように書き換えました。
def child_items(item) @items_cache ||= @items.group_by(&:parent_id) @items_cache[item.id] end
書き換え後では、@items
配列を事前に走査して、{ parent_id => children }
のような形式のハッシュを予め組み立てます。これによって、@items
配列の走査が1回で済むようになり、高速化がされました。
改善結果
この改善によって、ローカル環境でのレスポンスタイムが17.5%ほど改善しました。
本番環境では、今回高速化したかったメソッドが高速化されていることがDatadog APMを使用して確認ができました。一方で、速度が各ユーザーのデータの差に大きく影響されること、他の改善も短期間でリリースしたことから、明確な速度差は測れませんでした。これは今後の課題ですね。
数値をフォーマットする処理を高速化する
つぎに、数値のフォーマット処理を高速化しました。
起きていた問題
会計PlusではAction Viewが提供するnumber_to_currency
メソッドを使用して、数値を3桁カンマ区切りの文字列に変換していました。例えば、123456789
は"123,456,789"
に変換されます。
https://api.rubyonrails.org/classes/ActionView/Helpers/NumberHelper.html#method-i-number_to_currency
今回高速化の対象となった画面では非常に多くの数字が表示されていました。そのためnumber_to_currency
メソッドが多く呼ばれます。
number_to_currency
メソッドは数値をカンマ区切りにするだけではなく、I18nに関連してより多くの機能を持ったメソッドです。そのため、その処理に時間がかかってしまっていました。私達のアプリケーションではただカンマ区切りの文字列が手に入ればいいので、不要なコードが速度低下を引き起こしてしまっていました。
解決方法
数値を3桁カンマ区切りにすることに特化したメソッドを自前で実装して、高速化を行いました。具体的には次のコードです。
def number_to_currency_without_unit(number) return nil if number.nil? # 整数しか考慮していないので、整数でない場合は元の処理にfallback return number_to_currency(number, unit: '') unless number.is_a?(Integer) abs = number.abs return number.to_s if abs < 1000 str = abs.to_s len = str.size i = len - 3 while i > 0 str.insert(i, ',') i -= 3 end number.negative? ? '-' + str : str end
Integer#to_s
で作った文字列に対して、3文字ごとにString#insert
を使って破壊的にカンマを挿入しています。
なお、この方法の他にも以下の2つの方法も実装を試しました。
Integer#to_s
した文字列をString#bytes
で1文字ずつに分解し、3文字ごとにカンマで連結する方法Integer#to_s
した文字列をString#[]
で3文字ずつに分解し、それをカンマで連結する方法
その結果、String#insert
を使った方法が一番高速だったため、この方法を採用しました。高速なのは、文字列オブジェクトの生成個数が一番少なく抑えられているためではないかと予想しています。
改善結果
この改善によって、ローカル環境でのレスポンスタイムが11%ほど改善しました。
1つ目の例と同じく、本番環境でのレスポンスタイムの改善の計測は困難でした。ですがDatadog APMではviewのrender時間を見ることができ、その時間は次のように大きく改善していました。
グラフ中央の大きく崖になっているところで改善をデプロイしました。
OpenStructをStructに置き換える
最後に、OpenStruct
が使用されていた箇所をStructに置き換えました。
起きていた問題
対象の画面では、非常に多くのオブジェクトをOpenStruct
を用いて生成していました。この画面ではActiveRecordで非常に多くの件数を取得しています。その高速化を目的として、ActiveRecordのオブジェクトの代わりに、OpenStruct
が使われていました。
ところが計測をしてみたところ、OpenStruct
が原因で速度が悪化していそうな事がわかりました。ローカル環境でStackProfを使ってプロファイリングを取ったところ、OpenStruct
のオブジェクト生成に時間がかかっていました。
解決方法
OpenStruct
の代わりにStruct
を使用するようにしました。
Struct
にはOpenStruct
と比べて次のような利点があります。
- オブジェクトの生成が高速化される
- これが今回の高速化で狙っていたポイントでした。
- オブジェクトのフィールドへのアクセスも高速化される
- 静的にフィールドが決まるので、バグに強くなる
- 例えば
OpenStruct
は生成時に指定しないフィールド名でメソッド呼び出しをしても、NoMethodError
にならず、nil
が返ります。 Struct
の場合はNoMethodError
になるため、タイポなどに気が付きやすくなります。
- 例えば
- プログラマが意図を理解しやすくなる
Struct
の場合はクラスに名前を付けられるので、コードを読む際のヒントになります。
パフォーマンス以外の観点からも、ほとんどの場合はStruct
を使ったほうが望ましいでしょう。
今回のユースケースではOpenStruct
のフィールドは固定されていたため、Struct
で十分でした。そのためStruct
を使ってクラスを定義し、そのクラスをOpenStruct
の代わりに使うように書きかえました。
改善結果
ローカル環境では、レスポンスタイムが38%ほど改善しました。
またGCの実行時間が大きく減少したのも印象的でした。ローカル環境でStackProfを用いてプロファイリングをしたところ、次の画像のように改善前はGCが全体の28%を占めていたところ、改善後はGCの占める時間が9%まで減少していました。
本番環境でも改善が見られました。他の改善も合わせた結果になりますが、次のようにレスポンスタイムの50パーセンタイル、99パーセンタイルがともに大きく改善しています。
改善のための計測
今回Rubyの処理の修正で速度を改善できましたが、その裏には計測がありました。
傾向の調査
この速度改善に着手し始めたとき、まず最初にDatadog APMからプロファイリングの傾向を見ることにしました。すると次のようなプロファイリングが表示されました。
RDBMSで時間がかかっていることを予想していたので、この結果は意外でした。RDBMSへのアクセスはこの図の濃い紫色の部分なのですが、それがほとんどないことがわかります。そしてrails.action_controller
の実行に時間がかかっており、それ以上に詳細な情報が取れていないことが分かります。つまり、RDBMSなどにアクセスしていないコードがボトルネックになっていることがわかりました。
ですが、この図だけでは「ボトルネックがRDBMSではない」ことは分かっても、「ボトルネックがどのメソッドなのか」は分かりません。そのため、より詳細な計測をすることにしました。
詳細な計測
より詳細な計測をするための道具として、Datadog APMにはCustom Instrumentationという仕組みがあります。今回はこの機能を使用して、より詳細な計測を実施しました。
まず、ローカル環境で遅い箇所のあたりをつけます。具体的にはStackProfを使用して遅いメソッドがあることを突き止めました。
つぎにその遅いメソッドをDatadog APMでトレースできるようにします。Datadog::Tracing.trace
メソッドを使い、次のように設定しました。
# https://docs.datadoghq.com/tracing/trace_collection/custom_instrumentation/ruby/?tab=activespan#adding-spans def slow_method Datadog::Tracing.trace(__method__) do # The original implemenatation of `slow_method`. end end
このようにtrace
メソッドで処理を囲むことで、その範囲をトレースできます。trace
メソッドをあたりをつけた何箇所かに仕込んだあとでDatadog APMを見てみると、次のように黄緑色の部分が可視化されていました。
これで、initialize
メソッドの中の赤く塗りつぶされたメソッドの実行にそれぞれ時間がかかっていることが分かりました! どこが遅いのかなにもわからない状態からは、だいぶ進歩しましたね。
ここまでくれば、あとは高速化をするだけです。ということで、先ほど紹介した3つの高速化をこの後に実施しました。
なぜStackProfでは不十分なのか
ここまで読んで、「ローカル環境でStackProfを使ってあたりをつけられているなら、それで十分ではないか」と思った方もいるかも知れません。たしかにそれでも良いのですが、今回は以下のことを考えてDatadogにtraceを仕込みました。
- 本番環境とローカル環境で、データ傾向に差があるため
- ローカル環境ではデータの傾向が偏りがちで、本番環境とパフォーマンスの傾向に差が出てしまう可能性が考えられます。そのためローカル環境での傾向がどの程度本番環境でも通用するかをDatadog APMで確認しました(今回は似た傾向が出ていたので改善がスムーズに進みました)。
- 本番環境では多くのお客様が使っており、データの傾向もお客様ごとに異なります。そのため、多くのお客様で傾向を見れるようにしたいという意図もありました。
- 改善をした後、それを確認するため
- このトレースを仕込むことで、改善をリリースした後にその改善の効果があるかが計測しやすくなります。トレースを使えば、レスポンスタイム全体の改善を見なくとも、トレースした範囲の実行時間が減っているかを確認できます。
- 今回改善したアクションはレスポンスタイムにばらつきが多いため、トレース部分に絞って確認できるのは便利でした。
まとめ
Rubyレベルの修正で、Rails appの速度改善を行った話を紹介しました。
速度改善で大切なのは、ボトルネックを改善することです。多くのWebアプリケーションではボトルネックがRDBMSへのクエリにありますが、今回のようにRubyレベルがボトルネックのこともあります。この記事を読んだからと言って盲目的にRubyレベルの改善だけをするのではなく、またRDBMSへのクエリだけを改善するのではなく、当てずっぽうにならずボトルネックを直すことが大切です。
ぜひあなたのアプリケーションでも計測をしてボトルネックを洗い出してみてはいかがでしょうか。
会計Plusでは、高速化に興味のあるエンジニアを募集しています!
マネーフォワードでは、エンジニアを募集しています。 ご応募お待ちしています。
【会社情報】 ■Wantedly ■株式会社マネーフォワード ■福岡開発拠点 ■関西開発拠点(大阪/京都)
【SNS】 ■マネーフォワード公式note ■Twitter - 【公式】マネーフォワード ■Twitter - Money Forward Developers ■connpass - マネーフォワード ■YouTube - Money Forward Developers