.NET 10 JIT 컴파일러 개선 사항

먼저 .NET 10은 LTS(Long-Term Support) 버전으로, 마이크로소프트 공식 지원 기간이 3년입니다. 따라서 최신 주요 버전으로서 가능한 한 빠르게 업그레이드하여 사용할 수 있습니다.

오늘은 .NET 10의 주요 기능 중 하나인 JIT(Just-In-Time) 컴파일러 개선 사항을 자세히 소개하겠습니다.

.NET 10의 JIT 개선은 느린 코드를 빠르게 만드는 것이 아니라, "잘 설계된 코드가 성능 페널티를 받지 않도록" 하는 데 중점을 둡니다.

비즈니스 코드에 대한 "침���성"은 거의 없음

  • 코드를 다시 작성할 필요 없음
  • unsafe 키워드 사용 불필요
  • 특별한 컴파일 옵션 불필요

"좋은 코드"를 작성하면 JIT 점점 더 똑똑하게 성능을 극대화해 줍니다.

.NET 10의 JIT 컴파일러는 더 나은 코드 생성 및 최적화 전략을 통해 성능을 향상시키는 중요한 개선 기능을 포함하고 있습니다. 구조체 매개변수 코드 생성 개선

.NET의 JIT 컴파일러는 물리적 승격(physical promotion)이라는 최적화를 수행할 수 있습니다. 이는 구조체의 멤버를 스택 대신 레지스터에 배치하여 메모리 액세스를 제거하는 것입니다. 이 최적화는 구조체를 메서드에 전달할 때 특히 유용하며, 호출 규약은 구조체 멤버를 레지스터에 전달하도록 요구합니다.

.NET 10은 JIT 컴파일러의 내부 표현을 개선하여 공유 레지스터의 값을 처리합니다. 이전에는 구조체 멤버를 단일 레지스터에 패킹해야 할 때 JIT가 먼저 값을 메모리에 저장한 다음 레지스터로 로드했습니다. 이제 JIT 컴파일러는 구조체 매개변수의 최적화된 멤버를 공유 레지스터에 직접 저장하여 불필요한 메모리 작업을 제거할 수 있습니다.

struct Position
{
    public long X;
    public long Y;

    public Position(long x, long y)
    {
        X = x;
        Y = y;
    }
}

[MethodImpl(MethodImplOptions.NoInlining)]
private static void Process(Position pos)
{
    Console.WriteLine(pos.X + pos.Y);
}

private static void Main()
{
    Position pos = new Position(10, 20);
    Process(pos);
}

x64 CPU 아키텍처에서 Position의 멤버는 개별 레지스터에 할당되어 Process에 전달됩니다. 로컬 pos의 물리적 승격이 활성화되었으므로 초기에 스택에 아무것도 할당되지 않습니다.

Program:Main() (FullOpts): mov edi, 10 mov esi, 20 tail.jmp [Program:Process(Program+Position)]

이제 Position의 멤버 유형이 long이 아닌 int로 변경되었다고 가정해 봅시다. int는 네 바이트 너비이며, x64에서 레지스터는 8바이트 너비이므로 호출 규약은 Position의 멤버를 단일 레지스터에 전달하도록 요구합니다.

이전에는 JIT 컴파일러가 먼저 값을 메모리에 저장한 다음 8바이트 블록을 레지스터로 로드했습니다.

.NET 10에서 JIT 컴파일러는 이제 구조체 매개변수의 승격된 멤버를 공유 레지스터에 직접 배치할 수 있습니다:

Program:Main() (FullOpts): mov rdi, 0x140000000A tail.jmp [Program:Process(Program+Position)]

이렇게 하면 중간 메모리 저장이 필요 없어 더 효율적인 어셈블리 코드를 생성할 수 있습니다.

루프 변환 개선

JIT 컴파일러는 while 루프의 조건을 승격시켜 루프 본문을 do-while 루프로 변환할 수 있습니다. 이렇게 하면 최종 형태가 생성됩니다:

if (loopCondition)
{
    do
    {
        // 루프 본문
    } while (loopCondition);
}

이 변환을 루프 반전(loop reversal)이라고 합니다. 조건을 루프 하단으로 이동함으로써 JIT는 루프 상단으로 분기하여 조건을 테스트할 필요가 없어 코드 레이아웃이 개선됩니다. 루프 복제, 루프 전개(induction variable optimization) 등 많은 최적화도 루프 반전에 의존하여 이 형태를 생성하고 분석을 도움니다.

.NET 10은 어휘 분석 구현에서 그래프 기반 루프 인식 구현으로 전환하여 루프 반전을 강화했습니다.

이 변경은 모든 자연 루프(단일 진입점만 있는 루프)를 고려하고 이전에 고려되었던 오진(false positive)을 무시하여 정밀도를 높입니다. 이로 인해 forwhile 문을 포함하는 .NET 프로그램이 더 높은 최적화 잠재력을 갖게 됩니다.

배열 인터페이스 메서드 역가상화

.NET 10의 중점 영역 중 하나는 일반적으로 사용되는 언어 기능의 추상화 오버헤드를 줄이는 것입니다. 이 목표를 추구하기 위해 JIT의 메서드 호출 역가상화(devirtualization) 능력은 배열 인터페이스 메서드를 포함하도록 확장되었습니다.

배열을 순회하는 전형적인 방법을 살펴보겠습니다:

static int CalculateSum(int[] array)
{
    int sum = 0;
    for (int i = 0; i < array.Length; i++)
    {
        sum += array[i];
    }
    return sum;
}

