5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Unity PlayerPrefs用にシリアライズ/文字列化する為のパフォーマンステスト

Posted at

はじめに

UnityのゲームをWebGLとしてビルドし、進行状況を保存したいのです。
やり方としては4つ考えられて、

  • PlayerPrefsを使う
  • JavaScriptにアクセスする経路を作成し、localStorageを使う
  • 自前のサーバ建てる
  • 外部サービス利用する

自前のサーバはめんどいので最後の手段として、外部サービスを利用するような(ランキングとかの)仕様も含んでいません。

となるとPlayerPrefsかlocalStorageかなんですが、PlayerPrefsの解説を読むと「ブラウザのIndexedDB API使ってるで」と書いてありました。
つまりlocalStorageより良いもの使っていて、そこを自前で作るのも車輪の再開発になってしまうので、おべんきょするには良いと思うのですが開発が最終段に近付いていて今はリリースを優先したいので今回はパス。
となるとPlayerPrefsしか選択肢ないのではとなりました。

しかしPlayerPrefsはどうにも使いにくいのです。
プリミティブ型しか保存できないし、一個一個ちまちま保存展開処理を書かないといけないし。
PlayerPrefsXも見たのですが、プリミティブ型を配列にして一括保存で効率良くなるのは分かりましたが、でもやりたいことではない。

クラスのインスタンスを丸ごとシリアライズして保存したいし、それを取り出してデシリアライズして使いたい。

ので、Unity用のシリアライザをいくつか試して、StringにしてPlayerPrefsに保存/取り出しが一番速いのはどれか調べました。

一つ注意点として、どうもPlayerPrefsは非印刷文字をうまく保存してくれないようで、シリアライズ後のバイト列を直接ToString()した文字列は保存できませんでした。
エラーも出ず保存自体は成功しているように見えて、取り出すと最初の非印刷文字の部分で情報が途切れているとしか思えない文字列しか取得できませんでした。
(13バイト保存して1バイトしか取り出せないなど)
PlayerPrefsXでもBASE64にエンコードして保存しており、おそらく仕様であろうということで今回はテストから省きました。
なのでPlayerPrefsへの保存はすべてJSONまたはBASE64としています。

Quoted-Printableっていう手もあるかなーと思ったんですが、Unity C#ではあんまり良いライブラリが出てこなかったのでこれも今回は無しとしています。

確認時環境

  • Windows 10 Pro 日本語版
  • Unity 2018.2.7f1 Personal

比較対象シリアライザ

  • 素のPlayerPrefsを並べただけのもの(これが一番遅くなることを期待)
  • JsonUtility(割と速いらしいので一縷の望みをかけて)
  • Utf8json(シリアライズとJson化が同時に可能、Jsonから直接デシリアライズ可能)
  • ZeroFormater(デシリアライズ速度ゼロに期待大)
  • MessagePack-CSharp(たぶんこれが一番速いと思います、のやつ)

速くて使いやすいシリアライザに絞って確認しようと思ったら、@neueccさんのものばっかりになってしまって、でもまぁそうなるよなーと思ってこれでいくことにしました。

結果

結論を先に書けと方方からよく聞きますので先に書きます。

Windowsビルド(UWPでない)でのテスト結果(各回ループ10,000回、単位はms)

1回目 2回目 3回目 3回合計 3回平均
non Serialize save 1383 1501 1505 4389 1463
(JsonUtility / JSON save) 252 293 285 830 276.666
Utf8json / JSON save 183 191 194 568 189.333
Utf8json / BASE64 save 171 176 181 528 176
ZeroFormatter / BASE64 save 200 184 184 568 189.333
MessagePack-CSharp / BASE64 save 177 181 185 543 181
MessagePack-CSharp / JSON save 212 214 217 643 214.333
non Serialize load 807 1115 1080 3002 1000.666
(JsonUtility / JSON load) 261 267 275 803 267.666
Utf8json / JSON load 163 166 170 499 166.333
Utf8json / BASE64 load 195 191 193 579 193
ZeroFormatter / BASE64 load 182 179 178 539 179.666
MessagePack-CSharp / BASE64 load 144 146 146 436 145.333
MessagePack-CSharp / JSON load 202 207 209 618 206

