Span<T>, Memory<T> - slog(완료)

Span와 Memory에 대해 기록을 남기려 합니다. 처음 등장했을 때 익숙해지려고 노력하다가 실패했는데, 익숙해진다면 자연스럽게 메모리를 효율적으로 사용하는 습관, 비관리메모리의 효과적인 접근, 명세 표현으로 관련 버그를 최소하 함 등 다양한 장점이 기대되므로, 관련 정보들을 수집 한 후 실습을 통해 익혀나가봅시다

1개의 좋아요

정성태님이 정리한 내용을 공유 합니다. 정성태님의 글은 철저히 검증하여 작성된 글이기 때문에 도움이 됩니다.

.NET Framework: 758. C# 7.2 - Span (sysnet.pe.kr)
.NET Framework: 759. C# - System.Span 성능 (sysnet.pe.kr)
.NET Framework: 768. BenchmarkDotNet으로 Span 성능 측정 (sysnet.pe.kr)
.NET Framework: 995. C# - Span와 Memory (sysnet.pe.kr)

1개의 좋아요

jacking75님이 번역하신 내용도 좋습니다.

Span 를 사용해야 할 5가지 이유 - jacking75

1개의 좋아요

Span은 ref struct이기 때문에 스택에만 할당할 수 있습니다. 다음의 성태님 정리로 사용하면 됩니다.

  1. 평소에는 성능을 위해 Span를 사용하고, 2) 간혹 해당 버퍼를 다른 타입의 필드로 들고 있어야 할 때 Memory를 사용하다가, 3) 다시 그것을 접근해야 할 때는 Span로 캐시해 사용하는 것
1개의 좋아요

Span및 Memory는 인접한 임의 메모리 영역에 대해 형식 및 메모리 안전 표현을 제공합니다.

이에 반해 ReadOnlySequence는 불연속적인 메모리를 합쳐서 순차적인 접근이 가능하도록 합니다.
image

1개의 좋아요

내용 감사합니다. :grinning: :grinning:

2개의 좋아요

도식으로 잘 설명된 좋은 글을 링크 합니다. 내용 있으므로 찬찬히 시간을 두고 읽으시면 도움이 될 것 같습니다.

New NET Core 2.1 Flagship Types: Span and Memory (codemag.com)

2개의 좋아요

정성태님의
.NET Framework: 768. BenchmarkDotNet으로 Span 성능 측정 (sysnet.pe.kr)

벤치마크 코드를 이용한 결과는 다음과 같습니다.

image

Span는 비관리 메모리를 배열처럼 접근할 수 있다는 장점도 있지만, 바이트 배열은 비용없이 쪼갤 수 없지만, Span는 필요한 영역만 잘라 쓸 수 있고 비용 또한 없다는게 장점인 것 같습니다.

(byte[], offset, length) buffer 보다는 Span buffer가 코드도 깔끔하고 버그가 발생할 여지가 적다는 것이죠.

1개의 좋아요

C#의 사용자 지정 이진 직렬화 - :eyeglasses: 읽을 거리 - 닷넷데브 (dotnetdev.kr)

1개의 좋아요

이 글이 저에겐 실질적으로 도움이 됐습니다. 개념적으로 이해하는 것과 손이 나가는 것은 조금 다른 감각인 것 같네요. 마치 영어의 독해와 회화 같이요.

참고하여 Span 확장 클래스를 만들어 봤습니다. 같이 보시죠.

public static class SpanExtension
{
    public static ReadOnlySpan<byte> AsReadOnlyBytes<T>(this ref T @this)
        where T : struct
    {
        var span = MemoryMarshal.CreateReadOnlySpan(ref @this, 1);
        return MemoryMarshal.Cast<T, byte>(span);
    }

    public static ReadOnlySpan<byte> AsReadOnlyBytes<T>(this T[] @this)
        where T : struct
    {
        return MemoryMarshal.Cast<T, byte>(@this);
    }

