V8 엔진은 무엇을 개선했을까?

Nochi·2023년 3월 12일
1

Front-End

목록 보기
2/3

본 내용은 [참고]의 글을 정리한 글입니다.

2012년 구글 I/O 2012에서 발표한 JS의 성능에 관련된 발표가 있다.

  • C++에서 코드를 실행 시켰을 때, 2.xx 초
  • JS에서 코드를 실행 시켰을 때, 15.xx 초

이 자료를 볼 때, Google의 개발자들은 JS를 C++과 비교하여 속도를 동급으로 올리려고 하는 것으로 보인다.


V8

V8은 C++로 작성된 오픈소스 자바스크립트 엔진이다. 바이트코드를 생성하는 역할을 한다. V8과 다른 엔진의 가장 큰 차이점은 V8 엔진의 JIT(Just In Time) 컴파일러로 런타임에 모든 자바스크립트를 기계어 코드로 컴파일하고 중간 코드를 생성하지 않는 특징을 가진다.

V8 엔진은 Ignition 인터프리터와 Turbofan 컴파일러로 구성된다.

Ignition 인터프리터

  • 바이트코드로 해석하는 구문 분석을 담당한다.
  • 구문 분석기가 생성한 AST(추상 구문 트리: Abstract Syntax Tree)를 입력으로 사용하고 바이트코드 생성한다.
  • 전체 프로그램을 컴파일하는 컴파일러와 달리 인터프리터는 필요한 라인만 컴파일함.
  • 코드를 처음 실행할 때만 동작한다.
  • 주된 목적은 메모리 사용량을 줄이는 것이다

Turbofan 컴파일러

  • 코드 실행 중 받는 데이터를 기반으로 코드를 최적화하고 보다 최적화된 버전을 다시 컴파일한다.

V8은 자바스크립트를 최적화를 위해 C++로 작성되었으며 다중 스레드 방식을 사용하여 이런 모든 작업을 한 번에 관리한다.

자바스크립트에서는 클래스 없이 객체를 생성할 수 있으며 객체가 생성된 이후라도 동적으로 프로퍼티와 메서드를 추가할 수 있다. 이는 사용하기 매우 편리하지만 성능 면에서는 이론적으로 클래스 기반 객체지향 프로그래밍 언어의 객체보다 생성과 프로퍼티 접근에 비용이 더 많이 드는 비효율적인 방식이다.
따라서 V8 자바스크립트 엔진에서는 프로퍼티에 접근하기 위해 동적 탐색(Dynamic Lookup) 대신 히든 클래스(hidden class)라는 방식을 사용해 C++ 객체의 프로퍼티에 접근하는 정도의 성능을 보장한다. 히든 클래스는 자바와 같이 고정된 객체 레이아웃(클래스)과 유사하게 동작한다.

히든 클래스

히든 클래스는 자바스크립트에는 클래스가 없어서 엔진 안쪽에 클래스를 숨기는 개념으로 최적화하는 기법이다.

해당 글을 읽을 때, JSCell이 무엇인지 보다 히든 클래스가 어떻게 동작하는지 중점으로 이해하면서 보는게 좋을 것 같습니다!

WebKit의 JavaScriptCore 엔진의 hidden class 관련 내부 자료구조이다. 쉽게 표현한 글에 의하면 JSCell은 오브젝트를 담는 자료구조이고, Structure는 hidden class 자료구조라고 한다.

JSCell만 놓고 보면 기존 클래스 기반 언어의 방식과 유사하다. 실제 프로퍼티 값들만 저장하고, 대신 자신이 속한 hidden class에 대한 포인터를 가지고 있다. m_propertyTable은 hidden class로 가지고 있는 오브젝트들이 어떤 필드 구조를 가지고 있고, 각각의 필드가 어느 오프셋에 저장되어 있는지를 나타내는 테이블이다. 객체의 멤버 변수를 참조할 때 m_propertyTable을 참조함으로써 어떤 위치의 값을 읽으면 되는지 알아낼 수 있는 것이다.

