どの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 がどうにかしてくれそうな気配もしますし、 今更何かを自作しても仕方ない気はします。

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

Unityでスレッドから乱数を使いたくなった時に気になったこと

f:id:hirasho0:20190311125630j:plain

ここでは、UnityEngine.Randomを使えば99.9%解決するような話題について、 技術部平山が趣味で書いてみようと思います。 サンプルコードはgithubに置いてあります。

なお、マルチスレッドはあくまできっかけであり、本記事にスレッドの話はありません。 ただ、サンプルでは諸々の高速化のためにスレッドを使っております。

動機

少し前にAIを書いていた時、なにせ計算量が多いのでスレッドを使って並列化したいなと 思ったことがあります。 そうなると、使う乱数はマルチスレッドで呼べる奴じゃないとダメだなと。

しかし、UnityEngineの関数は他のスレッドから呼んではいけないという縛りがあります。 System.Randomを別個に持てばいいんですが、 System.Randomはずいぶん昔からある乱数です。 C言語のライブラリでは互換性のために標準の乱数が未だに昔のままで、 それを素直に使ってバグったゲームが売られたという話も聞きます。 C#でも同じことが起こったりはしないんでしょうか?

というわけで、調べてみることにしました。趣味です。

UnityEngine.Randomは使えないのか?

とりあえずUnityEngine.Randomが使えるならそれが一番いいでしょう。 中身が何なのかが気になったので ちょっと調べてみました。 本当かどうかはわかりませんが、XorShiftの128bit版だそうです。 21世紀に入ってから発明された手法で、よく見かけます。きっと良いに違いありません。 というわけで、本当に他のスレッドから呼んだらダメなのか、呼んでみましょう。

using UnityEngine;
using System.Threading;

public class Hoge : MonoBehaviour
{
    void Start()
    {
        int value = 0;
        var thread = new Thread(() =>
        {
            value = Random.Range(0, 100);
        });
        thread.Start();
        thread.Join();
        Debug.Log("Thread End! " + value);
    }
}

このコードから作ったスクリプトをテキトーなgameObjectに貼りつけて実行です。

f:id:hirasho0:20190311125633j:plain

やっぱりダメでした。他の手を考えましょう。

3つの手法

まあ仮にUnityEngine.Randomが使えたとしても、 staticで呼べるということは、中でスレッドセーフにするためのロックがあるはずで、きっとそれは遅いわけです。

ですので、真面目に使おうと思えばスレッドごとに別の乱数インスタンスを 持つべきだということになります。

そこで、ここでは3つの乱数について検証してみましょう。

乱数と言えばメルセンヌツイスタ な雰囲気なんですが、 数分で自作できるほど単純なものではなく、 ライブラリを入れないと使えないのがネックです。 乱数という奴は、どこか一箇所書き間違っても案外いい具合に動いて見えるので、 本当に正しく書けているのかわかりにくい特性があります。 ややこしい手法を自作するのは避けたいところです。

しかしライブラリを探すのも面倒くさいし、 もってきたライブラリがまともなのかを確かめるのも面倒なので、 まずは何も見ないで数行で書ける上の3つを候補としました。

System.Randomは単に呼ぶだけですので、下2つの実装をお見せします。

MWC(Multiply-with-carry)

class Mwc
{
    uint _x;
    public Mwc(int seed)
    {
        _x = 0xffff0000 | (uint)(seed & 0xffff);
    }
    int Next()
    {
        _x = ((_x & 0xffff) * 62904) + (_x >> 16);
        return (int)(_x & 0xffff);
    }
}

ミソはNext()の中で、実にシンプルです。 論理積、積、和、シフト、そして最後の論理積で5演算しかありません。いかにも速そうです。 32bitの状態変数を持ちますが、出力は16bitです。

なお、スレッドごとに別の系列を生成しないといけないので、乱数の種が設定できるように コンストラクタに引数を設けました。 ただ、MWCは状態変数が0になるとずっと0しか出てこなくなるので、 seedは下位16ビットだけを使い、上は全部1で埋めておきました (もし乱数インスタンスを6万個以上使いたい場合はこんなことをしてはまずいです)。 使う人が間違って0をつっこんでも事故らないようにしておくことは大切でしょう。

この乱数のいいところは、数学の専門家でなくても理屈がなんとなくわかることかなと感じます。 簡単に言えば「1を19で割ると0.052631578947368...となって桁が乱数っぽいよね」 的な話です。コードに割り算がないのに割り算をしたのと同じことになっている、 というところにも面白さがあります。 興味がおありの方は原論文をお読みください。

