C#

C#] Span<T>, ReadOnlySpan<T> 과 메모리 성능

Fastlane 2024. 4. 5. 16:30
728x90
반응형

성능은 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에 저장시키지 않고, 메모리를 덜 사용하므로 성능은 향상시킨다. 

728x90
반응형