C#に潜むstructの罠

こんにちは。技術部平山です。 この記事ではC#のstructを使った際にはまった罠について書きます。 Unityでの体験を軸にお話しますが、Unityに限ったことではないかと思います。

お急ぎの方のために結論を申しあげますと、structを使うなとなります。 どうしてもstructを使いたい気分になった時に、罠にはまって時間を無駄にする覚悟をした上で使いましょう。 未来に活きる良い失敗ができると思いますし、最終的には製品の性能も上がるとは思いますが、 structを使わないといけない理由は、たぶんありません。なくても製品は作れます。

しかし、一回もstructと書かなかったとしても、C#で書く限りstructからは逃れられないのです。

(2019/04/10) 末尾に話を単純化しすぎた点について補足をいたしました。

structとは

C#の型にはclassとstructがあります。 ...といった概論については、C#のクラスと構造体の違い・使い分け方 という良い記事がありますので、そちらをご覧になるのが良いと思います。 一次情報としてはMSDNのリファレンス、 それに公式のプログラミングガイド を読むのが良いでしょう。

とはいえ、その二つで何が根本的に違うのか?ということは、案外説明されていません。 おそらく基本的すぎて説明する気にならないからだろうと思いますが、 Cのような原始的な言語の経験をお持ちでない方も多いはずで、 実はあまり知られていないのではないかと思います。少し説明いたしましょう。

ヒープとスタック

変数にはメモリが必要でして、メモリには大きく分けて2種類あります。 ヒープとスタックです。 C#もそういう言語の一つです。

classのインスタンスをnewすると、ヒープと呼ばれるメモリが使われます。 Heapという英単語の意味 を知っているとイメージしやすいかと思いますが、「ゴチャッと山になってる何か」です。 トランプの山札はheapですし、食べ物を山盛りにしてもheapと言います。 メモリがゴチャッと山になっている所があって、そこから必要なだけ取ってくるイメージですね。

一方、structのインスタンスをnewすると、スタックと呼ばれるメモリが使われます。 これもStackという英単語の意味 を知っていた方がイメージしやすいでしょう。「積み重なった何か」です。 箱や皿や本が積み重なっているとstackです。

「山になってる何か」と「積み重った何か」は何が違うの?という話ですが、 heapはごっちゃりしていて、stackは結構ちゃんと積んである、という違いがあります。 プログラミング的な違いもそのイメージとだいたい合っていまして、 ヒープはいろんな所にアクセスできますが、スタックは綺麗に積んであるので一番上にしかアクセスできません。 下の方にあるものは、上にあるものをどけないとアクセスできないのです。

structの場合

以下のコードをご覧ください。

struct T{ int _x; }

void Foo()
{
    T a = new T();
    Bar();
}
void Bar()
{
    T b = new T();
}

Foo()を呼ぶと、Tのインスタンスがスタックの中に作られて、aという名前がつきます。 Tはstructなので、newするとスタックに置かれます。

さらにBar()を呼ぶと、Tのインスタンスがスタックに作られて、bという名前がつきます。 Bar()の実行中も、aと名前がついたインスタンスはメモリに存在しています。 そうでないと、Barが終わった後にaが使えませんよね? 一方、Bar()が終わると、bと名前がついたインスタンスはメモリから消えます。