WebGLビルドでのテスト結果(各回ループ10,000回、単位はms)

1回目 2回目 3回目 3回合計 3回平均
non Serialize save 4502 5994 6130 16626 5542
(JsonUtility / JSON save) 917 842 846 2605 868.333
Utf8json / JSON save 571 686 678 1935 645
Utf8json / BASE64 save 618 663 659 1940 646.666
ZeroFormatter / BASE64 save 710 628 634 1972 657.333
MessagePack-CSharp / BASE64 save 676 659 654 1989 663
MessagePack-CSharp / JSON save 794 753 744 2291 763.666
non Serialize load 47 45 46 138 46
(JsonUtility / JSON load) 198 188 188 574 191.333
Utf8json / JSON load 101 100 99 300 100
Utf8json / BASE64 load 119 123 113 355 118.333
ZeroFormatter / BASE64 load 80 78 77 235 78.333
MessagePack-CSharp / BASE64 load 71 66 64 201 67
MessagePack-CSharp / JSON load 164 153 154 471 157

太字にしている箇所は、save/load別でそれぞれ最速値のものです。
また、JsonUtilityだけ使っているデータクラスの形式が本当に少しだけですが違う(後述)ので、JsonUtilityの結果は参考記録としておきたいです。

まずはWindowsビルドのほうですが、綺麗に傾向が分かれて、save最速はUtf8json / BASE64で、load最速がMessagePack-CSharp / BASE64でした。
non Serializeは期待通りの遅さで、JsonUtilityはだいぶ健闘しているけどもやっぱり遅め、他はどれを選んでもだいたい速いですが、MessagePack-CSharp / BASE64はload最速かつsave速度も2位でバランスが良く総合点は一番高そうです。

WebGLビルドではまた結果が違っていて、non Serializeのloadが最速でこれインメモリのキャッシュがかなり効いているのでは?といった印象です。しかしsaveが死ぬほど遅いのでやはり候補には出来ません。

non Serializeを除くと、JsonUtilityがやはり健闘はしてるけども遅め、次がMessagePack-CSharp / JSON で、その他はどれを選んでもあまり大差が無さそうです。
一番バランスが良いのはZeroFormatter / BASE64 でしょうか?
ただ数値のバラ付きが大きく誤差範囲内の気がするので、好みで選んでもだいたい大丈夫そうです。

という結果になりました。

Windowsビルドのほうがsave/load共に速いのかと思っていましたが、WebGLビルドのほうはsaveが遅くloadが速い傾向があって、これも新たな発見でした。

保存対象クラス(JsonUtility以外)

保存の対象とするクラスですが、速度差が比較出来れば良いので、あんまり複雑にする意味はなく、ただ多少複雑な程度ならそのままシリアライズ/デシリアライズ出来るくらいは確認したい、ということで、クラス内に別クラスを一つ内包する、程度の形を作りました。

Status.cs
using MessagePack;
using ZeroFormatter;

[MessagePackObject]
[ZeroFormattable]
public class Status
{
    [Key(0)]
    [Index(0)]
    public virtual int lv { get; set; }

    [Key(1)]
    [Index(1)]
    public virtual int exp { get; set; }

    [Key(2)]
    [Index(2)]
    public virtual int hp { get; set; }

    [Key(3)]
    [Index(3)]
    public virtual int max_hp { get; set; }

    [Key(4)]
    [Index(4)]
    public virtual int atk { get; set; }

    [Key(5)]
    [Index(5)]
    public virtual int def { get; set; }

    [Key(6)]
    [Index(6)]
    public virtual int spd { get; set; }

    [Key(7)]
    [Index(7)]
    public virtual Equip equip { get; set; }
}
Equip.cs
using MessagePack;
using ZeroFormatter;
using ZeroFormatter.Segments;

