안녕하세요?
요즘 우리 닷넷데브에서 엄청 핫한 토픽이 있는데요ㅎ
코드를 리뷰하다 개선되면 좋을 것 같은 부분이 있어 닷넷에서 원시 형식 값을 메모리 할당 없이 버퍼에 쓰는 방법에 대해서 적어볼까 합니다.
구현된 코드를 보면 List<byte> 형식의 버퍼를 만들고 BitConverter.GetBytes()로 배열을 반환한 뒤 필요에 따라 엔디안 변환을 수행한 뒤 그 결과를 버퍼에 복사합니다.
private List<byte> packetData = new List<byte> ();
...
public static byte[] GetBytes(int intByte, bool isLittleEndian = true)
{
byte[] bytes = BitConverter.GetBytes (intByte);
if (BitConverter.IsLittleEndian != isLittleEndian)
Array.Reverse(bytes);
return bytes;
}
원시 형식 값을 직렬화 하는 과정에서 가장 간단하면서도 보편적인 방법이 아닐까 싶은데요, 이 방법은 BitConverter.GetBytes() 함수가 매번 힙에 바이트 배열을 할당해서 반환합니다. 배열 할당 없이 버퍼에 값을 바로 쓸 수 있다면 메모리 할당과 성능적인 부분에서 많은 이점이 있을 것입니다.
포인터를 이용한 원시 형식 값 기록
원시 형식 값을 버퍼에 기록하는 가장 빠른 방법은 byte 배열에 원시 형식 포인터로 접근해서 값을 써 주는 방법입니다.
var buffer = stackalloc byte[10];
*(int*)(buffer + 2) = 0x12345678;
하지만 여기에는 몇 가지 제약 사항이 있습니다.
- 데이터 길이가 동적일 경우
List와 같이 버퍼 확장을 할 수 없음 - Endian 처리가 불가능
닷넷이 발전하면서 이를 해소하는데 도움이 되는 몇 가지 기능들이 추가되었습니다.
이러한 메모리 할당 문제 해결에 도움을 주는 Span<T>의 등장과 함께 관련 라이브러리들이 업데이트 되고 있습니다.
대부분 닷넷데브에 이미 소개되었던 것들이라 익숙하신 분듯도 계실 듯 합니다.
CollectionMarshal
버퍼 확장은 .NET 5에서 추가된 CollectionMarshal을 통해 List의 요소를 Span으로 접근할 수 있게 되었습니다.
CollectionsMarshal 클래스 (System.Runtime.InteropServices) | Microsoft Learn
.NET Framework: 2095. C# - .NET5부터 도입된 CollectionsMarshal (sysnet.pe.kr)
하지만 아무래도 해당 기능은 값을 쓰기는 시나리오 보다는 읽는데 더 효과적인 클래스로 쓰기의 경우 List에 공간을 예약하고 값을 기록해야는 번거로움이 있습니다.
BinaryPrimitives
Endian 처리는 .NET Core 2.1부터 추가된 BinaryPrimitives 클래스를 사용하면 Span을 통해 Little Endian과 Big Endian 형태의 값을 읽고 쓸 수 있습니다.
BinaryPrimitives 클래스 (System.Buffers.Binary) | Microsoft Learn
Span<byte> span = new (buffer, 10)[2..];
if (_isLittleEndian)
{
BinaryPrimitives.WriteInt64LittleEndian(span, value);
}
else
{
BinaryPrimitives.WriteInt64BigEndian(span, value);
}
ArrayBufferWriter<T>
.NET Core 3.0에서는 Span<T>을 List<T>처럼 사용할 수 있게 해주는 ArrayBufferWriter<T> 클래스가 추가되었습니다.
ArrayBufferWriter 클래스 (System.Buffers) | Microsoft Learn
아래와 같이 사용할 수 있습니다.
var writer = new ArrayBufferWriter<byte>(10);
Span<byte> span = writer.GetSpan(4)[2..];
BinaryPrimitives.WriteInt32LittleEndian(span, 0x12345678);
writer.Advance(4);
writer로 부터 GetSpan()을 호출할 때 사용할 길이를 넣어주면 이 길이에 대해 예약된 공간이 부족하면 List와 동일하게 버퍼를 자동으로 확장한 뒤 Span을 반환해 줍니다. Span에 기록이 끝나면 Advance() 메서드로 포인터를 이동해 주면 됩니다. 이 조금 번거로운 과정을 간소화 하기 위해 아래와 같은 확장 메서드를 정의할 수 있습니다.
public static class ArrayBufferWriterExtension
{
public static ReservedSpan Reserve(this ArrayBufferWriter<byte> writer, int length)
=> new ReservedSpan(writer, length);
public static byte[] ToArray(this ArrayBufferWriter<byte> writer)
=> writer.WrittenSpan.ToArray();
}
public ref struct ReservedSpan
{
private int _length;
private ArrayBufferWriter<byte> _list;
public ReservedSpan(ArrayBufferWriter<byte> list, int length)
{
_length = length;
_list = list;
Span = list.GetSpan(length);
}
public Span<byte> Span { get; }
public static implicit operator Span<byte>(ReservedSpan reserved) => reserved.Span;
public void Dispose() => _list.Advance(_length);
}
GetSpan(length)와 Advance(length)를 using 패턴으로 사용할 수 있도록 한 것이죠.
using(var span = writer.Reserve(4))
{
BinaryPrimitives.WriteInt32LittleEndian(span, 0x12345678);
}
using(var span = writer.Reserve(8))
{
BinaryPrimitives.WriteInt64LittleEndian(span, 0x1234567890ABCDEF);
}
using var span = writer.Reserve(4);
BinaryPrimitives.WriteInt32BigEndian(span, 0x12345678);
적용해 보기
자, 이렇게 ArrayBufferWriter<T>와 확장 메서드, 그리고 BinaryPrimitives를 활용해서 @이광석 님의 코드를 수정해 봤습니다.
public PacketBuilder @byte(byte data)
{
using (var span = packetData.Reserve(sizeof(byte)))
{
span.Span[0] = data;
}
return this;
}
public PacketBuilder @bytes(IEnumerable<byte> datas)
{
if (!(datas is byte[] b))
{
b = datas.ToArray();
}
packetData.Write(b);
return this;
}
public PacketBuilder @string(string ascii)
{
var length = Encoding.ASCII.GetByteCount(ascii);
using var span = packetData.Reserve(length);
Encoding.ASCII.GetBytes(ascii, span);
return this;
}
public PacketBuilder @int(int value)
{
using var span = packetData.Reserve(sizeof(int));
if (_isLittleEndian)
{
BinaryPrimitives.WriteInt32LittleEndian(span, value);
}
else
{
BinaryPrimitives.WriteInt32BigEndian(span, value);
}
return this;
}
벤치마크 결과
원래 버전과 수정된 버전에 대해 아래 코드로 벤치마크 해봤습니다. Build() 메서드는 List<T>.ToArray()와 Span<T>.ToArray()가 거의 동일하므로 호출하지 않았습니다.
(추후 버퍼의 내용을 노출할 때도 ToArray()로 힙할당을 할 필요 없이 ReadOnlySpan<byte> 형식의 ArrayBufferWriter.WrittenSpan 속성을 반환하면 됩니다.)
var caseBinary = new PacketBuilder()
.@byte(1)
.@short(1)
.@ushort(1)
.@int(1)
.@uint(1)
.@long(1)
.@ulong(1)
.@byte(1)
.@short(1)
.@ushort(1)
.@int(1)
.@uint(1)
.@long(1)
.@ulong(1);
var caseString = new PacketBuilder()
.@string(new string('A', 65));
| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|---|
| OriginalLE | 231.55 ns | 4.42 ns | 6.76 ns | 1.00 | 0.00 | 0.086 | 544 B | 1.00 |
| SpanLE | 72.41 ns | 0.93 ns | 0.77 ns | 0.31 | 0.01 | 0.029 | 184 B | 0.34 |
| OriginalBE | 249.45 ns | 4.73 ns | 9.88 ns | 1.10 | 0.05 | 0.086 | 544 B | 1.00 |
| SpanBE | 77.62 ns | 1.58 ns | 3.96 ns | 0.35 | 0.02 | 0.029 | 184 B | 0.34 |
| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|---|
| OriginalString | 47.69 ns | 0.978 ns | 2.361 ns | 1.00 | 0.00 | 0.0395 | 248 B | 1.00 |
| SpanString | 28.69 ns | 0.608 ns | 1.215 ns | 0.60 | 0.04 | 0.0293 | 184 B | 0.74 |
보시는 바와 같이 성능과 메모리 사용량 모두 3배 이상 향상되었습니다.
두 케이스 모드 버퍼 크기를 64바이트로 예약하고 코드를 실행 할 경우 개선된 버전에서는 최초 생성자 호출 시점(PacketBuilder2.ctor()) 이후에는 힙 할당이 일어나지 않음을 알 수 있습니다.
추가 벤치마크 (초기화 이후 Append 성능만 측정)
| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|---|
| OriginalLE | 227.91 ns | 4.113 ns | 3.435 ns | 1.00 | 0.00 | 0.0610 | 384 B | 1.00 |
| SpanLE | 58.52 ns | 1.192 ns | 1.171 ns | 0.26 | 0.01 | - | - | 0.00 |
| OriginalBE | 232.99 ns | 1.798 ns | 1.501 ns | 1.02 | 0.02 | 0.0610 | 384 B | 1.00 |
| SpanBE | 56.73 ns | 0.478 ns | 0.423 ns | 0.25 | 0.00 | - | - | 0.00 |
| Method | Mean | Error | StdDev | Ratio | Gen0 | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|
| OriginalString | 35.78 ns | 0.748 ns | 1.458 ns | 1.00 | 0.0140 | 88 B | 1.00 |
| SpanString | 15.95 ns | 0.137 ns | 0.114 ns | 0.43 | - | - | 0.00 |
※ 생성자에서 byteKeyPoint과 _configuration 필드의 할당이 일어나서 메모리 사용량이 좀 왜곡되어 보여 초기화 및 64바이트 버퍼를 미리 예약한 된 상태에서 Append 오퍼레이션에 대해서만 벤치마크 이터레이션을 돌렸습니다.
닷넷에서 원시 형식 값을 메모리 할당 없이 버퍼에 쓰는 방법에 대해서 알아봤습니다. Span에 대해서 생소하셨던 분들에게는 조금이나마 도움이 되었으면 좋겠네요.
요즘 모던 닷넷의 트렌드처럼 Span을 적극적으로 활용해서 메모리 할당을 최소화 해 봅시다!




