連載
.NET&Windows Vistaへ広がるDirectXの世界

第6回 .NETアプリを軽快にするためのガベージ・コレクション講座

NyaRuRu
Microsoft MVP Windows - DirectX(Jan 2004 - Dec 2007)
2007/02/13
Page1 Page2 Page3 Page4

値型でGCヒープの消費を抑える

 .NETによるゲーム・プログラミングで最も頼りになるのは、値型のフィールドのみで構成された値型(以下、純粋な値型と呼ぶ)と、そしてその配列である。.NET 2.0ではジェネリックが導入され、さらに値型のメリットが生かしやすくなっている。

 純粋な値型はオブジェクト参照の連鎖の末端に位置する。配列化してもこの性質は変わらないため、要素数1万の純粋な値型の配列は、1万個のオブジェクトの状態を管理しながら、それ以上オブジェクト参照を増やさない。この性質は、まだ生きているオブジェクトを探索しているガベージ・コレクタの処理時間を短縮する効果がある。

 GCでスレッドがどれぐらいの時間停止するのか(レイテンシ)も、ゲームの品質としては重要な要素である。「Taming the CLR: How to Write Real-Time Managed Code」では、GCが1秒に1回発生するという仮定のもとで、25〜30ミリ秒の停止時間が起きると、利用者は時間のギャップに気付くだろうと述べられている。

 さらに記事には、10万個のオブジェクトがまだ参照されていることをXbox 360 CLRが調べて回るだけで、14ミリ秒の時間が必要だったという記述が続く(コンパクト化の作業時間を含まないでこの値だそうだ)。この停止時間を短くするには、結局のところオブジェクト数を減らすしかほかにない。純粋な値型の配列は要素数によらず1オブジェクト扱いなので有利というわけだ。幸い、Xbox 360 CLRでは、XNA Remote Performance Monitorに“GC Latency Time (ms)”というそのものズバリのカウンタが存在するので、モニタリングは容易だ。

 さて、せっかくGCの整備された環境でこれを行うのは残念だが、パフォーマンスを維持しつつ、どのような環境でも確実に動作するゲームを作成するために依然として有効な方法がオブジェクト・プーリングである。大量にインスタンスが必要となる型は、ゲーム開始時やステージ開始時に配列として十分な数を確保しておき、それを長期間使い回すという方法だ。

 アクション・ゲームでは、敵の攻撃やエフェクトなど、少ないフレーム数で消滅するものがしばしばこれに該当する。うまくフィールドを定義できれば、敵キャラクタなども値型配列で管理できるだろう。敵キャラクタなどの実装では継承や仮想関数といった仕組みを使用したくなるかもしれないが、純粋にプリミティブなデータとして管理できるかどうか検討してみるとよい。パフォーマンスを重視したデータ構造の設計という点では、データベースのテーブル設計に学ぶところも多い。

値型のコピーとそのコスト

 値型というと、受け渡しなどで頻繁にコピーが発生するため、パフォーマンスへの影響を心配する人がおられるかもしれない。Jeffrey Richter氏は、値型を使うべき条件として、著書『プログラミングMicrosoft .NET Framework 第2版』の中(第5章「単純型、参照型、値型」)で次のように述べている。

  • 型のインスタンスが小さい(大体16bytes以下)
  • 型のインスタンスが大きい(約16bytes以上)が、メソッドのパラメータやメソッドの戻り値としては利用されない

 このことは、値型オブジェクトをコピーで渡すか、参照で渡すかを決める1つの指標になる。値型が16bytesを超える場合は、refキーワードやoutキーワードを用いて参照で渡したり、配列とインデックスというふうに直接コピーが発生しない形で値型オブジェクトを指したりするとよいだろう。

 筆者は、以下のようなシグネチャのデリゲートとコレクション・クラス(カスタムのListクラス)を自作し、サイズの大きな値型のコレクションとして活用している。

/// <summary>
/// 参照によってオブジェクトを渡す軽量なActionデリゲート
/// </summary>
/// <typeparam name="T">値型の型</typeparam>
/// <param name="obj">渡されるオブジェクト</param>
/// <param name="index">コレクション中のインデックス(オプション)</param>
public delegate void LightweightAction<T>( ref T obj, int index )
  where T:struct;
参照によってオブジェクトを渡す軽量なActionデリゲート
 
public class LightweightList<T> where T : struct
{
  public void ForEach(LightweightAction<T> action) { ...... }
}
上記のActionデリゲートをサポートするコレクション・クラス

 このメソッドは、オリジナルのList<T>.ForEachメソッドとは異なり、列挙中のオブジェクトの更新についても意図したものとなっている。一般にIEnumeratorインターフェイス(System.Collections名前空間)を経由した列挙よりも、配列に直接アクセスする方がパフォーマンスに優れている。場合によってはList<T>に対するforeachステートメントの利用を避ける必要があるだろう。

値型による共用体

 また、値型の応用としては、FieldOffset属性(System.Runtime.InteropServices名前空間)を用いることで共用体のように使うというものがある。これによって、少々強引だが単一の値型配列を多態的に用いることが可能だ。

 FieldOffset属性でフィールドをオーバーラップさせるときは、それがCLRの検証をパスするかどうかに注意する必要がある。『プログラミングMicrosoft .NET Framework 第2版』第5章「単純型、参照型、値型」によれば、値型を共用体的に用いる場合には次の点に注意せよとある。

  • 値型と参照型をオーバーラップさせるのは不正である
  • 複数の参照型を同じ開始オフセットに配置することは許されるが、検証不能コードとなる
  • 値型をオーバーラップさせることは許される。ただし、オーバーラップする部分を通じて一部の型の非public領域を書き換えられるような場合、検証不能となる。そのような可能性がなければ検証可能である