[MessagePackObject]
[ZeroFormattable]
public class Equip
{
    [Key(0)]
    [Index(0)]
    public virtual string name { get; set; }

    [Key(1)]
    [Index(1)]
    public virtual int atk { get; set; }

    [Key(2)]
    [Index(2)]
    public virtual int def { get; set; }

    [Key(3)]
    [Index(3)]
    public virtual int spd { get; set; }

}

MessegePack-CSharpとZeroFormatterの属性が両方ついています。
また、ZeroFormatterはシリアライズ出来るデータとしてプロパティを要求するため、プロパティとして実装してあります。

@neueccさんのシリアライザは、どれもResolverが型ごとに最適な処理を選んで自動でシリアライズしてくれるようになっているのですが、Utf8jsonの標準のResolverがSystem.Reflection.Emitを利用する設定になっており、UnityのiOSビルドとWebGLビルドはSystem.Reflection.Emitが使われているとUnsupportedエラーで動きません。

ただ、ちゃんとやり方は用意してもらってあって、Utf8Json.UniversalCodeGeneratorを使って事前にPre Code Generationすると、特製Resolverのソースコードを自動生成してくれるので、こちらで生成したコードなら利用可能です。

で、ZeroFormatterもMessagePack-CSharpも類似のResolver自動生成ツールを用意してもらっているので、条件をあわせるためにすべて事前コード生成を利用しています。

その結果がMessegePack-CSharpとZeroFormatterの属性が両方ついているという今回のこの謎コードなのですがテスト用なのでお許しください。

保存対象クラス(JsonUtility)

JsonUtilityはプロパティをシリアライズ対象とすることが出来ないので、プロパティでなくメンバ変数としたクラスも用意しました。
プロパティかメンバ変数かでシリアライズ速度に影響が出るのか定かではありませんが、使ったデータクラスが一応別物なので、JsonUtilityの結果は参考記録とします。

StatusToJsonUtility.cs
using System;

[Serializable]
public class StatusToJsonUtility
{
    public int lv;

    public int exp;

    public int hp;

    public int max_hp;

    public int atk;

    public int def;

    public int spd;

    public EquipToJsonUtility equip;
}
StatusToJsonUtility.cs
using System;

[Serializable]
public class EquipToJsonUtility
{
    public string name;

    public int atk;

    public int def;

    public int spd;
}

テストコード

絞ったつもりなんですが、JSON / BASE64 との組み合わせでパターンが増えてしまって長くなってしまいました。

PlayerPrefsPerformanceTest.cs
using System;
using UnityEngine;
using UnityEngine.Events;
using Utf8Json;
using MessagePack;
using ZeroFormatter;

public class PlayerPrefsPerformanceTest : MonoBehaviour
{