이 코드 형식은 가상 호출이 없기 때문에 JIT 최적화하기 쉽습니다. 대신 JIT는 배열 액세스에 대한 경계 검사를 제거하고 .NET 9에서 추가된 루프 최적화를 적용하는 데 집중할 수 있습니다. 다음 예제에는 몇 가지 가상 호출이 추가됩니다:

static int CalculateSum(int[] array)
{
    int sum = 0;
    IEnumerable<int> temp = array;

    foreach (var num in temp)
    {
        sum += num;
    }
    return sum;
}

기본 컬렉션의 유형은 명확하므로 JIT는 이 코드 조각을 첫 번째 코드 조각으로 변환해야 합니다. 그러나 배열 인터페이스는 "일반" 인터페이스와 다른 방식으로 구현되므로 JIT는 이들을 역가상화하는 방법을 모릅니다. 이는 루프 내의 foreach 열거자 호출이 여전히 가상 상태를 유지하게 하여 인라인 및 스택 할당과 같은 여러 최적화를 방해합니다.

.NET 10부터 JIT는 배열 인터페이스 메서드를 역가상화하고 인라인할 수 있습니다. 이는 .NET 10 추상화 제거 계획에 설명된 것처럼 두 구현의 성능을 동일하게 만드는 여러 단계 중 첫 번째 단계입니다.

인라인 개선

.NET 10에서는 다양한 인라인 개선이 이루어졌습니다.

JIT는 이전 인라인으로 인해 비가상화 구현에 적합한 메서드를 인라인할 수 있게 되었습니다. 이 개선은 JIT가 추가 인라인 및 역가상화와 같은 더 많은 최적화 기회를 발견할 수 있게 합니다.

특히 try-finally 블록을 가진 메서드와 같은 일부 예외 처리 의미론을 가진 메서드도 인라인할 수 있습니다.

JIT의 특정 배열에 대한 스택 할당 능력을 더 잘 활용하기 위해 인라인 휴리스틱 알고리즘이 조정되어 작은 고정 크기 배열을 반환할 가능성이 있는 후보자의 수익성이 향상되었습니다.

반환 유형

인라인 과정에서 JIT 컴파일러는 반환 값을 저장하는 데 사용되는 임시 변수의 유형을 업데이트합니다. 호출된 쪽의 모든 반환 지점이 동일한 유형을 생성하는 경우 이 정확한 유형 정보가 사용되어 후속 호출의 가상화가 해제됩니다. 이 향상된 기능은 후기 비가상화 및 배열 열거 추상화 제거 개선을 보완합니다.

프로필 데이터

.NET 10은 JIT의 인라인 전략을 개선하여 프로필 데이터를 더 잘 활용합니다. 많은 휴리스틱 알고리즘에서 JIT 인라이너는 특정 크기 이상의 메서드를 인라인하지 않아 호출 메서드가 부풀어지는 것을 방지합니다. 호출 쪽이 특정 인라인 후보 함수가 빈번하게 실행된다는 것을 나타내는 프로필 데이터를 가지고 있을 때 인라이너는 해당 후보 함수에 대한 크기 허용 수준을 높입니다.

JIT가 프로필 데이터가 없는 호출된 쪽 Callee를 프로필 데이터가 있는 호출 쪽 Caller에 인라인한다고 가정해 봅시다. 이러한 불일치는 호출된 쪽이 감지할 가치가 없을 정도로 작거나 호출된 쪽이 너무 자주 인라인 호출되어 호출 횟수가 부족한 경우에 발생할 수 있습니다. Callee가 자체 인라인 후보를 가지고 있더라도 JIT는 이전에 이를 고려하지 않았습니다. 왜냐하면 Callee는 성능 분석 데이터가 부족했기 때문입니다. 이제 JIT는 Caller가 프로필 데이터를 가지고 있다는 것을 인식하고 크기 제한을 완화할 것입니다(하지만 정밀도 손실을 보상하기 위해 이 완화는 Callee가 프로필 데이터를 가질 때만큼은 이루어지지 않습니다).

마찬가지로 JIT가 특정 호출 지점이 인라인 최적화에 적합하지 않다고 결정할 때, 이는 향후 이 메서드에 대한 인라인 시도를 다시 하지 않도록 NoInlining으로 표시합니다. 그러나 많은 인라인 휴리스틱은 프로필 데이터에 민감합니다. 예를 들어, 프로필 데이터가 없는 경우 JIT는 메서드가 너무 커서 인라인할 가치가 없다고 생각할 수 있습니다. 그러나 호출 쪽이 충분히 "핫"할 때 JIT는 크기 제한을 완화하고 인라인 호출을 수행할 의사가 있을 수 있습니다. .NET 10에서 JIT는 더 이상 프로필 데이터로 인해 호출 지점의 성능이 저하되는 것을 방지하기 위해 수익성 없는 인라인 함수를 NoInlining으로 표지하지 않습니다.

스택 할당

스택 할당은 GC(가비지 컬렉터)가 추적해야 하는 객체 수를 줄이고 다른 최적화를 가능하게 합니다. 예를 들어, 객체가 스택에 할당된 후 JIT는 해당 객체를 완전히 스칼라 값으로 대체하는 것을 고려할 수 있습니다. 따라서 스택 할당은 참조 유형의 추상화 페널티를 줄이는 데 중요합니다. .NET 10은 값 유형과 참조 유형의 작은 배열에 대한 스택 할당을 추가합니다. 또한 로컬 구조체 필드와 대리자에 대한 이스케이프 분석(escape analysis)을 포함합니다.(이스케이프할 수 없는 객체는 스택에 할당할 수 있습니다.)

태그: .NET10 JIT컴파일러 성능최적화 C#

7월 12일 17:02에 게시됨