V8엔진에 대해 알아보자

date
Jan 15, 2023
thumbnail
slug
V8엔진
author
status
Published
tags
JavaScript
V8
summary
우리가 npm run start 하고나서는 어떤일이 벌어질까
type
Post
updatedAt
Jan 23, 2023 06:42 PM
js의 컴파일 타임과 런타임을 찾아보던 도중 알게되어 정리해봤다.
먼저, 브라우저에서는 html,css,js를 조작하여 웹을 구성한다. 여기서 크롬브라우저는 Blink라는 Renderer엔진과 V8이라는 자바스크립트 엔진을 가진다.

V8

v8엔진은 c++로 작성되었고, ECMAScript와 WebAssembly를 처리 할 수 있다.
v8은 기본적으로 자바스크립트를 컴파일하고 실행하는 과정을 담당한다. 그 외에
  • 생성하는 Object를 메모리에 할당
  • 가비지 콜렉터를 통해 사용하지 않는 Object 메모리 해제
등 과 같은 행동을 수행한다.

AJITC

보통 컴파일하는 과정에는 정적 컴파일러, 인터프리터, JITC 3가지 방법이 쓰인다.
자바스크립트는 보통 인터프리터 언어라고 하지만, 반은 맞고 반은 틀린 말이다. 컴파일러와 인터프리터를 혼합해서 사용하는 JITC 방법을 사용한다.
하지만 이 JITC 방법이 JSEngine에서는 안좋게 작용한다.
 
첫번째 이유는 자바스크립트가 동적 타입 의 언이이기 때문이다.
자바스크립트 엔진에서 JICT는 컴파일시 동적타입 경우를 모두 고려하여 컴파일을 한다. 예시를 한번 들어보자
function carculate(a,b){
  return a + b;
};

carculate(a,b);
여기서 JITC는 int + int, int + string, string + string 등 다양한 변수 타입에 대한 모든 네이티브 코드를 생성해야하는데, 이는 매우 비효율적인 작업이기 때문에 자바스크립트 엔진에서 JICT는 int + int일 경우를 제외하고는 slow case로 코드를 넘긴다.

slow case

slow case는 위와 같이 네이티브 코드(기계어)로 생성하기에 비효율적인 코드들을 엔진 내부에 구현 돼 있는 함수를 호출해 동작을 수행하는 기관이다. 쉽게 말하면 바이트 코드를 네이티브 코드로 컴파일 하지 않고, 바로 인터프리팅을 하는 곳이라고 보면 된다.
이렇게 비효율적인 코드를 slow case로 넘기게 되면, 네이티브코드로 컴파일 하는 비중보다 인터프리터의 비중이 더 많아 지게 된다. 따라서 인터프리터랑 별반 다를게 없어지게 된다는 문제점을 가지게 됐다.
 
두번째 이유는 자바스크립트의 한계성 때문이다.
초창기의 자바스크립트는 layout과 간단한 반응형 제작으로 설계되었기 때문에 Hot Spot 이 적었다. hot spot이 적다는건 네이티브 코드 최적화를 할 필요없었고, 그만큼 자바스크립트 엔진에서 JICT는 성능이 별로 좋지 않았다.
♻️
하지만 결국 웹 사용자가 늘어나며 자바스크립트 엔진은 개선될 필요가 있었다.
구글의 개발자들은 당시 구글 맵을 개발하며 자바스크립트 엔진의 많은 한계를 느꼈다. 많은 상호작용을 할 수 있어야 했고, 이에 커버 가능한 엔진이 필요했다. 그래서 기존 JITC방식에서 살짝 변형한 AJITC방식의 V8을 만들게 되었다.

AJITC

AJITC는 Adaptive Just In Time Compiler의 약자로 적응하는 JIT컴파일러를 말한다. 자바스크립트 코드에 적응하는 컴파일러란 뜻이다. 과연 어떻게 적응하는 것일까? 한번 V8의 동작과정을 살펴보자.
notion image
  1. Blink (Renderer 엔진) 에서 <script> 태그를 만나면, Javascript 스트리밍을 시작한다.
  1. 스트리밍으로 전달받은 UTF-16문자열은 V8 엔진 Scanner를 이용해 Token을 생성한다.
Scanner, Token
javascript 파일은 텍스트로 이루어져 있고, 이는 network로 다운받는다. 바로 이 과정이 스트리밍 과정이다. 그렇게 스트리밍 과정으로 받은 UTF-16문자열은 Parser로 파싱 되기 전에 ScannerToken을 만들어낸다.
이 때 Token은 개발자가 만들어낸 함수나 변수 등을 말한다.
  • for, const, if, let 같은 js에 미리 정의 돼 있는 키워드
  • 공백이나 탭
  • 변수나 함수 식별자
