目次
- 本記事の背景
- テストデータの構築
- 方式の比較
- Jsonシリアライズ
- カスタムバイナリシリアライズ
- BinaryWriter/BinaryReader
- ProtoBuf
- MessagePack
- 方式分析
- ベンチマークテスト
- まとめ
1. 本記事の背景
皆さんこんにちは、沙漠尽头的狼です。
ネットワーク通信において、データシリアライズはオブジェクトの状態を保存可能または転送可能な形式に変換するプロセスであり、TCPネットワーク転送において特に重要です。プロジェクトで数十万件のデータを転送する必要がある場合、従来のJsonシリアライズ方式では、冗長なフィールド名や文字列形式によりバイナリパッケージのサイズが大きくなり、シリアライズとデシリアライズの効率も低下します。これらの問題を解決するため、より効率的なシリアライズ手法を検討し、パッケージサイズを削減し処理速度を向上させることを目的としています。本記事では、カスタムバイナリシリアライズ、BinaryWriter/BinaryReader、MessagePack、ProtoBufの4つのシリアライズ手法を比較し、そのパフォーマンスを比較することで、現時点でのベストプラクティスガイドを提供します。
2. テストデータの構築
C#コンソールアプリを作成し、Organization と Member の2つのクラスを追加します。クラスには基本データ型と List<T> が含まれ、その他の配列やディクショナリは必要に応じて拡張できます。
public class Organization
{
public int Id { get; set; }
public string[]? Tags { get; set; }
public List<Member>? Members { get; set; }
}
public class Member
{
public int Id { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public string? Address { get; set; }
public double Value { get; set; }
public long UpdateTime { get; set; }
}
100万件のテストデータを作成します(タイトルと呼応させるため)。
public class BenchmarkTest
{
/// <summary>
/// テストデータ数
/// </summary>
private const int DataCount = 1000000;
private static readonly Random RandomShared = new(DateTime.Now.Millisecond);
/// <summary>
/// テストデータ
/// </summary>
private static readonly Organization TestData = new()
{
Id = 1,
Tags = Enumerable.Range(0, 5).Select(index => $"テストタグ{index}").ToArray(),
Members = Enumerable.Range(0, DataCount).Select(index => new Member()
{
Id = index,
Name = $"テスト名前{index}",
Description = $"テスト説明{RandomShared.Next(1, int.MaxValue)}",
Address = $"テスト住所{RandomShared.Next(1, int.MaxValue)}",
Value = RandomShared.Next(1, int.MaxValue),
UpdateTime = ((DateTimeOffset)DateTime.Now).ToUnixTimeMilliseconds()
}).ToList()
};
}
3. 方式の比較
まずシリアライズインターフェース ISerializeHelper を作成し、各種シリアライズプロバイダーがこれを実装します。
public interface ISerializeHelper
{
byte[] Serialize(Organization data);
Organization? Deserialize(byte[] buffer);
}
次に BenchmarkTest クラスを作成し、シリアライズプロバイダーを実行する RunSerialize メソッドを追加します。このメソッド内でプロバイダーのシリアライズおよびデシリアライズメソッドを順に呼び出し、統一して処理時間とパッケージサイズを出力・統計します。
public class BenchmarkTest
{
// ... 前述のコード省略
private static void RunSerialize(ISerializeHelper helper)
{
Stopwatch sw = Stopwatch.StartNew();
var buffer = helper.Serialize(TestData);
sw.Stop();
Log($"{helper.GetType().Name} Serialize {sw.ElapsedMilliseconds}ms {buffer.Length}byte");
sw.Restart();
var data = helper.Deserialize(buffer);
sw.Stop();
Log($"{helper.GetType().Name} Deserialize {sw.ElapsedMilliseconds}ms {data?.Members?.Count}件");
}
private static void Log(string log)
{
Console.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss fff}: {log}");
}
}
3.1. Jsonシリアライズ
まずJsonシリアライズをテストします。System.Text.Json パッケージをインストールします。
<PackageReference Include="System.Text.Json" Version="8.0.0" />
JsonSerializeHelper プロバイダーを作成し、ISerializeHelper インターフェースを実装します。
using ByteTest.Core.Models;
using System.Text.Json;
namespace ByteTest.Core.Helpers;
public class JsonSerializeHelper : ISerializeHelper
{
public byte[] Serialize(Organization data)
{
return JsonSerializer.SerializeToUtf8Bytes(data);
}
public Organization? Deserialize(byte[] buffer)
{
var data = JsonSerializer.Deserialize<Organization>(buffer);
return data;
}
}
JsonSerializer.SerializeToUtf8Bytes メソッドは直接バイトデータを生成するため、JSONデータをファイルやネットワークストリームに書き込む必要がある場合に便利です(文字列からバイトへの変換が不要)。また、不要な文字列割り当てを回避するためパフォーマンスが向上しメモリ負荷が軽減されます。
次に BenchmarkTest クラスにテストメソッド Test を追加します。
public static void Test()
{
RunSerialize(new JsonByteSerializeHelper());
}
Program で Test() を呼び出します。
BenchmarkTest.Test();
プログラム出力:
2023-12-10 22:28:24 880: JsonByteSerializeHelper Serialize 2813ms 196227181byte
2023-12-10 22:28:26 858: JsonByteSerializeHelper Deserialize 1964ms 1000000件
Jsonシリアライズで100万件のデータに 2.8s、パッケージサイズ 187.14MB。大きく、遅いです。
3.2. カスタムバイナリシリアライズ
次にカスタムバイナリシリアライズをテストします。これは私が以前よく使用していた方法ですが、今見ると冗長です。まずデータパケットのフィールド仕様を定義します。
| データ型 | バイナリ長 | 説明 |
|---|---|---|
| 数値型 (short\ushort\int\uint\long\ulong\double 等) | 2\2\4\4\8\8\8 | 基本数値型は固定長 |
| string | 4+n | int型4バイトで文字列のバイナリ長を表し、nは文字列のバイナリ配列の実長 |
T[]`List |
4+n | 配列やリストは文字列と同様にint型4バイトでバイナリ長を表し、nは配列/リストのバイナリ配列の実長 |
CustomSerializeHelper を追加し、ISerializeHelper を実装します。
using ByteTest.Core.Models;
namespace ByteTest.Core.Helpers;
public class CustomSerializeHelper : ISerializeHelper
{
public byte[] Serialize(Organization data)
{
// 1. Idの計算
var idBuffer = BitConverter.GetBytes(data.Id);
// 2. Tag配列の計算
var tagBuffer = GetBytes(data.Tags);
// 3. Membersの計算
var membersBuffer = GetBytes(data.Members);
return GetBytes(new[] { idBuffer, tagBuffer, membersBuffer });
}
public Organization? Deserialize(byte[] buffer)
{
var data = new Organization();
var index = 0;
data.Id = BitConverter.ToInt32(buffer, index);
index += sizeof(int);
data.Tags = GetTags(buffer, ref index);
data.Members = GetMembers(buffer, ref index);
return data;
}
/// <summary>
/// 文字列リストのbyte[]を取得
/// </summary>
private byte[] GetBytes(string[]? data)
{
var dataCount = data?.Length ?? 0;
var dataCountBuffer = BitConverter.GetBytes(dataCount);
if (dataCount <= 0)
{
return dataCountBuffer;
}
var dataValueBuffers = data!.Select(item => ByteHelper.GetBytes(item)!).ToArray();
var dataValueBuffer = GetBytes(dataValueBuffers);
return GetBytes(new[] { dataCountBuffer, dataValueBuffer });
}
/// <summary>
/// メンバーリストのbyte[]を取得
/// </summary>
private byte[] GetBytes(List<Member>? data)
{
var dataCount = data?.Count ?? 0;
var dataCountBuffer = BitConverter.GetBytes(dataCount);
if (dataCount <= 0)
{
return dataCountBuffer;
}
var dataValueBuffers = data!.Select(item =>
{
var idBuffer = BitConverter.GetBytes(item.Id);
var nameBuffer = ByteHelper.GetBytes(item.Name);
var descriptionBuffer = ByteHelper.GetBytes(item.Description);
var addressBuffer = ByteHelper.GetBytes(item.Address);
var valueBuffer = BitConverter.GetBytes(item.Value);
var updateTimeBuffer = BitConverter.GetBytes(item.UpdateTime);
var buffer = GetBytes(new byte[][]
{ idBuffer, nameBuffer, descriptionBuffer, addressBuffer, valueBuffer, updateTimeBuffer });
return buffer;
}).ToArray();
var dataValueBuffer = GetBytes(dataValueBuffers);
return GetBytes(new[] { dataCountBuffer, dataValueBuffer });
}
private byte[] GetBytes(byte[][] data)
{
var dataBufferLen = data.Sum(itemBuffer => itemBuffer.Length);
var dataBuffer = new byte[dataBufferLen];
var dataIndex = 0;
for (var i = 0; i < data.Length; i++)
{
var itemBuffer = data[i];
Array.Copy(itemBuffer, 0, dataBuffer, dataIndex, itemBuffer.Length);
dataIndex += itemBuffer.Length;
}
return dataBuffer;
}
private string[]? GetTags(byte[] buffer, ref int index)
{
var count = BitConverter.ToInt32(buffer, index);
index += sizeof(int);
if (count <= 0)
{
return default;
}
var data = new string[count];
for (var i = 0; i < count; i++)
{
data[i] = GetString(buffer, ref index);
}
return data;
}
private List<Member>? GetMembers(byte[] buffer, ref int index)
{
var count = BitConverter.ToInt32(buffer, index);
index += sizeof(int);
if (count <= 0)
{
return default;
}
var data = new List<Member>();
for (var i = 0; i < count; i++)
{
var people = new Member();
people.Id = BitConverter.ToInt32(buffer, index);
index += sizeof(int);
people.Name = GetString(buffer, ref index);
people.Description = GetString(buffer, ref index);
people.Address = GetString(buffer, ref index);
people.Value = BitConverter.ToDouble(buffer, index);
index += sizeof(double);
people.UpdateTime = BitConverter.ToInt64(buffer, index);
index += sizeof(long);
data.Add(people);
}
return data;
}
private string GetString(byte[] buffer, ref int index)
{
var count = BitConverter.ToInt32(buffer, index);
index += sizeof(int);
if (count <= 0)
{
return string.Empty;
}
var data = ByteHelper.DefaultEncoding.GetString(buffer, index, count);
index += count;
return data;
}
}
コードは多いですが、概要をつかめば十分です。主に BitConverter.GetBytes と BitConverter.ToXXX を使用して基本データ型の byte[] を取得・設定し、複数の byte[] を結合する際はデータが小さい場合は Array.Copy、大きい場合は Buffer.BlockCopy が推奨されます。
ヘルパークラス ByteHelper を使用して文字列の byte[] を計算します。
public static class ByteHelper
{
public static Encoding DefaultEncoding = Encoding.UTF8;
/// <summary>
/// 文字列のバイナリデータを取得:文字列バイナリデータ = 4バイトint(文字列の実長) + nバイト(文字列の実際の値)
/// </summary>
public static byte[] GetBytes(string? str)
{
if (string.IsNullOrEmpty(str))
{
return BitConverter.GetBytes(0);
}
var strValueBuffer = DefaultEncoding.GetBytes(str);
var strValueLen = strValueBuffer.Length;
var strValueLenBuffer = BitConverter.GetBytes(strValueLen);
var strBufferLen = sizeof(int) + strValueLen;
var strBuffer = new byte[strBufferLen];
var index = 0;
Array.Copy(strValueLenBuffer, 0, strBuffer, index, sizeof(int));
index += sizeof(int);
Array.Copy(strValueBuffer, 0, strBuffer, index, strValueLen);
return strBuffer;
}
}
Test メソッドを修正して、CustomSerializeHelper を追加します。
public static void Test()
{
var serializeHelpers = new List<ISerializeHelper>
{
new JsonSerializeHelper(),
new CustomSerializeHelper()
};
serializeHelpers.ForEach(RunSerialize);
}
プログラム出力:
2023-12-10 22:45:14 701: JsonSerializeHelper Serialize 2774ms 196225588byte
2023-12-10 22:45:16 613: JsonSerializeHelper Deserialize 1898ms 1000000件
2023-12-10 22:45:17 414: CustomSerializeHelper Serialize 801ms 92854209byte
2023-12-10 22:45:18 072: CustomSerializeHelper Deserialize 657ms 1000000件
最適化の効果が確認できました。カスタムバイナリシリアライズでは約1億バイト(約100MB)削減、シリアライズ(パッキング)時間は約2秒短縮、デシリアライズ(アンパッキング)時間は約1秒短縮されました。
3.3. BinaryWriter/BinaryReader
BinaryWriter と BinaryReader クラスは、バイナリ形式でデータを書き込み・読み取りするためのクラスです。int、float、double、string などの基本データ型のバイナリ表現を書き込んだり読み取ったりする一連のメソッドを提供します。通常ファイルストリーム(FileStream)と共に使用されますが、他の種類のストリーム(MemoryStream など)とも併用可能です。
カスタム方式は完全手動で、バイト配列のコピーや変換が必要で原始的です。BinaryWriter/BinaryReader を使用したシリアライズはバイナリシリアライズの標準的な使い方と言えるでしょう。
BinarySerializeHelper クラスを作成し、ISerializeHelper インターフェースを実装します。
public class BinarySerializeHelper : ISerializeHelper
{
public byte[] Serialize(Organization data)
{
using var stream = new MemoryStream();
using var writer = new BinaryWriter(stream, ByteHelper.DefaultEncoding);
writer.Write(data.Id);
Write(writer, data.Tags);
Write(writer, data.Members);
return stream.ToArray();
}
public Organization Deserialize(byte[] buffer)
{
var data = new Organization();
using var stream = new MemoryStream(buffer);
using var reader = new BinaryReader(stream, ByteHelper.DefaultEncoding);
data.Id = reader.ReadInt32();
data.Tags = ReadStringList(reader);
data.Members = ReadPeopleList(reader);
return data;
}
private static void Write(BinaryWriter writer, string[]? data)
{
var count = data?.Length ?? 0;
writer.Write(count);
if (count <= 0)
{
return;
}
foreach (var item in data!)
{
writer.Write(item);
}
}
private static void Write(BinaryWriter writer, List<Member>? data)
{
var count = data?.Count ?? 0;
writer.Write(count);
if (count > 0)
{
foreach (var item in data)
{
writer.Write(item.Id);
writer.Write(item.Name ?? string.Empty);
writer.Write(item.Description ?? string.Empty);
writer.Write(item.Address ?? string.Empty);
writer.Write(item.Value);
writer.Write(item.UpdateTime);
}
}
}
private static string[]? ReadStringList(BinaryReader reader)
{
var count = reader.ReadInt32();
if (count <= 0)
{
return default;
}
var values = new string[count];
for (int i = 0; i < count; i++)
{
values[i] = reader.ReadString();
}
return values;
}
private static List<Member>? ReadPeopleList(BinaryReader reader)
{
var count = reader.ReadInt32();
if (count <= 0)
{
return default;
}
var values = new List<Member>();
for (int i = 0; i < count; i++)
{
var item = new Member();
item.Id = reader.ReadInt32();
item.Name = reader.ReadString();
item.Description = reader.ReadString();
item.Address = reader.ReadString();
item.Value = reader.ReadDouble();
item.UpdateTime = reader.ReadInt64();
values.Add(item);
}
return values;
}
}
コード量は同じくらいです。リストのシリアライズ/デシリアライズは、カスタムバイナリシリアライズのときからメソッドにまとめ、リフレクションで汎用リストに対応させるべきでしたが、この節でも同様です。これ以上は改良せず、BenchmarkTest クラスの Test メソッドに BinarySerializeHelper を追加してプログラムを再実行します。
2023-12-10 22:52:56 986: JsonSerializeHelper Serialize 2715ms 196225584byte
2023-12-10 22:52:58 910: JsonSerializeHelper Deserialize 1910ms 1000000件
2023-12-10 22:52:59 730: CustomSerializeHelper Serialize 819ms 92853722byte
2023-12-10 22:53:00 389: CustomSerializeHelper Deserialize 659ms 1000000件
2023-12-10 22:53:00 660: BinarySerializeHelper Serialize 269ms 83853707byte
2023-12-10 22:53:01 466: BinarySerializeHelper Deserialize 806ms 1000000件
BinaryWriter/BinaryReader を使用すると、パッケージサイズはカスタムバイナリシリアライズよりも約8.5MB小さくなり、シリアライズ速度も約0.5秒速くなりました。デシリアライズはやや遅くなりましたが良好です。
3.4. ProtoBuf
Google の Protocol Buffers をご存知ですか?
この節では protobuf-net ライブラリを紹介します。これは .NET 環境で Google Protocol Buffers 形式のデータシリアライズをサポートするライブラリです。Protocol Buffers は軽量で効率的な構造化データシリアライズ機構であり、サービス間やアプリケーション間の通信、データストレージなどに使用されます。
protobuf-net パッケージをインストールします。
<PackageReference Include="protobuf-net" Version="3.2.30" />
テスト対象のクラスにクラスシリアライズ属性 [ProtoContract] とプロパティシリアライズ属性 [ProtoMember(シリアライズ順序)] を追加します。
[ProtoContract]
public class Organization
{
[ProtoMember(1)] public int Id { get; set; }
[ProtoMember(2)] public string[]? Tags { get; set; }
[ProtoMember(3)] public List<Member>? Members { get; set; }
}
[ProtoContract]
public class Member
{
[ProtoMember(1)] public int Id { get; set; }
[ProtoMember(2)] public string? Name { get; set; }
[ProtoMember(3)] public string? Description { get; set; }
[ProtoMember(4)] public string? Address { get; set; }
[ProtoMember(5)] public double Value { get; set; }
[ProtoMember(6)] public long UpdateTime { get; set; }
}
ProtoBufSerializeHelper クラスを作成し、ISerializeHelper を実装します。
using ByteTest.Core.Models;
using ProtoBuf;
namespace ByteTest.Core.Helpers;
public class ProtoBufSerializeHelper : ISerializeHelper
{
public byte[] Serialize(Organization data)
{
using var stream = new MemoryStream();
Serializer.Serialize(stream, data);
return stream.ToArray();
}
public Organization? Deserialize(byte[] buffer)
{
using var stream = new MemoryStream(buffer);
return Serializer.Deserialize<Organization>(stream);
}
}
さらに、上記の Test メソッドに ProtoBufSerializeHelper を追加します。
2023-12-10 23:01:17 478: JsonSerializeHelper Serialize 2767ms 196225803byte
2023-12-10 23:01:19 556: JsonSerializeHelper Deserialize 2064ms 1000000件
2023-12-10 23:01:20 350: CustomSerializeHelper Serialize 793ms 92853782byte
2023-12-10 23:01:21 012: CustomSerializeHelper Deserialize 662ms 1000000件
2023-12-10 23:01:21 271: BinarySerializeHelper Serialize 258ms 83853767byte
2023-12-10 23:01:22 086: BinarySerializeHelper Deserialize 815ms 1000000件
2023-12-10 23:01:22 629: ProtoBufSerializeHelper Serialize 542ms 88837248byte
2023-12-10 23:01:23 688: ProtoBufSerializeHelper Deserialize 1058ms 1000000件
あれ?ProtoBuf は BinaryWriter よりもパッケージサイズが大きく、しかも遅い。使い方が間違っているのでしょうか?もしかすると圧縮アルゴリズムを追加する必要があるかもしれません。これは今後の課題とし、最後の MessagePack を続けて見ていきます。使用上の問題があればご指摘ください。
3.5. MessagePack
最後のシリアライズパッケージ MessagePack を紹介します。これは効率的なバイナリシリアライズ形式で、異なるシステム間でデータを高速かつコンパクトに転送できます。JSON に似ていますが、より小さく、より速く、より省スペースです。
MessagePack パッケージをインストールする必要があります。
<PackageReference Include="MessagePack" Version="2.6.100-alpha" />
MessagePackSerializeHelper クラスを作成し、ISerializeHelper を実装します。
using ByteTest.Core.Models;
using MessagePack;
namespace ByteTest.Core.Helpers;
public class MessagePackSerializeHelper : ISerializeHelper
{
// この方法はクラスとフィールドに属性を追加する必要がありやや手間ですが、圧縮オプションを追加するとパッケージサイズ・パッキング・アンパッキング速度が向上します
//readonly MessagePackSerializerOptions _options = MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.Lz4BlockArray);
// この方法は転送オブジェクトに属性を追加する必要がなく、圧縮オプションも追加可能
readonly MessagePackSerializerOptions _options =
MessagePack.Resolvers.ContractlessStandardResolver.Options.WithCompression(MessagePackCompression
.Lz4BlockArray);
public byte[] Serialize(Organization data)
{
return MessagePackSerializer.Serialize(data, _options);
}
public Organization? Deserialize(byte[] buffer)
{
return MessagePackSerializer.Deserialize<Organization>(buffer, _options);
}
}
コメントアウトされたオプションのほうが優れている可能性があります(圧縮率が高く、サイズが小さくなる)。後でテストを追加します。
最後に Test メソッドを修正します。
public static void Test(List<ISerializeHelper>? moreHelpers = null)
{
var serializeHelpers = new List<ISerializeHelper>
{
new JsonSerializeHelper(),
new CustomSerializeHelper(),
new BinarySerializeHelper(),
new ProtoBufSerializeHelper(),
new MessagePackSerializeHelper(),
};
if (moreHelpers?.Count() > 0)
{
serializeHelpers.AddRange(moreHelpers);
}
serializeHelpers.ForEach(RunSerialize);
}
プログラムを実行します。
2023-12-11 21:34:47 782: JsonSerializeHelper Serialize 2456ms 196225500byte
2023-12-11 21:34:51 215: JsonSerializeHelper Deserialize 3430ms 1000000件
2023-12-11 21:34:52 186: CustomSerializeHelper Serialize 970ms 92853911byte
2023-12-11 21:34:52 711: CustomSerializeHelper Deserialize 526ms 1000000件
2023-12-11 21:34:53 734: BinarySerializeHelper Serialize 1022ms 83853896byte
2023-12-11 21:34:54 354: BinarySerializeHelper Deserialize 620ms 1000000件
2023-12-11 21:34:55 170: ProtoBufSerializeHelper Serialize 815ms 88837377byte
2023-12-11 21:34:56 205: ProtoBufSerializeHelper Deserialize 1035ms 1000000件
2023-12-11 21:34:57 123: MessagePackSerializeHelper Serialize 917ms 43583878byte
2023-12-11 21:34:58 527: MessagePackSerializeHelper Deserialize 1403ms 1000000件
次にコメントアウトしたオプションに切り替えます。コードを以下のように修正します。
using ByteTest.Core.Models;
using MessagePack;
namespace ByteTest.Core.Helpers;
public class MessagePackSerializeHelper : ISerializeHelper
{
// この方法はクラスとフィールドに属性を追加する必要がありやや手間ですが、圧縮オプションを追加するとパッケージサイズ・パッキング・アンパッキング速度が向上します
readonly MessagePackSerializerOptions _options = MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.Lz4BlockArray);
// この方法は転送オブジェクトに属性を追加する必要がなく、圧縮オプションも追加可能
//readonly MessagePackSerializerOptions _options =
// MessagePack.Resolvers.ContractlessStandardResolver.Options.WithCompression(MessagePackCompression
// .Lz4BlockArray);
public byte[] Serialize(Organization data)
{
return MessagePackSerializer.Serialize(data, _options);
}
public Organization? Deserialize(byte[] buffer)
{
return MessagePackSerializer.Deserialize<Organization>(buffer, _options);
}
}
さらに転送クラスに属性 [MessagePackObject] を追加し、シリアライズするプロパティには [Key(シリアライズインデックス)] を追加します。
using MessagePack;
using ProtoBuf;
namespace ByteTest.Core.Models;
[ProtoContract]
[MessagePackObject]
public class Organization
{
[ProtoMember(1)] [Key(0)] public int Id { get; set; }
[ProtoMember(2)] [Key(1)] public string[]? Tags { get; set; }
[ProtoMember(3)] [Key(2)] public List<Member>? Members { get; set; }
}
[ProtoContract]
[MessagePackObject]
public class Member
{
[ProtoMember(1)] [Key(0)] public int Id { get; set; }
[ProtoMember(2)] [Key(1)] public string? Name { get; set; }
[ProtoMember(3)] [Key(2)] public string? Description { get; set; }
[ProtoMember(4)] [Key(3)] public string? Address { get; set; }
[ProtoMember(5)] [Key(4)] public double Value { get; set; }
[ProtoMember(6)] [Key(5)] public long UpdateTime { get; set; }
}
プログラム出力:
2023-12-11 21:49:34 153: JsonSerializeHelper Serialize 2383ms 196226140byte
2023-12-11 21:49:37 736: JsonSerializeHelper Deserialize 3581ms 1000000件
2023-12-11 21:49:38 720: CustomSerializeHelper Serialize 983ms 92854251byte
2023-12-11 21:49:39 250: CustomSerializeHelper Deserialize 530ms 1000000件
2023-12-11 21:49:40 273: BinarySerializeHelper Serialize 1023ms 83854236byte
2023-12-11 21:49:40 907: BinarySerializeHelper Deserialize 632ms 1000000件
2023-12-11 21:49:41 660: ProtoBufSerializeHelper Serialize 754ms 88837717byte
2023-12-11 21:49:42 676: ProtoBufSerializeHelper Deserialize 1014ms 1000000件
2023-12-11 21:49:43 357: MessagePackSerializeHelper Serialize 681ms 38706475byte
2023-12-11 21:49:44 344: MessagePackSerializeHelper Deserialize 986ms 1000000件
先ほどのオプションの MessagePack 出力を再掲します。
2023-12-11 21:34:57 123: MessagePackSerializeHelper Serialize 917ms 43583878byte
2023-12-11 21:34:58 527: MessagePackSerializeHelper Deserialize 1403ms 1000000件
最初のオプション(属性付き+Lz4BlockArray)のほうがパッケージサイズと速度ともに優れていることがわかります。
方式分析
100万件のテストデータ、5つのシリアライズ方式の統計データを表にまとめます。
| シリアライズ方式 | Json | カスタムバイナリ | Binary | ProtoBuf | MessagePack |
|---|---|---|---|---|---|
| シリアライズ後サイズ(MB) | 187.13 | 88.55 | 79.97 | 84.72 | 36.91 |
| シリアライズ時間(ms) | 2383 | 983 | 1023 | 754 | 681 |
| デシリアライズ時間(ms) | 3581 | 530 | 632 | 1014 | 986 |
上の表から、シリアライズ後は MessagePack のパッケージが最小で 36.91MB、Json が最大で 187.13MB、残りの3方式は約80MBです。シリアライズ効率は MessagePack が最も良く、デシリアライズ効率はなんとカスタムバイナリ方式が最も速い結果になりました。Json 方式を除外してもう一度実行します。
2023-12-11 21:55:47 813: CustomSerializeHelper Serialize 1263ms 92854890byte
2023-12-11 21:55:48 804: CustomSerializeHelper Deserialize 989ms 1000000件
2023-12-11 21:55:49 215: BinarySerializeHelper Serialize 410ms 83854875byte
2023-12-11 21:55:50 081: BinarySerializeHelper Deserialize 866ms 1000000件
2023-12-11 21:55:50 726: ProtoBufSerializeHelper Serialize 644ms 88838356byte
2023-12-11 21:55:51 725: ProtoBufSerializeHelper Deserialize 999ms 1000000件
2023-12-11 21:55:52 426: MessagePackSerializeHelper Serialize 701ms 38701799byte
2023-12-11 21:55:53 427: MessagePackSerializeHelper Deserialize 999ms 1000000件
| シリアライズ方式 | カスタムバイナリ | Binary | ProtoBuf | MessagePack |
|---|---|---|---|---|
| シリアライズ後サイズ(MB) | 88.55 | 79.97 | 84.72 | 36.91 |
| シリアライズ時間(ms) | 1263 | 410 | 644 | 701 |
| デシリアライズ時間(ms) | 989 | 866 | 999 | 999 |
パッケージサイズは変わらず、シリアライズは BinaryWriter が最速、デシリアライズも BinaryReader が最速です。テストデータは信頼できないため、ベンチマークテストを実施します。
ベンチマークテスト
ベンチマークテスト用に BenchmarkDotNet パッケージをインストールします。
<PackageReference Include="BenchmarkDotNet" Version="0.13.11" />
BenchmarkTest クラスを修正します。
using BenchmarkDotNet.Attributes;
using ByteTest.Core.Helpers;
using ByteTest.Core.Models;
using MessagePack;
using System.Diagnostics;
namespace ByteTest.Core.Test;
[MemoryDiagnoser, RankColumn]
public class BenchmarkTest
{
// テストデータコード省略
//[Benchmark]
//public void JsonByteSerialize()
//{
// RunSerialize(new JsonSerializeHelper());
//}
[Benchmark]
public void CustomSerialize()
{
RunSerialize(new CustomSerializeHelper());
}
[Benchmark]
public void BinarySerialize()
{
RunSerialize(new BinarySerializeHelper());
}
[Benchmark]
public void ProtoBufPackSerialize()
{
RunSerialize(new ProtoBufSerializeHelper());
}
[Benchmark]
public void MessagePackSerialize()
{
RunSerialize(new MessagePackSerializeHelper());
}
// 統計関連コード省略
}
Program.cs を修正します。
using BenchmarkDotNet.Running;
using ByteTest.Core.Test;
// ベンチマークテスト実行
BenchmarkRunner.Run<BenchmarkTest>();
// 通常テスト
//BenchmarkTest.Test();
テスト結果は以下の通りです。
| Method | Mean | Error | StdDev | Rank | Gen0 | Gen1 | Gen2 | Allocated |
|---|---|---|---|---|---|---|---|---|
| CustomSerialize | 1.702 s | 0.0120 s | 0.0094 s | 4 | 156000.0000 | 45000.0000 | 2000.0000 | 1230.31 MB |
| BinarySerialize | 1.100 s | 0.0101 s | 0.0084 s | 1 | 38000.0000 | 14000.0000 | 2000.0000 | 566.16 MB |
| ProtoBufPackSerialize | 1.337 s | 0.0190 s | 0.0159 s | 2 | 38000.0000 | 14000.0000 | 2000.0000 | 581.66 MB |
| MessagePackSerialize | 1.544 s | 0.0222 s | 0.0197 s | 3 | 68000.0000 | 29000.0000 | 1000.0000 | 449.67 MB |
大まかに見ると、サイズは MessagePack が優れており、ネットワーク転送におけるフラグメントが少なくなるため、ネットワークの往復時間が少なくなります。パッキングおよびアンパッキングの処理速度は、ネイティブの BinaryWriter と BinaryReader が優れています。
4. まとめ
総じて、データパケットサイズはネットワーク環境やデバイスの能力に合わせて適切に設定し、効率的なデータ転送を実現する必要があります。同時に、パッキングおよびアンパッキングの処理能力を高く保つことも、ネットワーク転送のパフォーマンスを維持する上で重要です。前者では MessagePack による圧縮、後者ではネイティブの BinaryWriter と BinaryReader を検討すると良いでしょう。
より良い方法があればコメントでお知らせください。また、このテストコードへの PR も歓迎します。リンク先: ByteTest
参考