C#におけるヒープVSスタック問題、あるいはUnityにおけるスクリプト高速化。

個人的にC#はグルー言語であるという認識が強くて、パフォーマンスを意識したプログラミングの経験が無い。しかしUnityのスクリプトC#で書いていると、そこを意識しなくてはならないケースに出くわすことがある。

最近あったのは、テンポラリな配列のアロケーションが大きなオーバーヘッドを生み出しているという状況だった。これは結局、配列の使用をやめてstructのメンバー変数にパックするよう変更したところ、パフォーマンスは著しく改善された。言い換えれば、テンポラリオブジェクトの所在をヒープ上からスタック上へと移すことによってオーバーヘッドが解消された、という格好だ。

問題となっていたのは、まあざっくりとこんな感じの、ゲームステートを保持するクラスだった。

public class State {
  byte[] cells = new byte[16];
  public State() {}
  public State(State src) {
    src.cells.CopyTo(cells, 0);
  }
  public byte this[int i] {
    get { return cells[i]; }
    set { cells[i] = value; }
  }
}

これをAIで処理する際に、大量のオブジェクトコピーと、比較的少量の書き換えアクセスが発生していた。ここでは似たような負荷を発生させるために、こんな感じのテストコードを使うことにする。

int pos = 0;
State state = new State();
for (int i = 0; i < 1000 * 1000 * 10; ++i) {
  State temp = new State(state);
  int pos2 = (pos * 11 + 1627) % 1637;
  temp[pos & 0xf] = (byte)(temp[pos2 & 0xf] + 1);
  pos = pos2;
  state = new State(temp);
}

このコードを手元のUnity環境で実行すると、終了までに約19秒かかる。

このコードが重いのは、メモリロケーションとオブジェクトコピーに問題があるのだろう。それは書いている最中から想像できたし、プロファイリングの結果もそれを示唆していた。それならばと、Stateクラスをこんな感じのstructで置き換えてみた。

public struct State {
  private uint cells0, cells1, cells2, cells3;
  public byte this[int i] {
    get {
      int sh = (i & 3) * 8;
      switch (i >> 2) {
        case 0:  return (byte)((cells0 >> sh) & 0xff);
        case 1:  return (byte)((cells1 >> sh) & 0xff);
        case 2:  return (byte)((cells2 >> sh) & 0xff);
        default: return (byte)((cells3 >> sh) & 0xff);
      }
    }
    set {
      int sh = (i & 3) * 8;
      uint mask = ~(0xffU << sh);
      uint add = (uint)(value & 0xff) << sh;
      switch (i >> 2) {
        case 0: cells0 = (cells0 & mask) | add; break;
        case 1: cells1 = (cells1 & mask) | add; break;
        case 2: cells2 = (cells2 & mask) | add; break;
        case 3: cells3 = (cells3 & mask) | add; break;
      }
    }
  }
}

大きな違いは、classではなくstructを使っていることと、配列ではなくメンバー変数を用意し、そこに値をパックしていること。これで、ヒープ上に確保されていたオブジェクトはすべてスタック上へと移ってくれるはずだ。

変更後のコードは0.6秒で終了した。30倍以上の高速化だ。これは極端な例だけれど、シンプルな処理を反復することの多いAIの内部ループでは、同様の対処によって高速化の望めるケースがあると思う。

まあでも

C#CLRには詳しくないので、どうするのが正しい対処なのかはよく分かっていないです。そもそも今回の対処も合っていたのやら。恐らくC#XNAのコミュニティを探れば、その手のノウハウを見つけることができると思うのですが、そこまでするのは面倒だなと思い、まだ手を出していない次第。いつかはやんなきゃなんないかもしれないけども。