    public static Span<byte> AsBytes<T>(this ref T @this)
        where T : struct
    {
        var span = MemoryMarshal.CreateSpan(ref @this, 1);
        return MemoryMarshal.Cast<T, byte>(span);
    }

    public static Span<byte> AsBytes<T>(this T[] @this)
        where T : struct
    {
        return MemoryMarshal.Cast<T, byte>(@this);
    }
}

확장을 보시면 MemoryMarshal의 기능을 이용한 것 뿐이지만, 강력합니다. 이 확장을 이용해 다음과 같이 Stream에 쓰거나 읽는 코드를 단순화 하였습니다.

    public void Write(Stream s)
    {
        s.Write(체결시각.AsReadOnlyBytes());
        s.Write(체결가.AsReadOnlyBytes());
        s.Write(체결수량.AsReadOnlyBytes());
        s.Write(매수호가잔량.AsReadOnlyBytes());
        s.Write(매도호가잔량.AsReadOnlyBytes());
    }

    public static bool Read(Stream s, ref 실시간시세 v)
    {

        var length = s.Read(v.체결시각.AsBytes());
        if (length == 0)
            return false;
        
        s.Read(v.체결가.AsBytes());
        s.Read(v.체결수량.AsBytes());
        v.매수호가잔량 = new decimal[5];
        s.Read(v.매수호가잔량.AsBytes());
        v.매도호가잔량 = new decimal[5];
        s.Read(v.매도호가잔량.AsBytes());

        return true;
    }

AsReadOnlyBytes()를 통해 메모리 할당 없이 해당 필드를 ReadOnlySpan로 변환하여 쓰기를 합니다.
더욱 재밌는 것은 AsBytes()를 통해 해당 필드의 메모리 위치를 Span로 변환하여 바로 읽기할 수 있다는 것입니다.

2개의 좋아요

호가는 배열인데 할당을 별도로 해야 하는 이유는 C#의 배열은 참조기 때문입니다. 'fixed’를 쓰면 되는데 'unsafe’라 일반적으로는 잘 안쓰는것 같습니다

2개의 좋아요

슬라이싱의 강력함을 설명한 글입니다.

1개의 좋아요

임시실시간시세 1Gbytes (500만개)를 읽는 코드입니다. 초당 한개씩 발생한다고 할 때, 대략 60일 정도의 데이터가 됩니다. 흥미로운 것은 List로 목록을 취합하더라도 T[]과 큰 차이가 나지 않습니다. List가 최적화 되어 있다는 것이겠죠

void 임의시세목록_읽기()
{
    var filename = "실시간시세.dat";
    using var fs = File.OpenRead(filename);
    var length = fs.Length;
    var itemCount = length / Marshal.SizeOf<실시간시세>();
    Console.WriteLine($"File Length: {length}");
    Console.WriteLine($"Count: {itemCount}");

    var v = new 실시간시세();
    //var 실시간시세목록 = new List<실시간시세>();
    var 실시간시세목록 = new 실시간시세[itemCount];

    var count = 0;
    var sw = Stopwatch.StartNew();
    while (count < itemCount)
    {
        //var result = 실시간시세.Read(fs, ref 실시간시세목록[count]);
        var result = 실시간시세.Read(fs, ref v);
        if (result == false)
            break;
        //실시간시세목록.Add(v);
        실시간시세목록[count] = v;

        count++;
    }
    sw.Stop();

    // List<T> : 2571 ms
    // T[], 스택 임시값 저장 후 복사 : 2354 ms
    // T[], 직접 저장 : 2339 ms
    Console.WriteLine($"Elapsed Time: {sw.ElapsedMilliseconds}");
}
2개의 좋아요

조금 더 다른 접근을 해봤습니다. 만약, MemoryMappedFile을 이용해 Span<byte>을 획득할 수 만 있다면, 좀 더 효율적인 대량 데이터를 빠르게 처리할 수 있을꺼라 생각했습니다.

