Notice
Recent Posts
Recent Comments
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Today
Total
관리 메뉴

만재송

[JavaScript] Chrome V8 엔진 본문

프로그래밍/JavaScript, TypeScript

[JavaScript] Chrome V8 엔진

만재송 2018. 3. 11. 20:35

V8 이란?


V8은 독일 구글 개발 센터에서 만들어진 JavaScript 엔진이다. 오픈 소스이고 C++로 작성되었다. 클라이언트쪽(Google Chrome)과 서버쪽(node.js) JavaScript 어플리케이션 모두에 쓰인다.


V8은 웹 브라우저 안에서 실행되는 JavaScript의 성능을 높이기 위해 처음 고안되었다. 속도를 높이기 위해서 V8은 인터프리터 (프로그래밍 언어의 소스 코드를 바로 실행하는 컴퓨터 프로그램 또는 환경)를 이용하는 대신 JavaScript 코드를 좀더 효율적인 기계어 코드로 번역한다. V8은 SpiderMonkey나 Rhino(Mozilla)같은 많은 요즘의 JavaScript 엔진처럼 JIT(Just-In-Time) 컴파일러를 적용하여 JavaScript 코드를 실행할 때 컴파일하여 기계어 코드로 만든다. V8의 가장 큰 차이는 바이트코드 또는 다른 중간 코드를 생성하지 않는 다는 것이다.


중요한것은 V8이 클라이언트든 서버든 최적화된 코드를 생성하기 위해 어떻게 하고있는지를 아는게 중요한것이다. 이글을 통해 V8 엔진이 어떻게 JavaScript를 최적화하는지 이해하는지 알아보자.



인라이닝


첫 번째 최적화는 미리 가능한 많은 코드를 인라이닝(inlining)하는 것이다. 인라이닝이란 호출 지점(함수가 호출된 곳의 코드 위치)을 호출된 함수의 내용으로 바꾸는 과정이다. 이러한 단순한 과정으로 이후의 최적화가 더욱 큰 의미를 가지게 된다.




히든클래스


JavaScript는 프로토타입 기반 언어이다. 클래스라는 것은 없으며 객체는 복제 과정을 통해 성성된다. 또한, JavaScript는 동적언어이기 때문에 객체가 생성된 이후에도 프로퍼티를 쉽게 추가하거나 삭제할 수 있다.


타입과 프로퍼티에 효율적으로 접근하는 것이 V8의 어려운 첫번째 도전과제였다. 대부분의 자바스크립트 인터프리터가 딕셔너리 (키 하나와 값 하나가 연관되어 있으며 키를 통해 연관되는 값을 얻을 수 있는 자료구조)와 유사한 구조를 이용해 객체 속성 값의 위치를 메모리에 저장한다. 이러한 구조 때문에 자바스크립트의 속성 값을 가져오는 것은 자바나 C#에서 보다 계산적으로 더 비싼 행동이 된다. 자바에서는 모든 객체 속성이 컴파일 전에 고정된 객체 레이아웃에 의해 결정되고 런타임에 동적으로 추가되거나 제거될 수 없다. 따라서 속성값(혹은 이들 속성을 가리키는 포인터)은 메모리에 고정된 오프셋을 가진 연속적인 버퍼로 저장될 수 있고 오프셋의 길이는 속성 타입에 따라 쉽게 결정될 수 있다. 하지만 이런 것들이 속성 타입이 동적으로 변할 수 있는 자바스크립트에서는 불가능하다.


딕셔너리를 이용해서 메모리 상에서 객체 속성의 위치를 찾아내는 것은 매우 비효율적인 일이기 때문에 V8에서는 다른 방법을 이용한다. 바로 히든클래스(Hidden Classes)이다. 히든클래스는 자바와 같은 언어에서 사용되는 고정 객체 레이아웃과 유사하게 작동하는데 다만 런타임에 생성된다는 차이점이 있다. Point 함수와 Point 객체 생성으로 예를 들어보자.


function Point(x, y) {
this.x = x;
this.y = y;
}
var p1 = new Point(1, 2);


여기서 new Point (1, 2) 가 실행되면 V8은 c0 이라는 비어있는 히든 클래스를 생성한다.

