Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
(연관된 글이 2개 있습니다.)

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/




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]

[연관 글]






[최초 등록일: ]
[최종 수정일: 12/16/2022]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 



2018-06-13 05시33분
[spowner] 본문 소스코드 중, _newPos 관련 오류 발생합니다~
[guest]
2018-06-13 08시17분
struct_perf_compare.zip 파일을 다운로드해서 빌드했는데 오류가 발생한다는 건가요? (어쨌든, 방금도 해봤는데 잘 빌드가 됩니다.)
정성태
2018-06-14 01시28분
[spowner] 아 다운로드 파일이 있는지 확인을 못했습니다. 본문의 소스코드를 복사해 실행했더니 생겼습니다
[guest]
2018-06-14 08시54분
본문 예제 수정했습니다. ^^
정성태
2022-12-16 10시30분
[우잭손] 글 잘읽고 도움이 많이 되고 있습니다. 그런데 방어복사본을 회피하기 위해 멤버변수에 readonly를 주셨는데. 멤버변수가 아니라 X,Y,Z 속성에 readonly를 줘야 방어복사본이 생기는걸 회피할수 있는걸로 생각되는데요.
실제로 readonly public double X { get { return _x; }} 와 같이 하였을경우 성능 향상이 대폭 있음을 확인할 수 있었습니다.
[guest]
2022-12-16 02시57분
@우잭손 글쎄요, 제가 테스트해보면 딱히 그 둘(구조체/필드가 readonly이거나, get 메서드가 readonly이거나) 간의 차이는 없었는데요, 혹시 테스트하신 소스 코드에 해당하는 2개의 프로젝트를 첨부해주시겠어요?

참고로, 본문의 테스트 코드는 C# 7.2 시절에 소개한 것이며, 말씀하신 속성(property)의 readonly 구문은 8.0에 나온 것입니다. 제 책의 "16.13 구조체의 읽기 전용 메서드" 절에서도 설명했지만 속성에 대해 readonly를 적용해도 동일하게 방어 복사본 문제는 해결됩니다.

따라서 이론상 2개의 성능 차이는 없습니다. (실제로 제가 테스트한 경우에도 그렇습니다.)
정성태
2022-12-16 04시40분
[우잭손] 네. PC에 DRM이 걸려있어서 소스 업로드가 안되네요. Decompiler로 까보면 멤버만 readonly한 경우에는 방어 복사본이 발생하는것을 볼수 있었습니다.

잘 생각해 보면 get; 속성이 자동 생성된 것이면 readonly를 보장할 수 있으나 get 구문을 Vector구조체 처럼 직접 작성한 경우는 get 프로퍼티에서 내용을 수정하지 않는다는것을 보장하기 어협겠다는 생각이 듭니다.
정성태 선생님 테스트 코드를 Decompiler로 한번 까보시면 되지 않을까요?
[guest]
2022-12-16 05시27분
이 글에 첨부된 "struct_perf_compare.zip" 파일을 다운로드해보세요.

ConsoleApp2 프로젝트가 readonly 구조체/필드를 사용한 것이고, (우잭손 님이 더 빠르다고 주장하는) ConsoleApp3 프로젝트가 readonly 속성을 사용한 예입니다.

2개 실행해 보면, 속도가 거의 차이가 없을 것입니다. 마찬가지로 2개 모두 (decompiler로 열어봐도) 방어 복사본 문제가 없습니다.

------------

그리고 방어 복사본에 대해 잘못 알고 계신 듯한데요. "operator +" 메서드에서 in으로 Vector를 받으면 readonly + ref 유형으로 처리해야 하기 때문에 내부에서 방어 복사본 문제가 발생하는 것입니다. 하지만, in으로 받은 Vector가 readonly로 정의돼 있으면, 즉 필드가 readonly이면 어차피 get 프로퍼티를 호출해도 그 내부에서 값이 변하지 않는다는 것을 보장받을 수 있으므로 방어 복사본 문제가 발생하지 않습니다. get 속성의 자동 생성 여부와는 아무런 관련이 없습니다.


아마도 뭔가 잘못 테스트하고 계신 것이 아닌가 싶은데... 증거를 못 보여주시니 답답하군요. ^^;
정성태
2022-12-19 09시19분
[우잭손] 안녕하세요. 정성태 선생님.
첨부해 주신 struct_perf_compare.zip 파일을 확인해 보았습니다.

ConsoleApp2의 stuct Vector를 보시면 구조체 수준에 readonly를 해 놓으셨고(readonly struct Vector), ConsoleApp3는 속성에 readonly를 해 놓으셔서 비교가 조금 무리가 있어 보입니다.
(이 글 본문에도 구조체 자체에 readonly라는 제약이 없었거든요)