그리고 필드 구조가 런타임에 변할 수 있다라는 요구조건을 만족시키는 것이 바로 m_transitionTable이다. 중간에 객체의 필드 구조가 변한다면 m_transitionTable을 참조하여 객체가 다른 히든 클래스로 옮겨가거나 혹은 새로 생성하게 된다.

a.y = 2 부분이 수행되고 나면 히든 클래스가 위 그림과 같이 형성된다.

  • 아무 필드가 없는 객체는 [Structure 0]을 참조한다.
  • a에 x 필드가 추가되면서, [Structure 1]이 생성되고, 프로퍼티 테이블에 x(offset = 0)가 추가된다.
  • [Structure 0]의 트랜지션 테이블에 x가 추가된다. 앞으로 [structure 0]을 가리키는 객체에서 x라는 필드가 추가된다면, 트랜지션 테이블을 참조하여 [Structure 1]로 옮겨가게 된다.
  • a.y = 2가 수행되면서 y라는 필드를 추가하려고 한다. x때와 마찬가지로, [Structure 2]를 만들고, [Structure 1]에 y가 추가되는 경우에 대한 링크를 만든다.
  • 최종적으로 a는 [Structure 2]를 가리키고 있으므로, x에 접근할 때는 offset 0번을, y에 접근할 때는 offset 1번을 가져다 쓰면 된다.

여기에서 마지막 코드인 var b = new foo(3) 이 수행되면 아래와 같은 상태가 된다.

  • b가 처음 만들어지면서 [Structure 0]을 가리키는데, x 프로퍼티가 추가된다.
  • [Structure 0]의 트랜지션 테이블을 보니 x가 추가될 때의 링크가 있기 때문에, b의 히든 클래스를 [Structure 1]로 변경한다.
  • 만약 여기서 b.y를 추가한다면 [Structure 2]를 가리키도록 변경될 것이다.
  • 만약 여기서 b.z를 추가한다면 새로운 [Structure 3]을 만들 것이고, [Structure 1]의 트랜지션 테이블에 z가 추가될 것이다.

성능 문제 발생

같은 필드를 가지는 객체가 많아지는 경우 Hidden class를 사용하면 메모리는 절약될 수 있다. 실제 필드에 접근해서 값을 가져오려면 객체->hidden class->프로퍼티 테이블->프로퍼티 비교->[오브젝트 + 오프셋] 위치로 접근이라는 단계를 거쳐야만 실제 필드에 접근할 수가 있다.

오프셋(offset)

컴퓨터 과학에서 배열이나 자료 구조 오브젝트 내의 오프셋(offset)은 일반적으로 동일 오브젝트 안에서 오브젝트 처음부터 주어진 요소나 지점까지의 변위차를 나타내는 정수형이다.

이를테면, 문자 A의 배열이 abcdef를 포함한다면 'c' 문자는 A 시작점에서 2의 오프셋을 지닌다고 할 수 있다.

Hidden class가 없다 하더라도 프로퍼티 비교 단계를 거쳐야 하기 때문에 실제로는 hidden class에 접근하는 오버헤드만 추가되는 것이지만, 어떤 프로그램이든 사실상 객체의 필드에 값을 넣었다 뺐다 하는게 거의 대부분이다. 하나의 추가 동작이지만 그것이 필드 접근이라면 전체적인 성능에 큰 영향을 미치게 된다.

오버헤드(overhead)

어떤 처리를 하기 위해 들어가는 간접적인 처리 시간 · 메모리 등을 말한다.

예를 들어 A라는 처리를 단순하게 실행한다면 10초 걸리는데, 안전성을 고려하고 부가적인 B라는 처리를 추가한 결과 처리시간이 15초 걸렸다면, 오버헤드는 5초가 된다. 또한 이 처리 B를 개선해 B'라는 처리를 한 결과, 처리시간이 12초가 되었다면, 이 경우 오버헤드가 3초 단축되었다고 말한다.

Inline Caching

인라인 캐싱은 오프셋 값을 캐싱한다는 의미이다. 이것이야 말로 JavaScript 엔진의 최적화 철학이 가장 잘 드러나는 방법이며, 두 가지 가장이 바탕에 깔려있다.

  • 동적인 언어이지만 실제로 바뀌지 않는 것들이 더 많다.
  • 성능을 빠르게 하려면 루프를 노려야 한다.

