TECH 으로 돌아가기
TECH HACKER NEWS 오늘 8분 읽기 27 READS

.NET 11 성능 개선의 실제: JIT는 어떻게 추상화 비용을 지워내는가

.NET 11 성능 개선의 실제: JIT는 어떻게 추상화 비용을 지워내는가
SOURCE IMAGE · HACKER NEWS

마이크로소프트가 해마다 내놓는 '.NET 성능 개선' 시리즈는 이제 하나의 연례행사가 됐다. 스테판 토웁이 작성해 온 이 글은 .NET Core 2.0부터 5, 6, 7, 8, 9, 10을 거쳐 올해 .NET 11까지 이어지며, 런타임과 라이브러리에 축적된 수백 건의 개선을 마이크로벤치마크와 함께 훑는다. 필자는 이번 판을 록 밴드 다큐 패러디 영화 '이것이 스파이널 탭이다'에서 '눈금이 10이 아니라 11까지 올라가는 앰프' 장면에 빗댄다. 농담 같은 비유지만 요지는 분명하다. 성능 작업이란 극적인 한 방이 아니라, 경계 검사 하나를 없애고 할당 하나를 줄이고 잠금 하나를 걷어내고 루프를 몇 사이클 단축하는 식의 작은 이득이 서로 맞물려 누적되며 측정 가능한 차이를 만든다는 것이다.

실무자 입장에서 이 글의 가치는 단순히 '빨라졌다'는 선언이 아니라, 개선이 재현 가능한 형태로 제시된다는 점에 있다. 거의 모든 항목이 BenchmarkDotNet 기반의 자족적인 예제로 작성돼 있어, .NET 10과 11을 함께 설치하고 두 버전을 멀티타깃으로 빌드하면 같은 코드가 양쪽에서 어떻게 다르게 도는지 나란히 비교할 수 있다. 각 벤치마크 상단 주석에는 실행 명령까지 적혀 있다. 다만 필자 스스로 강조하듯 이들은 눈 깜빡할 새보다 짧은 연산을 재는 마이크로벤치마크이므로, 하드웨어와 OS, 런타임 설정, 그 순간 머신이 하던 다른 작업에 따라 결과가 달라진다는 전제는 잊지 말아야 한다.

JIT가 성능의 출발점인 이유

관리 코드는 결국 모두 JIT(적시 컴파일러)를 거친다. C#, F#, 비주얼베이직은 먼저 중간 언어(IL)로 컴파일되고, JIT가 이를 CPU가 실제로 실행하는 네이티브 명령으로 바꾼다. 그래서 JIT 한 곳의 개선은 특정 패턴이 등장하는 모든 애플리케이션과 라이브러리 코드에 파급되며, 대개 소스 수정이나 재컴파일 없이도 혜택이 돌아간다. 뜨거운 경로에서는 명령 하나를 지우거나 검사 하나가 불필요함을 증명하는 것만으로도 누적 효과가 커진다.

이번 글이 특히 공을 들이는 주제는 '역추상화(deabstraction)'다. 개발자는 인터페이스와 가상 호출 같은 추상화를 즐겨 쓰지만, 그 대가를 런타임마다 치르고 싶어 하지는 않는다. 런타임은 그 효과가 관찰되지 않음을 증명할 수 있으면 추상화를 걷어낸다. 가상 호출이 실제로 어떤 구체 메서드를 부르는지 알아내고, 힙에 할당한 객체가 현재 스택 프레임을 벗어나지 않음을 인식하고, 앞서 확립한 타입 사실을 캐스트에 재활용하는 식이다.

가상·인터페이스 호출을 지워내는 방법

interface를 쓸 때마다 우리는 계약을 만든다. 그 유연함 덕에 IEnumerable 하나로 배열, 리스트, LINQ, 사용자 정의 반복자까지 동일하게 다룰 수 있다. 그러나 CPU는 계약을 모른 채 명령만 실행한다. 인터페이스 참조가 가리키는 메서드를 호출하려면 객체 앞머리에 저장된 메서드 테이블 포인터를 읽고, 알려진 슬롯을 색인하고, 거기서 찾은 함수 포인터를 간접 호출해야 한다. 이 세 번의 종속적 메모리 참조와 간접 호출 자체도 부담이지만, 더 큰 손실은 인라이닝 기회를 잃는다는 데 있다. 호출 대상이 간접적이면 JIT는 피호출자 코드를 호출자에 접어 넣지 못하고, 그 결과 상수 전파, 죽은 코드 제거, 경계 검사 제거 같은 후속 최적화의 문이 함께 닫힌다.

