Unity プロジェクトにおけるパフォーマンス問題の一般的な原因のひとつに、文字列とテキストの扱いがあります。C# では、全ての文字列がイミュータブルです。文字列を操作した場合には必ず新しい完全な文字列の割り当てが発生します。これは比較的に負荷が高く、大きな文字列や大きなデータセット、またはタイトなループで文字列の連結を頻繁に行うと、パフォーマンスの問題に繋がり兼ねません。
さらに、N 文字列の連結には N–1 中間文字列の割り当てが必要なため、連結が繰り返されるとマネージメモリに高負荷が掛かる大きな原因ともなり得ます。
タイトなループ内で、あるいはフレーム毎に文字列を連結しなければならない場合は、StringBuilder を使用して実際の連結処理を行うようにしてください。StringBuilder インスタンスを使用することで、不要なメモリ割り当てを更に削減することもできます。
マイクロソフトから、C# で文字列を扱う場合のベストプラクティス一覧が提供されています。MSDN のウェブサイト msdn.microsoft.com を参照してください。
文字列関連のコードでよく見られる主要なパフォーマンス問題の 1 つは、速度の遅いデフォルト文字列 API を意図せず使用してしまうことです。こういった API はビジネスアプリケーション向けに開発されたもので、テキスト中の文字に関して、多様な文化的、言語的規則に基づく文字列を扱えるように意図されています。
For example, the following example code returns true when run under the US-English locale, but returns false for many European locales.
Note: As of Unity 5.3 and 5.4, Unity’s scripting runtimes always run under the US English (en-US) locale:
String.Equals("encyclopedia", "encyclopædia");
For most Unity projects, this is entirely unnecessary. It’s roughly ten times faster to use the ordinal comparison type, which compares strings in a manner familiar to C and C++ programmers: by simply comparing each sequential byte of the string, without regard for the character represented by that byte.
序数に基づく文字列比較への切り替えは、StringComparison.Ordinal
を最終引数引数として String.Equals
に提供するだけで簡単に行えます。
myString.Equals(otherString, StringComparison.Ordinal);
Beyond switching to ordinal comparisons, certain C# String
APIs are known to be extremely inefficient. Among these are String.Format
, String.StartsWith
and String.EndsWith
. String.Format
is difficult to replace, but the inefficient string comparison methods are trivially optimized away.
While Microsoft’s recommendation is to pass StringComparison.Ordinal
into any string comparison that doesn’t need to be adjusted for localization, Unity benchmarks show that the impact of this is relatively minimal compared to a custom implementation.
メソッド | 100k の短い文字列に掛かる時間(ms/ミリ秒) |
---|---|
String.StartsWith , デフォルト カルチャ |
137 |
String.EndsWith , default culture |
542 |
String.StartsWith , 序数比較 |
115 |
String.EndsWith , 序数比較 |
34 |
Custom StartsWith 置き換え |
4.5 |
Custom EndsWith 置き換え |
4.5 |
String.StartsWith
と String.EndsWith
は両方とも、以下のような単純な手書きコード版に置き換えることが可能です。
public static bool CustomEndsWith(this 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);
}
public static bool CustomStartsWith(this 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);
}
While Regular Expressions are a powerful way to match and manipulate strings, they can be extremely performance-intensive. Further, due to the C# library’s implementation of Regular Expressions, even simple Boolean IsMatch
queries allocate large transient datastructures “under the hood.” This transient managed memory churn should be deemed unacceptable, except during initialization.
If regular expressions are necessary, it’s strongly recommended to not use the static Regex.Match
or Regex.Replace
methods, which accept the regular expression as a string parameter. These methods compile the regular expression on-the-fly and don’t cache the generated object.
これは、ごく当たり障りのない 1 行コードです。
Regex.Match(myString, "foo");
しかし、これは実行される度に 5 KB のガベージを生成します。このガベージは簡単なリファクタリングによって無くすことができます。
var myRegExp = new Regex("foo");
myRegExp.Match(myString);
In this example, each call to myRegExp.Match
“only” results in 320 bytes of garbage. While this is still expensive for a simple matching operation, it’s a considerable improvement over the previous example.
Therefore, if the regular expressions are invariant string literals, it’s considerably more efficient to precompile them by passing them as the first parameter of the Regex object’s constructor. These precompiled Regexes should then be reused.
Parsing text is often one of the heaviest operations that occurs at loading time. Sometimes, the time spent parsing text can outweigh the time spent loading and instantiating Assets.
The reasons behind this depend on the specific parser used. C#’s built-in XML parser is extremely flexible, but as a result, it’s not optimizable for specific data layouts.
Many third-party parsers are built on reflection. While reflection is an excellent choice during development (because it allows the parser to rapidly adapt to changing data layouts), it’s notoriously slow.
Unity has introduced a partial solution with its built-in JSONUtility API, which provides an interface to Unity’s serialization system that reads/emits JSON. In most benchmarks, it’s faster than pure C# JSON parsers, but it has the same limitations as other interfaces to Unity’s serialization system – it can’t serialized many complex data types, such as Dictionaries, without additional code.
Note: See the ISerializationCallbackReceiver interface for one way to add the additional processing necessary to convert to/from complex data types during Unity’s serialization process.
テキストデータの構文解析に起因するパフォーマンス問題が発生した場合は、 3 つの解決方法をご検討ください。
テキスト構文解析の負荷を回避する最善の方法は、ランタイムでのテキスト構文解析を完全に無くすことです。これは基本的に、テキストデータを何かしらのビルド手順によってバイナリ形式に「ベイクする」ことを意味します。
この手法を採るディペロッパーにほとんどは、何らかの ScriptableObject 派生のクラス ヒエラルキーにデータを移動させ、その後にデータをアセットバンドル経由で分配します。 Youtube の Unite 2016 における Richard Fine の 講演 で、 ScriptableObject の使用に関して非常に有益な解説が提供されています。
This strategy offers the best possible performance, but is only suitable for data that doesn’t need to be generated dynamically. it’s best suited for game design parameters and other content.
2 つ目は、構文解析される必要のあるデータを小さく分割する方法です。分割されたデータの構文解析に掛かる負荷はいくつかのフレームに分散され得ます。理想的なのは、データの中で、理想のユーザー体験を提供するために必要な部分を特定し、その部分のみを読み込むことです。
簡単な例を挙げると、プラットフォームゲームのプロジェクトであれば、全てのステージのデータをひとつの巨大な塊にまとめてシリアライズする必要はありません。データをステージ毎に個別のアセットに分割し、さらに必要に応じ各ステージをエリア毎に分割すれば、プレイヤーの進行に応じて随時必要なデータを構文解析することができます。
これは簡単そうに聞こえますが、実際にはツールコードに相当な労力が掛かり、またデータ構造の再整理が必要になる可能性もあります。
For data that’s parsed entirely into plain C# objects, and doesn’t require any interaction with Unity APIs, it’s possible to move the parsing operations to worker threads.
This option can be extremely powerful on platforms with a significant number of cores. However, it requires careful programming to avoid creating deadlocks and race conditions.
Note: iOS devices have at most 2 cores. Most Android devices have from 2 to 4. This technique of more interest when building for standalone and console build targets.
Projects that choose to implement threading use the built-in C# Thread and ThreadPool classes (see msdn.microsoft.com) to manage their worker threads, along with the standard C# synchronization classes.