티스토리 뷰

반응형
Real-time Java, Part 2: 컴파일 기술 비교

Real-time Java ™ 시리즈, 두 번째 글에서는 자바 언어의 네이티브 컴파일과 관련한 문제점들을 설명합니다. 동적 (Just-in-time) 또는 정적 (Ahead-of-time) 컴파일 단독으로는 모든 자바 애플리케이션들의 요구 사항들을 맞출 수 없습니다. 필자는 다양한 실행 환경에서 이 두 개의 컴파일 기술들을 비교하고 서로 어떻게 보완되는지를 설명합니다.

자바 애플리케이션 성능 문제는 개발 커뮤니티에서 가끔씩 뜨거운 논쟁을 불러 일으킨다. 이 언어는 애플리케이션 이식성(portability)이라는 중요한 목표를 지원하도록 인터프리팅 되었기 때문에 초기 자바 런타임은 C와 C++ 같은 컴파일 된 언어 보다 훨씬 낮은 성능 레벨을 제공했다. 이 같은 언어들이 더 높은 레벨에서 수행될 수 있지만, 생성된 코드는 한정된 수의 시스템에서만 실행될 수 있다. 지난 10년 동안, 자바 런타임 벤더들은 Just-in-time (JIT) 컴파일러로 잘 알려진 고급 동적 컴파일러를 개발했다. 프로그램이 실행 되는 동안 JIT 컴파일러는 가장 자주 실행되는 메소드를 네이티브 코드로 선택적으로 컴파일 한다. C 또는 C++로 작성된 프로그램들처럼, 프로그램이 실행되기 전에 컴파일 하는 것이 아닌 네이티브 코드 컴파일을 런타임으로 지연시킴으로써 이식성 요구 사항들을 맞춘다. 일부 JIT 컴파일러는 인터프리터를 사용하지 않고 모든 코드를 공평하게 컴파일 하지만, 이러한 컴파일러 역시 프로그램이 실행되는 동안 작동함으로써 자바 애플리케이션에 대한 이식성을 보존한다.

동적 컴파일 기술의 큰 발전 덕택에 요즘의 JIT 컴파일러는 C 또는 C++의 정적으로 컴파일 된 성능에 견줄만한 애플리케이션 성능을 만들어 낼 수 있다. 여전히, 많은 소프트웨어 개발자들은 컴파일러가 애플리케이션과 CPU를 공유해야 하기 때문에 동적 컴파일이 프로그램 연산을 방해할 것이라고 생각한다. 일부 개발자들은 성능 문제를 풀 수 있을 것이라는 강한 확신으로 자바 코드에 정적 컴파일을 요구하기도 한다. 일부 애플리케이션과 실행 환경들의 경우, 정적 컴파일이 자바 성능에 큰 도움이 되거나 유일한 실질적인 옵션이 되는 것도 사실이다. 하지만 자바 애플리케이션을 정적으로 컴파일 할 때 좋은 성능을 이룩하기 위해서는 많은 복잡한 요소들이 개입된다. 보통의 자바 개발자는 동적 JIT 컴파일러가 가진 강점을 충분히 활용하지 못하고 있다.

이 글에서는, 실시간(RT) 시스템을 중심으로 자바 언어를 동적 및 정적으로 컴파일 하는 것과 관련한 몇 가지 문제들을 짚어볼 것이다. 자바 인터프리터가 어떻게 작동하는지를 간략히 설명하고 현대적인 JIT 컴파일러로 수행되는 네이티브 코드 컴파일의 장단점을 설명할 것이다. IBM®이 WebSphere® Real Time에서 도입했던 AOT 컴파일 기술을 소개하고 이것의 장단점을 설명한다. 두 가지의 컴파일 전략을 비교 및 대조하고 AOT 컴파일이 더욱 효과적인 애플리케이션 영역과 실행 환경들을 조명한다. 이러한 두 가지의 컴파일 기술들은 상호 배타적이지 않다는 점이 중요하다. 이 두 가지 모두 각각의 기술이 가장 효과적으로 사용되는 애플리케이션에 영향을 미칠 수 있는 장단점을 갖고 있다.

자바 프로그램 실행하기

자바 프로그램은 Java SDK의 javac 프로그램을 통해 클래스 파일로 알려진 네이티브 플랫폼 중립적인 포맷으로 초기에 컴파일 된다. 이 포맷은 자바 언어로 작성된 프로그램을 실행하는데 필요한 모든 정보를 정의하기 때문에 자바 플랫폼으로 간주될 수도 있다. Java Runtime Environment (JRE)로 알려진 자바 프로그램을 위한 실행 엔진에는 특정 네이티브 플랫폼을 위한 자바 플랫폼을 구현하는 가상 머신을 포함하고 있다. 예를 들어, 리눅스® 기반 Intel x86 플랫폼, Sun Solaris, AIX® 기반 IBM System p™ 플랫폼은 각각의 JRE를 갖고 있다. 이러한 JRE들은 자바 플랫폼을 위해 작성된 프로그램을 정확히 실행하는데 필요한 모든 것을 지원한다.

