V8 에서 Javascript 코드를 실행하는 방법 정리해보기

박성룡 ( Andrew park )
10 min readMar 17, 2020

--

V8 에서 Javascript 가 어떻게 해석되어, 실행 되는지 이해하기 위해 정리 해보고자 한다.

브라우저는 HTMLCSS 를 이용해서 화면을 만들고, Javascript 를 이용하여 화면을 조작 한다.

크롬 브라우저는 크게 Blink 라는 Renderer 엔진 (html, css) 과 V8 이라는 Javascript 엔진을 가지고 있다.

V8 의 특징

V8 엔진은 C++ 로 작성 되었으며, ECMA-262 에 기재된 ECMAScript 및 WebAssembly 를 처리할 수 있다.

V8 은 IA-32, ARM, MIPS 프로세서 를 사용하는 Windows 7 이상, macOS 10.12 이상, Linux x64 환경에서 실행이 가능하다.

V8 은 Chrome 이 아니더라도, 독립적으로 실행이 가능한데, 대표적인 예가 V8 으로 빌드 된 Node.js 가 있다.

V8 은 아래 특징을 지닌다.

  • JavaScript 소스 코드를 컴파일 하고, 실행한다.
  • 생성하는 Object 를 메모리에 할당한다.
  • 가비지 콜렉션을 이용해 더 이상 사용되지 않는 Object 의 메모리를 해제한다.
  • Hidden Class 를 이용해 빠르게 프로퍼티에 접근한다.
  • TurboFan 을 이용해 최적화된 코드로 만들어 속도 및 메모리를 최적화한다.

JIT Compiler

Javascript 는 보통 js 파일 (text) 로 배포되고, 이를 브라우저에서 사용한다.

브라우저에서는 Javascript 를 처리하기 위해서, Javascript 엔진 으로 Javascript 소스 를 내부에서 이해할 수 있는 언어로 변환하고 실행하는데, 이를 컴파일 이 라고 부른다.

브라우저에서 Javascript 의 컴파일은 보통 Interpreter 로 처리된다고 알려져 있지만, V8 엔진 에서는 꼭 그렇지도 않다.

브라우저는 javascript 를 매번 브라우저가 이해할 수 있는 언어로 변환해야 하는데, interpreter 의 경우 항상 같은 코드를 반복해서, Compile 하고 실행 한다. 웹의 특성상 새로고침이나 페이지 이동이 잦은데, 항상 같은 코드를 반복해서 Compile 하는 경우가 많다.

V8 에서는 먼저 JavaScript 코드를 Interpreter 방식으로 Compile 하고, 이를 ByteCode 로 만들어 낸다.

그리고 Compile 속도를 높이기 위해,이 ByteCode를 캐싱 해두고, 자주 쓰이는 코드를 인라인 캐싱(inline caching)과 같은 최적화 기법으로 최적화한 후, 이후에 Compile 할 시에 참조하여 속도를 높힌다.

이러한 방식을 JIT (Just-In-Time) Compiler 이라고 하며, Interpreter 의 느린 실행 속도를 개선할 수 있다.

V8 컴파일 과정

Javascript 도 사람이 읽을 수 있는 코드 이기 때문에, 기계가 읽을 수 있도록 기계어로 Compile 해야 한다.

V8 에서 Javascript 컴파일 과정은 다음과 같다.

  1. Blink 에서 <script> 태그를 만나면, Javascript 스트리밍 을 시작한다.
  2. 스트리밍 으로 전달 받은 UTF-16 문자열은 Scanner 를 이용해 Token (let, for) 을 생성한다.
  3. 생성된 Token 을 가지고, Parser추상 구문 트리 (AST) 를 만든다.
  4. 만들어진 ASTIgnition (Compiler) 에서 Byte Code 로 컴파일한다.
  5. 컴파일된 Byte Code 를 실행함으로써 원하는 Javascript 동작이 실행된다.

이때 컴파일한 내용을 V8 에서는 최적화를 진행한다.

  • Byte Code 를 실행하면서, Profiling 을 통해 최적화 해야 하는 데이터를 수집한다.
  • Profiling 을 통해 찾은 데이터는 TurboFan 을 통해 자주 사용되는 함수나 데이터를 기반으로 최적화를 진행하며, Optimized Machine Code 를 생성한다.
  • 이후 Optimized Machine Code 를 실행하며, 메모리 사용량을 줄이고, 기계어에 최적화되어, 속도와 성능을 향상 시킨다.

위 과정을 영상을 통해 자세히 설명하고 있다.

V8 Scanner 와 Token

Javascript 파일은 Text 로 이루어져 있으며, 이를 Network 를 통해 다운받는다.

V8 에서는 이 Text 정보를 Parsing 하기 전에, 일정한 형태의 UTF-16 으로 변환하고, Scanner 를 이용해 Token 을 생성한다.

이때 Token 은 미리 정의한 항목과 개발자가 정의한 함수나 변수들이다.

  • Javascript 에 미리 정의되어 있는 for, const, if, function 같은 키워드
  • 공백 이나 탭
  • 변수 나 함수 식별자

