C#
JSON
ビッグデータ
Jil
Utf8Json

ギガバイトクラスのJSONをデシリアライズするのにUtf8Jsonを使ってみる

linq.jsやZeroFormatterなどステッキーなライブラリを作っているneuecc先生の新作JSONシリアライザーのUtf8Jsonを試してみました。

Utf8Json - Fast JSON Serializer for C#
https://github.com/neuecc/Utf8Json

”Definitely Fastest”(明らかに最速)とのことなので、最速の恩恵を受けそうな例で実践します。拙作で恐縮ですが、C#で仮想通貨取引所のリアルタイムレートを記録するプログラムを作ってみたを1週間実行したところ、合計1GBを軽く超えるJSONファイルが生成できました。それをデシリアライズするプログラムを作ります。そして、既存のJSONシリアライザーとの比較を行います。

環境:VisualStudio2017 Community / .NET 4.6.0

ライブラリのバージョン

  • DataContractJsonSerializer : System.Runtime.Serialization 4.0.0.0
  • Json.NET : v10.0.3 (2017/6/8)
  • ServiceStack.Text : v5.0.2 (2018/1/2)
  • Jil : v2.15.4 (2017/8/24)
  • DynamicJson : v1.2.0 (2011/9/22)
  • Utf8Json : v1.3.6.1 (2018/1/23)

関連記事:
.NETのJSONシリアライザの速度比較
https://qiita.com/t_takahari/items/6855dfe78071bb5eaef6

対象データ

仮想通貨取引所のストリーミングAPIから配信される、ビットコイン円(btc-jpy)の取引(板情報+トランザクションデータ)。おおよそ1秒に1回~2回以下のようなJSONが送られてきます。JSON Viewerで見ると、次のような構造であることがわかります。
utf8json1.png
1つのJSONを1つのテキストファイルとして保存します。1つのテキストファイルはおよそ4KBです。

last_price.priceとtimestampを抽出するプログラムを作ります。これらのテキストファイルは一定時間(設定では1分、5分)で1つのZipアーカイブに保存されています。途中でこの設定値変えてしまったので1日あたりのアーカイブ数はいい加減です。1/18~1/23の6日分のデータ用意します。エクスプローラーで見ると次のようになります。
utf8json2.png

Zipをすべて展開たところ、次のようになりました。
utf8json3.png

35.5万個のJSONが無圧縮で1.43GB(ディスク上は2.70GB)です。32bitプロセスのメモリ制限があるので、まずこれをList<string>としてすべてメモリに落とし込んでみます

Zip→txtList
        var txtList = new List<string>();

        var zips = Enumerable.Range(18, 6).Select(x => "btc_jpy\\2018\\201801" + x)
            .SelectMany(x => Directory.GetFileSystemEntries(x, "*.zip", SearchOption.AllDirectories));
        var sw = new Stopwatch();
        sw.Start();
        foreach(var z in zips)
        {

            using (var archive = ZipFile.OpenRead(z))
            {
                foreach (var entry in archive.Entries)
                {
                    using (var sr = new StreamReader(entry.Open()))
                    {
                        txtList.Add(sr.ReadToEnd());
                    }
                }
            }
        }
        sw.Stop();
        Console.WriteLine("Zip load : " + sw.Elapsed);
        Console.WriteLine("Memory : " + Environment.WorkingSet.ToString("N0") + "bytes");

展開後のtxtファイルではなく、展開前のZipから直接読ませていますが、こっちのほうが速いからです(後述)。実行結果は次のようになります。

Zip load : 00:00:49.7493200
Memory : 3,179,085,824bytes

メモリ使用量は3.0GBです。6日ではなく、7日分読み込ませると「System.OutOfMemoryException」が出ます。なので、これが32bitプロセスの限界とみなしてもよいと思います。64bitでビルドすればほぼ際限なくいけますが、JSONデシリアライズのパフォーマンスを図るには32bitのMAXで十分でしょう。たかだか1~2GBでビッグデータと言い張る気は毛頭ないですが、ビッグデータに片足の親指の爪先ぐらい突っ込んだぐらいのサイズはあります。

