Unityでのボクシングの殺し方、或いはラムダ式における見えないnewの見極め方

Happy boxing! UniRxの前回リリース(UniRx 5.0.0)でパフォーマンス向上を果たしたと書きましたが、まだやり残したことがありました。それがボックス化(boxing)の殺害です。ボックス化は単純に言うと、せっかくの値型が箱に入っちゃってGCゴミが発生してGCがーーー、というもの。避けれるなら避けるべし。あ、ちなみに今回の内容は特に別にUnityに限らないふつーのC#の話です。

それと、というわけかでUniRx 5.1.0リリースしました、アセットストアから落とせます。基本的な内容は以下に解説するボックス化を徹底的に殺害したことによるパフォーマンス向上です。

ボックス化とジェネリクス

GCって、別に見えてるnewだけで発生するわけでもありません。見えてるものを警戒するのは大事ですが、見えないものを見てないのは片手落ち感が否めない。そんな見えないものの代表例がボックス化です。実際どういう時に発生するのかというと

var x = (object)10;

みんな大好きint(ValueType)がobject(ReferenceType)に!これがボックス化の害です。なるほど、避けたほうが良さそうだ。とはいえこんなのやらないって?ですよね。ではこれは?

void Hoge(object o)
{
}

Hoge(10);

まぁまぁやらないかもしれませんが、まぁまぁやるといえなくもないです。というかやる時はあります。ではこれは?

bool IsSame<T>(T t1, T t2)
{
    return t1.Equals(t2);
}

一見何も悪くないのですが、実は悪いです。どこが?

public virtual bool Equals(Object obj);

ここが。ようするにEqualsはobjectになった後に比較されてしまうのです。というわけでボックス化が発生します。ジェネリクスは基本的にボックス化を避けれるのですが、一部のObjectに生えてるメソッド、というかようするにEqualsですが、を触る場合、気をつけないとうっかりしがちです。他に t1.GetType() と書いてもボックス化が発生します。その場合、 typeof(T) と書くことで避けられます。

EqualityComparer<T>を使う

ボックス化を避けた比較を行うインターフェイスにIEquatable<T>があります。

public interface IEquatable<T>
{
    bool Equals(T other);
}

これを使い、つまり

bool IsSame<T>(T t1, T t2) where T : IEquatable<T>
{
    return t1.Equals(t2);
}

にすればボックス化は避けれる問題なし。ではあるんですが、これでは不便すぎます(さすがにintとかはIEquatable<T>を実装してはいますが、普通の参照型はほとんど実装していないでしょう)。同じなのかどうかとりあえずチェックしたい、Equalsを普通に呼びたいケースは沢山あります。そこでEqualsを外部から渡せるIEqualityComparer<T>インターフェイスと、デフォルト実装を取得するEqualityComparer<T>.Defaultが使えます。

bool IsSame<T>(T t1, T t2)
{
    return EqualityComparer<T>.Default.Equals(t1, t2);
}

EqualityComparer<T>.Defaultは、TがIEquatable<T>を実装していればTがIEquatable<T>のEquals(T other)を、実装してなければEquals(object other)を呼んで比較します。これによりめでたく値型のボックス化が避けれました!UniRxでもDistinct、DistinctUntilChanged、ObserveEveryValueChanged、そしてReactivePropertyのSetValueでボックス化が発生していたのですが、UniRx 5.1.0からは発生しなくなっています。なんで今まで発生していたのかというと、EqualityComparer<T>がiOS/AOTで怪しくてあえて避けてたんですが、5.0.0からAOTサポートはきってIL2CPPのみにしたので無事性能向上を果たせました。

UnityとIEquatable<T>

Unityにおいては、それだけでメデタシではなく、もう少し話に続きがあります。Unityにおける代表的な値型であるVector2やRectなどは、全て、IEquatable<T>を実装して、いません。へー。==はオーバーライドされているので、素のままで扱って比較している限りは問題ないのですが、ジェネリックの要素として、また、DictionaryのKeyとして使った場合などでもボックス化が発生しています。