그래서 JIT는 '탈가상화(devirtualization)'를 시도한다. new Dog()로 갓 할당했거나 변수 타입이 sealed 클래스이거나, NativeAOT의 전체 프로그램 컴파일에서 Animal의 유일한 파생 타입이 Dog임이 보이는 경우처럼 정적 분석으로 타입을 확정할 수 있으면 Dog.Speak()를 직접 호출로 바꾸고 인라이너가 개입한다. 정적으로 증명할 수 없을 때는 프로파일 기반 최적화(PGO)가 나선다. 계층형 컴파일에서 처음 호출된 메서드는 최적화가 거의 없는 Tier 0으로 컴파일되며, 이때 삽입된 프로브가 어떤 분기가 실행되고 가상 호출 지점에 어떤 구체 타입이 등장하는지 기록한다. 충분히 자주 호출되면 런타임은 그 관측을 반영한 Tier 1 버전을 요청한다.

다만 JIT는 언제나 올바른 코드를 내야 한다. 프로파일상 100% Dog였더라도 1001번째 호출이 Dolphin일 수 있기 때문이다. 해결책은 런타임 검사를 심는 것이다. Dog 경로에는 인라이닝 가능한 직접 호출을, 나머지에는 기존 가상 호출을 폴백으로 남긴다. 흔한 경우를 아주 작게 만들어 속도를 얻고, 드문 경우를 그대로 둬 정확성을 지키는 이 '추측 후 검증' 패턴이 바로 '보호된 탈가상화(GDV)'이며, 실제 워크로드의 처리량 향상 상당수가 여기서 나온다. 인터페이스 디스패치는 한 타입이 임의 개수의 인터페이스를 구현할 수 있어 슬롯이 고정 vtable 위치에 대응하지 않으므로 가상 디스패치보다 다소 비싼데, GDV는 여기에도 적용된다.

할당을 아예 없애는 탈출 분석

역추상화는 객체 생성 비용도 줄인다. .NET 객체는 기본적으로 GC 힙에 할당돼 추적되고 더는 도달할 수 없을 때 회수된다. 포인터를 밀어 올리는 힙 할당은 대개 빠르지만 공간이 부족하면 가비지 컬렉션을 유발하며 비싸지고, 할당된 객체는 결국 정리 비용을 분할 부담한다. '탈출 분석(escape analysis)'은 새로 할당한 객체가 현재 메서드를 벗어나는지를 따진다. 벗어나지 않음이 증명되면 JIT는 힙 대신 스택에 할당할 수 있고, 이는 스택 포인터를 감소시키는 것만으로 끝나 할당과 정리가 사실상 공짜이며 GC 부담이 0이 된다. .NET 9와 10이 델리게이트·클로저, Nullable 임시 객체, 소형 헬퍼 객체의 스택 할당에 크게 투자한 데 이어, .NET 11은 객체가 탈출한다고 잘못 판단하던 '거짓 양성' 목록을 여러 방향으로 더 깎아냈다. 이런 개선은 코드 한 줄 바꾸지 않아도 런타임 업그레이드만으로 돌아온다는 점에서, 뜨거운 경로를 가진 서비스일수록 실측 벤치마크로 자기 워크로드에서의 실제 이득을 확인해 볼 가치가 있다.

SOURCE · HACKER NEWS
원문 전체 보기 → https://devblogs.microsoft.com/dotnet/performance-improvemen...
SHARE
NEXT · CHOOSE

변화를 읽었다면,
내가 만들 수익 구조를 고릅니다.

정보를 더 모으는 데서 멈추지 않고, 광고·외주·판매·중개·구독 중 내 상황에 맞는 출발점을 정해보세요.

21가지 수익 구조 살펴보기
처리 중...