Coding Memo

직렬화 비교 (BitConverter, StructureToPtr, direct) 본문

Language/C#

직렬화 비교 (BitConverter, StructureToPtr, direct)

minttea25 2024. 6. 11. 13:38

3가지 직렬화를 비교해보자.

 

1. BitConverter를 이용

2. Unsafe 코드인 StructureToPtr을 이용하여 메모리 내용을 그대로 이용

3. 직접 바이트를 조작


속도 비교

 

C++로 작성된 서버와 C#으로 작성된 클라이언트의 통신에서 패킷 정보 파싱을 위해 PacketHeader라는 구조체를 이용했다. PacketHeader는 unsigned int16 (ushort)의 Id 값과 같은 타입의 Size로 이루어져 있다.

 

C++의 placement new를 이용해서 메모리에 있는 값 그대로 전송하여 C#에서 파싱하는 형태이다.

 

PacketHeader는 다음의 구조이다.

[Size, 2 bytes][Id, 2 bytes]

 

C#에서도 위 메모리 순서를 지키면서 다음 구조체를 작성하였다.

[StructLayout(LayoutKind.Sequential, Pack = 2)]
internal struct PacketHeader
{
    public static readonly int SizeOf = Marshal.SizeOf<PacketHeader>();

    public readonly ushort Size { get; }
    public readonly ushort Id { get; }
    
    public PacketHeader(ushort id, ushort size)
    {
        Id = id;
        Size = size;
    }

    public readonly void WriteTo1(byte[] buffer)
    {
        BitConverter.GetBytes(Size).CopyTo(buffer, 0);
        BitConverter.GetBytes(Id).CopyTo(buffer, 0 + 2);
    }

    public readonly void WriteTo2(byte[] buffer)
    {
        unsafe
        {
            fixed (byte* ptr = buffer)
            {
                Marshal.StructureToPtr(this, (IntPtr)ptr, false);
            }
        }
    }

    public readonly void WriteTo3(byte[] buffer)
    {
        buffer[0] = (byte)Size;
        buffer[1] = (byte)(Size >> 8);

        buffer[2] = (byte)Id;
        buffer[3] = (byte)(Id >> 8);
    }
}

 

- Stopwatch를 이용하여 시간을 확인했고, 디버그 모드는 속도 차이가 크기 때문에 릴리즈 모드로 시간 측정을 했다.

(사용되지 않는 변수 t에 대해서 최적화가 이루어 지지 않고 Serialize 메서드가 실행되는 것을 확인했다.)

 

- 기존에 flatbuffer를 이용하여 PacketHeader 뒤에 직렬화된 데이터를 붙이려고 했으므로, 이 부분은 동일하게 적용했다.

 

- TestPacket은 string 타입의 msg와 int 타입의 number로 이루어진 간단한 테이블이다.

 

- 직렬화 후에, 제대로 되었는지 확인하기위해 패킷 내용을 출력했다. (이 부분은 시간 측정되지 않는다.)

 

다음은 테스트용 코드이다.

더보기
public class PacketWrapper
{
    public static ArraySegment<byte> Serialize1(FlatBufferBuilder fb, ushort id)
    {
        int size = fb.Offset;
        int pos = fb.DataBuffer.Position;
        byte[] buffer = new byte[PacketHeader.SizeOf + size];
        var header = new PacketHeader(id, (ushort)size);
        header.WriteTo1(buffer);
        for (int i = 0; i < size; i++)
        {
            buffer[PacketHeader.SizeOf + i] = fb.DataBuffer.Get(pos + i);
        }

        return new ArraySegment<byte>(buffer);
    }

    public static ArraySegment<byte> Serialize2(FlatBufferBuilder fb, ushort id)
    {
        int size = fb.Offset;
        int pos = fb.DataBuffer.Position;
        byte[] buffer = new byte[PacketHeader.SizeOf + size];
        var header = new PacketHeader(id, (ushort)size);
        header.WriteTo2(buffer);
        for (int i = 0; i < size; i++)
        {
            buffer[PacketHeader.SizeOf + i] = fb.DataBuffer.Get(pos + i);
        }

        return new ArraySegment<byte>(buffer);
    }

    public static ArraySegment<byte> Serialize3(FlatBufferBuilder fb, ushort id)
    {
        int size = fb.Offset;
        int pos = fb.DataBuffer.Position;
        byte[] buffer = new byte[PacketHeader.SizeOf + size];
        var header = new PacketHeader(id, (ushort)size);
        header.WriteTo3(buffer);
        for (int i = 0; i < size; i++)
        {
            buffer[PacketHeader.SizeOf + i] = fb.DataBuffer.Get(pos + i);
        }

        return new ArraySegment<byte>(buffer);
    }
}
FlatBufferBuilder fb = new(128);
var msg = fb.CreateString("This is a message!");
Offset<TestPacket> offset = TestPacket.CreateTestPacket(fb, msg, 255);
fb.Finish(offset.Value);

// for static variable
Console.WriteLine(PacketHeader.SizeOf);

{
    var watch = new Stopwatch();
    // 48 0 254 255 // 0 0 0 8 0 12 ...
    // => size -> id -> data
    watch.Start();
    ArraySegment<byte> nBuf = PacketWrapper.Serialize1(fb, ushort.MaxValue - 1);
    for (int i = 0; i < Iterator; i++)
    {
        var t = PacketWrapper.Serialize1(fb, ushort.MaxValue - 1);
    }
    watch.Stop();
    Console.WriteLine($"Serialize1 execution time: {watch.ElapsedMilliseconds} ms");

    var recvBuf = nBuf.ToArray();
    var size = BitConverter.ToUInt16(recvBuf, 0);
    var id = BitConverter.ToUInt16(recvBuf, 2);
    ByteBuffer bb = new(recvBuf, PacketHeader.SizeOf);
    TestPacket pkt = TestPacket.GetRootAsTestPacket(bb);
    Console.WriteLine(size + ", " + id + ", " + pkt.Msg + ", " + pkt.Number);
}

 

 

