티스토리 뷰

Java & Kotlin

[JVM] JIT 컴파일러(Just-In Time Compiler)의 최적화 과정 자세히 살펴보기

망나니개발자 2024. 3. 26. 10:00
반응형

 

 

1. JIT 컴파일러(Just-In Time Compiler)의 최적화 과정 자세히 살펴보기


[ Inlining(인라인) ]

앞선 포스팅에서 살펴보았듯 Inlining은 호출되는 메서드를 본문으로 인라인하여 메서드 호출에 대한 오버헤드를 줄일 수 있고, 이를 통해 다른 최적화를 적용할 수도 있다. 하지만 인라인은 코드 크기를 늘리기 때문에 신중하게 적용되어야 한다. 왜냐하면 컴파일된 코드는 제한된 코드 캐시라는 힙 공간에 저장되는데, 공간이 부족해지면 더 이상 코드를 컴파일 할 수 없기 때문이다.

 

 

따라서 JVM은 휴리스틱하게 메서드의 인라인 여부를 결정하는데, 대표적으로 자주 호출되는 메서드나 Getter와 Setter 같은 간소한 메서드들을 인라인하려고 시도한다. 인라인 정책은 JVM 매개변수로 변경할 수 있지만, 이것보다 최적의 값을 찾는 것은 매우 어려우므로 변경하지 않는 것이 권장된다. 만약 중요한 코드에 인라이닝에 의한 문제가 있다고 의심되는 경우에는 다음의 플래그를 활성화하여 JVM 내부 동작을 확인할 수 있다.

  • XX:+PrintCompilation
  • XX:+UnlockDiagnosticVMOptions
  • XX:+PrintInlining

 

 

예를 들어 다음과 같은 메서드가 있다고 하자. b가 0인 경우는 매우 길고 복잡한 로직이 실행된다고 하자.

int doSomeCalculation(int a, int b) {
    if (b == 0) {
        ...
        // long and complicated error handling
        ...
        return 1;
    } else {
        return a + b;
    }
}

 

 

doSomeCalculation 메서드는 b가 0인 경우에 의해 메서드가 매우 길고 복잡하므로, doSomeCalculation 메서드를 호출하는 곳에서 doSomeCalculation 메서드를 인라인할 수 없음을 확인할 수 있다.

@ 13   org.sample.FirstExample::doSomeCalculation (13870 bytes)   hot method too big

 

 

하지만 다음과 같이 b가 0인 부분을 별도의 메서드로 추출해서 doSomeCalculation 메서드를 간결하게 만들면 인라이닝이 가능하다.

int doSomeCalculation(int a, int b) {
    if (b == 0) {
        return handleEdgeCase();
    } else {
        return a + b;
    }
}

int handleEdgeCase() {
    ...
    // long and complicated error handling with many other calls
    ...
    return 1;
}

 

 

이렇듯 가독성과 성능을 동시에 높이는 것은 상당히 보기 드문 케이스 중 하나인데, 이 경우에는 가능하다.

@ 13   org.sample.FirstExample::doSomeCalculation (13 bytes)   inline (hot)
@ 5    org.sample.FirstExample::handleEdgeCase (13862 bytes)   hot method too big

 

 

 

 

[ Polymorphism Inlining(다형성 인라인) ]

앞서 살펴보았듯 다형성이 연관된 경우 수신자 객체의 유형에 따라 다른 메서드를 참조할 수 있어서, 까다롭지만 인라이닝이 가능하긴 하다고 하였다.

예를 들어 다음과 같이 여러 구현을 갖는 Calculator가 있다고 하자.

interface Calculator {
    int doSomeCalculation(int a, int b);
}

class SimpleCalculator implements Calculator {
    public int doSomeCalculation(int startValue, int step) {
        return startValue + step;
    }
}

 

 

만약 다음과 같이 자주 사용되는 calculator 호출부를 인라이닝 할 수 있다면 훨씬 효율적일 것이다.

public void handleRequest(Calculator calculator, int a, int b) {
        int result = calculator.doSomeCalculation(a, b);
        // Do something with the result.
}

 

 

JVM은 다형성 호출부에 대한 프로필을 수집하므로, 충분한 시간이 주어지면 이를 효과적으로 인라인할 수 있는 방법을 찾아내려고 한다. 만약 calculator가 monomorphic이라면(단일 구현) 쉽게 인라인할 수 있다.

@ 42   org.sample.NMorphicInlinig::handleRequest (9 bytes)   inline (hot)
  @ 3   org.sample.NMorphicInlinig$MarvelousCalculator::doSomeCalculation (4 bytes)   inline (hot)

 

 

