Unity プロジェクトにおけるパフォーマンス問題の一般的な原因のひとつに、文字列とテキストの扱いがあります。 C# では、全ての文字列がイミュータブルです。文字列を操作した場合には必ず新しい完全な文字列の割り当てが発生します。これは比較的に負荷が高く、大きな文字列や大きなデータセット、またはタイトなループで文字列の連結を頻繁に行うと、パフォーマンスの問題に繋がり兼ねません。
さらに、 N 文字列の連結には N–1 中間文字列の割り当てが必要なため、連結が繰り返されるとマネージド メモリに高負荷が掛かる大きな原因ともなり得ます。
タイトなループ内で、あるいはフレーム毎に文字列を連結しなければならない場合は、 StringBuilder を使用して実際の連結処理を行うようにしてください。 StringBuilder インスタンスを使用することで、不要なメモリ割り当てを更に削減することもできます。
マイクロソフトから、 C# で文字列を扱う場合のベストプラクティス一覧が提供されています。 MSDN のウェブサイト msdn.microsoft.com でご参照いただけます。
文字列関連のコードでよく見られる主要なパフォーマンス問題のひとつは、速度の遅いデフォルト文字列 API を意図せず使用してしまうことです。こういった API はビジネス アプリケーション向けに開発されたもので、テキスト中の文字に関して、多様な文化(カルチャ)的・言語的規則に基づく文字列を扱えるように意図されています。
例えば、以下の参考コードは US-English ロケールで実行された場合には true を返しますが、多くの欧州ロケールでは false を返します。(1)
ノート: Unity 5.3 と 5.4 の時点では、 Unity のスクリプト ラインタイムは常にUS English (en-US) ロケールで実行されます。
String.Equals("encyclopedia", "encyclopædia");
大部分の Unity プロジェクトでは、これは全く不要です。序数に基づく比較を使用したほうが約 10 倍程速くなります。序数に基づく比較は C や C++ のプログラマーには馴染み深い方法で、各バイトが表す文字に関わらず、単純に文字列の一連のバイトを比較します。
序数に基づく文字列比較への切り替えは、 StringComparison.Ordinal
を最終引数引数として String.Equals
に提供するだけで簡単に行えます。
myString.Equals(otherString, StringComparison.Ordinal);
一部の C# String
API は著しく非効率的であることが知られています。 String.Format
、 String.StartsWith
、 String.EndsWith はそのいくつかです。 String.Format
は置き換えが難しいですが、非効率的な文字列比較方法の最適化は簡単に行えます。
マイクロソフトは、ローカライズ用の調整を必要としない文字列比較に StringComparison.Ordinal
を渡すことを推奨しています。 Unity のベンチマークは、カスタム実装と比べてこの影響は比較的小さいことを示しています。
メソッド | 100k の短い文字列に掛かる時間(ms/ミリ秒) |
---|---|
String.StartsWith , デフォルト カルチャ |
137 |
String.EndsWith , デフォルト カルチャ |
542 |
String.StartsWith , 序数比較 |
115 |
String.EndsWith , 序数比較 |
34 |
Custom StartsWith 置き換え |
4.5 |
Custom EndsWith 置き換え |
4.5 |
String.StartsWith
と String.EndsWith
は両方とも、以下のような単純な手書きコード版に置き換えることが可能です。
public static bool CustomEndsWith(string a, string b) {
int ap = a.Length - 1;
int bp = b.Length - 1;
while (ap >= 0 && bp >= 0 && a [ap] == b [bp]) {
ap--;
bp--;
}
return (bp < 0 && a.Length >= b.Length) ||
(ap < 0 && b.Length >= a.Length);
}
public static bool CustomStartsWith(string a, string b) {
int aLen = a.Length;
int bLen = b.Length;
int ap = 0; int bp = 0;
while (ap < aLen && bp < bLen && a [ap] == b [bp]) {
ap++;
bp++;
}
return (bp == bLen && aLen >= bLen) ||
(ap == aLen && bLen >= aLen);
}
正規表現は文字列のマッチや操作を行うにあたって非常に有益な方法ですが、パフォーマンス負荷が著しく高くなる場合があります。さらに、 C# ライブラリの正規表現の実装が原因で、単純なブーリアン IsMatch
クエリーでさえも大きな一時的データ構造体を「内部的に」割り当てます。このような一時的マネージド メモリの使い回しは、初期化中を除いては行われないようにする必要があります。
正規表現が必要な場合は、静的な Regex.Match
メソッドや Regex.Replace
メソッドを使用しないことが強く推奨されます。 Regex.Match
や Regex.Replace
は正規表現をひとつの文字列パラメーターとして受け取ります。これらのメソッドは正規表現をオンザフライでコンパイルし、生成されたオブジェクトをキャッシュしません。
これは、ごく当たり障りのない一行コードです。
Regex.Match(myString, "foo");
しかし、これは実行される度に 5 KB のガベージを生成します。このガベージは簡単なリファクタリングによって無くすことができます。
var myRegExp = new Regex("foo");
myRegExp.Match(myString);
この例では、 myRegExp.Match
への 1 回の呼び出しが生成するガベージは 320 バイト「のみ」です。単純なマッチング処理の場合、これでも高負荷ではありますが、先の例に比べればかなり改善されています。
したがって、正規表現が不変文字列リテラルであれば、 Regex オブジェクトのコンストラクタの最初のパラメーターとして渡して事前コンパイルするほうが格段に効率的です。プリコンパイルされた Regex はその後再使用します。
テキストの構文解析は、読み込み時間に発生する処理でしばしば最も重くなるもののひとつです。場合によっては、テキストの構文解析に掛かる時間がアセットの読み込みとインスタンス化に掛かる時間を越えることもあります。
この理由は使用されるパーサによって異なります。 C# のビルトイン XML パーサは非常に融通の利くものですが、特定のデータレイアウト向けには最適化できません。
サードパーティのパーサの多くはリフレクションを基盤に構築されています。リフレクションは、(パーサがデータレイアウトの変化に素早く適応できるようになるため)開発中には非常に優れた選択肢ですが、速度が遅いことで知られています。
Unity ビルトインの JSONUtility API によって、部分的にこれを解決することができます。これは、 JSON の入出力を行う Unity のシリアライゼーション システムにインターフェースを提供するものです。この API に関しては、純粋な C# JSON パーサよりも速いという評価が大部分のベンチマークで出ていますが、 Unity のシリアライゼーション システムに対して、他のインターフェースの場合と同様の制約( Dictionaries などの複雑なデータタイプの場合は追加コードなしでシリアライズができない)が掛かります (2) (ノート: ISerializationCallbackReceiver インターフェースに関するページ で、Unity のシリアライズ処理中に複雑なデータタイプの変換を行うために必要な処理を簡単に追加する方法をご確認いただけます。
テキストデータの構文解析に起因するパフォーマンス問題が発生した場合は、 3 つの解決方法をご検討ください。
テキスト構文解析の負荷を回避する最善の方法は、ランタイムでのテキスト構文解析を完全に無くすことです。これは基本的に、テキストデータを何かしらのビルド手順によってバイナリ形式に「ベイクする」ことを意味します。
この手法を採るディペロッパーにほとんどは、何らかの ScriptableObject 派生のクラス ヒエラルキーにデータを移動させ、その後にデータをアセットバンドル経由で分配します。 Youtube の Unite 2016 における Richard Fine の 講演 で、 ScriptableObject の使用に関して非常に有益な解説が提供されています。
この方法は最高のパフォーマンスをもたらしますが、動的に生成される必要のないデータにしか適しません。これが最も適しているのはゲームデザイン パラメーターなどです。
2 つ目は、構文解析される必要のあるデータを小さく分割する方法です。分割されたデータの構文解析に掛かる負荷はいくつかのフレームに分散され得ます。理想的なのは、データの中で、理想のユーザー体験を提供するために必要な部分を特定し、その部分のみを読み込むことです。
簡単な例を挙げると、プラットフォームゲームのプロジェクトであれば、全てのステージのデータをひとつの巨大な塊にまとめてシリアライズする必要はありません。データをステージ毎に個別のアセットに分割し、さらに必要に応じ各ステージをエリア毎に分割すれば、プレイヤーの進行に応じて随時必要なデータを構文解析することができます。
これは簡単そうに聞こえますが、実際にはツールコードに相当な労力が掛かり、またデータ構造の再整理が必要になる可能性もあります。
通常の C# オブジェクト内に完全に構文解析されたデータで、 Unity API とのインタラクションを一切必要としないものであれば、構文解析処理をワーカースレッドに移動することが可能です。
この方法は、コアを多数持つプラットフォームにおいて非常に有益です (3) ([注] iOS デバイスは最高で 2 つのコアしか搭載していません。大部分の Android デバイスは 2~4 つ搭載しています。この手法が比較的適しているのは、ビルドターゲットがスタンドアロンや据置き機の場合です。ただし、デッドロックや競合状態を避けるためには、プログラミングに慎重を要します。
スレッド方式を導入しているプロジェクトでは通常、ビルトイン C# スレッド クラスや ThreadPool クラス (msdn.microsoft.com をご参照ください)を通常の C# 同期クラスと併用してワーカースレッドを管理します。
脚注
(1) Unity 5.3 と 5.4 の時点では、 Unity のスクリプト ラインタイムは常にUS English (en-US) ロケールで実行されます。
(2) ISerializationCallbackReceiver インターフェースに関するページ で、 Unity のシリアライズ処理中に複雑なデータタイプの変換を行うために必要な処理を簡単に追加する方法をご確認いただけます。
(3) iOS デバイスは最高で 2 つのコアしか搭載していません。大部分の Android デバイスは 2~4 つ搭載しています。この手法が比較的適しているのは、ビルドターゲットがスタンドアロンや据置き機の場合です。