AWS lambda jvm 컴파일러 최적화 경험

Table of Contents

1 AWS lambda 컴파일 최적화를 위한 layer github

아래 깃허브의 README를 따라하면 된다. https://github.com/ssisksl77/public-code-snippet/tree/main/aws-jvm-optimization-layer

이것은 고도의 반복적인 수행을 하는 것이 아니라면 효과가 있을 것이다. (동일한 시뮬레이션을 수 천번 수행하는 것, 몬테카를로 시물레이션, 해시 계산 등…)

2 왜 이것이 퍼포먼스에 영향을 주는가

2.1 JIT 컴파일러

처음 JVM은 다른 인터프리터 처럼 동작한다. 코드를 한줄한줄 필요할 때마다 읽는 것이다. 이렇게만 한다면 코드수행속도가 느리다. C언어를 생각하자. 해당 OS에 맞는 머신코드로 컴파일되면 인터프리터방식보다 훨씬 빠르다. 하지만 이 방식의 언어는 write once, run anywhere 라는 특징을 가진 자바에서는 선택할 수 없다.

하여 느린 수행시간과 컴파일 언어보다 느린 인터프리트 언어방식의 문제를 해결하기 위해 JVM은 JIT compilation을 만들었다. JVM은 코드더미들을 모니터링하면서 어떤 코드 더미가 가장 많이 수행되는지 찾는다. 이 코드더미는 메소드일 수도 있고, 메소드의 부분일 수도 있다. (loop 같은거)

JVM은 네이티브 머신 코드로 컴파일하면 실행속도가 빨라질만한 코드더미를 찾아낸다. 애플리케이션의 몇몇은 바이트코드를 인터프리트 모드로 읽어서 수행하는 녀석도 있고, 또 몇몇은 이제 바이트코드가 아니라 compiled native machine code가 되기도 한다.

이것이 가능한 이유는 jvm이 코드를 프로파일하여 현재OS의 native machine code로 컴파일해서 최적화를 할 수 있기 때문이다.

바이트코드를 native machine code로 컴파일하는 일은 분리된 스레드가 수행한다. 즉, JIT 컴파일하는 코드와 바이트코드를 인터프리트하고 실행하는 스레드와는 별개이기에 서로에게 영향을 주지 않는다는 것이다.

JIT 컴파일링 프로세스는 애플리케이션이 도는 동안 멈추지 않는다. 컴파일이 진행되는 동안 JVM은 인터프리트된 버전을 계속 사용한다. 하지만 컴파일이 완료되면 네이티브 머신 코드가 사용가능해진다. 그러면 vm은 원활하게 바이트코드 대신 컴파일된 코드를 사용하도록 스위치한다.

2.2 -XX:PrintCompilcation

형태는 다음과 같다.

fibonacci git:(main) ✗ java -XX:+PrintCompilation Main 3000
     32    1       3       java.util.concurrent.ConcurrentHashMap::tabAt (22 bytes)
     32    2       3       jdk.internal.misc.Unsafe::getObjectAcquire (7 bytes)
     34    3       3       java.lang.Object::<init> (1 bytes)
     34    4       3       java.lang.StringLatin1::hashCode (42 bytes)
...
     42   39     n 0       java.lang.Object::hashCode (native)
     42   38       3       jdk.internal.module.ModuleReferenceImpl::hashCode (56 bytes)
     42   40       3       java.util.HashMap::hash (20 bytes)
     42   42   !   3       java.util.concurrent.ConcurrentHashMap::putVal (432 bytes)
     42   45     n 0       jdk.internal.misc.Unsafe::compareAndSetLong (native)
     43   46     n 0       jdk.internal.misc.Unsafe::compareAndSetObject (native)
     44   44       3       java.util.concurrent.ConcurrentHashMap::addCount (289 bytes)
     44   43       3       java.util.concurrent.ConcurrentHashMap::putIfAbsent (8 bytes)
     44   48       3       java.util.concurrent.ConcurrentHashMap$Node::<init> (20 bytes)
     44   51     n 0       java.lang.System::arraycopy (native)   (static)
     44   49       3       java.util.concurrent.ConcurrentHashMap::casTabAt (21 bytes)
