どのDownloadHandlerでAssetBundleをダウンロードして保存するのが速い?

f:id:hirasho0:20190311142927p:plain

この記事では、 アセットバンドルのダウンロードと保存をどのDownloadHandlerでやるのが速いか、 ということについて 技術部平山が書いてみます。

最初にお断りしておきますが、まだ独自研究の域を出ません。 今回も プログラムはgithubに置いておりますが 、そのまま実用になるものはありません。そもそも、キャッシュから読む所も作ってません。 今回は単なるダウンロード+保存の速度測定だけです。

社内で使うために作る物は、社内で使う前に技術ブログで公開してフィードバックを頂く というスタイルでやっていきたいので、是非ともご協力をよろしくおねがいいたします。

さて、お急ぎの方のために結論です。

  • 適切な並列度が最も重要。
  • ダウンロード順をランダム化することで大抵は改善が見られる
  • 不満がなければ標準の方法(DownloadHandlerAssetBundle +Caching)で良い。
  • キャッシュを自作する際はDownloadHandlerFileを使えば楽そう。ただし検証が必要なことがいくつかある。
  • 独自DownloadHandlerと保存を自作しても、そこそこの性能と機能で良ければそんなに大変じゃない気がする。

はじめに

多くのスマホ向け商業ゲームでは、 アプリ本体と別にデータを差し換えたり追加したりできる必要があります。 Unityでそれをやろうと思えば、いかに不満があったとしても、 AssetBundleを使うのが妥当な手段でしょう。 AssetBundleをwebサーバ上に置いておいて、 UnityWebRequest を用いて取ってきます。

となると、気になるのは待ち時間です。 できるだけ待ち時間を短くできる手段で実装したいですね。 そこで、どのDownloadHandlerを使うのが速いかを調べてみました。

なお、私個人としては、初回起動時に大量のダウンロードを待たされる 作りは嫌いで、必要になったデータをその場でダウンロードしてキャッシュ、 という方が好きなのですが、 「wifiにつながっている時に明示的にダウンロードをしたい」 という要求もありますし、 「特定の画面を過ぎたら絶対に全データがローカルにあるという保証」 があれば、デバグやテストの手間を大きく減らせます。 その利点のために一括ダウンロードにする場合もあるでしょう。

測定

まずは測定結果です。 今回作ったサンプルプログラムのstandaloneビルドを、作って測定しました。 マシンはMacBook Pro Mid-2014、Unityは2018.3.1f1です。

1000ファイル合計190MBほどのAssetBundleを遠隔のサーバ上に置いて、 どれだけの時間でダウンロードできるかを測定します。 ご自分で試す場合には、 データやサーバの用意が必要です(データの生成スクリプトはあります)。

方法 並列数 ランダム化 所要時間(秒) 最大メモリ消費(MB) 最大スパイク(ms)
DownloadHandlerAssetBundle 1 なし 123-242 33-34 33-50
DownloadHandlerAssetBundle 4 なし 34.1-36.3 34-35 33-51
DownloadHandlerAssetBundle 4 あり 27.5-30.7 34-34 18-19
DownloadHandlerAssetBundle 16 なし 24.1-28.2 40-40 57-80
DownloadHandlerAssetBundle 16 あり 27.2-28.6 37-37 19-106
DownloadHandlerAssetBundle 64 なし 35.6-38.6 56-63 117-167
DownloadHandlerAssetBundle 64 あり 29.7-30.6 42-45 103-233
DownloadHandlerFile 1 なし 93.5-126 32-32 18-19
DownloadHandlerFile 4 なし 24.1-28.0 32-32 18-19
DownloadHandlerFile 4 あり 19.0-21.0 32-32 18-33
DownloadHandlerFile 16 なし 14.0-16.6 32-32 18-33
DownloadHandlerFile 16 あり 13.1-15.3 32-33 19-67
DownloadHandlerFile 64 なし 13.5-15.5 33-33 35-54
DownloadHandlerFile 64 あり 12.1-13.6 33-33 32-33
DownloadHandlerBuffer+同期保存 1 なし 102-137 59-59 32-50
DownloadHandlerBuffer+同期保存 4 なし 23.1-27.5 95-95 18-50
DownloadHandlerBuffer+同期保存 4 あり 18.4-19.4 68-69 65-83
DownloadHandlerBuffer+同期保存 16 なし 14.3-16.3 131-132 50-83
DownloadHandlerBuffer+同期保存 16 あり 14.5-16.1 81-101 34-51
DownloadHandlerBuffer+同期保存 64 なし 14.7-16.1 169-169 51-100
DownloadHandlerBuffer+同期保存 64 あり 13.6-15.9 144-146 34-84
自作DownloadHandler+非同期保存 1 なし 133-196 33-33 20-94
自作DownloadHandler+非同期保存 4 なし 32.2-40.0 34-35 18-63
自作DownloadHandler+非同期保存 4 あり 37.0-29.3 33-34 18-32
自作DownloadHandler+非同期保存 16 なし 15.0-16.4 37-38 18-33
自作DownloadHandler+非同期保存 16 あり 11.9-13.8 35-35 18-18
自作DownloadHandler+非同期保存 64 なし 13.3-14.7 39-41 18-40
自作DownloadHandler+非同期保存 64 あり 12.1-14.5 39-39 18-19