また、一気にtxtListに読み込ませるのではなく、Zipアーカイブ単位で読み込ませ、その都度デシリアライズしていくと(実践的にはこちらを使います)、メモリ効率はいいですが、Zipの展開やファイルの読み込みにかかる処理時間と、JSONをパースするのにかかる処理時間を切り離しできなくなるので、ここでは一気にメモリを読ませる方法を使います。
txtListから読み込ませても、正確には、txtListから文字列を読み込ませる処理時間を切り離していないのですが、同じメモリ内での操作なので無視できるぐらい小さいとします。

Zip→txtListではなく、Zip→Txt→txtListと読み込ませると、Zip→Txtの処理を事前に行っておいてもZip→txtListの場合より遅くなります。試してみます。decompressフォルダにはbtc_jpyフォルダ以下のZipアーカイブがそのままの形で展開されているものとします。

Txt→txtList
        var txtList = new List<string>();

        var sw = new Stopwatch();
        var dirs = Enumerable.Range(18, 6).Select(x => "decompress\\2018\\201801" + x);

        sw.Start();
        foreach (var d in dirs)
        {
            foreach(var txt in Directory.GetFileSystemEntries(d, "*.txt", SearchOption.AllDirectories))
            {
                using(var fs = new StreamReader(txt))
                txtList.Add(fs.ReadToEnd());
            }
        }
        sw.Stop();
        Console.WriteLine("Text load : " + sw.Elapsed);
        Console.WriteLine("Memory : " + Environment.WorkingSet.ToString("N0") + "bytes");

50秒対26分で、明らかに遅いです。CPUパワーは全然使わないんですけどね。ファイルのオーバーヘッドぱない。

Text load : 00:26:46.2992704
Memory : 3,190,595,584bytes

とりあえずZipを使いましたが、この手の使い方をする場合、圧縮はパワー要しても解凍が軽いタイプの圧縮アルゴリズムを選ぶとよさそうな気がします。

JSONシリアライザー一覧

