Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (kevin13@chol.net)
홈페이지
첨부 파일

C# 7.2의 특징 - GC 및 메모리 복사 방지를 위한 struct 타입 개선

지난 글들을 통해 C# 7.2의 struct 관련 문법을 소개했는데요.

C# 7.2 (1) - readonly 구조체
; http://www.sysnet.pe.kr/2/0/11524

C# 7.2 (2) - 메서드의 매개 변수에 in 변경자 추가
; http://www.sysnet.pe.kr/2/0/11525

기타 - Microsoft Build 2018 - The future of C# 동영상 내용 정리
; http://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://blogs.msdn.microsoft.com/dotnet/2018/04/18/performance-improvements-in-net-core-2-1/




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





[최초 등록일: ]
[최종 수정일: 6/14/2018 ]

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

비밀번호

댓글 쓴 사람
 



2018-06-13 05시33분
[spowner] 본문 소스코드 중, _newPos 관련 오류 발생합니다~
[손님]
2018-06-13 08시17분
struct_perf_compare.zip 파일을 다운로드해서 빌드했는데 오류가 발생한다는 건가요? (어쨌든, 방금도 해봤는데 잘 빌드가 됩니다.)
정성태
2018-06-14 01시28분
[spowner] 아 다운로드 파일이 있는지 확인을 못했습니다. 본문의 소스코드를 복사해 실행했더니 생겼습니다

[손님]
2018-06-14 08시54분
본문 예제 수정했습니다. ^^
정성태

[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
11556정성태6/19/2018100.NET : 620. C# 7.3 - 구조체의 고정 크기를 갖는 fixed 배열 필드에 대한 직접 접근 가능파일 다운로드1
11555정성태6/18/2018110.NET : 619. C# 7.3 - 사용자 정의 타입에 fixed 적용 가능(Custom fixed)파일 다운로드1
11554정성태6/17/2018151.NET : 618. C# 7.3 - 자동 구현 속성에 특성 적용 가능(Attribute on backing field)
11553정성태6/15/2018162.NET : 617. C# 7.3 - 개선된 메서드 선택 규칙 3가지(Improved overload candidates)파일 다운로드1
11552정성태6/15/2018155.NET : 616. C# 7.3에서 개선된 문법 4개(Support == and != for tuples, Ref Reassignment, Constraints, Stackalloc initializers)파일 다운로드1
11551정성태6/14/2018152개발 환경 구성: 391. BenchmarkDotNet 사용 시 주의 사항
11550정성태6/13/2018160.NET : 615. BenchmarkDotNet으로 Span<T> 성능 측정
11549정성태6/13/2018149개발 환경 구성: 390. BenchmarkDotNet에서 생성한 BuildPlots.R 파일을 실행하는 방법
11548정성태6/13/2018162오류 유형 : 466. .NET Core + BenchmarkDotNet 실행 시 프레임워크를 찾지 못하는 문제
11547정성태6/13/2018203.NET : 614. BenchmarkDotNet 라이브러리 소개파일 다운로드1
11546정성태6/14/2018316.NET : 613. C# 7.2의 특징 - GC 및 메모리 복사 방지를 위한 struct 타입 개선 [4]파일 다운로드1
11545정성태6/11/2018259오류 유형 : 465. .NET Core 프로젝트를 Visual Studio에서 실행 시 System.BadImageFormatException 발생하는 경우 [1]
11544정성태6/10/2018203.NET : 612. C# 7.2 - 숫자 리터럴의 선행 밑줄과 뒤에 오지 않는 명명된 인수
11543정성태6/10/2018235.NET : 611. C# 7.2 - private protected 접근자 추가파일 다운로드1
11542정성태6/9/2018136개발 환경 구성: 389. Azure Web App 확장 예제 - Remove Custom Headers
11541정성태6/9/2018170개발 환경 구성: 388. Azure Web App 확장 배포 방법
11540정성태6/9/2018196개발 환경 구성: 387. Azure Web App 확장 예제 제작
11539정성태6/8/2018232.NET : 610. .NET Core 2.1 - Tiered Compilation 도입파일 다운로드1
11538정성태6/8/2018216.NET : 609. .NET Core 2.1 - 확장 도구(Tools) 관리
11537정성태6/8/2018204.NET : 608. C# - SmtpClient로 SMTP + SSL/TLS 서버를 이용하는 방법
11536정성태6/8/2018336.NET : 607. Microsoft Build 2018 - The future of C# 동영상 내용 정리 [1]파일 다운로드1
11535정성태6/7/2018331.NET : 606. C# - System.Span<T> 성능 [1]
11534정성태6/10/2018343.NET : 605. C# 7.2 - Span<T> [3]
11533정성태6/8/2018266.NET : 604. 포인터 형 매개 변수를 갖는 C++ DLL의 함수를 C#에서 호출하는 방법파일 다운로드1
11532정성태6/5/2018237.NET : 603. JSON의 escape sequence 문자 처리 방식
[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...