다음에 올 것을 만들어가는 기술에 대한 심층 기사.

JIT 컴파일러는 동적 언어를 어떻게 빠르게 만드는가

JIT 컴파일러가 Ruby, Python, JavaScript 같은 동적 언어의 중복 연산을 제거해 어떻게 빨라지는지, YJIT과 ZJIT 사례로 살펴봅니다.

빛나는 기계 속으로 들어가 빛의 혜성이 되어 날아가는 ruby 젬

Ruby는 느리다는 평판이 있습니다. Python도 그렇고, JavaScript도 그랬죠. 적어도 V8이 서버 사이드 워크로드를 감당할 만큼 빨라지기 전까지는요. 동적 언어가 어떻게 빨라졌는지에 대한 이야기는 곧 JIT(Just-In-Time) 컴파일의 이야기이며, 실무 컴퓨터 과학에서 가장 흥미로운 분야 중 하나입니다. 최신 장은 이렇습니다. Ruby의 ZJIT이 중간 표현(IR) 수준에서 중복된 객체 로드와 스토어를 제거하고 있는데, 이는 V8의 TurboFan을 그토록 효과적으로 만들었던 것과 같은 종류의 최적화입니다.

Ruby 코드가 왜 C의 몇 분의 1 속도로 돌아가는지, 또는 JavaScript가 어떻게 VS Code를 구동할 만큼 빨라졌는지 궁금했던 적이 있다면, 그 답은 JIT 컴파일러가 실제로 무엇을 하는지, 그리고 동적 언어 최적화가 정적 언어보다 근본적으로 왜 더 어려운지를 이해하는 데 있습니다.

동적 언어의 근본적인 문제

C 컴파일러는 a + b를 보면 컴파일 시점에 a와 b의 타입을 압니다. 둘 다 정수라면 단일 ADD 명령어를 내보냅니다. 부동소수점이라면 부동소수점 덧셈을 내보냅니다. CPU는 그 명령어를 한 사이클에 실행합니다. 모호함도 없고, 런타임에 판단할 일도 없습니다.

Ruby 인터프리터는 a + b를 보면 거의 아무것도 모릅니다. a는 정수, 실수, 문자열, 배열, 또는 + 메서드를 정의한 임의의 객체일 수 있습니다. 인터프리터는 a의 타입을 확인하고, 해당 타입의 + 메서드를 찾고, b의 타입을 확인하고, 필요하면 타입을 강제 변환하고, 예외 상황(오버플로, frozen 객체)을 처리한 뒤에야 비로소 연산을 수행합니다. 이 단일 +에 수십 개의 명령어, 메모리 조회, 분기 판단이 들어갈 수도 있습니다.

# What the programmer writes:
def sum(a, b)
a + b
end
# What the interpreter actually has to do (pseudocode):
def sum(a, b)
method = lookup_method(a.class, :+)      # Hash lookup
raise NoMethodError unless method         # Branch
if a.is_a?(Integer) && b.is_a?(Integer)   # Type checks
result = integer_add(a.value, b.value)  # Actual math
if overflow?(result)                     # Overflow check
result = promote_to_bignum(result)    # Bignum promotion
end
elsif a.is_a?(Float) || b.is_a?(Float)
result = float_add(to_float(a), to_float(b))
elsif a.is_a?(String)
result = string_concat(a, b.to_s)
else
result = call_method(method, a, [b])    # Generic dispatch
end
result
end

타입 검사, 메서드 조회, 디스패치 같은 이 오버헤드가 동적성의 대가입니다. a + b를 정수, 실수, 문자열, 사용자 정의 객체 어디에서나 동작하게 만드는 유연성은 런타임에 비용이 큽니다.

JIT 컴파일이 반격하는 방식

JIT 컴파일러의 역할은 프로그램이 런타임에 실제로 무엇을 하는지 관찰하고, 그 관찰을 바탕으로 최적화된 기계어를 생성하는 것입니다. 핵심 통찰은 이렇습니다. Ruby 코드는 원칙적으로 어떤 타입에든 동작할 수 있지만, 실제로 특정 호출 지점은 거의 항상 같은 타입을 받는다는 것입니다.