다음 Point 함수의 첫번째 구문인 this.x = x; 를 싱행하면 V8은 c0을 기반으로 한 c1이라는 두번째 히든 클래스를 생성한다. c1은 x 프로퍼티를 찾을 수 있는 메모리상의 위치에 대한 설명이 포함되어 있다. 아래 그림의 경우 x는 오프셋 0에 저장되는데 이는 연속된 버퍼로서 해당 메모리의 포인트 객체를 읽을 때 첫 번째 오프셋이 x속성에 대응한다는 것을 의미한다. V8은 또한 c0을 클래스전환으로 업데이트하는데 여기에는 만약 x 프로퍼티가 포인트 객체에 추가되면 히든 클래스가 c0에서 c1으로 전환되어야 한다는 내용이 있다. 이제 Point 객체는 c1이 된다.

마지막으로 this.y = y; 구문을 실행하면 아래와 같이 c2가 생성되고 Point 객체는 c2를 가르킨다.

이처럼 V8은 히든클래스를 생성하여 프로퍼티 접근 시간을 줄일 수 있다. 여기서 새로운 객체를 재생성한다고 해도 히든클래스에 의해서 최적화된 코드를 사용할 수 있다.


그러면 이제 여기서 아래와같이 프로퍼티를 추가한다면 어떻게될까?


function Point(x, y) {
this.x = x;
this.y = y;
}
var p1 = new Point(1, 2);
p1.a = 5;
p1.b = 6;
var p2 = new Point(3, 4);
p2.b = 7;
p2.a = 8;


아마 p1과 p2가 같은 히든클래스를 사용할것이라고 생각할수도 있겠지만 실제로는 그렇지 않다. p1에서는 a 프로퍼티가 먼저 추가되고 b가 추가된다. 하지만 p2는 b가 먼저 추가되고 a가 추가된다. 따라서 p1과 p2 의 a,b 의 오프셋이 다르기 때문에 서로 다른 히든 클래스를 사용하게 된다. 즉, 같은 히든클래스를 재사용할 수 없어 최적화가 되지 않는다.


따라서 최적화된 코드를 작성하려면 히든클래스를 재사용할 수 있도록 객체 프로퍼티를 생성자 함수 안에서 초기화 하거나, 동적으로 추가할 때는 항상 같은 순서로 프로퍼티를 초기화한다.



인라인 캐싱


V8가 최적화에 사용하는 또 다른 동적 타입 언어에서 사용할 수 있는 기술은 인라인 캐싱이다. 인라인 캐싱은 같은 메소드에 대한 반복되는 호출은 같은 타입의 객체에 이루어진다는 관찰 결과에 의존한다. 여기서는 인라인 캐싱의 일반적인 개념에 대해서만 다뤄보겠다.


인라인 캐싱은 어떻게 작동할까? V8은 최근 메소드 호출에 파라메터로 전달된 객체 타입의 캐시를 유지하고 이 정보를 이용해 앞으로 파라미터로 넘어올 객체의 타입에 대한 가정을 한다. 만약 V8이 메소드에 전달될 객체 타입에 대한 가정을 잘 할 수 있으면 객체의 프로퍼티에 접근할 방법을 알아내는 과정을 수행하지 않아도 되며 그 대신 객체의 히든 클래스에 대해 이전에 찾아서 저장했던 정보를 사용할 수 있다.


그러면 히든클래스와 인라인 캐싱의 개념은 서로 어떻게 관련 있을까? 특정 객체에 메소드가 호출될 때마다 V8은 특정 프로퍼티에 접근하기 위한 오프셋을 계산하기 위해 해당 객체의 히든클래스를 뒤져봐야 한다. 동일한 히든 클래스의 동일한 메소드에 대해 두 번의 성공적인 호출을 마치고나면 V8은 히든클래스를 찾는 것을 생략하고 단순하게 스스로 해당 객체 포인터에 프로퍼티 오프셋을 더해 놓는다. 이후 해당 메소드에 대한 모든 호출에 대해 V8은 히든클래스는 변하지 않았다고 가정하고 이전에 찾아 두었던 오프셋을 이용해 직접 메모리 주소로 점프한다. 이를 통해 실행 속도는 크게 증가한다.


인라인 캐싱은 같은 타입의 객체가 히든클래스를 공유하는 게 중요한 이유이기도 한다. 만약 타입은 같고 히든 클래스는 다른 두 객체를 만들면 (앞서의 예제처럼) V8은 인라인 캐싱을 사용할 수 없을 것이다. 왜냐하면 두 객체가 같은 타입이기는 해도 각각에 대응하는 히든클래스가 그들의 속성에 서로 다른 오프셋을 할당하기 때문이다.


Numbers