項目の粗い説明

まず表の項目について粗く説明します。

「並列数」は、UnityWebRequestを同時に何個作るかです。 配列に入れておいて毎フレーム監視し、isDoneがtrueならDispose して新しいものを発行します。 ネットワーク越しの通信の場合、通信の到達待ち時間が長いので、 並列数を上げることで、待っている間に他のファイルの転送を行える確率が上がって 速度が上がります。しかし、並列数が高いほどメモリを食うでしょうし、 終わったかどうかの判定その他でCPUも食います。

「ランダム化」はファイルを落とす順序をランダム化することです。 デフォルトではファイルサイズが小さい順にソートしてあり、 最初のうちは小さいものばかり、最後になると大きいものばかり落としてくる 状態になります。 実製品でも、ファイル名順に並んでいたりすると、 サイズでソートしたような状態に近くなることがあります。 例えばbanner_で始まるものはみんな20kbくらいで、それが100個続く、 とかいうような話です。 これをランダム化することで性能が変わるかを見ます。

残りは測定値です。3回測った範囲を書いています。

「所要時間」は説明不要ですね。 1000ファイル190MBを落とし終えるまでの時間です。

「最大メモリ消費」は、 ダウンロード処理中毎フレーム Profiler.GetTotalAllocatedMemoryLong() して、その最大値としました。多いほどメモリを食うということです。

最後の「最大スパイク」は、最も時間がかかったフレームの時間で、ミリ秒単位です。 30FPSのゲームであれば、これが33を超えたら処理落ちします。 PCで測った関係上、gmailやslackの通知が来た時など外部要因のスパイクが多くあり、 あまり信頼は置けませんが、3回の最小値が50を超えてくる場合や、 最大が100msを超えてくる場合には、怪しいと考えた方が良いと思います。 何か重い処理を同期的にしている疑いがあります。

では、いくつかすぐ言えそうなことを挙げてみましょう。

並列数の影響

どの手法であれ、並列数が最も効きます。 1並列、つまり「1つが終わるまで次の処理を始めない」 やり方だと凄まじく遅くなりますし、 その時々のネットワークの状況次第で時間が大きく変わり、 測定結果が大きくバラつきます。

どの手法でも、4だと足りず、16もあればそれ以上はあまり変わらない、 といった傾向が見えます。最適な数は8かもしれませんし10かもしれませんので、 そこは製品ごとに試してみるのが良いかと思います。 サーバ側の事情もおありでしょう。

なお、明確な差がないのであれば、並列数は小さい方が無難です。 並列数が多いほどスパイクやメモリ消費が上がる傾向があります。 UnityWebRequestの生成は遅いので、同一フレームに多数発行すると 結構なスパイクを起こす、ということもあります。

ランダム化の影響

だいたいどの手法、どの並列数でも、ランダム化した方が結果が良好です。 特に並列度が小さい場合には差が大きく出ます。

理由はある程度推測できます。 小さなものほど、サーバに要求するのにかかる時間が占める比率が 大きくなります。0.2秒かかってサーバにお願いし、その後0.1秒でデータが届けば、 2/3の時間は何も受け取っていないことになります。 小さいものばかり要求していると、実際に転送している時間の 比率が小さくなって効率が悪いのです。 しかし、ランダム化すれば大小のファイルが並列しますので、 小さなファイルの要求を出して待っている間にも、 大きなファイルは送られ続けてきます。

