JIT 컴파일된 코드의 저장 위치
Ruby는 코드를 로드할 때 각 메서드를 YARV(CRuby 가상 머신) 바이트코드 명령어를 포함하는 ISEQ(Instruction Sequence) 데이터 구조로 컴파일합니다. JIT 컴파일된 코드는 바이트코드를 대체하는 것이 아니라, ISEQ 내 jit_entry
필드에 네이티브 머신 코드에 대한 포인터로 저장됩니다. 원본 바이트코드는 디옵티마이제이션(de-optimization) 시 활용하기 위해 계속 유지됩니다. jit_entry
가 NULL이면 Ruby는 바이트코드를 인터프리터로 실행하며, 유효한 포인터를 가리키면 네이티브 코드로 직접 점프하여 실행합니다.
Ruby의 JIT 코드 실행 방식
Ruby는 실행하려는 모든 ISEQ에서 jit_entry
필드를 확인하여 JIT 컴파일된 코드의 존재 여부를 판단합니다.
* jit_entry
가 NULL인 경우: 인터프리터가 바이트코드를 실행합니다.
* jit_entry
가 유효한 포인터인 경우: 컴파일된 네이티브 코드를 직접 실행합니다.
Ruby의 컴파일 결정 기준
Ruby는 모든 메서드를 무작위로 또는 한 번에 컴파일하지 않습니다. 대신, 메서드의 반복적인 사용을 통해 컴파일 자격을 부여합니다. ZJIT에서는 이 과정이 두 단계로 진행됩니다.
1. 1단계: 프로파일링: jit_entry_calls
카운터가 rb_zjit_profile_threshold
(현재 기본값 25)에 도달하면 메서드 프로파일링을 시작합니다.
2. 2단계: 네이티브 코드 컴파일: jit_entry_calls
가 rb_zjit_call_threshold
(현재 기본값 30)에 도달하면 rb_zjit_compile_iseq
함수를 호출하여 네이티브 코드로 컴파일합니다.
이러한 메커니즘 때문에 JIT의 최대 성능을 얻기 위해서는 프로그램의 ‘웜업(warm-up)’ 과정이 필요합니다.
JIT 코드가 인터프리터로 폴백하는 이유 (De-optimization)
JIT 코드는 성능 향상을 위해 특정 가정을 기반으로 최적화됩니다. 이러한 가정이 깨질 경우, Ruby는 코드의 정확성을 보장하기 위해 인터프리터로 제어권을 반환하는 ‘디옵티마이제이션’을 수행합니다.
* 예시: add(a, b)
메서드가 항상 정수 인자로 호출될 것이라고 가정하여 최적화된 JIT 코드가 생성되었으나, add(1.5, 2)
와 같이 부동 소수점 인자로 호출되면 JIT 코드 내의 ‘가드(guard)’ 체크가 실패하고 인터프리터로 폴백합니다.
* 다른 폴백 트리거:
* TracePoint
활성화: TracePoint
는 바이트코드 실행에 기반한 이벤트를 필요로 하므로, 활성화 시 JIT 코드를 버리고 인터프리터 실행을 강제합니다.
* 코어 메서드 재정의: Integer
클래스의 +
연산자 의미가 변경되는 경우.
* Ractor 사용: 멀티 랙터 환경에서 일부 YARV 명령어의 동작이 변경될 수 있습니다.
이러한 ‘패치 포인트(patch points)’는 가정 변경 시에도 프로그램이 올바르게 동작하도록 보장합니다.
추가 질문 답변
- TracePoint 활성화 시 성능 저하 이유:
TracePoint
는 YARV 바이트코드 이벤트를 기반으로 작동합니다. 따라서TracePoint
가 활성화되면 JIT 컴파일러는 최적화된 코드를 폐기하고 Ruby가 YARV 명령어를 인터프리트하도록 강제하여 이벤트가 올바르게 트리거되도록 합니다. - Ruby가 모든 것을 컴파일하지 않는 이유: 자주 호출되지 않는 메서드를 컴파일하는 것은 메모리 및 컴파일 시간을 낭비하며 성능 이점이 없습니다. 또한, 프로파일링 없이 컴파일하면 잘못된 가정을 하거나 최적화 기회를 놓칠 수 있습니다.