사실, 피연산자 스택의 크기는 실질적인 한계가 있지만, 프로그래머가 그 한계를 초과하는 메소드를 작성하는 일은 거의 없다. JVM은 이 같은 메소드를 생성하는 프로그래머들에게 안정성 체크 공지를 보낸다.

자바 플랫폼 프로그램에서 한 가지 중요한 부분은 자바 클래스의 각 메소드가 수행하는 연산을 기술하는 바이트코드(bytecodes) 시퀀스이다. 바이트코드는 이론상으로 한계가 없는 피연산자 스택을 사용한 계산을 의미한다. 이러한 스택 기반 프로그램 표현은 플랫폼 중립성을 제공한다. 특정 네이티브 플랫폼의 CPU에 사용할 수 있는 레지스터들의 수에 의존하지 않기 때문이다. 피연산자 스택에서 수행될 수 있는 연산은 네이티브 프로세서의 명령어 세트와는 독립적으로 정의된다. 이러한 바이트코드의 실행은 Java Virtual Machine (JVM) 스팩에 의해 정의된다. (참고자료) 자바 프로그램을 실행할 때, 특정 네이티브 플랫폼용 JRE는 JVM 스팩에서 정한 규칙을 준수해야 한다.

일부 네이티브 플랫폼들은 스택 기반(Intel X87 부동 소수점 보조 처리 장치는 예외이다.)이기 때문에 대부분의 네이티브 플랫폼은 자바 바이트코드를 직접 실행할 수 없다. 이러한 문제를 해결하기 위해 초기 JRE는 바이트코드를 interpreting 함으로써 자바 프로그램을 실행했다. 다시 말해서, JVM은 다음을 반복적으로 순환한다.

  1. 다음에 실행할 바이트코드를 불러온다.
  2. 바이트코드를 해독(decode)한다.
  3. 피연산자 스택에서 필요한 피연산자를 불러온다.
  4. JVM 스팩에 따라 연산을 수행한다.
  5. 결과를 다시 스택에 작성한다.

이러한 접근 방식의 장점은 단순함이다. JRE 개발자는 각 유형의 바이트코드를 핸들 할 코드만 작성하면 된다. 연산을 기술할 때에는 255 바이트코드 미만이 사용되기 때문에 구현 비용도 낮다. 물론 단점은 성능이다. 많은 사람들은 초기에 다른 많은 장점들에도 불구하고 자바 플랫폼을 욕했다.

C또는 C++ 같은 언어들과의 성능 차이를 해결한다는 것은 이식성을 희생시키지 않은 방식으로 자바 플랫폼을 위한 네이티브 코드 컴파일을 개발한다는 의미였다.




위로


자바 코드 컴파일 하기

자바 프로그래밍의 "write-once-run-everywhere"가 모든 상황에 다 맞는 것은 아니지만 다양한 애플리케이션에 적용된다. 반면, 네이티브 컴파일은 본질적으로 플랫폼 스팩이다. 그렇다면 자바 플랫폼에서 플랫폼 중립성을 희생시키지 않고 네이티브 컴파일 성능을 얻을 수 있을까? 해답은 JIT 컴파일러의 동적 컴파일이다. (그림 1)


그림 1. JIT 컴파일러
그림 1. JIT 컴파일러

더 나은 성능을 얻기 위해 네이티브 프로세서의 명령어로 실행되기 때문에, JIT 컴파일러를 사용할 때 자바 프로그램은 한번에 하나의 메소드가 컴파일 된다. 바이트코드와는 다르지만 대상 프로세서의 네이티브 명령어 보다는 높은 레벨에 있는 내부적인 메소드 표현을 만든다. (IBM JIT 컴파일러는 일련의 식 트리를 사용하여 메소드 연산을 나타낸다.) 컴파일러는 최적화 작업을 수행하여 품질과 효율성을 향상시키고, 최적화된 내부 표현을 대상 프로세서의 네이티브 명령어로 변환하는 코드 생성 단계를 수행한다. 생성된 코드는 런타임 환경에 기반하여, 유형이 합법적인지를 확인하거나 코드에서 직접 수행할 특정 객체 유형을 할당하는 등의 액티비티를 수행한다. 애플리케이션이 컴파일을 기다리지 않도록 하기 위해 JIT 컴파일러는 애플리케이션 쓰레드와 분리된 컴파일 쓰레드에서 작동한다.