また、大きなファイルほど大きなメモリを食うような方式であれば、 大きなファイルを同時に複数処理すれば当然メモリを食います。 これもランダム化で軽減するでしょう。

手法ごとの特徴

手法による違いを軽く見てみましょう。

「DownloadHandlerAssetBundle」は遅めで、並列数が大きいとメモリを食い、スパイクも起こりやすい印象です。

「DownloadHandlerFile」は並列数が高ければ高速で、 メモリも一定です。並列数が高いとスパイクしやすい印象はありますが、 大したことはなさそうです。

「DownloadHandlerBuffer+同期保存」は、速度に問題はなさそうですが、 メモリ消費に危険な香りがあります。

「自作DownloadHandle+非同期保存」は、まあまあといったところでしょうか。 たまたま最高速度が出ていますが、この程度であればたまたまかもしれません。

実装を含めて細かく

何をやったのかを詳しく見ていきます。

テストデータについて

テストデータは、TestDataGenerator.cs というスクリプトにて生成しました。

400個くらいが10KB以下、400個くらいが10-100KB、170個くらいが100KBから1MB、 残りの30個が1MBから30MBくらい、といった分布です(対数正規分布で作っています)。

「おいおい、こんなに小さなファイルは、普通まとめるよね?」 と思われる方、いらっしゃいますよね? 私もそう思います。

しかし、運用上の事情で、1アセットバンドルに1ファイルしか入れていないケースもあり、 そうなると数KBのアセットバンドルが数千存在する状態になってしまうのです。 それでも速度を出すにはどうしたら良いか? というのが今回の話の発端ですので、ファイルの大きさは小さい方に寄せてあります。

ちなみに、今回の話の発端となった製品の場合、1000ファイルどころではありません。 7000ファイルあります。今後も増えていくでしょう。 測定に時間がかかりすぎるので、今回は1000個としましたが、 実製品での挙動を見るのであれば、 10000ファイルくらいでやった方が良いテストにはなると思います。

では、それぞれの手法を見ていきましょう。

DownloadHandlerAssetBundle

できるだけUnityが用意した物に乗ろう、という実装で、最も手間がかかりません。 おおよそ以下の手続きでダウンロードします。

// 前もってCaching.readyを待つ
if (!Caching.ready)
{
    yield return null;
}

var req = new UnityWebRequest(path);
req.downloadHandler = new DownloadHandlerAssetBundle(path, hash, crc);
req.SendWebRequest();
yield return req;

var assetBundle = DownloadHandlerAssetBundle.GetContent(req);
assetBundle.Unload(true);

まず、起動したらCaching.readyを待ちます。 その後、UnityWebRequestをnewして、 downloadHandlerとしてDownloadHandlerAssetBundleをnewして設定します。 UnityWebRequestAssetBundle なるクラスを使うと短く書けますが、2017.4にはなかったクラスで 複数Unityバージョン対応が面倒なので、今回は上のように書きました。

余談:キャッシュフォルダについて

サンプルでは、プロジェクトディレクトリ以下にキャッシュを配置するために、

var cachePath = Application.dataPath + "../AssetBundleCache";
System.IO.Directory.CreateDirectory(cachePath);
cache = Caching.AddCache(cachePath);
Caching.currentCacheForWriting = cache;

という具合に、Assetsの隣にAssetBundleCacheというディレクトリを配置しています。 これはサンプルだからという話ではありません。 同一PC上で複数起動してテストする時にキャッシュを共用されると テストしにくいので、別々に持ちたいのです。 私はエディタとStandaloneビルドではこうしています。

結果

測定結果です。