sum(a, b)가 10,000번 호출되었고 a와 b가 항상 정수였다면, JIT는 계속 정수일 것이라고 가정하는 특화된 기계어를 생성할 수 있습니다. 정수 덧셈 명령어 하나를 내보내되, 그 가정이 깨지면 느린 경로로 빠져나가는 '가드', 즉 빠른 타입 검사를 함께 둡니다.

Interpreter execution of sum(a, b) where a and b are Integers:
1. Load a from stack
2. Check if a is an object reference
3. Load a's class pointer
4. Look up :+ in method table (hash lookup)
5. Check method visibility
6. Load b from stack
7. Check if b is an object reference
8. Check if b is compatible type
9. Unbox a's integer value
10. Unbox b's integer value
11. Perform addition
12. Check for overflow
13. Box result as new Integer object
14. Return result
JIT-compiled version (after profiling shows a,b are always Integers):
1. Guard: check a is Integer (bail if not)
2. Guard: check b is Integer (bail if not)
3. ADD instruction
4. Guard: check no overflow (bail if overflow)
5. Return result

14단계짜리 과정이 5단계로 줄었고, 그중 1, 2, 4단계는 단일 비교 명령어입니다. 실제 계산인 ADD는 CPU 한 사이클입니다. JavaScript가 '진지한 용도로는 너무 느리다'에서 '완전한 IDE를 돌릴 만큼 충분히 빠르다'로 넘어온 방식이 바로 이것입니다.

Ruby의 JIT 여정: MJIT에서 YJIT, ZJIT까지

Ruby의 JIT 컴파일 역사는 이 문제가 얼마나 어려운지를 보여주는 사례입니다. Ruby는 여러 JIT 구현을 거쳤고, 각각 다른 접근을 택했습니다.

MJIT(Ruby 2.6, 2018)은 Ruby 바이트코드를 C 코드로 변환한 뒤 GCC나 Clang을 호출해 컴파일하는 방식이었습니다. 잘 최적화된 코드가 나왔지만 워밍업 시간이 끔찍했습니다. C를 컴파일하는 데는 밀리초가 아니라 수 초가 걸리기 때문에, JIT 코드가 준비될 무렵이면 프로그램은 이미 실행을 끝냈을 수도 있었습니다.

YJIT(Ruby 3.1, 2022)은 Shopify의 기여로, 처음에는 C로 작성되었고 이후 Rust로 다시 작성되었습니다. YJIT은 '지연 기본 블록 버전 관리(lazy basic block versioning)'라는 기법을 씁니다. 코드를 기본 블록 단위로, 실제로 실행될 때만 컴파일하고, 관찰한 타입에 따라 특화된 버전을 만듭니다. 덕분에 워밍업은 빠르고(밀리초 단위) 최고 성능도 좋습니다. YJIT은 Rails 애플리케이션 같은 실제 워크로드에서 보통 Ruby 성능을 15~30% 개선합니다.

ZJIT은 다음 진화 단계이며, 여기서부터 정말 흥미로워집니다. ZJIT은 바이트코드와 기계어 사이에 있는, 프로그램의 구조화된 표현인 중간 표현(IR)을 도입합니다. 이 IR 덕분에 YJIT의 바이트코드-기계어 직접 변환 방식으로는 쉽지 않았던 고전적인 컴파일러 최적화를 적용할 수 있습니다.

중복 로드와 스토어 제거

ZJIT이 최근 반영한 특정 최적화, 즉 중복된 객체 로드와 스토어를 제거하는 작업은 어렵게 들리지만 영향은 큽니다. 이유는 다음과 같습니다.

