인터프리터와 가상 머신이 네이티브 코드보다 느린 이유를 살펴보고, 이를 가속하기 위해 흔히 사용하는 방법들을 정리한다.
인터프리터와 가상 머신은 네이티브 머신 코드로 컴파일하지 않고도 코드를 실행하는 프로그램이다. CPU처럼 명령을 하나씩 따라가며 실행하지만, 하드웨어가 아니라 소프트웨어로 이를 수행한다. 덕분에 언어의 추상 머신을 구현하는 방식에 유연성이 생기지만, 네이티브 코드보다 느려지는 결과를 낳는다. 이 글에서는 왜 느린지 그 이유들과, 이를 빠르게 만들기 위해 흔히 취하는 접근들을 살펴보려 한다.
이 글을 인터프리터와 가상 머신에 대한 비판으로 받아들이지 말아 달라. 이들은 우리 기술 생태계의 부인할 수 없는 일부다. 이 글은 오직 그들의 핵심 성능 과제가 무엇인지 알리려는 목적뿐이다.
소스 형식이 무엇이든, 시스템이 코드를 실행하기 전에는 어느 정도의 컴파일이 필요하다. 파싱에는 여러 단계가 있고, 그중 몇몇은 꽤 느리다. 인터프리트 언어의 시작(스타트업) 속도 문제에 일정 부분 기여하긴 하지만, 여기서 초점을 맞추려는 부분은 아니다.
여기서는 코드가 이미 중간 표현(IR)으로 컴파일되었다고 가정하자. 특히 가상 머신에서 실행하기에 적합한 형태의 IR이다. 이는 어셈블리 코드처럼 보일 수도 있는 축약된 형태다. 가상 머신은 이 버전의 코드를 읽고 실행한다. IR이 익숙하지 않다면 “unwrapped intermediate representations”에 관한 내 글을 참고하라. IR을 처리하는 것이 가상 머신 성능의 가장 큰 부분을 차지한다.
IR을 사용하지 않고, 대신 추상 구문 트리(AST)를 직접 순회하며 실행하는 또 다른 형태의 인터프리터도 있다. 이런 것들은 대개 도메인 특화 언어(DSL)에 쓰이며 다른 제약 조건에서 동작한다. 범용 코드에서는 IR 기반 인터프리터보다 성능이 나쁜 편이다. 흥미롭고 가치 있는 사례이지만, 이 글의 범위 밖이다.
인터프리터는 IR을 실행하는 가상 머신을 구현한다. 명령을 읽고 실행하며, 순서대로 진행하거나 필요에 따라 분기한다. 또한 인터프리터는 힙, 스택, 레지스터 같은 메모리 시설도 제공해야 한다. IR이 대상 플랫폼에서 기대하는 모든 것을 제공해야 하며, 즉 추상 머신의 구현체다.
이 가상 머신의 핵심에는 루프와 switch 문이 있다. 이 둘은 다음 명령을 가리키는 포인터와 명령을 디스패치하는 능력을 갖춘다는 점에서 CPU의 동작과 비슷하다.
스택 기반 가상 머신을 대상으로 하는 간단한 IR 예시로 이를 살펴보자. a가 이미 메모리에 정의되어 있다고 가정하되, 어디에 있는지는 신경 쓰지 않겠다.
push_integer_from a
push_integer 5
add_integer
pop_integer_to a
바이트 형식은 무시하고, 이 명령들이 배열에 패킹되어 있으며 첫 항목이 명령이고 그 뒤가 인자라고 가정하자. 예를 들어 첫 번째 명령은 [ push_integer_from, a ]이고 두 번째는 [ push_integer, 5 ]이다. 각 명령은 인자의 정확한 타입에 특화되어 있으며, 인자를 어떻게 해석할지 알고 있다.
여기서는 a가 문자열이라고 가정하자. 어딘가에서 이미 메모리 오프셋이나 번호가 매겨진 가상 레지스터로 변환되었을 수도 있다. 하지만 문자열이라면 이름 기반 인덱스로 메모리를 조회해야 한다. 이 부분은 나중에 다시 돌아오겠다.
이를 바탕으로, 핵심 가상 머신 루프의 일부를 보자.
while( instruction_index < instruction_list.length ):
instruction = instruction_list[instruction_index]
switch( instruction[0] ):
...
case push_integer_from:
stack.push_integer( memory.get_integer(instruction[1]) )
instruction_index += 2
case push_integer:
stack.push_integer( instruction[1] )
instruction_index += 2
case add_integer:
tmp_a = stack.pop_integer()
tmp_b = stack.pop_integer()
stack.push_integer( tmp_a + tmp_b )
instruction_index += 1
case pop_integer_to:
tmp_a = stack.pop_integer()
memory.set_integer(instruction[0], tmp_a)
instruction_index += 2
...
예시 IR을 따라가 보면, 결국 a의 값과 5를 더한 뒤 그 결과를 다시 a에 저장하는 동작이 된다.
가상 머신의 오버헤드 한 가지는 명령 포인터를 추적하고, 명령들을 순서대로 밟아 가며, 그때그때 디스패치해야 한다는 점이다. 이런 일은 CPU가 네이티브로 수행한다. 별것 아닌 것처럼 보여도, IR의 각 문장 사이사이에 CPU가 실행해야 할 추가 명령들이 계속 끼어드는 셈이다. 명령 수의 증폭만으로도 영향이 크다.
명령 디스패치 자체도 문제가 될 수 있다. CPU는 하드와이어된 경로가 있지만, 가상 머신에서는 각 명령마다 그에 해당하는 코드를 작성해야 한다. 이 코드는 메모리에 존재해야 하고, CPU의 최하위 캐시에 들어가게 된다. 가상 머신은 이 코드 크기를 아주 작게 유지해 캐시에서 절대 밀려나지 않게 해야 한다. 캐시 미스가 자주 나면 코드 실행이 멈칫거리게 된다.
핵심 루프가 차지하는 메모리는 시스템에서 가장 희소한 메모리다. 가상 머신 코드가 그곳을 차지하면, 다른 용도로 쓸 수 있는 양이 줄어든다. 또한 명령 포인터와 스택 메모리를 가리키는 포인터는 레지스터에 올라가 있을 가능성이 큰데, 레지스터 역시 희소한 자원이다.
위 코드에서 사용한 함수 호출 오버헤드는 걱정할 필요가 없다. 가상 머신 자체는 머신 코드로 컴파일되며, 합리적인 수준으로 최적화하는 컴파일러라면 이런 함수들을 모두 인라인할 것이다. 예를 들어 add_integer는 레지스터에 있는 포인터를 통해 메모리에 직접 접근하는 2~4개의 머신 코드 명령 정도로 끝날 가능성이 높다.
CPU는 또한 작은 코드 조각을 최적화하는 데 매우 능숙하며, 핵심 루프에 대해 그런 최적화를 수행한다. 좋은 소식처럼 들리지만, 사실 성능 문제이기도 하다. 우리가 자체 명령 루프를 갖고 있기 때문에, 실제로 실행하고 싶은 “진짜 코드”인 IR은 CPU 입장에서 다소 보이지 않게 된다. 컴파일된 프로그램에는 적용되는 분기 최적화, 명령 재배치, 메모리 조회 최적화 등이 실행 중인 IR에는 적용되지 않거나, 최소한 같은 정도로는 적용되지 않는다.
핵심 가상 머신 루프 자체는 빠르게 돌 것이다. 이를 컴파일할 때의 컴파일러 최적화와 CPU 최적화를 모두 활용한다. 하지만 우리가 실제로 실행하려는 코드는 IR에 인코딩된 것인데, 그로부터 한 단계 떨어져 있다. 이 작은 추상화 한 겹이 전체 속도에 큰 영향을 줄 수 있다.
이 오버헤드에 대한 가장 명확한 해법은 IR을 머신 코드로 컴파일하는 것이다. 이것이 JIT 컴파일이다. IR 코드를 받아 대상 머신의 코드로 컴파일한다. VM 루프 없이 CPU에서 직접 실행되게 하면 성능이 크게 향상된다. VM 실행에서 가장 비싼 부분을 제거하기 때문이다.
물론 전체 프로그램을 전부 컴파일하기는 어렵거나, 사용자가 그동안 기다리게 하고 싶지 않을 수 있다. 그러면 VM은 필요해지는 부분을 그때그때 컴파일한다. 이 과정에서 핵심 루프 안팎으로 들어갔다 나오며 동기화 문제 같은 새로운 이슈가 생긴다. 하지만 코드가 아주 작지 않은 한, 이런 새로운 오버헤드가 CPU에서 네이티브로 조각들을 실행하는 이득을 상쇄한다고 보긴 어렵다.
JIT가 네이티브 컴파일러만큼 많은 시간을 들여 최적화하진 않을 가능성이 크다. 또한 덩어리 단위로 작업한다면 전역 최적화(global optimization)의 일부를 놓칠 수도 있다. 그 결과 JIT 코드가 네이티브로 컴파일된 구성 요소보다 상당히 느릴 수 있다. 이는 코드가 무엇을 하느냐에 크게 좌우된다. 거칠게 이해하자면, 최적화 없이 네이티브 코드로 컴파일한 프로그램과 최고 최적화 수준으로 컴파일한 프로그램의 차이를 떠올려 보라. 그 차이는 엄청날 수 있다. 그럼에도 최적화되지 않은 네이티브 코드조차 VM 루프보다는 훨씬 빠르다.
예시에서는 변수 a를 이름으로 참조했다. 가상 머신이 이런 방식을 지원할 수는 있지만, 속도에 큰 영향을 준다. a에 접근할 때마다 a의 주소를 얻기 위해 딕셔너리나 해시 테이블에서 조회해야 한다. 이는 큰 병목이 될 것이다.
하지만 네이티브 컴파일 코드와 마찬가지로 IR에서도 변수를 오프셋으로 인코딩할 수 있다. 예컨대 로컬 함수 스택으로부터의 오프셋이거나, 전역 베이스 포인터로부터의 오프셋이다. IR이 실행될 때에는 네이티브 실행 파일이 하는 것과 비슷하게 주소 재작성(address rewriting)이나 배치 작업을 처리해야 한다. 따라서 a에 쓰는 명령인 push_integer_from a는 push_integer_from 0x04502 같은 형태가 될 수 있으며, 여기서 0x04502는 힙에서 a의 주소다.
가상 머신이 놓치기 쉬운 것은 주소 지정 모드(addressing mode)다. 네이티브 명령은 종종 레지스터를 통한 간접 접근, 주소 접근 후 증가, 또는 다른 다양한 최적화된 메모리 접근 형태를 제공한다. IR도 최적화된 형태들을 여러 개 제공할 수는 있지만, 제공하는 형태가 많아질수록 가상 머신이 커지고, 그만큼 중요한 캐시 메모리를 더 소비하게 된다.
또한 가상 머신은 레지스터를 효율적으로 할당할 수 없다. 네이티브 코드로 컴파일할 때 최적화기는 대상에 대해 많은 정보를 알고 있으며, 레지스터를 잘 활용하도록 로컬 메모리 배치를 할 수 있다. 하지만 VM은 아직 어떤 코드를 실행할지 모르므로, 자신(가상 머신 코드)을 가장 잘 실행할 수 있는 방식으로만 레지스터를 할당할 수 있다. IR 안에 레지스터 관련 로직을 어느 정도 인코딩할 수도 있지만, 크로스 플랫폼을 의도한다면 사용할 수 있는 레지스터가 무엇인지 전부 알 수 없다. 그 결과 가상 머신은 코드를 실행하면서 스택이나 힙에서 값을 더 자주 읽고 쓰게 된다.
위 코드에서 stack을 순진하게 구현하면 또 다른 문제가 생긴다. 메모리 보호를 제공하려면(그리고 제공해야 한다), 스택에 push/pop 할 때마다 주소가 범위 내(in bounds)인지 검사해야 한다. 모든 메모리 연산마다 단순 비교 하나를 추가하는 것만으로도 성능에 큰 부담이 될 수 있다. 다만 그 부담이 어느 정도인지는 CPU의 분기 예측 능력에 따라 달라진다.
네이티브 스택은 이런 비용이 없다. 프로그램 시작 시 일정량을 할당하고, 오버플로가 나면 추가 공간이 자동으로 할당된다. 언더플로가 나면 메모리 접근 오류가 발생한다.
운영체제는 가상 머신에도 이런 네이티브 기능을 제공할 수 있어야 한다. 예를 들어 Linux에서는 mmap 호출에 MAP_GROWSDOWN 플래그를 사용해 스택처럼 동작하는 메모리 영역을 만들 수 있다. 또한 현대 OS라면 스택의 실행 금지(no-execute) 플래그, IR 명령 메모리의 쓰기 보호 같은 우리가 원하는 메모리 기능을 모두 노출하길 바란다.
따라서 스택 자체는 문제가 될 수 있지만, 올바른 가상 머신 구현이라면 그렇게 되지 않아야 한다. 다만 컴파일된 코드보다 스택을 더 자주 사용해야 한다는 점은 문제가 된다. 이는 레지스터를 최적으로 할당하는 법을 모르기 때문에 push/pop을 더 자주 하게 된다는 이야기로 돌아간다. 스택 사용이 네이티브만큼 빠르더라도, 아예 사용하지 않는 것만큼 빠를 수는 없다.
힙이나 전역 상수 같은 다른 메모리는 큰 문제가 아닐 것이다. 힙은 어떤 프로그램이든 동적으로 할당하므로, VM의 핵심 루프를 통해 한다고 큰 차이가 나진 않는다. 정적 데이터도 마찬가지다. 시작 시 주소가 주어진 데이터 블록일 뿐이다. 다만 역시 VM이 모든 주소 지정 모드를 활용하진 못할 수 있으므로, 메모리 관리에 오버헤드는 없더라도 배열 순회 같은 작업은 느려질 수 있다.
그렇다면 VM이 모든 메모리 주소를 미리 계산하고, 네이티브 스택 구현을 사용하며, 모든 코드를 JIT로 네이티브 코드로 바꿔버리면 되지 않을까 생각할 수 있다. 그렇게 되면 사실상 지연된 컴파일이지, 가상 머신이라기보다는 컴파일에 가깝다. 빠르긴 하겠지만, 사전 컴파일(precompiled)만큼 빠르진 않을 것이다. 그래도 다양한 도메인에서 충분히 쓸 만한 크로스 플랫폼 IR을 얻을 수 있으니 매우 유용하다.
남는 어려움은 그 크로스 플랫폼 IR에서 비롯된다. 네이티브 컴파일용 IR은 소스 언어로부터 가능한 한 많은 정보를 유지하려고 한다. 반면 VM용 IR은 VM 실행 또는 JIT 컴파일이 빠르도록 적합한 형태를 취한다. IR 형태의 차이는 네이티브 코드로 컴파일했을 때 얼마나 잘 컴파일되는지에도 차이를 만든다.
재미와 혼란을 더하자면, 최적화기가 그 빠진 정보 일부를 재구성할 수도 있고, VM 루프와 JIT 시스템이 동적으로 최적화 경로를 감지할 수도 있다. 런타임에서 최적화하면, “실행될 수도 있는 것”이 아니라 “실제로 실행되는 것”을 기준으로 최적화할 수 있다. 예를 들어 CPU 자체도 실행 속도를 높이기 위해 분기 예측을 한다. 항상 맞지는 않지만, 종종 충분히 잘 작동해 차이를 만든다.
앞의 내용은 어떤 언어가 실행되는지는 일부러 피했다. 이런 오버헤드는 소스 언어와 무관하게 적용된다. 명령 루프, 변수 및 스택 관리는 언어가 달라져도 변하지 않는 핵심 책임 집합을 갖는다. 가상 머신은 소스 언어와 무관하게 상당한 비용을 가진다.
하지만 어떤 언어는 다른 언어보다 더 느리게 동작하는 것이 사실이다. 언어 자체가 제공하는 구성 요소는 매우 다양하며, 가상 머신에서 실행하든 네이티브로 컴파일하든 실행 시간에 영향을 준다. 가장 큰 요인은 동적 타입(dynamic typing)으로, 비용이 막대하다. 다만 이것은 별도의 글로 떼어내려 한다. 이는 직교(orthogonal)하는 문제다. 다만 VM 기반 언어들 중 많은 수가 비용이 큰 구성 요소를 함께 제공하는 경우가 많을 뿐이다.