    void Start () {
        utf8jsonInit();
        messagePackInit();

        var status_in = new Status {
            lv = 1,
            exp = 0,
            hp = 10,
            max_hp = 10,
            atk = 1,
            def = 1,
            spd = 1,
            equip = new Equip {
                name = "ぬののふく",
                atk = 0,
                def = 1,
                spd = 0,
            },
        };

        var status_out = new Status {
            equip = new Equip()
        };

        var loop_num = 10000;

        var status_in_json_utility = new StatusToJsonUtility {
            lv = 1,
            exp = 0,
            hp = 10,
            max_hp = 10,
            atk = 1,
            def = 1,
            spd = 1,
            equip = new EquipToJsonUtility {
                name = "ぬののふく",
                atk = 0,
                def = 1,
                spd = 0,
            },
        };

        var status_out_json_utility = new StatusToJsonUtility {
            equip = new EquipToJsonUtility()
        };



        ////////////    non Serialize test    ////////////

        timeCheck("non Serialize save", () => {
            for (var i = 0; i < loop_num; i += 1) {
                PlayerPrefs.SetInt("Status1.lv", status_in.lv);
                PlayerPrefs.SetInt("Status1.exp", status_in.exp);
                PlayerPrefs.SetInt("Status1.hp", status_in.hp);
                PlayerPrefs.SetInt("Status1.max_hp", status_in.max_hp);
                PlayerPrefs.SetInt("Status1.atk", status_in.atk);
                PlayerPrefs.SetInt("Status1.def", status_in.def);
                PlayerPrefs.SetInt("Status1.spd", status_in.spd);
                PlayerPrefs.SetString("Equip1.name", status_in.equip.name);
                PlayerPrefs.SetInt("Equip1.atk", status_in.equip.atk);
                PlayerPrefs.SetInt("Equip1.def", status_in.equip.def);
                PlayerPrefs.SetInt("Equip1.spd", status_in.equip.spd);
            }
        });

        timeCheck("non Serialize load", () => {
            for (var i = 0; i < loop_num; i += 1) {
                status_out.equip.name = PlayerPrefs.GetString("Equip1.name");
                status_out.equip.atk = PlayerPrefs.GetInt("Equip1.atk");
                status_out.equip.def = PlayerPrefs.GetInt("Equip1.def");
                status_out.equip.spd = PlayerPrefs.GetInt("Equip1.spd");
                status_out.lv = PlayerPrefs.GetInt("Status1.lv");
                status_out.exp = PlayerPrefs.GetInt("Status1.exp");
                status_out.hp = PlayerPrefs.GetInt("Status1.hp");
                status_out.max_hp = PlayerPrefs.GetInt("Status1.max_hp");
                status_out.atk = PlayerPrefs.GetInt("Status1.atk");
                status_out.def = PlayerPrefs.GetInt("Status1.def");
                status_out.spd = PlayerPrefs.GetInt("Status1.spd");
            }
        });

        equalCheck(status_in, status_out);




        ////////////    JsonUtility / JSON test    ////////////

        timeCheck("JsonUtility / JSON save", () => {
            for (var i = 0; i < loop_num; i += 1) {
                var str = JsonUtility.ToJson(status_in_json_utility);
                PlayerPrefs.SetString("jsonUtility_JSON", str);
            }
        });

        timeCheck("JsonUtility / JSON load", () => {
            for (var i = 0; i < loop_num; i += 1) {
                var str = PlayerPrefs.GetString("jsonUtility_JSON");
                status_out_json_utility = JsonUtility.FromJson<StatusToJsonUtility>(str);
            }
        });

        equalCheckToJsonUtility(status_in_json_utility, status_out_json_utility);



        ////////////    Utf8json / JSON test    ////////////

        timeCheck("Utf8json / JSON save", () => {
            for (var i = 0; i < loop_num; i += 1) {
                var str = JsonSerializer.ToJsonString(status_in);
                PlayerPrefs.SetString("Utf8json_json", str);
            }
        });

        timeCheck("Utf8json / JSON load", () => {
            for (var i = 0; i < loop_num; i += 1) {
                var str = PlayerPrefs.GetString("Utf8json_json");
                status_out = JsonSerializer.Deserialize<Status>(str);
            }
        });

        equalCheck(status_in, status_out);


        ////////////    Utf8json / BASE64 test    ////////////

        timeCheck("Utf8json / BASE64 save", () => {
            for (var i = 0; i < loop_num; i += 1) {
                var bytes = JsonSerializer.Serialize(status_in);
                var str = Convert.ToBase64String(bytes);
                PlayerPrefs.SetString("Utf8json_base64", str);
            }
        });

        timeCheck("Utf8json / BASE64 load", () => {
            for (var i = 0; i < loop_num; i += 1) {
                var str = PlayerPrefs.GetString("Utf8json_base64");
                var bytes = Convert.FromBase64String(str);
                status_out = JsonSerializer.Deserialize<Status>(bytes);
            }
        });

        equalCheck(status_in, status_out);



        ////////////    ZeroFormatter / BASE64 test    ////////////

        timeCheck("ZeroFormatter / BASE64 save", () => {
            for (var i = 0; i < loop_num; i += 1) {
                var bytes = ZeroFormatterSerializer.Serialize(status_in);
                var str = Convert.ToBase64String(bytes);
                PlayerPrefs.SetString("ZeroFormatter_BASE64", str);
            }
        });

        timeCheck("ZeroFormatter / BASE64 load", () => {
            for (var i = 0; i < loop_num; i += 1) {
                var str = PlayerPrefs.GetString("ZeroFormatter_BASE64");
                var bytes = Convert.FromBase64String(str);
                status_out = ZeroFormatterSerializer.Deserialize<Status>(bytes);
            }
        });

        equalCheck(status_in, status_out);


/*
        ////////////    ZeroFormatter / JSON test    ////////////

        timeCheck("ZeroFormatter / JSON save", () => {
            for (var i = 0; i < loop_num; i += 1) {
                var bytes = ZeroFormatterSerializer.Serialize(status_in);
                var str = JsonUtility.ToJson(bytes);
                Debug.Log("str : " + str);
                PlayerPrefs.SetString("ZeroFormatter_JSON", str);
            }
        });

        timeCheck("ZeroFormatter / JSON load", () => {
            for (var i = 0; i < loop_num; i += 1) {
                var str = PlayerPrefs.GetString("ZeroFormatter_JSON");
                var bytes = JsonUtility.FromJson<byte[]>(str);
                status_out = ZeroFormatterSerializer.Deserialize<Status>(bytes);
            }
        });

        equalCheck(status_in, status_out);
*/

        ////////////    MessagePack-CSharp / BASE64 test    ////////////

        timeCheck("MessagePack-CSharp / BASE64 save", () => {
            for (var i = 0; i < loop_num; i += 1) {
                var bytes = MessagePackSerializer.Serialize(status_in);
                var str = Convert.ToBase64String(bytes);
                PlayerPrefs.SetString("MessagePackCS_BASE64", str);
            }
        });

        timeCheck("MessagePack-CSharp / BASE64 load", () => {
            for (var i = 0; i < loop_num; i += 1) {
                var str = PlayerPrefs.GetString("MessagePackCS_BASE64");
                var bytes = Convert.FromBase64String(str);
                status_out = MessagePackSerializer.Deserialize<Status>(bytes);
            }
        });

        equalCheck(status_in, status_out);



        ////////////    MessagePack-CSharp / JSON test    ////////////

        timeCheck("MessagePack-CSharp / JSON save", () => {
            for (var i = 0; i < loop_num; i += 1) {
                var str = MessagePackSerializer.ToJson(status_in);
                PlayerPrefs.SetString("MessagePackCS_JSON", str);
            }
        });

        timeCheck("MessagePack-CSharp / JSON load", () => {
            for (var i = 0; i < loop_num; i += 1) {
                var str = PlayerPrefs.GetString("MessagePackCS_JSON");
                var bytes = MessagePackSerializer.FromJson(str);
                status_out = MessagePackSerializer.Deserialize<Status>(bytes);
            }
        });

        equalCheck(status_in, status_out);

    }