확인한 결과, 어쩔 수 없이 unsafe영역을 사용해야 했고, decimal이 고정 배열이 안된다는 사실도 알게 되었지만, 어쨌든 상당히 빠른 속도로 목록(Span<실시간시세2>)을 생성할 수 있었습니다.

void 임의시세목록_읽기2()
{
    var filename = "실시간시세.dat";
    var fileLength = new FileInfo(filename).Length;

    using var mmf = MemoryMappedFile.CreateFromFile(filename, FileMode.Open);
    using var accessor = mmf.CreateViewAccessor();

    Span<byte> memory;
    unsafe
    {
        byte* ptr = null;
        accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr);
        ptr += accessor.PointerOffset;

        //memory = new Span<byte>(ptr, (int)accessor.SafeMemoryMappedViewHandle.ByteLength);
        memory = new Span<byte>(ptr, (int)fileLength);
    }


    var 실시간시세목록 = MemoryMarshal.Cast<byte, 실시간시세2>(memory);
    foreach (var item in 실시간시세목록)
    {
        ;
    }

    accessor.SafeMemoryMappedViewHandle.ReleasePointer();
}

[StructLayout(LayoutKind.Sequential, Pack = 1)]
unsafe struct 실시간시세2
{
    public long 체결시각;
    public decimal 체결가;
    public decimal 체결수량;
    public fixed int _매수호가잔량[4 * 5];
    public fixed int _매도호가잔량[4 * 5];
}
1개의 좋아요

TLS로 실행되는 코드 공유 합니다. 이것으로 Span<T>에 대한 공부는 마무리 하고, 다음번에 Memory<T>Memory<T>와 관련된 .NET 기능들을 살펴볼 예정입니다.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.IO.MemoryMappedFiles;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

// 모든 예제는 Top-level-statements(TLS)로 작성합니다.

// 제목: 대용량 파일 데이터를 .NET C#으로 처리하는 방법
// 주제: 아주 큰 데이터를 처리하려고 할 때 .NET에서 사용할 수 있는 가장 효과적인 방법을 찾는다.
// 전제: 1) 주식 실시간 시세 목록이 있다고 가정한다. 이 시세는 임의로 약 1Gbytes를 생성해둔다.
//       2) 하나의 시세는 정적 길이를 갖는다.
// 진행: 0) 임의의 실시간 시세 정보를 파일로 저장한다. (1Gbytes)
//       1) 가장 일반적인 FileStream으로 데이터를 처리하는 코드를 작성한다.
//       X 2) 극적인 속도 향상을 위해 실시간 시세를 메모리로 올린 후 Memory<T>를 이용해서 구현한다.
//       3) MemoryMappedFile을 이용해서 구현한다.


임의시세목록_만들기();
임의시세목록_읽기2();


void 임의시세목록_만들기()
{
    var filename = "실시간시세.dat";
    if (File.Exists(filename) == true)
        return;

    var now = DateTime.Now;
    var v = new 실시간시세
    {
        체결시각 = now.ToBinary(),
        체결가 = 15000,
        체결수량 = 100,
        매수호가잔량 = new decimal[] { 1, 2, 3, 4, 5 },
        매도호가잔량 = new decimal[] { 11, 22, 33, 44, 55 }
    };

    // Unsafe.Sizeof<T>로도 사이즈를 알 수 있으나, 참조형의 경우 참조 사이즈도 계산해야 하므로 Marshal.SizeOf<T>를 사용 함
    var count = 1000 * 1000 * 1000 / Marshal.SizeOf<실시간시세>();

    Console.WriteLine($"Marshal SizeOf: {Marshal.SizeOf<실시간시세>()} bytes");
    Console.WriteLine($"Unsafe SizeOf: {Unsafe.SizeOf<실시간시세>()} bytes");
    Console.WriteLine($"Count: {count}");

    using var fs = File.OpenWrite(filename);
    for (var i = 0; i < count; i++)
    {
        v.체결시각 += 1;
        v.체결수량 += 1;

        v.Write(fs);
    }

    fs.Dispose();

    var fi = new FileInfo(filename);
    Console.WriteLine($"File Length : {fi.Length} bytes");
}