JVM은 bimorphic(두개의 구현)인 경우도 감지하고 인라인 처리할 수 있으므로, 코드에 어떤 인라인 코드를 실행할지 결정하는 유형 검사를 포함할 수 있다.

@ 42   org.sample.NMorphicInlinig::handleRequest (9 bytes)   inline (hot)
  @ 3   org.sample.NMorphicInlinig$MarvelousCalculator::doSomeCalculation (4 bytes)   inline (hot)
  @ 3   org.sample.NMorphicInlinig$SimpleCalculator::doSomeCalculation (4 bytes)   inline (hot)
   \\-> TypeProfile (321335/642671 counts) = org/sample/NMorphicInlinig$SimpleCalculator
   \\-> TypeProfile (321336/642671 counts) = org/sample/NMorphicInlinig$MarvelousCalculator

 

 

하지만 각각의 타입이 정기적으로 호출되는 경우에는 인라인하지 않는다.

@ 42   org.sample.NMorphicInlinig::handleRequest (9 bytes)
  @ 3   org.sample.NMorphicInlinig$Calculator::doSomeCalculation (0 bytes)   not inlineable

 

 

만약 초기에 하나의 타입에 의해서만 호출된다면 다음과 같이 인라인을 시도하기도 한다.

@ 3   org.sample.NMorphicInlinig$MagnificentCalculator::doSomeCalculation (4 bytes)   inline (hot)
   \\-> TypeProfile (13236/13236 counts) = org/sample/NMorphicInlinig$MagnificentCalculator

 

 

하지만 새로운 타입에 의해 호출된다면 다음과 같이 빠르게 최적화를 버린다.

@ 17   org.sample.NMorphicInlinig::testMethod (27 bytes)   force inline by CompileOracle
  @ 2   org.sample.NMorphicInlinig::getCalculator (49 bytes)   callee is too large
  @ 19   org.sample.NMorphicInlinig::handleRequest (9 bytes)
    @ 3   org.sample.NMorphicInlinig$Calculator::doSomeCalculation (0 bytes)   not inlineable

 

 

이렇듯 최적화와 최적화 취소를 계속해서 반복하는 방식으로 JVM은 동작한다.

@ 19   org.sample.NMorphicInlinig::handleRequest (9 bytes)   inline (hot)
   @ 3   org.sample.NMorphicInlinig$MagnificentCalculator::doSomeCalculation (4 bytes)   inline (hot)
   @ 3   org.sample.NMorphicInlinig$SimpleCalculator::doSomeCalculation (4 bytes)   inline (hot)
    \\-> TypeProfile (1/791011 counts) = org/sample/NMorphicInlinig$SimpleCalculator
    \\-> TypeProfile (791010/791011 counts) = org/sample/NMorphicInlinig$MagnificentCalculator

 

 

그러다가 JVM이 호출이 한 유형에 의해 지배되고 있다고 판단하면 인라이닝을 다시 수행하는 것이다.

@ 19   org.sample.NMorphicInlinig::handleRequest (9 bytes)   inline (hot)
  @ 3   org.sample.NMorphicInlinig$MagnificentCalculator::doSomeCalculation (4 bytes)   inline (hot)
   \\-> TypeProfile (825826/825828 counts) = org/sample/NMorphicInlinig$MagnificentCalculator

 

 

인라인 코드는 알려진 드문 타입을 처리하도록 준비되어 있지만, 가장 빈번한 경우를 인라인 처리하여 지배적인 유형을 처리하도록 최적화되어 있다. 따라서 정리하면 JVM은 인자로 제공된 구체적인 타입을 기반으로 최적화한다. 호출 사이트에서 메서드에 하나의 타입의 객체만 제공되면 해당 케이스만 처리하도록 특화된다.

 

 

그렇다면 만약 다음과 같이 전달되는 객체를 변경하게 되면 어떻게 될까?

  1. 두 개의 다른 계산기를 같은 비율로 호출함
  2. 이후에 5개의 다른 계산기를 같은 비율로 호출함
  3. 이후에 단일 유형의 계산기만을 호출함

 

 

 

 

먼저 단일 타입으로 최적화된 상태에서 2개의 계산기를 같은 비율로 호출하니 상황에 맞게 재최적화되었다. 하지만 다시 단일 타입으로 최적화시킨 후에 호출 계산기를 5개로 늘렸더니 doSomeCalculation 메서드를 인라인할 수 없다고 표시되었다. 호출부가 megamorphic이라고 결론을 내리고 더 이상 최적화하지 않은 것 이다.

 

 

 

[ Branch prediction and Untaken branch pruning
(분기 예층 및 미사용 분기 가지치기) ]