이때 모든 파일을 다운 받고 실행되는 것이 아니라, 스트리밍 중 도착하는 순서대로, 여러 chunk 관리되며, 30kB 이상이되면, Script Stream Tread 에서 Parsing 을 시작한다.

Scanner 단계 에서 속도를 올리기 위해서는 소스 코드를 축소하고, 불필요한 공백이나 주석을 제거하고, 비 ASCII 식별자를 피하는 것이 좋다.

https://v8.dev/blog/scanner

V8 Parser 와 AST

ParserToken 을 가지고, 컴파일러 (Ignition) 가 사용할 AST 를 생성한다.

AST (Abstract Syntax Tree) 는 코드를 구조화된 트리로 만들어, 컴파일에서 사용할 수 있게 도와준다.

AST 란 소스코드를 트리로 만든 구조체 이며, 보통 컴파일러에서 사용한다.

https://astexplorer.net/ 에서 대략적인 예제를 확인해 볼 수 있다.

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 에서는 모든 코드를 즉시 Parsing 하지 않는다.

  • 실행하지 않을 코드를 컴파일하면, 여러 리소스가 낭비된다.
  • 컴파일후 메모리에 적재해고 있어야 하기 때문에, 가비지가 해지하기 전까지 메모리 낭비한다.

이를 해결하기 위해 Pre-Parser 를 함께 진행한다.

Token 중 참조하지 않는 Token 를 Pre-parser 로 전달하여 실제 작업에 필요한 최소한의 작업 만들 parsing 하고, Pre-parser 로 나중에 요청시 컴파일 된다.

https://v8.dev/blog/preparser

V8 Ignition

V8 은 Parseing 된 ASTIgnition 에서, Byte Code 로 컴파일 한다.

// app.js
function add(x, y) {
return x + y;
}
add(101, 201);

위 코드를 v8 엔진으로 컴파일 하면 다음과 같은 결과가 출력된다.

여기서 d8 은 v8 엔진을 빌드하고 나온 실행 파일이다.

d8 --print-bytecode app.js0xfde0821004e 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

위의 과정은, LdaGlobal 이나 LdaSmi 으로 누산기에서 값을 계산하고, Star 로 누산기의 값을 레지스터 공간에 저장하고, 최종 Return 으로 끝난다.

해당 바이트 코드 정의는 bytecodes.h 에 정의되어 있다.

이 바이트코드는 Ignition 으로 처리되어, 기계어로 해석되기 바로 전단계이며, 이 바이트 코드를 실행하며, Turbofan 에서 최적화할 코드를 Profiling 한다.

V8 Turbofan

V8 은 컴파일된 바이트 코드 를 최적화하기 위해 Turbofan 으로 최적화된 바이트 코드를 만들어 낸다.

Turbofan 은 자주 사용되는 Native code중복된 코드 재사용하여 코드 크기를 줄이고, 메모리 오버 헤드를 크게 줄여, 더 빠른 속도를 내고, 최적화된 Graph 를 만들어낸다.

Turbofan 은 아래 순서로 진행되며 자세한 내용은 Dosc 에 정리되어 있다.

  1. Graph Building : 바이트 코드 또는 AST 를 Graph 로 만든다.
  2. Native Context Specialization & Inlining : Load / Store / Call IC 를 기반으로 기본 컨텍스트에 특화된 Simple Graph 를 생성한다.
  3. Typed Optimization : Type 에 따라 Simple Graph 로 변환한다.
  4. General Optimization : Graph 를 기반으로 중복 제거 같은 최적화를 진행한다.
  5. Code Generation: 죽은 코드를 제거고, 레지스터에 할당한다.

여기서 v8 에서는 최적화를 진행해기 위해 Schedule (CFG: control flow graph) 과정을 거치게 되는데, 자세한 내용은 Docs 에 정리되어 있다.

V8 에서는 Turbofan 을 이용해서, 메모리 사용량 감소 하고, 실행 시간을 단축하며, 코드의 크기를 줄였지만 빠른 속도로 무장하게 되었다.

참고할만한 링크

아래는 V8 엔진을 정리하면서 참고하였던 링크들 입니다.

V8 과 다른 브라우저 Javascript 엔진이 어떻게 구성되어 있는지를 설명해 주셨다.
https://mathiasbynens.be/notes/shapes-ics

Turbofan 의 디자인하며, 고민하셨던 사항을 공유해주셨다.
https://benediktmeurer.de/2017/03/01/v8-behind-the-scenes-february-edition/

개선된 V8 엔진을 NodeJS 에서 관점에서 소개 해주셨다.
https://github.com/davidmarkclements/v8-perf

V8 에서 Javascript 성능을 최적화 할 수 있는 방법을 설명하고 있다.
https://v8.dev/blog/cost-of-javascript-2019
https://www.html5rocks.com/ko/tutorials/speed/v8/

Turbofan 에 대해 자세히 소개를 해주셨다.
https://doar-e.github.io/blog/2019/01/28/introduction-to-turbofan/#compilation-pipeline

--

--