결국 ConsoleApp2의 구조체 readonly때문에 속도 차이는 ConsoleApp3와 나지 않는것이 맞습니다.
그러나 ConsoleApp2의 구조체 readonly를 제거하고 멤버변수 readonly만 있을경우 Decompiler로 확인했을시 방어복사본이 생기고 속도도 2배 정도 느려지는것을 확인할 수 있었습니다.

원래 전제가 구조체 수준의 readonly였을까요? 그렇다면 제가 간과한 부분이 있었네요~
앞으로도 좋은글 부탁드립니다~^^
[guest]

... [31]  32  33  34  35  36  37  38  39  40  41  42  43  44  45  ...
NoWriterDateCnt.TitleFile(s)
12843정성태10/3/20216582스크립트: 29. 파이썬 - fork 시 기존 클라이언트 소켓 및 스레드의 동작파일 다운로드1
12842정성태10/1/202124744오류 유형: 763. 파이썬 오류 - AttributeError: type object '...' has no attribute '...'
12841정성태10/1/20218369스크립트: 28. 모든 파이썬 프로세스에 올라오는 특별한 파일 - sitecustomize.py
12840정성태9/30/20218407.NET Framework: 1119. Entity Framework의 Join 사용 시 다중 칼럼에 대한 OR 조건 쿼리파일 다운로드1
12839정성태9/15/20219485.NET Framework: 1118. C# 11 - 제네릭 타입의 특성 적용파일 다운로드1
12838정성태9/13/20219111.NET Framework: 1117. C# - Task에 전달한 Action, Func 유형에 따라 달라지는 async/await 비동기 처리 [2]파일 다운로드1
12837정성태9/11/20218049VC++: 151. Golang - fmt.Errorf, errors.Is, errors.As 설명
12836정성태9/10/20217627Linux: 45. 리눅스 - 실행 중인 다른 프로그램의 출력을 확인하는 방법
12835정성태9/7/20218937.NET Framework: 1116. C# 10 - (15) CallerArgumentExpression 특성 추가 [2]파일 다운로드1
12834정성태9/7/20217310오류 유형: 762. Visual Studio 2019 Build Tools - 'C:\Program' is not recognized as an internal or external command, operable program or batch file.
12833정성태9/6/20216767VC++: 150. Golang - TCP client/server echo 예제 코드파일 다운로드1
12832정성태9/6/20217581VC++: 149. Golang - 인터페이스 포인터가 의미 있을까요?
12831정성태9/6/20216138VC++: 148. Golang - 채널에 따른 다중 작업 처리파일 다운로드1
12830정성태9/6/20218354오류 유형: 761. Internet Explorer에서 파일 다운로드 시 "Your current security settings do not allow this file to be downloaded." 오류
12829정성태9/5/202110002.NET Framework: 1115. C# 10 - (14) 구조체 타입에 기본 생성자 정의 가능파일 다운로드1
12828정성태9/4/20218130.NET Framework: 1114. C# 10 - (13) 단일 파일 내에 적용되는 namespace 선언파일 다운로드1
12827정성태9/4/20218121스크립트: 27. 파이썬 - 웹 페이지 데이터 수집을 위한 scrapy Crawler 사용법 요약
12826정성태9/3/202110367.NET Framework: 1113. C# 10 - (12) 문자열 보간 성능 개선 [1]파일 다운로드1
12825정성태9/3/20217910개발 환경 구성: 603. GoLand - WSL 환경과 연동
12824정성태9/2/202116987오류 유형: 760. 파이썬 tensorflow - Dst tensor is not initialized. 오류 메시지
12823정성태9/2/20216732스크립트: 26. 파이썬 - PyCharm을 이용한 fork 디버그 방법
12822정성태9/1/202111935오류 유형: 759. 파이썬 tensorflow - ValueError: Shapes (...) and (...) are incompatible [2]
12821정성태9/1/20217471.NET Framework: 1112. C# - .NET 6부터 공개된 ISpanFormattable 사용법
12820정성태9/1/20217787VC++: 147. Golang - try/catch에 대응하는 panic/recover [1]파일 다운로드1
12819정성태8/31/20217906.NET Framework: 1111. C# - FormattableString 타입
12818정성태8/31/20217146Windows: 198. 윈도우 - 작업 관리자에서 (tensorflow 등으로 인한) GPU 연산 부하 보는 방법
... [31]  32  33  34  35  36  37  38  39  40  41  42  43  44  45  ...