なお、どんな乱数でも言えることですが、レイトレをするとかかで億以上の回数で乱数を使う場合は、 状態変数が32bitの手法だと乱数を使い果たしてしまいます。 _xが同じ値に戻ってきたら、そこからはまた繰り返しですから、 どんなにがんばっても2の32乗回呼ぶまでに戻ってきてしまうのです。 状態変数が64bitくらいある手法が欲しいところです。

XorShift

class XorShift
{
    uint _x;
    public XorShift(int seed)
    {
        _x = 0xffff0000 | (uint)(seed & 0xffff);
    }
    public int Next()
    {
        _x ^= _x << 13;
        _x ^= _x >> 17;
        _x ^= _x << 5;
        return (int)(_x & 0xffff);
    }
}

これもまたシンプルです。xorとシフトで6演算しかありません。 なんでこれで乱数になるんでしょうね? 最後にandして下16bitを取っているのはMWCに合わせたからで、 本来は32bit全域を使えます。 ただし、32bit全域を使うと「続けて同じ値が出ることはない」「絶対0が出ない」 といった乱数らしからぬ性質を持ってしまうので、状態変数の半分使うくらいで丁度良いでしょう。

さて、この乱数の理屈なんですが、私にもよくわかりません。 論文を見た感じ、どうも行列ベクトル積になってるみたいです。 「232-1乗して単位行列になるような行列を、32ビットから成るベクトルに乗算すると周期232-1の乱数ができる」 という説明を聞いて、「おお、なるほど」と思った方はこんなものを読んでいる場合ではないと思います。 原論文をお読みください。

なお、出力に32bit欲しい場合や、億以上の回数で呼ぶ場合は、64bit版のXorShiftを使うのが良いかと思います。 その場合3回のシフト量は上の例とは違う値になります(例えば順に21,35,4)。 Numerical Recipes をご参照ください。手元に置いておくと便利です。私は年に何回か使います。

計測

さて、これら2つにSystem.Randomを加え、 さらにUnityEngine.Randomの中身であるらしいXorShiftの128bit版(以下XorShift128)も加えて 速度を測ってみましょう。1億回ほど呼んでみます。

const int N = 1000 * 1000 * 100; // 1億
var t0 = Time.realtimeSinceStartup;
int sum = 0; // 結果を何かに使わないと最適化で消されそうなので用意
for (int i = 0; i < N; i++)
{
    sum += rand.Next();
}
var t1 = Time.realtimeSinceStartup;
Debug.Log((t1 - t0) + " sum:" + sum);

こんな感じです。randはMWCだったりXorShiftだったりします (サンプルコード ではIRandomというインターフェイスで抽象化しています)。 結果はこうなりました。

Mwc XorShift System.Random XorShift128
Editor 2.13 2.49 5.38 3.36
Standalone 0.312 0.682 0.876 0.303
WebGL 1.57 1.67 5.01 1.96

単位は秒です。 macbook pro mid2014(i7 3GHz)上で、エディタ、Standaloneビルド、WebGLビルド(chrome)にて計測しました。 プラットフォームごとに得意不得意があって、特にStandaloneの結果がいろいろ謎なのですが、 一番遅いものでも1億回呼んで6秒未満なので、 60FPSのゲームでCPUの1%を使えば3000回呼べる計算になります。 他にいくらでも遅い処理がありますし、気にするまでもないでしょう (ただし、WebGLは64bit整数演算が遅いですし、2羃でない整数除算は泣くほど遅いです。Javascriptだから仕方ないですね)。

さて、速度に大した差がないことを確認したところで、今度は質です。 こいつらは本当に乱数になっているのでしょうか。

乱数の品質を調べる

とはいえ、「乱数が乱数になっているか?」を完全に確認することはできません。 なぜなら、本当に乱数であればどんなことだって起こり得るからです。 100回連続サイコロで1が出ることだって、6の100乗回繰り返せば起こるかもしれません (たぶん宇宙が終わっても起こらないでしょうけど)。

残念ながら「乱数が乱数らしいか」というのは結構難しいテーマで、素人には手に余るところがあります。 概して確率とか統計とかは直感に反することが正しいことが多く、ちょっとかじった程度だと あっさり罠にはまります。

とはいえ、乱数がまともに動いているのか全く確かめない、というのもマズいでしょう。 例えばくじ引きが正しく実装できているか、は何らかの形でテストしないといけません。 乱数そのものに問題がなくても、使い方を間違えばダメになります。 必ず、お客さんにとって意味のある最終結果が望む確率分布になっているか、を確認したいところです。 くじ引きやアイテムドロップの類については、一回くらいはカイ二乗検定 をしておいた方がいいのではないでしょうか。

