본문 바로가기
javascript

V8은 JS를 컴파일 할 때 Interpreter 의 느린 실행 속도를 어떻게 개선 했을까?

by 윤-찬미 2022. 1. 12.

우선 v8의 간략적인 소개부터 하자면 v8은 c++ 로 개발된 자바스크립트엔진 이다.

크롬에서는 해당 v8엔진을 사용하여 자바스크립트를 컴파일 한다.

참고로 html과 css를 처리 하는 renderer 엔진도 물론 있는데 크롬에서는 blink라는 엔진을 사용한다.

blink엔진이 html, css를 처리하다가 script 코드를 만나면, v8이 javascript코드를 컴파일한다.

 

사전에 인터프리트와 컴파일 방식의 차이를 알고가자

컴파일 방식은 사람이 고급언어(java, c) 를 작성하면, 해당 고급언어를 한번에 번역 한다.

그렇기에 번역시간이 오래 걸린다. 하지만 한번 번역을 해놓으면 실행 파일이 생성 되기 때문에,

다음 실행 시 해당 실행 파일을 가져와 실행하면 되기 때문에 인터프리터에 비해 실행 시간이 빠를 수 밖에 없다.

 

인터프리트 방식은 소스코드의 각 행을 연속적으로 분석하며 실행한다.

즉 한줄 한줄 번역하기에 번역 속도는 빠르지만 컴파일러 처럼 실행 파일을 생성하지 않기에,

매번 실행시 번역을 해야한다. 그래서 인터프리터 언어가 실행속도가 느리다고 하는 것이다.

 

브라우저에서 javascript 컴파일은 보통 interpreter로 처리 된다고하는데,

v8엔진에선 조금 다르다.

 

V8엔진의 역사를 살펴보자.

초기에 V8엔진은 원래 내부에 두개의 컴파일러가 있었다.

Full-codegen - 자바스크립트 코드를 컴파일 방식을 통해 중간 단계 없이 바로 머신코드로 번역

Crankshaft - Full-codegen 이 만들어준 바이트코드를 최적화된 코드로 컴파일하는 컴파일러

이 시기 V8은 아래의 파이프라인을 가지고 있었다.

그러다가 2015년 TurboFan컴파일러가 추가 됐다.

아래서 자세히 설명할건데, TurboFan은 Crankshaft랑 비슷한 역할을 한다고 보면 된다.

 

2016년, Ignition 인터프리터가 추가 됐고,

Ignition 또한 아래서 자세히 설명 할건데, Full-codegen과 비슷한 역할을 한다고 보면 된다.

V8팀에서는 TurboFan과 Ignition의 등장으로 Crankshaft과 Full-codegen 을 파이프라인에서 빼려고 하였으나, 몇몇 이슈가 있어서 빼지 못했고 아래 그림과 같은 파이프라인이 탄생했다.

추후 설명할건데,

결론부터 말하자면 5.9 버전 이후, 시기로 치면 2017년 부터는

V8은 ignition과 TurboFan만 남고 Full-codegen, Crankshaft 은 사라졌다.

그래서 아래에서 설명할 그림과 같은 파이프라인 만 남았다.

 

v8 엔진의 작동원리에 대해 알아보자

2017년 v8엔진은 Full-codegen Crankshaft 이 사라지고 아래와 같은 파이프라인이 등장했다.

Parser는 AST(추상구문 트리 - abstract syntax tree) 를 만든다.

참고

https://astexplorer.net/

 

AST explorer

 

astexplorer.net

AST가 대충 어떤 식으로 만들어 지는지 위 사이트를 참고하면 볼 수 있다.

함수가 AST로 만들어짐

 

그렇게 생성된 AST를 Ignition은 바이트 코드로 변환 한다.

Ignition은 바이트 코드를 생성한다.

Ignition은 기존 Full-codegen을 완벽히 대체하는 인터프리터 이다.

참고로 바이트 코드는 기계어로 해석되기 바로 전단계이다.

 

기존의 Full-codegen 에서 Ignition으로 변경, 뭐가 달라졌을까?

Ignition은 인터프리터이다.

기존에 사용하고 있던 Full-codegen은 전체 소스 코드를 한번에 컴파일했는데,

V8팀은 기존의 Full-codegen이 모든 소스 코드를 한번에 컴파일할때

메모리 점유를 굉장히 많이 한다는 사실을 인지하고 있었다.

 

그래서 Ignition을 개발할 때는 모든 소스를 한번에 해석하는 컴파일 방식이 아닌

코드 한줄 한줄이 실행될 때마다 해석하는 인터프리트 방식을 채택했다.