또한, 그림 1에서 설명한 것처럼, 자주 실행되는 메소드를 찾기 위해 쓰레드를 주기적으로 샘플링 함으로써 실행 프로그램의 작동을 관찰하는 프로파일링 프레임웍도 있다. 또한 특화된 프로파일링 메소드 버전을 위한 장치를 제공하여 프로그램의 실행 시 변경되지 않는 동적 값들을 저장한다.

JIT 컴파일 절차는 프로그램이 실행 중인 동안 발생하기 때문에 플랫폼 중립성이 유지된다. 중립적인 자바 플랫폼 코드는 배포된 형태로 되어 있다. C와 C++ 같은 언어들은 이러한 부분이 부족하다. 이들의 네이티브 컴파일 단계는 프로그램이 실행되기 전에 수행된다. 네이티브 코드는 (네이티브 플랫폼) 실행 환경으로 배포된 것이 된다.

문제점

플랫폼 중립성이 JIT 컴파일로 유지되더라도 대가가 있다. 컴파일은 프로그램 실행과 동시에 발생하기 때문에 코드 컴파일에 걸리는 시간은 프로그램의 실행 시간에 추가된다. 중요한 C 또는 C++ 프로그램을 구현해봤던 사람이라면 알겠지만, 컴파일은 빠른 프로세스가 아니다.

이러한 문제를 해결하기 위해 현대적인 JIT 컴파일러는 두 가지의 방법 중 하나를 사용한다. (어떤 경우에는 두 방법 모두를 사용한다.) 첫 번째 방법은 값비싼 분석이나 변형을 수행하지 않고 모든 코드를 컴파일 하여 코드가 빠르게 생성될 수 있도록 하는 것이다. 이 코드는 매우 빠르게 생성될 수 있기 때문에 컴파일에서 생기는 오버헤드는 크기는 하지만 반복적으로 네이티브 코드를 실행하여 얻은 성능 향상은 이 오버헤드를 상쇄하고도 남는다. 두 번째 방법은 컴파일 리소스를 자주 실행되는 소수의 메소드(핫(hot) 메소드)에만 사용하는 것이다. 컴파일 오버헤드는 핫 코드를 반복적으로 실행함으로써 얻은 성능상의 이점으로 쉽게 극복할 수 있기 때문에 이러한 방식을 컴파일의 성능 비용을 효과적으로 줄인다.

동적 컴파일러의 근본적인 복잡성은 메소드의 실행이 전체 프로그램이 성능에 얼마나 많이 기여하는지 알아야 할 필요성과 코드를 컴파일 함으로써 얻은 예상 기대와의 균형을 맞추는 것에서 기인한다. 극단적인 예로, 프로그램 실행 후에 특정 실행에 가장 많이 기여한 메소드가 어떤 것인지에 대해 완전히 알 수 있지만 그러한 메소드를 컴파일 하는 것은 가치가 없다. 프로그램이 이미 끝났기 때문이다. 반면, 프로그램이 실행되기 전에는 어떤 메소드가 중요한 것인지를 알 수 없지만, 각 메소드의 잠재적 이점이 극대화 된다. 대부분의 동적 컴파일러들은 이러한 양 극단 사이에서 작동하면서 중요한 것이 무엇인지를 파악하고 그러한 지식에서 얻는 효용성과의 균형을 맞춘다.

자바 언어는 동적으로 로딩 될 클래스를 필요로 한다는 사실이 자바 컴파일러 디자인에 큰 영향을 미친다. 아직 로딩되지 않은 또 다른 클래스를 참조하는 코드가 컴파일 된다면? 아직 로딩되지 않은 클래스에 대한 정적 필드의 값을 읽는 메소드가 한 예이다. 자바 언어에서는 클래스에 대한 최초의 참조로 클래스가 로딩되도록 하고 현재의 JVM으로 변환된다. 첫 번째 실행까지는, 참조는 변환되지 않는다. 다시 말해서 정적 필드를 로딩할 주소가 없다는 의미이다. 컴파일러가 이러한 가능성을 어떻게 처리할까? 컴파일러는 클래스가 로딩되지 않았을 경우 클래스가 로딩 및 변환될 수 있도록 하는 코드를 생성한다. 클래스가 변환되면 원래 코드 위치는 쓰레드 보안 방식으로 수정되어 정적 필드의 주소로 직접 액세스 한다.

안전하고 효율적인 코드 패칭(code-patching) 기술을 사용하기 위해 IBM JIT에 상당히 많은 노력을 기울여 클래스가 변환된 후에, 마치 그 필드가 컴파일 시 변환된 것처럼, 실행되는 네이티브 코드가 필드 값을 로딩한다. 대안은 필드가 어디에 있는지를 찾고 값을 로딩하기 전에 필드가 변환되었는지를 확인하는 코드를 생성하는 것이다. 변환되고 자주 액세스 되는 변환되지 않은 필드들의 경우, 이러한 네이티브 프로시저가 매우 큰 성능 문제를 만들어 낼 수 있다.

