Grani Engineering Blog

株式会社グラニはC#を中心として、ASP.NET、Unity、VR開発を行っています。

String.InternによるUnityでの省メモリ化ハック

CTOの河合(@neuecc)です。常駐メモリは一ミリでも削りたい……!と思いつつも、それなりに富豪に使ってしまっていて削るのに四苦八苦な日々ですが、削れる箇所はテクスチャなどリソース系だけではない。C#側のマネージドなリソースもまた、それなりに確保しているので、削ることは可能なのだ……!というお話です。

ものすごいメモリプロファイラ(PA_ResourceTracker)を使う

まぁ、なにはともあれプロファイリングです。メモリプロファイラで見てみましょう。実際の黒騎士と白の魔王の開発ビルドに流してみると

f:id:neuecc:20171012210606p:plain

うーん、なかなか立派なString確保量。そして大量の「通常攻撃」という同一文字列。スクリーンショットからは削ってしまってますが、右側には誰が参照しているかがわかる表示もついているので、犯人はひと目で分かり、これは、マスタデータに起因するものでした。独自開発のインメモリデータベース内にオブジェクトとして配置しているのですが、同一文字列が設定されたデータが大量にある、と。インメモリデータベースといっても、一行一行がオブジェクトとして独立しているだけなので、気の効いた圧縮などはかかってません。さすがにザルすぎじゃね、と言われるとすみませんというところでもあり、しかしなるほど、こりゃしょうがないね、どうにもならないね。……というのはあんまりなので、それをなんとかするのが後述するString.Internです。

ところで、この見慣れたようで見慣れないメモリプロファイラは、PA_ResourceTrackerというもので、いつものUnity公式が提供しているMemoryProfilerを中国のエンジニアが魔改造したものです。その改造は素晴らしく、そもそも公式のかっこいいタイルマップなんかより、このシンプルなリストビューのほうが100億倍欲しかったものなのですが、と思います。更にスナップショット間のDiffなどもついているので、メモリの増減が容易に確認可能という素敵さ。

IL2CPPでビルドした実機iOSに接続すれば、詳細なマネージドメモリも確認可能(上のスクリーンショットはその手法で取得したものです)。大変素晴らしい。これを使って、現状と、そして改善結果を確認していきましょう。

String.Internについて

String.Internはあまり見慣れないメソッドだと思います。詳細はWikipediaのString interningの解説に譲るとして、多くの言語(PHP, Java, Python, etc...)にも存在する文字列のインターン化に関する.NETのメソッドです。

大雑把にいうと、文字列専用のハッシュテーブルといったところでしょうか。文字列は不変のため、同一の文字列は同一のメモリ領域を使うことで、全体的なメモリ使用量が節約されます。C#では文字列リテラルで記述された文字列は、必ずインターン化されています。unsafeを用いて、アドレスの位置を確認してみましょう。

// IL的にはldstr
var str1 = "foo";
var str2 = "foo";

fixed (void* p1 = str1)
fixed (void* p2 = str2)
{
    // 同一アドレスを指す
    Console.WriteLine((IntPtr)p1); // 46542800
    Console.WriteLine((IntPtr)p2); // 46542800

    // 勿論、true。
    Console.WriteLine(string.IsInterned(str1) != null); // true
    Console.WriteLine(string.IsInterned(str2) != null); // true
}

大昔には String.Empty vs "" のどちらがいいか、などという話もありましたが、インターン化されてるのでメモリ的には別に一緒、むしろldstrとldsfldで命令的には""のほうが有利、でもどうせJITで同じ結果になって一緒なので好みでOK(なお、私は""のほうを好んで使います)が結論です。

では、文字列リテラル以外で生成した場合は、というと

var str3 = Encoding.UTF8.GetString(Encoding.UTF8.GetBytes("foo"));
var str4 = new StringBuilder("f").Append("oo").ToString();

fixed (void* p3 = str3)
fixed (void* p4 = str4)
{
    // str1とも異なるアドレスを指す
    Console.WriteLine((IntPtr)p3); // 47150368
    Console.WriteLine((IntPtr)p4); // 47150432

    // 文字列自体がインターンプールの中に存在しているか、ではtrue。
    Console.WriteLine(string.IsInterned(str3) != null); // true
    Console.WriteLine(string.IsInterned(str4) != null); // true

    // ReferenceEqualsもfalse
    Console.WriteLine(String.ReferenceEquals(str3, str4)); // false
}

