MemoryPackWriter 는 숫자를 바꾸지 않고 메모리 모양 그대로 쓴다
Esc

    MemoryPackWriter 는 숫자를 바꾸지 않고 메모리 모양 그대로 쓴다

    –C#라이브러리직렬화

    게임 서버는 초마다 수천 개의 패킷을 만들고, 패킷 하나에는 좌표, 체력, 아이디 같은 숫자가 수십 개 들어갑니다. 직렬화기가 숫자 하나를 바이트로 바꿀 때 하는 일이 조금만 늘어도, 그만큼이 패킷 수 × 숫자 수로 곱해져 서버 비용이 됩니다.

    MemoryPack 에서 값을 바이트로 쓰는 것은 MemoryPackWriter 입니다. writer 는 숫자를 다른 형식으로 바꾸지 않습니다. C# 이 그 숫자를 메모리에 들고 있는 바이트를 그대로 옮겨 적습니다. 그리고 쓴 양은 모아 두었다가 자리가 모자랄 때와 Flush 때만 버퍼에 알립니다. 그래서 빠릅니다. 대신 writer 를 직접 만들어 쓰는 코드는 마지막에 Flush 를 불러야 하고, 문자열은 형식을 바꿔 쓰기 때문에 따로 비용이 있습니다. 기준 판본은 1.21.4 입니다.

    C# 은 숫자를 메모리에 이미 바이트로 들고 있다

    C# 프로그램이 int x = 200; 을 실행하면, 메모리 어딘가에 4 B 가 C8 00 00 00 으로 놓입니다. 200 을 16진수로 쓰면 C8 이고, 작은 자리 바이트부터 놓기 때문에 이 순서입니다. int 는 값이 0 이든 20억이든 늘 4 B 입니다.

    직렬화는 이 값을 패킷에 담을 바이트로 바꾸는 일입니다. 직렬화기마다 이 단계에서 하는 일이 다릅니다.

    직렬화기 int 200 을 쓸 때 하는 일 결과
    JSON 200 을 자릿수로 나눠 글자 '2' '0' '0' 으로 바꾼다 32 30 30 (3 B)
    MessagePack 값이 255 이하인지 보고, 1 B 짜리 형식을 고른 뒤 형식 표시를 붙인다 CC C8 (2 B)
    MemoryPack 아무것도 바꾸지 않는다. 메모리에 있던 C8 00 00 00 을 그대로 옮겨 적는다 C8 00 00 00 (4 B)

    JSON 과 MessagePack 은 숫자마다 “어떤 형식으로 쓸까” 를 정하는 일을 합니다. MemoryPack 은 그 일이 없습니다. 받는 쪽도 4 B 를 그대로 int 로 읽으면 끝입니다. 이것이 “메모리 그대로 쓴다” 는 말의 뜻입니다.

    X, Y 두 int 를 가진 Move 두 개를 쓰면 이렇게 됩니다.

    바이트 뜻
    02 멤버 2개
    C8 00 00 00 X = 200
    01 00 00 00 Y = 1
    02 멤버 2개
    03 00 00 00 X = 3
    04 00 00 00 Y = 4

    멤버 수 1 B 뒤에 숫자가 메모리 모양 그대로 붙을 뿐입니다. 숫자 멤버가 여러 개 이어지면 하나씩 따로 쓰지도 않습니다. MemoryPack 이 Move 에 만들어 주는 코드는 writer.WriteUnmanagedWithObjectHeader(2, value.X, value.Y) 한 번으로 9 B 를 한꺼번에 옮겨 적습니다. int[] 처럼 숫자만 든 배열도 배열 메모리를 통째로 한 번에 옮깁니다.

    버퍼에는 자리가 모자랄 때만 묻는다

    writer 와 버퍼는 역할이 다릅니다. 버퍼(IBufferWriter<byte>)는 바이트를 담아 두는 그릇이고, writer 는 그 그릇에서 빈 자리 한 조각을 받아 그 안에 쓰는 펜입니다.

    writer 는 값을 쓸 때마다 버퍼에 “자리 주세요”, “몇 바이트 썼어요” 를 말하지 않습니다. 받아 둔 조각에 자리가 있는 동안은 혼자 쓰고, 쓴 양도 혼자 기억합니다. 조각이 모자랄 때만 그동안 쓴 양을 한 번에 알리고 새 조각을 받습니다.

    Move 10,000개, 곧 숫자 20,000개를 직렬화하는 동안 버퍼가 불린 횟수를 셌습니다.

    쓴 값 결과 크기 버퍼에 자리를 달라고 한 횟수 (GetSpan) 쓴 양을 알린 횟수 (Advance)
    숫자 20,000개 90,004 B 10번 10번

    숫자는 20,000개인데 버퍼 호출은 20번이었습니다. 나머지는 전부 writer 안에서 “자리 확인, 옮겨 적기, 위치 이동” 으로 끝났습니다.

    그래서 빠르다

    같은 Move 10,000개 리스트를 byte[] 로 1,000번 직렬화한 평균입니다.

    직렬화기 1회 결과 크기
    MemoryPack 1.21.4 450.8 µs 90,004 B
    MessagePack 3.1.9 690.7 µs 69,427 B
    System.Text.Json 1,636.5 µs 203,336 B

    빠른 이유는 둘입니다.

    1. 숫자의 형식을 바꾸지 않습니다. JSON 은 숫자를 글자로 바꾸고, MessagePack 은 숫자마다 크기를 보고 형식을 고릅니다. MemoryPack 은 옮겨 적기만 합니다.
    2. 버퍼에 거의 묻지 않습니다. 숫자 20,000개에 버퍼 호출 20번입니다.

    대신 크기는 MessagePack 보다 큽니다. MessagePack 은 작은 숫자를 1~2 B 로 줄이는데, MemoryPack 은 0 이든 200 이든 4 B 를 씁니다.

    쓴 양은 Flush 때 알린다

    쓴 양을 모아 두는 방식에는 대가가 있습니다. writer 를 직접 만들어 Move 두 개를 쓴 뒤, Flush 전후로 버퍼가 아는 양을 봤습니다.

    시점 writer 가 쓴 양 버퍼가 아는 양
    Flush 전 18 B 0 B
    Flush 후 18 B 18 B

    바이트는 이미 버퍼의 메모리에 들어가 있지만, 버퍼는 “몇 바이트가 쓰였는지” 를 Flush 때 처음 듣습니다. 그 전에 버퍼의 WrittenSpan 을 읽어 소켓으로 보내면 0 B 를 보냅니다. 작은 패킷은 첫 조각 안에서 끝나니 Flush 를 빠뜨리면 늘 0 B 입니다.

    MemoryPackSerializer.Serialize(bufferWriter, value) 는 끝에 스스로 Flush 를 부르니 이 문제가 없습니다. 여러 패킷을 한 버퍼에 이어 쓰려고 writer 를 직접 만든 코드에서만 생깁니다.

    문자열은 형식을 바꿔 쓴다

    숫자와 달리 문자열은 그대로 옮겨 적지 않습니다. C# 문자열은 메모리에 UTF-16(글자당 2 B)으로 있는데, MemoryPack 의 기본은 UTF-8 로 바꿔 씁니다. 바꿔 봐야 몇 바이트인지 알기 때문에, writer 는 나올 수 있는 최대 크기 (글자 수 + 1) × 3 + 8 을 한 덩어리로 먼저 잡고 바꾼 뒤 실제 크기만큼만 위치를 밉니다.

    문자열 먼저 잡는 크기 실제로 쓴 크기
    "hoge" 23 B 12 B
    "가나다라" 23 B 20 B
    ASCII 100만 자 3,000,011 B 1,000,009 B
    "a\uD800b" (이모지 반쪽) 20 B 예외 InvalidData

    짧은 문자열에서는 문제가 없지만, 긴 문자열은 결과의 세 배를 한 덩어리로 요구합니다. 또 변환은 잘못된 글자를 대체 문자로 바꾸지 않도록 설정되어 있어서, 이모지(글자 두 개로 된 문자)가 반만 남은 문자열에서 예외로 멈춥니다.

    직접 확인하는 테스트 코드

    MemoryPack 을 참조한 콘솔 프로젝트에서 그대로 실행됩니다.

    using System.Buffers;
    using MemoryPack;
    
    // 1. Flush 전후
    var buffer = new ArrayBufferWriter<byte>();
    var state = MemoryPackWriterOptionalStatePool.Rent(MemoryPackSerializerOptions.Default);
    var writer = new MemoryPackWriter<ArrayBufferWriter<byte>>(ref buffer, state);
    
    writer.WriteValue(new Move { X = 200, Y = 1 });
    writer.WriteValue(new Move { X = 3, Y = 4 });
    Console.WriteLine($"Flush 전: writer {writer.WrittenCount} B, 버퍼 {buffer.WrittenCount} B");
    
    writer.Flush();
    Console.WriteLine($"Flush 후: 버퍼 {buffer.WrittenCount} B");
    Console.WriteLine(string.Join(" ", buffer.WrittenSpan.ToArray().Select(x => x.ToString("X2"))));
    
    // 2. 숫자 20,000개를 쓰는 동안 버퍼 호출 횟수
    var list = Enumerable.Range(0, 10_000).Select(i => new Move { X = i, Y = i * 2 }).ToList();
    var counting = new CountingWriter();
    MemoryPackSerializer.Serialize(counting, list);
    Console.WriteLine($"결과 {counting.Inner.WrittenCount} B, GetSpan {counting.GetSpanCalls}번, Advance {counting.AdvanceCalls}번");
    
    [MemoryPackable]
    public partial class Move
    {
        public int X { get; set; }
        public int Y { get; set; }
    }
    
    // 버퍼가 불린 횟수를 세는 IBufferWriter
    public sealed class CountingWriter : IBufferWriter<byte>
    {
        public readonly ArrayBufferWriter<byte> Inner = new();
        public int GetSpanCalls;
        public int AdvanceCalls;
    
        public void Advance(int count)
        {
            AdvanceCalls++;
            Inner.Advance(count);
        }
    
        public Memory<byte> GetMemory(int sizeHint = 0)
        {
            GetSpanCalls++;
            return Inner.GetMemory(sizeHint);
        }
    
        public Span<byte> GetSpan(int sizeHint = 0)
        {
            GetSpanCalls++;
            return Inner.GetSpan(sizeHint);
        }
    }
    출력 값
    Flush 전 writer 18 B, 버퍼 0 B
    Flush 후 버퍼 18 B
    버퍼 내용 02 C8 00 00 00 01 00 00 00 02 03 00 00 00 04 00 00 00
    버퍼 호출 결과 90004 B, GetSpan 10번, Advance 10번

    주의할 점

    1. writer 를 직접 만들었다면 Flush 를 부른다

    안 지키면 생기는 문제. 마지막 조각에 쓴 바이트를 버퍼가 모릅니다. 작은 패킷은 보낼 바이트가 0 B 가 됩니다.

    이렇게 쓴다. 값을 다 쓴 뒤 writer.Flush() 를 부르고 나서 버퍼를 읽습니다. 이유가 없으면 Flush 까지 해 주는 MemoryPackSerializer.Serialize(bufferWriter, value) 를 씁니다.

    2. 긴 텍스트는 문자열 대신 바이트로 담는다

    안 지키면 생기는 문제. 100만 자 문자열이면 약 3MB 를 한 덩어리로 요구해, 큰 배열 요청이 생깁니다.

    이렇게 쓴다. 큰 텍스트는 미리 UTF-8 byte[] 로 바꿔 담으면 배열 경로(길이와 한 번에 옮겨 적기)로 갑니다. 한글이 대부분이면 UTF-16 이 요청 크기(글자 수 × 2 + 4)도 작습니다.

    3. 사용자 입력 문자열은 잘린 이모지를 정리한 뒤 넣는다

    안 지키면 생기는 문제. 채팅이나 닉네임을 글자 수로 자르다 이모지 반쪽이 남으면, 그 문자열 하나 때문에 패킷 전체의 직렬화가 MemoryPackSerializationException: Failed in Utf8 encoding/decoding process, status: InvalidData 로 끝납니다.

    이렇게 쓴다. 자를 때 끝 글자가 이모지의 앞 반쪽(char.IsHighSurrogate)이면 한 글자 덜 자릅니다. 아니면 그 멤버만 [Utf16StringFormatter] 로 씁니다. UTF-16 은 형식을 바꾸지 않아 검사 없이 그대로 나갑니다.

    정리

    MemoryPackWriter 는 숫자를 다른 형식으로 바꾸지 않고 C# 이 메모리에 들고 있는 바이트를 그대로 옮겨 적고, 쓴 양은 자리가 모자랄 때와 Flush 때만 버퍼에 알립니다.

    • int 200 은 메모리에 있던 그대로 C8 00 00 00 4 B 이고, 숫자 20,000개를 쓰는 동안 버퍼 호출은 20번이었습니다.
    • 그래서 MessagePack, System.Text.Json 보다 빠르고, 대신 작은 숫자를 줄이지 않아 크기는 큽니다.
    • writer 를 직접 쓰면 Flush 전까지 버퍼는 0 B 로 알고, 문자열은 UTF-8 로 바꾸느라 최악 크기를 먼저 잡습니다.