동적 컴파일의 효과

자바 프로그램을 동적으로 컴파일 하면 정적으로 컴파일 된 언어에서 가능한 것 보다 더 나은 코드를 생성할 수 있다는 중요한 이점이 있다. 현대적인 JIT 컴파일러는 후크(hook)를 생성된 코드로 삽입하여 프로그램이 작동하는 방법에 대한 정보를 모으고, 재컴파일을 위한 메소드가 선택되면 동적 작동이 더 최적화 될 수 있다.

이러한 접근 방식의 좋은 예가 특정 arraycopy 연산의 길이를 수집하는 것이다. 이것이 실행될 때마다 길이가 일정하면 가장 자주 사용되는 arraycopy 길이를 위한 특별 코드가 생성되거나, 그 길이에 맞게 더욱 잘 조정된 코드 시퀀스가 호출될 수 있다. 메모리 시스템과 명령어 세트 디자인의 특성 때문에 카피 메모리에 대한 최상의 일반 루틴은 특정 길이를 카피하기 위해 작성된 코드만큼 빠르지 않다. 예를 들어, 8 바이트의 데이터를 복사하기 위해서는 한 개 또는 두 개의 명령어가 필요하다. 이는 어떤 바이트라도 처리할 수 있는 일반 카피 루프를 사용하여 같은 8 바이트를 복사하는 10 개의 명령어에 견줄만한 크기이다. 이 같은 직렬화된 코드가 하나의 특정 길이를 위해 생성되더라도 생성된 코드는 다른 길이에 맞는 카피를 정확히 수행해야 한다. 이 코드는 일반적으로 관찰되는 길이에 맞게 더 빨라서 일반적으로 성능이 향상된다. 이러한 유형의 최적화는 대부분의 정적으로 컴파일 된 언어에는 비현실적이다. 모든 가능한 예외에 일관된 길이는 특정 프로그램 실행해 일관된 길이 보다 드물기 때문이다.

이러한 종류의 최적화의 또 다른 중요한 예제는 클래스-계층 기반 최적화이다. 예를 들어, 가상 메소드 호출에는 어떤 대상이 수신자 객체를 위해 가상 메소드를 구현하는지를 알기 위해 수신자 객체의 클래스를 보는 것이 포함된다. 연구 결과 대부분의 가상 호출들에는 모든 수신 객체들에 대한 하나의 대상만을 갖고 있고, JIT 컴파일러들은 가상 호출 보다 효율적인 직접 호출을 위한 코드를 생성할 수 있다. 코드가 컴파일 될 때 클래스 계층의 상태를 분석함으로써, JIT 컴파일러는 가상 호출에 대한 하나의 대상 메소드를 찾을 수 있고 느린 가상 호출을 수행하는 것 보다는 대상 메소드를 직접 호출하는 코드를 생성할 수 있다. 물론, 클래스 계층이 변하고 두 번째 대상 메소드가 가능하다면, JIT 컴파일러는 원래 생성된 코드를 수정하여 가상 호출이 수행되도록 할 수 있다. 실제로, 이러한 수정은 거의 필요하지 않다. 이렇게 수정한다면 최적화를 정적으로 수행하는 것은 많은 문제를 남기게 된다.

동적 컴파일러는 일반적으로 적은 수의 핫(hot) 메소드에 집중하기 때문에, 보다 적극적인 분석이 수행되어 더 나은 코드를 만들어서 컴파일에 대한 보상이 훨씬 높아진다. 사실, 가장 현대적인 JIT 컴파일러들은 최고의 핫(hot) 메소드들의 재컴파일도 지원한다. 이렇게 자주 실행되는 메소드들은 분석될 수 있고 정적 컴파일러에서 찾아볼 수 있는 매우 적극적인 최적화로 변형되어 더 나은 코드와 더 높은 성능을 만들어 낸다.

이러한 향상에 대한 결합된 노력은 많은 자바 애플리케이션의 경우 동적인 컴파일이 그러한 차이를 메우고, C와 C++ 같은 언어의 정적 네이티브 컴파일의 성능을 훨씬 뛰어넘는다.

단점