...
     51   81       3       java.lang.String::hashCode (49 bytes)
     52   82       3       java.lang.AbstractStringBuilder::ensureCapacityInternal (39 bytes)
993652896

flag들은 다음을 나타낸다. (블로그를 참조함)

b    Blocking compiler (always set for client)	
*    Generating a native wrapper	
%    On stack replacement
!    Method has exception handlers	
s    Synchronized method	
n    native

또 다른 참고자료가 있다.

<timestamp>
 |
 |    <compilation id>
 |    |
 |    |                                      <method name>     <method size>
 |    |                                      |                 |
 |    |                                      |                 |
 95   61                java.util.HashMap::afterNodeInsertion (1 bytes)
 95   84           n    jdk.internal.misc.Unsafe::compareAndSetLong (native)
 95   81        !  |    java.util.concurrent.ConcurrentHashMap::putVal (432 bytes)
 100  23     s  |  |    java.io.BufferedOutputStream::flush(12 bytes)
 120  129 %  |  |  |    VolSample$1::run @ 2 (19 bytes)
	  |  |  |  |                       |
	  |  |  |  |                       |
	  |  |  |  |                       <byte code index of loop>
	  |  |  |  |
	  |  |  |  <native>
	  |  |  |
	  |  |  <exception handler>
	  |  |
	  |  <synchronized>
	  |
	   <on stack replacement>

Format:
timestamp | compilation id | %, s, !, n | method name | method size

Explained:
 timestamp: A timestamp in milliseconds when the compilation was completed since the VM started
 compilation id: id An id given to this compilation. The ids are assigned as they are enqueued by a thread for compilation. Therefore they may appear in any order.
 %: The method has been compiled via on stack replacement, this occurs for a loop inside a method body, the command also prints the index of the loop inside the bytecode as indicated by byte code index of loop
 s: The method is declared as synchronized, NOT a synchronized block inside the body of the method.
 !: Exception handlers are declared with this method, either as part of the method declaration on a try-catch inside the method body.
 n: This is a native method.
 method name: The name of the method without its signature.
 method size: The size of the method in bytes.

2.3 테스트를 함

https://github.com/ssisksl77/public-code-snippet/tree/main/demos/fibonacci

아래 코드로 테스트를 해보자.

java -XX:+PrintCompilation Main 10
     49   82       3       java.lang.StringLatin1::canEncode (13 bytes)
     49   83       3       java.lang.CharacterDataLatin1::getProperties (11 bytes)
     50   84       3       java.lang.AbstractStringBuilder::ensureCapacityInternal (39 bytes)
55

java -XX:+PrintCompilation Main 100000
    48   80       3       java.lang.StringLatin1::canEncode (13 bytes)
     48   81       3       java.lang.CharacterDataLatin1::getProperties (11 bytes)
     50   82       3       java.lang.AbstractStringBuilder::ensureCapacityInternal (39 bytes)
     51   83 %     3       Fib::get @ 25 (51 bytes)
     51   84       3       Fib::get (51 bytes)
     51   85 %     4       Fib::get @ 25 (51 bytes)
     52   83 %     3       Fib::get @ 25 (51 bytes)   made not entrant
     52   85 %     4       Fib::get @ 25 (51 bytes)   made not entrant
1884755131

중간데 3 4 로 이루어진 숫자는 원래 0~4 로 이루어진다. 이것은 컴파일레벨을 말한다.

0은 no compilation이다. 코드는 그저 인터프리트만 되었음을 말한다. 1~4번은 진보한 높은 레벨의 컴파일이 수행된 것이다. (4가 제일 높은 레벨임) Fib::get 이 3~4로 컴파일된 것을 알 수 있다. % 는 loop가 있는 경우를 말한다. 자바는 이렇게 호출되는 횟수에 따라 컴파일하는 방식이 달라진다. 최적화를 그때그때 하는 것이다. 또한 최적화를 해서 heap에 넣는 것이 아니라 heap은 자바 객체가 있는 곳이다.

2.4 C1 C2 컴파일러

C1 컴파일러는 level 1~3 까지 담당하고 C2는 level4를 담당한다. virtual machine이 어떤 컴파일 레벨을 적용할 것인지를 결정할 때,

  • 얼마나 많이 실행하는지
  • 얼마나 복잡하거나
  • 얼마나 시간을 잡아먹는 수행인지

를 본다. 이것은 profiling the code 라고 한다.

하여 어떤 메서드건 1~3 레벨을 받았다면 C1 컴파일러의 도움으로 컴파일 된 것이다. 만약 코드가 임계점을 넘을 만큼 충분히 호출된다면, C2컴파일러의 도움으로 4레벨의 최적화를 받는다.

이렇게 계층이 있기 때문에 compilation tier 라고 부른다. 많이 불릴 수록 계층이 올라가는 것을 상상하자. 하지만 그렇다면 결국 모든 메서드를 레벨4로 만들어주는 것인가? 그렇지는 않다. 트레이드오프가 존재한다. 항상 자주 호출되는 코드만 최적화가 된다. 또한 코드가 복잡하지 않으면 더 높은 레벨의 컴파일은 큰 도움이 되지 않을 수 있다.

위처럼 Fib:get 은 많이 호출됨에 따라 C2 컴파일러의 도움을 받아 레벨4가 되었고, 이것은 최적화된 퍼포먼스를 위해 코드 캐시로 옮겨진다.

콘솔에서보다 더 많은 정보를 보고 싶다면 java -XX:+UnlockDiagnosticVMOptions -XX:+LogCompilation Main 1000000000 이렇게 해보자.

$ java -XX:+UnlockDiagnosticVMOptions -XX:+LogCompilation Main 1000000000
$ ls
Fib.java Fib.class Main.java Main.class hotspot_pid52692.log

로그 안에 들어가면 c1 컴파일러를 썼다. 어떤 메서드다 그런걸 좀 더 자세하게 보여주는 것 같다.

2.5 코드 캐시

코드캐시라는 말을 처음 들었을 것이다. 만야게 코드 캐시가 부족해지면 어떻게 될 것인가?

그런경우 다음과 같은 warning을 볼 것이다.

VM warning: CodeCache is full. Compiler has been disabled.

코드 캐시에 대해 좀 더 알고 싶다면 아래처럼 작성하자.

java -XX:+PrintCodeCache Main 1000000000
1532868155
CodeHeap 'non-profiled nmethods': size=120032Kb used=20Kb max_used=20Kb free=120011Kb
 bounds [0x0000000111440000, 0x00000001116b0000, 0x0000000118978000]
CodeHeap 'profiled nmethods': size=120016Kb used=126Kb max_used=126Kb free=119889Kb
 bounds [0x0000000109f0c000, 0x000000010a17c000, 0x0000000111440000]
CodeHeap 'non-nmethods': size=5712Kb used=982Kb max_used=995Kb free=4729Kb
 bounds [0x0000000109978000, 0x0000000109be8000, 0x0000000109f0c000]
 total_blobs=316 nmethods=87 adapters=146
 compilation: enabled
	      stopped_count=0, restarted_count=0
 full_count=0

자바9부터 코드캐시는 세부분으로 나뉜다.

  • non-method segment는 JVM 내부관련 코드를 갖는다. (바이트코드 인터프리터 같은)
  • profiled-code segment는 짧은 라이프타임을 가져서 가볍게 최적화된 코드를 갖는다.
  • non-profiled segment는 긴 라이프타임을 가져서 최대로 최적호된 코드를 갖는다.

이 방식은 다양한 타입의 컴파일왼 코드에 따라 다르게 다룰 수 있게한다. 이것으로 전체적인 퍼포먼스가 더 오르게 한다. 예를들어, 수명이 짧은 컴파일된 코드를(profiled-code)는 주로 더 작은 메모리를 영역을 스캔해야하기 때문에 수명이 긴 컴파일된 코드(non-profiled code)에서 분리하면 메서드 스위퍼(method sweeper) 성능이 향상된다.

다음은 https://www.quora.com/What-is-a-java-sweeper 에서의 설명을 가져왔다. (공식문서도 찾아보아야겠다)

