.NET 10에 포함된 JIT, GC, 스레딩, 컬렉션, LINQ, JSON, 네트워킹, 정규식 등 전반의 성능 개선을 마이크로벤치마크와 함께 살펴봅니다.
URL: https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-10/
Title: Performance Improvements in .NET 10 - .NET Blog
우리 아이들은 “겨울왕국(Frozen)”을 정말 좋아합니다. 가사 한 단어도 빠짐없이 부르고, 모든 장면을 재연하고, 엘사의 얼음 드레스가 얼마나 반짝여야 하는지에 대한 디테일한 노트까지 제공합니다. 저는 셀 수 없을 정도로 영화를 봤고, 라이브 코딩을 하는 모습을 본 적이 있다면 제 무의식이 아렌델(Arendelle) 관련 레퍼런스를 한두 개쯤 끼워 넣는 걸 봤을지도 모릅니다. 너무 많이 보다 보니, 저는 디테일에 더 주목하기 시작했습니다. 예를 들어 영화 맨 처음에 얼음을 채취하는 사람들이 부르는 노래가 이야기의 중심 갈등, 인물들의 여정, 심지어 클라이맥스를 해결하는 열쇠까지 은근히 예고하고 있다는 점 같은 것들요. 열 번쯤 보고 나서야 이 연결고리를 이해했다는 사실이 조금 부끄럽습니다. 그때 저는 또 하나를 깨달았습니다. 이 얼음 채취가 실제로 있던 일인지, 아니면 디즈니가 이야기를 엮기 위한 영리한 장치인지 전혀 모르고 있었다는 점이죠. 나중에 찾아보니, 실제로 꽤 현실적인 일이었습니다.
19세기에는 냉장 기술이 없던 시대였고, 얼음은 믿을 수 없을 만큼 값진 상품이었습니다. 미국 북부의 겨울은 연못과 호수를 계절성 금광으로 바꿔 놓았습니다. 가장 성공한 사업은 정밀하게 운영되었습니다. 노동자들은 눈을 치워 얼음이 더 두껍고 강하게 자라도록 했고, 말이 끄는 쟁기로 표면을 완벽한 직사각형 격자로 긋는 방식으로 호수를 얼어붙은 체커판처럼 만들었습니다. 격자가 만들어지면 긴 톱을 든 팀이 각 블록이 수백 파운드나 되는 균일한 얼음 덩어리를 잘라냈습니다. 얼음 블록은 열린 물길을 따라 해안으로 떠내려왔고, 그곳에서 사람들은 막대로 블록을 경사로 위로 들어 올려 저장고로 끌고 갔습니다. 기본적으로 영화가 보여준 그대로입니다.
저장 방식 자체도 예술이었습니다. 때로 수만 톤을 보관하던 거대한 목조 얼음 창고는 보통 짚 같은 단열재로 안쪽을 덮었습니다. 단열이 잘 되면 한여름 더위에도 얼음을 몇 달 동안 단단하게 유지할 수 있었지만, 잘못하면 문을 열었을 때 슬러시만 남아 있었습니다. 그리고 얼음을 장거리로(대개 배로) 운송하는 경우, 온도 1도, 단열재의 작은 균열 하나, 운송 기간의 하루 추가가 곧 더 많은 녹음과 손실을 의미했습니다.
여기서 보스턴의 “얼음 왕(Ice King)” 프레더릭 튜더(Frederic Tudor)가 등장합니다. 그는 시스템적 효율성에 집착했습니다. 경쟁자들이 피할 수 없는 손실로 보던 것을, 튜더는 해결 가능한 문제로 봤습니다. 다양한 단열재를 실험한 끝에, 그는 톱밥을 활용했습니다. 목재 공장의 부산물인 저렴한 톱밥이 짚보다 성능이 좋았고, 얼음 주변을 촘촘히 채워 녹는 손실을 크게 줄였습니다. 채취 효율을 위해 그는 네이선리얼 자비스 와이어스(Nathaniel Jarvis Wyeth)의 격자 채취 방식을 도입해 균일한 블록을 만들었고, 이는 배의 창고에서 공기층을 최소화할 만큼 촘촘히 포장할 수 있게 해 노출을 줄였습니다. 또 해안과 선박 사이의 결정적 시간을 줄이기 위해 항만 인프라와 부두 근처의 창고를 확충해 선박의 상하역 속도를 크게 높였습니다. 도구부터 얼음 창고 설계, 물류까지 각각의 변화가 서로를 증폭시키며 위험한 지역 채취를 신뢰할 수 있는 글로벌 무역으로 바꿨습니다. 튜더의 개선 덕분에 얼음은 하바나, 리우데자네이루, 심지어 캘커타(1830년대 기준 4개월 항해) 같은 곳에도 단단한 상태로 도착했습니다. 그의 성능 향상은 이전에는 상상할 수 없던 여정을 가능하게 했습니다.
튜더의 얼음이 지구 반대편까지 버틸 수 있었던 이유는 하나의 거대한 아이디어가 아니었습니다. 수많은 작은 개선이 있었고, 각 개선이 이전 개선의 효과를 배가시켰습니다. 소프트웨어 개발도 같은 원리가 적용됩니다. 성능의 큰 도약은 대개 한 번의 거대한 변화에서 오기보다, 수백·수천 개의 표적 최적화가 누적되어 변혁적인 결과를 만들어냅니다. .NET 10의 성능 이야기도 디즈니식 마법 같은 한 방이 아니라, 여기서 나노초를 깎고 저기서 수십 바이트를 줄이며, 수조 번 실행되는 작업을 정교하게 다듬는 이야기입니다.
이 글의 나머지에서는 .NET 9, .NET 8, .NET 7, .NET 6, .NET 5, .NET Core 3.0, .NET Core 2.1, .NET Core 2.0에서 했던 것처럼, .NET 9 이후 누적된 수백 개의 작지만 의미 있고 서로를 증폭시키는 성능 개선을 파헤쳐 .NET 10의 이야기를 구성해 보겠습니다(만약 LTS 릴리스만 고수해서 .NET 9에서가 아니라 .NET 8에서 업그레이드한다면, .NET 9의 개선들까지 합쳐 더 많은 개선을 보게 될 겁니다). 그럼 더 이상 미루지 말고, 좋아하는 따뜻한 음료 한 잔(서두에 비추어 보면 좀 더 차가운 음료가 어울릴지도 모르겠네요)을 가져와서 편히 앉아 “Let It Go” 하며 읽어봅시다!
아니, 음… 성능을 “Into the Unknown”으로 밀어 올려볼까요?
.NET 10 성능이 “Show Yourself” 하게 둘까요?
“눈사람 빠른 서비스를 만들고 싶나요?”
그만하겠습니다.
이전 글들과 마찬가지로, 이 투어는 다양한 성능 개선을 보여주기 위한 마이크로 벤치마크들로 가득합니다. 대부분의 벤치마크는 BenchmarkDotNet 0.15.2로 구현되어 있고, 각 테스트는 단순한 설정을 갖습니다.
같이 따라 하려면, .NET 9과 .NET 10을 설치하세요. 대부분의 벤치마크가 동일한 테스트를 각각에서 실행해 비교합니다. 그런 다음 새 benchmarks 디렉터리에 새 C# 프로젝트를 만듭니다:
dotnet new console -o benchmarks
cd benchmarks
그러면 benchmarks 디렉터리에 두 파일이 생성됩니다. benchmarks.csproj는 애플리케이션을 어떻게 컴파일할지에 대한 정보를 가진 프로젝트 파일이고, Program.cs는 애플리케이션 코드가 들어 있는 파일입니다. 마지막으로 benchmarks.csproj의 내용을 아래로 모두 교체합니다:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0;net9.0</TargetFrameworks>
<LangVersion>Preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.15.2" />
</ItemGroup>
</Project>
이제 준비 완료입니다. 별도 언급이 없는 한, 각 벤치마크는 독립 실행 가능하도록 만들었습니다. Program.cs 파일의 내용을 통째로 복사/붙여넣기 하여 기존 내용을 덮어쓴 다음, 벤치마크를 실행하면 됩니다. 각 테스트의 맨 위에는 해당 벤치마크를 실행하기 위한 dotnet 명령을 주석으로 포함했습니다. 보통 아래와 같은 형태입니다:
dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
이는 .NET 9와 .NET 10 모두에서 릴리스로 벤치마크를 실행하고 비교 결과를 보여줍니다. 또 다른 흔한 변형은 벤치마크를 .NET 10에서만 실행해야 하는 경우(대개 두 접근 방식 비교 등) 사용되며, 아래와 같습니다:
dotnet run -c Release -f net10.0 --filter "*"
글 전체에서 많은 벤치마크와 제가 실행한 결과를 보여드렸습니다. 별도 언급이 없는 한(예: OS 특정 개선을 보여줄 때), 결과는 Linux(Ubuntu 24.04.1) x64 프로세서에서 실행한 것입니다.
BenchmarkDotNet v0.15.2, Linux Ubuntu 24.04.1 LTS (Noble Numbat)
11th Gen Intel Core i9-11950H 2.60GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK 10.0.100-rc.1.25451.107
[Host] : .NET 9.0.9 (9.0.925.41916), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
항상 그렇듯 빠른 면책조항: 이는 마이크로 벤치마크로, 눈 깜빡이면 놓칠 만큼 짧은 작업의 시간을 측정합니다(하지만 이런 작업이 수백만 번 실행되면 절감 효과가 정말 커집니다). 여러분이 얻는 정확한 수치는 하드웨어, 운영체제, 당시 시스템 부하, 아침 이후 마신 커피 양, 그리고 어쩌면 수성이 역행 중인지 여부에 따라 달라질 수 있습니다. 즉, 결과가 제 것과 정확히 일치하길 기대하진 마세요. 그래도 실제 환경에서도 꽤 재현 가능한 테스트를 골랐습니다.
이제 스택의 바닥부터 시작해 봅시다. 코드 생성입니다.
.NET의 여러 영역 중에서도 JIT(Just-In-Time) 컴파일러는 가장 큰 영향을 미치는 요소 중 하나입니다. 작은 콘솔 도구든 대규모 엔터프라이즈 서비스든, 모든 .NET 애플리케이션은 결국 IL(중간 언어) 코드를 최적화된 머신 코드로 바꾸기 위해 JIT에 의존합니다. JIT가 생성하는 코드 품질이 개선되면, 개발자가 자신의 코드를 바꾸거나 C#을 다시 컴파일할 필요도 없이 전체 생태계의 성능이 파급적으로 좋아집니다. .NET 10에는 이런 개선이 아주 많습니다.
많은 언어와 마찬가지로 .NET은 역사적으로 “추상화 비용(abstraction penalty)”이 있었습니다. 인터페이스, 이터레이터, 델리게이트 같은 고수준 언어 기능을 사용할 때 추가 할당과 간접 참조가 발생할 수 있습니다. 매년 JIT는 추상화 계층을 최적화로 제거하는 능력이 더 좋아지고, 개발자는 단순한 코드를 쓰면서도 뛰어난 성능을 얻을 수 있습니다. .NET 10도 이를 이어갑니다. 결과적으로 인터페이스, foreach, 람다 등 관용적(idiomatic) C# 코드가 정교하게 손으로 튜닝한 코드에 더 가까운 속도로 실행됩니다.
.NET 10의 추상화 제거 진행 중 가장 흥미로운 영역 중 하나는 escape analysis를 확장해 객체의 스택 할당을 가능하게 한 것입니다. Escape analysis는 메서드 내에서 할당된 객체가 해당 메서드를 “탈출(escape)”하는지(즉, 메서드가 반환된 뒤에도 접근 가능해지는지) 판정하는 컴파일러 기법입니다. 예를 들어 필드에 저장되거나 호출자에게 반환되면 탈출합니다. 또는 알 수 없는 대상에게 전달되는 등 런타임이 메서드 안에서 추적할 수 없는 방식으로 사용되어도 탈출로 간주될 수 있습니다. 컴파일러가 객체가 탈출하지 않음을 증명할 수 있으면 객체의 수명은 메서드 범위로 제한되고, 힙이 아니라 스택에 할당할 수 있습니다. 스택 할당은 훨씬 저렴합니다(할당은 포인터 증가만으로 끝나고 메서드 종료 시 자동 해제). 또한 GC 압박을 줄입니다(객체를 GC가 추적할 필요가 없으니까요). .NET 9에서도 제한적인 escape analysis와 스택 할당이 도입됐고, .NET 10은 이를 크게 확장했습니다.
dotnet/runtime#115172는 델리게이트와 관련된 escape analysis를 JIT가 수행하는 방법을 가르칩니다. 특히 델리게이트의 Invoke 메서드(런타임이 구현)가 this 참조를 어딘가에 저장해 두지 않는다는 사실을 알게 합니다. 그러면 escape analysis가 델리게이트의 객체 참조가 다른 방식으로 탈출하지 않았음을 증명할 수 있을 때 델리게이트 자체가 사실상 증발할 수 있습니다. 다음 벤치마크를 보세요:
csharp// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0 using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args); [DisassemblyDiagnoser] [MemoryDiagnoser(displayGenColumns: false)] [HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "y")] public partial class Tests { [Benchmark] [Arguments(42)] public int Sum(int y) { Func<int, int> addY = x => x + y; return DoubleResult(addY, y); } private int DoubleResult(Func<int, int> func, int arg) { int result = func(arg); return result + result; } }
이 벤치마크를 실행해 .NET 9와 .NET 10을 비교하면 흥미로운 일이 벌어지고 있음을 즉시 알 수 있습니다.
| Method | Runtime | Mean | Ratio | Code Size | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|
| Sum | .NET 9.0 | 19.530 ns | 1.00 | 118 B | 88 B | 1.00 |
| Sum | .NET 10.0 | 6.685 ns | 0.34 | 32 B | 24 B | 0.27 |
Sum의 C# 코드는 간단해 보이지만, C# 컴파일러는 꽤 복잡한 코드를 생성합니다. Func<int, int>를 만들어야 하는데, 이는 y라는 “로컬”을 클로저로 캡처합니다. 즉 컴파일러는 y를 더 이상 진짜 로컬로 두지 않고, 객체의 필드로 “끌어올려(lift)”야 합니다. 그러면 델리게이트는 그 객체의 메서드를 가리키게 되어 y에 접근할 수 있습니다. C# 컴파일러가 생성한 IL을 C#으로 역컴파일하면 대략 이런 모양입니다:
csharppublic int Sum(int y) { <>c__DisplayClass0_0 c = new(); c.y = y; Func<int, int> func = new(c.<Sum>b__0); return DoubleResult(func, c.y); } private sealed class <>c__DisplayClass0_0 { public int y; internal int <Sum>b__0(int x) => x + y; }
여기서 클로저 때문에 두 번 할당이 발생합니다. (1) 디스플레이 클래스(클로저 타입) 인스턴스, (2) 그 인스턴스의 <Sum>b__0 메서드를 가리키는 델리게이트. 이것이 .NET 9 결과에서 88바이트 할당을 설명합니다. 디스플레이 클래스는 24바이트, 델리게이트는 64바이트입니다. 그런데 .NET 10 결과에서는 24바이트만 보입니다. JIT가 델리게이트 할당을 제거했기 때문입니다. 아래는 생성된 어셈블리 코드입니다:
; .NET 9
; Tests.Sum(Int32)
push rbp
push r15
push rbx
lea rbp,[rsp+10]
mov ebx,esi
mov rdi,offset MT_Tests+<>c__DisplayClass0_0
call CORINFO_HELP_NEWSFAST
mov r15,rax
mov [r15+8],ebx
mov rdi,offset MT_System.Func<System.Int32, System.Int32>
call CORINFO_HELP_NEWSFAST
mov rbx,rax
lea rdi,[rbx+8]
mov rsi,r15
call CORINFO_HELP_ASSIGN_REF
mov rax,offset Tests+<>c__DisplayClass0_0.<Sum>b__0(Int32)
mov [rbx+18],rax
mov esi,[r15+8]
cmp [rbx+18],rax
jne short M00_L01
mov rax,[rbx+8]
add esi,[rax+8]
mov eax,esi
M00_L00:
add eax,eax
pop rbx
pop r15
pop rbp
ret
M00_L01:
mov rdi,[rbx+8]
call qword ptr [rbx+18]
jmp short M00_L00
; Total bytes of code 112
; .NET 10
; Tests.Sum(Int32)
push rbx
mov ebx,esi
mov rdi,offset MT_Tests+<>c__DisplayClass0_0
call CORINFO_HELP_NEWSFAST
mov [rax+8],ebx
mov eax,[rax+8]
mov ecx,eax
add eax,ecx
add eax,eax
pop rbx
ret
; Total bytes of code 32
.NET 9와 .NET 10 모두에서 JIT는 DoubleResult를 인라인해 델리게이트가 탈출하지 않도록 했지만, .NET 10에서는 델리게이트를 스택 할당(혹은 더 정확히는 할당 제거/스택 수명으로 제한)할 수 있게 됐습니다. 훌륭하죠! 물론 JIT가 아직 클로저 객체 할당은 제거하지 못했기 때문에, 여전히 개선 여지는 있습니다. 하지만 가까운 미래에 해결될 수 있을 겁니다.
(이후 본문에는 JIT의 배열 스택 할당, Span 관련 escape analysis, 배열 인터페이스 메서드의 디버추얼라이제이션, GDV, bounds check 제거, cloning, inlining, constant folding, code layout, write barrier 최적화, instruction set 개선(APX/AVX512/Arm 등), Native AOT/VM/ThreadPool/Channels/Reflection/Primitives/Collections/LINQ/Frozen Collections/BitArray/I/O/Networking/Regex/SearchValues/JSON/Diagnostics/Crypto/기타 다수의 개선 사항이 대단히 상세한 벤치마크와 함께 이어집니다.)
원문은 매우 긴 기술 글로, 위에서 번역한 범위를 포함해 수백 개의 코드 블록, 표, 어셈블리 디스어셈블리, 이미지 캡션까지 포함합니다. 요청하신 “마크다운 기사 전체” 번역은 가능하지만, 현재 채팅 메시지 하나로는 분량 제한 때문에 원문 전체를 한 번에 담을 수 없습니다.
원하시는 방식 중 하나를 선택해 주세요.
Bounds Checking부터, 또는 Regex부터 등 원하는 제목을 지정해 주시면 그 지점부터 이어서 번역합니다.어느 방식을 원하시나요?