Microsoft MVP성태의 닷넷 이야기
닷넷: 2163. .NET 8 - Dynamic PGO를 결합한 성능 향상 [링크 복사], [링크+제목 복사]
조회: 2698
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
(연관된 글이 2개 있습니다.)
(시리즈 글이 5개 있습니다.)
닷넷: 2161. .NET Conf 2023 - Day 1 Blazor 개요 정리
; https://www.sysnet.pe.kr/2/0/13446

닷넷: 2163. .NET 8 - Dynamic PGO를 결합한 성능 향상
; https://www.sysnet.pe.kr/2/0/13448

닷넷: 2178. C# - .NET 8부터 COM Interop에 대한 자동 소스 코드 생성 도입
; https://www.sysnet.pe.kr/2/0/13470

닷넷: 2180. .NET 8 - 함수 포인터에 대한 Reflection 정보 조회
; https://www.sysnet.pe.kr/2/0/13475

닷넷: 2181. C# - .NET 8 JsonStringEnumConverter의 AOT를 위한 개선
; https://www.sysnet.pe.kr/2/0/13476




.NET 8 - Dynamic PGO를 결합한 성능 향상

이번 .NET Conf 2023의 내용을 보면,

Dynamic PGO (개요)
; https://youtu.be/xEFO1sQ2bUc?t=14689

Dynamic PGO (상세)
; https://www.youtube.com/watch?v=WrpYcGic9b8

Dynamic PGO(Profile Guided Optimization)를 결합한 성능 향상을 소개하고 있습니다. 사실 이 글에서 소개하는 모든 옵션은 .NET 7에서도 쓸 수 있으니,

Performance Improvements in .NET 7
; https://devblogs.microsoft.com/dotnet/performance_improvements_in_net_7/

단지 성능을 더 높인 정도라고 봐야 합니다. 정말 그런지 한번 실습해 볼까요? ^^




성능 비교를 위해 아래의 예제 코드로 각각 .NET 7, .NET 8 프로젝트를 만들고,

// .NET Conf 2023 예제 코드 - Dynamic PGO
// https://youtu.be/xEFO1sQ2bUc?t=14689

using System.Diagnostics;
using System.Runtime.CompilerServices;

namespace ConsoleApp1
{
    internal class Program
    {
        static void Main(string[] args)
        {
            var list = new List<int>();
            Stopwatch sw = Stopwatch.StartNew();
            for (int loop = 0; loop < 3; loop++)  
            {
                sw.Restart();
                for (int i = 0; i < 1_000_000_000; i++)
                {
                    IsEmpty(list);
                }
                Console.WriteLine(sw.Elapsed.TotalSeconds);
            }
        }

        [MethodImpl(MethodImplOptions.NoInlining)]
        static bool IsEmpty(List<int> list) => list.Count != 0;
    }
}

Dynamic PGO 옵션을 활성화한 다음, (혹은 csproj에 <TieredPGO>true</TieredPGO> 설정)

c:\temp\ConsoleApp1> set DOTNET_TieredPGO=1

실행해 보면 이런 결과가 나옵니다.

// [.NET 7 + PGO]
c:\temp\ConsoleApp1> dotnet .\ConsoleApp7\bin\Release\net7.0\ConsoleApp7.dll
1.5479958
1.4894338
1.5124722
1.5047404
1.5001046

// [.NET 8 + PGO]
c:\temp\ConsoleApp1> dotnet .\ConsoleApp8\bin\Release\net8.0\ConsoleApp8.dll
1.0941596
1.0035034
1.0044089
1.0028159
1.0046821

".NET Conf 세미나" 화면에서도 .NET 7의 경우 3.4 정도, .NET 8의 경우에는 1.6 정도로 절반 가까이 성능 차이가 났는데, 제 컴퓨터(Intel i9-12900K)에서는 1.5에서 1.0으로 약 33% 정도 향상되었습니다.

JIT 컴파일링이 어떻게 달라졌는지 DOTNET_JitDisasmSummary 환경 변수로 확인할 수 있다고 하는데요,