ですがここではそこまでは深入りせず、 「あからさまにダメ」な場合をはじくことに主眼を置いてみます。 なんでもパッと見でわかる形に可視化すると楽ですので、 まずはランダム具合を画像化してみることにしましょう。

まずテキトーなテクスチャを用意します。

_texture = new Texture2D(Width, Height, TextureFormat.RGBA32, false);
_image.texture = _texture;

可視化するためにテスチャを貼りつけるためのRawImageを_imageとして用意し、 そこにテクスチャをつっこみます。

あとは、テキトーにColor32の配列を用意して、乱数を使って詰めます。

for (int i = 0; i < PixelsPerUnit; i++)
{
    var x = _random.Next() % Width;
    var y = _random.Next() % Height;
    var color = _random.Next();
    var r = ((color >> 10) & 0x1f) << 3; // 5ビット取り出して8倍すると0から248になる
    var g = ((color >> 5) & 0x1f) << 3;
    var b = ((color >> 0) & 0x1f) << 3;
    var offset = ((y * Width) + x);
    pixels[offset].r = (byte)r;
    pixels[offset].g = (byte)g;
    pixels[offset].b = (byte)b;
}

pixelsはColor32のWidth*Heightの配列で、 PixelsPerUnitは「1回に何ピクセル塗るか」です。 ランダムにx,yを決め、色もランダムで決めます。サンプルでは1万にしています。 ここでは出てきた乱数から5ビットづつ取り出してRGBを決めていますが、趣味です。 RGBそれぞれに乱数を使ってもいいでしょう。

これを、Texture2D.SetPixels32() で詰めます。 サンプルでは、テクスチャを縦に分割して、それぞれを別のスレッドで処理していますが、 品質の検査には関係ありません。

さて、それで出てきたのが以下のような画像です。

f:id:hirasho0:20190311125622j:plainf:id:hirasho0:20190311125630j:plain
f:id:hirasho0:20190311125627j:plainf:id:hirasho0:20190311125618j:plain

3つは見た感じランダムですね。 一方、右下はどう見てもランダムではない絵になっています。

これはダメな例を示すために用意した線形合同法 によるものです。ランダムさが足りないために全部の点が塗れません。奇数と偶数が交互に出るというひどい有様です。 2000年くらいまではこんな乱数を工夫しながら使っていたようですが、 MWCなりXorShiftなりメルセンヌツイスタなりが発明された今となっては、そんな必要はないでしょう。 興味がおありの方はサンプルコード をご覧ください。とりあえず実行している所を見たい方はWebGLビルドをどうぞ。

さて、残念ながらこのテストでは、MWCもXorShiftもSystem.Randomも、 「十分ランダム」に見えます。 しかし「System.Randomはランダムさが足りなくてダメだ」と言っている人もいるわけで、 何をやるとダメなのかは知っておきたいところです。 それがはっきりしていれば、逆に「そこまでやる気ないからこれでいいよ」と言えます。

ゴリラテスト

何か楽に差が出るテストはないかなあと調べていたところ、 こんな論文 を見つけました。「このテストを通る奴は、以前からあるテストは大抵通る」 という話で、本当なら魅力的な話です。MWCやXorShiftを発明した人の論文で、 なんとなく信用できそう、というのも良いですね。 中でも「ゴリラテスト」という奴は理屈が簡単で、実装も簡単そうです。ちょっと試してみましょう。

原理は簡単です。今、サルがテキトーにaからzまであるキーボードを打ちまくったとします。 例えば26回叩いたとしましょう。 何回かは同じ文字を叩くこともあるでしょうから、その分出てこない文字もあるでしょう。 もし本当にランダムなら、平均的に何個の文字が出てくるか、というのは計算できます。 ある文字が出てこない確率は、(25/26)で、26回やっても出てこないのですから26乗して、(25/26)26です。 出てくる確率は1から引くので、1-(25/26)26となります。 これを26倍すれば、「平均して何文字現れるか」がわかります。だいたい16.6文字です。

次に、文字1個でなく2個をセットにして、その単語で考えます。aiとかgoとかですね。 26*26で676通りあります。そこで、676回キーを叩いてできた文章の中から、 1文字づつずらしながら2文字の単語を拾って、出てきた単語を数えることができます。 abcdeの5文字から成る文章であれば、ab、bc、cd、de、が出てきます。 これに関しても同じような計算ができて、2文字の単語676種類のうち平均して何個出てくるか、 は前もってわかります。

さて実際にはアルファベットである必要はないので、01の2進法でやりましょう。 この論文では26ビットの文字列をどれくらい網羅しているかを計測して、 それが理屈上の平均値からどれくらい離れているかで判定しています。 26ビットの01文字列は226=6700万通りほどあり、 6700万回乱数を作って、テキトーなビットを取ってきてつなげます。 理屈上はだいたい4200万種類くらい出るはずです。