Ruby 객체는 인스턴스 변수를 프로퍼티 테이블에 저장합니다. @name을 읽을 때마다 인터프리터는 객체의 프로퍼티 테이블에서 메모리로부터 값을 가져옵니다. @name = value라고 쓸 때마다 그 테이블에 저장합니다. 같은 인스턴스 변수를 여러 번 접근하는 메서드에서는 인터프리터가 매번 메모리에서 다시 읽습니다. 일반적인 경우에는 읽기 사이에 다른 무언가가 값을 바꿨을 수 있기 때문입니다.

class Rectangle
def area
@width * @height
end
def perimeter
# @width and @height are each loaded TWICE from memory
2 * (@width + @height)
end
def scale(factor)
@width = @width * factor    # Load @width, multiply, store @width
@height = @height * factor  # Load @height, multiply, store @height
self
end
end
# In a tight loop, these redundant loads add up:
rects.each { |r| r.scale(2).perimeter }

IR이 있으면 ZJIT은 로드 제거(load elimination)를 수행할 수 있습니다. @width가 이미 로드되었고 그 이후 아무도 수정하지 않았다면, 메모리에서 다시 읽는 대신 레지스터에 있는 값을 재사용합니다. 스토어 제거(store elimination)도 가능합니다. @width에 두 번 연속 쓰면서 그 사이에 아무도 읽지 않았다면, 첫 번째 스토어는 없애도 됩니다.

이런 최적화는 정적 언어 컴파일러에서는 기본 중의 기본입니다. GCC와 LLVM은 수십 년 동안 해왔습니다. 하지만 동적 언어에서는 앨리어싱 때문에 훨씬 어렵습니다. Ruby에서는 어떤 메서드를 호출하든 instance_variable_set, method_missing, 또는 트레이스 훅을 통해 임의의 객체 인스턴스 변수를 수정할 수 있습니다. 따라서 JIT는 두 번의 @width 읽기 사이에 아무것도 그 값을 바꿀 수 없었다는 것을 증명해야 하고, 이는 그 사이에 끼어든 모든 연산이 무엇을 할 수 있는지 분석해야 한다는 뜻입니다.

IR의 이점

ZJIT이 IR을 도입한 이유(그리고 V8의 TurboFan, JavaScriptCore의 DFG/FTL, HotSpot의 C2가 모두 IR을 쓰는 이유)는 이런 최적화들을 조합 가능하게 만들기 때문입니다. IR은 본질적으로 연산들의 그래프이고, 최적화는 그래프 변환으로 적용할 수 있습니다.

  • 상수 폴딩(Constant folding): 덧셈의 두 피연산자가 모두 상수로 알려져 있으면 연산을 그 결과로 바꿉니다. 2 + 3은 컴파일 시점에 5가 됩니다.
  • 죽은 코드 제거(Dead code elimination): 어떤 연산의 결과가 전혀 사용되지 않으면 그 연산을 통째로 제거합니다.
  • 공통 부분식 제거(Common subexpression elimination): 같은 계산이 두 번 나타나면 한 번만 계산하고 결과를 재사용합니다.
  • 로드/스토어 제거(Load/store elimination): 앞서 설명한 대로 중복된 메모리 연산을 제거합니다.
  • 이스케이프 분석(Escape analysis): 객체가 생성되어 현재 메서드를 벗어나지 않는다면, 힙 대신 스택에 할당하거나(또는 할당 자체를 없앱니다).
  • 인라이닝(Inlining): 메서드 호출을 메서드 본문으로 대체해, 다른 최적화들이 적용될 기회를 늘립니다.

이 최적화들은 서로 맞물려 효과가 커집니다. 메서드를 인라이닝하면 그 연산들이 호출자의 문맥에 노출되고, 그러면 상수 값이 드러나 상수 폴딩이 가능해지고, 그 결과 어떤 코드는 죽은 코드가 되어 제거됩니다. 인라이닝 결정 하나가 수십 개 연산의 제거로 이어질 수 있습니다.

비최적화(Deoptimization) 안전망