方法 並列数 ランダム化 所要時間(秒) 最大メモリ消費(MB) 最大スパイク(ms)
DownloadHandlerAssetBundle 1 なし 123-242 33-34 33-50
DownloadHandlerAssetBundle 4 なし 34.1-36.3 34-35 33-51
DownloadHandlerAssetBundle 4 あり 27.5-30.7 34-34 18-19
DownloadHandlerAssetBundle 16 なし 24.1-28.2 40-40 57-80
DownloadHandlerAssetBundle 16 あり 27.2-28.6 37-37 19-106
DownloadHandlerAssetBundle 64 なし 35.6-38.6 56-63 117-167
DownloadHandlerAssetBundle 64 あり 29.7-30.6 42-45 103-233
DownloadHandlerFile 4 なし 24.1-28.0 32-32 18-19
DownloadHandlerFile 16 なし 14.0-16.6 32-32 18-33
DownloadHandlerFile 64 なし 13.5-15.5 33-33 35-54

DownloadHandlerFileを利用したものと比較すると、

  • 遅い
  • メモリを汚す
  • スパイクする

といったところが目につきますね。

時間がかかるのは見た通りです。並列度を上げすぎるとかえって悪化します。 メモリ消費は並列度が上がるほど増し、スパイクも同様に悪化します。 Unityの中のことですから、理由はわかりません。

ただ、推測はできます。

DownloadHandlerAssetBundleが想定する用途は、 「キャッシュにあればキャッシュから読み、なければダウンロードして読む」 であって、「ダウンロードしてキャッシュに溜める」 ではありません。主眼はAssetBundleをメモリにロードすることにあり、 ダウンロードではないのです。 時間がかかったり、 スパイクしたりする原因は、メモリにAssetBundleをロードする処理が 余計だからではないでしょうか?(単なる憶測)。

なお、UnityWebRequestのisDoneがtrueになった段階でDisposeして、 DownloadHandlerAssetBundle.GetContent をしない、というのはダメみたいです。 エラーが頻発してまともに動きません(どなたか追試おねがいします)。

というように、イマイチな所もありますが、 これらの欠点はファイル数が少なければまず問題になりません。 弊社東京プリズン でもこれを使っていましたが、問題は感じませんでした。 時期や種類ごとにある程度assetBundleをまとめており、 ファイル数がそれほど多くなかったからでしょう。

標準キャッシュの使い勝手の問題

実のところ、問題点は性能よりも機能かもしれません。

例えば、キャッシュの有効期限は150日より伸ばせません。 運用していればさしたる問題はないのですが、 サービス終了後も通信不要なコンテンツは利用可能なままに保とう、 なんて思うと厄介です。二度とダウンロードできないデータが 勝手に消されてしまうかもしれないからです。

また、普通に運用している場合にも問題があって、 新しいバージョンを落とした後も古いものが残り続けるので ストレージを余計に食います。 手動で消す方法は提供されていますが、正直面倒です。 挙動はあまり文書化されておらず、いつファイルが消えるのか、 等々もわかりません。

そのあたりの事情は、[Unity 2018.2] AssetBundleのキャッシュを完全に理解する に詳しく、大変助かりました。

でも正直、「自作しちゃえば完全に把握できるよね?」という気持ちでいっぱいです。

DownloadHandlerFile

「ファイルを書きこむところまでやってくれる物」が DownloadHandlerFileとして用意されています。 これを使えば、ファイルIOを自力で書かずに済んで楽なわけです。 作らないといけないのはバージョン管理だけで済みます。 非同期書き込みなので処理落ちせず、メモリを馬鹿食いすることもありません。

使い方はおおよそこんな感じです。

var req = new UnityWebRequest(path);
var handler = new DownloadHandlerFile(writePath);
handler.removeFileOnAbort = true; // 途中ミスったらファイル消す
req.SendWebRequest();

DownloadHandlerAssetBundleを使う場合の 「使う気もないのにAssetBundleがロードされて遅いしメモリ汚れる」 問題がなくなります。

測定結果

方法 並列数 ランダム化 所要時間(秒) 最大メモリ消費(MB) 最大スパイク(ms)
DownloadHandlerFile 4 なし 24.1-28.0 32-32 18-19
DownloadHandlerFile 4 あり 19.0-21.0 32-32 18-33
DownloadHandlerFile 16 なし 14.0-16.6 32-32 18-33
DownloadHandlerFile 16 あり 13.1-15.3 32-33 19-67
DownloadHandlerFile 64 なし 13.5-15.5 33-33 35-54
DownloadHandlerFile 64 あり 12.1-13.6 33-33 32-33

性能は良好で、スパイクも発生せず、並列数を上げても下げてもそんなに変化がありません。 これでやればいいのではないでしょうか?