これが地味に困る話で、UniRxにおいてもObserveEveryValueChangedなどでVector2などが流れてくるたびにボックス化が発生したらちょっとよろしくない。

そこで、その対策として今回のUniRx 5.1.0では UnityEqualityComparer.Vector2/Vector3/Vector4/Color/Rect/Bounds/Quaternion というものを用意しました。これら代表的なUnityの値型に関しては、専用のEquals/GetHashCodeを実装してあります。また、 UnityEqualityComparer.GetDefault[T] により、それらが型から取り出せます。普通にUniRxを使っている範囲では(Distinct、DistinctUntilChanged、ObserveEveryValueChangedなど) IEqualityComparer の取得は UnityEqualityComparer.GetDefault[T] を通すようにしているため、極力ボックス化が発生しないようになっています。

ラムダ式と見えないnew

ボックス化、見えないGCゴミの話を書いたので、ついでにもう一つ見えないゴミを発生させるラムダ式について。ラムダ式は実際のところコンパイラ生成の塊みたいなもの、かつ、中身によってかなり生成物が変わってきます。ざっと6通りのパターンを用意してみました。

static int DoubleStatic(int x)
{
    return x * 2;
}

int DoubleInstance(int x)
{
    return x * 2;
}

void Run()
{
    var two = int.Parse("2");

    Enumerable.Range(1, 1).Select(DoubleStatic);           // 1
    Enumerable.Range(1, 2).Select(DoubleInstance);         // 2
    Enumerable.Range(1, 3).Select(x => x * 2);             // 3
    Enumerable.Range(1, 4).Select(x => x * two);           // 4
    Enumerable.Range(1, 5).Select(x => DoubleStatic(x));   // 5
    Enumerable.Range(1, 6).Select(x => DoubleInstance(x)); // 6
}

どんな感じになるか想像できました?では、答え合わせ。ちょっと簡略化しているので正確にはもう少しこんがらがった機会生成になっていますが、概ねこんな感じになってます。

static Func<int, int> cacheA;
static Func<int, int> cacheB;

internal static int LambdaA(int x)
{
	return x * 2;
}

class Closure
{
    internal int two;
    
    internal int LambdaB(int x)
    {
        return x * two;
    }
}

internal static int LambdaC(int x)
{
	return DoubleStatic(x);
}

internal static int LambdaD(int x)
{
	return DoubleInstance(x);
}

void Run()
{
    var two = int.Parse("2");

    // 1 - Select(DoubleStatic)
    Enumerable.Range(1, 1).Select(new Func<int, int>(DoubleStatic));
    
    // 2 - Select(DoubleInstance)
    Enumerable.Range(1, 2).Select(new Func<int, int>(DoubleInstance));
    
    // 3 - Select(x => x * 2)
    if(cacheA != null)
    {
        cacheA = new Func<int, int>(LambdaA);
    }
    Enumerable.Range(1, 3).Select(cacheA);
    
    // 4 - Select(x => x * two)
    var closure = new Closure();
    closure.two = two;
    Enumerable.Range(1, 4).Select(new Func<int, int>(closure.LambdaB));
    
    // 5 - Select(x => DoubleStatic(x))
    if(cacheB != null)
    {
        cacheB = new Func<int, int>(LambdaC);
    }
    Enumerable.Range(1, 5).Select(cacheB);
    
    // 6 - Select(x => DoubleInstance(x))
    Enumerable.Range(1, 6).Select(new Func<int, int>(LambdaD));
}	

それぞれ似ているような違うような、ですよね?一つ一つ見ていきましょう。

パターン1、パターン2はメソッドを直接突っ込む場合。この場合、実際のところはデリゲートを生成して包んでます。そしてこのデリゲートはGCゴミになります。なります。全く見えないんですが地味にそうなってます。と、いうわけで、それを回避するには静的メソッドなら静的フィールドに静的コンストラクタででも事前に作ってキャッシュしておく、インスタンスメソッドの場合は、もし使うシーンがループの内側などの場合は外側で作っておくことで、生成は最小限に抑えられるでしょう。