値型、インターフェイス、ボクシング

 値型を使用するうえで気を付けるべきことの1つに、意図しないボクシング(boxing:値型を包み込む参照型のクラスを暗黙的に挿入する機能)が挙げられる。GCの発生回数を抑えるという観点では、GCヒープを消費するボクシングは避けるべきである。

 一般にObject型やインターフェイスをパラメータに取るメソッドに値型を渡す際にボクシングが発生する。このようなメソッドには、例えばObjectクラスのEquals(object obj)メソッドやConsoleクラスのWriteLine(object value)メソッドなどがある。次の例を見ていただきたい。

public interface ICharacter
{
  void Update();
}

public struct MyCharacter : ICharacter
{
  public int Count;
  public void Update() { ++Count; }
}

public static class Program
{
  static void UpdateA(MyCharacter c) { c.Update(); }
  static void UpdateB(ICharacter c) { c.Update(); }
  static void UpdateC<T>(T c) where T:ICharacter { c.Update(); }

  static void Main(string[] args)
  {
    MyCharacter c1 = new MyCharacter();
    MyCharacter c2 = new MyCharacter();
    MyCharacter c3 = new MyCharacter();
    MyCharacter c4 = new MyCharacter();
    MyCharacter c5 = new MyCharacter();

    c1.Update(); // シンプルな呼び出し

    ((ICharacter)c2).Update(); // ボクシング発生!

    UpdateA(c3); // コピー

    UpdateB(c4); // ボクシング発生!

    UpdateC(c5); // コピー

    System.Console.WriteLine("{0} {1} {2} {3} {4}",
       c1.Count, c2.Count, c3.Count, c4.Count, c5.Count);
  }
}
意図しないボクシングが発生する例

 2番目のケースと4番目のケースではボクシングが発生している。UpdateBメソッドとUpdateCメソッドは、インターフェイス指向のプログラミングという点では似ているが、前者ではボクシングが発生し、後者では発生しないことにも注意してほしい。

 UpdateCメソッドでボクシングが回避できるのは、.NET Framework 2.0MSILに追加されたconstrainedプリフィックスをC#コンパイラが活用するためだ。ジェネリックによって、インターフェイスによる型安全性の保証と、ボクシングの回避が両立しているところに注目していただきたい。

開発生産性を飛躍的に高めるジェネリック

 幸い、Xbox 360 CLRでは、ボクシングの発生回数もモニタリングすることができる。予想外のボクシングの混入も、プロファイラの活用で容易に発見することができるだろう。

 なお、上記のプログラムの実行結果は「1 0 0 0 0」となる。値が更新されないパターンには、単純コピーされた対象がアップデートされる場合と、ボクシングによってコピーされたオブジェクトがアップデートされる場合の2通りがある。値の更新を意図するようなインターフェイスを値型に適用するときは、混乱が起きないよう注意してほしい。

まとめ

 今回は、知られているようで知られていないガベージ・コレクションについてのいくつかのトピックを取り上げてみた。残念ながら、本来詳細に解説すべき基本的な事柄についてほとんど参考資料に任せる形になってしまったことをおわびしたい。最後にもう一度、本記事でのトピックをまとめておく。

  • パフォーマンスには、スループットとレイテンシ(レスポンス・タイム)の2つがあるということ
  • GCについて疑問があれば、まず適切な情報源を参照してみること
  • オブジェクトの寿命による分類という視点で、アプリケーションを眺め直してみること
  • 自分のアプリケーションが、どれぐらいのペースでメモリを確保しているか意識してみること
  • GCの働きとその影響を、ツールを使って可視化してみること
  • 値型を利用して、GCヒープの消費を抑えること

 これらは、突き詰めれば「自分の作ったプログラムがどのように動くかイメージできるか?」ということに尽きる。「平均10ミリ秒」とか「約10Mbytes」といった、生の数字に対する感覚を普段から養うようにしておくと、いざというときにも落ち着いて対処できるだろう。End of Article


 INDEX
  .NET&Windows Vistaへ広がるDirectXの世界
  第6回 .NETアプリを軽快にするためのガベージ・コレクション講座
    1.GCの影響を軽減する必要性
    2.GCがアプリケーションに与える影響
    3.GCの発生頻度を抑えるための対策
  4.値型でGCヒープの消費を抑える
 
インデックス・ページヘ  「.NET&Windows Vistaへ広がるDirectXの世界」


Insider.NET フォーラム 新着記事
  • 第2回 簡潔なコーディングのために (2017/7/26)
     ラムダ式で記述できるメンバの増加、throw式、out変数、タプルなど、C# 7には以前よりもコードを簡潔に記述できるような機能が導入されている
  • 第1回 Visual Studio Codeデバッグの基礎知識 (2017/7/21)
     Node.jsプログラムをデバッグしながら、Visual Studio Codeに統合されているデバッグ機能の基本の「キ」をマスターしよう
  • 第1回 明瞭なコーディングのために (2017/7/19)
     C# 7で追加された新機能の中から、「数値リテラル構文の改善」と「ローカル関数」を紹介する。これらは分かりやすいコードを記述するのに使える
  • Presentation Translator (2017/7/18)
     Presentation TranslatorはPowerPoint用のアドイン。プレゼンテーション時の字幕の付加や、多言語での質疑応答、スライドの翻訳を行える
@ITメールマガジン 新着情報やスタッフのコラムがメールで届きます(無料)

注目のテーマ

Insider.NET 記事ランキング

本日 月間