    void utf8jsonInit()
    {
        Utf8Json.Resolvers.CompositeResolver.RegisterAndSetAsDefault(
            Utf8Json.Resolvers.GeneratedResolver.Instance
            //Utf8Json.Resolvers.StandardResolver.Default
        );
    }

    void messagePackInit()
    {
        MessagePack.Resolvers.CompositeResolver.RegisterAndSetAsDefault(
            MessagePack.Resolvers.GeneratedResolver.Instance,
            MessagePack.Resolvers.BuiltinResolver.Instance,
            //AttributeFormatterResolver.Instance,
            MessagePack.Resolvers.PrimitiveObjectResolver.Instance
        );
    }





    void equalCheck(Status inStatus, Status outStatus)
    {
        if (inStatus.lv != outStatus.lv) throw new Exception("Status equal check error : lv");
        if (inStatus.exp != outStatus.exp) throw new Exception("Status equal check error : exp");
        if (inStatus.hp != outStatus.hp) throw new Exception("Status equal check error : hp");
        if (inStatus.max_hp != outStatus.max_hp) throw new Exception("Status equal check error : max_hp");
        if (inStatus.atk != outStatus.atk) throw new Exception("Status equal check error : atk");
        if (inStatus.def != outStatus.def) throw new Exception("Status equal check error : def");
        if (inStatus.spd != outStatus.spd) throw new Exception("Status equal check error : spd");
        if (inStatus.equip.name != outStatus.equip.name) throw new Exception("Status equal check error : equip.name");
        if (inStatus.equip.atk != outStatus.equip.atk) throw new Exception("Status equal check error : equip.atk");
        if (inStatus.equip.def != outStatus.equip.def) throw new Exception("Status equal check error : equip.def");
        if (inStatus.equip.spd != outStatus.equip.spd) throw new Exception("Status equal check error : equip.spd");
    }

