ゲーム開発で必要となる プロファイリングの基本

最適化とパフォーマンス計測

最適化とは、プログラムの処理速度や消費メモリなどを改善し、プログラムのパフォーマンスを向上させることをいいます。ゲーム開発においてはパフォーマンスが重要ですので、最適化は必須といえるでしょう。

ソフトウェア開発においては、最適化を行う前にプロファイリングなどのパフォーマンス計測を行います。これはプログラムの実行時間の大半は、ごく一部の処理によって消費されることが多く、全体の実行時間の大半を占める一部分を最適化した方が効率的であるためです。例えば、実行時間の2割にあたる部分を最適化し、その部分の実行時間を半分にできたとしても、全体では高々10%程度の改善です。しかし、実行時間の8割にあたる部分を最適化し、その部分の実行時間を半分にできたとすると、全体では40%の改善になります。

事前にパフォーマンス計測を行わずに最適化をしようとすると、もともとあまり処理時間がかかっていなかった部分の最適化を行ってしまい、結果全体では速くならなかった、ということもよくおこります。

なお、一口に最適化といっても、GPUなのかCPUなのか、あるいはメモリ消費量なのか処理速度なのか、と多数の分野があります。本稿ではCPUの実行速度に関する最適化のためのプロファイリングについて説明します。

プロファイリングとは

プロファイリングとは、実際にプログラムを実行しながら統計情報を取り、プログラムの実行において、どの部分に時間がかかっているのか調べることをいいます。

またプロファイリングを行うツールをプロファイラといいます。インテル社、マイクロソフト社などの製品がよく使われますが、これらの製品は高価なこともあり、個人の開発でプロファイラを使うのは簡単ではありませんでした。しかし、近年登場したVisual Studio Communityは条件があるものの無料で利用できます。Professionalなどの製品版とほぼ同等な機能があり、これにプロファイラも含まれていますので、個人開発でもプロファイラを使う機会が増えてきました。

プロファイリングのための準備

本稿では、Visual Studio Communityを使ったプロファイリングを紹介します。以下のURLからVisual Studio Communityのインストーラをダウンロードし、インストールを行ってください。

次にプロファイリング対象のサンプルプログラムを準備します。

  1. Visual Studioの[ファイル]メニューから[新規作成⁠⁠→⁠プロジェクト]を選択し、⁠新しいプロジェクト]ダイアログボックスを開きます。
  2. ダイアログボックス左のツリーから[インストール済⁠⁠→⁠Visual C++⁠⁠、中央のリストから[Windowsコンソールアプリケーション]を選択し、⁠OK]をクリックします。
  3. 作成したプロジェクトのConsoleApplication1.cppを開き、リスト1で置き換えます。
  4. [構成マネージャー][アクティブソリューション構成][Release]にします。
  5. [ビルド]メニューから[ソリューションのビルド]を選択し、正常にビルドできることを確認します。
リスト1 プロファイリングするサンプルプログラム
#include "stdafx.h"

  #pragma optimize( "", off )
  
  struct LargeStruct {
    int a;
    int b;
  };
  LargeStruct aosSrc[10000];
  LargeStruct aosDst[10000];
  
  void copy1() {
    for (int i = 0; i < 10000; ++i) {
      aosDst[i].a = aosSrc[i].a;
    }
    for (int i = 0; i < 10000; ++i) {
      aosDst[i].b = aosSrc[i].b;
    }
  }
  
  void copy2() {
    for (int i = 0; i < 10000; ++i) {
      aosDst[i].a = aosSrc[i].a;
      aosDst[i].b = aosSrc[i].b;
    }
  }
  
  void func1() {
    for (int i = 0; i < 100; ++i) {
      copy1();
      copy2();
    }
  }
  
  int main() {
    for (int i = 0; i < 100; ++i) {
      func1();
      copy1();
      copy2();
    }
    return 0;
  }
  
  #pragma optimize( "", off )

プロファイリングの実行

基本的なCPUサンプリングによるプロファイリングを行います。以下の手順を実行してください。

  1. Visual Studioの[分析]メニューから[パフォーマンスプロファイラー]を選択します。 2.[レポート]ページで[パフォーマンスウィザード]をチェックし、⁠開始]をクリックします。 3.[パフォーマンスウィザード][CPUサンプリング]を選択し、⁠完了]をクリックします。プロファイリングが完了すると、⁠サンプル プロファイル レポート]が表示されます。

CPUサンプリングとは、プログラムの実行中に一定間隔で実行中のスタックトレース(つまりその時点で実行中の関数と、それがどこから呼ばれたかの情報)を記録する方式です。サンプリングによりある関数の出現回数が多いということは、プログラム実行時間全体に対してその関数の実行時間の割合が高いということになります。よって、このサンプリングの統計を見ることで、そのプログラム中のどの関数に時間がかかっているかを調べられます。この方式は完全に正確な測定ではありませんが、プロファイリングによるオーバーヘッドが小さく手軽に行えるため、パフォーマンス解析のはじめの一歩としては使いやすいでしょう。

