Span와 Memory에 대해 기록을 남기려 합니다. 처음 등장했을 때 익숙해지려고 노력하다가 실패했는데, 익숙해진다면 자연스럽게 메모리를 효율적으로 사용하는 습관, 비관리메모리의 효과적인 접근, 명세 표현으로 관련 버그를 최소하 함 등 다양한 장점이 기대되므로, 관련 정보들을 수집 한 후 실습을 통해 익혀나가봅시다
정성태님이 정리한 내용을 공유 합니다. 정성태님의 글은 철저히 검증하여 작성된 글이기 때문에 도움이 됩니다.
.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)
Span은 ref struct
이기 때문에 스택에만 할당할 수 있습니다. 다음의 성태님 정리로 사용하면 됩니다.
- 평소에는 성능을 위해 Span를 사용하고, 2) 간혹 해당 버퍼를 다른 타입의 필드로 들고 있어야 할 때 Memory를 사용하다가, 3) 다시 그것을 접근해야 할 때는 Span로 캐시해 사용하는 것
Span및 Memory는 인접한 임의 메모리 영역에 대해 형식 및 메모리 안전 표현을 제공합니다.
이에 반해 ReadOnlySequence는 불연속적인 메모리를 합쳐서 순차적인 접근이 가능하도록 합니다.
내용 감사합니다.
도식으로 잘 설명된 좋은 글을 링크 합니다. 내용 있으므로 찬찬히 시간을 두고 읽으시면 도움이 될 것 같습니다.
New NET Core 2.1 Flagship Types: Span and Memory (codemag.com)
정성태님의
.NET Framework: 768. BenchmarkDotNet으로 Span 성능 측정 (sysnet.pe.kr)
벤치마크 코드를 이용한 결과는 다음과 같습니다.
Span는 비관리 메모리를 배열처럼 접근할 수 있다는 장점도 있지만, 바이트 배열은 비용없이 쪼갤 수 없지만, Span는 필요한 영역만 잘라 쓸 수 있고 비용 또한 없다는게 장점인 것 같습니다.
(byte[], offset, length) buffer 보다는 Span buffer가 코드도 깔끔하고 버그가 발생할 여지가 적다는 것이죠.
이 글이 저에겐 실질적으로 도움이 됐습니다. 개념적으로 이해하는 것과 손이 나가는 것은 조금 다른 감각인 것 같네요. 마치 영어의 독해와 회화 같이요.
참고하여 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로 변환하여 바로 읽기할 수 있다는 것입니다.
호가는 배열인데 할당을 별도로 해야 하는 이유는 C#의 배열은 참조기 때문입니다. 'fixed’를 쓰면 되는데 'unsafe’라 일반적으로는 잘 안쓰는것 같습니다
슬라이싱의 강력함을 설명한 글입니다.
임시실시간시세 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}");
}
조금 더 다른 접근을 해봤습니다. 만약, 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];
}
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);
}
}
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 언어가 부럽다는 생각을 해봅니다.