C#] Span<T>, ReadOnlySpan<T> 과 메모리 성능
성능은 software 개발자에게 항상 중요한 문제이다. .NET 팀에서 Span<> 구조체를 release했을때, 개발자들은 앱 성능을 강화할 수 있게 되었다. 어떻게 구현하고, 사용할 수 있는지 알아보자.
C#에서 Span<T>이란?
Span<>은 ref struct object로 구현되며, 이것은 Span이 항상 stack memory에 할당된 다는 것을 나타낸다.
Span<>은 pointer와 length를 갖는 stuct로 나타낼 수 있다.
public readonly ref struct Span<T>
{
private readonly ref T _pointer;
private readonly int _length;
}
힙에 있는 T type object의 reference와 length가 있다.
Span<>은 항상 stack에 위치하므로 성능을 향상시킨다. Span<>은 배열에서 효율적으로 동작한다.
heap에 할당된 bytes 배열이 있다고 가정해보자.
이 byte 배열을 span 생성자에 전달해 span으로 감쌀 수 있다. pointer는 data가 시작하는 (배열의 0번째 element) 메모리 주소를 갖는다. length field는 연이은 접근가능한 elements의 개수를 갖는다. (여기서는 배열의 길이)
만약에 array의 일부분만 필요하다면 span을 slice할 수 있다. Slicing은 heap에 어떤 것도 할당하거나 새로운 span을 만들때 copy하지 않으므로 매우 효율적이다.
Span은 단지 view일뿐, memory block을 초기화하지 않는다. Span의 또 다른 구현은 ReadOnlySpan<>이다. Span<T>는 memory에 read-write접근을 제공하고, ReadOnlySpan<T>는 read-only접근만 제공한다. ref T 가 아닌 readonly ref T를 return한다. String과 같은 immutable data type도 나타낼 수 있다.
Span은 int, byte, ref structs, bool, enum과 같은 value type과 사용할 수 있고, object, dynamic, interface 같은 type은 사용할 수 없다.
.NET Core 2.1부터 내장된 Span API보다 portable Span API( System.Memory NuGet package )가 약간 더 느릴 수 있다.
Span Limitations
컴파일러는 reference type object는 heap에 할당한다. 따라서 span을 reference type의 fields로 사용할 수 없다. 더 정확히 ref struct object는 다른 value-type objects처럼 boxed될 수 없다. 동일한 이유로 lambda statement는 span을 사용할 수 없다. await와 yield를 사용하는 비동기 프로그래밍에서 사용할 수 없다.
- stack 실행 시에만 존재한다.
- boxed되거나, heap에 위치할 수 없다.
- generic type argument로 사용될 수 없다.
- stack-only가 아닌 type의 instance field가 될 수 없다.
- 비동기 함수와 함께 사용할 수 없다.
String 대신 ReadOnlySpan 사용하기
성능 향상을 위해 어떻게 strings 대신 ReadOnlySpan<>을 사용하는지 알아보자.
line별로 string을 파싱하는 아래 예를 살펴보자. 파일에 얼마나 많은 빈줄이 있는지 알아보려고 한다.
문자열은 _hamletText 변수에 저장되어 있다. _hamletText 의 각 char별로 순환하다가 \n을 찾으면 Substring()을 사용해서 새로운 string을 만든다. 줄이 비어있으면 rowNum++ 를 한다. 여기서 문제는 Substring()이 heap에 string을 만든다는 점이다. garbage collector가 이 string을 정리하는데는 시간이 필요하다.
public void ParseWithString()
{
var indexPrev = 0;
var indexCurrent = 0;
var rowNum = 0;
foreach (char c in _hamletText)
{
if (c == '\n')
{
indexCurrent += 1;
var line = _hamletText.Substring(indexPrev, indexCurrent - indexPrev);
if (line.Equals(Environment.NewLine))
rowNum++;
indexPrev = indexCurrent;
continue;
}
indexCurrent++;
}
Console.WriteLine($"Number of empty lines in a file: {rowNum}");
}
이제 ReadOnlySpan<>을 사용해서 동일한 프로세스를 구현하자.
새 줄에 대한 추가 string을 만드는 것을 제외하고 모두 동일하다. AsSpan()을 호출하므로, text string을 ReadOnlySpan<>으로 변환한다. Substring() 대신에 Slice()를 사용한다. 이 과정에서 어떤 것도 heap에 할당되지 않는다.
garbage collecting object를 메모리로 호출하는 것은 앱 성능에 영향을 주는 실행을 잠시 중단시킨다.
ReadOnlySpan<>은 Sting과 유사한 함수를 많이 갖고 있따. Contains(), EndsWith(), StartsWith(), IndexOf(), LastIndexOf(), ToString(), Trim()을 사용할 수 있다.
public void ParseWithSpan()
{
var hamletSpan = _hamletText.AsSpan();
var indexPrev = 0;
var indexCurrent = 0;
var rowNum = 0;
foreach (char c in hamletSpan)
{
if (c == '\n')
{
indexCurrent += 1;
var slice = hamletSpan.Slice(indexPrev, indexCurrent - indexPrev);
if (slice.Equals(Environment.NewLine, StringComparison.OrdinalIgnoreCase))
rowNum++;
indexPrev = indexCurrent;
continue;
}
indexCurrent++;
}
Console.WriteLine($"Number of empty lines in a file: {rowNum}");
}
Byte 배열의 elements의 합 반환하기
public static int ArraySum(byte[] data)
{
int sum = 0;
for (int i = 0; i < data.Length; i++)
{
sum += data[i];
}
return sum;
}
span으로 대체해보자
public static int SpanSum(Span<byte> data)
{
int sum = 0;
for (int i = 0; i < data.Length; i++)
{
sum += data[i];
}
return sum;
}
input parameter를 제외하고는 변경되는 것이 없다.
static void Main(string[] args)
{
byte[] data = { 1, 2, 3, 4, 5, 6, 7 };
ArraySum(data); // returns 28
SpanSum(data); // returns 28
}
둘 차이에 성능 차이는 없다. spans과 arrays를 순환하는 것 사이에 성능 차이는 거의 없다.
Comma로 분리된 숫자를 포함하는 String의 합 반환하기
public static int StringParseSum(string data)
{
int sum = 0;
// allocates
string[] splitString = data.Split(',');
for (int i = 0; i < splitString.Length; i++)
{
sum += int.Parse(splitString[i]);
}
return sum;
}
span으로 변경해보자.
public static int StringParseSum(string data)
{
ReadOnlySpan<char> span = data;
int sum = 0;
while (true)
{
int index = span.IndexOf(',');
if (index == -1)
{
sum += int.Parse(span);
break;
}
sum += int.Parse(span.Slice(0, index));
span = span.Slice(index + 1); // skip ','
}
return sum;
}
원래 값은 그대로 두고, Array의 마지막 element값 반환하기
public static int HeapAllocReverseArray(int[] data)
{
// Heap-allocated array for defensive copy
int[] array = new int[data.Length];
Array.Copy(data, array, data.Length);
Array.Reverse(array);
return array[0];
}
input array가 stack에 저장해도 될 정도로 작다고 하면, stack memory 할당을 통해 heap 할당을 피할 수 있다. 128bytes 사이트 정도만 가능하다. thread 당 기본 stack 사이즈가 one megabyte이다.
public static int UnsafeStackAllocReverse(int[] data)
{
unsafe
{
// We lose safety and bounds checks
int* ptr = stackalloc int[data.Length];
// No APIs available to copy and reverse
for (int i = 0; i < data.Length; i++)
{
ptr[i] = data[data.Length - i - 1];
}
return ptr[0];
}
}
stackalloc pointer를 Span<T>로 감쌀 수 있다.
public static int UnsafeStackAllocReverse(int[] data)
{
unsafe
{
int* ptr = stackalloc int[data.Length];
// Using Span ctor that takes a pointer
var span = new Span<int>(ptr, data.Length);
// Easy to use span APIs
data.CopyTo(span);
span.Reverse();
return span[0];
}
}
stack-allocated와 heap-allocated를 아래와 같이 한 method로 합칠 수 있다.
public static int SafeStackOrHeapAllocReverse(int[] data)
{
// Chose an arbitrary small constant
Span<int> span = data.Length < 128 ?
stackalloc int[data.Length] :
new int[data.Length];
data.CopyTo(span);
span.Reverse();
return span[0];
}
Collection 대신 Span 사용하기
Span은 메모리의 연속영역을 나타낼 수 있기 때문에, arrays를 포함한 collection types을 사용할 수 있다.
int[] arr = new[] { 0, 1, 2, 3 };
// public Span(T[] array) 생성자 사용
Span<int> span = new Span<int>(arr);
// 암시적 변환 사용
Span<int> intSpan = arr;
// 확장함수 사용
var otherSpan = arr.AsSpan();
C#은 T[]에서 Span<T>로 암시적 변환을 제공하지만, 배열에 AsSpan()을 호출할 수도 있다. 암시적 변환은 span을 받는 함수에 arrays를 전달할 때 유용하다.
arrays와 동일하게 Span<T> indexer를 사용해서 data에 직접 접근하거나 수정할 수 있다. Span은 범위 밖의 elements에 접근하면 IndexOutOfRangeException을 던진다.
string[] array = { "a", "b", "c", "d", "e" };
// Using Span ctor (array, start, length)
// Note that the spans overlap
var firstView = new Span<string>(array, 0, 3);
var secondView = new Span<string>(array, 2, 3);
firstView[0] = "w";
// array = { "w", "b", "c", "d", "e" }
firstView[2] = "x";
// array = { "w", "b", "x", "d", "e" }
secondView[0] = "y";
// array = { "w", "b", "y", "d", "e" }
// Throws IndexOutOfRangeException
firstView[4] = "a";
ReadOnlySpan<>처럼 Span<>도 비슷한 함수를 제공한다.
List<int> intList = new() { 0, 1, 2, 3 };
var listSpan = CollectionsMarshal.AsSpan(intList);
collection을 span으로 얻기 위해서는 CollectionsMarshal.AsSpan() 함수를 사용해야 한다.
marshal을 사용하기 위해 System.Runtime.InteropServices를 import한다.
Span은 새로운 객체를 help에 저장시키지 않고, 메모리를 덜 사용하므로 성능은 향상시킨다.