객체 필드 구조가 런타임에 의해 변경될 수 있지만 실제로는 자주 발생하지 않고, 루프 안에서 변할 일은 거의 없다는 것이다. 이 가정이면 인라인 캐싱은 높은 효율을 보여줄 것이다.

  • 최초 실행 시, 캐싱된 값이 없으므로 Slow Case(핸들러 함수, 비효율적)로 실행하고, 이 때 찾은 오프셋 값과 히든 클래스(Structure)를 Constant Pool 영역에 캐싱한다.
  • 2회 이상 실행 시, 같은 필드에 접근할 때 캐싱된 Structure와 현재 객체가 가리키는 Structure의 주소값을 비교한다.
    • 주소가 같다면 캐싱된 프로퍼티의 오프셋이 유효하다는 의미로, 아래쪽 코드 (파란색 영역)를 수행해서 바로 필드에 값을 저장한다.
    • 주소가 다르다면 필드 구조가 달라졌다는 의미이므로, 오프셋 값이 무효가 되고 Slow Case로 넘어가서 다시 캐싱하거나 캐싱을 하지 않도록 변경한다.

Slow Case

'일반적으로 실행 속도가 느릴 수 있는 코드 경로를 처리하는 것'

실제 예시

for (var i=0; i<10; i++) {
   arr[i].x = i;
}

arr[i].x = i에서 인라인 캐싱이 이루어진다. 최초인 i === 0일 때에는 캐싱된 값이 없기에 Slow Case로 실행되고, arr[0]의 structure와 arr[0].x의 offset 값이 캐싱된다. 두 번째부터는 캐싱된 offset 값을 바로 쓸 수 있어서 클래스 기반 언어와 같은 성능을 보여준다.

단, arr[1]에서 arr[9]까지 모두 같은 필드 구조를 가지고 있어야만 성립되는 이야기이다. 아니면 인라인 캐싱의 혜택을 전혀 못 받고 오히려 더 느려질 것이다. 그리고 인라인 캐싱은 사실 두 번째 수행부터 캐싱을 한다. 왜냐하면 한 번 수행된 코드는 한 번만 수행될 가능성이 높지만, 두 번 수행된 코드는 이후에 더 수행될 확률이 높기 때문이다.

인라인 캐싱이 적용되려면 루프 안에서 필드 접근을 하려는 객체가 모두 같은 hidden class를 가리키고 있어야 합니다.

성능이 좋은 자바스크립트 프로그램을 만들고 싶다면, 자바스크립트를 정적인 언어라고 생각하고 쓰는 것이 좋습니다. 동적인 특성들을 최대한 활용하여 멋지고 파워풀한 코드를 작성할 수도 있지만, 거기엔 항상 성능이라는 대가가 따른다는 것을 명심해야 합니다.
- 정원기 NHN엔터테인먼트 / TOAST앱개발팀


후기

얼마전 JavaScript에 대한 논쟁이 하나 있었다. 자바스크립트에서 원시값이 Stack에 쌓이느냐에 대한 질문이었다. 여기 저기서 상충되는 의견들을 보다가 문득 자바스크립트의 성능이 좋아진 시기인 V8엔진에 관하여 찾다보면 알게 되지 않을까란 생각으로 찾아보게 되었다.

전혀 그런 내용이 아니잖아

그러나 V8 엔진이 무엇을 개선했는지 첫걸음으로 Hidden Class라는 것을 알게 되었다. 자바스크립트의 성능을 어떻게 향상시켰는지의 일부분을 알게 되었고, 이해를 했다는 사실에 신기했다.

이 글을 쓰기 전에 사실 자료들을 읽으면서도 보면서도 이해하지 못했다. 이해하기 위해 읽기 쉽게 다시 정리하였는데 이런 부분에 대해서 너무 부족하지 않나 생각은 했지만 쓰면서 내가 이해하기 쉽도록 가공하면서 지식이 늘어가는 것 같다.

[참고]

0개의 댓글