JavaScript

[JS] V8: 전체 컴파일러

나른한 노치 2023. 4. 20. 01:45

 

V8 가비지 컬렉션을 공부하다가, 이게 맞나란 생각을 좀 많이 하면서 V8이 무엇인지 좀 자세히 알아보기 위해 A tour of V8: full compiler를 번역하여서 공부해보고자 한다. 번역하다가 이해하기 쉽게 가감한 면이 있지만 이해가 잘 되었으면 좋겠다.


JavaScript 가상 머신에서 해석하다 JIT(Just-In-Time) 컴파일로 전환되면서 성능이 엄청나게 빠르게 증가했다. 이로 인해 JavaScript와 웹 앱의 유용성이 크게 증가했다. 그 결과, JavaScript는 이제 HTML5, 즉 웹 기술의 다음

세대를 이끄는 메인이 되었다. 네이티브 코드를 생성하고 실행하는 최초의 자바스크립트 엔진 중 하나인 V8 엔진은 구글 크롬, 안드로이드 브라우저, WebOS 및 Node.js와 같은 다른 프로젝트에서 사용된다.

 

글쓴이는 ARM 마이크로아키텍처에 맞게 V8 최적화하는 팀에 참여하여 SunSpider 성능은 두 배가 되었고, V8 벤치마크 성능은 하드웨어와 소프트웨어의 기여로 인해 약 50% 증가했다.

 

하지만, V8은 공개적으로 문서가 다소 부족하다. 다음 몇 가지 글에서 높은 수준의 개요를 제공할 것이며, 이는 가상 머신이나 컴파일러의 내부에 대해 궁금해하는 모든 이들에게 흥미로운 내용이 될 것이다.


High level architecture (고급 아키텍처)

V8은 실행하기 전에 모든 자바스크립트를 네이티브 코드로 컴파일한다. 해석이나 바이트코드가 없다. 컴파일은 한 번에 하나의 함수씩 수행된다(예전의 파이어폭스 VM인 TraceMonkey에서 사용되는 트레이스 기반 파일과는 달리). 대개 함수는 처음 호출될 때까지 실제로 컴파일되지 않으므로, 큰 라이브러리 스크립트를 포함하더라도 VM은 사용되지 않는 부분을 컴파일하는데 시간을 낭비하지 않는다.

 

  • 네이티브 코드: 특정 컴퓨터 하드웨어 아키텍처나 운영 체제에 최적화된 기계어 코드. 컴퓨터가 직접 실행할 수 있어서 프로그램이 더 빠르게 실행되고, 시스템 자원을 더 효율적으로 사용할 수 있다.
  • VM(Virtual Machine): 맥락 상 자바스크립트 가상 머신. 웹 브라우저에서 자바스크립트 코드를 실행하기 위한 소프트웨어 구성 요소로, 웹 페이지의 동적 요소를 처리하고 사용자와의 상호 작용을 관리하는데 중요한 역할을 한다. 가상 머신은 하드웨어나 운영 체제와 독립적으로 실행되는 프로그램을 구동하기 위한 소프트웨어 기반 환경으로 하나의 프로그램을 다양한 플랫폼에서 실행할 수 있게 되어 이식성과 호환성을 높일 수 있다.

 

V8은 실제로 두 가지 다른 자바스크립트 컴파일러를 사용한다. 그것들은 간단한 컴파일러와 보조 컴파일러라고 생각한다. 전체 컴파일러는 최적화되지 않은 컴파일러이다. 네이티브 코드를 최대한 빨리 생성하는 것이 이 작업의 목적이며, 이는 페이지 로드 시간을 빠르게 유지(사용자가 방문했을 때 웹페이지가 빠르게 열리고 화면에 나타나는 시간)하는데 중요하다. Crankshaft는 최적화 컴파일러이다. V8은 먼저 전체 컴파일러로 모든 것을 컴파일한 다음 내장 프로파일러를 사용하여 Crankshaft에 의해 최적화될 'hot' 함수(자주 호출되는 함수)를 선택한다. V8은 대부분 단일 스레드(버전 3.14 기준)이기 때문에 컴파일러가 실행되는 동안 실행이 일시 중지된다. 따라서 두 컴파일러 모두 매우 효율적인 코드를 생성하는데 많은 시간을 소비하는 대신 코드를 빠르게 생성하도록 설계되었다. 향후 V8 버전에서는 자바스크립트 실행과 동시에 별도의 스레드에서 Crankshaft(또는 적어도 그 일부)가 실행되어 더 비싼 최적화가 가능하다.

 

  • 더 비싼 최적화: 향상된 성능을 얻기 위해 더 많은 컴퓨팅 자원이나 시간이 소비되는 최적화 과정. 문맥에서는 자바스크립트 실행이 중단되지 않고 최적화가 진행되는 동안 병렬적으로 실행되도록 하여 성능을 더욱 향상시킬 수 있다는 것을 의미한다.

