MessagePack-CSharp 는 워밍업 뒤 결과물만 할당한다
Esc

    MessagePack-CSharp 는 워밍업 뒤 결과물만 할당한다

    –C#라이브러리직렬화GC
    byte[] bytes = MessagePackSerializer.Serialize(small);      // 32 B 할당
    var back = MessagePackSerializer.Deserialize<Small>(bytes); // 64 B 할당

    초당 수만 번 도는 직렬화 경로에서는 호출 한 번이 힙에 무엇을 남기는지가 곧 GC 부담입니다. 버퍼, 중간 객체, 문자열 키를 맞추느라 만든 문자열이 하나씩만 붙어도 그대로 쌓입니다.

    MessagePack-CSharp 는 워밍업이 끝나면 결과물 말고는 힙에 남기지 않습니다. 위의 32 B 는 8바이트짜리 결과 byte[] 하나, 64 B 는 Small 객체와 "hello" 문자열 하나입니다. 기준 판본은 v3.1.9(커밋 aa16e71)입니다.

    작은 객체 한 번이 남기는 것은 결과뿐이다

    Windows 11, .NET SDK 10.0.401, net10.0 Release 에서 워밍업 20,000회 뒤 500,000회의 GC.GetAllocatedBytesForCurrentThread() 증가분을 나눴습니다. 빈 동작 0 B, new byte[16] 40 B, new byte[1024] 1,048 B 가 이론값과 정확히 맞는 것을 먼저 확인했고, 프로세스를 다섯 번 따로 띄워도 바이트는 모두 같았습니다.

    호출 B/op 이론값 ns/op 중앙값
    Serialize<Small> → byte[] (정수 키) 32 byte[8] = 24 + 8 40 ~ 48
    Deserialize<Small> ← byte[] 64 Small 32 + "hello" 32 59 ~ 74
    Serialize<SmallS> → byte[] (문자열 키) 40 byte[16] = 24 + 16 44 ~ 58
    Deserialize<SmallS> ← byte[] 64 정수 키와 같음 80 ~ 98
    Serialize(typeof(Small), obj) (비제네릭) 32 제네릭과 같음 50 ~ 55

    직렬화는 스레드마다 둔 64 KB 스크래치에 먼저 쓰고 딱 맞게 복사하므로 결과 배열 하나만 남습니다. 문자열 키 역직렬화가 정수 키와 같은 64 B 인 것은 "Id", "Name" 키를 string 으로 만들지 않고 맞췄다는 뜻이고, 비용은 시간(20~30 ns)으로만 나타납니다. 비제네릭 API 도 값 타입을 델리게이트 안에서 Unbox 로 꺼내 박싱을 더하지 않았습니다.

    재사용 버퍼로 보내면 0 B 다

    결과 배열마저 없애려면 호출자가 버퍼를 줍니다.

    결과를 어차피 소켓 버퍼로 옮길 코드라면 처음부터 그 버퍼에 쓰는 편이 복사 한 번과 배열 하나를 아낍니다. 역직렬화의 64 B 는 줄일 수 없는 몫입니다. 역직렬화가 할당하는 것은 만들어지는 객체 그래프 그 자체입니다. 원소 수에도 정비례해서, List<int> 1,000개와 2,000개의 역직렬화는 이론값 그대로 4,056 B 와 8,056 B 였습니다. 개수를 읽고 CollectionsMarshal.SetCount 로 용량을 한 번에 잡으니 재할당이 없습니다.

    첫 호출은 비싸다

    위 숫자는 모두 워밍업 뒤입니다. 새 타입을 처음 직렬화한 호출은 달랐습니다.

    할당 시간
    새 타입의 첫 Serialize 128,888 B 24 ~ 26 ms
    같은 타입 두 번째 64 B (new Cold 32 B + 결과 32 B) 8 ~ 15 µs

    128,888 B 중 65,560 B 가 스레드 스크래치이고, 나머지 약 63 KB 가 리졸버 체인을 처음 훑고 포매터를 만드는 1회 비용입니다. 새 프로세스에서 StandardResolver.Instance.GetFormatter<Cold>() 만 불러도 70,048 B 가 나가고 두 번째는 0 B 였습니다(이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release). 포매터 캐시가 타입마다 정적 생성자를 한 번 도는 정적 제네릭 클래스라, 비용이 첫 호출로 몰리고 그 뒤로는 필드 읽기 하나입니다.

    한 번에 128 KB 를 넘게 요청하면 풀 밖으로 나간다

    스크래치가 모자라면 SequencePool 에서 빌린 Sequence<byte> 로 옮겨 탑니다. 그 세그먼트 배열은 ArrayPool<byte>.Create(80 * 1024, 100) 으로 만든 전용 풀에서 오는데, 이 풀은 길이를 두 배씩 커지는 버킷으로 올려 잡아 131,072 B(128 KB) 버킷까지 풀링합니다. 결과가 100,007 B 인 값은 워밍업 뒤 100,032 B, 곧 결과 배열 하나만 할당했습니다. 64 KB 를 넘는 것만으로는 할당이 늘지 않습니다. 문제는 포매터가 한 번에 128 KB 보다 큰 자리를 요청할 때입니다.

    3만 개짜리 정수 리스트를 byte[] 입구로 보낼 때마다 대형 객체 힙(LOH) 할당이 두 번 생깁니다. 150 KB 세그먼트는 쓸 때 Vector128 로 원소 넷씩 인코딩 폭을 분류하는 경로가 최악 크기를 한꺼번에 요청해서 생긴 것이라, 큰 원시 배열과 byte[] 입구가 만날 때 나타납니다. 버퍼를 재사용하면 둘 다 사라집니다. 256 KB ArrayBufferWriter<byte> 하나에 같은 리스트를 1,000번 보내자 0 B/op 에 0, 1, 2세대 수집 증가가 모두 0 이었습니다(이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release).

    주의할 점

    1. 핫 패스에서는 재사용 버퍼에 직렬화한다

    안 지키면 생기는 문제. byte[] 를 돌려받는 입구는 호출마다 결과 배열 하나를 반드시 만듭니다. 초당 수만 번 도는 경로라면 그 배열들이 전부 0세대 수집 거리이고, 결과를 소켓 버퍼로 옮기는 코드라면 복사도 한 번 더 합니다. 결과가 85,000 B 를 넘으면 매번 LOH 입니다.

    이렇게 쓴다. 필드에 ArrayBufferWriter<byte> 나 풀링된 IBufferWriter<byte> 를 두고 Clear() 한 뒤 MessagePackSerializer.Serialize(buffer, value) 로 씁니다. 결과를 넘길 때는 WrittenMemory 를 쓰는 동안 다음 직렬화가 덮어쓰지 않게 버퍼의 소유를 분명히 합니다.

    2. 큰 원시 배열은 byte[] 입구로 보내지 않는다

    안 지키면 생기는 문제. 원소 수만 개짜리 int[], List<int> 는 원소당 최악 5바이트를 한꺼번에 요청해 64 KB 스크래치와 128 KB 풀 한도를 모두 넘깁니다. 3만 개 리스트 한 번에 LOH 배열이 두 개(150 KB 와 90 KB) 생겼고, LOH 는 2세대 수집에서만 거둬지니 자주 보내면 2세대 수집이 늘어납니다.

    이렇게 쓴다. 큰 배열이 든 메시지는 충분히 큰 재사용 IBufferWriter 로 보내거나, 몇천 개 단위로 나눠 여러 메시지로 보냅니다. 어느 쪽이 맞는지는 실제 메시지로 GC.CollectionCount(2) 를 보며 정합니다.

    3. 역직렬화 할당을 줄이려면 객체 그래프를 바꾼다

    안 지키면 생기는 문제. 역직렬화의 할당은 결과 객체와 문자열, 컬렉션 그 자체라 버퍼나 옵션을 바꿔도 줄지 않습니다. 같은 메시지를 초당 수만 번 받으면 그 객체들이 그대로 0세대에 쌓입니다.

    이렇게 쓴다. 자주 받는 메시지는 문자열 필드를 줄이거나 정수 코드로 바꾸고, 작은 값 묶음은 구조체로 선언해 그래프 자체를 작게 만듭니다. 짧고 자주 반복되는 문자열 필드라면 [MessagePackFormatter(typeof(StringInterningFormatter))] 로 같은 인스턴스를 재사용하게 할 수 있습니다. "hello" 한 필드짜리 타입에서 56 B/op 가 24 B/op 가 됐습니다.

    정리

    MessagePack-CSharp 는 워밍업이 끝나면 결과물 말고는 힙에 남기지 않습니다.

    • 작은 객체의 직렬화는 결과 배열 하나(32 B), 역직렬화는 객체와 문자열(64 B)뿐이었고, 문자열 키를 맞추는 데는 할당이 없었습니다. 재사용 IBufferWriter 로 보내면 0 B 입니다.
    • 비용은 첫 호출로 몰려, 새 타입의 첫 Serialize 는 128,888 B 와 약 25 ms 였습니다.
    • 한 번에 128 KB 를 넘게 요청하는 큰 원시 배열은 byte[] 입구에서 LOH 배열 두 개를 매번 만들고, 재사용 버퍼로 보내면 사라집니다.