1 94 92 107 95 108 104 103 87 101 101
2 86 89 94 100 88 90 86 96 85 101
3 74 62 64 73 61 67 64 65 65 87

(단위: ms, iterator=500000)

 

결과

 

BitConverter를 사용한 직렬화는 대부분의 시도에서 가장 느린것을 확인할 수 있었다. 또한 unsafe코드를 이용한 StructureToPtr도 만만치 않게 느린 것을 알 수 있었다.

이에 반해, 직접 바이트 배열에 바이트 값을 작성하는 경우는 위 두 경우에 비해 모든 시도에서 빠른 것을 알 수 있었다.

 

속도: WriteTo1 < WriteTo2 < WrtieTo3

 

(ByteBuffer.Get() 메서드를 이용했지만, 이 코드 내부에서 범위 체크가 들어가기 때문에 매번 호출 시 속도가 느려질 수 있으므로, 바이트 배열을 복사하지 않는 ByteBuffer.ToArraySegment()를 이용하여 ArraySegment를 가져와, 버퍼에 바이트를 복사하는 편이 낫다.)

 


BitConverter

public readonly void WriteTo1(byte[] buffer)
{
    BitConverter.GetBytes(Size).CopyTo(buffer, 0);
    BitConverter.GetBytes(Id).CopyTo(buffer, 0 + 2);
}

 

GetBytes의 내부 코드를 보면 다음과 같다. (Net 6.0 버전)

/// <summary>
/// Returns the specified 16-bit unsigned integer value as an array of bytes.
/// </summary>
/// <param name="value">The number to convert.</param>
/// <returns>An array of bytes with length 2.</returns>
[CLSCompliant(false)]
public static byte[] GetBytes(ushort value)
{
    byte[] bytes = new byte[sizeof(ushort)];
    Unsafe.As<byte, ushort>(ref bytes[0]) = value;
    return bytes;
}

 

눈여겨 볼 점은 호출마다 new byte를 통해 새 배열을 할당하고 있다는 점이다. 또한 CopyTo를 호출 하여 이 배열을 버퍼에 복사하기 때문에 속도가 느린 것을 알 수 있다.

추가적으로, 단순히 Unsafe 코드를 이용해서 바이트 배열에 직접 작성하고 있는 것 또한 알 수 있다.

 

대신, BitConverter는 아키텍처가 BigEndian인지, LittleEndian인지도 확인하여 적절하게 비트 컨버터를 해준다.

        // This field indicates the "endianess" of the architecture.
        // The value is set to true if the architecture is
        // little endian; false if it is big endian.
#if BIGENDIAN
        [Intrinsic]
        public static readonly bool IsLittleEndian /* = false */;
#else
        [Intrinsic]
        public static readonly bool IsLittleEndian = true;
#endif

 

BitConverter를 사용하면 안정적이라는 것이 장점이다. 단순히 GetBytes() 메서드 외에도 역직렬화 시, TryWriteBytes()라는 메서드도 제공하여 안정성을 더욱 신경쓸 수 있다. 호환성과 이식성 면에서 뛰어나다.


Marshal.StructurToPtr

public readonly void WriteTo2(byte[] buffer)
{
    unsafe
    {
        fixed (byte* ptr = buffer)
        {
            Marshal.StructureToPtr(this, (IntPtr)ptr, false);
        }
    }
}

 

 

Unsafe 코드이다. PacketHeader의 메모리 구조에서 바이트 배열로 직접 마샬링하여 중간의 할당 과정을 줄이므로, 상대적으로 빠르다. 그러나 이 과정에서 약간의 오버헤드가 발생할 수 있다고 한다.

 

말 그대로 unsafe 코드라서 메모리와 포인터를 직접 컨트롤 하므로 안정적이지 않다.

사용하고 있는 플랫폼의 메모리 순서를 따르기 때문에 주의해야 한다.

 


바이트 컨트롤

 

public readonly void WriteTo3(byte[] buffer)
{
    buffer[0] = (byte)Size;
    buffer[1] = (byte)(Size >> 8);

    buffer[2] = (byte)Id;
    buffer[3] = (byte)(Id >> 8);
}

 

직접 바이트 배열에 값을 할당하는 방법이다. 위 2가지 방법보다 빠르고 간단하다.

그러나 직접 바이트 배열을 컨트롤 하는 만큼, 데이터를 송수신하는 아키텍쳐가 BigEndian을 따르는지, LittleEndian을 따르는지를 확인해볼 필요가 있다.

 


성능을 생각한다면, 바이트 순서에 주의해서 직접 바이트를 컨트롤 하는 방법을 사용하는 것이 좋겠다.

 

만약 성능이 가장 우선시 되지 않는다면, BitConverter를 이용한 자동 직렬화 방법을 생각해보는 것도 좋을 수 있겠다.

 

나의 경우에는 LittelEndian 고정이고, C++ 에서 Placement new를 통해 바이트 배열이 고정되는 것을 확인했으므로, 직접 바이트를 컨트롤 하는 방법을 사용할 것 같다.