Web/[JS] Common

Compile in V8

ihl 2021. 10. 23. 14:51

  브라우저에서 JS 컴파일 방식은 일반적으로 인터프리터로 알려져 있다. 그러나 검색을 조금만 해보면 컴파일러처럼 최적화를 진행한다고 하기도 한다. 대체 JS 컴파일이 어떻게 진행되고 있는 것인지 조금 더 자세히 알아보자.

 

1. 인터프리터와 컴파일러

  컴파일이란 A언어를 B언어로 바꾸는 과정을 의미한다. 일반적으로 컴파일이라고 하면 고급 언어를 컴퓨터(CPU/프로세서)가 이해하고 실행할 수 있는 기계어로 번역한다는 의미로 사용된다. 컴파일의 대표적인 2가지 방법은 컴파일러와 인터트리터이다. 컴파일러는 미리 모든 내용을 읽고 목적 프로그램을 생성한다. 그 후 필요한 라이브러리들을 엮는 링킹 작업을 거쳐 실행 프로그램을 생성한다.(링킹까지 합쳐서 컴파일러가 하는 일이라고 하기도 한다.) 반면, 인터프리터는 고급 언어를 중간 언어로 번역 후 그 중간 코드를 한 줄씩 실시간으로 해석하여 실행한다. 

 

  컴파일러는 먼저 모든 코드를 읽어 최적화 해야하기 때문에 초기 구동 시간이 오래 걸리며, 코드를 수정하면 다시 최적화가 수행해야 하는 단점이 있다. 그러나 코드 최적화를 거치기 때문에 실행 시간은 더 빠른 편이다. 반대로 인터프리터는 코드를 최적화하지 않기 때문에 빠르게 코드 실행을 준비할 수 있다. 또한 코드가 수정되었을 때 바로 실행하기만 하면 된다. 그러나, 코드 최적화가 되어있지 않기 때문에 실행 시간은 좀 더 오래 걸린다.

 

2. V8 엔진의 JS 코드 컴파일

JS 코드 컴파일 과정

  V8 엔진에서 JS 파일은 AST로의 파싱 > Byte Code화 or 최적화된 기계화 과정으로 변환되어 실행된다. 각 과정을 설명하면 다음과 같다.

 

  1. JS 파일을 컴퓨터가 분석하기 쉬운 Abstract Syntax Tree 형태로 파싱한다. 
    V8은 이 과정에서 변수, 함수, 조건문 등 코드의 의미를 파악하며, 스코프가 설정된다.
  2. Ignition 내의 Byte Code Generator에 의해 AST가 Byte Code로 변환된다.
  3. Ignition에서 변환된 Byte Code가 해석 및 실행된다.
  4. Profiler(스레드)가 어떤 메소드가 많이 호출되는지 등 최적화에 필요한 정보를 수집하여 TurboFan에 전달한다.
  5. TurboFan은 Profiler가 수집한 정보를 바탕으로 최적화 가능한 부분을 최적화된 기계어로 변환한다.
  6. 최적화된 코드를 수행할 차례에는 원래 코드 대신 최적화된 기계어가 프로세서에 의해 실행된다.

 

3. Ignition

  Ignition은 AST를 이용하여 바이트 코드를 생성하는 바이트 코드 생성기와  바이트 코드를 해석하고 실행하는 바이트 코드 인터프리터 2가지를 포함한다. 

 

  원래 V8은 Ignition 대신 전체 소스 코드를 한 번에 컴파일하는 Full-codegen을 사용했었다. 그러나, 전체 소스 코드를 컴파일할 때 메모리를 굉장히 많이 점유하며, JavaScript가 동적 타이핑 언어이므로, 코드가 실행되기 전에는 알 수 없는 값들이 많아 실행 전에 한 번에 최적화하기가 어려웠다. 따라서 인터프리터 방식인 Ignition을 개발하였다.

 

