C# 7.2의 특징 - GC 및 메모리 복사 방지를 위한 struct 타입 개선
지난 글들을 통해 C# 7.2의 struct 관련 문법을 소개했는데요.
C# 7.2 (1) - readonly 구조체
; https://www.sysnet.pe.kr/2/0/11524
C# 7.2 (2) - 메서드의 매개 변수에 in 변경자 추가
; https://www.sysnet.pe.kr/2/0/11525
기타 - Microsoft Build 2018 - The future of C# 동영상 내용 정리
; https://www.sysnet.pe.kr/2/0/11536
위에서 소개한 "
기타 - Microsoft Build 2018 - The future of C# 동영상 내용 정리" 글에서도 나왔지만 C# 7.2의 최대 특징은 GC와 메모리 복사를 줄이는 것에 있습니다. 이를 위해 크게 개선된 점이 바로 "구조체"(와 구조체로 정의된 "Span<T>")입니다.
그렇다면, 성능이 어느 정도 체감될까요?
예를 들어, 기존에 다음과 같이 작성된 Game Loop가 있다고 가정해 보겠습니다.
using System;
using System.Threading;
// .NET Core 2.1 + x64 + Release에서 테스트
namespace ConsoleApp2
{
class Program
{
static void Main(string[] args)
{
Thread t = new Thread(gcCheck);
t.IsBackground = true;
t.Start();
GameLoop();
}
private static void gcCheck()
{
long oldValue = 0;
while (true)
{
int gcCount = GC.CollectionCount(0) + GC.CollectionCount(1) + GC.CollectionCount(2);
long frame = _frame;
double fps = (frame - oldValue) / 60.0;
oldValue = frame;
Console.WriteLine(gcCount + ": " + fps);
Thread.Sleep(1000);
}
}
static int _frame;
private static void GameLoop()
{
while (true)
{
int x = Environment.TickCount % 30;
int y = Environment.TickCount % 35;
int z = Environment.TickCount % 60;
Vector player = new Vector(x, y, z);
x = Environment.TickCount % 30;
y = Environment.TickCount % 35;
z = Environment.TickCount % 60;
Vector speed = new Vector(x, y, z);
Vector newPos = player + speed;
_frame++;
}
}
}
class Vector
{
double _x;
double _y;
double _z;
public double X { get { return _x; } }
public double Y { get { return _y; } }
public double Z { get { return _z; } }
public Vector(double x, double y, double z)
{
_x = x;
_y = y;
_z = z;
}
public static Vector operator + (Vector v1, Vector v2)
{
return new Vector(v1.X + v2.X, v1.Y + v2.Y, v1.Z + v2.Z);
}
public override string ToString()
{
return $"({_x},{_y},{_z})";
}
}
}
이때 화면에 출력되는 GC 수와 fps를 보면 다음과 같습니다. (소수점 2자리 이하는 절삭했습니다.)
0: 0
501: 219024.1
1350: 370783.25
2320: 423876.9
3052: 319816.06
3959: 396515.31
4905: 413009.1
5799: 390606.53
6795: 435278.36
7713: 400970.31
8664: 415150.06
9612: 414202.71
...[생략]...
게임에서 GC 발생으로 인한 성능 저하는 가장 피하고 싶은 것 중의 하나일 텐데요, 이를 위해 Vector를 struct로 만들 수 있습니다.
struct Vector
{
//...[생략]...
}
이렇게 바꾸고 실행하면 GC가 발생하지 않으므로 성능이 2배 가까이 올라가는 것을 볼 수 있습니다.
0: 0
0: 832359.6
0: 887992.65
0: 894983.23
0: 893596.2
0: 847630.03
0: 884984.05
0: 897776.4
0: 883163.91
0: 911639.2
0: 899867.93
...[생략]...
더 개선할 수 있을까요? struct 인스턴스를 인자로 전달 시 값 복사가 발생하는 부하를 없애기 위해 in 예약어를 operator +에 적용해 보겠습니다.
struct Vector
{
//...[생략]...
public static Vector operator + (in Vector v1, in Vector v2)
{
return new Vector(v1.X + v2.X, v1.Y + v2.Y, v1.Z + v2.Z);
}
}
그런데 실제로 해보면 딱히 그다지 나아진 성능을 볼 수 없고 오히려 떨어졌습니다.
0: 0
0: 729735.63
0: 767630.78
0: 748675.51
0: 758570.78
0: 734026.31
0: 733838.71
0: 756316.41
0: 768155.45
0: 761856.18
0: 779781.7
0: 759016.11
0: 769092.55
이유는, v1.X, v2.X 등에서의 get 접근자로 인해 방어 복사본이 생성되었기 때문입니다. 이 부하를 없애기 위해 readonly를 적용해 볼까요?
readonly struct Vector
{
readonly double _x;
readonly double _y;
readonly double _z;
//...[생략]...
}
다시 성능 테스트를 해보면,... 그래도 거의 달라진 점 없이 struct만 적용했을 때를 따라잡지 못합니다.
0: 0
0: 736243.83
0: 775353.63
0: 753936.2
0: 758081.36
0: 775756.01
0: 707858.68
0: 780843.81
0: 749853.05
0: 774323.53
0: 771705.58
0: 710503.7
혹시 모르니, struct에 포함된 필드를 (기존 3개에서) 6개로 늘려보겠습니다.
struct Vector
{
double _x;
double _y;
double _z;
double _x2;
double _y2;
double _z2;
// ...[생략]...
public static Vector operator + (Vector v1, Vector v2)
{
return new Vector(...[생략]...);
}
}
위와 같이 하면 복사 부하가 늘어날 것으로 짐작되는데, 이럴 때 일반 struct인 경우 아래의 성능을 보이지만,
0: 0
0: 499835.38
0: 459274.05
0: 503823.2
0: 413757.51
0: 503190.33
0: 494314.35
0: 470810.4
0: 506102.25
0: 489019.13
복사 부하가 없어지는 readonly struct + in 예약어로 바꾸면,
readonly struct Vector
{
readonly double _x;
readonly double _y;
readonly double _z;
readonly double _x2;
readonly double _y2;
readonly double _z2;
// ...[생략]...
public static Vector operator + (in Vector v1, in Vector v2)
{
return new Vector(...[생략]...);
}
}
이번엔 성능이 조금 나아졌습니다.
0: 0
0: 475020.35
0: 597225.11
0: 549815.65
0: 578523.76
0: 603204.06
0: 616510.96
0: 598533.23
0: 567418.03
0: 601100.78
(
첨부 파일은 이 글의 예제 코드를 포함합니다.)
현상을 보면, struct의 복사 부하가 낮을 때는 readonly struct + in 예약어 처리가 살짝 느렸습니다. 반면 복사 부하가 늘어나면서 readonly struct + in 예약어 처리가 조금 빨라졌습니다.
이런 차이가 나는 이유는, 인라인 메서드 처리에 있습니다. 작은 수의 인자일 때(예제에서는 3개) .NET Core CLR은 일반 struct의 op_Addition 호출을 인라인으로 대체한 반면, readonly struct + in 메서드의 호출은 인라인 시키지 않았습니다. 이 때문에 in 예약어로 인한 값 복사 오버헤드가 없음에도 불구하고 일반적인 struct 구문이 더 빨랐던 것입니다.
반면 필드가 6개로 늘어나면서 일반 struct의 op_Addition 호출도 인라인되지 않고 함수 호출로 바뀝니다. 이 때문에 값 복사를 하지 않는 readonly struct + in 메서드의 호출이 더 나은 성능을 보이게 되는 것입니다. 여기서 재미있는 것은, 일반 struct의 op_Addition과 readonly struct + in일 때의 op_Addition 메서드의 IL 코드는 Readonly 특성을 제외하고 완전히 같다는 것입니다.
따라서 다음과 같이 옵션을 주면 readonly struct + in의 op_Addition 메서드에 대해서도 인라인 처리가 정상적으로 이뤄집니다.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Vector operator +(in Vector v1, in Vector v2)
{
return new Vector(v1.X + v2.X, v1.Y + v2.Y, v1.Z + v2.Z);
}
이때의 성능 차이는 일반 struct의 op_Addition과 비슷합니다. 아마도 readonly struct + in 메서드의 기본 인라인 처리는 CoreCLR 버전이 올라가면서 바뀔 수도 있을 것입니다.
그럼 정리해 볼까요? ^^
어찌 보면 readonly struct + in 처리가 성능을 높인 것이라기보다는, 최근의 함수형 프로그래밍 추세에 따라 immutable 타입 정의가 늘어나면서 발생하는 방어본 복사로 인한 성능 저하를 보완하는 형식으로 나왔다고도 볼 수 있습니다. 어쨌든, 결과적으로는 struct의 최대 단점이었던 복사 오버 헤드를 피할 수 있는 방법이 나왔고 그에 따라 스택의 부담이 줄어들면서 이전과는 다르게 보다 더 적극적으로 "값 형식"을 활용할만한 여지가 생긴 것입니다.
이러한 struct 관련 개선과 비-관리 메모리에 대한 Span 래퍼를 극단적으로 이용하게 되면 GC가 거의 발생하지 않는 닷넷 응용 프로그램을 만드는 것이 가능합니다. 이로 인해 게임 루프나 Request/Response 유형의 웹 기반 프레임워크에서는 부하를 최소화시켜 응용 프로그램의 성능을 보다 더 높일 수 있게 되었습니다. 그 좋은 사례로, .NET Core 스스로 이런 부분들을 적용해 성능 개선을 이뤄낸 것입니다.
Performance Improvements in .NET Core 2.1
; https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-core-2-1/
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]