JIT 컴파일러가 하는 모든 일은 추측에 기반합니다. 타입이 바뀌지 않을 것이라고, 메서드가 재정의되지 않을 것이라고, 몽키 패칭이 최적화 코드를 무효화하지 않을 것이라고 가정합니다. 이런 가정이 깨지면 JIT는 '디옵티마이즈(deoptimize)', 즉 최적화된 코드를 버리고 인터프리터로 되돌아가야 합니다.

디옵티마이즈는 JIT 설계에서 가장 어려운 부분 중 하나입니다. 최적화된 코드는 지역 변수를 없앴을 수도, 연산 순서를 바꿨을 수도, 깊게 중첩된 호출을 인라이닝했을 수도 있습니다. 인터프리터로 되돌아가려면 JIT는 최적화 코드가 가진 정보로부터 인터프리터 상태, 즉 모든 지역 변수, 콜 스택, 프로그램 카운터를 재구성해야 합니다. 이를 위해 최적화 코드 상태를 인터프리터 상태로 다시 매핑하는 메타데이터(‘온 스택 리플레이스먼트(OSR)’ 맵이라고도 함)를 유지해야 합니다.

디옵티마이즈가 자주 일어나면, 즉 '디옵트 스래싱(deopt thrashing)'이 발생하면 성능이 순수 인터프리터 실행보다 나빠질 수 있습니다. JIT가 최적화 코드를 컴파일하고, 잠깐 실행하고, 디옵티마이즈하고, 다시 반복하는 데 시간을 쓰기 때문입니다. V8은 디옵티마이즈 횟수를 추적하다가 특정 함수의 최적화를 포기하는 방식으로 이를 처리합니다. YJIT은 더 단순한 접근을 택해, 타입 조합별로 코드 경로의 여러 버전을 생성합니다. 이렇게 하면 디옵티마이즈가 덜 필요하지만, 생성되는 코드가 늘어난다는 비용이 있습니다.

Ruby를 넘어서 왜 중요한가

Ruby의 JIT 여정은 동적 언어 전반에서 일어나는 일을 그대로 보여줍니다. Python의 copy-and-patch JIT(CPython 3.13에 반영)는 Python의 본격적인 JIT 컴파일을 향한 첫걸음입니다. LuaJIT은 공격적인 트레이싱 JIT 덕분에 오랫동안 놀라울 만큼 빠릅니다. PHP 8.0 이후의 JIT는 IR 기반 최적화에 LLVM을 사용합니다.

패턴은 일관됩니다. 인터프리터로 시작하고, 런타임 동작을 파악하기 위해 프로파일링을 추가하고, 핫 경로를 타입 특화로 컴파일하고, 고전적 최적화를 위해 IR을 도입하고, 그다음 다듬어 갑니다. 각 언어는 동적 디스패치, 가변 객체, eval, 몽키 패칭이라는 같은 문제에 부딪히고, 비슷한 해법에 도달합니다.

이 언어들을 쓰는 개발자에게 실용적인 결론은, 동적 언어와 정적 언어의 성능 격차가 좁혀지고 있다는 것입니다. 완전히 없어지지는 않을 겁니다. 타입 검사와 가드에는 여전히 비용이 들고, 디옵티마이즈는 본질적인 오버헤드이기 때문입니다. 하지만 잘 최적화된 JIT는 계산 중심 워크로드에서 동등한 C 코드의 2~5배 이내까지 따라갈 수 있고, 이는 대부분의 애플리케이션에 '충분히 빠른' 수준입니다.

조금 더 미묘한 결론은 코드를 단순하게 쓰라는 것입니다. JIT 컴파일러는 예측 가능한 패턴을 잘 최적화합니다. 모노모픽 호출 지점(메서드가 항상 같은 타입을 받는 곳)은 잘 최적화됩니다. 폴리모픽 호출 지점(타입이 다양한 곳)은 더 어렵습니다. 메가모픽 호출 지점(수십 개의 타입)은 아예 최적화되지 않을 수도 있습니다. 사람이 이해하기 쉬운 코드는 대개 JIT가 최적화하기도 쉽습니다. 인센티브가 잘 맞아떨어지는 셈입니다.