#pragma warning disable CS8321 // 로컬 함수가 선언되었지만 사용되지 않음
void 임의시세목록_읽기()
#pragma warning restore CS8321 // 로컬 함수가 선언되었지만 사용되지 않음
{
    var filename = "실시간시세.dat";
    using var fs = File.OpenRead(filename);
    var length = fs.Length;
    var itemCount = length / Marshal.SizeOf<실시간시세>();
    Console.WriteLine($"File Length: {length}");
    Console.WriteLine($"Count: {itemCount}");

    var v = new 실시간시세();
    //var 실시간시세목록 = new List<실시간시세>();
    var 실시간시세목록 = new 실시간시세[itemCount];

    var count = 0;
    var sw = Stopwatch.StartNew();
    while (count < itemCount)
    {
        //var result = 실시간시세.Read(fs, ref 실시간시세목록[count]);
        var result = 실시간시세.Read(fs, ref v);
        if (result == false)
            break;
        //실시간시세목록.Add(v);
        실시간시세목록[count] = v;

        count++;
    }
    sw.Stop();

    // List<T> : 2571 ms
    // T[], 스택 임시값 저장 후 복사 : 2354 ms
    // T[], 직접 저장 : 2339 ms
    Console.WriteLine($"Elapsed Time: {sw.ElapsedMilliseconds}");
}

/// <summary>
/// MemoryMappedFile로 Span<byte> -> Span<실시간시세>로 변환하여 직접 접근
/// </summary>
void 임의시세목록_읽기2()
{
    var filename = "실시간시세.dat";
    var fileLength = new FileInfo(filename).Length;

    using var mmf = MemoryMappedFile.CreateFromFile(filename, FileMode.Open);
    using var accessor = mmf.CreateViewAccessor();

    Span<byte> memory;
    unsafe
    {
        byte* ptr = null;
        accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr);
        ptr += accessor.PointerOffset;

        //memory = new Span<byte>(ptr, (int)accessor.SafeMemoryMappedViewHandle.ByteLength);
        memory = new Span<byte>(ptr, (int)fileLength);
    }


    var 실시간시세목록 = MemoryMarshal.Cast<byte, 실시간시세2>(memory);
    foreach (var item in 실시간시세목록)
    {
        ;
    }

    accessor.SafeMemoryMappedViewHandle.ReleasePointer();
}

/// <summary>
/// C#의 배열은 참조이므로, MemoryMappedFile에서 사용하기 위해 고정 배열로 접근
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
unsafe struct 실시간시세2
{
    public long 체결시각;
    public decimal 체결가;
    public decimal 체결수량;
    public fixed int _매수호가잔량[4 * 5];
    public fixed int _매도호가잔량[4 * 5];
}

[StructLayout(LayoutKind.Sequential, Pack = 1)]
struct 실시간시세
{
    public long 체결시각;
    public decimal 체결가;
    public decimal 체결수량;
    // Marshal.Sizeof 계산 용도
    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 5)]
    public decimal[] 매수호가잔량;
    // Marshal.Sizeof 계산 용도
    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 5)]
    public decimal[] 매도호가잔량;

    public void Write(Stream s)
    {
        s.Write(체결시각.AsReadOnlyBytes());
        s.Write(체결가.AsReadOnlyBytes());
        s.Write(체결수량.AsReadOnlyBytes());
        s.Write(매수호가잔량.AsReadOnlyBytes());
        s.Write(매도호가잔량.AsReadOnlyBytes());
    }

    public static bool Read(Stream s, ref 실시간시세 v)
    {

        var length = s.Read(v.체결시각.AsBytes());
        if (length == 0)
            return false;
        
        s.Read(v.체결가.AsBytes());
        s.Read(v.체결수량.AsBytes());
        v.매수호가잔량 = new decimal[5];
        s.Read(v.매수호가잔량.AsBytes());
        v.매도호가잔량 = new decimal[5];
        s.Read(v.매도호가잔량.AsBytes());

        return true;
    }
}