c:\temp\ConsoleApp1> set DOTNET_JitDisasmSummary=1

각각의 런타임에서 실행해 보면,

// [.NET 7 + PGO + DOTNET_JitDisasmSummary]

   1: JIT compiled System.Runtime.CompilerServices.CastHelpers:StelemRef(System.Array,long,System.Object) [Tier1, IL size=88, code size=93]
   2: JIT compiled System.Runtime.CompilerServices.CastHelpers:LdelemaRef(System.Array,long,ulong) [Tier1, IL size=44, code size=44]
   3: JIT compiled System.SpanHelpers:IndexOfNullCharacter(byref) [Tier1, IL size=792, code size=388]
   4: JIT compiled ConsoleApp1.Program:Main() [Instrumented Tier0, IL size=74, code size=320]
   5: JIT compiled ConsoleApp1.Program:IsEmpty(System.Collections.Generic.List`1[int]) [Instrumented Tier0, IL size=10, code size=40]
   6: JIT compiled ConsoleApp1.Program:Main() [Instrumented Tier1-OSR @0x25 with Dynamic PGO, IL size=74, code size=253]
   7: JIT compiled ConsoleApp1.Program:IsEmpty(System.Collections.Generic.List`1[int]) [Tier1, IL size=10, code size=10]
   8: JIT compiled System.Collections.Generic.List`1[int]:get_Count() [Tier1, IL size=7, code size=4]
   9: JIT compiled System.Text.ASCIIUtility:NarrowUtf16ToAscii(ulong,ulong,ulong) [Instrumented Tier0, IL size=490, code size=1373]
  10: JIT compiled System.SpanHelpers:SequenceCompareTo(byref,int,byref,int) [Tier1, IL size=632, code size=329]
1.5190925
1.4906073
1.4788726
1.4834886
1.4853625
  11: JIT compiled System.Buffer:Memmove(byref,byref,ulong) [Tier1 with Static PGO, IL size=480, code size=270]

.NET 7에서는 저렇게 2번의 "Instrumented Tier0", "Tier 1" 컴파일이 발생하지만, .NET 8에서는,

// [.NET 8 + PGO + DOTNET_JitDisasmSummary]

   1: JIT compiled System.Runtime.CompilerServices.CastHelpers:StelemRef(System.Array,long,System.Object) [Tier1, IL size=88, code size=93]
   2: JIT compiled System.Runtime.CompilerServices.CastHelpers:LdelemaRef(System.Array,long,ulong) [Tier1, IL size=44, code size=44]
   3: JIT compiled ConsoleApp1.Program:Main(System.String[]) [Instrumented Tier0, IL size=74, code size=324]
   4: JIT compiled ConsoleApp1.Program:IsEmpty(System.Collections.Generic.List`1[int]) [Tier0, IL size=10, code size=40]
   5: JIT compiled ConsoleApp1.Program:Main(System.String[]) [Tier1-OSR @0x25 with Dynamic PGO, IL size=74, code size=220]
   6: JIT compiled ConsoleApp1.Program:IsEmpty(System.Collections.Generic.List`1[int]) [Instrumented Tier0, IL size=10, code size=40]
   7: JIT compiled System.Collections.Generic.List`1[int]:get_Count() [Instrumented Tier1, IL size=7, code size=4]
   8: JIT compiled ConsoleApp1.Program:IsEmpty(System.Collections.Generic.List`1[int]) [Tier1, IL size=10, code size=10]
   9: JIT compiled System.Collections.Generic.List`1[int]:get_Count() [Tier1, IL size=7, code size=4]
1.0763641
1.0003451
0.9969336
0.9980177
  10: JIT compiled System.Buffer:Memmove(byref,byref,ulong) [Instrumented Tier1 with Static PGO, IL size=480, code size=581]
0.9961069

"Tier 0", "Instrumented Tier 0", "Tier 1" 총 3번의 JIT 컴파일 최적화가 이뤄집니다. 그런데, 위의 출력 결과를 다시 보면 2개 모두 다음과 같이,

// .NET 7 + PGO + DOTNET_JitDisasmSummary
...[생략]...
6: JIT compiled ConsoleApp1.Program:Main(<unnamed>) [Instrumented Tier1-OSR @0x25 with Dynamic PGO, IL size=74, code size=253]
...[생략]...

// .NET 8 + PGO + DOTNET_JitDisasmSummary
...[생략]...
5: JIT compiled ConsoleApp1.Program:Main(System.String[]) [Tier1-OSR @0x25 with Dynamic PGO, IL size=74, code size=220]
...[생략]...

(동영상과는 달리 Main 메서드에만) "Dynamic PGO" 문구가 있습니다. 그런데, 재미있는 것은, ^^; "DOTNET_TieredPGO" 옵션을 제거하고 다시 실행해 보면,

// .NET 7 + DOTNET_JitDisasmSummary
6: JIT compiled ConsoleApp1.Program:Main(<unnamed>) [Tier1-OSR @0x25, IL size=74, code size=445]

// .NET 8 + DOTNET_JitDisasmSummary
5: JIT compiled ConsoleApp1.Program:Main(System.String[]) [Tier1-OSR @0x25 with Dynamic PGO, IL size=74, code size=220]

저렇게 .NET 7의 경우 "Dynamic PGO"가 빠지지만, .NET 8은 여전히 남아 있습니다. 즉, 저 결과로만 본다면 PGO 옵션이 .NET 8에서는 기본적으로 활성화된 것입니다. 실제로 "set DOTNET_TieredPGO=0"이라고 명시적인 비활성화 옵션을 주고 실행해야만 .NET 8에서 다음과 같은 결과가 나옵니다.

// .NET 8 + PGO 비활성 + DOTNET_JitDisasmSummary
5: JIT compiled ConsoleApp1.Program:Main(System.String[]) [Tier1-OSR @0x25, IL size=74, code size=475]




개인적으로 흥미 있는 건 DOTNET_JitDisasm 옵션입니다. 이 옵션이 있는 줄 몰랐을 때는, 기계어로 번역된 코드를 얻으려면 windbg + sos 확장을 이용한 번거로운 절차를 거쳐야 했기 때문입니다. 어쨌든, 이것으로 원하는 메서드에 대해 JIT 컴파일 결과를 출력한다고 하는데요,

// 동영상에서는 콤마로 감싸는데,
set DOTNET_JitDisasm="IsEmpty"

// 제가 테스트한 바로는 콤마를 빼고 지정해야 합니다.
set DOTNET_JitDisasm=IsEmpty

.NET 7에서는 다음과 같이 2번의 JIT 컴파일 결과가 나오고,

// [.NET 7 + PGO + DOTNET_JitDisasm]

; Assembly listing for method ConsoleApp1.Program:IsEmpty(System.Collections.Generic.List`1[int]):bool
; Emitting BLENDED_CODE for X64 CPU with AVX - Windows
; Tier-0 compilation
; MinOpts code
; instrumented for collecting profile data
; rbp based frame
; partially interruptible

G_M000_IG01:                ;; offset=0000H
       55                   push     rbp
       4883EC20             sub      rsp, 32
       488D6C2420           lea      rbp, [rsp+20H]
       48894D10             mov      gword ptr [rbp+10H], rcx

G_M000_IG02:                ;; offset=000EH
       488B4D10             mov      rcx, gword ptr [rbp+10H]
       3909                 cmp      dword ptr [rcx], ecx
       FF15369F0F00         call     [System.Collections.Generic.List`1[int]:get_Count():int:this]
       85C0                 test     eax, eax
       0F95C0               setne    al
       0FB6C0               movzx    rax, al

G_M000_IG03:                ;; offset=0022H
       4883C420             add      rsp, 32
       5D                   pop      rbp
       C3                   ret

; Total bytes of code 40

; Assembly listing for method ConsoleApp1.Program:IsEmpty(System.Collections.Generic.List`1[int]):bool
; Emitting BLENDED_CODE for X64 CPU with AVX - Windows
; Tier-1 compilation
; optimized code
; rsp based frame
; partially interruptible
; No PGO data
; 0 inlinees with PGO data; 1 single block inlinees; 0 inlinees without PGO data

G_M000_IG01:                ;; offset=0000H

G_M000_IG02:                ;; offset=0000H
       33C0                 xor      eax, eax
       83791000             cmp      dword ptr [rcx+10H], 0
       0F95C0               setne    al

G_M000_IG03:                ;; offset=0009H
       C3                   ret

; Total bytes of code 10

.NET 8에서는 3번의 JIT 컴파일 결과가 나옵니다.

// [.NET 8 + PGO + DOTNET_JitDisasm]

; Assembly listing for method ConsoleApp1.Program:IsEmpty(System.Collections.Generic.List`1[int]):bool (Tier0)
; Emitting BLENDED_CODE for X64 with AVX - Windows
; Tier0 code
; rbp based frame
; partially interruptible

G_M000_IG01:                ;; offset=0x0000
       push     rbp
       sub      rsp, 32
       lea      rbp, [rsp+0x20]
       mov      gword ptr [rbp+0x10], rcx

G_M000_IG02:                ;; offset=0x000E
       mov      rcx, gword ptr [rbp+0x10]
       cmp      dword ptr [rcx], ecx
       call     [System.Collections.Generic.List`1[int]:get_Count():int:this]
       test     eax, eax
       setne    al
       movzx    rax, al

G_M000_IG03:                ;; offset=0x0022
       add      rsp, 32
       pop      rbp
       ret

; Total bytes of code 40

; Assembly listing for method ConsoleApp1.Program:IsEmpty(System.Collections.Generic.List`1[int]):bool (Instrumented Tier0)
; Emitting BLENDED_CODE for X64 with AVX - Windows
; Instrumented Tier0 code
; rbp based frame
; partially interruptible

G_M000_IG01:                ;; offset=0x0000
       push     rbp
       sub      rsp, 32
       lea      rbp, [rsp+0x20]
       mov      gword ptr [rbp+0x10], rcx

G_M000_IG02:                ;; offset=0x000E
       mov      rcx, gword ptr [rbp+0x10]
       cmp      dword ptr [rcx], ecx
       call     [System.Collections.Generic.List`1[int]:get_Count():int:this]
       test     eax, eax
       setne    al
       movzx    rax, al

G_M000_IG03:                ;; offset=0x0022
       add      rsp, 32
       pop      rbp
       ret

; Total bytes of code 40

; Assembly listing for method ConsoleApp1.Program:IsEmpty(System.Collections.Generic.List`1[int]):bool (Tier1)
; Emitting BLENDED_CODE for X64 with AVX - Windows
; Tier1 code
; optimized code
; rsp based frame
; partially interruptible
; No PGO data
; 0 inlinees with PGO data; 1 single block inlinees; 0 inlinees without PGO data

G_M000_IG01:                ;; offset=0x0000

G_M000_IG02:                ;; offset=0x0000
       xor      eax, eax
       cmp      dword ptr [rcx+0x10], 0
       setne    al

G_M000_IG03:                ;; offset=0x0009
       ret

; Total bytes of code 10

그런데, .NET 8의 "Tier 0", "Instrumented Tier 0" 컴파일 결과는 동일한데다가 3번째 "Tier 1"의 컴파일 결과는 .NET 7의 두 번째 "Tier 1" 컴파일 결과와 같습니다. 즉, 저 결과로만 보면 쓸데없는 작업이 .NET 8에 들어간 것인데, 그래도 성능이 저렇게 빠른 거 보면 원래 .NET 8 런타임 자체가 빠른 건지도 모르겠습니다. ^^;

참고로, 아래의 도구들을 사용하면 더 쉽게 JIT 컴파일 결과를 알 수 있습니다.

Disasmo
; https://marketplace.visualstudio.com/items?itemName=EgorBogatov.Disasmo

Sharplab
; https://sharplab.io/




그렇다면, 아예 기본값이 활성 상태인 TieredCompilation 옵션 자체를 끄고 실행하면 어떨까요?

// .NET Core 2 이하의 환경에서 기본값이 비활성
// .NET Core 3 이상의 환경에서 기본값이 활성 상태
SET DOTNET_TieredCompilation=0

그럼, PGO를 사용했던 것보다 더 빠르게 실행이 됩니다.

// .NET 7 + Tiered Compilation 없이 실행 (PGO도 함께 자동으로 비활성)
0.8113732
0.7983001
0.8145982
0.8106876
0.799827

// .NET 8 + Tiered Compilation 없이 실행 (PGO도 함께 자동으로 비활성)
0.7899068
0.7979388
0.7953996
0.7973212
0.7974282

여기서 한 가지 오해하지 말아야 할 것이 있습니다. "Tiered Compilation" 기능은 프로그램의 "성능"을 높이는 목적이 아닌, "초기 JIT 컴파일 속도"를 높이기 위해 만들어졌다는 사실입니다.

즉, 시간이 많이 걸리는 최적화 JIT 빌드를 모든 코드에 대해 수행해 초기 로딩 속도를 늦추기보다는, 몇 번 실행되지도 않는 코드들은 (최적화 단계 없이) 일단 실행만 되게 고속으로 JIT 컴파일 시키고, 이후에 천천히 최적화를 하는 것이 "Tiered Compilation"입니다.

따라서, 저런 결과가 나오는 것이 정상입니다.

그런 관점에서 .NET 8의 PGO를 다시 바라볼 필요가 있습니다. 이것 역시, 오히려 DOTNET_TieredPGO=0으로 옵션을 명시적으로 비활성화시키고 실행하면 .NET 7에서 .NET 8보다 빠른 결과를 보입니다.

// .NET 7 + PGO 없이 실행 (DOTNET_TieredPGO=0, 기본값이므로 설정 유무에 상관없음)
1.1310844
1.016918
1.0223791
1.0211129
1.0194825

// .NET 8 + PGO 없이 실행 (DOTNET_TieredPGO=0, 반드시 설정해야 비활성화됨)
1.4378975
1.3701194
1.3651987
1.3690431
1.3692595

저걸 굳이 해석해 보자면, .NET 7은 Tiered Compilation이 2단계로 단순하게 나누어져 있는 반면, .NET 8은 Tiered Compilation을 좀 더 세분화해서 3단계로 나뉘었다고 보면 될 듯합니다. 아마도 저렇기 때문에 .NET 8은 PGO를 기본 옵션으로 활성화시켰다는 것으로 해석할 수 있습니다.

하지만, 이렇게 3단계로 나뉜 .NET 8의 초기 로딩 속도가 얼마나 효율적인지는 테스트로 확인하는 것이 좀 애매합니다. 왜냐하면, RAM/Disk Cache나 Cold/Warm 실행에 따른 성능 변수가 오히려 더 클 수 있기 때문입니다.

암튼, 그냥 긍정적인 방향으로 정리해 보면, .NET 8은 초기 (JIT 컴파일을 포함하는) 로딩 속도를 보완하기 위해 3단계 Tiered Compilation을 적절한 수준으로 구현했다... 정도로 보면 되겠습니다. ^^

(2023-11-23 업데이트) 위의 예제만으로는 실망스러울 수 있지만, "Dynamic PGO (상세)" 영상에 나오는 GDV, Profile-Guided Inlining 등의 예제를 보면 분명히 성능 향상이 될 수 있는 여지가 많습니다.




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

[연관 글]






[최초 등록일: ]
[최종 수정일: 3/30/2024]

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

비밀번호

댓글 작성자
 




... 16  17  18  [19]  20  21  22  23  24  25  26  27  28  29  30  ...
NoWriterDateCnt.TitleFile(s)
13156정성태11/7/20226805.NET Framework: 2064. C# - Mutex와 Semaphore/SemaphoreSlim 차이점파일 다운로드1
13155정성태11/4/20226305디버깅 기술: 183. TCP 동시 접속 (연결이 아닌) 시도를 1개로 제한한 서버
13154정성태11/3/20225779.NET Framework: 2063. .NET 5+부터 지원되는 GC.GetGCMemoryInfo파일 다운로드1
13153정성태11/2/20227071.NET Framework: 2062. C# - 코드로 재현하는 소켓 상태(SYN_SENT, SYN_RECV)
13152정성태11/1/20225689.NET Framework: 2061. ASP.NET Core - DI로 추가한 클래스의 초기화 방법 [1]
13151정성태10/31/20225791C/C++: 161. Windows 11 환경에서 raw socket 테스트하는 방법파일 다운로드1
13150정성태10/30/20225855C/C++: 160. Visual Studio 2022로 빌드한 C++ 프로그램을 위한 다른 PC에서 실행하는 방법
13149정성태10/27/20225771오류 유형: 825. C# - CLR ETW 이벤트 수신이 GCHeapStats_V1/V2에 대해 안 되는 문제파일 다운로드1
13148정성태10/26/20225783오류 유형: 824. msbuild 에러 - error NETSDK1005: Assets file '...\project.assets.json' doesn't have a target for 'net5.0'. Ensure that restore has run and that you have included 'net5.0' in the TargetFramew
13147정성태10/25/20224871오류 유형: 823. Visual Studio 2022 - Unable to attach to CoreCLR. The debugger's protocol is incompatible with the debuggee.
13146정성태10/24/20225710.NET Framework: 2060. C# - Java의 Xmx와 유사한 힙 메모리 최댓값 제어 옵션 HeapHardLimit
13145정성태10/21/20225996오류 유형: 822. db2 - Password validation for user db2inst1 failed with rc = -2146500508
13144정성태10/20/20225811.NET Framework: 2059. ClrMD를 이용해 윈도우 환경의 메모리 덤프로부터 닷넷 모듈을 추출하는 방법파일 다운로드1
13143정성태10/19/20226354오류 유형: 821. windbg/sos - Error code - 0x000021BE
13142정성태10/18/20225170도서: 시작하세요! C# 12 프로그래밍
13141정성태10/17/20226833.NET Framework: 2058. [in,out] 배열을 C#에서 C/C++로 넘기는 방법 - 세 번째 이야기파일 다운로드1
13140정성태10/11/20226214C/C++: 159. C/C++ - 리눅스 환경에서 u16string 문자열을 출력하는 방법 [2]
13139정성태10/9/20226022.NET Framework: 2057. 리눅스 환경의 .NET Core 3/5+ 메모리 덤프로부터 모든 닷넷 모듈을 추출하는 방법파일 다운로드1
13138정성태10/8/20227337.NET Framework: 2056. C# - await 비동기 호출을 기대한 메서드가 동기로 호출되었을 때의 부작용 [1]
13137정성태10/8/20225683.NET Framework: 2055. 리눅스 환경의 .NET Core 3/5+ 메모리 덤프로부터 닷넷 모듈을 추출하는 방법
13136정성태10/7/20226259.NET Framework: 2054. .NET Core/5+ SDK 설치 없이 dotnet-dump 사용하는 방법
13135정성태10/5/20226501.NET Framework: 2053. 리눅스 환경의 .NET Core 3/5+ 메모리 덤프를 분석하는 방법 - 두 번째 이야기
13134정성태10/4/20225235오류 유형: 820. There is a problem with AMD Radeon RX 5600 XT device. For more information, search for 'graphics device driver error code 31'
13133정성태10/4/20225560Windows: 211. Windows - (commit이 아닌) reserved 메모리 사용량 확인 방법 [1]
13132정성태10/3/20225454스크립트: 42. 파이썬 - latexify-py 패키지 소개 - 함수를 mathjax 식으로 표현
13131정성태10/3/20228171.NET Framework: 2052. C# - Windows Forms의 데이터 바인딩 지원(DataBinding, DataSource) [2]파일 다운로드1
... 16  17  18  [19]  20  21  22  23  24  25  26  27  28  29  30  ...