4. TurboFan

  TurboFan은 Profiler가 모은 코드 정보를 바탕으로 코드를 최적화하는 JIT 컴파일러이다. JIT(Just In Time) 컴파일이란 프로그램을 실행하는 시점에서 필요한 부분을 바로 컴파일하는 방식을 의미한다. JIT는 같은 코드를 매번 해석하는 인터프리터와 달리 자주 쓰이는 코드를 캐싱하는 등의 최적화를 수행하므로, 인터프리터의 느린 실행 속도를 개선할 수 있다.

 

  최적화를 수행해야하는 코드를 결정하는 것에는 크게 2가지 조건이 있다. 첫 번째는 코드가 뜨거운가 이고, 두 번째는 코드가 안정적인가 이다. 코드가 뜨겁다는 의미는 자주 호출되는 것을 의미한다. 코드가 안정적이라는 것은 코드가 변하지 않는 것을 의미하며, 작고 단순한 함수도 동작이 매우 제한적인 확률이 높기 때문에 안정적이라고 볼 수 있다.

 

  프로파일러는 코드가 실행되는 동안 함수가 자주 호출되는지, 값의 타입이 변하지 않는지 등의 정보를 수집하여 JIT 컴파일러에게 전달한다. JS는 동적인 언어지만, JIT 컴파일러는 JS 실행 중 실제로는 동적인 변화가 자주 일어나지 않을 것이라고 가정하고 최적화한다. 예를 들어 프로파일링을 수행하는 동안 특정 변수의 타입이 변하지 않았다면 그 이후에도 변하지 않을 것이라 생각하는 것이다. 따라서 실제 동적인 변화가 발생했을 때 페널티가 크더라도, 변하지 않았을 때 큰 성능 이득을 볼 수 있는 히든 클래스, 인라인 캐싱 등의 최적화를 수행한다.

 

  JIT 컴파일러가 최적화에 실패하면 어떻게 될까? 예를 들어 특정 함수의 리턴 객체가 모두 같은 모양을 갖는다는 가정하에 최적화를 했다고 생각해보자. JS 에선 해당 함수를 100번 호출했을 때 99번은 동일했지만 1번은 프라퍼티가 약간 다를 수도 있다. 이 경우 JIT 컴파일러는 가정이 잘못되었다고 판단하고 최적화된 코드를 버린다. 그 후 원래 byte code로 돌아간다. 이를 역최적화(deoptimization) 혹은 구제(bailing out)이라고 한다. 만약 최적화-역최적화가 지속적으로 발생한다면 성능에 오히려 악영향을 줄 것이다. 따라서 일반적으로 JIT가 일정 횟수 이상 최적화에 실패한다면 더 이상 최적화를 그만 시도하는 로직이 포함되어있다.

 

  JS 컴파일시 항상 JIT를 사용하는 것이 좋을까? 최적화할 수 있는 구간. 즉, 자주 반복되어 수행되는 구간(hotspot)이 많다면 최적화하는 것이 효과적이지만, 그렇지 않다면 최적화를 수행하는 시간 및 사용 메모리(over head)가 더 크고, 오히려 역최적화가 발생할 수 있기 때문에 때문에 인터프리터만을 사용하는 것이 더 나을 수도 있다. 이러한 생각이 적용된 컴파일 방식을 Adaptive JIT Compile이라 한다.

 

  Adaptive JIT 컴파일이란 모든 코드에 대해 같은 수준의 최적화를 적용하는 것이 아니라, 반복 수행되는 정도에 따라 서로 다른 최적화를 수행하는 것이다. 반복되는 코드에 대해 처음에는 최소한의 최적화(baseline-JITC)만 적용하다가, 더 자주 반복되는 경우 더 많은 최적화(Optimizing-JITC)를 적용한다.

 

  과거 V8은 컴파일러로 Crankshaft를 사용했으나, 지속적인 확장이 어렵다고 판단하여, 여러 레이어로 계층화된 TurboFan을 사용하고 있다.

 


JIT : https://dongwoo.blog/2017/06/06/%eb%b2%88%ec%97%ad-%ec%a0%80%ec%8a%a4%ed%8a%b8-%ec%9d%b8-%ed%83%80%ec%9e%84jit-%ec%bb%b4%ed%8c%8c%ec%9d%bc%eb%9f%ac-%ec%a7%91%ec%a4%91-%ec%bd%94%ec%8a%a4/

V8 : V8 엔진은 어떻게 내 코드를 실행하는 걸까? | Evans Library (evan-moon.github.io)

Byte code in V8 : Understanding V8’s Bytecode. V8 is Google’s open source JavaScript… | by Franziska Hinkelmann | DailyJS | Medium

Ignition은 최적화된 기계어를 실행하는가 : javascript - Does V8 Ignition execute highly optimized machine code that is produced by Turbofan? - Stack Overflow

JS 작동원리 : 자바스크립트의 작동 원리는?(크롬 V8 엔진) (tistory.com)

JS 최적화 : 자바스크립트 엔진의 최적화 기법 (1) - JITC, Adaptive Compilation : NHN Cloud Meetup (toast.com)

JITC, Adaptive-JITC : https://gist.github.com/snaag/5943c77869498a30310e5b13b53aaae3