JIT 컴파일러
JIT 컴파일러
서론
Typescript의 타입 추론 속도를 개선하던 중 타입스크립트는 타입 추론 과정 즉 컴파일과정은 어떻게 진행되는 걸까? 하는 궁금증이 생겼습니다. JS는 JIT 컴파일러를 사용한다는 말만 주구장창 들었는데 JIT은 어떤 동작원리를 가지고 있을까? 하여 공부해보게 되었습니다.
브라우저의 언어
우리는 자바스크립트를 통해 브라우저와 대화합니다. 브라우저와의 대화를 하기위해서는 어떤 과정이 필요할까요?
우리는 인간이므로 우리의 언어를 사용합니다. 웹 개발은 주로 JS를 이용합니다. 안타깝게도 브라우저는 JS를 알아듣지 못하니 우린 브라우저와 소통을 하게 해주는 통역사가 필요합니다.
전통적인 통역 방식
유명한 통역방식이 있습니다. 바로 인터프리터와 컴파일러입니다. JIT 컴파일러의 경우 이 두가지 특징을 모두 가지므로 간단하게 알아보도록 하겠습니다.
인터프리터(Interpreter → 통역사)
인터프리터는 소스 코드를 한 줄씩 저수준 즉 바이트코드로 번역하여 각 줄을 즉시 처리합니다.
장점
- 실행 준비가 빠릅니다. 첫 번째 줄부터 바로 번역하고 코드를 실행합니다.
- 바로 실행하기 때문에 오류를 즉시 감지하여 디버깅이 더 쉬워지는 이점을 제공합니다.
단점
- 코드를 한 줄씩 처리하므로 번역하는 데 시간이 더 오래 걸립니다.
- 중복 번역: 동일한 코드를 여러 번 실행해야 할 때, 매번 동일한 번역 과정을 반복해야 한다는 점이 단점입니다.
컴파일러(Compiler → 번역가)
인터프리터와 달리 컴파일러는 전체 프로그램을 저수준 언어로 한 번에 번역합니다. 한 번의 작업으로 전체 프로그램을 컴파일합니다.
장점
- 초기에 코드를 한 번만 번역하기 때문에, 루프(loop)처럼 동일한 코드가 반복 실행되는 경우 더 빠르게 실행됩니다.
- 코드 번역 중 더 많은 시간을 들여 코드를 분석하고 수정하여 실행 속도를 높일 수 있습니다.(최적화)
단점
- 실행 전에 전체 컴파일 과정을 거쳐야 하기 때문에, 초기 실행 준비에 더 많은 시간이 걸립니다.
- 실시간 디버깅이 불가능합니다.
컴파일 & 인터프리터의 결과물
- 결국 컴파일러와 인터프리터는 인간의 언어를 byte-code로 파싱하는 역할을 합니다.(컴파일러의 경우는 항상 기계어로 직접 파싱하는 경우도 존재합니다.)
통역 과정
- 어휘 분석(Lexical Analysis)
소스 코드를 문자 단위로 읽고, 이를 최소 단위인 토큰으로 분할하는 단계입니다.
Token
- 구문 분석(Syntax Analysis)
토큰 리스트를 기반으로 코드 구조를 이해하고 AST(abstract syntax tree, 추상 구문 트리)로 변환하는 단계입니다.
AST
- 바이트 코드 생성
AST를 기반으로 바이트 코드를 생성합니다.
byte code
JIT 컴파일러 통역 과정
Just-In-Time Compiler (JIT)
JIT은 기본적으로 인터프리터와 컴파일러의 역할을 동시에 수행합니다.
인터프리터의 비효율성, 즉 루프를 반복 실행할 때마다 코드를 재번역해야 하는 문제를 해결하기 위해 브라우저들은 컴파일러를 결합하는 방식을 도입했습니다.
JIT은 3단계의 과정을 거칩니다.
- 프로파일러
- 기본 컴파일러
- 최적화 컴파일러
프로파일러
- 인터프리터를 통해 코드 실행
- 프로파일러가 코드 실행 패턴 관찰
- Warm 코드: 동일한 코드가 몇 번 실행되면, 그 코드 부분을 “warm” 상태라고 부릅니다.
- Hot 코드: 동일한 코드가 매우 자주 실행되면, 그 코드 부분을 “hot” 상태라고 부릅니다.
이는 코드의 실행 빈도에 따라 최적화를 적용하는 기준으로 사용됩니다.
기본 컴파일러(Baseline Compiler)
- Warm Code를 컴파일합니다.
- 컴파일 결과 저장: 컴파일된 코드는 저장되어 이후 반복 실행 시 빠르게 실행될 수 있습니다.(기계어)
이 과정은 코드 실행 성능을 개선하기 위해 자주 실행되는 함수를 효율적으로 최적화하는 방식입니다.
최적화
- 라인별 스텁 생성: “스텁(stub)“이라는 단위로 컴파일됩니다.
- 스텁들은 줄 번호와 변수 타입을 기준으로 인덱싱됩니다.
- 동일한 코드가 동일한 변수 타입으로 다시 실행되면, 프로파일러는 컴파일된 스텁을 가져와 사용합니다.
- 결과 : 실행 속도를 크게 높입니다.
이러한 방식은 실행 성능을 극대화하면서도 초기 실행 시간을 최소화하는 데 도움을 줍니다.
최적화 컴파일러(Optimizing Compiler)
- Hot Code를 컴파일합니다.
- 최적화 컴파일러는 해당 코드를 더욱 빠르고 효율적인 버전을 생성합니다.
- 최적화 컴파일러에서는 함수 전체를 한꺼번에 컴파일합니다
더 빠른 버전의 코드를 만들기 위해 최적화 컴파일러는 몇 가지 가정을 해야 합니다.
- 동일한 Shape 사용
- 객체가 가진 속성과 속성의 추가 순서에 따라 객체의 구조적 형태(Shape)가 정의됩니다.
- JavaScript 엔진은 같은 구조를 가진 객체들을 효율적으로 처리하기 위해 동일한 Shape을 공유하도록 최적화합니다.
- 같은 Shape을 가진 객체는 메모리 공간을 공유합니다.
Shape 예제
- 모니터링과 추론:
JIT 컴파일러는 코드를 모니터링합니다. 반복문 등을 통해 수집된 정보를 바탕으로 패턴을 추론합니다.
과거 실행에서 특정 조건이 항상 참이었다면, 이후에도 그럴 것이라 가정하고 최적화된 코드를 생성합니다.
- 검증과 코드 폐기
JavaScript의 동적 특성상 99개의 객체가 모두 같은 형태를 가졌더라도, 100번째 객체에서 속성이 누락되거나 형태가 달라질 수 있습니다. 이 경우, 최적화된 코드는 실행 전에 가정을 검증합니다.
가정이 유효하면 최적화된 코드가 실행되지만, 그렇지 않으면 JIT 컴파일러는 해당 코드를 “잘못된 최적화”로 간주하고 폐기합니다. 이 경우 이전 버전으로 돌아갑니다.(이를 deoptimization 또는 bailout이라고 함)
즉, JIT 컴파일러는 동일한 패턴을 최대한 활용하여 성능을 높이려고 하지만, 언제든 잘못된 가정이 발생할 수 있다는 점에서 신중하게 동작합니다.
문제점
만약 코드가 최적화와 디옵티마이제이션을 반복한다면, 오히려 기본 컴파일된 버전을 실행하는 것보다 더 느려질 수 있습니다.
대부분의 브라우저는 이러한 최적화/디옵티마이제이션 사이클에서 벗어나는 한계를 추가했습니다. 예를 들어, JIT 컴파일러가 10번 이상 최적화를 시도했지만 매번 폐기해야 하는 상황이 반복된다면, 더 이상 최적화를 시도하지 않도록 설계되었습니다.
deoptimization 기준
- 객체 Shape의 변경
- 타입 변경
- 프로토타입의 변경
- 함수 인라인 취소
함수
인라인화
예상치 못한 호출로 인라인화가 취소될 경우
- eval 또는 with의 사용
eval과 with는 런타임에 변수나 스코프를 동적으로 변경하기 때문에, 최적화된 코드를 사용할 수 없게 만듭니다.
- 함수의 인자 개수 변화
- 배열의 비일관성
- try-catch 블록
try-catch 블록은 예외 처리로 인해 런타임 동작이 복잡해지므로 최적화된 경로에서 제외됩니다.
최적화 유지 방법
1. 객체의 Shape을 고정
2. 일관된 타입 사용
3. 배열의 타입 일관성 유지
4. eval과 with 사용 금지
후기
예전 프로젝트의 경우 Vue로 개발했음에도 불구하고 현재 React프로젝트보다 성능이 떨어진다고 느끼고 있었습니다. 그 이유에 대해서는 명확하지 않았습니다. Vue의 경우 컴파일러가 따로 존재하여 오히려 더 성능이 좋아야 하지 않을까? 하는 의문도 있었구요.
그러나 지금 JIT 컴파일러를 공부해보니 그 이유가 어느정도 파악이 되는 것 같습니다. 현재는 단순히 “정적인 것이 성능에 좋다”라고 알고있었고 그래서 최대한 정적인 코드를 작성하며 런타임에서 객체를 변형하거나 타입이 변경되는 것을 하지 않고 있습니다.
이전에 Vue로 개발할 때에는 런타입에서 TS를 엄격하게 사용하지 않기도 했고 객체의 속성을 런타임에 변경하는 등의 행동을 했던 기억이 있습니다. 현재 개발하는 코드가 성능이 더 좋다는 것은 알았지만 우연히 명확한 이유에 대해서 이렇게 알게되어 기쁘기도 하고 공부의 부족함도 많이 느끼게 됩니다.
V8 →
Webkit→
Spider →
Edge →
다음
⇒ 각 브라우저 엔진
V8 엔진의 동작 원리
JIT(Just-In-Time Compiler)의 동작 원리를 이해했으니, 이제 V8 엔진의 작동 방식을 살펴보겠습니다.
Google Chrome과 Mozilla Firefox 같은 다양한 브라우저는 각자의 선호하는 JavaScript 엔진을 사용합니다. 다음은 주요 브라우저에서 사용하는 JavaScript 엔진의 이름입니다:
- Google Chrome: V8 Engine
- Mozilla Firefox: SpiderMonkey
- Safari: JavaScriptCore
- Microsoft Edge: Chakra
이 중에서 V8 엔진은 현재 가장 인기 있고 강력한 엔진으로 평가받고 있습니다. V8 엔진의 작동 방식을 이해하면 다른 엔진의 작동 원리를 이해하는 데도 도움이 됩니다. V8 엔진이 어떻게 작동하는지 파악하려면 몇 가지 선행 개념을 이해해야 합니다.
1. 파서(Parser)
파서는 JavaScript 코드를 토큰(token)으로 나눕니다. 토큰화(tokenization)는 최종적으로 **추상 구문 트리(AST, Abstract Syntax Tree)**를 생성하기 위해 필요합니다.
2. 추상 구문 트리(Abstract Syntax Tree, AST)
AST는 JavaScript 프로그램을 계층적으로 표현한 구조로, 토큰 리스트로부터 생성됩니다. 인터프리터는 AST를 받아 이를 기반으로 실행 가능한 **바이트코드(bytecode)**를 생성합니다.
우리는 이미 인터프리터, 프로파일러, 컴파일러와 같은 개념을 앞서 배운 바 있습니다. 이와 같은 개념들은 V8 엔진의 작동 방식에 중요한 역할을 합니다.
V8 엔진은 JavaScript 코드를 효율적으로 실행하기 위해 AST를 생성한 후, 이를 바이트코드로 변환하고 최적화된 머신 코드로 컴파일하는 과정을 통해 프로그램 성능을 극대화합니다.
V8 JIT: 인터프리터와 컴파일러의 조합
JIT(Just-In-Time)은 인터프리터와 컴파일러의 장점을 결합한 기술입니다. 인터프리터는 코드를 자연스럽게 실행하는 반면, 컴파일러는 자주 실행되는 코드 조각을 매우 최적화된 코드로 변환합니다. 이러한 최적화는 V8 엔진의 효율성과 인기를 높이는 데 기여합니다.
JavaScript 프로그램이 파서(parser)에 도달하면, 토큰화(tokenization) 과정에서 생성된 토큰으로부터 노드를 생성합니다. 이 노드들은 Abstract Syntax Tree(AST, 추상 구문 트리)를 만드는 데 사용되며, 이후 인터프리터에 의해 바이트코드로 변환됩니다. V8 엔진의 인터프리터는 Ignition이라 불립니다. Ignition은 레지스터를 메모리로 사용하여 바이트코드를 실행합니다. V8 엔진은 객체마다 구조를 설명하는 Shape를 생성하여 성능을 최적화합니다. 이러한 최적화는 인라인 캐싱과 같은 추가적인 최적화 작업을 가능하게 합니다.
위 설명에는 명시적으로 언급되진 않았지만, 프로파일러는 백그라운드에서 동작하며 “핫 코드(hot code)” 부분을 식별합니다. 이렇게 식별된 핫 코드는 V8의 JIT 컴파일러인 Turbofan으로 전달됩니다. Turbofan은 이 코드들을 특정 아키텍처에 최적화된 머신 코드로 변환하여 최상의 성능을 제공합니다.
요약
1. 인터프리터는 바이트코드를 실행하며, 이를 Ignition이 처리합니다.
2. 프로파일러는 바이트코드 중 자주 실행되는 “핫 코드”를 식별합니다.
3. Turbofan은 이 핫 코드를 최적화된 머신 코드로 변환하여 대상 아키텍처에서 최적의 성능을 발휘하도록 합니다.
A crash course in just-in-time (JIT) compilers
브라우저에서 JavaScript가 실행되는 방식
개발자로서 웹 페이지에 JavaScript를 추가할 때, 본질적으로 두 가지를 시도하는 것입니다:
1. 목표: 컴퓨터에게 무엇을 해야 할지 지시합니다.
2. 문제: 인간과 컴퓨터는 서로 다른 언어를 사용합니다.
우리는 인간이 사용하는 언어를 사용하지만, 컴퓨터는 기계어를 이해합니다. JavaScript와 같은 고급 프로그래밍 언어가 자연어와는 다르더라도, 본질적으로 인간이 읽고 이해하기 쉽게 설계된 언어입니다.
JavaScript 엔진의 역할은 이 인간 친화적인 언어를 컴퓨터가 이해할 수 있는 언어로 변환하는 것입니다.
이를 영화 **컨택트(Arrival)**와 비교할 수 있습니다. 영화에서 인간과 외계인이 서로 소통하려는 것처럼, JavaScript와 컴퓨터도 서로 다른 언어를 이해하기 위해 다리를 놓는 작업을 합니다.
영화 **컨택트(Arrival)**에서는 인간과 외계인이 단순히 단어를 일대일로 번역하지 않습니다. 두 그룹은 세상을 바라보는 방식 자체가 다릅니다. 이것은 인간과 기계에도 마찬가지로 적용됩니다. (이 부분은 다음 글에서 더 자세히 설명할 예정입니다.)
그렇다면 이 번역은 어떻게 이루어질까요?
프로그래밍에서는 기계어로 번역하는 데 일반적으로 두 가지 방식이 있습니다: 인터프리터와 컴파일러를 사용하는 것입니다.
- 인터프리터를 사용하는 경우, 번역은 주로 한 줄씩 실시간으로 이루어집니다. 프로그램이 실행되면서 코드가 바로바로 해석되고 실행됩니다.
- 반면 컴파일러는 즉석에서 번역하지 않습니다. 미리 번역을 생성하고 기록하는 작업을 거칩니다.
인터프리터의 장점
- 빠른 시작: 인터프리터는 실행 준비가 빠릅니다. 코드를 실행하기 전에 전체 컴파일 과정을 거칠 필요가 없습니다. 첫 번째 줄부터 바로 번역하고 실행을 시작할 수 있습니다.
- 즉각적인 실행: 이 특징 덕분에 JavaScript와 같은 언어에 적합합니다. 웹 개발자는 코드를 빠르게 실행하고 결과를 확인할 수 있어야 하기 때문입니다.
- 초기 브라우저에서의 활용: 이러한 이유로 초기 브라우저들은 JavaScript 인터프리터를 사용했습니다.
인터프리터의 단점
- 중복 번역: 동일한 코드를 여러 번 실행해야 할 때, 매번 동일한 번역 과정을 반복해야 한다는 점이 단점입니다.
예를 들어, **루프(loop)**에서 동일한 코드가 반복 실행되면, 매번 번역 작업을 다시 해야 합니다.
이는 성능 저하로 이어질 수 있습니다.
인터프리터 방식은 즉시 실행의 장점을 제공하지만, 반복적인 작업에서는 비효율적일 수 있습니다.
컴파일러의 장점
- 반복 실행 시 빠른 성능: 컴파일러는 초기에 코드를 한 번만 번역하기 때문에, 루프(loop)처럼 동일한 코드가 반복 실행되는 경우 더 빠르게 실행됩니다.
- 최적화 가능: 컴파일러는 번역 중 더 많은 시간을 들여 코드를 분석하고 수정하여 실행 속도를 높일 수 있습니다. 이러한 수정 작업을 **최적화(optimization)**라고 합니다.
컴파일러의 단점
- 시작 지연: 실행 전에 전체 컴파일 과정을 거쳐야 하기 때문에, 초기 실행 준비에 더 많은 시간이 걸립니다.
컴파일러와 인터프리터의 차이
- 최적화 능력: 인터프리터는 런타임 동안 작업을 수행하므로 번역 단계에서 많은 최적화를 수행할 시간이 부족합니다. 반면, 컴파일러는 코드를 실행하기 전에 최적화를 적용할 수 있는 충분한 시간을 갖습니다.
컴파일러는 초기 준비 시간이 느리지만, 실행 성능과 최적화 측면에서 큰 이점을 제공합니다.
JIT(Just-In-Time) 컴파일러: 두 방식의 장점 결합
인터프리터의 비효율성, 즉 루프를 반복 실행할 때마다 코드를 재번역해야 하는 문제를 해결하기 위해 브라우저들은 컴파일러를 결합하는 방식을 도입했습니다.
기본 아이디어
- 브라우저마다 구현 방식에 약간의 차이는 있지만, 기본적인 원리는 동일합니다.
- JavaScript 엔진에 새로운 구성 요소인 **프로파일러**를 추가했습니다.
이 모니터는 코드가 실행되는 동안 이를 관찰하며, 코드가 얼마나 자주 실행되는지와 어떤 데이터 타입이 사용되는지 기록합니다.
초기 동작
- 처음에는 모든 코드를 인터프리터를 통해 실행합니다.
즉, 모니터는 코드 실행 패턴을 관찰하는 역할을 하며, 코드 자체를 직접 최적화하거나 변환하지 않습니다.
JIT 컴파일러는 인터프리터의 즉시 실행 능력과 컴파일러의 최적화 능력을 결합하여 성능과 효율성을 동시에 제공합니다.
- Warm 코드: 동일한 코드가 몇 번 실행되면, 그 코드 부분을 “warm” 상태라고 부릅니다.
- Hot 코드: 동일한 코드가 매우 자주 실행되면, 그 코드 부분을 “hot” 상태라고 부릅니다.
이는 코드의 실행 빈도에 따라 최적화를 적용하는 기준으로 사용됩니다.
기본 컴파일러(Baseline Compiler)
- 기본 컴파일러의 동작: 함수가 warm 상태에 도달하면, JIT(Just-In-Time) 컴파일러가 해당 코드를 컴파일하도록 처리합니다.
- 컴파일 결과 저장: 컴파일된 코드는 저장되어 이후 반복 실행 시 빠르게 실행될 수 있습니다.
이 과정은 코드 실행 성능을 개선하기 위해 자주 실행되는 함수를 효율적으로 최적화하는 방식입니다.
기본 컴파일러와 최적화
- 라인별 스텁 생성: 함수의 각 줄은 “스텁(stub)“이라는 단위로 컴파일됩니다.
- 이 스텁들은 줄 번호와 변수 타입을 기준으로 인덱싱됩니다.
- 동일한 코드가 동일한 변수 타입으로 다시 실행되면, 모니터는 컴파일된 스텁을 가져와 사용합니다.
- 속도 향상: 이 방식은 실행 속도를 크게 높입니다.
하지만 컴파일러는 더 나아가 작업을 최적화할 여지가 있습니다. 최적화를 통해 코드를 더 효율적으로 실행할 방법을 찾아낼 수 있습니다.
- 기본 컴파일러의 최적화:
- 기본 컴파일러는 일부 최적화를 수행합니다(아래에서 예를 듭니다).
- 그러나 너무 많은 시간을 들이지 않으려고 합니다. 실행을 지연시키지 않기 위해서입니다.
- Hot 코드에 대한 추가 최적화:
- 코드가 hot 상태, 즉 매우 자주 실행되는 경우, 추가 최적화를 수행할 가치가 있습니다.
- 이 경우 더 많은 시간을 들여 최적화 과정을 진행합니다.
이러한 방식은 실행 성능을 극대화하면서도 초기 실행 시간을 최소화하는 데 도움을 줍니다.
최적화 컴파일러(Optimizing Compiler)
- Hot 코드 처리: 코드의 특정 부분이 매우 hot 상태에 도달하면, 모니터는 해당 코드를 **최적화 컴파일러(optimizing compiler)**로 보냅니다.
- 최적화된 버전 생성: 최적화 컴파일러는 해당 함수의 더욱 빠르고 효율적인 버전을 생성합니다.
- 저장: 이렇게 생성된 최적화된 버전도 저장되어 이후 실행에서 활용됩니다.
최적화 컴파일러는 코드 실행 성능을 한층 더 높이기 위해 설계된 컴파일러입니다.
이 과정은 JIT(Just-In-Time) 컴파일러가 JavaScript의 성능을 최적화하기 위해 어떻게 추론적 최적화와 동적 특성을 균형 있게 다루는지를 보여주는 고전적인 예입니다. 아래에 내용을 정리했습니다:
1. 객체 형태(Shape) 가정:
• JavaScript 객체는 본질적으로 동적입니다. 속성을 추가하거나 제거하거나 재정렬할 수 있습니다.
• JIT 컴파일러는 객체의 “형태(shape)“를 기준으로 최적화를 수행합니다. 객체의 형태는 속성의 구조나 배열을 뜻합니다.
2. 형태 기반 최적화:
• 동일한 생성자 함수로 만들어진 객체들이 항상 동일한 속성 이름과 순서를 가지면, JIT 컴파일러는 이를 기반으로 코드를 최적화합니다.
• 이를 통해 특정 속성을 바로 찾거나 접근 속도를 빠르게 하기 위한 내부 데이터를 생성합니다.
3. 모니터링과 추론:
• JIT 컴파일러는 실행 중 코드를 모니터링하여, 반복문 등을 통해 수집된 정보를 바탕으로 특정 패턴(예: 객체의 형태가 변하지 않음)을 추론합니다.
• 과거 실행에서 특정 조건이 항상 참이었다면, 이후에도 그럴 것이라 가정하고 최적화된 코드를 생성합니다.
4. 검증과 코드 폐기:
• 그러나 JavaScript의 동적 특성상 99개의 객체가 모두 같은 형태를 가졌더라도, 100번째 객체에서 속성이 누락되거나 형태가 달라질 수 있습니다.
• 이 경우, 최적화된 코드는 실행 전에 가정을 검증합니다.
• 가정이 유효하면 최적화된 코드가 실행되지만, 그렇지 않으면 JIT 컴파일러는 해당 코드를 “잘못된 최적화”로 간주하고 폐기합니다(이를 deoptimization 또는 bailout이라고 함).
즉, JIT 컴파일러는 동일한 패턴을 최대한 활용하여 성능을 높이려고 하지만, 언제든 잘못된 가정이 발생할 수 있다는 점에서 신중하게 동작합니다.
그런 다음 실행은 다시 인터프리터나 기본 컴파일된 버전으로 돌아갑니다. 이 과정을 디옵티마이제이션(deoptimization) 또는 **탈출(bailing out)**이라고 합니다.
보통 최적화 컴파일러는 코드를 더 빠르게 만들어주지만, 때로는 예기치 못한 성능 문제를 유발할 수도 있습니다. 만약 코드가 최적화와 디옵티마이제이션을 반복한다면, 오히려 기본 컴파일된 버전을 실행하는 것보다 더 느려질 수 있습니다.
대부분의 브라우저는 이러한 최적화/디옵티마이제이션 사이클에서 벗어나는 한계를 추가했습니다. 예를 들어, JIT 컴파일러가 10번 이상 최적화를 시도했지만 매번 폐기해야 하는 상황이 반복된다면, 더 이상 최적화를 시도하지 않도록 설계되었습니다.
최적화의 예: 타입 특화 (Type Specialization)
JavaScript의 동적 타입 시스템은 런타임에 추가 작업을 필요로 합니다. 이를 최적화하기 위해 **타입 특화(Type Specialization)**가 사용됩니다. 아래 코드를 예로 들어 보겠습니다:
코드 분석: sum += arr[i]
루프 내의 += 연산은 단순해 보이지만, JavaScript의 동적 타입 특성 때문에 예상보다 많은 단계가 필요합니다.
1. 기본적인 동작 원리:
• sum과 arr[i]는 특정 타입(예: 숫자, 문자열 등)으로 고정되어 있지 않습니다.
• 동적 타입 시스템 때문에, += 연산은 숫자 덧셈 또는 문자열 연결이 될 수 있습니다.
2. 가정: arr가 정수 100개로 이루어진 배열이라면:
• 코드가 “웜업(warm-up)“되면서, 기본 컴파일러는 sum += arr[i]에 대해 “정수 덧셈”으로 처리하는 기본 스텁(stub)을 생성합니다.
• 이 스텁은 정수 덧셈을 최적화하도록 설계됩니다.
문제: 동적 타입의 특성
sum과 arr[i]가 반드시 정수라는 보장은 없습니다. 이후 반복에서 arr[i]가 문자열이 될 가능성이 있습니다. 이 경우:
• 정수 덧셈과 문자열 연결은 완전히 다른 연산입니다.
• 각 연산은 서로 다른 기계어(machine code)로 컴파일됩니다.
JIT의 처리 방식: 다중 스텁 생성
1. 모노모픽 코드(Monomorphic Code):
• 특정 연산이 항상 동일한 타입으로 호출된다면(예: 모든 arr[i]가 정수), JIT는 하나의 스텁만 생성합니다.
• 예: sum과 arr[i]가 항상 숫자라면 “숫자 덧셈”에 특화된 스텁이 생성됩니다.
2. 폴리모픽 코드(Polymorphic Code):
• 같은 코드가 서로 다른 타입으로 호출된다면, JIT는 각 타입 조합에 대해 별도의 스텁을 생성합니다.
• 예: 일부 arr[i]가 문자열이라면, JIT는 “숫자 덧셈” 스텁과 “문자열 연결” 스텁을 모두 생성합니다.
최적화와 성능 이슈
• 모노모픽 코드일 때 JIT는 최적화된 하나의 스텁으로 빠른 실행이 가능합니다.
• 폴리모픽 코드의 경우, 여러 스텁을 생성하고 런타임에 이를 전환해야 하므로, 추가 비용이 발생합니다.
• 코드가 지나치게 다양하게 호출된다면, 최적화 효과가 떨어지거나 디옵티마이제이션(deoptimization)이 발생할 수 있습니다.
결론
타입 특화는 코드 실행 중 모니터링된 타입 정보를 바탕으로 JIT 컴파일러가 최적화할 수 있도록 돕는 중요한 기법입니다. 하지만 코드가 동적이고 다양한 타입으로 호출될 경우, 최적화와 디옵티마이제이션이 반복되어 성능 문제가 발생할 가능성도 있습니다.
기본 컴파일러에서는 각 코드 줄마다 고유한 스텁 세트를 가지고 있기 때문에, JIT는 코드 줄이 실행될 때마다 매번 타입을 확인해야 합니다. 따라서 루프를 반복할 때마다 동일한 질문을 반복적으로 처리해야 합니다.
코드가 훨씬 더 빠르게 실행되려면 JIT가 이러한 타입 검사를 반복하지 않아야 합니다. 최적화 컴파일러가 바로 이를 해결합니다.
최적화 컴파일러에서는 함수 전체를 한꺼번에 컴파일합니다. 타입 검사는 루프 실행 전에 이동되어 한 번만 수행됩니다.
결론
JIT(Just-In-Time) 컴파일러의 핵심은 바로 이것입니다. JIT는 코드 실행 중에 이를 모니터링하고, 자주 실행되는 코드 경로(hot code path)를 최적화하여 JavaScript를 더 빠르게 실행시킵니다. 이를 통해 대부분의 JavaScript 애플리케이션에서 성능이 크게 향상되었습니다.
하지만 이러한 개선에도 불구하고 JavaScript의 성능은 여전히 예측하기 어려운 경우가 많습니다. 그리고 성능을 높이기 위한 JIT의 과정에서 런타임에 추가적인 부하가 발생합니다. 예를 들면:
• 최적화 및 디옵티마이제이션 과정
• 모니터링과 복구 정보를 관리하는 데 사용하는 메모리 (bailout 발생 시를 대비한 데이터)
• 기본 및 최적화된 버전의 함수를 저장하는 데 필요한 메모리
여기에는 개선의 여지가 있습니다. 이와 같은 오버헤드를 제거하면 성능이 더 예측 가능해질 수 있습니다. 그리고 이것이 WebAssembly가 해결하려는 문제 중 하나입니다.
다음 글에서는 어셈블리(Assembly)와 컴파일러가 이를 어떻게 처리하는지에 대해 더 자세히 설명하겠습니다.
Let's Understand the JavaScript Just In Time Compiler (JIT) and How the V8 engine works
A crash course in just-in-time (JIT) compilers
: https://hacks.mozilla.org/2017/02/a-crash-course-in-just-in-time-jit-compilers/
자바스크립트와 JIT 컴파일

