Untiy2018.3辺りから**AsyncReadManager**と言うAPIが追加されたようです。
ドキュメントには「仮想ファイルシステムを使用して非同期I/Oを実行可能」「任意のThread/Jobに対して実行可能」とあり、AsyncReadManager.Readのページを見てみた感じだとUnsafeUtility.Mallocで確保したメモリに読み込んだデータを乗っける仕組みのように見受けられました。
非同期読み且つアンマネージドメモリの領域に乗っけるという性質からしてストレージに保存されている巨大なデータ1の読み込みやアンマネージドメモリへのキャッシュなどに使えないかと思い、ファイルの読み込みテスト/それに伴うメモリ負荷などを計測してみたのでメモ序に軽く纏めていきたいと思います。
サンプルプロジェクトはGitHubにアップしてます。
mao-test-h/AsyncReadManagerTest
▼ 実装/動作環境
-
Unity version
- Unity2018.3.0b9
※補足 : Unityのメモリ管理について
本題に入る前にUnityのメモリ管理についても軽くおさらいしておきます。
(以降、「マネージドヒープ」「アンマネージドメモリ」と言った単語が良く出てくるので)
Unityには大きく分けて以下の2つのメモリがあり、以下に要点を箇条書きでまとめます。
- C#メモリ(記事中では主に"マネージドヒープ"と記載)
- C#側で使用されているメモリであり、GC(Garbage Collection)の対象。
- メモリが確保出来なくなると自動的にメモリ領域を拡張する。
- 拡張されたメモリ領域は基本返ってこない。
- CGのアルゴリズムの性質上、ヒープが拡張するのに応じてパフォーマンスが劣化。
- ※上記を踏まえてピーク使用量はなるべく抑える必要がある。。
- Unityメモリ(記事中では主に"アンマネージドメモリ"と記載)
- UnityのNativeCodeへのメモリ割り当て量。
- Texture/Meshと言った各種アセットやエンジン内部処理の物が含まれる。
- NativeArrayもこちらに含まれる。
上記の点を踏まえると、マネージドヒープについてはなるべく拡張を抑える必要があります。
しかし、C#側(マネージド側)でサイズが大きいファイル読み込みなどを行うとそれだけで大量のメモリが確保されてヒープが拡張されてしまい、場合によっては以後のメモリが圧迫されてしまうと言った懸念があるので、今回の主な目的としてはこれを回避するためにアンマネージドメモリの領域を使ってみると言ったお話となります。
※参考
Understanding the managed heap
▽ 簡単な例
まず最初にAPIの簡単な実装例から触れていきます。
※内容としてはAsyncReadManager.Readのページに載っているサンプルコードとほぼ同じです。
例としてテキストファイルを非同期で読み込んでStringに変換 → ログに出力してます。
※サンプルプロジェクトではMinimumSample.unity
にて確認可能。
public sealed unsafe class MinimumSample : MonoBehaviour
{
/// <summary>
/// 非同期読み用 ハンドル
/// </summary>
/// <remarks>※Disposeを呼ばないとメモリリークする</remarks>
ReadHandle _readHandle;
/// <summary>
/// 非同期読みデータのバッファなど
/// </summary>
NativeArray<ReadCommand> _readCommand;
/// <summary>
/// 読み込み開始
/// </summary>
public void ReadData()
{
// コマンド発行用にファイルサイズを取得
var fileInfo = new System.IO.FileInfo(Constants.SampleDataPath);
long fileSize = fileInfo.Length;
// コマンド生成
this._readCommand = new NativeArray<ReadCommand>(1, Allocator.Persistent);
this._readCommand[0] = new ReadCommand
{
Offset = 0,
Size = fileSize,
// ※サンプルではalignmentを16としているが、型が分かるなら「UnsafeUtility.AlignOf<T>」と言うAPIで取る事も可能。
Buffer = (byte*)UnsafeUtility.Malloc(fileSize, 16, Allocator.Persistent),
};
// 読み込み開始
this._readHandle = AsyncReadManager.Read(Constants.SampleDataPath, (ReadCommand*)this._readCommand.GetUnsafePtr(), 1);
}
/// <summary>
/// MonoBehaviour.Update
/// </summary>
void Update()
{
if (this._readHandle.IsValid() && this._readHandle.Status != ReadStatus.InProgress)
{
// エラーハンドリング
if (this._readHandle.Status != ReadStatus.Complete)
{
Debug.LogError($"Read Error : {this._readHandle.Status}");
this.ReleaseReadData();
return;
}
// 以下のコードで(void*)を直接文字列に変換することが可能。
// → 但しデータが全てマネージドヒープに乗ってしまう。
string ret = Marshal.PtrToStringAnsi((IntPtr)this._readCommand[0].Buffer);
Debug.Log($" <color=red>--- Data : {ret}</color>");
this.ReleaseReadData();
Debug.Log("Complete !!!");
}
}
/// <summary>
/// 非同期読み用データ関連の破棄
/// </summary>
void ReleaseReadData()
{
this._readHandle.Dispose();
UnsafeUtility.Free(this._readCommand[0].Buffer, Allocator.Persistent);
this._readCommand.Dispose();
}
}
上記のMinimumSample.ReadData()
が呼び出されると処理が走ります。
※ちなみに..今回は簡単なサンプル故に処理待ちは全体的にUpdateにて愚直に待機してます。
ポイントとしてはAsyncReadManager.Read
に渡すためのコマンドとしてReadCommandを作成する必要があり、内容としては以下の3点を指定する必要があります。
- Buffer : 読み込みデータを受け取るバッファ
- ※メモリはUnsafeUtility.Mallocで確保しているので、使い終わったらUnsafeUtility.Freeで解放してやらないとメモリリークする。
- Offset : ファイル内の読み込み開始位置
- Size : 読み込みサイズ ※byte指定
読み込んだデータについても簡単に触れておくと、ReadCommand.Buffer
自体は void* となるので、実際にデータを用いる際には何かしらの型などに変換してやる必要があります。
今回の例では単純にテキストファイル全体をStringに変換してログに出力しているだけとなりますが、この全体をStringに変換するやり方だと冒頭にもある様な巨大なデータを読み込んだ際にそれだけでメモリ負荷になってしまう懸念があります。
そこで何とか「マネージドヒープの負荷自体は最小限に抑えつつ、読み込んだデータをパースしてアンマネージド側に保持できないか?」と思い検証を行ってみたので後述します。
▽ マネージドヒープの負荷を抑えるための検証
「マネージドヒープの負荷を最小限に抑えつつ、読み込んだデータをパースしてアンマネージド側に保持」と言う要件を満たせるかを検証するために、以下の条件を満たすCSVの読み込み処理とそれに伴うデータのパース/キャッシュ処理を実装してみました。
比較用にアンマネージドメモリを一切使用しない普通の実装?も合わせて用意してみたので順番に解説していきます。
※以降、C#側の処理は"マネージド側"と言ったニュアンスで記載。
- CSVのデータとしては以下の要素を持つ。→ 行には"名前, HP, MP, Attack, Defense"の順で格納
- 名前(string)
- HP(int)
- MP(int)
- Attack(int)
- Defense(int)
-
データ数(行数)は50万件あり、ファイルサイズにして25.7MB近くある。
- ※Sample_full.csv を参照。
- キャッシュについて
- マネージド側は読み込んだデータはそのままマネージドヒープに配列としてキャッシュ。
- アンマネージド側の方はNativeArray(アンマネージドメモリ)にキャッシュ。
- 動作確認について
- 両方共Windows環境 + Standalone(IL2CPP)ビルドにて確認。
- 結果についてはUnityのProfilerにて計測
▼ マネージド側の処理について
こちらの実装は単純なのでコード全体を載せます。
やっていることはFile.ReadAllLines
でファイル全体の文字列を取得し、それをパースしてキャッシュしているだけです。
public sealed class ReadManaged : MonoBehaviour
{
/// <summary>
/// CSVから取得できるデータ
/// </summary>
public struct CharacterData
{
public string Name;
public int HP;
public int MP;
public int Attack;
public int Defense;
}
/// <summary>
/// CSVから読み込んだデータのキャッシュ
/// </summary>
CharacterData[] _charaData = null;
/// <summary>
/// CSVの読み込み
/// </summary>
public void ReadData()
{
var lines = File.ReadAllLines(Constants.SampleDataPath);
this._charaData = new CharacterData[lines.Length];
Profiler.BeginSample(">>> Parse Managed");
for (int i = 0; i < lines.Length; ++i)
{
var line = lines[i];
var args = line.Split(new char[] { ',' });
this._charaData[i] = new CharacterData
{
Name = args[0],
HP = args[1].ParseInt(),
MP = args[2].ParseInt(),
Attack = args[3].ParseInt(),
Defense = args[4].ParseInt(),
};
}
Profiler.EndSample();
Debug.Log("Complete ReadManaged");
}
}
◎ 処理負荷について
単純かつ極端な例ということもあってか、メモリ負荷自体は大きい印象です。。
(そもそもこの手のデータを扱うことになるなら別の管理方法を考えるなど色々検討できるかもしれませんが、今回は置いといて...)
※以降の計測結果はUnityのMemory Profilerの表記で記載します。(以下は日本語ドキュメントの引用)
・[Unity] Unity のネイティブコードへのメモリ割り当て量
・[Mono] 全体のヒープサイズとマネージドコードが使用しているヒープサイズ。このメモリはガベージコレクションされます。
- 読み込み前
- 【Used Total】: 39.8 MB
- Unity: 34.6 MB
- Mono: 500.0 KB
- 【Reserved Total】: 89.6 MB
- Unity: 82.3 MB
- Mono: 0.6 MB
- 【Used Total】: 39.8 MB
- 読み込み後
- 【Used Total】: 163.7 MB
- Unity: 38.5 MB
- Mono: 120.5 MB
- 【Reserved Total】: 217.5 MB
- Unity: 86.3 MB
- Mono: 124.5 MB
- 【Used Total】: 163.7 MB
- パース時の負荷(※
Profiler.BeginSample(">>> Parse Managed")
の間)- GC Alloc : 241.1 MB
- Time ms : 895.13
- Self ms : 650.17
※補足として説明しておくと、そもそもこの規模のファイルサイズになってくるとFile.ReadAllLines
で読み込むだけでもそれなりに負荷はあります。参考までに読み込んだ際の計測結果を載せておきます。
- 読み込み前
- 【Used Total】: 39.8 MB
- Unity: 34.6 MB
- Mono: 496.0 KB
- 【Reserved Total】: 89.6 MB
- Unity: 82.3 MB
- Mono: 0.6 MB
- 【Used Total】: 39.8 MB
- 読み込み後
- 【Used Total】: 104.0 MB
- Unity: 34.6 MB
- Mono: 64.7 MB
- 【Reserved Total】: 161.9 MB
- Unity: 82.3 MB
- Mono: 72.9 MB
- 【Used Total】: 104.0 MB
- 読み込み時の負荷(※
File.ReadAllLines
の負荷)- GC Alloc : 70.0 MB
- Time ms : 179.92
- Self ms : 144.98
▼ アンマネージド側の処理について
こちらは少しコードが長くなっているので複数に分けて解説していきます。
全体は載せずに一部を引用しながら解説していくので、コード全体についてはReadUnmanaged.csを御覧ください。
構造体の定義
まず最初にパースした結果を入れる構造体の定義から解説すると、こちらは最終的にNativeArrayにキャシュする事を前提としているためにデータをBlittable型にする必要がありました。
intはBlittable型なのでそのままでも良いのですが、文字列については非Blittable型となるので、今回の実装では実体はbyte*
として持っておき、必要な時に関数呼び出しでStringに戻す実装となっております。
/// <summary>
/// CSVから取得できるデータ
/// </summary>
public unsafe struct CharacterData
{
// ※構造体をNativeArrayで持たせる都合上、stringや配列は使えないのでポインタで持たせている。
public StringPtr Name;
public int HP;
public int MP;
public int Attack;
public int Defense;
}
/// <summary>
/// 文字列のポインタ
/// </summary>
/// <remarks>※Bilittableを考慮した結果、こう持ってみることにした。</remarks>
public unsafe struct StringPtr : IDisposable
{
public byte* Data;
public int Length;
public void Dispose()
{
UnsafeUtility.Free(this.Data, Allocator.Persistent);
}
/// <summary>
/// 文字列の取得
/// </summary>
public override string ToString()
{
byte[] ret = new byte[this.Length];
Marshal.Copy((IntPtr)this.Data, ret, 0, this.Length);
return System.Text.Encoding.UTF8.GetString(ret);
}
}
読み込み周りについて
次に読み込み周りの処理について軽く解説していきます。
解説すると言っても、こちらでやっている処理は前述の簡易サンプルと同じくデータファイル全体を読み込むコマンドを生成して処理を走らせ。Updateにて待機しているだけとなります。
読み込み完了時に行っているパース処理については後述します。
/// <summary>
/// CSVの読み込み
/// </summary>
public void ReadData()
{
Profiler.BeginSample(">>> Command Unmanaged");
// コマンド発行用にファイルサイズを取得
var fileInfo = new System.IO.FileInfo(Constants.SampleDataPath);
long fileSize = fileInfo.Length;
// コマンド生成
this._readCommand = new NativeArray<ReadCommand>(1, Allocator.Persistent);
this._readCommand[0] = new ReadCommand
{
Offset = 0,
Size = fileSize,
Buffer = UnsafeUtility.Malloc(fileSize, 16, Allocator.Persistent),
};
// 読み込み開始
this._readHandle = AsyncReadManager.Read(Constants.SampleDataPath, (ReadCommand*)this._readCommand.GetUnsafePtr(), 1);
Profiler.EndSample();
}
/// <summary>
/// MonoBehaviour.Update
/// </summary>
void Update()
{
if (this._readHandle.IsValid() && this._readHandle.Status != ReadStatus.InProgress)
{
// エラーハンドリング
if (this._readHandle.Status != ReadStatus.Complete)
{
Debug.LogError($"Read Error : {this._readHandle.Status}");
this.ReleaseReadData();
return;
}
Profiler.BeginSample(">>> Parse Unmanaged");
// 読み込んだCSV(void*)をパース
this.Parse();
Profiler.EndSample();
this.ReleaseReadData();
Debug.Log("Complete ReadUnmanaged");
}
}
/// <summary>
/// 非同期読み用データ関連の破棄
/// </summary>
void ReleaseReadData()
{
this._readHandle.Dispose();
UnsafeUtility.Free(this._readCommand[0].Buffer, Allocator.Persistent);
this._readCommand.Dispose();
}
パース処理について
最後に読み込んだCSVのパース処理周りについて解説していきます。
アンマネージドメモリにデータをキャッシュすると言いつつも、実装としてはJob等で並列化せずに愚直にメインスレッド上でパースを行っております。。(もう少し工夫すればJob化出来そうな気がしなくもなく。。要検証)
全体的にステップが長いので以下に要点を纏めます。
- 読み込み済みのデータとなる
void*
が指す値をNativeArray<byte>
に入れ直すのと同時に全データ数(行数)をカウント。全データ数が分かったらキャッシュを入れておくNativeArray<CharacterData>
のメモリも確保。2 -
NativeArray<byte>
に入れ直したバイト配列をforループで回し、各種文字コードを見て「カンマ区切り」「行末」などを判定してデータをパース。 - 行末まで解析したらインスタンスを1の段階で確保した
NativeArray<CharacterData>
に追加。以降終わりまで繰り返し。
パース処理の実装としては、1の手順で入れ直したCSV全体が入っているNativeArray<byte>
に対してNativeSliceで内容を切り出し → 後は切り出したbytesをintに変換するなりキャッシュ用に確保した別の文字列用メモリにUnsafeUtility.MemCpyで値をコピーするなどしてパースを行っております。
全体を通して見ると妙に泥臭い実装となってしまいましたが、、取り扱うデータが文字列な都合上こうなってしまったという印象です。。
ひょっとしたらもう少しスマートな解析方法も無きにしもあらずですが...今回は未検証。。
/// <summary>
/// 読み込んだCSVデータのパース
/// </summary>
void Parse()
{
void* ptr = this._readCommand[0].Buffer; // データのポインタ
// ptrの内容をこちらに入れ直す。
// (NativeArrayUnsafeUtility.ConvertExistingDataToNativeArrayで変換できない..? やり方が変かもしれないので要検証)
var source = new NativeArray<byte>((int)this._readCommand[0].Size, Allocator.Temp);
// 全データ数のカウント及びNativeArrayへの入れ直し
{
int iterator = 0;
int maxRecordCount = 0; // 全データ数
while (true)
{
// 泥臭いが1byteずつチェックしていき「改行 == 1レコード」とみなしてカウントしていく。
byte val = Marshal.ReadByte((IntPtr)ptr, iterator);
if (val == NullCode) { break; }
else if (val == LineFeedCode) { ++maxRecordCount; }
source[iterator] = val;
++iterator;
}
this._charaData = new NativeArray<CharacterData>(maxRecordCount, Allocator.Persistent);
}
// パース
int rowIndex = 0; // カンマ間の内部のindex. → e.g...[0-1-2-3, 0-1, 0-1-2,..]
int commaIndex = 0; // レコード中のカンマの位置
int recordCount = 0; // レコード数
var parseData = new CharacterData(); // 解析したデータを格納
int sliceIndex = 0; // NativeSlice用の開始位置
// TODO: Job化を検討できそう
for (int i = 0; i < source.Length; ++i)
{
// こちらも同じく1byteずつチェック。
byte val = source[i];
// 終端が来たら終わり
if (val == NullCode) { break; }
if (val == CommaCode)
{
// カンマを検知したらNativeSliceで切り取っていく
switch (commaIndex)
{
// 名前
case 0:
parseData.Name = this.SliceToStringPtr(source, sliceIndex, rowIndex, Allocator.Persistent);
break;
// HP
case 1:
parseData.HP = this.SliceToString(source, sliceIndex, rowIndex).ParseInt();
this.DebugLog("HP", parseData.HP);
break;
// MP
case 2:
parseData.MP = this.SliceToString(source, sliceIndex, rowIndex).ParseInt();
this.DebugLog("MP", parseData.MP);
break;
// Attack
case 3:
parseData.Attack = this.SliceToString(source, sliceIndex, rowIndex).ParseInt();
this.DebugLog("Attack", parseData.Attack);
break;
}
++commaIndex;
rowIndex = 0;
}
else if (val == LineFeedCode)
{
// ※この時点ではこの値じゃないとおかしいので念の為チェックしておく
Assert.IsTrue(commaIndex == 4);
// Defense
parseData.Defense = this.SliceToString(source, sliceIndex, rowIndex).ParseInt();
this.DebugLog("Defense", parseData.Defense);
// インスタンスの保持
this._charaData[recordCount] = parseData;
++recordCount;
// 次のレコードを見に行く前にインスタンスを新規生成
parseData = new CharacterData();
commaIndex = 0;
rowIndex = 0;
}
else
{
++rowIndex;
}
if (val == CommaCode || val == LineFeedCode)
{
// カンマ及び改行コードの次をindexをスライス開始位置とする。
sliceIndex = i + 1;
}
}
source.Dispose();
}
/// <summary>
/// 切り取ったバイト配列を新規で確保したメモリにコピーし、それを文字列用のポインタとして返す
/// </summary>
/// <param name="source">ソース</param>
/// <param name="start">切り取り開始位置</param>
/// <param name="length">データの長さ</param>
/// <param name="allocator">Allocator</param>
/// <returns>文字列のポインタ</returns>
StringPtr SliceToStringPtr(NativeArray<byte> source, int start, int length, Allocator allocator)
{
var slice = new NativeSlice<byte>(source, start, length);
byte* arrPtr = (byte*)UnsafeUtility.Malloc(length, UnsafeUtility.AlignOf<byte>(), allocator);
UnsafeUtility.MemClear(arrPtr, length);
UnsafeUtility.MemCpy(arrPtr, NativeSliceUnsafeUtility.GetUnsafePtr(slice), length);
return new StringPtr { Data = arrPtr, Length = length };
}
/// <summary>
/// バイト配列を切り取って文字列に変換
/// </summary>
/// <param name="source">ソース</param>
/// <param name="start">切り取り開始位置</param>
/// <param name="length">データの長さ</param>
/// <returns>文字列</returns>
string SliceToString(NativeArray<byte> source, int start, int length)
{
var slice = new NativeSlice<byte>(source, start, length);
return System.Text.Encoding.UTF8.GetString(slice.ToArray());
}
◎ 処理負荷について
マネージド主体の読み込み後のReservedの結果が「Unity: 86.3 MB Mono: 124.5 MB」だったのに対し、アンマネージメモリにキャッシュする方の結果は「Unity: 113.5 MB Mono: 0.7 MB」となっているので、意図通りにキャッシュ先をマネージドヒープからアンマネージドメモリに移すことで前者の拡張を抑えることが出来ているのを確認できました。
※とは言えども、メインスレッドで愚直にwhile回してパースなどしているためか..逆に処理時間は結構掛かっているようにも見受けられますが..。
- 読み込み前
- 【Used Total】: 39.8 MB
- Unity: 34.6 MB
- Mono: 500.0 KB
- 【Reserved Total】: 89.6 MB
- Unity: 82.3 MB
- Mono: 0.6 MB
- 【Used Total】: 39.8 MB
- 読み込み後
- 【Used Total】: 115.5 MB
- Unity: 67.1 MB
- Mono: 0.7 MB
- 【Reserved Total】: 165.0 MB
- Unity: 113.5 MB
- Mono: 0.7 MB
- 【Used Total】: 115.5 MB
- パース時の負荷(※
Profiler.BeginSample(">>> Parse Unmanaged")
の間)- GC Alloc :146.6 MB
- Time ms : 1321.16
- Self ms : 1084.06
▽ まとめ
今回は「巨大なCSVをパースして内容をアンマネージドメモリにキャッシュする」と言う前提でサンプルの方を用意してみました。
※ただ、実際に使われるであろう形式としてはjsonと言った他の形式も考えられるので、今回実装した例を単純に応用できるということは無いかもしれませんが。。
AsyncReadManager自体を雑に纏めるとAPI自体は「普通に呼べば使える」と言った感じではありますが、課題となるのは実際に受け取ったデータ(void*
)をどう扱うのか?について考える必要が出てくると言った印象です。
→ その上でNativeArray等に入れるとなるとBlittable型の制限も出てくるので、それを踏まえると万能ではないという点も加味して。。
今回の例のようにマスターデータ的な物をキャッシュしたいと言った場合には、Jobに回すことを踏まえてパースしやすい用に独自バイナリにして読み込ませると言ったことも検討する価値はあるかもしれません。(※例えばヘッダーに情報を持たせてそれを元にパースするとか何とか)
後は実際のタイトルに於いてはファイル読み込みの前提となる処理としてデータのダウンロード/ファイル保存を行う必要があったり、物によっては暗号化なども検討する必要が出てくるかと思われます。
それらを踏まえて取りあえずは要検証項目として構想的なものをメモ。
- ファイルのダウンロード/保存について
- Unity2017.2ぐらい?から地味に**DownloadHandlerFile**と言う機能が追加されており、こちらを用いることでダウンロードから保存までのメモリコストを削れないかと予想。(イメージ的には
DownloadHandlerFile
で先にDL/保存を行い、その後にAsyncReadManager
で取得しに行くイメージ。)
- Unity2017.2ぐらい?から地味に**DownloadHandlerFile**と言う機能が追加されており、こちらを用いることでダウンロードから保存までのメモリコストを削れないかと予想。(イメージ的には
- 暗号化について
- 割と悩ましい所...。自分自身そこまで暗号化周りに詳しいという訳ではないので現時点に於いて上手いことを言えるわけではないのですが...簡単な想定レベルとしてファイル単位ではなく中のデータ単位で上手い具合に暗号化を掛けてやり、ファイル読み込む際に全体ではなくオフセットとサイズを指定することでチャンク読み → チャンクを復号と言った事が出来ないかなーと予想。。
- そもそもとしてアンマネージドメモリにデータをキャッシュするのはありなのか?
- 例えばキャッシュしたデータをJobの方でも参照するとかであれば良いかもしれませんが、マネージド側でしか参照されないものであれば参照時のコストなどが気になる所..。通常の配列やListと比べてどれくらい差が出てくるのかは要検証。
- 後はBlittable型の制限があるのでそれを踏まえると単純な使い勝手も気になる所..。
- 諸々踏まえるとケースバイケースになりそうな予感。
▽ 参考/関連サイト
- Blittable 型と非 Blittable 型
-
Understanding the managed heap
- ※マネージドヒープについて