要確認事項

ただ、使うなら確認しておくべきことがいくつかあります。

  • ダウンロード中の中途半端なファイルはテンポラリファイルになっているか。
  • ファイル書き込みは非同期で、遅い機械でもスパイクが起こらないか。
  • 書き込みの失敗はいつわかるか。どう検出するか。
  • isDoneがtrueになった瞬間にそのファイルは読めるのか。まだ読めないのか。

DownloadHandlerFileはファイルが全量届く前に書き込みを始めますので、 ダウンロード中に通信が切れたりアプリが落ちたりした場合、 中途半端なファイルができるはずです。 それが一時的な偽のファイル名であればいいのですが、 そうでなかった場合、書き込みが完了していると勘違いする原因になります。 「成功していれば何バイトであるはずか」というデータを別途持っていないのと、 後からは途中かどうかが判定できないですし、サイズをチェックする余計な手間もかかります。 removeFileOnAbortをtrueにすれば「中断とエラーでファイルを消す」 ようにはなりますが、ファイル名はどうなのでしょうか?

また、ダウンロード中にもうゲームを遊ばせよう、と思うと、処理落ちは避けたいものです。 書き込みが全て非同期ならば良いのですが、メインスレッドがガツンと止まるような処理が あると、ダウンロード中にゲームで遊ばせるような作りはキツい、ということになります。 性能やOS、Unityのバージョンに関わらず非同期である、という保証はどこにもありませんし、 「小さいファイルは同期書き込みにして高速化する」みたいな工夫があるかもしれませんので、 ストレージが遅いスマホでも大丈夫かは別途確認する必要があります。

そしてエラーの検出も現状よくわかりません。ストレージ容量不足でIOエラーを吐いた場合、 それがどこにどう返るのかは試しておく必要があります。

最後に、そのファイルが読めるタイミングはいつか、です。 これは読み終わったファイルからどんどん開けてゲームに使う、 というようなことをやるなら重要です。 チュートリアルに必要なものを先にダウンロードし、 終わり次第チュートリアルを開始して、裏で他のデータのダウンロードを続ける、 というような作りは当然やりたくなるわけですが、 そのためには書き終わるタイミングがわかる必要があります。

DownloadHandlerBuffer+同期保存

何も指定しない時に使われるデフォルトの DownloadHandlerであるDownloadHandlerBuffer を使い、保存を同期書き込みにしてみた実装です。

var req = new UnityWebRequest(path);
req.downloadHandler = new DownloadHandlerBuffer();
req.SendWebRequest();
yield return req;
System.IO.File.WriteAllBytes("hoge.unity3d", req.downloadHandle.date);

ここではUniyWebRequestをnewしてから、別途DownloadHandlerBufferもnewしていますが、 UnityWebRequest.Get() を使えばこの2行が1行になります。 全量をメモリに格納する単純なDownloadHandlerです。 当然メモリを食いますが、メモリが十分にあるか、ファイルが小さければ、 それは欠点ではありません。

終了したらFile.WriteAllBytes() でドカッと書き込みます。 サイズがさほど大きくなく、ファイルIOが十分高速なら、これで十分です。 実装も単純で、余計なことをしないのでオーバーヘッドも小さいことが期待できます。 実際には、一旦偽のファイル名で書き込んで、書き込みが終わってから File.Move() で改名する方が良いでしょう。また、tryでくくって発生したエラーを検出する必要もあります。

性能

方法 並列数 ランダム化 所要時間(秒) 最大メモリ消費(MB) 最大スパイク(ms)
DownloadHandlerBuffer+同期保存 4 なし 23.1-27.5 95-95 18-50
DownloadHandlerBuffer+同期保存 4 あり 18.4-19.4 68-69 65-83
DownloadHandlerBuffer+同期保存 16 なし 14.3-16.3 131-132 50-83
DownloadHandlerBuffer+同期保存 16 あり 14.5-16.1 81-101 34-51
DownloadHandlerBuffer+同期保存 64 なし 14.7-16.1 169-169 51-100
DownloadHandlerBuffer+同期保存 64 あり 13.6-15.9 144-146 34-84
DownloadHandlerFile 16 あり 13.1-15.3 32-33 19-67
DownloadHandlerFile 64 あり 12.1-13.6 33-33 32-33

