アプリケーションの処理を40倍高速化!効果的な最適化手法と実践事例の紹介

レバレジーズ株式会社 HRテック事業部の桐生です。

アプリケーション開発において、重たい処理の高速化は避けては通れない課題の一つですが、なんとなくで取り組んであまり良い結果が得られなかったり、そもそもどこから手をつけていいか分からなかったりすることもあるかと思います。

本記事では、処理の高速化を上手に行うための流れと、各ステップで抑えるべきポイントをご紹介します。

実際に私が携わっていたプロダクトでも、今回ご紹介する流れに則って高速化に取り組み、最終的に処理時間を40倍以上高速化することに成功しました。こちらの具体的な事例も含めて詳しくご紹介しますので、ぜひ最後までお読みいただければと思います!

なお、こちらは6月に開催されたレバレジーズ テックフェスにて発表させていただいた内容と同じものです。

大まかな流れ

以下の流れで処理の高速化を行っていきます。

  1. 無駄を含む処理を見つける
  2. 処理の遅い原因を特定する(計測)
  3. 高速化のための手段を考え、実装する
  4. 実装した高速化の効果を測定する

「無駄」を含む処理を見つけるには?

遅い処理を目の前にした時に、まず考える必要があるのは「この処理に削れる無駄は残っているか?」ということです。当たり前の話ではあるのですが、既にリソースを十分に使い切っている処理を高速化するのは難しいためです。

「無駄」が存在していることを判断するのに重要なのが、処理内容から考えてどのくらいの時間がかかりそうかを予測し、それを実際の処理時間と比較することです。「こういう処理だからこれくらいの時間がかかりそう」という予測を精度良く行うことで、無駄を見つけることができるようになります。

処理の性質ごとにかかる時間を計算する

処理時間を見積もる際には、「どんなリソースで」「どれくらいの量の」処理を行っているかに着目します。例えば、多くの処理で使われるであろう「CPU」「ストレージ」「ネットワーク通信」は、以下に示すように1回の操作にかかる時間が非常に大きく異なります。

このため、処理の性質によって以下のように見積もりを変える必要があります。

計算処理が中心の処理の場合、時間がかかると感じるには少なくとも数百万回〜数千万回程度の計算ステップが必要になります。ソート処理や大規模な文字列処理、および画像処理等はこれに該当することがありますが、一般的な業務アプリケーションでこのレベルの計算負荷を求められる場面は比較的まれといえます。 逆に、ストレージ・ネットワークアクセスが中心の場合、もっと小さな数字でも時間がかかる場合があります。

処理時間を概算してみる

上記を踏まえて、実際に処理時間を見積もってみましょう。例として、以下のような処理を想定します。

  • 1000件/ユーザーのデータを、100ユーザー分集計する
  • 集計は単純な平均・合計等
  • データはDBサーバーから取得する

この場合、行われる処理は以下のように分類できます。

  • CPUでの計算: 1000×100 = 10万のオーダー → 1秒は決して超えない
  • データ取得: NW通信1回 + 読み出しに1秒〜数秒?

これらのことから、どんなに長くても数秒〜10秒程度で完了することが予想できます。よって、これよりも遥かに長い時間がかかっている場合はほぼ確実に無駄が潜んでいると考えられるわけです。

このように、処理内容から想定の処理時間を見積もることで、無駄を含んでいる処理を見分けることができます。

計測せよ!

無駄を含んでいそうな処理を特定できたところで、次はこの処理を速くしようという話になるわけですが、ここでやってしまいがちなのが「何となくここが遅そう」という推測だけで高速化に手をつけてしまうことです。しかし、これは次に示すように、可能な限り避けるべきです。

ロブ・パイクのプログラミング5カ条

プログラミング界隈で有名なロブ・パイクの「プログラミング5カ条」より、処理時間に関する2つのルールをご紹介します。

  • ルール1: プログラムがどこで時間を消費することになるか知ることはできない。ボトルネックは驚くべき箇所で起こるものである。したがって、どこがボトルネックなのかをはっきりさせるまでは、推測を行ったり、スピードハックをしてはならない。
  • ルール2: 計測すべし。計測するまでは速度のための調整をしてはならない。コードの一部が残りを圧倒しないのであれば、なおさらである。