일반적으로 CPU는 명령어를 실행하려고 할 때 디코딩, 관련 데이터 가져오기, 명령어 처리, 결과 쓰기 등 많은 작업을 수행해야 한다. 최신 프로세서는 이러한 단계를 위한 별도의 하드웨어가 있으므로 한 명령어가 이전 명령어가 완전히 완료될 때까지 기다릴 필요 없이 병렬로 실행할 수 있다. 예를 들어, 명령어가 디코딩되는 즉시 다음 명령어의 디코딩이 시작될 수 있으므로 이전 명령어가 완전히 완료될 때까지 기다릴 필요가 없다. 이 기술을 명령어 파이프라이닝이라고 한다.

그러나 실행이 분기를 만나면 다음 명령은 이전 명령의 결과에 따라 결정되므로 병렬 처리가 작동하지 않는다. CPU가 어떤 분기를 취할지 추측하고 그에 따라 명령을 실행하는 것이다. 올바르게 추측했다면 성능 저하가 없지만, 틀렸다면 파이프라인을 플러시해야 하므로 이전 노력이 낭비되고 새로운 명령어로 다시 시작해야 한다.

JVM은 코드의 분기 실행 빈도를 모니터링하고 그에 따라 컴파일을 시도함으로써 다양한 하드웨어의 특성을 고려해 분기 오버헤드를 최소화한다. 분기 예측을 돕기 위해 생성된 머신 코드에 힌트를 추가할 수도 있다고 생각했는데, 이 스택 오버플로 답변에서 찾은 메일 스레드는 그렇지 않음을 알려준다.

 

따라서 다음의 실험은 JVM과 최신 하드웨어 모두에 의한 최적화를 측정하는 것이다. 아래의 코드는 주어진 숫자가 0보다 크거나 같을 때 특정 작업을 수행한다. 이미 warm-up된 상태이며 두 가지 모두 최소 수천 번 이상 수행된다고 하자.

public void isNonNegative(int i) {
    if (i >= 0) {
            // Do something.
    } else {
            // Do something else.
    }
}

 

 

다음의 실험 결과를 보면 어떤 분기를 취할지 예측할 수 있을수록 더 잘 최적화할 수 있음을 보여준다. 예를 들어 양수만에서 음수만으로 변경해도 평균 성능에 거의 영향을 미치지 않는 등 시스템은 변화를 빠르게 따라간다. 또한, JVM은 분기를 사용하지 않는 경우 이를 감지하여 컴파일된 코드에서 해당 분기를 생략함으로써 크기를 줄이고 다른 최적화를 개선한다. 컴파일된 코드에는 분기 처리가 필요한 경우에 대비한 비주류 코드에 대한 대비가 포함되어 있어서, 비주류 코드가 호출되면 최적화를 해제한다.

 

 

[ Optimistic nullness assertions (낙관적 널 검사 생략) ]

흔히 볼 수 있는 에러 유형 중 하나는 널 포인터와 관련된 부분이다. 예를 들어 널 포인터 역참조(Null Pointer Dereference)는 널 포인터에 어떤 값은 대입할 때 발생하는 에러이다. 만약 널 포인터의 값을 읽으려고 하면 세그멘테이션 폴트가 발생하며 프로세스가 종료될 수도 있다.

비정상적인 종료는 일반적으로 바람직하지 않으므로 JVM은 이러한 신호를 NullPointerException으로 전환한다. 따라서 명시적으로 확인하지 않더라도 다음 코드는 입력 문자열이 null인 경우를 처리하고 예외를 던진다.

public String mix(String s) {
    // if s is null then throw a NullPointerException
    return s.toLowerCase() + s.toUpperCase();
}

 

 

암시적인 널 검사(implicit null-checking)에는 비용이 발생하지만, 만약 수집된 런타임 프로파일에서 전달되는 널이 없는 것으로 나타나면 JVM은 이를 낙관적으로 생략할 수 있다. 다음은 의도적으로 몇 개의 널을 주입한 후와 주입하지 않은 경우의 실험 결과이다.

 

 

이러한 실험을 통해 우리가 작성하는 코드와 JVM이 최적화하여 실제 실행되는 코드에는 큰 차이가 있을 수 있음을 알 수 있다. 따라서 이러한 성능 개선 포인트를 안다면 보다 최적화를 할 수 있을 것이다.

위의 내용은 해외의 포스팅을 번역 및 요약 그리고 재정리한 내용이다. 원문은 참고자료를 참고하도록 하자.

 

 

 

 

관련 포스팅

 

 

참고 자료

 

 

 

 

반응형
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG more
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함