メモリ消費がヤバいですね。スパイクも大きそうです。 しかし、所要時間そのものはいい線行ってます。 同期処理ならファイルの書き込み終わりがいつなのかとか、 エラー処理がどうだとか、 そういったことを考える難度が劇的に下がりますので、 これで済むならこれでいいでしょう。

よくあるゲームのようにダウンロード中にゲームを遊ばせたりせず単に待たせるだけなら、 メモリを多少多く使おうが、スパイクを起こそうが、 大した問題ではありません。 順序をランダム化すれば、大きなファイルが同時に処理される率が減るので、 メモリ消費はかなりマシになります。 非同期処理や、分割ファイル書き込みは、どうしても実装が複雑でバグりやすくなるので、 これで済むならこれでいいのではないでしょうか?

なお、DownloadHandlerFileに比べて所要時間で微妙に負けているのは、 「ファイルを書いている間他のことをできない」ためでしょう。 書き込みが非同期であれば、その時間にダウンロードを進めたり、 新たな要求を出したりできるからです。 ただし、 非同期化は必ずオーバーヘッドとメモリ消費の増加をもたらしますし、 実装が稚拙であれば思ったような効果は出ません。 性能を求めて非同期化を行う時には、 比較用の同期処理を必ず用意しておきましょう。 それで足りる用途ならまずそれを使って、そもそも非同期化なんてしない方が ずっと幸せになれると思います。

自作DownloadHandler+非同期保存

さてここからが趣味です。

標準で良ければそれで良いですし、 機能上の要求があってキャッシュを自作したいなら、DownloadHandlerFileで良いでしょう。 DownloadBufer+同期保存でも良い用途であれば、そうすることでさらに実装が減ります(特にエラー処理が)。 ダウンロード専用画面でダウンロードさせるなら、 スパイクやメモリ使用量はさして問題にはなりませんので、 同期保存がおすすめです。

ですが、それで終わっては面白くないので、軽く自作してみました。 作ったものは、ファイルを非同期で書き込む機能と、 それを使うDownloadHandlerです。

ファイル保存側

FileWriterという名前で作ってみました。

public class FileWriter : System.IDisposable
{
    public class Handle{...};

    public FileWriter(string root);
    public void Dispose();
    public Handle Begin(string path);
    public void Write(
        out int writtenLength,
        Handle handle,
        byte[] data,
        int offset,
        int length);
    public void End(Handle handle);
}

BeginでHandleをもらったら、そのHandleを指定してWriteを 呼んで書き込み、最後にEndを呼んで閉じます。

コンストラクタで書き込みディレクトリの根本(root)をもらっているのは、 毎回フルパスをもらうインターフェイスだと、 不正な場所への書き込みを防ぎにくいからです。 rootに+=してフルパスを生成していれば、 rootよりも下にしか書き込めません。 ".."が含まれていたらバグとしてプログラムを止めます。

最初はFileStream.BeginWrite()FileStream.EndWrite() を使ってスレッドを使わずに実装したのですが、 FileStreamのコンストラクタ や、 FileStream.Close() で許容できない負荷(1ms以上)があったため、スレッドでの実行にしました。

さて、まだ試作ですので実装は極めて単純です。

Begin,Write,Endのいずれも、キューに要求を入れます。 これを取り出して実行するスレッド側の関数は、

void ThreadFunc()
{
    Request req;
    while (true)
    {
        _semaphore.WaitOne(); // 何か投入されるまで待つ
        lock (_thread)
        {
            req = _requestQueue.Dequeue();
        }
        if (req.handle == null) // ダミージョブが来たらスレッド終了
        {
            break;
        }
        Execute(ref req);
    }
}

といった感じで、無限ループでキューを処理します。 RequestはBeginやWrite,Endの要求が入ったstructです。 ループ内では、セマフォが0より大きくなるのをSemaphore.WaitOne()で待ち、 Queue.Dequeue()して仕事を取り出して実行します。 無限ループするスレッドを作る時には、「仕事がない時には寝る」 作りにするのが基本です。そうしなければ、他のスレッドの仕事を邪魔したり、 無駄に電気を食ったりします。Semaphoreはそのためのものです。 要求それぞれにセマフォやロックの負荷がかかるので理想的な実装ではありませんが、 まずは簡単な実装としました。 また、handleがnullなものがキューから出てきたら 終了の合図であるとしてスレッドを終了しています。

