안녕하세요?
요즘 우리 닷넷데브에서 엄청 핫한 토픽이 있는데요ㅎ
코드를 리뷰하다 개선되면 좋을 것 같은 부분이 있어 닷넷에서 원시 형식 값을 메모리 할당 없이 버퍼에 쓰는 방법에 대해서 적어볼까 합니다.
구현된 코드를 보면 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
을 적극적으로 활용해서 메모리 할당을 최소화 해 봅시다!