AOT와 JIT의 기본 개념
AOT(Ahead-of-Time) 컴파일은 프로그램 실행 전에 미리 기계어로 변환하는 방식이고, JIT(Just-in-Time) 컴파일은 프로그램 실행 중에 필요한 부분을 실시간으로 컴파일하는 방식입니다.
JIT 컴파일 과정
1단계: 소스 코드 → 중간 코드 변환
- Java의 경우: .java → .class (바이트코드)
- C#의 경우: .cs → .dll/.exe (IL 코드)
- 이 단계에서는 아직 기계어가 아닌 중간 표현 형태로 변환
2단계: 런타임 로딩
- 프로그램 실행 시 가상머신(JVM, .NET CLR 등)이 중간 코드를 메모리에 로드
- 메타데이터와 함께 프로그램 구조 파악
3단계: 인터프리터 실행
- 초기에는 중간 코드를 인터프리터가 한 줄씩 해석하여 실행
- 실행 속도는 느리지만 즉시 시작 가능
4단계: 핫스팟 탐지
- 자주 실행되는 코드 블록(hot spot) 식별
- 실행 빈도, 루프 횟수 등을 기준으로 판단
- 임계값을 넘으면 컴파일 대상으로 선정
5단계: 프로파일링
- 실행 중인 코드의 특성 분석
- 변수 타입, 메소드 호출 패턴, 분기 예측 정보 수집
- 최적화에 필요한 런타임 정보 축적
6단계: 최적화된 기계어 컴파일
- 수집된 프로파일 정보를 바탕으로 최적화 수행
- 인라이닝, 루프 최적화, 데드 코드 제거 등 적용
- 타겟 CPU 아키텍처에 맞는 기계어 생성
7단계: 코드 캐싱 및 실행
- 컴파일된 기계어를 메모리에 캐싱
- 이후 동일한 코드 실행 시 컴파일된 버전 사용
- 필요시 디옵티마이제이션으로 되돌리기도 함
JPT의 답변을 듣고 궁금한점 인터프리터 / Optimizing Compiler가 뭐야?
인터프리터(Interpreter)
기본 동작 원리
인터프리터는 소스 코드나 중간 코드를 한 줄씩 읽어서 즉시 실행하는 방식입니다.
인터프리터의 세부 동작 과정
1단계: 코드 파싱
- 소스 코드를 토큰으로 분해
- 구문 분석을 통해 추상 구문 트리(AST) 생성
- 의미 분석으로 타입 검사, 변수 바인딩 등 수행
2단계: 실행 환경 준비
- 심볼 테이블 생성 (변수, 함수 등의 정보 저장)
- 스택 프레임 초기화
- 메모리 할당 및 가비지 컬렉터 설정
3단계: 명령어별 실행
while (instruction = getNextInstruction()) {
switch (instruction.type) {
case LOAD_VARIABLE:
value = symbolTable.lookup(instruction.name);
stack.push(value);
break;
case ADD:
b = stack.pop();
a = stack.pop();
stack.push(a + b);
break;
case CALL_FUNCTION:
executeFunction(instruction.functionName);
break;
}
}
4단계: 런타임 타입 체크
- 매 연산마다 피연산자의 타입 확인
- 동적 타입 변환 수행
- 메모리 경계 검사
인터프리터의 종류
트리 워킹 인터프리터(Tree-Walking Interpreter)
- AST를 직접 순회하며 실행
- 구현이 간단하지만 성능이 느림
- 재귀적 함수 호출로 각 노드 처리
바이트코드 인터프리터(Bytecode Interpreter)
- 소스 코드를 바이트코드로 컴파일 후 실행
- 스택 기반 가상 머신에서 동작
- Python, Java 초기 버전에서 사용
스레드 코드 인터프리터(Threaded Code Interpreter)
- 각 명령어를 함수 포인터로 표현
- 함수 호출 오버헤드를 줄여 성능 향상
- Forth 언어에서 주로 사용
최적화 컴파일러(Optimizing Compiler)
기본 개념
최적화 컴파일러는 프로그램의 성능을 향상시키기 위해 다양한 최적화 기법을 적용하여 기계어를 생성합니다.
최적화 컴파일러의 단계별 과정
1단계: 중간 표현(IR) 생성
- 고수준 언어를 중간 표현으로 변환
- SSA(Static Single Assignment) 형태로 변환
- 제어 흐름 그래프(CFG) 구성
2단계: 분석 단계
데이터 흐름 분석:
- 변수의 정의와 사용 지점 파악
- 도달 가능성 분석
- 생존 변수 분석
제어 흐름 분석:
- 루프 구조 식별
- 지배 관계 분석
- 분기 예측 정보 수집
3단계: 최적화 기법 적용
로컬 최적화
- 상수 접기(Constant Folding): 3 + 5 → 8
- 상수 전파(Constant Propagation): int x = 5; int y = x * 2; → int y = 10;
- 공통 부분식 제거: a * b + c * d + a * b → temp = a * b; temp + c * d + temp
글로벌 최적화
- 데드 코드 제거: 사용되지 않는 변수나 코드 삭제
- 루프 불변 코드 이동: 루프 내 변하지 않는 계산을 루프 밖으로 이동
// 최적화 전
for (int i = 0; i < n; i++) {
result[i] = array[i] * (x + y);
}
// 최적화 후
int temp = x + y;
for (int i = 0; i < n; i++) {
result[i] = array[i] * temp;
}
고급 최적화
- 인라이닝(Inlining): 함수 호출을 함수 본체로 대체
- 루프 언롤링(Loop Unrolling): 루프 반복 횟수 줄이기
- 벡터화(Vectorization): SIMD 명령어 활용
4단계: 레지스터 할당
- 변수를 CPU 레지스터에 효율적으로 할당
- 그래프 컬러링 알고리즘 사용
- 스필(Spill) 코드 생성으로 메모리 접근 최소화
5단계: 기계어 생성
- 타겟 아키텍처에 맞는 명령어 선택
- 명령어 스케줄링으로 파이프라인 최적화
- 페어링 규칙 적용으로 병렬 실행 극대화
최적화 레벨
O0 (최적화 없음)
- 컴파일 속도 최우선
- 디버깅 정보 보존
O1 (기본 최적화)
- 로컬 최적화 중심
- 컴파일 시간과 성능의 균형
O2 (표준 최적화)
- 대부분의 최적화 기법 적용
- 실제 배포에서 주로 사용
O3 (공격적 최적화)
- 모든 최적화 기법 적용
- 코드 크기 증가 가능성
인터프리터 vs 최적화 컴파일러 비교
성능 특성
실행 시간 = 컴파일 시간 + 런타임
인터프리터:
- 컴파일 시간: 0 (즉시 실행)
- 런타임: 매우 느림 (매번 파싱/해석)
최적화 컴파일러:
- 컴파일 시간: 길음 (복잡한 분석/최적화)
- 런타임: 매우 빠름 (최적화된 기계어)
메모리 사용량
인터프리터: 소스 코드 + 실행 상태 + 런타임 시스템 컴파일러: 최적화된 기계어 (일반적으로 더 효율적)
개발 생산성
인터프리터: 즉시 실행 가능, 동적 기능 지원 컴파일러: 컴파일 단계 필요, 정적 분석으로 오류 조기 발견
현대적 하이브리드 접근법
계층적 컴파일(Tiered Compilation)
- 인터프리터로 즉시 실행 시작
- 자주 사용되는 코드를 빠른 컴파일러로 컴파일
- 핫스팟은 최적화 컴파일러로 재컴파일
적응적 최적화(Adaptive Optimization)
- 런타임 프로파일 정보를 활용
- 실행 패턴에 따라 동적으로 최적화 수준 조정
- 추측 기반 최적화 후 검증
이러한 하이브리드 방식은 인터프리터의 빠른 시작과 컴파일러의 높은 성능을 모두 활용할 수 있게 해줍니다.
AOT JIT의 등장 배경
전통적인 JIT의 단점을 보완하기 위해 AOT JIT가 개발되었습니다:
- 콜드 스타트 문제: 초기 실행 시 컴파일 시간으로 인한 지연
- 웜업 시간: 최적화되기까지의 시간 소요
- 메모리 오버헤드: 런타임 컴파일러와 메타데이터 유지
AOT JIT 구현 방식
프로파일 기반 AOT (Profile-Guided AOT)
- 사전에 프로파일 정보를 수집하여 AOT 컴파일 시 활용
- 실제 사용 패턴을 반영한 최적화 가능
하이브리드 접근법
- 중요한 코드는 AOT로 미리 컴파일
- 나머지는 런타임에 JIT 컴파일
- GraalVM의 Native Image가 대표적 예시
계층적 컴파일 (Tiered Compilation)
- 여러 최적화 레벨을 단계적으로 적용
- 빠른 초기 컴파일 후 점진적 최적화
주요 장단점
장점:
- 빠른 시작 시간과 초기 성능
- 예측 가능한 성능 특성
- 메모리 사용량 절약
단점:
- 런타임 정보 부족으로 인한 최적화 한계
- 컴파일 시간 증가
- 동적 기능 제약