パターン3は、恐らく最もよく使うラムダ式の形式で、使う値が全てラムダ式の中だけで完結している場合。この場合、自動的に静的にキャッシュを生成してそれを未来永劫使いまわしてくれるので、非常に効率的です。一番良く使う形式が効率的というのは嬉しい、遠慮無くどんどん使おう。

パターン4も、まぁよく使う形式、でしょう。ローカル変数をラムダ式内で使っている(キャプチャ)した場合。この場合、普通にクラスがnewされて、そこにラムダ式内部で使われる値を詰め込み、その自動生成のクラスのインスタンスメソッドを呼ぶ形に変換されます。というわけで、パターン4は見た目は人畜無害ですが、中身はそれなりのゴミ発生器です!いや、まぁたかがクラス一個。であり、されどクラス一個。画面上に大量に配置されるGameObjectのUpdateなどで無自覚に使っていたりすると危なっかしいので、それなりに気を留めておくと精神安定上良いでしょう。

パターン5、パターン6は内部でメソッドを使っている場合。ちなみにここではメソッドにしましたが、フィールドやプロパティでも同じ生成結果になります。抱え込む対象がstaticかinstanceかで変わってきて、staticの場合ならキャッシュされるので少しだけ有利です。

なお、この挙動は現時点でのVisual Studio 2015のC#コンパイラによって吐かれるコードであり(Unityの今のmonoもほぼ一緒、のはず、です、確か多分)、将来的にはそれぞれもう少し効率的になるかもしれません(メソッドを直接突っ込む場合のキャッシュとかは手を加える余地がある気がする)。とはいえ原理を考えたら、外部変数をキャプチャするラムダ式はどうやってもこうなるしかなさそうだったりなので、大筋で変わることはないと思います。

まとめ

正直なところ今回書いたのは細かい話です!別に気にしすぎてもしょうがないし、というかこんなの細部まで気にして避けながら書くのは不可能です。ギチギチに避けてラムダ式禁止だのLINQ禁止だの言い出すなら、早すぎる最適化の一種で、かなり愚かしい話です。が、ゲームの中にはひじょーにタイトな部分は存在するはずで、そこで無自覚に使ってしまうのも大きなダメージです。私だってタイトになることが想定されるUpdateループの中でLINQを貫くならやめろバカであり、普通にペタペタとforで書けとは思いますよ。

あんまりゼロイチで考えないで、柔軟に対処したいところですねえ。どこに使うべきで、使うべきでないか。まぁその見極めがむつかしいから全面禁止とかって話になるのは実際のところ非常によくわかる!のですが、それこそプロファイラで問題発見されてからでもいいじゃん、ぐらいの牧歌的な考えではいます。いやだって、そんなたかがLINQやラムダ式ぐらいであらゆるところがボトルネックになるわけないぢゃん?そんなのより大事なとこ沢山あるでしょう。それに比べたらLINQを普通に使えることのほうが、UniRxを普通に使えることのほうが100億倍素晴らしい。もちろん、地味な積み重ねでダメージが出てくるところであり、そして一個一個は地味だったりするから見つけづらくて辛いとかって話もありつつ。

そんなわけでUniRxは、かなり厳し目に考慮しながら作っているので、比較的概ね性能面でも安心して使えるはずです!まだもう少しやれることが残ってはいるんですが、ちょっと踏み込んで書いてみると謎のuNET weaver errorに見舞われて回避不能で死んでいるので、当面はこの辺が限界です(ほんとuNET絡みのエラーはなんとかして欲しい、理不尽極まりない)。とはいえ、何かネタがあれば継続してより良くしていきますので、よろしくおねがいします。

そういえば第一回Unityアセットコンテストでは、セミファイナリスト頂きました。ほぼほぼスクリプトのみの地味 of 地味なアセットであることを考えると全然上等で、嬉しい話です。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive