C#オブジェクトのバイナリシリアル化最適化:ビットフィールド技術による極限圧縮

C#オブジェクトのバイナリシリアル化最適化:ビットフィールド技術による極限圧縮

C#オブジェクトをバイナリ形式に変換し、ネットワーク転送におけるパケットサイズを削減するための最適化方法を示します。

最終更新 2024/01/22 0:33
沙漠尽头的狼
読了目安 13 分
カテゴリ
.NET
タグ
.NET C# バイナリ

1. はじめに

オペレーティングシステムにおいて、プロセス情報はシステム監視やパフォーマンス分析にとって極めて重要です。ここで、現在のOSのプロセス情報をキャプチャし、それをサーバーや監視端末などの他端末に効率的に転送する監視プログラムを開発する必要があると仮定しましょう。この過程で、キャプチャしたプロセスオブジェクトをバイナリデータに変換し、さらにパケットサイズを最小化するための最適化が重要な課題となります。本稿では、ステップバイステップの分析を通じて、C#オブジェクトのバイナリシリアル化をビットフィールド技術を用いて最適化する方法について考察します。

OSのプロセス情報

まず、プロセスオブジェクトのフィールド定義例を示します。このオブジェクトをネットワーク(TCP/UDP)で転送するためには、バイナリ形式に変換する必要があります。その際、いかにして最小のパケットサイズを実現するかが課題です。

フィールド名 説明
PID プロセスID 10565
Name プロセス名 码坊
Publisher 発行者 沙漠尽头的狼
CommandLine コマンドライン dotnet CodeWF.Tools.dll
CPU CPU(全コアの合計処理利用率) 2.3%
Memory メモリ(プロセスが占有する物理メモリ) 0.1%
Disk ディスク(全物理ドライブの合計利用率) 0.1 MB/秒
Network ネットワーク(現在の主要ネットワークの利用率) 0 Mbps
GPU GPU(全GPUエンジンの最高利用率) 2.2%
GPUEngine GPUエンジン GPU 0 - 3D
PowerUsage 電力使用状況(CPU、ディスク、GPUの消費電力への影響)
PowerUsageTrend 電力使用状況の傾向(一定期間のCPU、ディスク、GPUの消費電力への影響) 非常に低
Type プロセスタイプ アプリ
Status プロセスステータス 効率モード

2. 最適化プロセス

2.1. プロセスオブジェクトの定義と初期分析

各フィールドのサンプル値に基づいてデータ型を決定しました。

フィールド名 データ型 説明
PID int プロセスID 10565
Name string? プロセス名 码坊
Publisher string? 発行者 沙漠尽头的狼
CommandLine string? コマンドライン dotnet CodeWF.Tools.dll
CPU string? CPU(全コアの合計処理利用率) 2.3%
Memory string? メモリ(プロセスが占有する物理メモリ) 0.1%
Disk string? ディスク(全物理ドライブの合計利用率) 0.1 MB/秒
Network string? ネットワーク(現在の主要ネットワークの利用率) 0 Mbps
GPU string? GPU(全GPUエンジンの最高利用率) 2.2%
GPUEngine string? GPUエンジン GPU 0 - 3D
PowerUsage string? 電力使用状況(CPU、ディスク、GPUの消費電力への影響)
PowerUsageTrend string? 電力使用状況の傾向(一定期間のCPU、ディスク、GPUの消費電力への影響) 非常に低
Type string? プロセスタイプ アプリ
Status string? プロセスステータス 効率モード

C# クラス SystemProcess を作成してプロセス情報を表現します。

public class SystemProcess
{
    public int PID { get; set; }
    public string? Name { get; set; }
    public string? Publisher { get; set; }
    public string? CommandLine { get; set; }
    public string? CPU { get; set; }
    public string? Memory { get; set; }
    public string? Disk { get; set; }
    public string? Network { get; set; }
    public string? GPU { get; set; }
    public string? GPUEngine { get; set; }
    public string? PowerUsage { get; set; }
    public string? PowerUsageTrend { get; set; }
    public string? Type { get; set; }
    public string? Status { get; set; }
}

テストデータを定義します。

private SystemProcess _codeWFObject = new SystemProcess()
{
    PID = 10565,
    Name = "码坊",
    Publisher = "沙漠尽头的狼",
    CommandLine = "dotnet CodeWF.Tools.dll",
    CPU = "2.3%",
    Memory = "0.1%",
    Disk = "0.1 MB/秒",
    Network = "0 Mbps",
    GPU = "2.2%",
    GPUEngine = "GPU 0 - 3D",
    PowerUsage = "低",
    PowerUsageTrend = "非常低",
    Type = "应用",
    Status = "效率模式"
};

2.2. JSONシリアル化の除外

オブジェクトをJSON文字列に変換するのは、Web開発では最も一般的です。簡潔で、フロントエンドとバックエンドの両方で処理しやすいためです。

public class SysteProcessUnitTest
{
    private readonly ITestOutputHelper _testOutputHelper;

    private SystemProcess _codeWFObject // 前述の定義、ここでは省略

    public SysteProcessUnitTest(ITestOutputHelper testOutputHelper)
    {
        _testOutputHelper = testOutputHelper;
    }

    /// <summary>
    /// Jsonシリアル化サイズテスト
    /// </summary>
    [Fact]
    public void Test_SerializeJsonData_Success()
    {
        var jsonData = JsonSerializer.Serialize(_codeWFObject);
        _testOutputHelper.WriteLine($"Json長さ:{jsonData.Length}");

        var jsonDataBytes = Encoding.UTF8.GetBytes(jsonData);
        _testOutputHelper.WriteLine($"jsonバイナリ長さ:{jsonDataBytes.Length}");
    }
}
標準出力: 
Json長さ:366
jsonバイナリ長さ:366

JSONシリアル化はWeb開発で非常に普及しており、簡潔で処理が容易ですが、TCP/UDPネットワーク転送では、不要なパケットサイズの増加(冗長なフィールド名の宣言)を引き起こす可能性があります。したがって、JSONシリアル化を除外し、より効率的なバイナリシリアル化手法を模索します。

{
  "PID": 10565,
  "Name": "\u7801\u754C\u5DE5\u574A",
  "Publisher": "\u6C99\u6F20\u5C3D\u5934\u7684\u72FC",
  "CommandLine": "dotnet CodeWF.Tools.dll",
  "CPU": "2.3%",
  "Memory": "0.1%",
  "Disk": "0.1 MB/\u79D2",
  "Network": "0 Mbps",
  "GPU": "2.2%",
  "GPUEngine": "GPU 0 - 3D",
  "PowerUsage": "\u4F4E",
  "PowerUsageTrend": "\u975E\u5E38\u4F4E",
  "Type": "\u5E94\u7528",
  "Status": "\u6548\u7387\u6A21\u5F0F"
}

2.3. BinaryWriter を用いたバイナリシリアル化

以前の記事で書いたバイナリシリアル化ヘルパークラス SerializeHelper を使用します。このクラスは BinaryWriter を使用してオブジェクトをバイナリデータに変換します(逆シリアル化には BinaryReader を使用)。

まず、SystemProcess クラスに空インターフェース INetObject を実装させ、クラスに NetHeadAttribute 属性を追加します(パケットヘッダ定義を追加し、複数のネットワークオブジェクトの逆シリアル化識別を容易にします。シリアル化後、システムID、ネットワークオブジェクトID、オブジェクトバージョン番号などのシリアル化補助フィールドが追加されるため、数バイト増加します)。

/// <summary>
/// ネットワークオブジェクトシリアル化インターフェース
/// </summary>
public interface INetObject
{
}
[NetHead(1, 1)]
public class SystemProcess : INetObject
{
    // フィールド定義は省略
}

次に、シリアル化と逆シリアル化の正確性を検証し、シリアル化後のバイナリデータ長を出力するテストメソッドを作成します。

/// <summary>
/// バイナリシリアル化テスト
/// </summary>
[Fact]
public void Test_SerializeToBytes_Success()
{
    var buffer = SerializeHelper.SerializeByNative(_codeWFObject, 1);
    _testOutputHelper.WriteLine($"シリアル化後のバイナリ長さ:{buffer.Length}");

    var deserializeObj = SerializeHelper.DeserializeByNative<SystemProcess>(buffer);
    Assert.Equal("码坊", deserializeObj.Name);
}
標準出力: 
シリアル化後のバイナリ長さ:152

JSON の 366 バイトから半分以下のサイズになりました(366→152、しかもいくつかフィールドが増えています)。上の単体テストではデータの逆シリアル化後の正しさも確認しています。この状態をベースにさらに最適化を進めます。

2.4. データ型の調整

バイナリデータサイズをさらに最適化するため、データ型の調整を行いました。プロセスデータのサンプル分析により、一部のフィールドはよりコンパクトに表現できることがわかりました。例えば、CPU利用率はパーセント記号なしの数値部分(例:2.3)のみ渡せばよく、プロセスタイプはカスタム文字列ではなく列挙値のみ渡せば十分です。このような調整により、パケットサイズを削減できます。

フィールド名 データ型 説明
PID int プロセスID 10565
Name string? プロセス名 码坊
Publisher string? 発行者 沙漠尽头的狼
CommandLine string? コマンドライン dotnet CodeWF.Tools.dll
CPU float CPU(全コアの合計処理利用率) 2.3
Memory float メモリ(プロセスが占有する物理メモリ) 0.1
Disk float ディスク(全物理ドライブの合計利用率) 0.1
Network float ネットワーク(現在の主要ネットワークの利用率) 0
GPU float GPU(全GPUエンジンの最高利用率) 2.2
GPUEngine byte GPUエンジン、0:なし、1:GPU 0 - 3D 1
PowerUsage byte 電力使用状況(CPU、ディスク、GPUの消費電力への影響)、0:非常に低、1:低、2:中、3:高、4:非常に高 1
PowerUsageTrend byte 電力使用状況の傾向(一定期間のCPU、ディスク、GPUの消費電力への影響)、0:非常に低、1:低、2:中、3:高、4:非常に高 0
Type byte プロセスタイプ、0:アプリ、1:バックグラウンドプロセス 0
Status byte プロセスステータス、0:正常実行、1:効率モード、2:一時停止 1

テストデータ定義を変更します。

[NetHead(1, 2)]
public class SystemProcess2 : INetObject
{
    public int PID { get; set; }
    public string? Name { get; set; }
    public string? Publisher { get; set; }
    public string? CommandLine { get; set; }
    public float CPU { get; set; }
    public float Memory { get; set; }
    public float Disk { get; set; }
    public float Network { get; set; }
    public float GPU { get; set; }
    public byte GPUEngine { get; set; }
    public byte PowerUsage { get; set; }
    public byte PowerUsageTrend { get; set; }
    public byte Type { get; set; }
    public byte Status { get; set; }
}
/// <summary>
/// 通常最適化(フィールドデータ型)
/// </summary>
private SystemProcess2 _codeWFObject2 = new SystemProcess2()
{
    PID = 10565,
    Name = "码坊",
    Publisher = "沙漠尽头的狼",
    CommandLine = "dotnet CodeWF.Tools.dll",
    CPU = 2.3f,
    Memory = 0.1f,
    Disk = 0.1f,
    Network = 0,
    GPU = 2.2f,
    GPUEngine = 1,
    PowerUsage = 1,
    PowerUsageTrend = 0,
    Type = 0,
    Status = 1
};

単体テストを追加します。

/// <summary>
/// バイナリシリアル化テスト
/// </summary>
[Fact]
public void Test_SerializeToBytes2_Success()
{
    var buffer = SerializeHelper.SerializeByNative(_codeWFObject2, 1);
    _testOutputHelper.WriteLine($"シリアル化後のバイナリ長さ:{buffer.Length}");

    var deserializeObj = SerializeHelper.DeserializeByNative<SystemProcess2>(buffer);
    Assert.Equal("码坊", deserializeObj.Name);
    Assert.Equal(2.2f, deserializeObj.GPU);
}

テスト結果:

標準出力: 
シリアル化後のバイナリ長さ:99

パケットサイズはさらに1/3削減され、152バイトから99バイトになりました。これは一部のフィールドのデータ型を string? から float または byte に変更した成果です。

2.5. 再びデータ型調整とビットフィールド最適化

さらに進んで、ビットフィールド技術を導入します。ビットフィールドを使用すると、メモリ内のフィールドレイアウトをより細かく制御でき、バイナリデータのサイズをさらに削減できます。フィールドルールを再定義し、いくつかの列挙値フィールドにビットフィールドを使用します。これにより、パケットサイズを大幅に削減できます。

前の表と下の表を比較すると、主に2種類のデータ型調整があります。ルールは以下のとおりです。

  • 1つ目:一部のフィールドは単なる列挙値であり、byte(8ビット)で表現されていますが、例えばプロセスタイプは2つの状態(0:アプリ、1:バックグラウンドプロセス)しかなく、1ビット(0、1)で表現できます。電力使用状況は5状態なので、3ビットで十分です(6状態まで表現可能)。

  • 2つ目:一部のfloatデータ型は、実際には小数点以下1桁までしか要求しません。パーセンテージを表す値は100.0%を超えないため、整数化を検討します。例えば23.3%の場合、23.3を送信する代わりに10倍して233を送信し、最大値は1000(100.0%)を超えません。別のプロセスはデータを解析した後、10で割って使用します。これにより、floatの4バイト(32ビット)から10ビット(最大値1024)に最適化できます。

このルールに従ってフィールドルールを再定義します。

フィールド名 データ型 説明
PID int プロセスID 10565
Name string? プロセス名 码坊
Publisher string? 発行者 沙漠尽头的狼
CommandLine string? コマンドライン dotnet CodeWF.Tools.dll
Data byte[8] 固定サイズのいくつかのフィールド。なぜ8バイト長なのか?(注:逆シリアル化時にはbyte[]長さを表す4バイトが追加されるため、Dataフィールドは合計12バイトを占める)

固定フィールド (Data) の詳細説明:

フィールド名 Offset Size 説明
CPU 0 10 CPU(全コアの合計処理利用率)、最後の桁が小数部を表す。例:23 は 2.3% を意味する 23
Memory 10 10 メモリ(プロセスが占有する物理メモリ)、最後の桁が小数部を表す。例:1 は 0.1% 1
Disk 20 10 ディスク(全物理ドライブの合計利用率)、最後の桁が小数部を表す。例:1 は 0.1% 1
Network 30 10 ネットワーク(現在の主要ネットワークの利用率)、最後の桁が小数部を表す。例:253 は 25.3% 0
GPU 40 10 GPU(全GPUエンジンの最高利用率)、最後の桁が小数部を表す。例:22 は 2.2% 22
GPUEngine 50 1 GPUエンジン、0:なし、1:GPU 0 - 3D 1
PowerUsage 51 3 電力使用状況(CPU、ディスク、GPUの消費電力への影響)、0:非常に低、1:低、2:中、3:高、4:非常に高 1
PowerUsageTrend 54 3 電力使用状況の傾向(一定期間のCPU、ディスク、GPUの消費電力への影響)、0:非常に低、1:低、2:中、3:高、4:非常に高 0
Type 57 1 プロセスタイプ、0:アプリ、1:バックグラウンドプロセス 0
Status 58 2 プロセスステータス、0:正常実行、1:効率モード、2:一時停止 1

上の表は、固定フィールドの一部のビットフィールドルールです。Offset は Data バイト配列内でのフィールドの開始位置(ビット単位)、Size は占有サイズ(ビット単位)を示します。例えば Memory フィールドは、Data 配列内で 10 ビット目から 20 ビット目までを占有します。

これにより、固定サイズで元々25バイト長だった10個のフィールドを8バイトに最適化できました(5つのfloat:4バイト(32ビット)→10ビット、単一バイト(8ビット)→2ビット、4ビット、6ビットなど。つまり 200ビット(25*8)→64ビット(実際は60ビットですが、ネットワーク転送の最小単位はバイトのため、切り上げて8バイト(64ビット))。

クラス定義を以下のように変更します。コード内のコメントに注意してください。

[NetHead(1, 3)]
public class SystemProcess3 : INetObject
{
    public int PID { get; set; }
    public string? Name { get; set; }
    public string? Publisher { get; set; }
    public string? CommandLine { get; set; }
    private byte[]? _data;
    /// <summary>
    /// シリアル化対象。実際にシリアル化するデータ
    /// </summary>
    public byte[]? Data
    {
        get => _data;
        set
        {
            _data = value;

            // 重要:逆シリアル化時にbyteをオブジェクトに変換する(ビットフィールド操作)
            _processData = _data?.ToFieldObject<SystemProcessData>();
        }
    }

    private SystemProcessData? _processData;

    /// <summary>
    /// プロセスデータ。NetIgnoreMember属性を付与するとシリアル化で無視される
    /// </summary>
    [NetIgnoreMember]
    public SystemProcessData? ProcessData
    {
        get => _processData;
        set
        {
            _processData = value;

            // 重要:オブジェクトをbyte[]に変換する(ビットフィールドシリアル化操作)
            _data = _processData?.FieldObjectBuffer();
        }
    }
}

public record SystemProcessData
{
    [NetFieldOffset(0, 10)] public short CPU { get; set; }
    [NetFieldOffset(10, 10)] public short Memory { get; set; }
    [NetFieldOffset(20, 10)] public short Disk { get; set; }
    [NetFieldOffset(30, 10)] public short Network { get; set; }
    [NetFieldOffset(40, 10)] public short GPU { get; set; }
    [NetFieldOffset(50, 1)] public byte GPUEngine { get; set; }
    [NetFieldOffset(51, 3)] public byte PowerUsage { get; set; }
    [NetFieldOffset(54, 3)] public byte PowerUsageTrend { get; set; }
    [NetFieldOffset(57, 1)] public byte Type { get; set; }
    [NetFieldOffset(58, 2)] public byte Status { get; set; }
}

単体テストを追加します。

/// <summary>
/// 限界最適化(フィールドデータ型)
/// </summary>
private SystemProcess3 _codeWFObject3 = new SystemProcess3()
{
    PID = 10565,
    Name = "码坊",
    Publisher = "沙漠尽头的狼",
    CommandLine = "dotnet CodeWF.Tools.dll",
    ProcessData = new SystemProcessData()
    {
        CPU = 23,
        Memory = 1,
        Disk = 1,
        Network = 0,
        GPU = 22,
        GPUEngine = 1,
        PowerUsage = 1,
        PowerUsageTrend = 0,
        Type = 0,
        Status = 1
    }
};

/// <summary>
/// バイナリ限界シリアル化テスト
/// </summary>
[Fact]
public void Test_SerializeToBytes3_Success()
{
    var buffer = SerializeHelper.SerializeByNative(_codeWFObject3, 1);
    _testOutputHelper.WriteLine($"シリアル化後のバイナリ長さ:{buffer.Length}");

    var deserializeObj = SerializeHelper.DeserializeByNative<SystemProcess3>(buffer);
    Assert.Equal("码坊", deserializeObj.Name);
    Assert.Equal(23, deserializeObj.ProcessData.CPU);
    Assert.Equal(1, deserializeObj.ProcessData.PowerUsage);
}

テスト出力:

標準出力: 
シリアル化後のバイナリ長さ:86

99 バイトから 86 バイトへ、13 バイトの削減です。ネットワークが制限された環境では非常に大きな効果で、例えば 100 万件のデータでは 12.4MB もの差になります。ビットフィールドのシリアル化と逆シリアル化のコードについては、ここでは詳細を述べません(退屈で、説明が難しいかもしれません)。コードは以下のようになります。

public partial class SerializeHelper
{
    public static byte[] FieldObjectBuffer<T>(this T obj) where T : class
    {
        var properties = typeof(T).GetProperties();
        var totalSize = 0;

        // 合計ビット長を計算
        foreach (var property in properties)
        {
            if (!Attribute.IsDefined(property, typeof(NetFieldOffsetAttribute)))
            {
                continue;
            }

            var offsetAttribute =
                (NetFieldOffsetAttribute)property.GetCustomAttribute(typeof(NetFieldOffsetAttribute))!;
            totalSize = Math.Max(totalSize, offsetAttribute.Offset + offsetAttribute.Size);
        }

        var bufferLength = (int)Math.Ceiling((double)totalSize / 8);
        var buffer = new byte[bufferLength];

        foreach (var property in properties)
        {
            if (!Attribute.IsDefined(property, typeof(NetFieldOffsetAttribute)))
            {
                continue;
            }

            var offsetAttribute =
                (NetFieldOffsetAttribute)property.GetCustomAttribute(typeof(NetFieldOffsetAttribute))!;
            dynamic value = property.GetValue(obj)!; // dynamic型で動的にプロパティ値を取得
            SetBitValue(ref buffer, value, offsetAttribute.Offset, offsetAttribute.Size);
        }

        return buffer;
    }

    public static T ToFieldObject<T>(this byte[] buffer) where T : class, new()
    {
        var obj = new T();
        var properties = typeof(T).GetProperties();

        foreach (var property in properties)
        {
            if (!Attribute.IsDefined(property, typeof(NetFieldOffsetAttribute)))
            {
                continue;
            }

            var offsetAttribute =
                (NetFieldOffsetAttribute)property.GetCustomAttribute(typeof(NetFieldOffsetAttribute))!;
            dynamic value = GetValueFromBit(buffer, offsetAttribute.Offset, offsetAttribute.Size,
                property.PropertyType);
            property.SetValue(obj, value);
        }

        return obj;
    }

    /// <summary>
    /// 値をビット単位でbufferに書き込む
    /// </summary>
    /// <param name="buffer"></param>
    /// <param name="value"></param>
    /// <param name="offset"></param>
    /// <param name="size"></param>
    private static void SetBitValue(ref byte[] buffer, int value, int offset, int size)
    {
        var mask = (1 << size) - 1;
        buffer[offset / 8] |= (byte)((value & mask) << (offset % 8));
        if (offset % 8 + size > 8)
        {
            buffer[offset / 8 + 1] |= (byte)((value & mask) >> (8 - offset % 8));
        }
    }

    /// <summary>
    /// bufferからビット単位で値を読み出す
    /// </summary>
    /// <param name="buffer"></param>
    /// <param name="offset"></param>
    /// <param name="size"></param>
    /// <param name="propertyType"></param>
    /// <returns></returns>
    private static dynamic GetValueFromBit(byte[] buffer, int offset, int size, Type propertyType)
    {
        var mask = (1 << size) - 1;
        var bitValue = (buffer[offset / 8] >> (offset % 8)) & mask;
        if (offset % 8 + size > 8)
        {
            bitValue |= (buffer[offset / 8 + 1] << (8 - offset % 8)) & mask;
        }

        dynamic result = Convert.ChangeType(bitValue, propertyType); // プロパティの型に応じて変換
        return result;
    }
}

3. 最適化の効果とまとめ

段階的な最適化により、初期のJSONシリアル化(366バイト)から、通常のバイナリシリアル化(152バイト)、そしてビットフィールド技術を用いた86バイトへとサイズを削減できました。この最適化はネットワーク転送、特に大量のデータを転送する必要がある場合に非常に効果的です。

本稿では、サンプルケースを通じて、C#オブジェクトのバイナリシリアル化最適化手法を考察しました。ビットフィールド技術を使用することで、パケットサイズを極限まで圧縮し、ネットワーク転送の効率を向上させることができました。これはC/Sプログラム開発における楽しみの一つであり、究極のパフォーマンスを追求する姿勢の表れです。

最後に、本稿のテストソースコードのGitHubリンクを提供します。読者の参考と学習に役立ててください。

おまけ:このリポジトリには、前回の記事『C#百万オブジェクトシリアル化深層解析:ネットワーク転送で速度とサイズの完璧なバランスを実現する方法 (dotnet9.com)』のサンプルコードも含まれており、TCP、UDPのサーバー・クライアント結合テストプログラムも同梱されています。

さらに探索

関連読書

その他の記事
同じカテゴリ / 同じタグ 2026/04/22

各OSバージョンの.NETサポート状況(250707更新)

仮想マシンとテストマシンを使用して、各OSバージョンの.NETサポート状況を確認します。OSインストール後、対応するランタイムをインストールし、Stardustエージェントを実行できることを確認します(合格条件)。

続きを読む
同じカテゴリ / 同じタグ 2026/02/07

AOTの使用経験のまとめ

プロジェクト作成当初から、新機能を追加したり新しい構文を使用したりした場合には、すぐにAOT公開テストを実施するという良い習慣を身につけるべきです。

続きを読む