메서드 스위퍼는 non-heap에서 동작한다. (우린 non-heap, heap, stack 그리고 기타등등) 같은 공간에 metadata, code cache, library cache가 있다. 클래스가 사용될 때, metadata 영역에 있는 메타데이터를 필요로 한다. 이 데이터는 클래스로더와 관련이 있으며, 인스턴스의 많다고 이 데이터가 많아지지는 않는다. 클래스 하나당 한 번 저장한다. 그리고 해당 코드는 런타임 중에 컴파일되고 machine code는 code cache에 저장된다.

메타 데이터가 가득 차면 더 이상 필요하지 않은(클래스가 언로드된) 메타데이터를 찾고 이 메모리를 스윕(sweep)한다. 새로운 클래스가 로드되고 강제 스윕(forced sweep) 이후에도 공간이 없으면 메타데이터는 확장된다. 기본값은 무한대라고 한다. 해서 앱이 커질 수 있다.(그래봤자 코드를 담는 곳인데… 앱이 커져봤자라고 생각해보자. 그리고 이 내용은 오래된 내용이므로 최신공식문서의 확인이 필요하다)

- The non-method segment contains JVM internal related code such as the bytecode interpreter. 
  By default, this segment is around 5 MB. 
  Also, it's possible to configure the segment size via the -XX:NonNMethodCodeHeapSize tuning flag
- The profiled-code segment contains lightly optimized code with potentially short lifetimes. 
  Even though the segment size is around 122 MB by default, 
  we can change it via the -XX:ProfiledCodeHeapSize tuning flag
- The non-profiled segment contains fully optimized code with potentially long lifetimes. 
  Similarly, it's around 122 MB by default. 
  This value is, of course, configurable via the -XX:NonProfiledCodeHeapSize tuning flag

만약 여기서 usedsize 가 비슷하다면 코드캐시를 올려보는 것이 도움이 될 것이다.

  • InitialCodeCacheSize : 앱이 시작할 때 코드캐시 사이즈 설정
  • ReservedCodeCacheSize : 최대 사이즈
  • CodeCacheExpansionSize : 얼마나 빠르게 사이즈를 올릴 것이다. (커지는 속도)

3 JRockit을 위한 JIT compiler 관련 문서 번역

3.1 내용

HotSpot은 바이트코드 인터프리터 외에 2개의 다른 JIT 컴파일러(클라이언트 C1)와 서버(C2)를 갖추고 있다. HotspotVM은 기본적으로 바이트코드를 해석(인터프리팅)하며 런타임 프로파일링이 특정임계 횟수로 메서드가 실행되면 이것을 hot 하다고 결정하고, 이 결정된 메서드만 JIT compile 한다.

클라이언트 컴파일러는 메서드를 빠르게 컴파일하지만 서버 컴파일러보다 덜 최적화된 머신 코드를 내보낸다. (클라이언트 컴파일러는 금방 수행하기 위한 컴파일, 서버 컴파일러는 오랫동안 수행하기 위한 컴파일)

이에 비해 서버 JIT 컴파일러는 동일한 메서드를 컴파일하는 데 많은 시간(및 메모리)이 소요되지만 클라이언트 컴파일러에 의해 생성된 코드보다 더 잘 최적화된 머신 코드를 생성한다.

그 결과 클라이언트 컴파일러는 컴파일 오버헤드가 적기 때문에 대부분의 응용 프로그램을 보다 빠르게 기동할 수 있게 된다. 그러나 서버 컴파일러는 응용 프로그램이 정상상태가 되면(예열된 상태, wram up) 성능이 급격하게 좋아진다. 독립적으로 사용되는 이 두 컴파일러는 각각 두 가지 다른 사용 사례를 제공합니다.

  • 클라이언트 : 안정된 퍼포먼스보다 빠른 스타트업과 작은 메모리 공간이 중요한 경우.
  • 서버 : 빠른시작보다 안정적인 상태의 성능이 중요한 경우.

JIT 컴파일러를 선택해야 하는 경우 대부분 Oracle JRockit 사용자는 서버 컴파일러를 선택해야 한다. 클라이언트 컴파일러는 WLST 같은 커맨드라인 관리 툴(잠깐 쓰고 꺼지는 툴을 말하는 듯)에 적합하다.

