.NET(C#)におけるシリアライザのパフォーマンス比較

ちょっとしたログ解析(細々としたのを結合して全部で10万件ぐらい)に書き捨てコンソールアプリケーションを使って行っていたのですが(データ解析はC#でLinqでコリっと書くのが楽だと思うんです、出力するまでもなく色々な条件を書いておいてデバッガで確認とか出来るし)、実行の度に毎回読んでパースして整形して、などの初期化に時間がかかってどうにも宜しくない。そこで、データ丸ごとシリアライズしてしまえばいいんじゃね?と思い至り、とりあえずそれならバイナリが速いだろうとBinaryFormatterを使ってみたら異常に時間がかかってあらあら……。

というしょうもない用途から始まっているので状況としては非現実的な感じではありますが、標準/非標準問わず.NET上で実装されている各シリアライザで、割と巨大なオブジェクトをシリアライズ/デシリアライズした時間を計測しました。そんなヘンテコな状況のパフォーマンスなんてどうでもいー、という人は、各シリアライザの基本的な使いかたの参考にでもしてください(いやまあ、どれもnewしてSerializeメソッド呼ぶだけですが)。ソースは後で出しますが、具体的に計測に使ったオブジェクトはテスト用クラスが10万件含まれたListです。まずは結果のほうを。

Serialize BinaryFormatter
00:00:06.4701421
53MB
Serialize XmlSerializer
00:00:07.7035246
59MB
Serialize DataContractSerializer
00:00:02.1545153
149MB
Serialize DataContractSerializer Binary
00:00:01.4706517
78MB
Serialize DataContractJsonSerializer
00:00:02.6021908
47MB
Serialize DataContractJsonSerializer Binary
00:00:02.5019512
75MB
Serialize NetDataContractSerializer
00:00:09.1802584
183MB
Serialize NetDataContractSerializer Binary
00:00:08.1960399
99MB
Serialize Formatter`1 - Protocol Buffers
00:00:00.7043000
13MB
Serialize MsgPackFormatter - MessagePack
00:01:33.3844083
46MB
Serialize JsonSerializer - JSON.NET
00:00:07.4997295
38MB
Serialize JsonSerializer - JSON.NET BSON
00:00:11.7767353
44MB

Deserialize BinaryFormatter
00:00:59.1693980
Check => OK
Deserialize XmlSerializer
00:00:02.7073623
Check => NG
Deserialize DataContractSerializer
00:00:06.3459340
Check => OK
Deserialize DataContractSerializer Binary
00:00:03.5622500
Check => OK
Deserialize DataContractJsonSerializer
00:00:10.5392504
Check => OK
Deserialize DataContractJsonSerializer Binary
00:00:07.2658857
Check => OK
Deserialize NetDataContractSerializer
00:00:09.1020073
Check => OK
Deserialize NetDataContractSerializer Binary
00:00:07.4024345
Check => OK
Deserialize Formatter`1 - Protocol Buffers
00:00:00.9176016
Check => OK
Deserialize MsgPackFormatter - MessagePack
00:00:29.8292134
Check => OK
Deserialize JsonSerializer - JSON.NET
00:00:11.7517757
Check => OK
Deserialize JsonSerializer - JSON.NET BSON
00:00:12.0099519
Check => OK

対象シリアライザ、かかった時間、シリアライズ時は出力ファイルサイズ、デシリアライズ時は正しく復元できたかを表示しています。

.NET標準ライブラリからはBinaryFormatter, XmlSerializer, DataContractSerializerとそのバイナリ出力, DataContractJsonSerializerとそのバイナリ出力, NetDataContractSerializerとそのバイナリ出力。オープンソースライブラリからは、GoogleのProtocol Buffersの.NET移植protobuf-net、国産のMessagePackのC#実装Json.NETのJSONシリアライズとBSON(Mong DBで使われているバイナリ形式のJSON)のシリアライズを出力しました。

BinaryFormatter遅くね?が発端であり裏付けるように、BinaryFormatterのデシリアライズが異常に遅い。他が数秒なのに1分かかってます。テスト用データの詳細は後で述べますが、どうもObjectの配列が含まれていると遅くなるようです。テストデータにはKeyValuePair[]を含んでいるので、それが引っかかって激遅に。シリアライザする対象によって速度が変化するのは分かりますが、幾らなんでも限度を超えた速度低下。バグじゃないかしら?と言いたい。DataContractSerializerでもバイナリが吐ける昨今、もはやObsoleteにしてもいい雰囲気すら漂う。

XmlSerializerが速い・サイズ少ないという感じですがデシリアライズでCheck => NGと表記されているように、シリアライズに失敗しています。テストデータにKeyValuePair[]が含まれているのですが、それがシリアライズ出来なくて空配列になってしまったため。Dictionaryがシリアライズ出来なかったりと、XmlSerializerは割と使いにくいところがあります。今だとガイドライン的にも、XmlのAttributeの設定とか出力するXMLを細かく制御するならXmlSerializer、そうでないならDataContractSerializerのほうを推奨、とのことです。

そのDataContractSerializerはファイルサイズが嵩んでいるのが難点。属性などを付与していないため、テストデータ中の自動プロパティが <_x003C_MyProperty1_x003E_k__BackingField>hoge1</_x003C_MyProperty1_x003E_k__BackingField> といったような、とんでもなく長ったらしいタグになってしまっているのが原因。DataMember属性で名前を振ってあげればマシになりますが、今回は属性未使用で計測としました。そんなDataContractSerializerですが、バイナリXMLとして保存するとファイルサイズが縮むので気になる場合はそちらを使えばいいのかも。パフォーマンスも良くなります。

DataContractJsonSerializerは、シリアライズ・デシリアライズの速度はXMLよりも劣っていますが、ファイルサイズに関しては(JSONなので当然とはいえ)比較にならないほど小さい。バイナリXMLよりも小さくて、中々優秀のようです。しかし、バイナリ化して保存すると逆にファイルサイズが膨らむという罠が。

NetDataContractSerializerはシリアライザ作る時に型指定がいらなかったりと利便性はちょびっと○。また、循環参照が含まれていても問題なくシリアライズ出来たりと、DataContractSerializer(素の状態だと循環参照が含まれると例外)とは若干毛色が違います。といった点から言っても、BinaryFormatterの後継はこれになるのでしょう。

Protocol Buffersは爆速な上に非常に縮んで素晴らしい!さすがGoogle。ということだけじゃなく、protobuf-netの実装も良いのでしょうね。APIも非常に練られていて使いやすいし、C#でのクラスから.protoファイルの生成とかも出来て大変便利。Silverlightで使えるし、Windows Phone 7でも動かせるよう調整中、とのことでかなり気に入りました。

MsgPackFormatterはシリアライズ、デシリアライズ共にかなり時間が……。これは、MessagePackが、というよりも、実装の方でオブジェクトグラフの生成に時間がかかってるようです。

JSON.NETは中々優秀な結果を出しています。しかし、Performance ComparisonでDataContractSerializerよりも速いぜ!と謳っていたけれど、そんなことはなく。こういうのは計測する内容によって変わってくるので一概にどうこう言えないんですねー、というのを知るなど。バイナリ化で逆にサイズが膨らむのはDataContractJsonSerializerと同様。なお、JSON.NETはAPIが正直使いにくくてどうにかならないのかねー、と思うので個人的には好きじゃありません。使いやすいAPI設計ってセンスが必要ですよね……。

コード

割と長ったらしいので、分割して。追試したい方はこちらに元ソース置いておくので使ってみてください。VS2010用。ライブラリ全部揃えるのも大変だと思うので、測定を省きたい項目はMainメソッドのリスト初期化子の部分で該当するものをコメントアウトすれば省けます。

[ProtoContract]
[Serializable]
public class TestClass : IEquatable<TestClass>
{
    [ProtoMember(1)]
    public string MyProperty1 { get; set; }
    [ProtoMember(2)]
    public int MyProperty2 { get; set; }
    [ProtoMember(3)]
    public DateTime MyProperty3 { get; set; }
    [ProtoMember(4)]
    public bool MyProperty4 { get; set; }
    [ProtoMember(5)]
    public KeyValuePair<string, string>[] MyProperty5 { get; set; }

    public bool Equals(TestClass other)
    {
        return this.MyProperty1 == other.MyProperty1
            && this.MyProperty2 == other.MyProperty2
            && this.MyProperty3 == other.MyProperty3
            && this.MyProperty4 == other.MyProperty4
            && this.MyProperty5.SequenceEqual(other.MyProperty5);
    }

    public override int GetHashCode()
    {
        return this.MyProperty1.GetHashCode() + this.MyProperty2.GetHashCode();
    }
}

これがテスト用クラスの中身です。string, int, DateTime, bool, KeyValuePair<string,string>[] とそこそこ満遍なく散りばめて、それとデシリアライズがちゃんと出来たか確認出来るようIEquatable<TestClass>を実装して値比較出来るようにしています。&&や||や三項演算子の:は前置にする派。綺麗に揃う感じが好き。属性はProtocol Buffersはつけないと動かないのでProtoMemberを指定していますが、それ以外は無指定です。指定した方が縮んだりとかありそうですが、私的には無指定の状態で調べたいなあ、と思ったのでなし。最適じゃない状態からのそれぞれのシリアライズ形式への生成、書き戻しの具合を見たいと思ったので。いや、単純に各実装で使う属性を調べるのが面倒だったという手抜きな理由もありますが。

public abstract class SerializerBenchmark
{
    public static readonly List<TestClass> TestData;

    static SerializerBenchmark()
    {
        TestData = Enumerable.Range(1, 100000)
            .Select(i => new TestClass
            {
                MyProperty1 = "hoge" + i,
                MyProperty2 = i,
                MyProperty3 = new DateTime(1999, 12, 11).AddDays(i),
                MyProperty4 = i % 2 == 0,
                MyProperty5 = Enumerable.Range(1, 10)
                    .ToDictionary(x => x.ToString(), _ => i.ToString()).ToArray()
            })
            .ToList();
    }

    public static SerializerBenchmark<T> Create<T>(T serializer, Func<T, Action<Stream, Object>> serializeSelector, Func<T, Func<Stream, Object>> deserializeSelector, string optional = null)
    {
        return new SerializerBenchmark<T>(serializer, serializeSelector, deserializeSelector, optional);
    }

    public abstract void Serialize();
    public abstract void Deserialize();
}

ベンチマークで重複コードのコピペ(ストップウォッチを前後に挟んだり)を省くために抽象クラスを作りました。このList<TestClass> TestDataが実際にシリアライズ/デシリアライズに使ったデータになります。Enumerable.Range(1, 100000)のToListで10万件のリスト生成。KeyValuePairの配列は、全て長さ10で、DictionaryのToArrayで作っています。

public class SerializerBenchmark<T> : SerializerBenchmark
{
    private T serializer;
    private string name;
    Action<Stream, Object> serialize;
    Func<Stream, Object> deserialize;

    private string FileName { get { return name + ".temp"; } }

    public SerializerBenchmark(T serializer, Func<T, Action<Stream, Object>> serializeSelector, Func<T, Func<Stream, Object>> deserializeSelector, string optional = null)
    {
        this.serializer = serializer;
        this.name = serializer.GetType().Name + ((optional == null) ? "" : " " + optional);
        this.serialize = serializeSelector(serializer);
        this.deserialize = deserializeSelector(serializer);
    }

    private void Bench(string label, Action action)
    {
        GC.Collect();
        Console.WriteLine(label + " " + name);
        var sw = Stopwatch.StartNew();
        action();
        Console.WriteLine(sw.Elapsed);
    }

    private void OpenAndExecute(string path, Action<FileStream> action)
    {
        using (var fs = File.Open(path, FileMode.OpenOrCreate))
        {
            action(fs);
        }
    }

    public override void Serialize()
    {
        Bench("Serialize", () => OpenAndExecute(FileName, fs => serialize(fs, TestData)));
        Console.WriteLine(new FileInfo(FileName).Length / 1024 / 1024 + "MB");
    }

    public override void Deserialize()
    {
        List<TestClass> data = null;
        Bench("Deserialize", () => OpenAndExecute(FileName, fs => data = (List<TestClass>)deserialize(fs)));
        Console.Write("Check => ");
        Console.WriteLine(TestData.SequenceEqual(data) ? "OK" : "NG");
    }
}

こちらがベンチマークの中身。処理を共通化したかったのですが、各シリアライザがIFormatter(Serialize,Deserializeメソッドを持つインターフェイス)を実装している、なんてことは全くなくて共通化しようがなくてうぎゃー。せめてメソッド名が全てSerializeならdynamic使うという最終手段もあるけれど、DataContractSerializerのメソッド名はWriteObjectだし、ダメだこりゃ。

で、気づいたのがSerializeはAction<Stream, Object>、DeserializeはFunc<Stream, Object>だということ。というわけで、各メソッドそのものをコンストラクタで渡してあげる形にすることで共通化できた。めでたしめでたし。もはやFuncやActionのない世界は考えられませんね!え、Javaのことですか知りません。

Deserialize時には、正しくデシリアライズ出来たかのチェックを仕込んでいます。データがListなのでLinqのSequenceEqualで元データと値比較。Listの要素であるTestClassもIEquatableなので、全部値比較で確認。

static class Program
{
    static void Main(string[] args)
    {
        var bench = new List<SerializerBenchmark>
        {
            SerializerBenchmark.Create(new BinaryFormatter(), x => x.Serialize, x => x.Deserialize),
            SerializerBenchmark.Create(new XmlSerializer(typeof(List<TestClass>)), x => x.Serialize, x => x.Deserialize),
            SerializerBenchmark.Create(new DataContractSerializer(typeof(List<TestClass>)), x => x.WriteObject, x => x.ReadObject),
            SerializerBenchmark.Create(new DataContractSerializer(typeof(List<TestClass>)),
                x => (s, data) => XmlDictionaryWriter.CreateBinaryWriter(s).Using(xw => x.WriteObject(xw, data)),
                x => s => XmlDictionaryReader.CreateBinaryReader(s,XmlDictionaryReaderQuotas.Max).Using(xr => x.ReadObject(xr)),
                "Binary"),
            SerializerBenchmark.Create(new DataContractJsonSerializer(typeof(List<TestClass>)), x => x.WriteObject, x => x.ReadObject),
            SerializerBenchmark.Create(new DataContractJsonSerializer(typeof(List<TestClass>)),
                x => (s, data) => XmlDictionaryWriter.CreateBinaryWriter(s).Using(xw => x.WriteObject(xw, data)),
                x => s => XmlDictionaryReader.CreateBinaryReader(s,XmlDictionaryReaderQuotas.Max).Using(xr => x.ReadObject(xr)),
                "Binary"),
            SerializerBenchmark.Create(new NetDataContractSerializer(), x => x.Serialize, x => x.Deserialize),
            SerializerBenchmark.Create(new NetDataContractSerializer(),
                x => (s, data) => XmlDictionaryWriter.CreateBinaryWriter(s).Using(xw => x.WriteObject(xw, data)),
                x => s => XmlDictionaryReader.CreateBinaryReader(s,XmlDictionaryReaderQuotas.Max).Using(xr => x.ReadObject(xr)),
                "Binary"),
            SerializerBenchmark.Create(ProtoBuf.Serializer.CreateFormatter<List<TestClass>>(), x => x.Serialize, x => x.Deserialize, "- Protocol Buffers"),
            SerializerBenchmark.Create(new MsgPackFormatter(), x => x.Serialize, x => x.Deserialize, "- MessagePack"),
            SerializerBenchmark.Create(new JsonSerializer(),
                x => (s, data) => new StreamWriter(s).Using(sw => new JsonTextWriter(sw).Using(tw=> x.Serialize(tw, data))),
                x => s => new StreamReader(s).Using(sr => new JsonTextReader(sr).Using(tr=> x.Deserialize<List<TestClass>>(tr))),
                "- JSON.NET"),
            SerializerBenchmark.Create(new JsonSerializer(),
                x => (s, data) => new BsonWriter(s).Using(bw => x.Serialize(bw, data)),
                x => s => new BsonReader(s){ReadRootValueAsArray = true}.Using(br => x.Deserialize<List<TestClass>>(br)),
                "- JSON.NET BSON")
        };

        bench.ForEach(b => b.Serialize());
        Console.WriteLine();
        bench.ForEach(b => b.Deserialize());

        Console.ReadKey();
    }

    // IDisposable extensions

    static void Using<T>(this T disposable, Action<T> action) where T : IDisposable
    {
        using (disposable) action(disposable);
    }

    static TR Using<T, TR>(this T disposable, Func<T, TR> func) where T : IDisposable
    {
        using (disposable) return func(disposable);
    }
}

Listに突っ込んで、まとめてForEachで計測。でも何だかゴチャゴチャしててキタナイですね……。Listの最初の3つまでは良いんです。それぞれ一行で。SerializeとDeserialize登録するだけで。その次から想定外でして……。そう、Serializeは決して必ずしもAction<Stream, Object>じゃあなかった!例えばDataContractSerializerでバイナリXML化を行うには、WriteObjectにStreamじゃなくてXmlDictionaryWriterを渡さなきゃいけない。と、いうわけで、そういった特別対応が必要なメソッドには入れ子のラムダ式を作って処理しています。そのせいでゴチャゴチャと。もはや可読性の欠片もない。がくり。入れ子のラムダが出てくると読むのシンドイんですよね、可能な限り避けたいとは思ってるんですが……。

Using拡張メソッドは、using構文使うと式じゃなくて文になってラムダ式で書きづらくてウザいので無理やり式にするためのシロモノ。そのせいで入れ子が更に入れ子になって可読性落としてる気がする。何事もやりすぎはいけない。

まとめ

DataContractSerializer良いよね。XmlDictionaryWriter.CreateBinaryWriter突っ込めばバイナリ化も出来るし。あとprotobuf-netはこうして数字見ると本当に凄いね。と、いったことことぐらいしか言うことがなく。

それとは関係あったりなかったりで、Twitter素晴らしい。毎回思うけれど、やっぱTwitterは素晴らしい。もともとBinaryFormatterなんか遅くね?とTwitterに愚痴ったところからスタートして、色々な人にアドバイスを受けて最終的にこんな記事(斜め上なだけな気はする)になったわけで、Twitterなかったら「遅くね?以上終了」で終わってた。

私がTwitterをはじめたのはTwitterはじめましたとかいう記事で確認出来るところでは2008/01/04(CoD4について書いてますけど、CoD4のシングルはあんま好きじゃなかった、でもその後マルチプレイヤーにはまったので結局MW2買ってますね!)。しかも、この後も当分は何もポストしないままでいて、同年9月の三度目の正直とかいう記事でTwitter→はてダへの転送ツールを作ってからようやく始めてました。コードの一部が載ってますけど、postList.Addとかいうのが初々しいなあ。今だとforeach使ったら負け、.ToList()だろ常識的に考えて。ですね。あと、TrimStartの使い方を間違えてるという。1年半前の私のド素人っぷりが伺えて面白い。ていうか、考えてみると物凄く成長しましたね……。

そして今ではすっかりTwitter中毒です。あらあらうふふ。

アドバイス受けてばかりというのもアレですので、私でも応えられそうなのがTLに流れてきたり検索(キーワード「Linq」を割と頻繁に眺めてる)にかかった時は答えるようにしています。いや、あんましてないかも。積極的に答えるようにしていきたいなあ、と。そんなわけで、@neueccでTwitterやってるので気が向いたらフォローしてやってください。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive