Money Forward Developers Blog

株式会社マネーフォワード公式開発者向けブログです。技術や開発手法、イベント登壇などを発信します。サービスに関するご質問は、各サービス窓口までご連絡ください。

20230215130734

Rails appをRubyコードの改善だけで50%以上高速にした話

この記事は 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】 ■マネーフォワード公式noteTwitter - 【公式】マネーフォワードTwitter - Money Forward Developersconnpass - マネーフォワードYouTube - Money Forward Developers