JIT 컴파일을 이해하려면 컴파일러, 가상 머신, bytecode, CPU instruction set을 한 덩어리로 묶지 않는 것이 먼저다. Java를 예로 들면 source code가 JVM bytecode로 바뀌는 단계와, 실행 중인 JVM이 그 bytecode를 해석하거나 native machine code로 바꾸는 단계는 서로 다르다.
VM이라는 말부터 범위를 정한다
가상 머신은 문맥에 따라 두 뜻으로 쓰인다.
- system VM: guest operating system까지 실행하는 가상 하드웨어 환경
- process 또는 language VM: 하나의 program을 위한 실행 환경. JVM 같은 runtime이 여기에 가깝다
JIT를 설명할 때 말하는 VM은 대개 두 번째다. JVM specification은 class file format과 JVM instruction의 semantics를 정의하지만, 실제 구현이 반드시 interpreter와 JIT를 어떤 순서로 써야 하는지까지 고정하지는 않는다. JVM specification의 implementation 경계도 bytecode를 해석하거나 load·execution 중 host CPU의 native instruction으로 바꾸는 여러 구현 방식을 허용한다.
Java code가 실행되는 층
대표적인 흐름은 다음과 같다.
Java source
-> javac
JVM class file와 bytecode
-> class loading, linking, verification
interpreter 또는 compiled code
-> JIT compilation
host CPU의 native machine code
javac가 만드는 것은 특정 x86-64나 Arm64 CPU용 executable이 아니라 JVM instruction이다. class file을 받은 runtime은 specification의 결과를 지키는 범위에서 interpreter, JIT, ahead-of-time code 같은 전략을 선택할 수 있다.
public final class SumExample {
static long sum(int limit) {
long result = 0;
for (int i = 0; i < limit; i++) {
result += i;
}
return result;
}
public static void main(String[] args) {
long checksum = 0;
for (int i = 0; i < 20_000; i++) {
checksum ^= sum(10_000);
}
System.out.println(checksum);
}
}
이 코드를 반복 실행한다고 해서 그 자체가 JIT 성능 benchmark가 되지는 않는다. warm-up, dead-code elimination, inlining, CPU 상태까지 통제해야 하기 때문이다. 특히 반복문 안의 println 시간을 재면 I/O와 class initialization 비용이 지배해 JIT를 보여 주는 예제가 되기 어렵다. bytecode 구조만 확인하려면 Oracle javap 문서의 javap -c SumExample처럼 disassembly를 먼저 보는 편이 낫다.
JIT는 필요한 code를 runtime 정보로 최적화한다
HotSpot은 실행 초기에 interpreter로 시작하고, 자주 실행되는 부분과 branch·type profile을 관찰해 compilation 대상을 고를 수 있다. Oracle의 HotSpot technology overview는 performance-critical portion을 adaptive compiler가 골라 native code로 바꾸는 흐름을 설명한다.
여기에는 중요한 trade-off가 있다.
| 단계 | 얻는 것 | 지불하는 비용 |
|---|---|---|
| interpretation | 빠른 시작, 아직 쓰이지 않은 code를 미리 compile하지 않음 | 반복 실행의 dispatch 비용 |
| 낮은 tier compilation | 비교적 빠른 compilation과 profiling | 최고 수준보다 적은 optimization |
| 높은 tier compilation | profile 기반 inlining·specialization 가능 | compilation CPU·memory와 warm-up |
runtime은 관측한 type이나 class hierarchy를 바탕으로 낙관적인 가정을 할 수 있다. 나중에 그 가정이 깨지면 optimized frame을 덜 optimized한 상태나 interpreter frame으로 되돌리는 deoptimization이 일어날 수 있다. 이는 실패가 아니라 동적으로 변하는 program에 맞추기 위한 mechanism이다. OpenJDK HotSpot glossary는 dependency가 깨졌을 때 compiled method를 버리고 다시 compile하는 과정을 설명한다.
따라서 “JIT를 쓰면 처음부터 무조건 빠르다”는 결론은 맞지 않는다. 짧게 끝나는 CLI, startup-sensitive workload, long-running server는 warm-up과 steady state의 비중이 서로 다르다.
JVM bytecode와 CPU ISA는 같은 instruction set이 아니다
instruction set이라는 표현도 어느 층인지 밝혀야 한다.
- JVM bytecode: JVM specification이 정의한 virtual instruction
- CPU ISA: x86-64, Arm64처럼 processor가 실행하는 native instruction contract
- microarchitecture: 같은 ISA를 실제 pipeline·cache·execution unit로 구현한 방식
JIT는 JVM instruction의 의미를 보존하면서 현재 host ISA에 맞는 machine code를 만든다. bytecode 하나가 machine instruction 하나로 단순 대응한다고 생각하면 inlining, register allocation, loop optimization 같은 변환을 설명할 수 없다.
Python의 dis도 CPU instruction을 보여 주는 도구가 아니다. Python dis 문서는 CPython bytecode가 implementation detail이며 Python release나 다른 Python VM 사이에서 instruction이 바뀔 수 있다고 명시한다.
import dis
def add_one(value):
return value + 1
dis.dis(add_one)
이 출력은 사용 중인 CPython의 bytecode를 살펴보는 자료다. CPU ISA나 모든 Python implementation의 공통 contract로 읽으면 안 된다.
storage object와 helper function은 별도 개념이다
“storage object”는 JVM·JIT를 설명하는 표준 구성 요소 이름이 아니다. project에서 data를 보관하는 object, database abstraction, compiler의 storage location처럼 문맥마다 뜻이 달라진다. heap object, local variable, field, persistent record를 모두 한 용어로 묶기보다 실제 lifetime과 ownership을 명시해야 한다.
helper function 역시 반복 code를 분리한 작은 function을 흔히 부르는 설계 용어다. runtime이 제공하는 특별한 mechanism이라고 일반화할 수 없다. 다만 eBPF helper처럼 restricted VM이 kernel 기능을 통제된 API로 노출하는 구체적 문맥에서는 특별한 의미가 생긴다. 그 차이는 eBPF 내부 동작과 verifier·JIT에서 이어서 비교할 수 있다. JVM memory 영역과 object allocation은 Spring Boot memory 설정처럼 별도 주제로 보는 편이 정확하다.
핵심 정리
- language compiler가 bytecode를 만드는 단계와 runtime JIT가 native code를 만드는 단계를 나눈다.
- VM specification과 HotSpot 같은 구현의 optimization policy를 구분한다.
- bytecode, CPU ISA, microarchitecture를 같은 instruction layer로 취급하지 않는다.
- profiling에 기대어 만든 가정은 deoptimization과 recompilation으로 바뀔 수 있다.
- storage object와 helper function은 JIT의 필수 구성 요소가 아니므로 각 문맥에서 다시 정의한다.
참고 자료
'배움과 성장 > 시스템·성능' 카테고리의 다른 글
| Linux Kernel 동작 흐름: System Call·Task·Virtual Memory·VFS 연결하기 (0) | 2024.12.06 |
|---|---|
| Linux man 페이지 섹션과 syscall 추적: man 1·2·3·5·7·8 (5) | 2024.12.06 |
| Linux free 읽는 법: available·page cache·slab·dentry 구분하기 (1) | 2024.11.25 |
| CPU와 메모리 차이: 프로그램이 실행될 때 실제로 일어나는 일 (2) | 2024.09.23 |
| Linux CPU 스케줄러 동작: task·run queue·dispatcher 구분하기 (6) | 2024.09.22 |
댓글