図1 CPUサンプリングによるプロファイリング結果
図1 CPUサンプリングによるプロファイリング結果

それでは結果を見てみましょう図1⁠。最初[概要]ビューが開かれていると思いますが、⁠ホットパス]はプログラム実行全体の処理時間のうち、どこに時間がかかっているかをツリー形式で表示しています。たとえば、main関数の実行時間(≒プログラム全体の実行時間)のうち、99.06%はfunc1関数の実行時間であり、さらにその99.06%のうちの66.25%はcopy1で、32.81%はcopy2の実行時間であることが分かります。

なお、それぞの関数の実行時間として[包括サンプル][排他サンプル]という項目があります。⁠包括サンプル]はその関数とその関数から呼び出される他の関数の実行時間も含みますが、⁠排他サンプル]はその関数自体の実行時間であり、そこから呼びだされる関数の実行時間は含みません。たとえばfunc1は、それ自体ほとんど処理をしていないので、⁠排他サンプル]は0.00%ですが、重い処理のcopy1とcopy2を呼び出しているため、⁠包括サンプル]はそれらの合計の99.06%になっています。また、copy1とcopy2は他の関数を呼び出さず自身の処理が重いので、⁠排他サンプル]自体が大きく、⁠包括サンプル][排他サンプル]と同じ値です。

一方[最も頻繁に個別の作業を実行している関数]には、プログラム全体の実行時間のうち関数ごとの実行時間が大きい順に並んでいます。ここでいう関数ごとの実行時間とは、その関数から呼び出される他の関数の実行時間は含みません。⁠ホットパス]のところで説明した[排他サンプル]に相当します。なお[ホットパス][最も頻繁に個別の作業を実行している関数]では、同じ[排他サンプル]ですが少しだけ値が違います。これは後者のほうはプログラム実行全体を通した、その関数の実行時間が表示されているためです。具体的には、前者のcopy1の[排他サンプル]は、func1から呼び出されているcopy1の実行時間のみが表示され、後者のcopy1の[排他サンプル]にはmainから呼び出されているcopy1の実行時間も含まれているはずです。

さて、結果を見ると、関数copy1の処理時間がおよそ全体の66%であり、このプログラムのボトルネックになっていることが分かります。まずはこのcopy1の処理速度を改善するのがよいでしょう。なお、関数copy2も同様の処理を行っていますが、copy1はcopy2の2倍くらい時間を使っていることが分かります。このように見ただけでは分からないようなパフォーマンス上の問題を定量的に分析できることがプロファイリングのメリットです(今回のサンプルは恣意的な例なので、実際には見てすぐわかった方も多いかと思います⁠⁠。

copy1の実装はarray of structureを飛び飛びにアクセスしているので効率がよくありません。たとえばcopy2の実装でcopy1を置き換えて再度プロファイリングすると、copy1だけ処理が重いのが改善できることが確認できます。なお、ここでは説明は省きますが、興味のある人は参考文献や"array of structure"をキーワードに調べてみてください。

インストルメンテーションによるプロファイリング

今回の例では、ボトルネックとなっている関数を調べ、その関数の処理速度を改善する最適化について説明しました。しかし関数の処理時間を減らすには、もう一つ方法があります。その関数の呼び出し回数を削減するのです。例えば、ある計算を行う関数に対して、その計算結果を何度も使うために関数呼び出しを連続して行うプログラムはよく見ると思います。これ自体は悪いことではありませんが、処理負荷が高い場合は、最初に関数を呼び出して得られた計算結果を一時変数に保存し、他の関数呼び出しをその変数で置き換えることで、関数呼び出しの回数を削減し、処理速度を改善できます。

それではプログラムの実行を通して関数が何回呼び出されているかはどのように調べればいいでしょうか。Visual Studio Communityのプロファイラではインストルメンテーション方式を使うことで、呼び出し回数を調べられます。インストルメンテーション方式とは、プロファイラが関数呼び出し毎に記録する方式です。正確な呼び出し回数が分析できますが、オーバーヘッドがCPUサンプリングよりも大きく、もともと処理時間のかかるプログラムの解析にはさらに時間がかかる点は注意が必要です。

インストルメンテーション方式は、前述のプロファイリング手順の[パフォーマンスウィザード]で、⁠インストルメンテーション]を選択することで行えます。プロファイリング実行後表示されるページ上部の[現在のビュー]から[関数]を選択してください。関数毎の呼び出し回数が分かります。また[コールツリー]ビューでは、どの関数からどの関数が何回呼び出されているかツリー形式で表示されます。

まとめ

本稿ではVisual Studio Communityによる、CPUサンプリング方式・インストルメンテーション方式のプロファイリングについて説明しました。Visual Studio Communityの登場により、個人開発でもプロファイリングの敷居が下がりました。最適化に活用してみてください。

参考文献

長谷川勇はせがわいさむ)

株式会社スクウェア・エニックスにて,Luminous Studio,「FINAL FANTASY XV」の開発に参加し,VFX・UIを担当。専門は言語処理系。学会活動にも参加している。

Twitter:@IsamuHasegawa