さて、Writeなのですが、

public void Write(
    out int writtenLength,
    Handle handle,
    byte[] data,
    int offset,
    int length);
}

outがついている引数がありますね。ここに書き込みに成功したバイト数が返ってきます。 FileWriterは固定のリングバッファを中に持っており、 そこにもらったデータをコピーします。 それが一杯だと全部を書き込めません。 そのうち、動的にリングバッファを拡張するとか、 テンポラリのバッファを別途確保するとか、 そういったケアを入れるかもしれませんが、今は何もしてません。

DownloadHandler側

DownloadHandlerはDownloadHandlerScriptを継承した クラスを作れば自作できます。

public class DownloadHandlerFileWriter : DownloadHandlerScript
{
    public DownloadHandlerFileWriter(
        FileWriter writer,
        FileWriter.Handle writerHandle,
        byte[] inputBuffer) : base(inputBuffer)
    {
        _writer = writer;
        _writerHandle = writerHandle;
    }

    protected override bool ReceiveData(byte[] data, int length)
    {
        // writerで詰まるようならここでブロックする。 TODO: 後で何か考えろ
        int offset = 0;
        while (true)
        {
            int written; // 書けた量が返ってくる
            _writer.Write(out written, _writerHandle, data, offset, length);
            length -= written;
            if (length <= 0) // 全量書けた!抜ける!
            {
                break;
            }
            System.Threading.Thread.Sleep(1); // 寝て待つ
            offset += written;
        }
        return true;
    }

    protected override void CompleteContent()
    {
        _writer.End(_writerHandle);
        _writerHandle = null;
    }

    FileWriter _writer;
    FileWriter.Handle _writerHandle;
}

まずコンストラクタでbyte[]のバッファを渡します。 基底クラスDownloadHandlerScriptのコンストラクタ に渡すと、これが通信結果の受け取りバッファになり、 勝手にnewされることはなくなります。

このバッファが小さすぎると、ReceiveDataがムチャクチャな回数呼ばれて 負荷が増します。 プロファイラで見ると、ReceiveDataの度に微妙に大きな負荷と 17バイトのGCAllocが発生しており、呼ばれる回数が多いと結構重くなります。

f:id:hirasho0:20190311142924p:plain

かといってバッファが大きすぎると無駄ですし、初期化の負荷が増します。 今回の実装では設定可能にしていますが、16KB以上に増やしても あんまり性能に変化はなかったので、測定は16KBでやりました。

次に、データが届くとReceiveDataが呼ばれます。これをFileWriter.Writeに渡します。 ここで注意すべきは、引数のdataが再利用される、ということです。 dataはコンストラクタで渡した配列そのもので、 ReceiveDataを抜けたら、dataには別のデータが書き込まれます。 非同期書き込みはすぐには終わりませんから、 コピーを取るか、書き込みが終わるまでReceiveDataを抜けないようにするかの いずれかが必要です。後者をやると非同期ではなくなりますので、 今回はFileWriter.Writeの中でコピーをしています。

なお、FileWriter.Writeのリングバッファが満杯だと書けないので、 全量書けるまでSleep(1) しながらループしています。 バッファをnewして書き込みが空くまで取っておけばブロックはしなくなりますが、 メモリ使用量が増します。非同期処理において ブロック時間とメモリ使用量はトレードオフになるので、 完璧はありません。用途に合わせた妥協、バランスが設計のミソです。

最後に、データが全量到達するとCompleteContent() が呼ばれます。 ここでFileWriter.End()を読んでファイルを閉じる要求をしています。 なお、実戦投入する場合には、エラー時の処理も別途必要です。 DownloadHandlerのコールバックにはエラー用のものはないため、別途用意して直接呼ぶことになるでしょう。

結果

方法 並列数 ランダム化 所要時間(秒) 最大メモリ消費(MB) 最大スパイク(ms)
DownloadHandlerFile 16 あり 13.1-15.3 32-33 19-67
DownloadHandlerFile 64 あり 12.1-13.6 33-33 32-33
自作DownloadHandler+非同期保存 16 あり 11.9-13.8 35-35 18-18
自作DownloadHandler+非同期保存 64 あり 12.1-14.5 39-39 18-19