Why no bytecode? (왜 바이트코드가 없습니까?)

대부분의 VM에는 바이트 코드 인터프리터가 포함되어 있지만 V8에는 없다. 바이트코드로 컴파일하고 실행하는 것이 더 쉬울 수 있는데 왜 전체 컴파일러를 쓰는지 궁금할 수 있다. 최적화되지 않은 네이티브 코드로 컴파일하는 것이 실제로 바이트 코드로 컴파일하는 것보다 훨씬 더 비싸지 않다(성능이 좋다)는 것이 밝혀졌다. 다음 두 가지의 대한 컴파일 프로세스 과정을 살펴보자.

바이트 코드 컴파일

  • 구문 분석(파싱)
  • 범위 분석
  • 구문 트리를 바이트 코드로 변환

네이티브 컴파일

  • 구문 분석(파싱)
  • 범위 분석
  • 구문 트리를 네이티브로 변환

두 케이스 모두 소스 코드를 구문 분석하고 추상 구문 트리(AST)를 생성해야 한다. 각 기호가 로컬 변수, 컨텍스트 변수(클로저) 또는 전역 속성을 참조하는지 여부를 알려주는 범위 분석을 수행해야 한다. 번역 단계가 유일하게 다르다. 여기서 매우 정교한 작업을 수행할 수 있지만 컴파일러를 최대한 빠르게 하려면 직접 번역(direct translation)을 수행해야 한다. 모든 구문 트리 노드는 고정된 바이트 코드 시퀀스나 네이티브 명령어로 변환된다.

 

바이트 코드에 대한 인터프리터에서 네이티브 구현은 다음 바이트 코드를 가져와서 큰 스위치 문을 입력하고 고정된 명령 시퀀스를 실행하는 루프이다. 이를 개선하기 위해서는 인터프리터 루프를 사용하는 대신 각 작업에 고정된 명령 시퀀스를 내보내는 방식이다. 이것이 전체 컴파일러가 작동하는 방식이다. 이렇게 하면 인터프리터가 필요하지 않고 최적화되지 않은 코드와 최적화된 코드 간의 전환이 간소화된다.

일반적으로 바이트 코드는 컴파일러의 일부 작업을 미리 수행할 수 있는 상황에서 유용하다. 하지만 웹 브라우저 내부의 경우가 아니기 때문에 V8의 경우 전체 컴파일러가 더 적합하다.


Inline caches: accelerating unoptimized code(인라인 캐시: 최적화되지 않은 코드 가속화)

ECMA 스크립트 사양을 살펴보면 대부분의 작업이 터무니없이 복잡하다는 것을 알 수 있다.
+ 연산자를 예시로 보면,

  • 두 피연산자가 모두 숫자인 경우 추가를 수행한다.
  • 두 피연산자 중 하나가 문자열인 경우 문자열 연결을 수행한다.
  • 피연산자가 숫자 또는 문자열이 아닌 경우 숫자 추가 또는 문자열 연결 전에 일부 복잡한(사용자 정의?) 원시 변환이 발생한다.