ここでは、16bitの出力のそれぞれで別々に文字列を作り、 ビットごとに偏りがないかどうかを調べてみました。 結果は色で表してあります。 各ビットについて、理論上の平均値よりあんまり多ければ赤、あんまり少なければ緑、 だいたい同じなら灰色っぽい感じ、としています(標準偏差の4倍ズレると色が飽和します)。

f:id:hirasho0:20190311125553p:plainf:id:hirasho0:20190311125608p:plain
f:id:hirasho0:20190311125603p:plainf:id:hirasho0:20190311125612p:plain

簡単に言えば、色が灰色っぽければ良くて、キツい赤やキツい緑ならダメ、という感じです。 これで言うと、XorShiftはヤバイですね。完全に緑です。 つまり、思ったより単語が出てきてない、イコール、同じ単語が何回も出ている、 ということですから、乱数のバリエーションが少ないのでは?という疑いがあります。 MWCも緑ですが、XorShiftよりはマシな印象です。 一方図にStandardとあるSystem.Randomもだいぶ灰色な感じに見えますが、 左下はだいぶ赤いです。 そして最後にXorShift128ですが、この中では一番鈍い感じの色になりました。 全部赤っぽいとか、全部緑っぽい、とかだとヤバい感じがしますが、そうでもないようです。

これくらい厳しいテストをすると差が出てくるわけですね。 6700万回乱数を叩いて、それぞれのビットごとにバラバラにする、 というようなことをして初めてわかる偏り、が問題になるかどうかは用途次第です。

ところで、なぜ「ゴリラ」なんでしょうか。 実は「サルがテキトーにキーを叩いた的なテスト」として モンキーテストというものが昔からありまして、 「今回はそれをすごい回数でやるから、モンキーよりも強い。そう、ゴリラだ!!」 という感じでゴリラなようです。

メモリ量比較

参考までにそれぞれの手法の消費メモリ量も表にしておきます。 MWCやXorShiftは上で紹介した32ビットバージョンです。

Mwc XorShift System.Random XorShift128
4 4 288 16

単位はバイトです。 System.Randomはnewしてプロファイラで何バイト取ったかを見た数値なので、 アルゴリズム上の正味のサイズは数十バイト小さいと思われます。 System.RandomのアルゴリズムはKnuth先生によるものらしく、 もしそのままであれば32bit変数を55個使うので、220バイトくらいなのではないでしょうか。 「そんなに使う割に別に性能がいいわけでもない」と言えなくもないですが、 気にするレベルではないと思います。なにせ、標準ですから。 標準から外れるのは勇気がいります。長いものに巻かれて困らないなら巻かれればいいのです。

まとめ

マルチスレッドしないなら、UnityEngine.Randomでいいと思います。 XorShift128は上に示した通り、相当厳しいテストをしてもそんなに偏りません。 本当にXorShift128なのか?パラメータも同じなのか? といったことはわかりませんが、まさかそんなところに問題があることはないでしょう。 なにせ標準ですから。

そして、もしマルチスレッドするならSystem.Randomでいいと思います。 今回見た範囲では優れている所はありませんでしたが、なにせ標準でみんな使っています。 下手に自分で手法を選んで罠に落ちる危険を冒すよりもいいでしょう。

とはいえ、テトリスで次にどれが落ちてくるか決める、とか、 ゲーム中20/256の確率で宝箱を落とす、とかいうような程度なら 32ビットのMWCでも全く問題ないと思います。

ところで、最近はスマホでもCPUの演算器が64bitになってきました。 64bitということは、32bitの数に32bitの数を乗算できるということですので、 変数をulongにして、

_x = ((_x & 0xffffffff) * 4294957665) + (_x >> 32);

とすれば簡単に32bit出力のMWCが得られます。 ゴリラテストでもXorShift128以上に灰色になりました。

f:id:hirasho0:20190311125557p:plain

状態変数は64bitで、2の60乗回くらい呼ばないと元の状態には戻ってこないので、実質無限です。 2の60乗はだいたい10の18乗くらいですから、 1秒間に1億回(10の8乗)使うのを、1万台のマシン(10の4乗)で、10年間(だいたい100万秒で10の6乗) 続けてやっと使い切るかな?というくらいなので、我々ゲーム屋にとっては十分無限でしょう。 計算速度も今回試したmacでは32bitとほとんど同じでした。むしろ速いくらいです。

でもまあ、何か罠があった時に私は責任を取れないので、 「標準でいいんじゃないでしょうか」と言うことでお話を締めたいと思います。 長いものに巻かれましょう。