[Unity2018.2] Caching.readyはなぜ遅いのか？ & ベンチマークとってみた

# はじめに

前回[AssetBundleのキャッシュを完全に理解する](https://qiita.com/k7a/items/23d909ffeea3bab7dfcb)という記事を書きました。
今回は昔から囁かれている**Caching.ready遅い問題**について、本当に（今でも）遅いのか？何故遅いのか？という事を明らかにするために、遅い理由を考察し、さらにCaching.readyのベンチマークを取ってみました。

# 調査環境

- Unity 2018.2.4f1
- MacBook Pro (15-inch, 2016) macOS Sierra
- iPhone6S iOS 11.4.1 (15G77)
- HUAWEI Ascend G620S Android 4.4.4

# Caching.readyが遅い理由について

今回はリバースエンジニアリング的な解析は行わず、キャッシュのディレクトリ構造とCachingクラスのAPIから仕組みを考察してみよう、という事しかやっていません。
ということで、まずはキャッシュディレクトリの中身から見てみます。

## 中身を見てみる

AssetBundleのキャッシュディレクトリを見てみると、以下のような構造になっている事が確認できます。
ディレクトリ構成の詳細については[前回の記事](https://qiita.com/k7a/items/23d909ffeea3bab7dfcb)をご覧ください。

```
/
├─__info
├─hoge/
│　├─606587c5f30c073b56fe732abbfa1527/
│　│　├─__data
│　│　└─__info
│　└─5670aa7ba887ab8f70d801da7edb8914/
│　　 ├─__data
│　　 └─__info
├─fuga/
│　└─3b91fe9110a83c0490dbba8138a39210/
│　　 ├─__data
│　　 └─__info
~
```

また、読み書き中のキャッシュのディレクトリには`__lock`ファイルが一時的に作成されます。
それぞれのファイルの中身を確認してみると

- `__data`ファイルはキャッシュファイルの本体
- `__info`ファイルはキャッシュのタイムスタンプなどの情報

となっています。
ここで、`__info`ファイルの中身を詳しく見てみます。
ルートディレクトリに存在する`__info`は以下のようになっています。

```
1549694817
1
0

```

一番上の数字はUnixTimeっぽいですね。おおよそ現在時刻から150日後を指しているようです。150日というのはCache.expirationDelayのデフォルト値ですね。
そこでexpirationDelayの値を変えてみた所、ちょうと現在時刻からexpirationDelay分加算された値が書き込まれていることが確認できました。
デフォルトではキャッシュ作成時に150日後のUnixTimeが書き込まれ、その後**Cache.expirationDelayに値を設定したタイミングで値が変わっている**ようです。
下の1,0は見ている限りでは値が変わっている所を確認できませんでした。なんらかのフラグっぽいような感じはします。

次に、各キャッシュごとの`__info`ファイルを覗いてみます。

```
-1
1536741091
1
__data

```

2行目はUnixTimeっぽいです。見た所、これはそのAssetBundleがロードされたタイムスタンプになっているようでした。初回はキャッシュされた時刻が書き込まれ、以降はそのキャッシュが読まれるか、Caching.MarkAsUsedされるとタイムスタンプが更新されています。
4行目はキャッシュのファイル名ですが、ここはどのキャッシュも固定で`__data`なので、いまいち存在意義が分かりません。
1行目、3行目についてもよく分かりませんでした。

## 仮説

以上の事から予想すると、恐らく以下のような仕組みになっていると考えられます。

- 起動時にキャッシュのディレクトリ構造と全ての`__info`ファイルを走査し、キャッシュ情報のデータベースをメモリ上に構築する
- 上記処理が終わった後、Caching.readyをtrueにする
- 以降はメモリとディスクに対して同時に書き換えを行う

キャッシュデータベースを構築するために、起動時に毎回`__info`ファイルの数の分だけディスクI/Oが発生するため、そりゃ件数が増えれば遅くなるな…という雰囲気がしますね。といってもまあ遅すぎな気もします。

…ここまで調べておいてなんですが、Unite2016でこの辺についての解説があったみたいです。大体仮説通りかなと。

- [［Unite 2016 Tokyo］学校では教えてくれないアセットバンドルのしくみ](https://japan.unity3d.com/unite/unite2016/files/DAY1_1800_Room1_Ebata.pdf)
- [講演動画](https://www.youtube.com/watch?v=krZ6v050xdY)

# Caching.readyのベンチマーク

ここからは、実機で実際Caching.readyがどれくらい時間がかかっているか？というのを計測してみます。

## ベンチマーク方法

キャッシュ件数による時間の伸び方を見るため、まずは[前回の記事](https://qiita.com/k7a/items/23d909ffeea3bab7dfcb)で調査したキャッシュの仕組みを利用してキャッシュのかさ増しを行います。ざっくり説明すると以下のような感じです。

- 適当なアセットからAssetBundleを作成
  - 今回は1KBの0埋めされたTextAssetを使用
  - アセット自体の容量はキャッシュデータベースの構築時間に影響しないはずなので、気にしないこととする
- 作成したAssetBundleをStreamingAssetsに配置
  - UnityWebRequestはローカルファイルを対象にできる
  - ローカルファイルを対象とした場合でも、条件（バージョン指定する）を満たせばキャッシュは行われる
  - テストのためにサーバーを立てて通信させるのは面倒
- 同じAssetBundleを異なるキャッシュキーで保存しまくる
  - CachedAssetBundle.nameによるキャッシュキーの指定を用いる

キャッシュのかさ増しをした後、アプリを再起動してCaching.readyにかかった時間を見るという感じです。

実際の画面とコードは以下のような感じです。色々と雑です。細かい所はなんとなく察してください。（暇があったらGithubにでも上げます）

<img src="https://qiita-image-store.s3.amazonaws.com/0/97014/de210125-9374-0dc7-1de1-74b6d174ec1c.png" width=300>


```cs
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.Networking;
using System;
using System.IO;
using System.Text;
using System.Diagnostics;
using System.Collections;

public class AssetBundleCacheTest : MonoBehaviour
{
    public Text text;
    public Text cacheInfo;
    public Text progress;

    public InputField countField;

    bool cacheTestRunning;

    void Start()
    {
        var stopWatch = new Stopwatch();
        stopWatch.Start();
        var cache = Caching.currentCacheForWriting;

        StartCoroutine(WaitCachingReady(() => {
            stopWatch.Stop();

            var sb = new StringBuilder();
            sb.AppendFormat("index: {0}\n", cache.index);
            sb.AppendFormat("CachePath: {0}\n", cache.path);
            sb.AppendFormat("persistentDataPath: {0}\n", Application.persistentDataPath);
            sb.AppendFormat("temporaryCachePath: {0}\n", Application.temporaryCachePath);
            sb.AppendFormat("maximumAvailableStorageSpace: {0}\n", cache.maximumAvailableStorageSpace);
            sb.AppendFormat("Caching.ready: {0} ms\n", stopWatch.ElapsedMilliseconds);
            text.text = sb.ToString();

            UpdateCacheInfo();
        }));
    }

    // キャッシュの情報の表示を更新する（UIButtonからも呼ばれる）
    public void UpdateCacheInfo()
    {
        if (!Caching.ready) return;

        var cache = Caching.currentCacheForWriting;

        var sb = new StringBuilder();
        sb.AppendFormat("spaceFree: {0}\n", cache.spaceFree);
        sb.AppendFormat("spaceOccupied: {0}\n", cache.spaceOccupied);
        // 今回はキャッシュパスにサブディレクトリを指定しておらず、各キャッシュパスに対して1つのバージョンしかキャッシュしていない為
        // 直下のディレクトリ数をカウントすることでキャッシュ数が確認できる
        // Caching.cacheCountはAssetBundleキャッシュの数ではないので注意
        sb.AppendFormat("cacheCount: {0}\n", Directory.GetDirectories(cache.path, "*", SearchOption.TopDirectoryOnly).Length);
        cacheInfo.text = sb.ToString();
    }

    // キャッシュをクリアする（UIButtonから呼ばれる）
    public void ClearCache()
    {
        Caching.ClearCache();
        UpdateCacheInfo();
    }

    // キャッシュの作成を開始する（UIButtonから呼ばれる）
    public void StartCreateCache()
    {
        var count = 0;
        int.TryParse(countField.text, out count);

        StartCoroutine(CreateCache(count));
    }

    // Caching.readyを待ってから指定した処理を実行するCoroutine
    IEnumerator WaitCachingReady(Action action)
    {
        while (!Caching.ready)
        {
            yield return null;
        }

        action();
    }

    // 指定した数のAssetBundleキャッシュを作成する
    IEnumerator CreateCache(int count)
    {
        if (cacheTestRunning) yield break;
        cacheTestRunning = true;

        var i = 0;
        while (i < count)
        {
            // 同時にやるとtoo many files openとかなって死ぬので0.1秒ごとに
            yield return new WaitForSeconds(0.1f);
            var str = i.ToString();
            StartCoroutine(LoadAndCacheAssetBundle(str));
            progress.text = str;
            i++;
        };
    }

    // ローカルからAssetBundleをロードし、指定したキャッシュキーでキャッシュする
    IEnumerator LoadAndCacheAssetBundle(string cachePath)
    {
        // 前提として`Assets/StreamingAssets/test`にAssetBundleが配置されていること
#if UNITY_EDITOR || UNITY_STANDALONE_OSX || UNITY_IPHONE
        var uri = "file://" + Application.streamingAssetsPath + "/test";
#else
        var uri = Application.streamingAssetsPath + "/test";
#endif
        // hashは別に適当でも良い
        var hash = Hash128.Parse("606587c5f30c073b56fe732abbfa1527");
        var ca = new CachedAssetBundle(cachePath, hash);

        var request = UnityWebRequestAssetBundle.GetAssetBundle(uri, ca, 0);
        yield return request.SendWebRequest();
    }
```

## 結果

上記のものを各端末にビルドして実行し、キャッシュ件数ごとにCaching.readyにどれくらいの時間がかかるかを測定してみました。
何回も施行した結果の平均を取る、というような丁寧なことはしておらず、適当な1回の数値を取っています。
そのため多少のブレはあるものの、とりあえず**遅い**というのは分かるかなと思います。
件数が増えるほどブレは大きくなります。（G620Sの3000件の場合、5000ms〜9000ms程度までブレがありました）

デバイス|0件|100件|1000件|2000件|3000件
---|---|---|---|---|---
HUAWEI Ascend G620S|32ms|568ms|1533ms|5411ms|7369ms
iPhone6S|1ms|156ms|991ms|1957ms|3491ms

1000件で既に1秒前後かかるため、これはなかなか無視できない遅延ですね…。

# 結論

**Caching.readyはキャッシュ件数に比例して遅く**なり、Unity2018においても依然として**見過ごせない遅延が発生する**という事が判明しました。
これを飲んだ上でUnity標準のキャッシュシステムを使うか、開発コストを掛けてでも独自のキャッシュシステムを作るかは判断の余地がありそうです。

ちなみに、CyberAgentのオルガルでは独自でキャッシュ管理システムを作ることで対応したみたいです。が、相応のリスク・コストがあったようなので、やはり一筋縄ではいかなさそうですね。

参考：[複雑化するAssetBundleの配信からロードまでを基盤化した話【CEDEC 2017】 \| CyberAgent](https://creator.game.cyberagent.co.jp/?p=4791)

別スレッドでキャッシュの走査、というのはUnity標準でもそうなので、どこで差が付いているんでしょうか…？
細かい排他制御をする事によって、読み込み終わったキャッシュから順次使えるようになっている…という感じなんでしょうかね。実装と実際の速度差が気になる。