LabHub

블로그

JIT 컴파일 완전 가이드 2025: V8, JVM HotSpot, .NET, Tiered Compilation, 성능 분석

TL;DR


1. 컴파일 모델

1.1 3가지 접근

1. 인터프리터:

2. AOT (Ahead-Of-Time):

3. JIT (Just-In-Time):

1.2 비교

인터프리터AOTJIT
시작빠름느림빠름
실행느림빠름빠름 (warm-up 후)
최적화없음정적동적
메모리적음적음많음
Python (CPython)C, RustJava, JS, C#

1.3 JIT의 장점

1. 프로파일 기반 최적화:

2. 가정 기반 최적화:

3. 인라이닝:

4. 동적 정보 활용:


2. JIT가 작동하는 방식

2.1 기본 흐름

[Source Code]
Parse
[AST]
Compile to Bytecode
[Bytecode]
Execute (Interpreter)
[Profile (어떤 함수가 hot?)]
JIT Compile (hot 함수만)
[Native Code]
Execute (Fast)
[Deoptimize if assumption wrong]
[Bytecode (다시)]

2.2 Hot Spot 감지

Method-level:

Loop-level:

2.3 OSR — On-Stack Replacement

void process() {
    for (int i = 0; i < 1_000_000_000; i++) {
        // 매우 hot
    }
}

문제: 함수가 한 번만 호출되어도 루프가 hot.

해결: OSR — 실행 중인 인터프리터를 컴파일된 코드로 교체.

[Interpreter at iteration 100,000]
JIT compile
[Native code]
OSR: 인터프리터 → 네이티브
[Iteration 100,001 in native code]

2.4 컴파일 임계값

시스템Method 임계값OSR 임계값
HotSpot C11,50013,500
HotSpot C210,00014,000
V8 Maglev~1,000
V8 TurboFan~10,000

너무 낮음: 컴파일 오버헤드. 너무 높음: 인터프리터에서 너무 오래.


3. V8 (Chrome, Node.js)

3.1 V8의 컴파일 파이프라인

[JavaScript]
Parse
[AST]
Bytecode
[Ignition (Interpreter)]
Profile
[Sparkplug (baseline JIT)]
Hot
[Maglev (mid-tier)]
Hotter
[TurboFan (top-tier optimizer)]

3.2 Ignition — Bytecode Interpreter

2016 도입.

역할:

왜 인터프리터?:

3.3 Sparkplug — Baseline JIT

2021 도입.

역할:

3.4 Maglev — Mid-tier Optimizer

2023 도입.

역할:

3.5 TurboFan — Top-tier Optimizer

2017 도입 (Crankshaft 대체).

역할:

컴파일 비용: 매우 큼 (~ms 단위), hot 함수만.

3.6 Inline Caching

JavaScript의 동적 디스패치 최적화.

function getX(obj) {
    return obj.x  // obj의 형태를 모름
}

// Hidden Class 학습
getX({x: 1, y: 2})  // Class A
getX({x: 3, y: 4})  // Class A (재사용)
getX({x: 5, y: 6, z: 7})  // Class B (다른 형태)

V8이 학습:

Monomorphic이 가장 빠름. 같은 형태 객체를 일관되게 사용.

3.7 Hidden Classes

JavaScript는 동적 타입이지만, V8 내부에서는 숨은 클래스를 사용:

const obj1 = {}        // Class C0
obj1.x = 1             // Class C1 (x 필드 추가)
obj1.y = 2             // Class C2

const obj2 = {}        // Class C0
obj2.x = 1             // Class C1 (재사용!)
obj2.y = 2             // Class C2 (재사용!)

같은 순서로 필드 추가 → 같은 hidden class → 빠름.

다른 순서면:

const obj3 = {}
obj3.y = 1            // Class D1 (다른 경로)
obj3.x = 2            // Class D2
// obj1, obj2와 다른 클래스 → 느림

: 객체 생성 시 모든 필드를 한 번에:

function makePoint(x, y) {
    return { x, y }  // 모든 필드 같은 순서
}

4. JVM HotSpot

4.1 컴파일 파이프라인

[Java source]
    ↓ javac
[Bytecode (.class)]
JVM
[Interpreter]
Hot (1,500 호출)
[C1 (Client Compiler)]
Very Hot (10,000 호출)
[C2 (Server Compiler)]
    ↓ 또는
[Graal]

4.2 C1 (Client Compiler)

역할:

사용: 짧은 프로그램, 데스크톱 앱.

4.3 C2 (Server Compiler)

역할:

최적화:

4.4 Tiered Compilation (JDK 8+)

기본: 인터프리터 → C1 → C2.

장점:

# 활성화 (기본)
java -XX:+TieredCompilation MyApp

# C2만 (고성능)
java -XX:-TieredCompilation -server MyApp

4.5 Graal — 새로운 컴파일러

Oracle이 만든 차세대 JIT. C2 대체.

장점:

java -XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler MyApp

4.6 Escape Analysis

public Point getPoint() {
    Point p = new Point(1, 2);  // 객체 생성
    return p.x + p.y;            // 객체가 함수 밖으로 안 나감
}

C2가 분석:

효과: GC 부담 감소, 캐시 친화적.

4.7 Inlining

int square(int x) { return x * x; }
int sumSquares(int n) {
    int sum = 0;
    for (int i = 0; i < n; i++)
        sum += square(i);
    return sum;
}

C2가 인라이닝:

int sumSquares(int n) {
    int sum = 0;
    for (int i = 0; i < n; i++)
        sum += i * i;  // square 인라이닝
    return sum;
}

효과:

한계: 너무 큰 함수는 인라이닝 X (코드 크기 폭증 방지).


5. .NET CLR

5.1 컴파일 파이프라인

[C# source]
Roslyn
[CIL (Common Intermediate Language)]
CLR
[ReadyToRun (옵션, AOT)]
[Tier 0 (빠른 컴파일)]
Hot
[Tier 1 (최적화)]

5.2 ReadyToRun (R2R)

.NET Core 2.1+ AOT 컴파일.

역할:

dotnet publish -c Release -r linux-x64 -p:PublishReadyToRun=true

효과:

5.3 Tiered Compilation

.NET Core 3.0+ 기본.

Tier 0: 빠른 컴파일, 최적화 적음. Tier 1: 느린 컴파일, 공격적 최적화.

[MethodImpl(MethodImplOptions.AggressiveInlining)]
int Square(int x) => x * x;

5.4 RyuJIT

.NET Core의 기본 JIT.

개선 (.NET 8+):

5.5 NativeAOT

.NET 7+: 완전 AOT.

dotnet publish -p:PublishAot=true

장점:

단점:


6. PGO — Profile-Guided Optimization

6.1 정적 PGO

  1. 빌드 1: 계측 코드 추가
  2. 실행: 프로파일 수집
  3. 빌드 2: 프로파일 사용해 최적화
# Clang
clang -fprofile-generate -o app app.c
./app  # profile.profdata 생성
clang -fprofile-use=profile.profdata -O2 -o app app.c

효과:

6.2 동적 PGO (.NET, JVM)

JIT가 런타임에 자동으로:

→ 다음 컴파일에 활용.

장점: 사용자 패턴에 자동 적응.


7. Tracing JIT

7.1 Method JIT vs Tracing JIT

Method JIT (HotSpot, V8):

Tracing JIT (PyPy, LuaJIT):

7.2 PyPy

Python 인터프리터 + tracing JIT.

장점:

단점:

7.3 LuaJIT

Lua의 tracing JIT.

Mike Pall의 작품. 종종 C와 비슷한 성능.

사용:

7.4 Tracing의 어려움

→ Method JIT가 더 일반적.


8. Speculation과 Deoptimization

8.1 Speculative Optimization

JIT는 가정을 합니다:

예시: V8

function add(a, b) {
    return a + b
}

// 1000번 호출, 항상 정수
add(1, 2)
add(3, 4)
// V8: "a, b는 항상 정수다" → 정수 덧셈으로 최적화

컴파일된 코드:

; 정수 덧셈 (매우 빠름)
add eax, ebx

가정 검증: 매번 타입 체크.

8.2 가정이 깨지면?

add("hello", "world")  // 문자열!

문제: 정수 덧셈 코드는 문자열 처리 못 함.

해결: Deoptimization.

[Native Code (정수 덧셈)]
Type check 실패
[Deopt to Bytecode]
Re-execute in interpreter
[String concatenation]

비용: deopt는 비쌈. 자주 발생하면 JIT가 포기.

8.3 Deopt 방지

일관된 타입 사용:

// 좋음 - 항상 정수
function add(a, b) {
    return a + b
}
add(1, 2)
add(3, 4)

// 나쁨 - 타입 혼용
function process(x) {
    if (typeof x === 'string') return x.toUpperCase()
    return x * 2
}

8.4 Megamorphic 함수

function getName(obj) {
    return obj.name
}

getName({name: "Alice"})
getName({name: "Bob", age: 30})
getName({name: "Charlie", age: 25, country: "KR"})
// 5+ 다른 hidden class → megamorphic

Megamorphic = 느림. V8의 inline cache가 polymorphic까지만 효율적.


9. 디버깅과 분석

9.1 V8

Node.js:

node --trace-opt --trace-deopt my-app.js

출력:

[marking 0x... for optimized recompilation]
[deoptimizing 0x... because of wrong map]

Chrome DevTools:

9.2 JVM

JIT 컴파일 로그:

java -XX:+PrintCompilation MyApp

JITWatch: GUI 도구.

Async-profiler: 샘플링 프로파일러.

./profiler.sh -d 30 -f profile.html <pid>

9.3 .NET

dotnet-trace:

dotnet-trace collect --process-id <pid>

PerfView: Windows 도구.

BenchmarkDotNet: 마이크로벤치마크.

[Benchmark]
public int Sum() {
    int sum = 0;
    for (int i = 0; i < 1000; i++) sum += i;
    return sum;
}

10. 실전 — 빠른 코드 작성

10.1 V8 (JavaScript)

Do:

Don't:

10.2 JVM

Do:

Don't:

10.3 .NET

Do:

Don't:

10.4 일반 원칙

  1. 측정 먼저 — 추측하지 말기
  2. 핫 루프에 집중 — Pareto 원칙
  3. JIT warm-up 고려 — 첫 1초는 느림
  4. 벤치마크는 microbench가 아닌 실제 부하

퀴즈

1. JIT가 인터프리터와 AOT의 장점을 어떻게 결합하나요?

: 인터프리터의 빠른 시작 + 컴파일러의 빠른 실행. 처음에는 인터프리터로 빠르게 시작 → 자주 실행되는 "hot" 코드를 프로파일링 → JIT가 그 부분만 기계어로 컴파일. AOT보다 우월한 점: (1) 런타임 정보 활용 (실제 사용 패턴), (2) Speculative optimization (가정 기반), (3) Deoptimization (가정 틀리면 인터프리터로 돌아감). 단점: 메모리 사용량 증가, warm-up 필요.

2. V8의 Hidden Class가 왜 중요한가요?

: JavaScript는 동적 타입이지만 V8 내부는 숨은 클래스로 객체 형태를 추적합니다. 같은 순서로 필드 추가 → 같은 hidden class → V8이 효율적으로 코드 생성. 다른 순서 → 다른 hidden class → 느림. : 객체 생성 시 모든 필드를 한 번에 ({ x, y }), delete 사용 X, 일관된 형태 유지. Monomorphic > Polymorphic > Megamorphic — 같은 형태로 호출하면 100배+ 빠를 수 있음.

3. JVM HotSpot의 C1과 C2의 차이는?

: C1 (Client Compiler): 빠른 컴파일, 보통의 최적화. 짧은 프로그램이나 데스크톱 앱에 적합. C2 (Server Compiler): 느린 컴파일, 공격적 최적화 (인라이닝, escape analysis, loop unrolling). 긴 실행 프로그램에 적합. Tiered Compilation (JDK 8+ 기본): 인터프리터 → C1 → C2 — 빠른 시작 + 최종 고성능. Graal은 C2를 대체할 차세대 컴파일러 (Java로 작성).

4. Speculative Optimization과 Deoptimization은?

: Speculation: JIT가 가정을 함 (예: "a, b는 항상 정수"). 가정 기반으로 매우 빠른 코드 생성. Deoptimization: 가정이 깨지면 인터프리터로 돌아감. 예: add(1, 2) 1000번 호출 → 정수 덧셈으로 최적화 → add("hello", "world") 호출 → 가정 깨짐 → deopt → 인터프리터에서 문자열 처리. deopt는 비쌈. 자주 발생하면 JIT가 그 함수를 포기. 일관된 타입 사용이 핵심.

5. NativeAOT는 언제 사용하나요?

: NativeAOT 적합: (1) 컨테이너/Lambda — 빠른 시작 시간 필수, (2) CLI 도구 — 짧은 실행, (3) 임베디드 — 작은 바이너리, (4) JIT 메모리 부담 회피. 장점: 매우 빠른 시작, 작은 바이너리, JIT 없어 메모리 적음. 단점: 런타임 동적 최적화 없음 (긴 실행 프로그램은 JIT가 더 빠를 수 있음), Reflection 제한, 일부 라이브러리 호환 안 됨. .NET 7+, GraalVM Native Image가 대표적. 장기 실행 서버는 JIT, 단기 실행은 AOT.


참고 자료

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다