소스 코드만 봐도 어떤 명령이 나와야 하는지 알 수 없다. 속성 로드는 또 다른 복잡한 작업의 좋은 예이다. 코드를 보면 개체("own" property), 프로토타입 개체의 속성, getter 메서드 또는 some magic browser-defined callback(매직 브라우저 정의 콜백) 중 어느 것에 일반 속성을 로드하고 있는지 알 수 없다. 속성이 존재하지 않을 수도 있다. 모든 가능한 사례를 전체 컴파일된 코드로 처리하려면 이 간단한 작업에도 수백 개의 지침이 필요해버린다.

 

  • magic browser-defined callback: 웹 브라우저에서 자체적으로 정의한 특별한 콜백 함수. 특정 상황에서 자동으로 호출되어 특별한 기능이나 동작을 수행한다.

 

Inline caches (IC)는 이 문제에 대한 해결책을 제공한다. 인라인 캐시는 기본적으로 특정 작업을 처리하기 위해 호출될 수 있는 여러 구현(일반적으로 즉시 생성)이 가능한 함수이다. V8은 광범위한 작업 집합에 인라인 캐시(IC)를 적용한다. 전체 컴파일러는 로드, 저장, 호출, 이진, 단항 및 비교 연산자뿐만 아니라 ToBoolean 암시적 연산(ToBoolean implicit operation)도 구현하기 위해 인라인 캐시(IC)를 사용한다.

 

  • ToBoolean 암시적 연산: JavaScript에서 값이 불리언(Boolean) 형태로 필요한 경우에 변환하는 과정을 의미한다.

 

인라인 캐시(IC)의 구현을 스텁이라고 한다. 스텁은 사용자가 스텁을 호출하는 의미에서 함수처럼 작동하고 반환되지만 스택 프레임을 설정하고 전체 호출 규칙을 반드시 따르는 것이 아니다. 스텁은 일반적으로 즉시 생성되지만 일반적인 경우에는 여러 인라인 캐시(IC)에서 캐시되어 재사용될 수 있다. 인라인 캐시(IC)를 구현하는 스텁은 일반적으로 특정 인라인 캐시(IC)가 과거에 만난 피연산자 유형을 처리하는 최적화된 코드를 포함한다(이 때문에 캐시라고 함).

 

스텁이 처리할 준비가 되지 않은 경우를 발견하면 "누락(misses)"하고 C++ 런타임 코드를 호출한다. 런타임은 사례를 처리한 다음 누락된 사례(이전 사례와 마찬가지로)를 처리할 수 있는 새 스텁을 생성한다. 전체 컴파일된 코드에서 이전 스텁에 대한 호출이 새 스텁을 호출하도록 다시 작성되고 스텁이 정산적으로 호출된 것처럼 실행이 재개된다.

  • 스텁(stup): 소프트웨어 개발에서, 아직 구현되지 않은 기능이나 메서드를 대신하는 일시적인 코드 조각. 실제 구현과 동일한 인터페이스를 제공하지만, 대부분 간단한 결과를 반환하거나 아무런 동작을 수행하지 않는다.

간단한 프로퍼티 로드 예시,

function f(o) {
  return o.x;
}

