[번역] just-in-time 실시간(JIT) 컴파일러에 대한 단기 과정
원문: A crash course in just-in-time (JIT) compilers - Mozilla Hacks - the Web developer blog
이 글은 WebAssembly and what makes it fast 시리즈의 두 번째 파트입니다. 아직 다른 파트를 읽지 않았다면, 처음부터 시작 (또는 역자 번역을 확인) 하는 것을 추천합니다.
자바스크립트가 처음 나왔을 때는 느렸지만, JIT라는 것 때문에 빨라졌습니다. JIT는 어떻게 동작할까요?
How JavaScript is run in the browser
개발자가 페이지에 자바스크립트를 추가하면 다음의 목표와 문제가 발생합니다.
목표 : 컴퓨터에게 무엇을 해야 하는지 알려주고 싶습니다.
문제 : 당신과 컴퓨터가 이해하는 언어가 서로 다릅니다.
여러분은 인간의 언어로 말하고 컴퓨터는 기계어를 사용합니다. 여러분이 비록 자바스크립트나 다른 고급 프로그래밍 언어들을 인간의 언어라고 생각하지 않는다 하여도 실제로 (프로그래밍 언어)는 기계를 위한 것이 아니라 인간의 인식을 위해 설계되었습니다.
그래서 자바스크립트 엔진의 역할은 인간의 언어를 기계가 이해하는 것으로 바꾸는 것입니다.
저는 영화 “컨택트(Arrival)“에서 인간과 외계인이 서로 대화하려고 하는 장면과 비슷하다고 생각합니다.
이 영화에서 인간과 외계인은 서로의 언어 간(단어-와-단어) 번역을 사용하지 않습니다. 두 집단은 세상에 대해 서로 다른 사고방식을 가지고 있고, 이는 인간과 기계에 대해서도 마찬가지입니다.(여기에 대해서는 다음 게시물에서 자세히 설명하겠습니다.)
그렇다면 번역은 어떻게 이루어질까요?
프로그래밍에서는 일반적으로 인터프리터 또는 컴파일러를 사용하는 두 가지 방법으로 기계어로 번열할 수 있습니다.
인터프리터를 이용하는 번역의 경우에는, 대게 한 줄-한 줄(line-by-line)로 진행이 됩니다.
반면에 컴파일러는 실행하기에 앞서 미리 번역을 작성해 둡니다.
번역을 처리하는 이러한 각각의 방법에는 장단점이 있습니다.
Interpreter pros and cons
인터프리터는 빠르게 준비하고 실행합니다. 코드를 실행하기 전에 전체 소스를 컴파일 할 필요가 없습니다. 단지 첫 번째 줄을 읽고 번역하고 실행합니다.
웹 개발자가 작성한 코드를 빨리 실행해야 하는 것은 중요하며, 바로 이점 때문에 인터프리터는 자바스크립트와 같은 언어와 잘 맞는 것 같습니다.
이점이 바로 초기에 브라우저가 자바스크립트 인터프리터를 사용한 이유입니다.
그러나 인터프리터 사용의 단점은 동일한 코드를 두 번 이상 실행할 때 발생합니다. 예를 들어, 루프 안에서 똑같은 번역을 몇 번이고 반복해야 합니다.
Compiler pros and cons
컴파일러는 인터프리터와는 반대되는 절충점을 가지고 있습니다.
실행 전에 컴파일 단계를 거쳐야 하기 때문에 시작하는데 조금 더 시간이 걸리지만, 루프를 통과할 때마다 번역을 반복할 필요가 없기 때문에 코드가 더 빨리 실행됩니다.
또 다른 차이점은 컴파일러가 인터프리터 보다 코드를 살피고 편집할 수 있는 시간이 더 많기 때문에 코드를 더 빨리 실행하는데 유리합니다. 이 편집 과정을 최적화라고 부릅니다.
인터프리터는 런타임에 변환 작업을 수행해야 하기 때문에 이러한 최적화를 파악하는데 많은 시간을 할애하기 어렵습니다.
Just-in-time compilers: the best of both worlds
인터프리터에서 루프 코드가 통과할 때마다 계속해서 다시 번역해야 하는 비 효율성을 제거하기 위해 브라우저는 컴파일러를 혼합하기 시작했습니다.
브라우저마다 약간 다른 방식으로 동작하지만, 기본적인 아이디어는 동일합니다. 엔진에 모니터(프로파일러라고 불리는)를 추가하여, 실행 중인 코드를 감시하고 실행 횟수 및 사용된 타입(유형)을 기록합니다.
처음 실행될 때에는 모든 코드가 인터프리터에서 실행됩니다.
동일한 코드 라인이 몇 번 반복되면 해당 코드 세그먼트를 warm이라고 부릅니다. 그리고 더 많이 실행되면, 그것을 hot이라고 부릅니다.
Baseline compiler
함수가 달궈지기 시작하면 JIT가 컴파일을 시작합니다. 그런 다음 해당 컴파일을 저장합니다.
함수의 각 행은 “스텁"으로 컴파일됩니다. 스텁은 행 번호와 변수 유형별로 색인화됩니다 (이점이 중요한 이유에 대해서는 나중에 설명하겠습니다). 모니터가 코드의 실행을 주시하다가 동일한 코드가 동일한 변수 타입으로 반복 실행되는 것을 확인하면 컴파일 된 버전을 꺼냅니다.
이 방법이 속도를 향상시키는 데 도움을 주긴 하지만 이미 말했듯이, 컴파일러가 할 수 있는 것이 더 있습니다. 시간이 좀 더 걸릴 수는 있겠지만 가장 효율적인 방법을 찾는 것… 바로 최적화를 수행하는 일입니다.
Baseline 컴파일러는 이러한 최적화 중 일부 만 수행합니다 (아래 예제를 예로 들겠습니다). 코드의 실행 시간이 너무 길어지는 것을 원치 않기 때문에 최적화 작업에 많은 시간을 들일 필요가 없습니다.
그러나 코드가 정말 뜨겁다면 (동일 코드가 빈번하게 실행되고 있다면 - 역자 주) 최적화를 위해 추가적인 시간을 들일 가치가 있습니다.
Optimizing compiler
코드의 일부가 매우 뜨거워지면 모니터는 이를 최적화 컴파일러에 전송합니다. 이렇게 하면 함수의 또 다른, 그리고 더 빠른 버전이 생성되고 저장됩니다.
코드의 더 빠른 버전을 만들기 위해, 최적화 컴파일러는 몇 가지 가정을 해야 합니다.
예를 들어, 특정 생성자에 의해 생성된 모든 객체가 동일한 속성을 가지고 있고, 동일한 순서로 추가된 경우에는 이를 근거로 실행 절차를 좀 더 생략할 수 있습니다.
최적화 컴파일러는 모니터가 수집한 정보를 사용하여 코드 실행을 관찰하고 판단합니다. 만약 반복문 코드의 결과가 직전까지 참(true)이었다면, 계속해서 참(true)일 것이라고 추측합니다.
물론 자바스크립트에는 절대적인 보장이 없습니다. 99개의 객체가 모두 같은 모양을 가지고 있다가도 100번째 객체에서 그 속성을 잃어버릴 수 있습니다.
따라서 컴파일된 코드를 실행하기 전에 그 가정이 유효한지 확인할 필요가 있습니다. 만약 유효하지 않다면, JIT는 추측이 틀렸다고 가정하고 최적화된 코드를 폐기합니다.
그러면, 인터프리터(코드를 최초에 실행할 때처럼) 또는 baseline 컴파일 버전에서 실행됩니다. 이 과정을 (최적화 해제) deoptimization (또는 bailing out) 이라고 부릅니다.
일반적으로 최적화 컴파일러가 만들어낸 코드는 빠르게 실행되지만 때로는 예상치 못한 성능 문제를 일으킬 수 있습니다. 최적화되었다가 해제(deoptimized) 된 코드가 있다면 baseline 컴파일 버전을 실행하는 것보다도 느리게 실행될 수 있습니다.
대부분의 브라우저에는 이러한 최적화/최적화 해제(deoptimized) 주기에서 벗어날 수 있는 제한이 추가되어 있습니다. 만약 JIT가 최적화 시도를 10회 이상 시도하였고, 그것을 폐기해야 한다면, 최적화 시도를 멈추게 됩니다.
An example optimization: Type specialization
많은 종류의 최적화 방법이 있지만 그중 한가지 유형을 살펴보면 최적화를 수행하는 방식에 대한 느낌을 얻을 수 있습니다. 컴파일러를 최적화하는 방법 중 가장 많이 알려진 방식은 타입 특수화(type specialization)에서 유래된 방식입니다.
자바스크립트와 같은 동적 타입 시스템의 런타임은 약간의 추가 작업을 필요로 합니다. 예를 들어, 다음의 코드를 살펴보세요.
function arraySum(arr) {
var sum = 0;
for (var i = 0; i < arr.length; i++) {
sum += arr[i];
}
}
루프 안에서 += 스텝은 하나의 연산만 수행하는 것처럼 단순해 보이지만 동적 타이핑 때문에 예상보다 많은 단계를 필요로 합니다.
arr 변수가 100 개의 정수로 이루어진 배열이라고 가정합시다. 일단 코드가 워밍업 되면 baseline 컴파일러는 함수의 각 오퍼레이션에 대한 스텁을 생성합니다. 따라서 정수의 가산 연산(+=)을 처리하는 sum += arr [i]에 대한 스텁이 만들어집니다.
그러나 sum과 arr[i]를 정수로 보장하기 어렵습니다. 자바스크립트의 타입은 동적이기 때문에 이후의 루프에서 arr[i] 값이 문자열이 될 가능성이 있습니다. 정수의 더하기 연산과 문자열 연결(concatenation)은 다른 오퍼레이션이기 때문에 둘은, 다른 기계 코드로 컴파일 됩니다.
JIT가 이것을 처리하는 방법은 여러 개의 baseline 스텁을 컴파일하는 것입니다. 코드 조각이 단일 형태(monomorphic)인 경우 (즉, 항상 같은 타입으로 호출되는 경우) 하나의 스텁만 만들어집니다. 만약 다형성(하나의 경로에서 또 다른 코드를 다양한 타입으로 호출하는)인 경우, 해당 연산을 통해 나온 각각의 타입 조합들의 스텁이 만들어집니다.
이는 JIT가 스텁을 선택하기 전에 많은 질문을 던저야 한다는 것을 의미합니다.
각 코드 라인은 baseline 컴파일러에 자체 스텁 집합이 있기 때문에 JIT는 코드 라인이 실행될 때마다 타입을 계속 확인해야 합니다. 그래서 루프를 반복할 때마다, 동일한 질문을 해야만 합니다.
만약 JIT가 이러한 검사를 반복할 필요가 없다면 코드는 훨씬 더 빨리 실행될 수 있습니다. 이것이 최적화 컴파일러가 하는 일 중 하나입니다.
최적화 컴파일러에서는 전체 함수가 모두 컴파일 됩니다. 타입 검사는 루프 전에 발생하도록 이동합니다.
일부 JIT는 이를 좀 더 최적화합니다. 예를 들어, Firefox에는 정수만 포함하는 배열에 대한 특별한 명세가 있습니다. arr 변수가 이러한 배열 중 하나인 경우 JIT는 arr[i]가 정수인지 확인할 필요가 없습니다. 즉, 루프로 진입하기 전에 JIT는 전체 타입 검사를 수행할 수 있음을 의미합니다.
Conclusion
코드를 실행할 때 코드를 모니터링하고 최적화하기 위해 핫 코드의 경로를 전송하여 최적화함으로써 자바스크립트를 보다 빠르게 실행합니다. 이런 결과로 대부분의 자바스크립트 응용 프로그램에서 성능이 몇 배 향상되었습니다.
그러나 이러한 개선에도 불구하고 자바스크립트의 성능은 여전히 예측할 수 없습니다. 그래서 JIT는 보다 빠른 성능을 위해 런타임에 약간의 오버헤드를 추가하였습니다.
- 최적화(optimization) 최적화 해제(deoptimization)
- bailouts 발생 시 모니터 기록(bookkeeping)과 복구 정보에 이용되는 메모리
- baseline 컴파일러와 최적화 컴파일러에 저장된 함수 버전을 사용하는데 쓰이는 메모리
아직 개선할 여지가 남아 있습니다. 오버헤드를 제거하여 성능을 예측 가능하도록 만들 수 있습니다. 이것이 웹어셈블리가 하는 것 중 하나입니다.
다음 게시물에서는 어셈블리와 컴파일러가 어떻게 작동하는지에 대해 자세히 설명할 것입니다.
About Lin Clark
Lin works in Advanced Development at Mozilla, with a focus on Rust and WebAssembly.