그럼에도 불구하고, 동적 컴파일은 어떤 경우에는 이상적인 솔루션이 아니다. 예를 들어, 자주 실행되는 메소드를 규명하고 이러한 메소드를 컴파일 하는 데는 시간이 걸리기 때문에 애플리케이션은 성능이 피크에 도달하지 못하는 워밍업(warm-up) 기간을 경험하게 된다. 이러한 워밍업 기간은 많은 이유로 인해 성능 문제를 일으킬 수 있다. 우선, 많은 초기 컴파일이 애플리케이션 시작 시간에 직접적인 영향을 줄 수 있다. 이러한 컴파일은 애플리케이션이 안정적인 상태가 되는 시간을 지연시키며(유용한 작업을 수행하는 지점에 도달하기 전에 초기화 단계를 겪는 웹 서버를 상상해 보라), 이러한 워밍업 단계 동안 자주 실행되는 메소드들은 애플리케이션의 안정적인 성능에 기여할 수 없다. 시작을 지연시키고 애플리케이션의 장기적 성능을 향상시킬 수 없는 JIT 컴파일을 수행하는 것은 낭비이다. 모든 현대적인 JVM이 시작 패널티를 완화시키기 위해 성능 튜닝을 하지만 문제가 완벽히 해결되지 않는다.

두 번째로, 일부 애플리케이션들은 동적 컴파일과 연관된 지연을 참을 수 없다. GUI 인터페이스 같은 인터랙티브 애플리케이션이 대표적인 예이다. 이 경우, 컴파일 액티비티는 애플리케이션의 성능을 향상시키지도 못하면서 사용자 경험에 역효과를 줄 수 있다.

마지막으로, 엄격한 태스크 데드라인을 가진 실시간 환경에서 수행되어야 하는 애플리케이션은 이러한 비결정적인 컴파일의 성능 효과나 동적 컴파일의 메모리 오버헤드를 견딜 수 없다.

따라서, JIT 컴파일 기술이 정적 언어 성능 보다 더 나은 성능을 제공하게 되더라도, 동적 컴파일은 특정 애플리케이션에는 적합하지 않다. 이 같은 상황에서는 자바 코드용 Ahead-of-time (AOT) 컴파일이 올바른 솔루션이다.




위로


AOT 자바 컴파일

원론적으로는, 자바 언어의 네이티브 컴파일은 C++ 또는 Fortran 같은 전통적인 언어들을 위해 개발된 컴파일 기술을 단순히 적용하는 것이어야 한다. 안타깝게도, 자바 언어가 가진 고유의 동적인 특성 때문에 정적으로 컴파일 된 코드의 품질에 영향을 미치는 복잡성이 추가된다. 하지만 기본 개념은 갖다. 프로그램이 실행되기 전에 자바 메소드용 네이티브 코드를 생성하여 네이티브 코드가 프로그램이 실행될 때 한번 직접 사용될 수 있도록 하는 것이다. 목표는 JIT 컴파일러의 런타임 성능이나 메모리 비용을 피하거나, 인터프리터의 초기 성능 오버헤드를 피하는 것이다.

문제점

동적 JIT 컴파일러에게 도전이 되는 동적 클래스 로딩은 AOT 컴파일에 있어서는 더욱 심각한 문제이다. 클래스는 실행 코드가 그 클래스를 참조하기 전까지는 로딩될 수 없다. AOT 컴파일은 프로그램 실행 전에 발생하기 때문에 이 컴파일러는 어떤 클래스가 로딩되었는지를 추측할 수 없다. 다시 말해서, 컴파일러가 정적 필드의 주소, 객체의 인스턴스 필드의 오프셋, 호출의 실제 대상, 심지어는 직접 호출에 대해 알 수 없다는 의미이다. 코드가 실행될 대 false로 판명 난 정보에 대해 추측을 하는 것은 정확하지 않으며, 자바 순응성도 희생되어야 한다.

코드는 어떤 환경에서도 실행될 수 있으므로, 클래스 파일은 코드가 컴파일 되었던 때와 같지 않을 수 있다. 예를 들어, 하나의 JVM 인스턴스가 디스크의 특정 위치에서 클래스를 로딩하고, 후속 인스턴스가 다른 위치 또는 네트워크를 통해서 클래스를 로딩할 수 있다. 버그 픽스가 이루어진 개발 환경을 상상해 보라. 클래스 파일의 콘텐트는 하나의 프로그램 실행부터 다음 프로그램 실행까지 바뀔 수 있다. 더욱이, 자바 코드는 프로그램이 실행될 때까지 존재하지 않을 수 있다. 예를 들어, 자바 리플렉션(reflection) 서비스들은 런타임 시 새로운 클래스들을 생성하여 프로그램의 액티비티를 지원한다.

통계, 필드, 클래스, 메소드에 대한 정보의 부족은 자바 컴파일러에서의 대부분의 최적화 프레임웍이 심각하게 방해 받고 있다는 것을 의미한다. 정적 또는 동적 컴파일러에 적용되는 가장 중요한 최적화인 인라이닝(Inlining)은 컴파일러가 호출 대상의 정보를 갖고 있지 않기 때문에 더 이상 적용될 수 없다.

인라이닝(Inlining)