同じ短い文字列であっても、異なるアドレス、つまり二重に別のメモリ領域を使っているということになります。では、インターンプールから取得するにはどうすればいいか、というと、簡単です。

// インターンプールから取り出す(or 登録する, GetOrAddみたいなもの)
var str5 = string.Intern(str3);
var str6 = string.Intern(str4);

fixed (void* p5 = str5)
fixed (void* p6 = str6)
{
    // str1と同じアドレス
    Console.WriteLine((IntPtr)p5); // 46542800
    Console.WriteLine((IntPtr)p6); // 46542800

    // ReferenceEqualsもtrue
    Console.WriteLine(String.ReferenceEquals(str5, str6)); // true
}

無事、常駐の同一Stringは同一メモリ領域を使うようになりました。この場合、str3やstr4はGCで回収されます。

なお、インターン化した文字列はずっとメモリ領域を確保し続け、解放する手段はないことに気をつけてください。

MessagePack for C#のIMessagePackSerializationCallbackReceiver

string.Internが効果あるのは分かったが、適用して回るのは面倒くさいというか難しいというか無理というか、システムの根っこでやらないとらちがあきません。黒騎士と白の魔王ではMasterMemoryという独自開発のインメモリデータベースを通しています、が、MasterMemory自体は同じく弊社開発のMessagePack for C#でデシリアライズしたオブジェクトを登録しているだけです。

つまり、システムの根っこはMessagePack for C#のデシリアライザです。というわけで、デシリアライズ時に自動的にインターン化しましょう。ここで便利に使えるのが IMessagePackSerializationCallbackReceiver です。

public class ToaruMaster : IMessagePackSerializationCallbackReceiver
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }

    void IMessagePackSerializationCallbackReceiver.OnAfterDeserialize()
    {
        // デシリアライズ後のタイミングでインターン化してしまう
        this.Name = (this.Name != null) ? string.Intern(this.Name) : null;
        this.Description = (this.Description != null) ? string.Intern(this.Description) : null;
    }

    void IMessagePackSerializationCallbackReceiver.OnBeforeSerialize()
    {
        // シリアライズ前に呼ばれる, なにもしない
    }
}

これにより、マスタデータに使われる型の、必要な、任意のプロパティだけインターン化することができました。「インターン化した文字列はずっとメモリ領域を確保し続け、解放する手段はない」わけですが、マスタデータはインメモリデータベースにアプリケーション起動時から終了時まで常駐し続けるので、インターン化は有効な手段です。

なお、IMessagePackSerializationCallbackReceiverの挙動はJsonUtilityISerializationCallbackReceiverと同一です。そのため、もしJsonUtilityを使っている場合で同じようにIntern化したい時も、同様の対応が取れます。

最後に、結果を見てましょう。

f:id:neuecc:20171012210748p:plain

Refsが1438と、見事にまとめられました!「通常攻撃」以外にもまとめられたものは結構あったので(それと他の部分でもインターン化が効率的なところに仕込んだ)、なんのかんのでトータルでは4MBぐらいは減ったっぽい。たった4MB、されど4MB。

まとめ

まず言うと、PA_ResourceTrackerは絶対的にお薦めです。本当に素晴らしい。最近、中国圏のゲームがストアランキングを賑わせていて、確かな実力を感じさせますが、エンジニアリングも素晴らしく、GitHubの中国圏でのみ賑わっているライブラリ類もまた、隠れた宝石といった趣があります。

String.Internは、存在は知っていましたが、使ったことはありませんでした。あまりゲーム以外だと気にするレベルではない、というのが実際ですが、こうした古典的なC#のテクニックも役に立つこともあるでしょう。まぁ、たまに、ですが。たまには。たまたま。

細かいC#の動きに関してのプロファリングは、Microsoft CLRの環境よりもUnityのほうが、豊富なツール郡やIL2CPPの力もあって、把握しやすいように思えます。Unityはより深くC#を追求していく環境としても、かなり面白い環境なのは間違いないので、是非ともエクストリームC#の沼にはまりましょう。