この記事は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)にしました。
具体的には次のようなメソッドが存在していました。受け取ったitem 引数の子の一覧を返すメソッドです。
defchild_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の件数が多かったためここがボトルネックとなってしまっていました。
このメソッドを次のように書き換えました。
defchild_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桁カンマ区切りにすることに特化したメソッドを自前で実装して、高速化を行いました。具体的には次のコードです。
defnumber_to_currency_without_unit(number)returnnilif number.nil?# 整数しか考慮していないので、整数でない場合は元の処理にfallbackreturn number_to_currency(number,unit:'')unless number.is_a?(Integer) abs = number.absreturn number.to_sif abs <1000 str = abs.to_s len = str.size i = len -3while i >0 str.insert(i,',') i -=3end number.negative? ?'-' + str : strend
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を用いて生成していました。この画面では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-spansdefslow_methodDatadog::Tracing.trace(__method__)do# The original implemenatation of `slow_method`.endend
このようにtraceメソッドで処理を囲むことで、その範囲をトレースできます。traceメソッドをあたりをつけた何箇所かに仕込んだあとでDatadog APMを見てみると、次のように黄緑色の部分が可視化されていました。
これで、initializeメソッドの中の赤く塗りつぶされたメソッドの実行にそれぞれ時間がかかっていることが分かりました! どこが遅いのかなにもわからない状態からは、だいぶ進歩しましたね。
ここまでくれば、あとは高速化をするだけです。ということで、先ほど紹介した3つの高速化をこの後に実施しました。
ここまで読んで、「ローカル環境でStackProfを使ってあたりをつけられているなら、それで十分ではないか」と思った方もいるかも知れません。たしかにそれでも良いのですが、今回は以下のことを考えてDatadogにtraceを仕込みました。
Rubyレベルの修正で、Rails appの速度改善を行った話を紹介しました。
速度改善で大切なのは、ボトルネックを改善することです。多くのWebアプリケーションではボトルネックがRDBMSへのクエリにありますが、今回のようにRubyレベルがボトルネックのこともあります。この記事を読んだからと言って盲目的にRubyレベルの改善だけをするのではなく、またRDBMSへのクエリだけを改善するのではなく、当てずっぽうにならずボトルネックを直すことが大切です。
ぜひあなたのアプリケーションでも計測をしてボトルネックを洗い出してみてはいかがでしょうか。
会計Plusでは、高速化に興味のあるエンジニアを募集しています!
マネーフォワードでは、エンジニアを募集しています。ご応募お待ちしています。
【会社情報】■Wantedly■株式会社マネーフォワード■福岡開発拠点■関西開発拠点(大阪/京都)
【SNS】■マネーフォワード公式note■Twitter - 【公式】マネーフォワード■Twitter - Money Forward Developers■connpass - マネーフォワード■YouTube - Money Forward Developers
引用をストックしました
引用するにはまずログインしてください
引用をストックできませんでした。再度お試しください
限定公開記事のため引用できません。