V8은 구글에서 C++으로 개발한 고성능 JavaScript 엔진으로 JavaScript 코드를 바이트 코드로 컴파일하고 실행하며 히든 클래스, 인라인 캐싱과 같은 최적화 기법을 사용하여 동적 타이핑 언어인 자바스크립트의 특성에도 불구하고 높은 성능을 제공하여 Chrome, Node,js, Electron 등 많은 곳에서 사용되고 있다
JIT(Just-In-Time)은 프로그램이 실행되는 런타임 시점에 기계어로 번역하는 컴파일 기법이다. 인터프리터와 컴파일러를 혼합하여 둘의 장점을 취한 기법으로 볼 수 있다.
기존의 컴파일 방식은 모든 코드를 번역 해야해서 컴파일 시간이 길어지고 메모리 사용량도 커질 수 있는데 JIT 컴파일러는 코드가 실제로 실행될 때 필요한 부분만을 컴파일하여 컴파일 시간을 줄이고 코드의 변경에 유연하게 대응이 가능하며 실행 중에 발생하는 정보들을 바탕으로 실행 환경에 특화된 최적화를 통해 성능 향상이 이루어진다
Chrome, Firefox, Safari를 비롯해 모던 브라우저 자바스크립트 엔진은 대부분 JIT 컴파일 방식을 채택하여 자바스크립트 코드를 해석하고 실행한다
초창기 자바스크립트는 HTML을 동적으로 처리하는 것이 최우선 이였기때문에 가벼운 실행을 위해 인터프리터 방식을 기반으로 자바스크립트 엔진을 개발하였다.
하지만 이런 인터프리터 방식은 메모리 관점에서는 가볍지만 실행속도, 최적화 등 고성능 프로그램을 실행하기에는 한계가 있었고 그러던중 구글은 Google Maps 같은 대규모 웹 어플리케이션 서비스를 제공하기 위해서 JIT컴파일러 방식을 도입한 고성능의 자바스크립트 엔진 V8을 개발하였다
V8의 핵심 구조는 아래의 그림으로 설명할 수 있는데 자바스크립트로 작성된 소스코드를 Parser
에게 전달하고 Parser
는 자바스크립트를 분석하여 AST
를 생성한다.
그러면 Ignition
이라는 인터프리터는 AST
를 기반으로 Bytecode
를 생성하고 실제 자바스크립트가 실행된다
이 과정에서 성능 향상을 위해 최적화 컴파일러 Turbofan
이 사용되어 Bytecode
를 분석하고 자주 사용되는 코드(hot)
에 대해서 더 효율적으로 최적화 하며, V8 9.1버전 부터 도입된 비최적화 컴파일러 Sparkplug
을 통해서 Ignition
이 생성한 Bytecode
를 빠르게 컴파일하면서 Ignition
과 TurboFan
사이의 너무 큰 성능차로 인한 각극을 줄이는 중간단계의 컴파일러를 도입하여 실제 전반적인 성능이 5~15% 향상 되었다고 한다
V8엔진 동작 방식 medium
V8엔진은 처음 자바스크립트 소스코드를 Parser
에게 보낸다 Parser
는 전달받은 자바스크립트 코드를 어휘분석(Lexical Analysis)
이라는 과정을 통해서 토큰으로 분해되는데 이 과정에서 소스코드가 키워드, 식별자, 연산자 및 구분자로 분해된다. 이후 Parser
는 이렇게 만들어진 토큰을 구문분석(Syntax Analysis)
이 라는 과정을 진행하며 이때 문법에러가 있으면 Syntax Error
가 발생하게된다. 만약 여기까지 문법에러가 없다면 이렇게 만들어진 토큰으로 트리 형태를 만드는데 이게 바로 추상구문트리(Abstract Syntax Tree, AST)
이다
function square(n) {
return n * n;
}
{
type: "FunctionDeclaration",
id: {
type: "Identifier",
name: "square",
},
params: [
{
type: "Identifier",
name: "n",
},
],
body: {
type: "BlockStatement",
body: [
{
type: "ReturnStatement",
argument: {
type: "BinaryExpression",
operator: "*",
left: {
type: "Identifier",
name: "n",
},
right: {
type: "Identifier",
name: "n",
},
},
},
],
},
};
Ignition
이란 전달받은 AST
를 바탕으로 바이트 코드를 생성하는 V8의 인터프리터이다. 바이트 코드란 기계어를 추상화한 코드로 고수준 언어와 기계어 사이의 중간 형태의 코드이다. 이렇게 변환된 바이트코드가 실행되기 바로 전에 실행 컨텍스트가 생성되고 이 시점에 호이스팅, this바인딩 등이 일어난다. 또한 Ignition
은 바이트 코드를 실행하면서 프로파일링 및 피드백 데이터를 수집하는데 이런 수집한 정보를 바탕으로 코드가 반복되어 실행되는지 등의 여러 조건에 따라 TurboFan
컴파일 또는 SparkPlug
컴파일이 결정된다
Ignition
이 생성한 바이트 코드를 Ignition
이 수집한 데이터를 기반으로 복잡하고 정교하게 기계어 코드로 변환하는 최적화 컴파일러이다. TurboFan
이 사용하는 최적화 기법으로는 대표적으로 히든클래스, 인라인캐싱이 존재하며 필요에따라 역최적화를 진행하기도 한다
Ignition
과 TurboFan
사이의 중간단계에 위치한 비최적화 컴파일러이다. TurboFan
과 마찬가지로 Ignition
이 생성한 바이트 코드를 바탕으로 기계어 코드를 만들어 낸다
🔎SparkPlug가 도입된 이유
V8 5.9 버전이후로는
Ignition
과TurboFan
만 존재했는데 9.1버전부터 비최적화 컴파일러SparkPlug
가 도입되게 되었는데 이유가 뭘까?
최적화 컴파일러TurboFan
은 시간은 조금 걸려도 최적의 성능으로 코드 실행이 가능했지만Ignition
과TurboFan
사이의 너무 큰 성능차로 인한 각극을 줄이고자 빠르고 단순한 비최적화 컴파일러SparkPlug
를 도입하게된다SparkPlug
는TurboFan
과 같이AST
가 아닌 바이트코드를 기계어로 변환하며TurboFan
처럼 최적화를 하지않아서 빠르게 동작이 가능하다
앞서 TurboFan
에서 최적화 기법으로 히든클래스와 인라인캐싱이 존재한다고 했는데 이 두 가지에 대해서 알아보자
정적 타이핑 언어는 컴파일 시점에 프로퍼티에 대한 정보를 결정하고 오프셋을 결정하여 런타임에 객체의 프로퍼티를 빠르게 참조할 수 있지만 동적 타이핑언어는 객체의 프로퍼티가 동적으로 변경될 수 있기 때문에 각 프로퍼티에 대한 정보를 메모리에 저장하고 프로퍼티를 읽어야할때마다 동적탐색 해야 한다. 이는 자바스크립트의 유연성을 높여주지만 메모리 사용량과 성능에 영향을 줄 수 있다.
V8에서는 이러한 동적 타이핑언어의 객체에 대한 성능 저하를 최소화하기 위한 장치로 히든 클래스라는 내부 구조를 만들어서 동적탐색을 회피하고 있다.
쉽게말해 히든 클래스를 만들어서 동일한 히든클래스를 가진객체는 동일한 구조와 프로퍼티 배치를 공유하며, 이를 통해 같은 구조의 객체가 여러 번 생성되어도 쓸데없이 객체를 늘리지 않고 히든 클래스를 공유하면서 메모리 효율적으로 오프셋 관리를 하는 것
const obj = {
x: 1,
y: 1,
};
이런 간단한 객체 obj
가 존재한다고 가정할 때 객체의 각 프로퍼티 x,y
는 메모리에 저장되는데 그 위치는 히든클래스(Shape)
에 정의된 offset에 따라 결정된다. 아래의 그림에서 x는 offset이 12, y는 offset이 16인 곳에 각각 저장되어 있으며, 이는 각각의 오프셋만큼을 포인터를 이동시켜 obj의 x,y를 찾도록 알려준다. 이처럼 히든클래스는 객체의 구조와 프로퍼티에 대한 정보를 담고 있으며, V8엔진은 이 정보를 활용하여 프로퍼티를 찾는 과정을 수행한다
이러한 히든클래스는 동일한 구조의 객체를 생성할 때 비로소 빛을 발하는데
동일한 구조의 객체를 여러 번 생성해도 히든 클래스를 통해 각 객체의 속성 구조와 오프셋을 공유하면서도, 각 객체는 독립적인 메모리 공간에 속성값을 저장하므로, 서로 다른 속성값을 가질 수 있고 이는 히든 클래스를 쓸데없이 늘리지 않으면서 오프셋을 효율적으로 사용해 속성 접근방법을 높이는 최적화 방법이다
const obj1 = {
x: 1,
y: 1,
};
const obj2 = {
x: 3,
y: 4,
};
const obj3 = {
x: 5,
y: 6,
};
🚩주의해야 할 점은 동일한 구조의 객체 라고해서 항상 동일한 히든 클래스를 갖는 것은 아닌데 아래의 코드에서
obj1
,obj2
,obj3
은 같은 구조를 갖지만 결과적으로 셋다 다른 히든클래스를 갖는다
const obj1 = {
x: 1,
y: 1,
};
const obj2 = {};
obj2.x = 1;
obj2.y = 1;
function Point(x, y) {
this.x = x;
this.y = y;
}
const obj3 = new Point(1, 1);
동적으로 속성을 추가하게 되는경우 추가할 때마다 새로운 히든클래스가 생성되며 새로 만들어지는 히든 클래스에서는 속성을 추가하기 전의 기존의 히든클래스를 참조하는 transition
이라는 속성을 갖게되어 히든클래스들은 transition
체이닝을 통해 이전 히든클래스의 참조를 하게된다. 아래의 그림을 보면 같은 구조이지만 위에서 본 히든클래스의 구조랑 다른 구조를 갖는 것을 알 수 있다
따라서 결과적으로 빈객체에서 동적으로 할당된 객체와 초기값이 할당된 객체는 분명 같은 속성을 갖는 객체일지라도 히든클래스의 형태가 달라진다. 그래서 특별한 이유가 있는게 아니라면 동적으로 속성을 추가하는 것보다 한 번에 객체를 생성하는 것이 히든 클래스의 수도 줄이고 transition
체이닝도 단축하여 최적화 이점을 가져갈 수 있다.
인라인 캐싱이란 자바스크립트에서 반복적으로 객체의 프로퍼티에 접근하는 작업을 최적화하는 기법이다. 자바스크립트에서 객체의 프로퍼티에 접근하려면 해당 객체의 히든클래스를 통해 프로퍼티의 위치를 찾아야 한다.
하지만 반복적인 접근이 발생할 경우, 매번 히든클래스로부터 프로퍼티의 위치를 다시 찾는 것은 비효율적인데 인라인 캐싱은 이러한 비효율성을 개선하기 위해 히든클래스로부터 알아낸 프로퍼티의 인덱스 정보를 캐싱하는데 같은 히든클래스를 가진 객체에 접근할 때에는 프로퍼티의 위치를 다시 찾는 과정을 생략하고, 캐싱된 인덱스를 그대로 사용하여 접근 속도를 향상시킨다
즉, 인라인 캐싱 이란 객체의 프로퍼티에 접근하는 과정에서 히든클래스로부터 알아낸 프로퍼티의 인덱스를 캐싱하여 반복적인 접근을 최적화하는 기법이다
위 그림에서 getX({x: 'a'})
가 실행될 때 x
라는 프로퍼티를 갖는 히든클래스가 생성되어 해당 히든클래스와 오프셋정보가 캐싱된다. 이후에 getX({x: 'b'})
가 실행될 때 이전에 캐싱된 {x: 'a'}
와 동일한 히든클래스를 갖기 때문에 히든 클래스의 프로퍼티 조회를 하지 않고 바로 캐싱된 offset을 통해 값을 가져올 수 있게된다. 따라서 반복적인 접근에서 히든클래스와 오프셋을 재활용하여 접근속도를 향상시킬 수 있게된다.
가능한 같은 구조, 같은 순서의 객체로 히든 클래스를 공유하도록 해서 불필요한 히든 클래스를 최소화 하기
객체 초기화 이후 동적으로 프로퍼티를 추가하거나 제거하는 작업은 히든클래스 구조가 변경되기 때문에 가능하면 피하기
함수 호출 시 인라인 캐싱 최적화를 위해 동일한 객체유형 사용하기
이러한 것들을 지키지 않아도 성능상의 큰 이슈가 발생하는 것은 아니지만 최적화 기법을 적용함으로써 V8엔진 내부에서 추가적인 최적화 이점을 얻을 수 있기 때문에 코드를 작성할 때 이러한 사항들을 인지하고 있어야한다.
라인 V8의 히든 클래스 이야기
V8 기본 자바스크립트 컴파일러 Sparkplug
V8 function optimization
구글 V8엔진 살펴보기
JavaScript engine fundamentals: Shapes and Inline Caches