인라이닝은 함수의 호출 코드를 콜러(caller)의 함수로 삽입함으로써 런타임 시 프롤로그나 에필로그의 오버헤드를 피하는 코드를 생성하는 기술이다. 무엇보다도 인라이닝의 가장 큰 장점은 옵티마이저에게 보여지는 코드의 범위가 늘어나면서 고급 코드 생성이 가능하다는 점이다. 다음은 인라이닝 이전의 코드 예제이다.

int foo() { int x=2, y=3; return bar(x,y); }
final int bar(int a, int b) { return a+b; }

컴파일러의 이 barfoo() 내에서 호출된 것이라는 것을 입증할 수 있다면 bar에서 온 코드는 foo()에서 bar()에 대한 호출을 대체할 수 있다. 이 경우, bar() 메소드는 final이기 때문에, foo()에서 호출된 것이어야 한다. 가상 케이스의 경우에서도, 동적 JIT 컴파일러는 대상 메소드의 코드를 종종 인라이닝 할 수 있다. 컴파일러는 다음과 같은 코드를 만들어 낸다.

int foo() { int x=2, y=3; return x+y; } 

이 예제에서, value propagation이라고 하는 최적화와 단순화는 간단히 5를 리턴하는 코드를 만들어 냈다. 인라이닝 없이, 이러한 최적화는 불가능 했고, 훨씬 더 낮은 성능을 보였을 것이다. bar() 메소드가 변환되지 않으면, 정적 컴파일 경우와 마찬가지로 최적화는 불가능 하고 코드는 가상 호출을 수행해야 한다. 런타임 시, 두 개의 숫자들을 더하기 보다는 곱하는 다른 bar는 호출의 실제 대상이 될 수 있다. 이 같은 이유로, 인라이닝은 자바 프로그램의 정적 컴파일 동안 수행될 수 없다.

AOT 코드는 변환되지 않은 모든 필드, 클래스, 메소드 참조로 생성되어야 한다. 실행 시 이러한 참조들 각각은 현재 런타임 환경에 맞는 정확한 값으로 업데이트 되어야 한다. 이 프로세스는 첫 번째 실행 성능에 직접적인 영향을 준다. 왜냐하면 모든 참조들은 첫 번째 실행 시 변환되기 때문이다. 물론 후속 실행들은 코드 패칭의 결과에서 혜택을 얻기 때문에 인스턴스나 정적 필드 또는 메소드 대상은 보다 직접적으로 참조된다.

자바 메소드를 위해 생성된 네이티브 코드는 일반적으로 하나의 JVM 인스턴스에서 사용될 수 있는 값을 필요로 한다. 예를 들어, 이 코드는 JVM 런타임 시 특정 런타임 루틴을 호출하여 변환되지 않은 메소드를 찾거나 메모리를 할당하는 것 같은 특정 액션을 수행해야 한다. 이러한 런타임 루틴의 주소는 JVM이 메모리에 로딩될 때마다 다를 수 있다. AOT로 컴파일 된 코드는 이것이 실행되기 전에 JVM의 현재 실행 환경에 바인딩 되어야 한다. 다른 예제들로는 스트링의 주소와 상수 풀(pool) 엔트리들의 내부 위치가 있다.

WebSphere Real Time에서 AOT 네이티브 코드 컴파일은 jxeinajar라고 하는 툴로 수행된다. (그림 2) 이 툴은 네이티브 코드 컴파일을 JAR 파일에 있는 모든 클래스들의 모든 메소드에 적용하거나, 관련 메소드에 선택적으로 적용한다. 결과는 Java eXEcutable (JXE)로 알려진 내부 포맷에 저장되지만 영속성 컨테이너로 쉽게 저장될 수 있다.


그림 2. jxeinajar
그림 2. jxeinajar

런타임 시 실행되는 많은 네이티브 코드를 만들어 내기 때문에 모든 코드를 정적으로 컴파일 하는 것이 최고의 접근 방식이라고 생각할지 모른다. 하지만, 여러 가지 장단점이 있다. 메소드가 많이 컴파일 될수록, 코드가 차지하는 메모리는 더 많아진다. 컴파일 된 네이티브 메소드는 바이트코드 보다 약 10정도 크다. 네이티브 코드는 바이트코드 보다 밀도가 덜하고 이 코드에 대한 추가 메타데이터가 포함되어 코드가 JVM에 바인딩 되고 예외가 발생하거나 스택 트레이스가 요청될 때 올바르게 실행되어야 한다. 평균 자바 애플리케이션을 구성하는 JAR 파일들에는 거의 실행되지 않는 많은 메소드들이 포함되어 있다. 그러한 메소드들을 컴파일 하면 메모리 패널티가 수행된다. 디스크에 코드를 저장하고, 코드를 디스크에서 JVM으로 가져오고, 이 코드를 JVM에 바인딩 하는데 비용이 든다. 코드가 여러 번 실행되지 않는 한 네이티브 코드의 성능 이익으로는 상쇄되지 않는다.