aの上にbが積まれていて、bをどけない限りaが見えない、 と考えれば良いかと思います(Bar実行時にFooの変数が見える言語もありますがC#は見えません)。 そして、どけたものはもう使えません。完全に消えます。

この「今実行中の関数で作った変数しか見えない」「関数が終わったら消える」という 制限のおかげで、メモリ確保や管理が著しく簡単になり、 aやbをメモリに用意したり、解放したりする際の手間はゼロ同然になっています。

そして、structな型の代表例がintです。

void Foo()
{
    int a = 0;
    Bar();
}
void Bar()
{
    int b = 0;
}

こう書くのは、

void Foo()
{
    int a = new System.Int32(0);
    Bar();
}
void Bar()
{
    int b = new System.Int32(0);
}

の省略形にすぎません。 System.Int32 はstructなので、a,bと名前をつけた インスタンスはスタック上に作られ、 Bar()が終わればbは消え、Foo()が終わればaは消えます。

classの場合

ところが、classは違います。

class T{ int _x; };

void Foo()
{
    var a = new T();
    Bar();
}
void Bar()
{
    var b = new T();
}

classはnewするとインスタンスがヒープに作られます。ヒープは積み重なっていないので いつでもどこでもアクセスでき、関数を出ても勝手には消えません。 Bar()が終わってもbはまだメモリにあり、 Foo()が終わってもaはまだメモリにあります。

今の場合、いずれにせよ関数を出ればアクセスできなくなるので同じに見えますが、 中身は全然違っているのです。

returnと代入

さて、関数を出ると変数がなくなってしまう、となると、 変数をreturnした時はどうなるのでしょうか。

struct T{ int _x; };

void Foo()
{
    var a = Bar();
}
T Bar()
{
    var b = new T();
    return b;
}

ここでFoo()を呼んだ時に起こることは以下のようになります。

  • Foo()にて、Bar()の返り値はTで、Tはstructなので、まずTのインスタンスをスタックに作ってaと名前をつける。
  • Bar()を実行し、Tのインスタンスをスタックに作り、bと名前をつける。
  • Bar()が終わるとbが消えてしまうので、その前にbのコピーをスタック上に作る。これには名前がない。
  • Foo()に戻ってきて、Bar()が作ったbのコピーの中身をaにコピーする。
  • Foo()が終わる時にはaが消える。

Bar()が終わる時にはbは消えるので、それをFoo()が受け取ることはできません。 受け取るのはbのコピーです。そして、aはBar()を呼ぶ前にスタックに作られています。 Tをnewしたのは一回だけですが、a、b、bのコピー、という3つのT型インスタンスが出てくるのです。

  • structをreturnすると名前のないコピーが作られる
  • structを=で代入すると、中身がコピーされる。左辺が初出なら、前もってスタックにインスタンスが作られる

ということです。しかしclassは全く違います。

class T{ int _x; };

void Foo()
{
    var a = Bar();
}
T Bar()
{
    var b = new T();
    return b;
}

structをclassにしただけですが、挙動は以下のように変わります。

  • Foo()にて、Bar()の返り値はTで、Tはclassなので、aという名前だけ用意しておく。
  • Bar()にて、Tのインスタンスをヒープの中に作り、bという名前でアクセスできるようにする
  • Bar()がbをreturnし、これをaに代入すると、aはbが指しているのと同じインスタンスを指すようになる。

まず、classなので作ったTのインスタンスはBar()を出ても消えません。 そして、classの=による代入は、「左辺が右辺が指しているものを指すようになる」という動作で、 コピーではありません。 ここで、T型のインスタンスは1つしか作られず、コピーは一度も発生しません。

return=の挙動が全然違うのです。

classという単語を使うかstructという単語を使うかでこれほどの違いがあり、 それがさまざまな所で挙動の違いを見せる原因となります。 では具体的にどんな恐ろしいことが起こるかを見ていきましょう。

UnityでよくあるVector3の恐怖

UnityにはUnityEngine.Vector3 という型があります。こいつはstructです。ですから、

void Foo()
{
    var a = Bar();
}
Vector3 Bar()
{
    var b = new Vector3();
    return b;
}

と書けば、Vector3のインスタンスは計3回スタックに作られ、 2回のコピーが発生します。

返すコピーは変更不可

さて、少し実用に近づけてみます。

static Vector3 _v;
Vector3 GetV()
{
    return _v;
}
void Foo()
{
    GetV().x = 4f;
}

_vをstaticに用意しました。staticなstructはヒープでもスタックでもない、 固定的な場所に置かれ、永遠に消えません。 では、Foo()実行後の_v.xはどうなっているでしょうか?

これはそもそも実行できません。コンパイルが通らないのです。 GetV()が返すのは、_vそのものでなく、「名前がない_vのコピー」です。 そして、C#では、「関数が返した名前のないコピー」は変更不能なのです。 つまり、GetV().xは変更不能なので、=による代入ができません。 このために、上のようなコードを書いて_v.xが4になったと勘違いするバグは 起きないようになっています。親切設計ですね。

Vector3.Set()問題

ですが、UnityEngine.Vector3には恐ろしい関数が用意されています。

var a = new Vector3();
a.Set(1f, 2f, 3f);

Set()によって、a.xが1、a.yが2、a.zが3になるわけで一見便利なのですが、 これによって以下のような事故が起こります。

GetV().Set(1f, 2f, 3f);

これはコンパイルを通り、エラーなしに実行されます。 そして、_vは何も変化しません。 GetV()が返した「名前のない_vのコピー」 が(1,2,3)になりはするものの、それは何にも代入されていないのでそのまま消えるのです。

悲しいことに、C#は直接.xのように指定して代入文を書くと コンパイルエラーにしてくれるのに、変数をいじる関数呼び出しはコンパイルできてしまいます。 これによって「よし、_vが(1,2,3)になったな」と思ってバグる人は私だけではないと思います。

プロパティとの素敵なコラボレーション

とはいえ、これだけならまだ害は少ないと言えます。例えば、

struct Vertex{ public Vector3 position; }

void Foo()
{
    v = new Vertex();
    v.position.Set(1f, 2f, 3f);
}

は問題なく動き、vが(1,2,3)になります。これが本当に牙を剥くのは、 C#が持つ「プロパティ」という素敵な機能と併用した時です。

struct Vertex{ public Vector3 position{ get; set; } }

void Foo()
{
    v = new Vertex();
    v.position.Set(1f, 2f. 3f);
}

アウトです。vは(1,2,3)にはなりません。

プロパティというのは 「関数を変数に見せかける仕組み」 です。 オブジェクト指向入門 という、私がいたく感動した本があるのですが(すごくおすすめ)、 そこには「関数か変数かは実装であって、インターフェイスで区別できる必要はない」 というようなことが書かれています。 変数が本当に変数なのか値を返す関数なのかを意識しないで使えるべきだ、 ということで、私もその思想には共感します。

しかし、C#でstructを使う場合は話が別です。

思い出してください。関数がstructを返せば、それはコピーにすぎません。 ですから、このコードはv.positionを(1,2,3)にはできません。 何も起こらないコードになるのです。

実際の状況で何が起こるかと言えば、

transform.position.Set(1f, 2f, 3f);

と書いた時に、物体の位置を(1,2,3)にしたつもりでいたが、なってない、というような状況です。 Unity初心者の頃に私も罠にはまりました。

ここから導かれる教訓は、structに中身を変更する関数を用意してはならない、となります。 これを守るには、「コンストラクタが終わったら書き換え不能にする」 (immutableとも。「変化できる」のmutableにimをつけて対義語にしているので「変化できない」の意)か、 「書き換え得る変数は素直にpublicにしちゃう」ということになります。 可能なら前者にすべきですが、それで性能が劣化する場合には 後者にするケースもあるかと思います。

配列などにつっこんだ時に起こる地獄

私が今でもたまにやらかしてバグる事として、 配列などのコレクションにつっこんだ時の操作をミスることがあります。

var a = new int[10];
var b = a[4];
b = 7;

実行後のa[4]の値はいくつでしょうか?

7じゃありません。intはstructであり、structの代入(=)はコピーです。 bはスタックに置かれたaとは何の関係もない変数で、それが7になっただけです。 var b = ...と書いた時に、右辺の型がstructであればインスタンスがスタックに作られます。 bは単なる名前でなく実体があり、それが7になるのです。

当たり前だろ、と思われるかもしれません。私だってそう思いますが、 以下のように書いてもそう言えるでしょうか。

struct T{ public int x; }

がどこかに書かれているとして、

var a = new T[10];
var b = a[4];
b.x = 7;

を実行した後のa[4]の値は?

もちろん7じゃありませんね。 Tはstructなので代入はコピーですし、 var b = ...と書いた時にはインスタンスがスタックに用意されます。 単にb.xが7になるだけで、a[4]とは何の関係もありません。 にも関わらず、私は結構な頻度でこのミスをします。 たぶん、classでなくstructにしたことを忘れているからです。

とはいえ、こんなケースなら、

a[4].x = 7;

と書けばいいし、実際そう書くわけですが、以下のようなケースになると話が違ってきます。

struct T
{
    public int x, y, z, w;
}

がどこかにあるとして、

a[index].x = 1;
a[index].y = 2;
a[index].z = 3;
a[index].w = 4;

と書くのは結構面倒くさいですよね。配列をループで順にアクセスしながら中身を設定する、 というような時に、4回もa[index]を書くのは面倒くさいわけです。

var b = a[index];
b.x = 1;
b.y = 2;
b.z = 3;
b.w = 4;

と書きたい衝動に駆られます。そしてバグるのです。

対策

では、こういう場合の正解はどのようなものでしょうか。一つ考えられるのは、

struct T
{
    public void Set(int x, int y, int z, int w){ ... }
}

を用意することです。そうすれば、

a[index].Set(1, 2, 3, 4);

と書けるので、別の変数に入れてコードを短くしよう、という衝動を抑えられます。 しかし、これは先程の 「structに中身を変更する関数を用意するな」 に違反します。 じゃあどうするか?

struct T
{
    public T(int x, int y, int z, int w){ ... }
}

というコンストラクタを用意して、

a[index] = new T(1, 2, 3, 4);

とすればだいぶマシになります。 コンストラクタなら後から呼べないので、事故の原因にはなりません。 x,y,z,wをprivateにできて、さらにreadonlyまでつけられれば最高です。

ただ、後から書き換えが必要な場合にはそうも行きません。publicにしておいて、

var b = a[index];
b.z = 3;
a[index] = b;

のように、書く場合もあるでしょう。この「変数を用意してコピーして、一部書き換えてから、コピーで戻す」は、 Unity使用時には頻繁にやる操作で、 例えばTransform型が持つpositionのyだけ+5したい、という場合、

var p = transform.position;
position.y += 5f;
transform.position = p;

と書く羽目になるわけです。配列とは関係ありませんが、 「コードが長くなって腹立たしい」という問題点は同じです。 transform.positionと2回書きたくないわけですから。

防ぐには

つまり、structを使う場合はコードが多少長くなっても我慢するしかない時がある、 ということを承知しておく必要があります。 下手に短くしようとすると、罠にはまります。 Vector3のように罠の存在が有名ならばいいですが、 自作した型だと、structにしたことを忘れていることは多いでしょう。

このような事情から言って、他人が使う型をstructにするのは極力避けるべきである、 という教訓が導かれます。自分ならともかく、他人が作った型がclassかstructかなんて いちいち気にしたくはありません。他人にそのような面倒を強いるのは、 良い設計ではないのです。

そして、「未来の自分は他人」であり、 「別の部分を実装している自分も他人」ですから、 structは何かのclass内でprivateに定義せよという、 より実践的なルールが導かれます。

UnityのVector3型のように性能的な事情でやむを得ない場合はあるでしょうが、 せめてSet()なんて関数を作らないようにしましょう。 もしどうしてもそれが必要なら、structを返すプロパティは極力避けて、GetHoge()やHoge()にしましょう。 それなら関数とすぐわかります。foo.v.Set(4)よりはfoo.GetV().Set(4)の方が 問題に気づきやすくなります。

性能が激落ちする罠

structはメモリ確保が高速なわけですが、その代わりに頻繁にコピーが発生しますから、 「確保解放の回数に比べてコピー回数が多い、あるいは型の容量デカい」用途だと逆に遅くなる可能性があります。 変数を10個以上持った型をstructにするのは、たぶん間違っています。

また、structを使ってまで高速化したいと言うならば、 refout の使い方はよく研究した方が良いでしょう。コピーを減らせます。 structをreturnしたくなったらoutを使う方が良いかもしれません(コンパイラが勝手にその変形をやってくれることを期待したい所ですが)。

ですが、そんな細かいことよりも遥かに問題になることがあるのです。

GC Alloc地獄

以下のコードをご覧ください。

struct T
{
    public T(int x){ _x = x; }
    int _x;
}
void Foo()
{
    var dictionary = new Dictionary<T, int>();
    // ... いろいろする。例えば、
    if (dictionary.ContainsKey(new T(3)))
    {
        // 何か
    }
}

structなTを作って、それをKeyとしたDictionaryに入れ、いろいろします。 Add()したり、Remove()したり、ContainsKey()したり、TryGetValue()したり、 まあいろいろするでしょう。

ですが、これが結構な遅さになります。実際にどんな状態か見てみましょう。 以下のようなコードを用意します。私はUnity上で作りましたが、 Unityである必要はありません。

struct T
{
    public T(int x){ _x = x; }
    int _x;
}
public void Foo()
{
    var set = new Dictionary<T, int>();
    const int N = 1000 * 1000;
    for (int i = 0; i < N; i++)
    {
        set.Add(new T(i), i);
    }
    int sum = 0;
    for (int i = 0; i < N; i++)
    {
        int value;
        if (set.TryGetValue(new T(i), out value))
        {
            sum += value;
        }
    }
    for (int i = 0; i < N; i++)
    {
        set.Remove(new T(i));
    }
}

Unity2018.3.9、MacBook Pro mid-2014のエディタ実行にて、 Deep Profile有効状態で測定したところ、 6.63秒かかりました。これが速いか遅いかはわかりませんが、 何に時間を食っているのかをプロファイラで見てみましょう。

f:id:hirasho0:20190326121528p:plain

見事にDictionaryの操作です。Add()、TryGetValue()、Remove()の3操作ですが、 GC Allocの所にご注目ください。 TryGetValue()、Remove()で57.2MB、Add()で70.5MBもGC Allocしています。 GC Allocしているということは、classをnewしてヒープの中にインスタンスを作った、 ということです。

まあAdd()でnewが必要なのはわかります。 Dictionaryの内部データ構造に必要なものを何かしらnewするのでしょう。 しかし、TryGetValue()やRemove()でnewするのは謎です。

そこで、もっと詳しく見てみましょう。

f:id:hirasho0:20190326121532p:plain

TryGetValue()内でObjectEqualityCompare.Equals()なるものを呼んでおり、 それが猛烈にGC.Allocしています。また、 ObjectEqualityComparer.GetHashCode()なるものも呼んでいて、 これも猛烈にGC.Allocしています。

Dictionaryの内部実装はハッシュ ですから、ハッシュ値を計算する必要があります。 GetHashCode()はそのためのものでしょう。

さらに、ハッシュ値が等しい場合には実際に等しいかどうかを調べる必要があり、Equals() はそのためのものと推測できます。 そして、どちらも上のTでは定義していないので、デフォルトの実装が呼ばれ、 それは全ての型の基底であるSystem.Object(あるいはobject)GetHashCode()Equals() ということになります(実際にそうかは実装依存で、事実このUnity内の実装ではそうなっていませんが、概念上はそれでいいと思います)。

では、これらはなんでGC Allocしてるんでしょうか? もうおわかりでしょう。boxing(ボクシング)です

boxingについて

System.Objectはclassです。しかしTはstructです。 structに対してclassの関数は呼べません。 そういう時には、boxingと呼ばれる処理が自動で行われて、 structがclassに変換されます。

boxingというのは言うならばこんな処理です。

struct T{ ... }
class BoxedT{ T _value; }

var boxed = new BoxedT(t);
boxed.GetHashCode();
boxed.Equals(...);

structなTがあるとした時、それに対応して、 Tを持ったBoxedTなるclassが自動で定義される、とでも考えてください。 System.Objectの関数を呼ばねばならなくなった時には、 BoxedTをnewして、中にTを入れておきます。 BoxedTはclassでSystem.Objectを継承しますから、 System.Objectの関数が呼べますし、 またTにキャストしたくなったら、中身を取り出します(取り出すのはunboxingと言います)。

さて、newしてますよね。

classのnewですから、ヒープの中にインスタンスが作られます。 これはスタックにインスタンスを用意するよりずっと重い処理です。 高速化するためにstructにしたはずなのに、これでは台無しになってしまいます。

IEquatable<T>

ではどうすればいいか?こうします。

struct T : System.IEquatable<T>
{
    public T(int x){ _x = x; }
    public bool Equals(T other){ return _x == other._x; }
    int _x;
}

TにSystem.IEquatable<T> を実装させます。必要な関数は Equals() で、同じならtrue、違えばfalseを返させます。 これだけで、実行時間が4.37秒まで減りました。ほぼ1.5倍です。 プロファイラを見てみましょう。

f:id:hirasho0:20190326121536p:plain

TryGetValue()のGC Alloc量が57.2MBから19.1MBになりました。1/3です。 そして、先程あったObjectEqualityCompare.Equals()が消滅して、 代わりに自分で定義したT.Equals()が呼ばれています。

structは普通の継承はできませんが、interfaceの実装はできます。 そして、System.IEquatable<T>を実装していれば、 それが「二つのものが等しいかどうか」の判定に使われ、 System.Objectへのキャストが不要になるのです。 単にEquals(T)を実装しただけでは使われず、interfaceの指定が必要なことに注意しましょう。

GetHashCode()のoverride

ではもう一つのGetHashCode()もどうにかしましょう。 Equalsと同じように、GetHashCode()を持つinterfaceが何かあるのでしょうか? これが不思議なことにないのです。こうします。

struct T : System.IEquatable<T>
{
    public T(int x){ _x = x; }
    public bool Equals(T other){ return _x == other.x; }
    public override int GetHashCode(){ return _x; }
    int _x;
}

GetHashCode()をoverrideするだけです。これで、実行時間が3.05秒まで減りました。 元の倍速ですね。プロファイラを見るとこうなります。

f:id:hirasho0:20190326121540p:plain

TryGetValue()とRemove()のGC Allocが0になりました。Add()は仕方ないですね。

なんでGetHashCode()をoverrideしないといけないのか、 逆に、なんでGetHashCode()をoverrideすれば良いのかは、正直よくわかっていません。 structは全てValueType というclassを継承している「ことになって」おり、 何もしなければValueType.GetHashCode() がデフォルト実装として使われます。しかし、structはvirtualな関数を持つことはできず、 そもそも継承自体できませんから、ValueTypeにキャストしたり、ValueTypeから元に戻したりすることはできません。 よくわかりませんが、「まあそういうものなんだろう」と思っておきます。 そのうちコンパイラが進歩して何もしなくても良くなるのかもしれませんが、 でも現状(Unity2018.3.9)はGetHashCode()をoverrideしないと 無駄なGC Allocが消えないようです。

余談: GetHashCode()の実装について

GetHashCode()の実装は、実は簡単ではありません。 上の例では中身がint一個なのでそのまま返して終わりでいいのですが、 複数の変数を含んでいたり、複雑なデータ型だったりすると、 値を作るのは簡単ではなくなります。下手な値を返すと、 DictionaryやHashSetの性能が落ちてしまいますし、 それどころか正しく動かない可能性もあります。

GetHashCode()は、

  • 等しいとみなされるものは同じ値を返さねばならない

という条件を絶対に守らねばなりません(異なるものが同じ値を返すのはかまわない)。 今回はstructの話なので関係ありませんが、 classに対してGetHashCode()を実装する場合は問題になります。 classに対するGetHashCode()のデフォルト実装は、中身が同じでもインスタンスが異なれば 異なる値を返します。ですから、stringのように「中身が同じなら同じとしたい」 のであれば、GetHashCode()を自作する必要があるのです。 Equals()を「中身が同じならtrue」として実装した場合、 GetHashCode()をデフォルトのままにしておくとバグるのです(つい最近やらかしました)。

classだったらどうなの?

さて、がんばってstructで高速化してきましたが、そもそもclassだったら速度はどうなのか? ということは当然確認する必要がありますね。見てみましょう。

class T
{
    public T(int x){ _x = x; }
    int _x;
}

と、何もinterfaceを実装せず、ただclassにしてみました。

3.28秒でした。structで高速化したのとほとんど変わりません。 だから言ったでしょう? 結論は 「structを使うな」 だって。

プロファイラを見てみましょう。

f:id:hirasho0:20190326121548p:plain

GC Allocが129MBと、structでがんばった時の2.5倍くらい食っていますが、 速度は大差ありません。実はnewって案外速いんですよ

ただ、structの方が速いのは事実ですし、何よりメモリ消費量が全然違います。 それをもって「がんばる価値がある」と考えるならstructを使うのも良いでしょう。 しかし、製品で100万個もインスタンスを作ることはまずないでしょうし、 実際には差は微々たるものになると思われます。 これだけ罠があるstructをそれでも使いますか?

SortedDictionaryやAray.Sort()を使う場合

System.IEquatable<T>を実装してGetHashCode()をoverride、 というのは、DictionaryやHashSetを使う場合の話です。

SortedDictionaryを使う場合や、Array.Sort()、List.Sort()等を使う場合は 話が別であり、今度はSystem.IComparable<T> の実装が必要になります。 SortedDictionaryはDictionaryと違って中身が探索木 なので、大小関係の定義が必要だからです。

struct T : System.IComparable<T>
{
    public T(int x){ _x = x; }
    public int CompareTo(T other){ return _x - other._x; }
    int _x;
}

intを返すCompareTo(T)を実装します。自分が小さければマイナス、大きければプラス、 同じなら0を返せば良く、intの倍は引き算するだけで簡単です。

ただ、これに関しては、やらないとコンパイルが通らないので、ひどい問題になることはないでしょうし、 structに限った話でもありません。classでも同じ準備が必要です。 ただ、structに限って、あるミスをした時のダメージがひどく大きくなります。

私がこの前やったミス

ここで間違ってSystem.IComparableを実装すると、どうなるでしょうか。

struct T : System.IComparable
{
    public T(int x){ _x = x; }
    public int CompareTo(object other){ return _x - ((T)other)._x; }
    int _x;
}

CompareTo(T)でなく、CompareTo(object)を実装してしまいました。 もうおわかりですね。System.Objectへのキャストが引き起こすboxing地獄であり、 これはstruct特有の問題です。

f:id:hirasho0:20190326121544p:plain

ソートの場合、比較を行う回数が要素数をNとしてN*log(N)倍になりますから、 それはもうひどいことになっています。たかが100万個をソートするのに、 630MBもの無駄なメモリが使われてしまいました。 実行時間は3倍くらいになっています。

なお、「IEquatable<T>と一緒にIEquatableも実装しておこう」と書かれている記事を見かけますが、 意図がよくわかりません。 「遅くてメモリ汚しまくりだけど動いちゃう」より「そもそもコンパイル通らない」の方が安全です。 型指定がないObjectバージョンのEquals()やCompareTo()が必要なケースが どれだけあるのか私にはわかりません。

GC Allocを避けるため、のまとめ

まとめますと、structを定義して、それが Listにつっこまれてソートされたり、Dictionaryにつっこまれたりすることがありうるのであれば、 System.IEquatable<T>、GetHashCode()、System.IComparable<T>を 実装すべきだ、ということになります。 実装しない場合、どこでGC Alloc祭になるかわかったものではありません。

ただし、すでに述べたように、 「structを他人に使わせるな」 というルールを守るのであれば、使うのは自分だけ、 つまりclass内にprivate定義されるだけですから、 そのclassで使う機能だけ実装すれば良いことになります。

なお、このGC Alloc地獄に陥る問題については、「気にしない」という考え方も可能です。 Dictionaryはたかだか2倍にしかなりませんでしたし、ソートでも3倍です。 「2倍や3倍は誤差」という考え方もあろうかと思います。 しかし、だったらclassでいいじゃんとも言えますし、 structにしたいくらい性能を気にするなら最後までやれよ、 と個人的には思います。まあ、人それぞれでしょう。

そういえば一つ覚えておいた方がいいこととして、GC Allocしまくれば、後でGC.Collectの時間が増す という事実があります。GC.Collect()、つまりガベージコレクションにかかる時間は、 GC Allocされて作られたインスタンスの数が多いほど長くなります。 実際どれくらい長くなるかは実装依存ですので、案外大丈夫かもしれませんが、 大丈夫でないかもしれません。 安全を期すならば、それについても考えておいて損はないでしょう。

おわりに

これまで出てきた「struct使用上の注意」をまとめてみましょう。

  • 中身を変更する関数を用意するな。理想的には「書き換え不能」、それが無理なら素直に変数をpublicにしてしまえ。
    • 中身を変更する関数が必要なら、プロパティでstructを返すな
  • structはclass内にprivateで定義し、公開するな
  • Dictionary,HashSetに入れるならSystem.IEquatable<T>を実装し、GetHashCode()をoverrideせよ
  • SortedDictionaryに入れたりソートしたりするなら、System.IComparable<T>を実装せよ

なお、これは私の個人的見解であり、弊社内で合意が取れているわけでもありません。 少なくとも、Unityの中の人とは意見が異なるでしょうね。私ならVector3.Set()は用意しないでしょうから。

さて、一番重要な結論は、おわかりですね?

「structを使うな」 です。私のようなレベルの人間が使うと、 structを使う度に最低1回はバグらせて時間を余計に食います。 上で紹介したミスは、全て私が自分でやらかしたものです。

実にいい勉強になりました。

おまけ

structの利用、とは少しズレますが、こんなのもよく見掛けます。

yield return 0;

yield returnの次に書くものはIEnumerator.Current に格納され、 IEnumerator.CurrentはSystem.Objectでclassなので、 intでstructである0はboxingされます。GC Allocされて遅いわけです。 5fを返せばfloatでこれもstructですし、Vector3を返せばこれもstructです。 処理系がよろしく最適化してくれるかもしれませんが、してくれないかもしれません。

ここで、

yield return null;

とすれば、この問題はありません。nullはSystem.Object型でclassだからです。

この、0をyield returnするのって、誰が広めたんでしょうね? 誰かが最初にやったんだと思うのですが...

ブックマークで頂いたご意見を鑑みて補足

説明を端折りすぎた点、単純化しすぎた点が多々ありましたので、 補足をいたします。

「スタックは一番上しかアクセスできない」

「一番上しか見えない」というのは、関数を単位と考えた表現で、 「現在処理中の関数(=一番上)にある変数しか見えない」の意味です。 そして、実際のところそれは言語の設計がそうだというだけで、 実際には「現在スタックに存在しているもの全て」にアクセスできます。

事実、ある関数Aでスタックに置いた変数の参照を、そこから呼び出した関数Bに渡せば、 BでAの変数にアクセスできることになります。 言語的に直接のアクセスが禁止されているだけです。

直接のアクセスを許す言語もあり、 Pascalでは現在処理中の関数を呼び出した関数が持つ変数にもアクセスできます。

「クラスのフィールドにstructがあればそれはヒープに作られるはずだ。間違っている」

その通りです。おそらく多くの方にとって自明であろうということ、 もし説明するとさらに記事が長くなること、 そこを誤解することが原因でバグが起きるケースはそれほどないと考えたこと、 などから話を単純化いたしました。 この記事ではローカル変数のケースのみを扱っています。

class A
{
    int x;
}

というクラスがあった場合、Aをnewすれば、Aはヒープに確保されます。 そして、Aに含まれているstructであるint xもまた、ヒープの中に存在します。

「structはスタックにしか存在しない」を厳密に考えると、 classの中にある全てのintやfloatがスタックにしか置かれない、 ということになって、さすがにおかしいことになります。

structはnewしなくても使えるよ?

使えます。

int a;

これが合法なのですから、structはnewなしで使えます。

Vector3 v;

も同じく合法です。ただし、代入がない場合は未初期化状態で作られますので、 全てのフィールドに代入をするまで、使用に制限がかかります。 例えば、そのインスタンスに対する関数呼び出しはできませんし、 フィールドの読み出しもできません。 C/C++と違って、不定値を読み出してしまう、という 恐ろしいバグが起きないのはC#の良いところかと思います。

そして、newでデフォルトコンストラクタを呼んだ場合には、 全てのフィールドがデフォルト値で初期化されますので、 次の行から使えます。

var v = new Vector3();

この場合はx,y,zが0fになり、次の行で読み出しや関数呼び出しが可能です。 そして、記事中にも書きましたが、ヒープでなくスタックに インスタンスが確保されます。 structにおいてnewの有無は初期化の有無です。

+= 使うと簡単に書けるよ

プロパティのフィールドを単体で変更したい場合に、

transform.position += new Vector3(0f, 5f, 0f);

と書くと楽というご指摘を頂きました。 確かにこれなら幾分短いですね。ありがとうございます。

var tmp = transform.position; // get property
tmp += new Vector3(0f, 5f, 0f);
transform.position = tmp; // set property

と展開される、と考えれば良いのでしょうか。 get,setの二つが存在してアクセス可能であり、+演算子が定義されているのであれば、 そう書けそうです。

ただ、使っているstructが、Vector3のようにnewで全フィールドが初期化でき、 +が定義されているとも限りませんので、 このように書けずgetとsetのプロパティを別に書かねばならない ケースもあるかとは思います。

yield return 0; なんてする人いるの?

yield return 0で検索すると結構出ます。

アセットバンドルの不満をなんぼか軽減するライブラリを作ってみた

f:id:hirasho0:20190326115852p:plain

こんにちは。技術部平山です。

もうすぐAddressable Asset Systemなるものが出て、 AssetBundle関連の問題が何もかもが解決するように言われている昨今ですが、 いろいろありまして待っていられないので、自分で作ってみました。

コードはテストプロジェクトごとgithubに置いてあります。 ライブラリ本体はAssets/Kayac/Loader以下です。 しかしながら、実戦投入してはおりません。あくまでサンプルとお考えください。

製品のAssetBundle関連コードを置き換えて動かしたみたり、 ランダムにロードと解放を繰り返す耐久テストをしたりはしていますが、 全てのバグが取れたとは到底思えませんし、不足機能もあるかと思います。 そうこうしているうちにAddressable Asset Systemが現れて魔法のように何もかもを 解決してくれるようであれば、さっさと捨てると思います。

今回作ってるものの機能概要

簡単に言えば、 AssetBundleをダウンロードして、ローカルのストレージに置いて、 そこからアセットをメモリにロードする仕掛けです。

特徴はこんな感じです。

  • ダウンロードは指定した並列数で行う
  • 大きなファイルをダウンロードしてもメモリ使用量が増えない
  • ローカルストレージへの書き込みは非同期でスパイクしない
  • メモリにロードしたアセットは参照カウント管理で自動解放
  • アセットのメモリ内キャッシュ機能
  • 起動時の初期化処理は別スレッドで非同期
  • ローカルストレージからのデータ削除は別スレッドで非同期
  • AssetBundle以外にpng/jpg/ogg/テキストファイルもストレージに保存+バージョン管理可能
  • 依存関係サポート

詳細は使い勝手を見ていただいてからの方がわかりやすいでしょうから、 先にインターフェイスを簡単に紹介します。

使い勝手

だいたいこんな感じです。

class Main : MonoBehaviour
{
    class AssetFileDatabase : Kayac.Loader.AssetFileDatabase
    {
        public override bool ParseIdentifier(
            out string assetFileName,
            out string assetName,
            string assetIdentifier)
        {
            var sharpPos = assetIdentifier.IndexOf('#'); // #までがファイル名、#の後がアセット名。例えば、の実装。
            assetFileName = assetIdentifier.Substring(0, sharpPos);
            assetName = assetIdentifier.Substring(sharpPos + 1, assetIdentifier.Length - sharpPos - 1);
            return true;
        }
    }

    public RawImage _image;
    Kayac.Loader _loader;

    void Start()
    {
        _loader = new Kayac.Loader(
         storageCacheRoot: Application.persistentDataPath + "/AssetFileCache", // ローカル保存場所
         useHashInStorageCache: true); // ハッシュ値をファイル名に加えて保存
        _loader.Start(
            "http://hoge/assetBundles/",
            new AssetFileDatabase(),
         downloadParallelCount: 8);
        _loader.Load(
            "hoge.unity3d#fuga",
            typeof(Texture2D),
         onError: (errorType, name, exception) =>
            {
                Debug.LogError(errorType + " " + name + " " + exception.Message);
            },
         onComplete: asset =>
            {
                _image.texture = asset as Texture2D;
            },
         holderGameObject: this.gameObject);
    }
}

まず、Loader.IAssetFileDatabaseを実装します。ここでは、 デフォルト実装であるLoader.AssetFileDatabaseを継承して、 ParseIdentifier()のみ実装しました。これは、 Loader.Load()に渡すアセットの識別子からファイル名とアセット名を取り出す関数です。

次に、Loaderをnewします。ストレージのファイル保存場所を指定してnewをすると、 別のスレッドで何が保存されているかを調べ始めます。

そして、ダウンロードするサーバのurlと、さっき実装したIAssetFileDatabaseを渡して、 Loader.Start()します。ダウンロードの並列数などのパラメータもここで与えます。 すると、ローカルのストレージにあるファイルで不要になったものを消したりする 処理が別スレッドで走り始めます。

コンストラクタとStartを別にしたのは、 ローカルのファイルのリストを作るのは起動と同時に始めたい一方で、 ダウンロード元のurlや、ファイル名からハッシュ値を引くテーブルを用意したりすることは、 サーバと通信してからでないとできないケースもあると思うからです。 東京プリズンはそうでした。

そして最後にLoad()でロード要求を出します。ローカルにあればローカルから読み、 なければサーバからダウンロードします。エラー時のコールバックと、完了時のコールバック、 そして、アセットの生存期間を決めるためにgameObjectを一つ渡します。

この例では、エラー時にはDebug.LogErrorし、完了時には 中に入っているTextureをRawImage.textureに差しています。 そして、このMainなるスクリプトがついているgameObjectが破棄される時に、 一緒にassetも破棄される、ということを指定しています。

コルーチンで待ちたい

今の例では完了コールバックでデータを受け取りましたが、 コルーチンで待つこともできます。

IEnumerator CoLoad()
{
    var handle = _loader.Load(
        "hoge.unity3d#fuga",
        typeof(Texture2D),
     onError: (errorType, name, exception) =>
        {
            Debug.LogError(errorType + " " + name + " " + exception.Message);
        },
     onComplete: null,
     holderGameObject: this.gameObject);
    yield return handle;
    _image.texture = handle.asset as Texture2D;
}

LoadはLoadHandleなるクラスを返します。 これはIEnumeratorでして、完了するとMoveNextがfalseを返しますので、 yield returnできるようになっています。 終わったらassetプロパティで結果を取れます。

gameObjectと関係なく生存期間を管理したい

Load()にgameObjectを渡さなかった場合、Load()が返すLoadHandleがGCされた時に ロードしたアセットが解放されます。この場合はLoadHandleをフィールドにでも 持っておいて、解放したいタイミングでnullを代入することになります。 ただしGCがいつ走るかは不定ですので、画面を暗くして「今やってくれ!」 という場合は、GC.CollectなりResources.UnloadUnusedAssetsなりを呼んでください。

なお、生存期間の管理をgameObjectでなく、シーンに紐付けたい、 という要望もあるかと思います。 その場合はシーンにあるどれかのgameObjectを渡せば良いでしょう。 例えば弊社のシーン管理ライブラリでは、「シーンに一つだけ配置するMonoBehaviour」 的なクラスがありますので、それがついているgameObjectを渡すのが自然です。 そういうこともあって、特別にシーンを対象とするインターフェイスは作っていません。

起動時のローカルファイル検索時間を有効に使いたい

前述のように、Loaderのコンストラクタを呼ぶと、ローカルストレージに何のファイルがあるかを 調べ始めます。ただし、Start()までにもしそれが終わっていなければ、 Start()で処理を止めて完了を待ちます。

同様に、Start()を呼ぶと、渡したIAssetFileDatabaseを使って、 不要なファイルを消す処理を別スレッドで始めます。 そして、Loader.Load()などの多くの関数は、これの完了までブロックします。

これらのブロックは、Loader.readyを定期的に呼んで、trueが返るまで待てば、 避けることができます。ただし、 完了までの時間が短くなるわけではありません。

pngやoggも読みたい

pngやjpgを指定してLoad()した場合、Texture2Dが出てきます。 また、oggを指定した場合、AudioClipが出てきます。 さらに、拡張子がtxt、html、json、xml、csv、yamlの場合は、TextAssetが出てきます。 AssetBundleと同じ仕組みでローカルストレージに保存され、 ハッシュ値が提供されればバージョン管理もされます。

メモリ内キャッシュ

ある二つの画面を頻繁に行き来する、といった場合、 前の画面でロードした素材を、次の画面で使わないからといって破棄してしまうと 毎度ロードがかかってお客さんを待たせてしまうことになります。 画面ごとに面倒なコードを書かずにこれを緩和するために、 メモリ内に一定容量までアセットを捨てずに取っておく機能があります。

_loader.SetMemoryCacheLimit(32 * 1024 * 1024);

といった感じで、この例では32MBまでは捨てずに置いておきます。 端末のメモリ量をSystemInfoで見て、それに応じた量を指定すると良いかもしれません。 製品の想定使用メモリ量によっても違うでしょうが、 今時のスマホゲームであれば、 1GB端末ならゼロ、2GB端末なら32MB、3GB端末なら64MB、 といったあたりではいかがでしょうか。

ストレージ保存ファイルの破棄

Caching.ClearCache() に相当する機能が当然あります。

_loader.StartClearStorageCache();

これで専用スレッドが起動してファイルを消し始めます。 消し終わる前に他の関数を呼ぶと、多くの場合消し終わるまで待ちますが、 すぐにロードが走りさえしなければ、画面遷移等々をしている間に終わりますので、 お客さんを待たせることは少ないかと思います。

ただし、この関数でファイルを消せるのは、 ロードしたアセットが全て解放されており、 ロード中のアセットもない時だけです。 そうでなければfalseが返って何もしません。 AssetBundleが開いている状態でそこから出てきたアセットを消すことには 諸々面倒があり、実験も必要ですので、今はできなくしてあります。 一切のアセットロードを行っていない状態(例えばタイトル画面) でしか叩かれないという前提です。

なお、単ファイル削除機能もあります。

_loader.DeleteStorageCache("hoge.unity3d");

ゆくゆくは、ロードに失敗してファイルが壊れていることが疑われる時に、 自動で削除して再ダウンロードするようにした方が良いかなと思っていますが、 短いコードで楽に実装する方法が浮かぶまでは放置かなという気もします。

ローカル保存ファイルの管理について

UnityEngine.Caching に相当する機能ですが、挙動はだいぶ違います。 それは用途が違うからです。 本ライブラリでは以下のような管理になっています。

  • IAssetFileDatabase.GetFileMetaData()がfalseを返した場合、ローカルファイルを消す
  • IAssetFileDatabase.GetFileMetaData()が返したハッシュと異なるローカルファイルは消す
  • 時間で消す処理はない
  • 容量制限で消す処理はない

消すのはLoader.Startのタイミングだけです。 また、起動中にIAssetFileDatabase.GetFileMetaDataが返すハッシュ値が変わって あるファイルをダウンロードし直した場合、古いファイルは即消します。

UnityEngine.Cachingクラスは、おそらく名前のように「キャッシュ」として実装されています。 ある程度の容量ストレージを割くことで、ダウンロード待ちを軽減する、という意図でしょう。 ダウンロード可能なファイルが全部ローカルに置かれる、 といったことは想定しておらず、 時間で勝手に消えるとか、容量超えたら消えるとか、そういう機能が実装されています。 同じファイルが複数バージョン併存しても、一定容量を超えた分は消され、 その容量が小さく設定されていれば問題ないわけです。

しかし、以前作った東京プリズンや、その他弊社で多いタイプの製品の場合、 これが全く異なります。 サーバにあるファイルは全部ローカルにダウンロードして保存する前提で、 容量制限は設定しません。 時間で勝手に消える必要はなく、複数バージョン併存する必要もありません。 時間を見て消す仕掛けは無駄、むしろ邪魔ですし、 複数バージョン保存されたままになるとストレージが圧迫されて困ります。 また、サーバ側で「どのファイルを消したいか」の制御ができないのも困ります。 期間限定イベントが終わったら即端末からもデータを消したい、 ということはあるわけで、 「サーバから配信するAssetBundleのリストから消せばローカルからも消える」 という作りであれば非常に簡単です。

サーバ上のURLとローカル保存パスの関係について

現状、サーバ上のフォルダ構造を保ってローカルに保存します。

例えば、http://hoge.com/assetbundles/character/1.unity3d をロードするとしましょう。 Loaderにダウンロードルートフォルダとして http://hoge.com/assetbundles/ 、ローカルの保存ルートフォルダとして Application.persistentDataPath + "/assetFileCache"を指定した場合、 assetFileCache/character/1.ハッシュ値16進32桁.unity3dとして保存されます。 ハッシュ値はIAssetFileDatabaseにて供給します。 拡張子は最後にしてハッシュ値を挟みますので、PC上でダブルクリックして開く機能を邪魔しません。 pngやjpgが直接置いてある場合にはこの方が便利かと思います。

フォルダ構成を保つことで、サーバ側に同名のファイルがあっても、 区別して保存することができます。 標準のCachingを使う場合はCachedAssetBundle を使えば同じことができます。 これを使わないとフォルダ構成を無視して置かれてしまい、 同名のファイルの区別がつかなくなるので注意が必要です。

なお、サーバ側の置き方として、ハッシュ値をパスに含めている場合もあるかと思います。 この場合はファイル名にハッシュ値を足す必要がありません。 このためにLoaderのコンストラクタの引数で、ハッシュ値をファイル名に足す処理を無効化できます。 例えば、 http://hoge.com/assetbundles/0123456789abcdef0123456789abcdef/1.unity3d という具合にパスにハッシュ値が含まれている場合は、 assetFileCache/0123456789abcdef0123456789abcdef/1.unity3dとして 素直に保存すればいいわけです。

さて、フォルダ構造を保存することには当然コストがかかります。 フォルダはOS的にはファイルの一種であり、増やせばコストがかかります。 フォルダの中の物を列挙する関数を呼ぶ回数も増え、 起動時に中に何が保存されているか調べる処理も増えます。 ですから、「同名のファイルはない」とわかっているのであれば、 ローカルではフォルダを作らずに一つのフォルダに全部入れてしまえば諸々効率的です。 しかし現在のところそのような選択肢は設けていません。 それをやるには、ファイル名からサーバ上のパスを引ける表が別途必要になるからです。 IAssetFileDatabaseの機能を増やさねばならず、実装の手間が増えてしまいます。 フォルダ構造が保存されていれば、ローカルのフォルダを含んだパスから サーバ上のURLが決定できますから、追加情報はいらないのです。 現段階では、インターフェイスを小さくすることを優先して、 このようにしておきました。必要があれば機能を足します。

依存関係

アセットバンドル同士の依存関係にも対応しています。 上の簡単な例にはありませんが、IAssetFileDatabaseを実装するクラスに

IEnumerable<string> GetDependencies(string fileName);

を実装する必要があります。例えば"hoge.unity3d"が"fuga.unity3d"に依存する場合、

var dependencies = database.GetDependencies("hoge.unity3d");
foreach (var dependency in dependencies)
{
    Debug.Log(dependency);
}

と書いた時に、"fuga.unity3d"が出てくるようにしておきます。

こうしておくと、hoge.unity3dからアセットをロードする時には、 自動でfuga.unity3dのロードも行われますし、 ローカルのストレージになければダウンロードも行われます。

ついでに依存関係について少し詳しく

依存関係については説明があまりありませんので簡単に説明しましょう。

例えば、hogeに入っているspriteが、fugaに入っているtextureを参照している場合、 hogeとfugaの両方のAssetBundleクラスの インスタンスを生成する必要があります。 本実装では非同期処理以外はしていないので、用いる関数は AssetBundle.LoadFromFileAsync() です。これが済んだ後に、問題のアセット、つまりこの例ではspriteをhogeの方から AssetBundle.LoadAssetAsync() でロードします。

もしここでfugaのAssetBundleインスタンスを作り忘れると、 spriteをロードしたがそこにテクスチャがくっついていない、という事態になります。 テクスチャであれば真っ白になるだけで済みますが、他のアセットだとそうは行きません。 hogeに入っているプレハブに、fugaに入っているアニメーションが差してあって、 スクリプトからそのアニメーションにアクセスする、なんてことになると、 実行時にnull例外で死ぬことになります。

依存関係は芋蔓的に何段もつながっている可能性があり、 手でコードを書くと相当面倒です。 そこでライブラリの内部で面倒を見るようにしてあります。

ついでにAssetBundleのロードとAssetのロードについて少し詳しく

AssetBundle.LoadFromFileAsync() するとAssetBundleのインスタンスができますが、 これは何をしているのでしょうか?

実際のアセットは、AssetBundle.LoadAssetAsync() しない限り使えるようにならないわけで、AssetBundleのインスタンスができたところで、 アセットのロードがされたわけではないのです。

AssetBundleがLZ4圧縮されている、ということを前提とすれば、 「AssetBundle型のインスタンスを作る」とは「AssetBundleの目次をメモリにロードする」 ことを意味します。目次だけです。 AssetBundleファイルの先頭部分には、そのファイルに どんなAssetが入っているかを記した目次部分があり、これだけをロードします。 テクスチャや音声の本体はファイルに置きっぱなしです。

そして、LoadAssetAsync()を呼んだ時に初めて、テクスチャや音声などの実際のデータを ファイルからメモリに読み込んで初期化を行います。

したがって、AssetBundleのインスタンスを作ってもアセットをロードする行程は ほとんど丸々残っています。「使う少し前にAssetBundleをロードしておいて後を高速化しよう」 と思うならば、AssetBundleをロードする所で止めず、 中のアセットのロードもやってしまう必要があります。でなければ効果がありません。

同期ロードと非同期ロード

本実装は、非同期ロードのみに対応しています。 ストレージやネットワークがどれくらい遅いかはわかりませんから、 ロードが終わるまでガツンと止める、というのはやりたくありませんし、 これを使って作る製品側チームにもやって欲しくありません。 ですので、同期ロードの機能自体を省いてあります。

いいえ。そのはずでした。

実は今回の実装をテストするために既存製品に組み込んでみた際、 同期ロードの機能なしではゲームを動かせない事がわかり、 やむを得ず同期ロードの機能も作ってしまいました。Loaderに、

LoadHandle LoadSynchronous_SHOULD_BE_REMOVED(string identifier, Type type)

という、いかにも使ってほしくなさそうな関数が用意してあります。 この関数が返すLoadHandleはisDoneがtrueであることが保証されますが、 アセットバンドル以外だと動かず、ダウンロードが済んでいないと失敗する、 といった制約があります。もし積極的な意図で同期ロードと非同期ロードを使い分けたい 方がいらっしゃるならば、名前を普通にして、 中身の実装もまともにして頂けると良いかと思います。

余談: 同期ロードの方が速い?

「非同期ロードはゲームを止めないためにあるだけのもので、 ゲームが多少止まってもいいなら同期ロードの方が速い」 と考えていらっしゃる方が多いかと思います。 なるほど、Unityの実装によっては本当にそうかもしれません。

しかし、もし実装がまともであれば、そんなことはないだろう、と私は思います。

データのロードには、大きく分けて二つの処理があります。 IOと初期化です。IOというのはネットワークからのダウンロードや、 ローカルストレージからの読み出しで、CPUを使いません。 CPUを使う初期化とは並列できます。

まともな実装であれば、複数のアセットのロードがかかった時には、 1個目のロード後に初期化をしている間に2個目のIOを行い、 2個目の初期化をしている間に3個目のIOを行い... といった具合に並列性を最大に活かすはずです。 それならば、それぞれを同期処理で待つよりも速いはずでしょう。

また、初期化に関しても、メインスレッドを使わねばならない部分は最小に留めて、 極力別のスレッドで並列で進めるように実装するはずです。 一つのアセットを複数スレッドに分割、というのは難しく効果も出ないでしょうが、 アセットが複数あればそれらを別のスレッドにやらせることは難しくありません。 したがって、初期化に関しても同時に複数投げてしまって非同期処理にした方が 速いはずだ、ということが言えます。

とはいえ、1個しかアセットがなく、1フレーム未満の時間しかかからないものに 関して言えば、確かに同期処理の方が速いでしょう。 Unityの非同期版関数は、どんなに小さなアセットであっても、 次のフレームまで完了を返さないように見えます。

var req = assetBundle.LoadAssetAsync("hoge");
while (!req.isDone){}

と書いてその場でひたすらループしていても、永遠に終わらないのです。 つまり、最低でも1フレームかかってしまうわけで、 もし1msで終わる小さなアセットであれば同期ロードの方が良い、 ということになります。

しかしさしあたり、今回の実装では全て非同期に統一しました。 アセットのサイズが一定以下なら自動で同期版を使う、 といった改良は後でもできますが、 同期で作ってしまってスパイクしたから非同期にする、 という逆の改造は後からではできないのです。

デバグ機能

状態取得

f:id:hirasho0:20190326115849p:plain

内部の状態を文字列に吐き出すLoader.Dump()を用意してあります。 画面写真には、ローカルストレージにダウンロードしたファイル数、 ローカルストレージの保存場所、ダウンロード中のファイル数、 読み込まれたアセットの数、容量、などが出ています。

また、画面に出すには適しませんが、 どんなアセット、どんなファイルがロードされていて、 それぞれの参照カウントはいくつか、 といった情報も出すことができます。 これは大変な量になるので、 ボタンを押すとログファイルに書き込まれる、 といったようにするのが良いかと思います。

ロード制限

Loaderには「これ以上メモリを使っていたら、ロードを失敗させる」 という値を設定できます。例えば、

_loader.SetLoadLimit(4 * 1024 * 1024);

とすれば、4MBを超えた状態で呼んだLoad()が全て失敗してnullを返すようになります。 開発中はこれを妥当な数字に設定しておくべきです。

どうしても、物を作るのに忙しく余裕がないと、 メモリや処理負荷のことは後回しになってしまいますが、 それがもたらすものは、 最も忙しく貴重な発売寸前の時期にメモリ削りや負荷削りをやる羽目になるという地獄です。 前もって厳しめの制限を課しておいて、 それを超えた時にすぐにわかるようにしておく方が無駄は少ないと思います。

ただし、そこには多少の強制力が必要でしょう。「警告が出る」程度だとすぐにスルーするようになります。 「コードを書き換えて制限を緩めるかデータを小さくしないとゲームが動かない」 くらいの力強さが必要です。 結局はどんどん制限が緩んでいくことになったとしても、 「どれくらいの制限にしているのか」という自覚があるだけマシではないでしょうか。

ただし、何分Unityを使っている関係上、 メモリ量のカウントはテキトーです。 そもそもアセット以外が何メガ使っているのかを知る術もありません (Unity以前はmallocから自作していたので完全に把握できたのですが...)。

さらに、アプリ組み込みのデータが支配的で、 AssetBundle等のファイルからの動的読み込みの比率が小さければ、 それだけ測ってもあんまり意味がない、ということにもなります。 シーンやプレハブに参照がささっているアセットによるメモリ消費は、 今回の仕掛けでは測定できません。

また、メモリ使用量自体の計算にも問題があります。 どうせ容量のほとんどはテクスチャだろう、ということで、 テクスチャに関してはそれなりに計算していますが、 サウンドは全部16bit無圧縮で計算していますし、 サイズがよくわからないものは一括で4KB(後で変えるかも)としています。 それでもないよりはマシです。

1GBメモリの機械は相手にしなくても良くなりつつありますが 2GBの機械はいくらでもありますし、 メモリ量を多く使えば、バックグラウンドに回った時にアプリを落とされやすくなります。 ちょっとツイートして戻ってきたらアプリが死んでて30秒かけて再起動しないといけない、 なんてゲームは、続けてもらう上で非常に不利です。 「動かなくなると困るからメモリを減らす」という考えでいると、 「動けばいい」になってしまいます。 一歩進めて「メモリ使用が小さいほど良い製品になってお客が喜ぶ」 と考えるくらいでも良いかと思います。

未実装機能と改善予定

未実装機能は以下です。

  • AssetBundleのVariant。使ったことがないのでよくわからないのです。
  • Resources経由とサーバからのダウンロードの切り換え。
  • ローカルファイルの破壊が疑われる場合の自動破棄と自動再ダウンロード
  • あるAssetBundle内の全アセットを一度にロードする機能
  • ダウンロードされていないファイルだと失敗するようにする機能
  • subAssetのロード
  • CRCチェック
  • 暗号化対応

どれも実際に必要な空気を感じたら実装することになると思います。 必要な空気を感じなければ永遠に実装しないでしょう。 特にVariantは、IAssetFileDatabaseの実装をユーザが工夫すれば、 それで済む気がします。

また改善予定があるのは以下です。

  • 性能。特にGCAllocの回数。
  • ストレージにあるファイルの列挙と削除の高速化

現状オブジェクトの使い回しを全くしていないので、 GCAllocの回数が結構なことになっています。 しかし、たかだかファイルやアセットの数の数倍程度の回数で、 毎フレーム増えていくわけでもない、と考えると、 それほど製品の動作に影響を与えるとは思えず、 コードを複雑化してまでやる価値はないかもしれません。 もし「同時に多量のアセットを破棄するとスパイクする」 というようなことがあれば、それは製品の質を落としますので、 対応しようと思っています。

そして、起動時に行うストレージのファイル列挙と、不要ファイルの削除は、 まだ高速化の余地があります。フォルダごとにスレッドを分割すれば、 おそらく高速化できるでしょう。 しかし実装がややこしくなりますし、 「そもそもそんなにファイル数が多いのが悪い」という話でもありますので、 正直やらずに済むならこのままでいいかなと思っています。

おわりに

二週間くらいかけて作ってしまいましたが、 実のところ弊社内でも「これを使うことが決まっている」というわけではありません。 「こんなこともあろうかと」を言うため、 勉強のため、現状の問題把握のため、といった目的です。 そういうわけで、いつ実戦投入するかわかりませんし、そもそもしないかもしれません。 しかし、これを作ったおかげでいろいろなことがわかりました。 もし弊社内で使わなくても十分今後のためになったと思いますし、 公開したことで誰かが使ってくれたり参考にしてくれたりすれば、 なおさら良いかなと思っております。

フィードバックが頂けると泣いて喜びます! 修正要望を頂ければすぐ直しますよ!

なお、現在までに行ったテストは、

  • 公開しているテストアプリと、弊社製品の実データの組み合わせで耐久試験
  • 弊社製品2つに試験組み込みして、不足機能を足し、なんとなく動くところまで修正

という感じです。間違いなくまだバグは残っていると思いますが、 そこそこ動くんじゃないかなあ、とは思っております。

なお、今回の記事では実装には触れませんでしたが、 ネタが尽きたり、要望があるようでしたら、 実装についても記事を書くこともあるかと思います。