자바스크립트와 JIT 컴파일

자바스크립트 실행 직전에 벌어지는 사건들을 조사해봅니다

자바스크립트와 JIT 컴파일
크롬에서 자바스크립트를 실행하기 전엔 무슨 일이 벌어질까요?

컴파일

 컴파일은 고수준 언어를 저수준으로 번역하고 최적화 등 여러 가지를 하는 작업입니다. 컴퓨터는 0과 1로만 이뤄진 입력을 원하지만 사람이 그렇게 작성할 순 없습니다. 그래서 모든 프로그래밍은 사람이 이해하기 쉬운 고차원 언어(자바스크립트 등)으로 "생산"하고, 기계어로 번역해서 "소비"하는 공정을 거칩니다. 번역 뿐이라면 인터프리터로도 되지만, "특정 코드를 만 번 실행할 경우" 인터프리터와 컴파일은 아래와 같은 차이를 보입니다.

  • 인터프리터: 똑같은 작업을 10000번 새로 실행.
  • 컴파일: 저장한 결과물을 10000번 실행.

 자바스크립트는 작업 효율을 높이기 위해 JIT 컴파일을 합니다.

JIT(Just-In-Time)

런타임에 컴파일

 JIT란 브라우저가 스크립트를 실행하려고 할 때 컴파일하는 방식입니다. 대비되는 개념으로는 서버에서 미리 컴파일해두는 AOT(Ahead-of-Time) 방식이 있습니다. 압축해온 걸 현지에서 풀어내느냐, 미리 풀어서 커진 걸 가져오냐의 차이죠.
 JIT 방식은 사용자 컴퓨터의 많은 메모리와 CPU 사이클을 요구하므로 빠릅니다. (파이썬보다 빠르다는 PyPy도 JIT 컴파일을 합니다.) 그러나 이는 메모리 접근이 상대적으로 쉽다는 의미이므로 보안 이슈라는 단점이 있습니다.

 ‌‌JIT 컴파일은 스크립트 실행 직전에 파싱 & 컴파일하기 때문에 오버헤드가 우려될 수 있습니다. 이에 대비해서 캐싱을 활용합니다.
 첫 컴파일 땐 캐시 데이터를 만듭니다. 나중에 똑같은 스크립트를 컴파일하려고 하면 인스턴스에서 캐시를 찾아 재사용합니다.
...
 ‌‌캐시 생성도 비용이 요구됩니다. 그래서 이틀 내에 동일한 스크립트를 봤을 때 캐시를 만듭니다. 이렇게 하면 스크립트 파일을 평균 2배 이상 빠른 코드로 바꾸기 때문에 페이지 로딩 시 유저의 시간을 아낄 수 있습니다.
-V8 블로그-
‌‌ 어떨 땐 메모리 할당 없이 실행하는 게 바람직할 수 있는데요. 일부 플랫폼(iOS, 스마트 TV, 게임 콘솔 등)은 가용 메모리에 접근할 수 없습니다. V8을 사용할 수 없단 얘기죠. 가용 메모리 작성을 금지하면 어플 악용을 위한 공격 표면(attack surface)을 줄일 수 있습니다.‌‌
...
 ‌V8의 JIT-less 모드는 이를 위한 모드입니다. jitless 플래그로 V8을 시작하면 런타임에 가용 메모리를 할당하지 않고 사용합니다.
-V8 블로그-

런타임

  • 런타임 (실행중)
  • 런타임 라이브러리 (컴파일러, 가상 머신이 쓰는 라이브러리)
  • 런타임 환경 (실행을 돕는 프로그램)

 이 포스팅에서 런타임은 런타임 환경을 지칭하겠습니다. 크롬과 Node.js는 둘 다 V8 엔진을 쓰지만 런타임이 다릅니다. 브라우저는 화면 렌더가 목적이지만 Node.js는 VSCode 같은 비 브라우저에서의 스크립트 실행이 목적입니다.
 목적에 차이가 있기 때문에 제공되는 환경도 다릅니다. 예를 들면 브라우저의 DOM과 Node.js의 Filesystem 등은 각자만의 고유한 객체입니다. 그래서 브라우저 개발자 도구에선 Filesystem, require 등을 사용할 수 없습니다.

어휘 분석(Lexical Analysis)

“파싱을 위해 낱말로 나누다.”

흔히 렉싱이라고 부르는 과정입니다. 우리가 실행하기 전의 스크립트는 긴 문자열로 다운로드됩니다.

// 내가 작성한 코드
const count = 0;
function handleClick(){
  count++;
  console.log(count);
}