컴파일 된 메소드와 인터프리팅 된 메소드 간 호출들이(컴파일 된 메소드가 인터프리팅 된 메소드를 호출하거나 또는 그 반대의 경우), 인터프리팅 된 메소드에서 인터프리팅 된 메소드로의 호출이나 컴파일 된 메소드에서 컴파일 된 메소드로의 호출 보다 비용이 많이 든다. 동적 컴파일러는 JIT로 컴파일 된 코드에 의해 자자 호출되는 모든 인터프리팅 된 메소드들을 컴파일 함으로써 비용을 줄이지만, 동적 컴파일러가 없다면 비용을 줄일 수 없다. 따라서, 메소드가 선택적으로 컴파일 된다면 컴파일 된 메소드에서 컴파일 되지 않은 메소드로의 트랜지션을 최소화 하기 위해서는 주의를 기울여야 한다. 이러한 문제를 피하기 위해 올바른 메소드 세트를 선택하는 것은 어려운 일이다.

장점

AOT-컴파일 코드가 문제점을 갖고 있지만, AOT 방식의 자바 컴파일은 동적 컴파일러들이 언제나 효과적인 솔루션은 아닌 환경에서 성능상의 효과를 만들어 낼 수 있다.

AOT-컴파일 코드를 잘 사용함으로써 애플리케이션의 시작을 가속화 할 수 있다. 왜냐하면 이 코드는 일반적으로는 JIT-컴파일 코드 보다는 느리지만 인터프리테이션 보다는 훨씬 빠르기 때문이다. 더욱이, AOT-컴파일 코드를 로딩하고 바인딩 하는 시간은 중요한 메소드를 탐지하여 동적으로 컴파일 하는 시간 보다 덜 들기 때문에 프로그램 실행 시 일찍 성능 효과를 볼 수 있다. 마찬가지로, 인터랙티브 애플리케이션들은 형편 없는 반응을 만들어 내는 동적 컴파일 없이도 빠르게 네이티브 코드 성능의 혜택을 누릴 수 있다.

RT 애플리케이션들은 AOT-컴파일 코드에서 혜택을 누릴 수도 있다. 보다 결정적인 성능이 인터프리팅 된 성능을 초월한다. WebSphere Real Time이 사용하는 동적 JIT 컴파일러는 RT 시스템 사용에 맞춰졌다. 컴파일 쓰레드는 RT 태스크 보다 낮은 우선 순위로 실행되고 심각하게 비결정적인 성능 결과를 가진 코드를 만들어내지 않도록 조정된다. 일부 RT 환경에서는 JIT 컴파일러의 존재 자체가 허용되지 않는다. 이 같은 환경에서는 보다 엄격한 데드라인 관리가 필요하다. 이 같은 경우에, AOT-컴파일 코드는 결정론 정도에 영향을 주지 않고 인터프리팅 된 코드 보다 더 나은 성능을 제공할 수 있다. JIT 컴파일 쓰레드를 줄이면 더 높은 우선 순위 RT 태스크가 초기화 되어야 할 때 이를 선점하는 성능 조차도 줄인다.




위로


점수

애플리케이션 실행의 동성과 로딩된 클래스와 계층에 대한 지식을 활용함으로써 동적(JIT) 컴파일러는 플랫폼 중립성을 지원하고 고급 품질의 코드를 만들어 낸다. 하지만, JIT 컴파일러들은 컴파일 시간 제한이 있고 프로그램의 런타임 성능에 영향을 미칠 수 있다. 정적(AOT) 컴파일러는 플랫폼 중립성과 코드 품질은 떨어진다. AOT 컴파일은 런타임 성능에 영향을 주지 않기 때문에 컴파일 시간이 무제한이다.

표 1은 이 글에서 설명한 자바 언어의 동적 컴파일러와 정적 컴파일러의 특성을 정리한 것이다.


표 1. 컴파일 기술 비교
Dynamic (JIT) Static (AOT)
플랫폼 중립성 있음 없음
코드 품질 높음 좋음
동적 작동 활용 활용함 활용하지 않음
클래스와 계층에 대한 인식 인식함 인식하지 않음
컴파일 시간 제한됨. 런타임 비용이 든다. 제한이 적다. 런타임 비용이 없다.
런타임 성능 영향 있음 없음
컴파일 대상 JIT가 핸들함. 자가 핸들함.