    void equalCheckToJsonUtility(StatusToJsonUtility inStatus, StatusToJsonUtility outStatus)
    {
        if (inStatus.lv != outStatus.lv) throw new Exception("Status equal check error : lv");
        if (inStatus.exp != outStatus.exp) throw new Exception("Status equal check error : exp");
        if (inStatus.hp != outStatus.hp) throw new Exception("Status equal check error : hp");
        if (inStatus.max_hp != outStatus.max_hp) throw new Exception("Status equal check error : max_hp");
        if (inStatus.atk != outStatus.atk) throw new Exception("Status equal check error : atk");
        if (inStatus.def != outStatus.def) throw new Exception("Status equal check error : def");
        if (inStatus.spd != outStatus.spd) throw new Exception("Status equal check error : spd");
        if (inStatus.equip.name != outStatus.equip.name) throw new Exception("Status equal check error : equip.name");
        if (inStatus.equip.atk != outStatus.equip.atk) throw new Exception("Status equal check error : equip.atk");
        if (inStatus.equip.def != outStatus.equip.def) throw new Exception("Status equal check error : equip.def");
        if (inStatus.equip.spd != outStatus.equip.spd) throw new Exception("Status equal check error : equip.spd");
    }



    void timeCheck(string key_name, UnityAction action)
    {
        var start_time = time();
        action();
        Debug.Log(key_name + " : " + (time() - start_time).ToString());
    }

    int time()
    {
        return DateTime.Now.Hour * 60 * 60 * 1000 +
            DateTime.Now.Minute * 60 * 1000 +
            DateTime.Now.Second * 1000 +
            DateTime.Now.Millisecond;
    }

}

テストは、各シリアライザについて、保存処理を10,000回ループして時間計測、取り出し処理を10,000回ループして時間計測、その後に保存前と取り出し後のデータの一致チェック(これは時間計測なし)を行っています。
一致チェックは仮のもので本来は1回ずつで正しい数値が取れていればいいのですが、これでもコードは通るけど結果が正しくないものの抽出に役立ちました。

あとは、ZeroFormatterでシリアライズ後のバイト列をJSON化したかったんですが、これもバイト列からのJSON化のライブラリがうまく探せてなくて無しにしました。バイト列からJSON化はシリアライザとセットじゃないと原理的に出来ない気がするので仕方ないところかなぁとも思います。

参考資料

IndexedDBとWebStorage(localStorageとsessionStorage)のざっくりまとめ - Qiita

neuecc/ZeroFormatter: Fastest C# Serializer and Infinitely Fast Deserializer for .NET, .NET Core and Unity.

neuecc/Utf8Json: Definitely Fastest and Zero Allocation JSON Serializer for C#(NET, .NET Core, Unity, Xamarin).

neuecc/MessagePack-CSharp: Extremely Fast MessagePack Serializer for C#(.NET, .NET Core, Unity, Xamarin). / msgpack.org[C#]

[Unity] JsonUtility vs ZeroFormatter vs FlatBuffers (ローカルデータの永続化) - Qiita

UnityでセーブデータをSerialize保存する 〜現状〜 - Qiita

データをJSON形式でPlayerPrefsに保存する - Qiita

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?