// 다운로드할 때
"const count=0;\n function handleClick(){\n count++;\n console.log(count);\n}\n"
대충 이런 식

 렉서(분석기)는 이 문자열을 스캔해서 토큰(문법적으로 유의미한 가장 작은 단위의 말)들로 분류합니다.

예시.

const a = b / 10 + "a";

 위 선언문은 아래 토큰들로 나눠집니다.

const
a
=
b 
/ 
10 
+ 
"a"


 C같은 언어는 아래와 같이 분류합니다.

일반 토큰 특수 토큰(예약어)
a (식별자) const (지정어)
b (식별자) = (연산자)
10 (리터럴) / (연산자)
"a" (리터럴) + (연산자)

자바스크립트는 아래와 같이 분류합니다.

일반 토큰 특수 토큰
const (예약어)
a (식별자)
= (부호)
b (식별자) / (나누기 부호)
10 (숫자 리터럴)
+ (부호)
"a" (문자열 리터럴)
  • 특수 토큰은 나누기, 정규 표현식 리터럴// 등이 있는데 소수이고, 나머지는 일반 토큰입니다.
  • 예약어는 식별자로 못 씁니다. (변수명으로 사용 불가능)
  • 지정어(keyword)는 그 언어체계에서 특별한 의미를 가진 단어. (if, while 등은 지정어 & 예약어다. 지정어 전부가 예약어는 아닙니다.)
  • 예약어는 아주 특별한 경우에는 식별자로 사용할 수 있다. awaitasync 함수 내에서만 예약어다. async 함수 밖에서는 변수명으로 아무런 문제 없이 사용할 수 있습니다.
  • 의외로 let은 예약어가 아닙니다.
  • 예약어는 아니지만 strict 모드에서는 식별자로 못 쓰는 말 : let, static, implements, interface, package, private, protected, public
  • implements, private 같은 몇몇 단어는 future reserved words(예약 예정어).
// strict 모드가 아니면 사용 OK
var let = 123;

// 사용 불가
let let = 123;
const let = 123;

렉싱 중엔 렉시컬 스코프도 정의합니다. 렉시컬 스코프는 식별자가 선언된 곳을 기준으로 정의한 식별자의 유효 범위입니다. 정적 영역이라고도 부르는데, 호출한 곳을 기준으로 스코프를 정의하는 동적 영역과 대조적입니다.
 대부분 현대 언어들은 정적 영역을 사용합니다. for 문의 index 변수가 대표적 예시입니다.

for(let i = 0; i < 5; i++){
  const num = i + 1;
  console.log(num);
}

inumfor문 안에서만 사용 가능하므로 numi의 스코프는 for문 블록입니다.
 이러한 렉싱 과정이 끝나면 토큰들은 파서에 전달됩니다.

문법 분석(Syntax Analysis)

“AST를 만들다.”

 흔히 파싱이라 부르는 과정입니다. 이 과정에서 문법 오류를 찾으면 SyntaxError를 냅니다.

const foo = "hi": // SyntaxError
:의 용법이 잘못 됐다.

 검사를 통과하면 토큰들로 구조체를 만듭니다. 필요한 정보만 골라서 트리로 만드는데 이를 AST(abstract syntax tree, 추상 구문 트리)라고 부릅니다.
 AST는 코드 분석, 최적화, 생성 등의 다용도로 사용하기 좋아서 타입스크립트, 프리티어 등 여러 분야에서 활용합니다.

const comment = "Hello world";

 위 코드는 아래와 같은 AST로 변환됩니다.

바이트 코드 생성

“이그니션이 AST를 바이트 코드로 컴파일한다.”
JS -> AST -> bytecode

 바이트 코드는 나중에 실행할 때 힙에 보관합니다.

 “바이트 코드 생성 중에 BytecodeGenerator는 context object pointers(클로저 전반에 걸쳐 상태 유지에 사용) 등을 위해 함수 레지스터 파일에 레지스터를 할당합니다.”
- 이그니션: V8 인터프리터 문서 -
 “클로저는 특수한 자료 구조로서 일반적으로 함수 코드의 포인터와 클로저가 생성되는 시점의 어휘적 환경을 합쳐서 만듭니다.”
- 위키피디아 -

 이 때 클로저 생성에 대한 준비를 미리 해둡니다. 단, 클로저 자체는 함수를 실행해야 생깁니다.

컴파일