두 기술 모두 가장 높은 성능을 획득하기 위해서는 컴파일 방식을 신중히 선택해야 한다. 동적 컴파일러의 경우, 컴파일러 자체가 결정을 내리는 반면, 정적 컴파일러의 경우 선택은 개발자가 한다. JIT 컴파일러가 컴파일 될 메소드를 선택하게 하는 것은 장점이 될 수도 있고 되지 않을 수도 있다. 컴파일러가 주어진 상황에서 얼마나 잘 작동하는가에 달려있다. 대부분의 경우 효과가 있다고 본다.

JIT 컴파일러들은 실행 프로그램을 최적화 하기 때문에, JIT 컴파일러들은 많은 자바 실행 시스템에 안정적인 성능을 가져다 준다. 인터랙티브 성능은 정적 컴파일러가 알맞다. 어떤 런타임 컴파일 액티비티도 사용자가 기대하는 응답 시간을 방해하지 않는다. 시작 및 결정적인 성능은 동적 컴파일러를 조정하여 어느 정도는 해결되지만, 정적 컴파일은 가장 빠른 시작 및 가장 높은 레벨의 결정을 수행한다. 표 2는 네 개의 다른 실행 환경에서의 두 개의 컴파일 기술을 비교한 것이다.


표 2. 최상의 기술 가려내기
동적 (JIT) 정적 (AOT)
시작 성능 조정 가능하지만, 좋지는 않다. 최상
스테디(steady) 상태 성능 최상 좋음
인터랙티브 성능 보통 좋음
결정적 성능 조정 가능하지만, 좋지는 않다 최상

그림 3은 시작 성능과 스테디 상태 성능에 대한 일반적인 경향이다.


그림 3. AOT 대 JIT
그림 3. AOT 대 JIT

JIT 컴파일러의 성능은 초기에는 매우 낮다. 메소드들이 초기에 인터프리팅 되기 때문이다. 많은 메소드들이 컴파일 되고 JIT가 컴파일을 수행하는데 걸리는 시간이 줄어들면서, 성능 곡선은 증가하고 피크 성능에 이르게 된다. AOT-컴파일 코드는 인터프리팅 된 성능 보다 훨씬 높게 시작하지만, JIT 컴파일러를 통해 수행되는 것만큼 높아지지는 않는다. 정적 코드를 JVM 인스턴스에 바인딩 하면 비용이 들기 때문에 성능은 초기에는 스테디 상태 값 보다 낮게 떨어진다. 하지만, 스테디 상태 레벨은 JIT 컴파일러를 사용했을 때 보다 훨씬 더 빠르게 수행된다.

어떤 한 가지의 네이티브 코드 컴파일 기술만이 모든 자바 실행 환경에 맞는 것은 아니다. 각 기술들이 장단점을 갖고 있다. 따라서, 두 개의 컴파일 기술 모두 필요하다. 사실, 정적 및 동적 컴파일은 함께 사용되어 더 높은 성능 향상을 가져올 수 있다. 단 플랫폼 중립성이 문제가 되지 않을 경우에만 해당한다.




위로


요약

소셜 북마크

mar.gar.in mar.gar.in
digg Digg
del.icio.us del.icio.us
Slashdot Slashdot

이 글에서, 자바 언어의 네이티브 코드 컴파일 문제, 특히 동적인 JIT 컴파일러와 정적인 AOT 컴파일을 비교해 보았다.

비록 동적 컴파일러가 지난 10년 동안 많은 성장을 이룩하여 대부분의 자바 애플리케이션들이 C++이나 Fortran 같은 정적으로 컴파일 된 언어에서 얻을 수 있는 성능을 따라가거나 이를 넘어설 수 있었지만, 동적 컴파일러는 여러 애플리케이션 및 실행 환경 유형에는 적합하지 않을 수 있다. 동적 컴파일의 단점을 극복할 만병통치약으로 대두 된 AOT 컴파일도 자바 언어의 동적인 성향 때문에 많은 문제점에 직면하고 있다.

이러한 기술 중 어떤 것 하나도 자바 실행 환경에서의 네이티브 코드 컴파일에 대한 모든 요구 사항들을 해결할 수 없지만, 각각 알맞은 환경에서 효과를 거둘 수 있는 툴이다. 두 기술은 상호 보완적이다. 두 컴파일 모델을 적절히 사용하는 런타임 시스템은 개발자와 사용자에게 놀라운 효과를 가져다 줄 것이다.



참고자료

교육

제품 및 기술 얻기
  • WebSphere Real Time: WebSphere Real Time에서는 정밀한 응답 시간에 의존하는 애플리케이션들이 결정론(determinism)을 희생하지 않고 표준 자바 기술을 활용할 수 있다.

  • Real-time Java technology: 필자의 IBM alphaWorks 연구 사이트에서 RT Java 관련 첨단 기술을 만나보십시오.


토론


출처 : http://www.ibm.com/developerworks/kr/library/j-rtj2/index.html

반응형
댓글