時間、メモリ、スパイクのいずれに関しても、 いい線行ってるんじゃないでしょうか。

スパイクしなかったのはたまたまでしょうが、 今回の実装においては、 FileWriterのリングバッファが満杯にならない限り、 メインスレッドをブロックする所は一つもありません。 メモリ消費量がいささか大きいのは、 FileWriterのリングバッファに固定で16MB割り当てているからです。 少な目にしておいて、足りない時に拡張する作りにすれば、 もっと減らせるでしょう(マルチスレッドなので結構面倒くさいですが)。

ただし、拡張を無制限に許すと、 メモリ消費量が無限に増えて死ぬバグの原因になります。 書き込みよりもダウンロードが速い状況が続けば無限にメモリ使用量が増えていくので、 どこかで制限をかける必要があります。 どれくらいでどう制限するかはいろいろあると思いますが、 個人的にはメモリ消費が少ない所で抑えてしまった方がいい気がします。 メモリがあふれればアプリが死ぬからです。 多少ブロックしても死にはしません。 許容できる量で固定できるなら、拡張可能にして複雑にする必要もないでしょう。

コピーは必要なの?

今回の実装では、DownloadHandlerとFileWriterの2箇所にバッファがあります。 そのためにコピーが必要です。 しかしコピーと言えば遅い処理の代表であり、 なくて済むならその方が良いでしょう。どうにかならないのでしょうか。

本来、入力と出力が非同期で行われる処理は、バッファ一個でも書けます。 例えば10バイトの配列をバッファとして使う場合を考えましょう。 ダウンロードデータが0,1,2,3...と入ってきます。 ファイルに書き出すクラスはそれを監視して、 入ってきたデータをファイルに書き込みます。 ダウンロードデータが10バイト溜まった段階で、 もし5までファイルに書き終わっていれば、0,1,2,3,4,5は 上書きしてもかまいません。9番の次は0番に書き込んで良く、 限られたバッファで無限に続けることができます。 配列を循環的に使う、リングバッファ という手法で、一つのバッファ上で完結するのでコピーはゼロです。

では、何故今回はコピーが必要なのでしょうか?

それは、Unityのインターフェイスがそうだからです。 DownloadHandler.ReceiveDataでもらう配列は、 使い回されます。そのまま別のスレッドに持っていって、 ファイルに書き込もうとしても、その時にはもう 中身が変わっているかもしれません。 リングバッファを公開するインターフェイスはバグりやすいので、 それを避けたのでしょう。

実のところDownloadHandlerScriptのコンストラクタ にバッファを渡さなければ、 ReceiveDataの度にバッファがnewされるので、使い回しが起きません。 コピーは不要になります。 しかし、書き込み待ちのデータ量が多いほどメモリ使用量が増えます (書き込みが終わるまで参照が消えないのでずっと残ってしまう)。 もし書き込みで詰まったままダウンロードが大量に来ると、 メモリが枯渇してアプリごと死ぬかもしれません。 それに、C#では配列をnewするとデフォルト値(つまり0)で塗りつぶす処理が走るので、 コピーほどではないにしても重いのです。 そういうわけで、今回はコピーする方を選んでいます。

DownloadHandlerFileが用意される前の話だと思いますが、 UnityWebRequest自体を使わない、というところまでやる方もいました。 さすがと感じますが、今となってはDownloadHandlerFileを使えば良いでしょう。

結論

標準、つまりDownloadHandlerAssetBundleを使えばいいのではないでしょうか。 十分使えます。実際それで一つゲームを売りました(東京プリズン)。 もし性能に問題があるのであれば、ファイルをまとめて 数を減らすことを考えましょう。 余計なキャッシュファイルを消すことも、面倒ではありますが可能です。

また、このあたりはいずれAddressable Assets System がどうにかしてくれそうな気配もしますし、 今更何かを自作しても仕方ない気はします。

と言っておいてなんですが、それで済まされない事情もありますので、 現在、アセットバンドルのダウンロード、キャッシュ、 アセットのロード、参照カウントによる自動解放、 といったあたりを含むライブラリを作っております。 次の記事ではそのお話ができれば、と思います。