/// <summary>
/// Span 확장
/// 
/// MemoryMarshal.Cast<T, byte>(span)의 경우 MemoryMarshal.AsBytes(span)을 대신 사용할 수 있음
/// </summary>
public static class SpanExtension
{
    public static ReadOnlySpan<byte> AsReadOnlyBytes<T>(this ref T @this)
        where T : struct
    {
        var span = MemoryMarshal.CreateReadOnlySpan(ref @this, 1);
        return MemoryMarshal.Cast<T, byte>(span);
    }

    public static ReadOnlySpan<byte> AsReadOnlyBytes<T>(this T[] @this)
        where T : struct
    {
        return MemoryMarshal.Cast<T, byte>(@this);
    }

    public static Span<byte> AsBytes<T>(this ref T @this)
        where T : struct
    {
        var span = MemoryMarshal.CreateSpan(ref @this, 1);
        return MemoryMarshal.Cast<T, byte>(span);
    }

    public static Span<byte> AsBytes<T>(this T[] @this)
        where T : struct
    {
        return MemoryMarshal.Cast<T, byte>(@this);
    }
}
2개의 좋아요

Span<T>은 스택에만 생성할 수 있어서 속도스레드 안정성을 확보할 수 있었던 대신 관리힙에 적재할 수 없는 단점이 있는데, Memory<T>struct으로 관리힙에 적재가 되기 때문에, 연속메모리를 계속 유지할 수 있습니다.

하지만 힙 메모리에 있을 수 있다는 것은 다중 스레드에서 Memory<T>에 접근 할 수 있다는 이야기 인데요, 그래서 Memory<T>에는 소유자/소비자 모델이 등장합니다.

Memory 및 Span 사용 지침 | Microsoft Docs

규칙 #1: 동기 API의 경우 가능하면 Memory 대신 Span를 매개 변수로 사용합니다.
규칙 #2: 버퍼가 읽기 전용이어야 하는 경우 ReadOnlySpan 또는 ReadOnlyMemory 사용 을 참조하세요.
규칙 #3: 메서드가 Memory를 사용하고 void를 반환하는 경우 메서드가 반환된 후에는 Memory 인스턴스를 사용하면 안 됩니다.
규칙 #4: 메서드가 Memory를 사용하고 Task를 반환하는 경우, Task가 터미널 상태로 전환된 후에는 Memory 인스턴스를 사용하면 안 됩니다.
규칙 #5: 생성자가 Memory를 매개 변수로 사용하는 경우 생성된 개체의 인스턴스 메서드가 Memory 인스턴스의 소비자로 간주됩니다.
규칙 #6: 설정 가능한 Memory 형식의 속성(또는 동등한 인스턴스 메서드)이 형식에 있는 경우 해당 개체의 인스턴스 메서드는 Memory 인스턴스의 소비자로 간주됩니다.
규칙 #7: IMemoryOwner 참조가 있는 경우 일정 시점에서 삭제하거나 해당 소유권을 이전해야 합니다(둘 다는 아님).
규칙 #8: API 노출 영역에 IMemoryOwner 매개 변수가 있는 경우 해당 인스턴스의 소유권을 허용하는 것입니다.
규칙 #9: 동기 P/Invoke 메서드를 래핑하는 경우 API가 Span를 매개 변수로 사용해야 합니다.
규칙 #10: 비동기 P/Invoke 메서드를 래핑하는 경우 API가 Memory를 매개 변수로 사용해야 합니다.

음. 규칙이 생각보다 많군요. 이럴때는 메모리 접근 동시성을 언어적으로 지원하는 D Language나 Rust 언어가 부럽다는 생각을 해봅니다.

2개의 좋아요