Oracle JRockit JVM은 Java 메소드를 컴파일하고 이것이 처음 호출될 때 기계어를 생성한다. 자주 호출되는 메서드의 컴파일된 코드는 나중에 Optimizer 스레드에 의해 백그라운드에서 최적화된다. 이것은 클라이언트(최적화 감소) 또는 서버(최적화 증가) 컴파일러에 의해 메서드가 먼저 해석되고 나중에 컴파일되는 HotSpot JVM과는 완전히 다릅니다.

클라이언트 컴파일러는 -client JVM 옵션을 사용하여 호출할 수 있으며 서버 컴파일은 -server JVM 옵션을 사용하여 호출할 수 있습니다. 서버 컴파일러는 기본적으로 선택된다.

Java SE 7에서 도입된 계층형 컴파일(Tiered compilation)은 서버 VM의 클라이언트 부팅 속도를 향상시킵니다. 서버 VM은 인터프리터를 사용하여 프로파일링된 정보를 수집한다. 여기서 프로파일링되는 것은 컴파일러에 공급되는 메서드를 말한다.

계층화된 스키마(Tiered Scheme)은 인터프리터 외에도 클라이언트 컴파일러는 자신에 대한 프로파일링 정보를 수집하는 컴파일된 버전의 메서드를 수집한다. 컴파일된 코드는 인터프리터보다 상당히 빠르기 때문에이 프로파일링 단계에서 더 높은 성능으로 실행됩니다.

대부분의 경우 애플리케이션 초기화 초기 단계에서 서버 컴파일러에 의해 생성된 최종 코드를 이미 사용할 수 있기 때문에 클라이언트 VM보다 더 빠른 부팅이 가능하다. 또, 계층형 스킴에서는, 프로파일링 단계가 고속이기 때문에, 보다 긴 프로파일링이 가능하기 때문에, 통상의 서버 VM보다 높은 피크 퍼포먼스를 실현할 수 있다.

계층화된 컴파일을 활성화하려면 java 명령과 함께 -XX:+TieredCompilation 플래그를 사용하라.

Java SE 8에서 Tiered Compilation은 서버 VM의 기본 모드입니다. 32비트 및 64비트 모드가 모두 지원됩니다. -XX:-TieredCompilation 플래그를 사용하여 계층화된 컴파일을 비활성화할 수 있습니다.

4 Java HotSpot™ Virtual Machine Performance Enhancements 내용 정리

4.1 Tiered Compilation

Java SE 7에 도입된 계층형 컴파일은 서버 VM에 클라이언트 시작 속도를 빠르게 하기 위해 제공됨. 일반적으로 서버 VM은 인터프리터를 사용하여 컴파일러에 제공되는 메서드에 대한 프로파일링 정보를 수집합니다.

계층화된 체계에서(In the Tiered Scheme) 인터프리터 외에도 클라이언트 컴파일러는 메서드들에 대한 프로파일링 정보로 컴파일된 버전의 메서드를 생성하는 데 사용됩니다.

컴파일된 코드는 인터프리터보다 훨씬 빠르기 때문에 프로그램은 프로파일링 단계에서 더 높은 성능으로 실행됩니다. 많은 경우, 서버 컴파일러에서 생성된 최종코드를 애플리케이션 초기화 단계에서 이미 사용가능하기 때문에 클라이언트 VM보다 훨씬 더 빠른 시작을 달성할 수 있다.

Tiered Scheme은 더 빠른 프로파일링 단계에서 더 긴 프로파일링 기간을 허용하여 더 나은 최적화를 얻을 수 있기 때문에 일반 서버 VM보다 더 나은 최고 성능을 달성할 수도 있다.

Tiered Compilation은 서버VM의 디폴트다. 32, 64비트 그리고 compressed oops가 모두 지원한다. java-XX:-TieredCompilation 플래그를 더하면 된다.

5 Reference

Date: 2022-03-20 Sun 00:00

Author: Younghwan Nam

Created: 2022-11-15 Tue 08:10

Emacs 27.2 (Org mode 9.4.4)

Validate