전체 컴파일러가 이 함수에 대한 코드를 처음 생성할 때 로드에 인라인 캐시(IC)를 사용한다. 인라인 캐시(IC)는 어떠한 경우도 처리하지 않는 사소한 스텁을 사용하여 초기화되지 않은 상태에서 시작된다. 전체 컴파일된 코드가 스텁을 호출하는 방법은 다음과 같다.

  ;; full compiled call site
  ldr   r0, [fp, #+8]     ; load parameter "o" from stack
  ldr   r2, [pc, #+84]    ; load string "x" from constant pool
  ldr   ip, [pc, #+84]    ; load uninitialized stub from constant pool
  blx   ip                ; call the stub
  ...
  dd    0xabcdef01        ; address of stub loaded above
                          ; this gets replaced when the stub misses

(Sorry if you are not familiar with ARM assembly. Hopefully the comments make it clear what's happening.)
(ARM 조립에 익숙하지 않은 분들은 죄송합니다. 댓글을 통해 무슨 일이 일어나고 있는지 확실히 알 수 있기를 바랍니다.)

초기화되지 않은 스텁의 코드,

  ;; uninitialized stub
  ldr   ip,  [pc, #8]   ; load address of C++ runtime "miss" function
  bx    ip              ; tail call it
  ...

이 스텁이 처음 호출되면 "누락(miss)"되고 런타임은 실제로 누락된 경우를 처리하기 위한 코드를 생성한다. V8에서 속성을 저장하는 가장 일반적인 방법은 객체 내의 고정 오프셋을 사용하는 것이다. 그 예로, 모든 객체에는 객체의 구조를 설명하는 대부분 불변의 구조인 맵에 대한 포인터가 있다. 객체 내 로드 스텁은 객체의 맵을 알려진 맵(초기화되지 않은 스텁이 누락되었을 때 표시되는 맵)과 비교하여 객체가 올바른 위치에 원하는 속성을 가지고 있는지 신속하게 확인한다. 이 맵 검사를 통해 값비싼 해시 테이블 조회를 피할 수 있다.

 

  • 오프셋(offset): 기준점으로부터의 상대적인 거리를 나타내는 값.
  • 고정 오프셋(fixed offset): 데이터 구조 내에서 특정 요소의 위치를 나타내는 상대적인 거리를 나타내는 값.
  • 해시 테이블 조회(hash table lookup): 해시 테이블이라는 데이터 구조를 사용하여 특정 키(key)에 대한 값(value)을 검색하는 과정.

 

  ;; monomorphic in-object load stub
  tst   r0,   #1          ; test if receiver is an object
  beq   miss              ; miss if not
  ldr   r1,   [r0, #-1]   ; load object's map
  ldr   ip,   [pc, #+24]  ; load expected map
  cmp   r1,   ip          ; are they the same?
  bne   miss              ; miss if not
  ldr   r0,   [r0, #+11]  ; load the property
  bx    lr                ; return
miss:
  ldr   ip,   [pc, #+8]   ; load code to call the C++ runtime
  bx    ip                ; tail call it
  ...

이 표현식이 객체 내부의 속성 로드만 처리해야 한다면, 추가적인 생성 없이도 로드를 빠르게 수행할 수 있다. 인라인 캐시(IC)는 한 가지 경우만 처리할 수 있으므로 이는 단형태(monomorphic) 상태에 있다. 만약 다른 경우가 발생하고 인라인 캐시가 다시 실패하면, 더 일반적인 메가형태(megamorphic) 스텁이 생성된다.

 

  • 단형태(monomorphic): 인라인 캐시가 특정 유형의 객체에 대해서만 작동하도록 최적화된 상태를 의미
  • 단형태 인라인 캐시: 오직 한 가지 유형의 객체에 대해 동작하는 캐시. 해당 유형의 객체에 대한 작업이 반복될 때 높은 성능을 제공.
  • 메가형태 인라인 캐시: 성능은 다소 저하되지만, 다양한 객체 유형에 대응할 수 있는 캐시.

To be continued...

보다시피, 전체 컴파일러는 상대적으로 높은 성능을 가진 기본 코드를 빠르게 생성하는 목표를 이룬다. 인라인 캐시가 많이 사용되므로, 전체 컴파일된 코드는 매우 일반적이고, 이로 인해 전체 컴파일러는 매우 간단해진다. 인라인 캐시는 코드를 매우 유연하게 만들어서 모든 경우를 처리할 수 있다.

다음 글에서는 프로그래머가 구조적 명세(ex: 클래스 선언)를 제공하지 않아도 대부분의 경우 O(1) 접근이 가능하게 V8이 메모리에서 객체를 어떻게 표현하는지 살표볼 것이다.


 

오늘 V8엔진의 컴파일러에 관련하여서 살펴보았는데, 적합한 알고리즘 선택으로 인해 성능이 많이 개선되었다고 생각이 든다. 그리고 컴파일러도 두 가지를 사용함으로 그 역할에 맞게 적합하게 사용함으로 사용자 편의를 위해 속도적으로 많은 개선이 되었다고 생각한다. 전반적으로 V8에서 해당 기능들을 왜 사용하였고, 그로 인해 성능이 좋아짐에 따라 해당 엔진이 널리 쓰여지고 있다고 생각이 들었다.