V8은 데이터 타입이 변할 때 값을 효율적으로 나타내는 태그를 사용한다. 사용자가 사용하고 있는 값을 통해서 어떤 number 타입을 다루고 있는지 추론한다. 이러한 데이터 타입은 동적으로 변할 수 있기 때문에, 일단 V8은 추론을 해서 데이터 타입을 결정하고나면, 효율적으로 값을 나타낼 수 있는 태그를 사용한다. 그러나, 이러한 데이터 타입을 나타내는 태그를 변경하는 데에 때때로 비용이 들기 때문에, number 타입을 지속적으로 사용하는 것이 가장 좋습니다. 일반적으로 적합하다면, 31비트 부호있는 정수를 사용하는 것이 최적이다.


// 31비트 부호있는 정수
var i = 42;
// double 타입의 부동 소수점 숫자 데이터
var j = 4.2;


즉, 31비트 부호있는 정수로 나타낼 수 있는 숫자 값 사용을 우선적으로 검토해라.



배열


V8은 배열 처리를 위해 2가지 유형을 사용한다. 이 두가지 유형이 서로 다른 유형이 되지 않는게 중요하다.


선형 타입 : 키 값이 빈틈없이 채워진 경우

해쉬 타입 : 그렇지 않은 경우는 해쉬 테이블에 저장


따라서 인덱스는 0부터 순차적으로 쓰고, 배열크기를 선언하여 쓰지말고 사용하면서 늘려가는 게 좋다. 예를들어 순차적으로 잘쓰고 있었는데, 중간에 요소를 삭제하면 선형 타입에서 해쉬타입으로 넘어가게 된다. 배열이 한 유형에서 다른 유형으로 변경되지 않게 하는 것이 가장 좋다.


즉, 배열의 0번째 인덱스부터 시작하는 연속된 키값을 사용하고, 배열 크기를 선언하지 말고, 배열의 요소를 삭제하지 말자!

그리고 번외편으로 초기화하지 않은 배열의 요소를 불러오지 말자. 아래는 예시이다.


a = new Array();
for (var b = 0; b < 10; b++) {
a[0] = a[0] + b; // x
}
//vs.
a = new Array();
a[0] = 0;
for (var b = 0; b < 10; b++) {
a[0] = a[0] + b; // o. 2배 더 빠르다.
}




결론


마지막으로 잘 최적화되어있고 더 나은 자바스크립트를 작성하는 몇 가지 팁을 소개한다. 물론 위 내용에서 쉽게 이러한 내용을 추출할 수도 있겠지만 편의를 위해 정리를 해둔다.


  • 객체 속성의 순서: 객체 속성을 항상 같은 순서로 초기화해서 히든클래스 및 이후에 생성되는 최적화 코드가 공유될 수 있도록 한다. 
  • 동적 속성: 객체 생성 이후에 속성을 추가하는 것은 히든 클래스가 변하도록 강제하고 이전의 히든클래스를 대상으로 최적화되었던 모든 메소드를 느리게 만든다. 대신에 모든 객체의 속성을 생성자에서 할당한다.
  • 메소드: 동일한 메소드를 반복적으로 수행하는 코드가 서로 다른 메소드를 한 번씩만 수행하는 코드 보다 더 빠르게 동작한다(인라인 캐싱 때문)
  • 배열: 값이 띄엄띄엄 있어서 키가 계속해서 증가하는 숫자가 되지 않는 배열은 피하는게 좋다. 모든 요소를 가지지는 않는 배열은 해시테이블이다. 이와 같은 배열의 요소들은 접근하기에 많은 비용이 든다. 또한 커다란 배열을 미리 할당하 지말자. 사용하면서 크기가 커지도록 하는 게 낫다. 마지막으로 배열의 요소를 삭제하지 말자. 그 배열의 키가 띄엄띄엄 배치된다(해시테이블이 됨).
  • 태깅된 값: V8은 객체와 숫자를 32비트로 표현한다. 어떤 값이 오브젝트(flag = 1)인지 혹은 정수(flag = 0)인지는 SMI(Small Integer)라는 하나의 비트에 저장하고 이 때문에 31비트가 남는다. 따라서 어떤 숫자가 31비트 보다 크면 V8은 이 숫자를 분리해서 double 타입으로 전환한 다음 이 숫자를 넣을 새로운 객체를 생성한다. 이러한 동작은 비용이 높으므로 가능한한 31비트의 숫자를 사용하자.




참고



Comments