모든 파일을 한번에 다운받고 Token을 만들어내는 것이 아니고, 스트리밍 중 도착하는 순서대로 여러 청크로 관리된다. 그리고 30KB 이상이 되면 Script Stream Tread에서 ScannerToken을 만든다.
  1. 생성된 Token이 Parser로 전달되고 Parser가 추상구문트리(AST)를 만들어낸다.
Parser, AST
Parser는 Token을 가지고, 인터프리터(Ignition)가 사용할 AST를 만들어낸다.
AST는 코드를 구조화된 트리로 만들어, 컴파일에서 사용할 수 있게 만든다.
function print(x) {
  console.log(x)
}
-------------------
{
  "type": "Program",
  "start": 0,
  "end": 39,
  "body": [
    {
      "type": "FunctionDeclaration",
      "start": 0,
      "end": 38,
      "id": {
        "type": "Identifier",
        "start": 9,
        "end": 14,
        "name": "print"
      },
      "params": [
        {
          "type": "Identifier",
          "start": 15,
          "end": 16,
          "name": "x"
        }
      ],
      "body": {
          //...
      }
    }
  ],
}
여기서 한가지 문제점이 생긴다. 실행하지 않는 코드는 어떻게 할까? 실행하지 않는 코드도 파싱해버리면 리소스 낭비로 이어지고, 그렇다고 메모리에 적재를 안 할 수는 없는 노릇이다. 따라서 V8은 모든 코드를 파싱하지 않고 Pre-Parser와 역할분담을 한다.
Token중 참조하지 않는 Token은 Pre-Parser로 전달되고, 참조하는 Token만 Parser로 전달된다. 그리고 Pre-Parser로 전달된 Token들은 나중에 요청시 컴파일된다.
  1. 만들어진 AST가 Ignition (인터프리터)로 전달되고 바이트코드로 중간 번역되고 실행된다.
Ignition
Ignition은 만들어진 AST를 바이트 코드로 번역한다.
function add(x, y) {
 return x + y;
}
add(101, 201);
다음과 같은 코드가 Ignition을 거치면
d8 --print-bytecode app.js
0xfde0821004e LdaConstant [0]
0xfde08210050 Star r1
0xfde08210052 Mov <closure>, r2
0xfde08210055 CallRuntime [DeclareGlobals], r1-r2
0xfde0821005a LdaGlobal [1], [0]
0xfde0821005d Star r1
0xfde0821005f LdaSmi [101]
0xfde08210061 Star r2
0xfde08210063 LdaSmi.Wide [201]
0xfde08210067 Star r3
0xfde08210069 CallUndefinedReceiver2 r1, r2, r3, [2]
0xfde0821006e Star r0
0xfde08210070 Return
이렇게 만들어진 바이트 코드는 한줄씩 실행된다.
  1. 바이트 코드를 실행하면서 지속적인 프로파일링을 통해 최적화해야하는 데이터(Hot Spot)를 수집한다.
  1. 수집된 Hot Spot들은 Turbo Fan 을 통해 Optimized Machine Code 를 생성하고 실행함으로써 최적화 된다.
Turbo Fan
Turbo Fan은 지속적인 프로파일링으로 전달되어 온 바이트 코드를 최적화 하기 위해 최적화된 바이트 코드를 만들어낸다.
자주 사용되는 Native Code와 중복된 코드를 재사용해서 코드 크기를 줄이고, 메모리 오버헤드를 줄여 더 빠른 속도를 내게 한다.
Turnbo Fan에서는 다음과 같은 과정으로 Optimized Machine Code 를 만들어낸다.
  1. Graph Building : 바이트코드 또는 AST를 Graph로 만들어낸다.
  1. Native Context Specialization & Inlining : 기본 컨텍스트에 특화된 Simple Graph를 만들어낸다.
  1. Typed Optimization : Type에 따라 Simple Graph로 변환한다.
  1. General Optimization : 중복되는 Graph를 제거하며 최적화를 진행한다.
  1. Code Generation : 죽은 코드를 제거하고 레지스터에 할당한다.
위 과정을 거쳐 Turbo Fan에서 최적화를 진행하게된다.
 
 
 

결론

JS는 크롬브라우저에서 돌아가고 그 과정에서 V8엔진을 통해 컴파일되고 실행되는데, V8이 JITC 형식이여서 컴파일과 인터프리팅이 둘다 혼합되어 실행된다. 따라서 JS는 런타임에 컴파일과 최적화 컴파일이 되는 듯 하다.

마치며

단순히 나는 npm run start만 주구장창 치며 브라우저가 뜨길 기다렸지만, 그 안에는 수많은 과정들이 존재했다는 것이 신기했다.