以下のJSONシリアライザーを比較します。

  1. DataContractJsonSerializer(.NET標準)
  2. Json.NET(MIT)
  3. ServiceStack.TextAGPLv3 FOSS License Exception
  4. Jil(MIT)
  5. DynamicJSON(Utf8Jsonと作者同じ / Ms-PL
  6. Utf8Json(New!! / MIT)

1番目以外はdynamic型が使えるので、型を指定する場合、型を指定せずdynamicでデシリアライズする場合をそれぞれを見ます。
dynamicの場合、型を指定する場合と同様の返し方をすると少し不公平なので(キャストが多い分当然遅くなる)、必要なtimestampとlast_price.priceのみに絞ってTupleで返すようにします。今回の例では、dynamicでデシリアライズする変数の数が、型を指定する場合とそこまで変わらないので、基本的に型を指定したほうが速いです。ただし、文字と数字が混在した配列など不定形なJSONをデシリアライズする場合は、dynamicを使ったほうが都合がいいことがあるので、必要に応じて使い分けるのがいいかと思います。

型を指定する場合のクラスをDataとして次のようにします。

Data.cs
public class Data
{
    public LastPrice last_price { get; set; }
    public string timestamp { get; set; }

    public class LastPrice
    {
        public string action { get; set; }
        public double price { get; set; }
    }
}

ライブラリの数が多いので、ライブラリごとに型を指定する場合、dynamicの場合とラッパーメソッドを作ります(普段はこんなことしなくていいです)。

1.DataContractJsonSerializer

typeofで指定してStreamからしか読めないというのはちょっとなーという感

using System.IO;
using System.Runtime.Serialization.Json;

public static class DataContractJsonSerialize
{
    public static Data Deserialize(string jsonStr)
    {
        var serializer = new DataContractJsonSerializer(typeof(Data));
        using (var ms = new MemoryStream(Encoding.UTF8.GetBytes(jsonStr)))
        {
            return (Data)serializer.ReadObject(ms);
        }
    }
}

2.Json.NET

一番有名なライブラリ。dynamicの場合が面白い書き方

using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

public static class JsonNet
{
    public static Data Deserialize(string jsonStr)
    {
        return JsonConvert.DeserializeObject<Data>(jsonStr);
    }

    public static (string, double) DeserializeDynamic(string jsonStr)
    {
        dynamic dyn = JObject.Parse(jsonStr);//Newtonsoft.Json.Linq
        return ((string)dyn.timestamp, (double)dyn.last_price.price);
    }
}

3.ServiceStack.Text

dynamicの場合、内部で文字列になるのが少しトリッキー。(double)とかキャストすると例外になる。

using ServiceStack;
using ServiceStack.Text;

public static class ServiceStackJson
{
    public static Data Deserialize(string jsonStr)
    {
        return JsonSerializer.DeserializeFromString<Data>(jsonStr);//ServiceStack.Text.JsonSerializer
    }

    public static (string, string) DeserializeDynamic(string jsonStr)
    {
        var dyn = DynamicJson.Deserialize(jsonStr);//ServiceStack.DynamicJson
        return ((string)dyn.timestamp, (string)dyn.last_price.price);//小数でも文字列として記録される
    }
}

4.Jil

直感的でわかりやすい

using Jil;

public static class JilJson
{
    public static Data Deserialize(string jsonStr)
    {
        return JSON.Deserialize<Data>(jsonStr);
    }

    public static (string, double) DeserializeDynamic(string jsonStr)
    {
        var dyn = JSON.DeserializeDynamic(jsonStr);
        return ((string)dyn.timestamp, (double)dyn.last_price.price);
    }
}

5.DynamicJSON

"Dynamic"JSONなので、型指定の場合はdynamicの変数をキャストしてデシリアライズするというピーキーな仕様。プロジェクトにDynamicJSON.csが追加されるのが特徴。個人的には昔だいぶお世話になりました。

using Codeplex.Data;

public static class DynamicJsonDeserialize
{
    public static Data Deserialize(string jsonStr)
    {
        return (Data)DynamicJson.Parse(jsonStr);
    }

    public static (string, double) DeserializeDynamic(string jsonStr)
    {
        var dyn = DynamicJson.Parse(jsonStr);
        return ((string)dyn.timestamp, (double)dyn.last_price.price);
    }
}

6.Utf8Json

dynamicの場合はインデクサを使います(.変数名は例外発生)。今後のアップデートで変わるかもしれません。

using Utf8Json;

public static class Utf8Json
{
    public static Data Deserialize(string strJson)
    {
        return JsonSerializer.Deserialize<Data>(strJson);
    }

    public static (string, double) DeserializeDynamic(string strJson)
    {
        var dyn = JsonSerializer.Deserialize<dynamic>(strJson);
        return ((string)dyn["timestamp"], (double)dyn["last_price"]["price"]);//インデクサを使う
    }
}

デシリアライズ時間の比較

1.逐次処理

以下のコードで比較します。Zip→txtListの続きからです。

        //DataContractJsonSerializer
        sw.Restart();
        foreach (var str in txtList)
        {
            DataContractJsonSerialize.Deserialize(str);
        }
        sw.Stop();
        Console.WriteLine("DataContractJsonSerializer\t" + sw.Elapsed);

        //JsonNet
        sw.Restart();
        foreach (var str in txtList)
        {
            JsonNet.Deserialize(str);
        }
        sw.Stop();
        Console.WriteLine("JsonNet\t" + sw.Elapsed);
        sw.Restart();
        foreach (var str in txtList)
        {
            JsonNet.DeserializeDynamic(str);
        }
        sw.Stop();
        Console.WriteLine("JsonNet(dynamic)\t" + sw.Elapsed);

        //ServiceStack.Text
        sw.Restart();
        foreach (var str in txtList)
        {
            ServiceStackJson.Deserialize(str);
        }
        sw.Stop();
        Console.WriteLine("ServiceStack.Text\t" + sw.Elapsed);
        sw.Restart();
        foreach (var str in txtList)
        {
            ServiceStackJson.DeserializeDynamic(str);
        }
        sw.Stop();
        Console.WriteLine("ServiceStack.Text(dynamic)\t" + sw.Elapsed);

        //Jil
        sw.Restart();
        foreach (var str in txtList)
        {
            JilJson.Deserialize(str);
        }
        sw.Stop();
        Console.WriteLine("Jil\t" + sw.Elapsed);
        sw.Restart();
        foreach (var str in txtList)
        {
            JilJson.DeserializeDynamic(str);
        }
        sw.Stop();
        Console.WriteLine("Jil(dynamic)\t" + sw.Elapsed);

        //DynamicJSON
        sw.Restart();
        foreach (var str in txtList)
        {
            DynamicJsonDeserialize.Deserialize(str);
        }
        sw.Stop();
        Console.WriteLine("DynamicJSON\t" + sw.Elapsed);
        sw.Restart();
        foreach (var str in txtList)
        {
            DynamicJsonDeserialize.DeserializeDynamic(str);
        }
        sw.Stop();
        Console.WriteLine("DynamicJSON(dynamic)\t" + sw.Elapsed);


        //Utf8Json
        sw.Restart();
        foreach (var str in txtList)
        {
            Utf8Json.Deserialize(str);
        }
        sw.Stop();
        Console.WriteLine("Utf8Json\t" + sw.Elapsed);
        sw.Restart();
        foreach (var str in txtList)
        {
            Utf8Json.DeserializeDynamic(str);
        }
        sw.Stop();
        Console.WriteLine("Utf8Json(dynamic)\t" + sw.Elapsed);

本当はtimestampとlast_priceを記録するはずでしたが、純粋にパフォーマンスを見たかったので端折っています。これを5回繰り返します。結果は次の通り。

逐次処理 1回目 2回目 3回目 4回目 5回目 平均 最大 最小
Zip load 00:50.981 00:50.870 00:50.253 00:50.896 00:50.770 00:50.754 00:50.981 00:50.253
DataContractJsonSerializer 01:13.843 01:15.788 01:13.863 01:13.945 01:12.715 01:14.031 01:15.788 01:12.715
JsonNet 00:43.019 00:43.401 00:42.688 00:43.640 00:42.800 00:43.110 00:43.640 00:42.688
ServiceStack.Text 00:06.125 00:05.928 00:05.883 00:06.037 00:05.946 00:05.984 00:06.125 00:05.883
Jil 00:06.825 00:06.749 00:07.059 00:06.951 00:06.858 00:06.888 00:07.059 00:06.749
DynamicJSON 02:21.014 02:20.810 02:21.179 02:20.486 02:17.193 02:20.136 02:21.179 02:17.193
Utf8Json 00:13.312 00:13.498 00:13.459 00:13.685 00:14.240 00:13.639 00:14.240 00:13.312
JsonNet(dynamic) 01:40.964 01:41.524 01:40.885 01:41.389 01:44.052 01:41.763 01:44.052 01:40.885
ServiceStack.Text(dynamic) 00:15.938 00:15.915 00:15.838 00:15.799 00:15.865 00:15.871 00:15.938 00:15.799
Jil(dynamic) 01:03.895 01:03.903 01:03.662 01:05.012 01:03.606 01:04.016 01:05.012 01:03.606
DynamicJSON(dynamic) 02:14.418 02:18.594 02:18.907 02:15.051 02:16.810 02:16.756 02:18.907 02:14.418
Utf8Json(dynamic) 00:53.832 00:54.887 00:54.959 00:55.590 00:53.505 00:54.555 00:55.590 00:53.505

この例ではUtf8Jsonが最速にはなりませんでした型を指定した場合、ServiceStackが6秒弱で最速、Jilも同じくらい速く7秒弱、そこから倍の6秒程度遅れてUtf8Jsonが13.6秒、そこから30秒程度とだいぶ遅れてJson.Netが43秒。.Net標準のDataContractJsonSerializerはJson.Netからさらに30秒程度遅れて1分14秒。DynamicJSONは一番遅く、2分オーバー。ただ、他のライブラリがdynamicでデシリアライズしたときに軒並み遅くなっているのに対し(おそらく全部のパラメーターを内部で一度パースしているのかと)、DynamicJSONはほぼ変わらないどころか、dynamicのほうが速いまでもあるので、型指定のクラス側で全部パースする場合はそこまで見劣りしないのかと思われます。
比較用にZipの読み込み時間を置いておきましたが、JSONシリアライザーの選択によってZipの読み込み1倍~2倍分の処理時間の差が出ます。本当はZip部分を高速化したかったけどそれはまた別な投稿で。
※追記:いろいろ試してみましたが、MessagePack C#を使うのが良さそうです。気力があれば書きます。

dynamicの場合はServiceStackの15秒以外はだいたい1分オーバー。かろうじてUtf8Jsonが55秒と1分割っているぐらいです。こう見るとServiceStack一強のように見えますが、ServiceStackの場合はdynamicの場合doubleが文字列で記録されているので、キャストに苦労するかもしれません。ServiceStackがdynamicの場合に明らかに速いのは、利便性を犠牲にして高速化しているからかもしれません。

この例の場合はStringの内部エンコードがUTF-16であるため、Stringのまま読み込ませているとUTF-8へ余計なエンコード変換が入るのがUtf8Jsonにとっていけないのかもしれません。そこでUtf8Jsonの場合のみ、stringではなくbyte[]を読ませるように改良してみます。

Byte[]で記録
        var byteList = new List<byte[]>();
        var sw = new Stopwatch();
        sw.Start();
        foreach(var z in zips)
        {

            using (var archive = ZipFile.OpenRead(z))
            {
                foreach (var entry in archive.Entries)
                {
                    using (var sr = new StreamReader(entry.Open()))
                    {
                        //txtList.Add(sr.ReadToEnd());
                        using (var ms = new MemoryStream())
                        {
                            sr.BaseStream.CopyTo(ms);
                            byteList.Add(ms.ToArray());
                        }
                    }
                }
            }
        }
        sw.Stop();
        Console.WriteLine("Zip load\t" + sw.Elapsed);
        Console.WriteLine("Memory : " + Environment.WorkingSet.ToString("N0") + "bytes");

        sw.Restart();
        foreach (var bytes in byteList)
        {
            var x = Utf8Json.DeserializeBytes(bytes);
        }
        sw.Stop();
        Console.WriteLine("Utf8Json(Bytes)\t" + sw.Elapsed);
public static class Utf8Json
{
    public static Data DeserializeBytes(byte[] byteJson)
    {
        return JsonSerializer.Deserialize<Data>(byteJson);
    }
}

これを3回ほど実行してみました。

Zip load        00:00:37.8030819
Memory : 1,605,480,448bytes
Utf8Json(Bytes) 00:00:08.7113992

Zip load        00:00:37.7465432
Memory : 1,606,115,328bytes
Utf8Json(Bytes) 00:00:08.6711459

Zip load        00:00:38.4523601
Memory : 1,606,238,208bytes
Utf8Json(Bytes) 00:00:08.7258462

キャッシュ方式をstring→byte[]に変えたところ、Zipの展開時間が50秒→38秒と12秒ほど短縮され(24%減)、メモリ使用量が3.19Gbytes→1.61Gbytesと半分になり、Utf8Jsonのデシリアライズ時間が13.6秒→8.7秒と5秒ほど短縮され(36%減)ました。これでもまだ、stringの場合のServiceStackとJilのケースを抜くことができなかったのですが、特にメモリ減少の効果が大きそうです。JSONだとUTF-16までエンコードを拡大するメリットが無い(JSONの規則上エスケープされる)ので、UTF-8 byte[]で読み込んでしまうという作戦はかなり有用かもしれません。
ちなみにJilもServiceStackもTextReaderやStreamを引数として渡すことはできるものの、byte[]を直接渡すことができるのはざっと見たところUtf8Jsonだけでした。細かいチューニングを気にする場合、トータルで見たときにUtf8Jsonが優位に立てる可能性は十分にあります。

2.並列処理

1.のstringの例を並列化してみます。Parallel.ForEachを使うだけです。ただし、並列化する場合は読み込む順番の保証がされなくなるので、結果が時系列で欲しい場合は最終的にソートする、スレッドセーフなコレクションを使って記録するなどの工夫が必要になります。Utf8Jsonの場合はこうします(他も同様なので省略します)。

並列処理
        var option = new ParallelOptions { MaxDegreeOfParallelism = 4 };
        Parallel.ForEach(txtList, option, str =>
        {
            Utf8Json.Deserialize(str);
        });

MaxDegreeOfParallelismを指定しないときりがなく並列していって死ぬので、スレッド数は4に制限しました。結果は次のようになりました。

並列処理 1回目 2回目 3回目 4回目 5回目 平均 最大 最小
DataContractJsonSerializer 00:28.103 00:26.502 00:27.138 00:28.102 00:27.326 00:27.434 00:28.103 00:26.502
JsonNet 00:15.382 00:15.428 00:15.336 00:16.527 00:16.030 00:15.741 00:16.527 00:15.336
ServiceStack.Text 00:02.466 00:02.779 00:02.317 00:02.115 00:02.460 00:02.427 00:02.779 00:02.115
Jil 00:02.474 00:02.395 00:02.661 00:02.825 00:02.686 00:02.608 00:02.825 00:02.395
DynamicJSON 00:49.005 00:50.380 00:47.114 00:52.129 00:48.747 00:49.475 00:52.129 00:47.114
Utf8Json 00:05.365 00:05.307 00:04.810 00:05.250 00:05.298 00:05.206 00:05.365 00:04.810
JsonNet(dynamic) 00:35.265 00:34.807 00:35.518 00:38.748 00:35.058 00:35.879 00:38.748 00:34.807
ServiceStack.Text(dynamic) 00:05.630 00:05.543 00:05.617 00:05.714 00:05.582 00:05.617 00:05.714 00:05.543
Jil(dynamic) 00:21.534 00:21.296 00:21.354 00:23.030 00:20.929 00:21.629 00:23.030 00:20.929
DynamicJSON(dynamic) 00:47.160 00:49.797 00:46.207 00:46.603 00:46.825 00:47.318 00:49.797 00:46.207
Utf8Json(dynamic) 00:20.059 00:19.494 00:19.764 00:19.649 00:20.118 00:19.817 00:20.118 00:19.494

試行間でそこまでぶれがないので、逐次処理の平均と並列処理の平均を比較してみます。

ライブラリ 逐次平均 並列平均 逐次÷並列 逐次-並列
DataContractJsonSerializer 01:14.031 00:27.434 2.698 00:46.597
JsonNet 00:43.110 00:15.741 2.739 00:27.369
ServiceStack.Text 00:05.984 00:02.427 2.465 00:03.556
Jil 00:06.888 00:02.608 2.641 00:04.280
DynamicJSON 02:20.136 00:49.475 2.832 01:30.661
Utf8Json 00:13.639 00:05.206 2.620 00:08.433
JsonNet(dynamic) 01:41.763 00:35.879 2.836 01:05.884
ServiceStack.Text(dynamic) 00:15.871 00:05.617 2.825 00:10.254
Jil(dynamic) 01:04.016 00:21.629 2.960 00:42.387
DynamicJSON(dynamic) 02:16.756 00:47.318 2.890 01:29.438
Utf8Json(dynamic) 00:54.555 00:19.817 2.753 00:34.738

逐次÷並列を時間比、逐次-並列を時間差とします。比のほうは2.45~3.0の範囲内です。4並列にしても額面通り速度が4倍になることはないので、妥当といえるでしょう。差が大きいケースほど比も大きくなることが多く、これは極端に速いケースでは、JSONデシリアライズ以外の関係ない処理(foreachや文字列の読み込み)が全体に占めるウェイトが高くなるからだと思われます。逆に差が大きいのに比でそこまで伸びていないケースは、もともとの計算負荷が高いゆえに並列化してもCPUリソース食い尽くしやすく、あまり好ましくないと言えるかもしれません。
utf8json4.png
ここで回帰直線から大きく上に飛び出いている例は、DataContractJsonSerializerの2例と、DynamicJSONの2例です。この2つは単純に処理時間を見ても選びづらいです。

3.逐次処理+Streamを引数にする

今までデシリアライズの際にstringやbyte[]を引数として渡していましたが、Zipアーカイブの各エントリーのStreamを渡す場合を考えます。したがってtxtListは必要なくなり、エントリーからダイレクトにデシリアライズするので、メモリ効率は遥かに良くなります。より実践的な方法です。これまで特に良好だった、ServiceStack, Jil, Utf8Jsonの3つについて比較をします。

        var sw = new Stopwatch();
        sw.Start();
        foreach (var z in zips)
        {

            using (var archive = ZipFile.OpenRead(z))
            {
                foreach (var entry in archive.Entries)
                {
                    using (var sr = new StreamReader(entry.Open()))
                    {
                        ServiceStack.Text.JsonSerializer.DeserializeFromStream<Data>(sr.BaseStream);//ServiceStackの場合
                        //Jil.JSON.Deserialize<Data>(sr);//Jilの場合
                        //Utf8Json.JsonSerializer.Deserialize<Data>(sr.BaseStream);//Utf8Jsonの場合
                    }
                }
            }
        }
        sw.Stop();
        Console.WriteLine("ServiceStack(stream)\t" + sw.Elapsed);

各5回やった場合の結果は以下の通りです。

逐次 / stream 1回目 2回目 3回目 4回目 5回目 平均 最大 最小
ServiceStack(stream) 00:40.706 00:41.131 00:40.793 00:40.724 00:40.230 00:40.717 00:41.131 00:40.230
Jil(stream) 00:49.434 00:49.169 00:49.786 00:49.728 00:49.609 00:49.545 00:49.786 00:49.169
Utf8Json(stream) 00:38.752 00:39.733 00:39.731 00:39.715 00:39.449 00:39.476 00:39.733 00:38.752

この例では、Utf8Jsonが(無事?)最速になりました。ほとんど処理時間にブレがないので、引数をstringにする場合とstreamにする場合で処理時間の平均値を比較します。

逐次処理 平均(stream) 平均(string) zip load + string - stream
ServiceStack 00:40.717 00:05.984 00:16.021
Jil 00:49.545 00:06.888 00:08.097
Utf8Json 00:39.476 00:13.639 00:24.917

「zip load」はStringとしてZipを読み込んだ場合のZipをすべて読み込む時間の平均値50.754秒を足しています。「zip load + string - stream」はこれにstringを引数にJSONをデシリアライズしたときの平均処理時間を足し、streamを引数にJSONをデシリアライズした平均処理時間を引いたものです。ここでの処理の差はstream→stringの変換をするかどうか、txtListにstringを格納するかどうか(zip load, stringの場合は格納しているが、streamを直接渡した場合は格納する必要がない)、txtListをforeachで回すかどうか、とすべて定数項で表されそうな処理ですが、ライブラリによってこの差が一定ではないので、引数がstringの場合、streamの場合でそれぞれデシリアライズに最適化が入っていると思われます。Jilはstringに強く、Utf8Jsonはstreamに強いことがわかります。ServiceStackは傾向としてはUtf8Jsonに近そうです。

※追記(2/2)
entry.Open()で直接Stream読ませればいいところをStreamReaderをかませるという間抜けをしてしまったので、ダイレクトにEntryから読み込ませて再実験しました。JilについてはTextReader(StreamReaderが継承している抽象クラス)が引数なので除外し、ServiceStackとUtf8Jsonの比較を行います。

        sw.Start();
        foreach (var z in zips)
        {
            using (var archive = ZipFile.OpenRead(z))
            {
                foreach (var entry in archive.Entries)
                {
                    ServiceStack.Text.JsonSerializer.DeserializeFromStream<Data>(entry.Open());
                    //Utf8Json.JsonSerializer.Deserialize<Data>(entry.Open());                    
                }
            }
        }
        sw.Stop();
        Console.WriteLine("ServiceStack(Direct)" + sw.Elapsed);

        sw.Restart();
        foreach (var z in zips)
        {
            using (var archive = ZipFile.OpenRead(z))
            {
                foreach (var entry in archive.Entries)
                {
                    using (var sr = new StreamReader(entry.Open()))
                    {
                        ServiceStack.Text.JsonSerializer.DeserializeFromStream<Data>(sr.BaseStream); 
                        //Utf8Json.JsonSerializer.Deserialize<Data>(sr.BaseStream);                                                                                                                               //Utf8Json.JsonSerializer.Deserialize<Data>(sr.BaseStream);//Utf8Jsonの場合
                    }
                }
            }
        }
        sw.Stop();
        Console.WriteLine("ServiceStack(StreamReader)" + sw.Elapsed);
Direct vs StreamReader 平均 最大 最小 1回目 2回目 3回目 4回目 5回目
ServiceStack(Direct) 00:37.564 00:38.108 00:36.921 00:38.108 00:36.921 00:37.532 00:38.093 00:37.166
ServiceStack(StreamReader) 00:37.714 00:38.675 00:36.956 00:38.675 00:36.956 00:37.402 00:37.501 00:38.037
Utf8Json(Direct) 00:36.915 00:37.461 00:36.234 00:37.189 00:36.234 00:36.773 00:36.919 00:37.461
Utf8Json(StreamReader) 00:36.590 00:37.017 00:36.231 00:36.231 00:36.253 00:36.846 00:36.603 00:37.017

初回にライブラリのロードが入ってるので、逆に遅くなっているかもしれません。基本的にStreamReaderを噛ませたほうが速くなるということはないはずです。この場合は0.1~0.5秒程度、entry.Open()したほうが速いといえるでしょう。(byte[]を変数で取る目的でもなく、StreamReader噛ませてたのがタダの間抜けなだけ)

コメントで指摘してくださった方、ありがとうございました。

まとめ

以上、ギガバイトクラスのJSONを読み込んでデシリアライズのパフォーマンスを計測しました。この例ではUtf8Jsonがぶっちぎりで強いという結論にはなりませんでしたが、特定の状況下では最速になりました。次のような結論になります。

  • Jil, Utf8Json, ServiceStack.Text→どれも優秀。型を指定する場合はServiceStackが最速だが、指定しない場合は型のキャストでハマる可能性はある。Jilは安定して強く、stringからデシリアライズするときに最速。Utf8Jsonはstreamやbyte[]から読み込ませる場合は最速。stringからデシリアライズするのが無駄というのが理解できれば最も性能を引き出せるかもしれない。
  • Json.Net→良くも悪くも普通
  • DataContractJsonSerializer→配布用バイナリに余計なDLLが同梱されないメリットはあるが使いづらい
  • DynamicJSON→7年前のライブラリだしそっとしておいてUtf8Jsonを使ってあげてください

今後のアップデートで大きく変わる可能性があるので、期待しましょう。

すっかり忘れていましたが、これが作りたかったビットコインの2018/1/18~1/24の6日分のティックチャートです。csvで出力してExcelでグラフ化しました。
utf8json5.png