(引用元: http://www.lysator.liu.se/c/pikestyle.html )

ここで述べられている通り、処理の重さが具体的に何に起因するのか、推測によって特定するのは非常に困難です。このため、推測によって作業に手をつけると、全く遅くない箇所を一生懸命高速化するという事態に陥る可能性があるのです。

このように、高速化を行う際には「どこが遅いのか」を計測して特定し、処理時間の占める割合が大きいところを削っていく必要があるのです。

処理時間の計測のための手法はいろいろと存在しますが、ここでは代表的なものを2つご紹介します。

タイマーによる計測

一つ目は非常に愚直な方法で、処理の前後の時刻(タイマーの値)を記録することで処理時間を計測する方法です。

例として、JavaScriptのDateクラスを使って処理時間を計測する方法を示します。

function run() {
  // 開始時の時刻を記録
  const startTime = Date.now();

  someHeavyWork();

  // 終了時の時刻を記録
  const endTime = Date.now();

  // 経過時間は、終了時時刻 - 開始時時刻 で求められる
  const elapsedTime = endTime - startTime;

  console.log(‘someHeavyWork:’, elapsedTime, ‘ms’);
}

Date.now()を呼ぶことで、その時点での時刻(ミリ秒単位)を記録することができます。今回は例としてJavaScriptの機能を用いましたが、他の言語でも現在時刻のタイムスタンプを取得する関数(PHPのmicrotimeやRubyのTime.now等)を用いることで同様に計測できます。これを利用して処理の開始時・終了時の時刻を計測し、その差を取ることで、間に挟んだ処理にどのくらいの時間がかかったかを知ることができます。

※なお、JavaScriptの場合はconsole.timeという関数を用いることでより簡単に時間を計測することができます。詳しくはMDNの該当ドキュメントをご参照ください。

このような時間の記録処理を、重い可能性のある処理の前後に挟むことで、実際に重い部分を絞り込んでいきます。(最初は大きめの範囲を挟んで、徐々にその範囲を狭めていく)

この方法は非常に原始的なので、どんな処理にでも適用できるという良さはあるのですが、その反面、計測処理を手動で挟んでいく必要があるため手間がかかります。次に紹介するプロファイルを用いると、手動で処理を挟まずに時間を計測することができます。

プロファイラの活用

プロファイラとは、関数ごとの処理時間を自動で記録してくれるツールです。プロファイラを用いると、プログラム実行中の関数呼び出しを追跡して、時間のかかっている部分を特定することができます。大抵のプラットフォームではそれぞれのプラットフォームごとに専用のプロファイラが存在しており、例えばNode.jsプロファイラを使って計測を行うと以下のような出力を得ることができます。

各関数の中でかかった時間が帯状に表示されています。また、グラフの上下関係は関数の呼び出し関係を表しています。下側にいくほど関数の呼び出し階層が深くなっていっています。

今回用いたNode.jsプロファイラの使い方の参考記事: Node.js の CPU プロファイリングでボトルネックを特定する

–-

今回は処理時間を計測する方法を2つご紹介しました。これらを用いて実際に遅い箇所を特定したら、ついに実際の高速化作業に取り組むことになります。

高速化に王道なし

いよいよ実際の高速化作業を行うわけですが、プログラムが遅い原因というのは実に様々で、そのため、高速化のための普遍的な手法も存在しません。ただ、それだけで説明を済ませてしまうのも寂しいので、今回は私が実際に関わった高速化の事例をご紹介して、高速化作業のイメージを掴んでいただこうと思います。

事例:月次処理の高速化

1つ目にご紹介する事例、給与計算ソフトにおける月次の勤怠締め操作の高速化です。こちらは会社ごとの全社員の勤怠データを集計して給与額を計算する処理なのですが、会社あたりのユーザー数が増加すると、計算完了まで何分もかかってしまうという問題がありました。

処理時間の見積もり

まずは、本来の処理時間を見積もってみます。具体的にかかっていた時間ですが、例えば300人程度の会社ですと7分以上かかっていました。ただ、実際に処理するデータの量を考えると以下のようになり、大きな乖離が生じています。

  • 300人 * 30日 = 9000個の勤怠データの集計 + 過去の有給使用データ(300人 * 数十件 = 数千〜1万件)の集計
    • 集計は基本的には時間を累積していくのみ
    • 実際は営業日は30日もないので多めに見積もっている
  • データの取得等含めて考えても何十秒もかかるのですらおかしい
    • 数万件のデータの取得(数秒) + データ処理(1秒以内) + 数万件のデータの書き戻し(数秒)にしかならないはず

これを踏まえると、この処理はまだ高速化の余地があると判断できます。

遅い箇所の特定

次に、実際に何が遅い原因となっているかを計測して調査します。処理部分にログを仕込んで計測した結果、DBのデータ読み書きに大半の時間を費やしていることがわかりました。

さらに詳しくコードを調査した結果、以下の2つの原因が判明しました。

  • 特定のテーブル(大きめ)にインデックスが張られていなかった
  • 1ユーザーの処理ごとに大量のクエリを発行していた

それぞれ詳しく説明します。

テーブルにインデックスが張られていなかった

今回の遅かった箇所の一つが、勤怠集計データを保持するRDBテーブルからのデータ読み込みです。読み取っているデータはユーザーごとに数十件程度なのですが(1ヶ月分のデータしか読み取らないため)、読み取りに異常に時間がかかっていました。これは当該テーブルに必要なインデックスが張られていなかったことが原因です。

データベースから条件で絞ってデータを取得する場合、何も設定しないとテーブル全体を検索するため、テーブル全体のデータ量に比例した時間がかかってしまいます。このため、通常は検索したいカラムにインデックスを張ることでこの時間を一定に抑えるのですが、今回操作していたテーブルでは検索対象のカラムにインデックスが張られていませんでした。このテーブルには過去の全ての勤怠データが保持されており、その総レコード数は数十万にも及んでいたため、不要なデータに対して検索をかけており時間がかかっていたのです。

対処法としては、単純に検索対象のカラムに有効なインデックスを張るだけでした。この対処だけで処理時間が元々の3分の1程度にまで減少しました。

1ユーザーごとに多数のクエリを発行していた

もう一つの原因が、1ユーザーごとにデータの取得・書き出しクエリを発行していたことです。今回の実装においては、1ユーザーごとに必要なデータを全てデータベースから取得しており、また結果データの書き込みも1行ずつ行っていたため、結果的にユーザーあたり100以上のクエリを発行しており、トータルで何万件ものクエリを発行してしまっていました。これによりネットワーク通信待ちとクエリ実行のオーバーヘッドで大きく時間がかかってしまい、何分も処理に時間がかかってしまっていました。

対処法としては、予め処理対象のユーザーのデータをまとめて取得することによりクエリ発行回数を減らしました。また、書き戻しの際もある程度まとめて書き込みクエリを発行するようにしました。


上記2つの施策を行ったことにより、計算アルゴリズムには一切手を加えませんでしたが、最終的に10秒程度まで処理時間を短縮することに成功しました。

事後の計測を忘れずに

高速化作業を行った後は、それが本当に高速化に寄与したかどうかを必ずチェックするようにしましょう。特に、2つ以上の変更を行った場合は、両方の変更に効果があったかどうかをそれぞれ調べないと、効果のない変更を入れ込んでしまうことがあります。実際に自分が高速化を行った際にも、片方の変更は高速化に寄与していたが、もう一方の変更はむしろプログラムを遅くしてしまっていた、という場合がありました。

高速化のためのコード変更というのは一般的にコードをより複雑にしてしまうものなので、効果のないコードはなるべく排除するようにしましょう。

まとめ

ここまでで、処理高速化のための大まかな流れを一通りご紹介しました。見積もりと計測に基づいて高速化作業に取り組むことで、より高い精度で作業を行うことができるようになるはずです。

本記事の内容が少しでも皆様のお役に立てば幸いです。最後までお読みいただきありがとうございました!