이후 이 Ignition 이 생성한 바이트 코드를 실행 시킴으로 우리의 소스코드가 실제로 작동하게 된다.

 

Ignition 의 background에 대해 자세히 보고 싶은 분들은 아래 문서를 참고하면 좋다.

참고로 나도 다 읽어보진 못했다.

https://docs.google.com/document/d/11T2CRex9hXxoJwbYqVQ32yIPMh0uouUZLdyrtmMoL44/edit#heading=h.z0c1oogsbnnq

 

Ignition Design Doc

Ignition: V8 Interpreter Authors: rmcilroy@, oth@ Last Updated: 2016/03/22 Background Overall Design Generation of Bytecode Handlers Bytecode Generation Interpreter Register allocation Context chains Constant Pool Entries Local Control Flow Exception Handl

docs.google.com

 

Ignition을 사용해보자

NodeJs 또한 V8엔진을 사용 중이기 때문에, --print-bytecode 옵션 으로 내 코드가 어떻게 바이트 코드로 인터프리팅 되었는지 확인할 수 있다.

아래 코드를 입력하고, --print-bytecode 옵션 으로 실행시켜보자.

// test1.js
function addOne(x) {
  return 1 + x;
}
addOne(1);
node --print-bytecode test1.js

 

실행해서 나온 바이트 코드는 아래와 같다.

[generated bytecode for function: addOne (0x19c02e5882d1 <SharedFunctionInfo addOne>)]
Parameter count 2
Register count 1
Frame size 8
   23 S> 0x19c02e588aae @    0 : 0c 01             LdaSmi [1]
         0x19c02e588ab0 @    2 : 26 fb             Star r0
         0x19c02e588ab2 @    4 : 25 02             Ldar a0
   32 E> 0x19c02e588ab4 @    6 : 34 fb 00          Add r0, [0]
   36 S> 0x19c02e588ab7 @    9 : aa                Return 
Constant pool (size = 0)
Handler Table (size = 0)
Source Position Table (size = 8)
0x19c02e588ab9 <ByteArray[8]>

결과물을 한번 보자.

Parameter count 2

나는 분명 파라미터 한개만 받기로 되어 있었는데, parameter 의 count가 2인 이유는,

이 중 하나는 암시적 리시버인 this 이기 때문이다.

LdaSmi [1]

LdaSmi [1] 은 누산기에 상수 값 1을 로드했다는 말이다.

(함수 보면 1이 상수로 박혀 있다)

Star r0

다음으로 Start r0은 현재 누산기에 있는 값 1을 레지스터 r0에 저장했다는 말이다.

Ldar a0

누산기에 a0번에 있는 값을 담는다는 말이고, 이경우 a0 레지스터의 값은 인자 x 가 된다.

Add r0, [0]

r0의 1(상수) 와 인자값으로 받은 1을 더하고 누산기에 저장한다.

Return

누산기에 있는 값을 반환한다.

 

간단한 코드임에도 불구하고 Ignition 은 위와 같은 과정으로 바이트 코드를 생산한다.

너무 멋지다..!!

 

자 그럼 중간에 있는 TurboFan은 뭘까?

TurboFan은 기존에 사용하던 Crankshaft 컴파일러를 완전히 대체한 최적화 담당 컴파일러이다.

위에서 인터프리터의 단점을 설명했다. Ignition 또한 인터프리터다.

인터프리터가 가지고 있는 단점을 보완하고자,

TurboFan은 컴파일 방식을 채택해 자주 쓰는 코드를 최적화 시키고 기존 코드랑 최적화된 코드랑 바꿔준다.

이때 이 최적화는 인라인캐싱, 히든 클래스 등과 같은 방법을 사용해 최적화 한 후

이후에 컴파일 할 때 참조하여 속도를 높인다.

이를 JIT (Just-In-Time) Compiler 이라고 하며,

해당 방식으로 Interpreter 의 느린 실행 속도를 개선할 수 있었다.

 

단계로 표현하면 아래와 같다.

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

즉 인터프리트 방식을 사용하다가 필요할 때 컴파일 방식을 사용한다.

솔직히 여기까지 공부하면서 V8팀의 노력에 마음이 웅장해졌다.

 

결론은, 자바스크립트는 실행되는 플랫폼에 따라 인터프리팅과 컴파일이 혼합되어 사용된다.

이 방식은 자바스크립트의 성능을 크게 향상시켰다.

 

참고:

https://medium.com/dailyjs/understanding-v8s-bytecode-317d46c94775

https://benediktmeurer.de/2017/03/01/v8-behind-the-scenes-february-edition/