“기계어 번역 | 코드 최적화.”
  • 터보팬: 최적화 및 여러 복잡한 작업으로 느림.
  • 스파크 플러그: 빠른 실행이 목적.

 처음 보는, 혹은 한 번만 실행할 코드는 최적화할 필요가 없습니다. 스파크 플러그는 최적화를 안 하고 바로 번역합니다.

 ‌‌저희는 인터프리터 최적화의 한계에 부딪혔습니다. V8의 인터프리터는 최적화가 매우 잘 되어있고, 아주 빠릅니다.‌‌ 하지만 인터프리터는 해결할 수 없는 고유한 오버헤드를 발생시킵니다. 예를 들면 바이트코드 디코딩 오버헤드나 디스패치 오버헤드는 인터프리터 기능의 본질적인 부분이거든요.
‌‌...‌‌
 현재의 2-컴파일러 모델로는 더 빠른 최적화를 계층화할 수 없습니다.‌‌ 최적화 속도를 올리도록 할 수는 있지만, 최고 퍼포먼스를 깎고 최적화 경로를 제거해야 속도를 올릴 수 있는 상황입니다.
‌‌...
 ‌‌스파크플러그 도입 : 이그니션 인터프리터와 터보팬 최적화 컴파일러 사이에 넣은, V8 9.1버전부터 도입된 비최적화 컴파일러‌‌
...
 ‌‌스파크플러그는 빠르게 컴파일하도록 디자인했습니다. 엄청 빨리요. 너무 빠른 나머지 우리가 원하면 언제든 컴파일할 수 있기 때문에 터보팬 코드보다 더 적극적으로 코드를 계층화할 수 있습니다.
-V8 블로그-
최적화가 필요해지면 터보팬이 코드를 최적화한다.

~ 2017년 : 풀 코드 제네레이터 + 크랭크 샤프트
2017년~ 2021년 : 이그니션+ 터보팬
2021년~ 현재 : 이그니션+ 스파크 플러그+ 터보팬

hot, warm, cold script

코드를 접하는 횟수마다 대처가 변하는 V8
  1. 처음 보는 스크립트는 컴파일하면서 브라우저 캐시에 스크립트 태그를 저장한다. (cold run)
  2. 두 번째: 브라우저 캐시에서 스크립트를 불러와서 다시 컴파일한다. 이번엔 컴파일 코드를 직렬화해서 메타 데이터로 첨부한다. (warm run)
  3. 세 번째 이후: 브라우저 캐시에서 스크립트, 메타 데이터를 가져온다. 메타 데이터를 역직렬화해서 힙에서 매칭되는 코드를 찾아온다. (hot runs)

힙: 원본 코드, 최적화한 코드 등을 저장, 관리하는 메모리 공간.
브라우저 캐시: 자주 접하는 페이지, 이미지, 스크립트 등을 보관.

코드 캐시 전개도

메인 스레드와 백그라운드 스레드

 자바스크립트는 싱글 스레드 언어, V8 엔진은 멀티 스레드 구조입니다. V8의 백그라운드 스레드는 메인 스레드를 보조하고, 메인 스레드는 실행에 집중합니다.

 ‌‌크롬은 41 버전부터 백그라운드 스레드의 자바스크립트 파싱을 지원했습니다.‌‌덕분에 첫 번째 청크를 다운로드하는 순간부터 파싱하고 스트리밍중에도 병렬 파싱할 수 있었죠.‌‌다운로드가 끝날 쯤에는 파싱도 거의 동시에 끝나서 로딩 시간을 단축했습니다.
...
 ‌‌이그니션은 멀티 스레딩을 염두하고 제작했는데요. 백그라운드 컴파일을 시키기 위해 파이프라인 전반을 손봤습니다.
...
 이번에 이그니션 + 터보팬 파이프라인으로 전환하면서 바이트 코드 컴파일도 백그라운드 스레드로 넘겨줄 수 있게 되었고, 메인 스레드는 더 부드럽고 응답성이 좋은 브라우징 환경을 제공하게 됐습니다.
-V8 블로그-
몇 년 전까진 파싱까지만 지원했지만 이젠 바이트 코드 생성까지 미리 해둔다.

 top-level 코드와 즉시 실행 함수, 대용량 작업용 웹 워커는 백그라운드 스레드에서 처리합니다. top-level은 최상위 계층을 뜻합니다. index.js 같은 스크립트, 부모 모듈, 부모 컴포넌트, 혹은 index.html 내부에서 선언한 <script>는 전역 스코프이므로 top-level입니다.

실행

 최종적으로 코드를 실행합니다. 컴파일과 실행 작업은 병행합니다. 흔히 스택, 혹은 콜 스택이라 부르는 게 스크립트 실행의 핵심입니다. ECMAScript에서 부르는 정식 명칭은 실행 문맥 스택(execution context stack)이고 코드 실행, 추적, 관리가 목적이라고 서술합니다.

참조