본문으로 건너뛰기

번개처럼 빠른 파싱, 2부: 지연 파싱

· 약 12분
툰 베르와스트 ([@tverwaes](https://twitter.com/tverwaes))와 마르야 횔타 ([@marjakh](https://twitter.com/marjakh)), 더 얇은 파서

이 시리즈는 V8이 자바스크립트를 가능하게 가장 빠르게 파싱하는 방법을 설명합니다. 첫 번째 글에서는 V8의 스캐너가 빠르게 작동하도록 만든 방법을 설명했습니다.

파싱은 소스 코드를 중간 표현으로 변환하여 컴파일러(V8의 경우는 바이트코드 컴파일러 Ignition)가 사용할 수 있도록 만드는 단계입니다. 파싱과 컴파일링은 웹 페이지 시작의 중요한 경로에서 발생하며, 브라우저에 전달된 모든 함수가 시작 시 즉시 필요한 것은 아닙니다. 개발자는 비동기 및 지연 스크립트를 사용하여 그러한 코드를 지연시킬 수 있지만, 항상 그것이 가능한 것은 아닙니다. 또한 많은 웹 페이지는 특정 기능에서만 사용되는 코드를 전달하며, 사용자가 페이지를 한 번 실행하는 동안 전혀 접근하지 않을 수 있습니다.

불필요하게 코드를 열정적으로 컴파일하는 것은 자원적으로 비용이 듭니다:

  • CPU 사이클이 코드 생성을 위해 사용되어, 실제로 시작에 필요한 코드의 가용성을 지연시킵니다.
  • 코드 객체는 메모리를 차지하고 있으며, 바이트코드 플러싱이 해당 코드가 현재 필요하지 않다고 판단하고 가비지 컬렉션을 허용할 때까지 메모리를 점유합니다.
  • Top-level 스크립트 실행이 끝날 때까지 컴파일된 코드는 디스크에 캐시되며 디스크 공간을 차지하게 됩니다.

이러한 이유로, 주요 브라우저는 모두 지연 파싱 을 구현합니다. 각 함수에 대한 추상 구문 트리(AST)를 생성한 다음 바이트코드로 컴파일하는 대신, 파서는 발견한 함수를 '사전 파싱(pre-parse)'하도록 선택할 수 있습니다. 이렇게 하여 함수를 완전히 파싱하지 않고 넘어갈 수 있습니다. 파서는 사전 파서로 전환하여, 이러한 최소한의 작업을 수행합니다. 사전 파서는 건너뛰는 함수들이 구문적으로 유효한지 확인하고, 외부 함수들이 올바르게 컴파일될 수 있도록 필요한 모든 정보를 생성합니다. 나중에 사전 파싱된 함수가 호출되는 경우, 해당 함수는 온디맨드로 완전히 파싱되고 컴파일됩니다.

변수 할당

사전 파싱을 복잡하게 만드는 주된 요인은 변수 할당입니다.

성능상의 이유로 함수 활성화는 머신 스택에서 관리됩니다. 예를 들어, 함수 g가 인수 12와 함께 함수 f를 호출하는 경우:

function f(a, b) {
const c = a + b;
return c;
}

function g() {
return f(1, 2);
// `f`의 반환 명령 포인터가 이제 여기를 가리킵니다
// (`f`가 `return`될 때 여기를 반환하기 때문입니다).
}

먼저 수신자(예: fthis 값으로, 이는 느슨한 함수 호출이므로 globalThis)가 스택에 푸시되고, 이후 호출된 함수 f가 푸시됩니다. 그런 다음 인수 12가 스택에 푸시됩니다. 이때 함수 f가 호출됩니다. 호출을 실행하기 위해, 우선 g의 상태를 스택에 저장합니다: f의 '반환 명령 포인터' (rip; 어떤 코드로 반환되어야 하는지) 및 '프레임 포인터' (fp; 반환 시 스택의 모습). 그런 다음 f에 들어가며, 로컬 변수 c 및 필요할 수 있는 임시 공간을 위해 스페이스를 할당합니다. 이로 인해 함수 활성화가 스코프에서 벗어나면 함수가 사용하는 모든 데이터가 단순히 스택에서 팝됩니다.

스택에 인수 a, b와 로컬 변수 c가 할당된 함수 f의 호출 스택 레이아웃.

이 설정의 문제는 함수가 외부 함수에서 선언된 변수를 참조할 수 있다는 점입니다. 내부 함수는 해당 함수가 생성된 활성화보다 더 오래 지속될 수 있습니다:

function make_f(d) { // ← `d`의 선언
return function inner(a, b) {
const c = a + b + d; // ← `d`에 대한 참조
return c;
};
}

const f = make_f(10);

function g() {
return f(1, 2);
}

위 예에서 innermake_f에서 선언된 로컬 변수 d를 참조하는 것은 make_f가 반환된 이후 평가됩니다. 이를 구현하기 위해, 어휘적 클로저를 가진 언어의 VM은 내부 함수가 참조하는 변수를 힙에 있는 '컨텍스트'라는 구조에 할당합니다.

히프에 컨텍스트를 할당하여 이후 inner가 캡처하는 d를 사용하는 make_f 호출의 스택 레이아웃.

함수 내에서 선언된 각 변수에 대해, 내부 함수가 변수에 참조하는지 여부를 파악해야 합니다. 그래야 변수가 스택에 할당될지 힙 할당 컨텍스트에 할당될지를 결정할 수 있습니다. 함수 리터럴을 평가할 때, 함수 코드와 현재 컨텍스트를 모두 가리키는 클로저를 할당합니다. 이 컨텍스트는 접근이 필요할 수 있는 변수 값을 포함하는 객체입니다.

요약하자면, 우리는 최소한 변수 참조를 사전 파서에서 추적할 필요가 있습니다.

만약 참조만 추적한다면, 참조되는 변수를 과대 평가하게 될 것입니다. 외부 함수에서 선언된 변수가 내부 함수에서 다시 선언되어 그 내부 참조가 외부 선언을 참조하지 않게 될 수도 있습니다. 조건 없이 외부 변수를 컨텍스트에 할당한다면 성능이 저하될 것입니다. 따라서 사전 파싱에서 변수 할당이 올바르게 작동하려면, 사전 파싱된 함수가 변수 참조 및 선언을 적절히 추적하는지 확인해야 합니다.

최상위 코드(top-level code)는 이 규칙의 예외입니다. 스크립트의 최상위는 항상 힙에 할당됩니다. 왜냐하면 변수들이 스크립트 간에 보이는 특성을 가지기 때문입니다. 잘 작동하는 구조에 가까워지는 쉬운 방법은 변수 추적 없이 사전 파서를 실행하여 최상위 함수를 빠르게 파싱하도록 하고, 내부 함수에는 전체 파서를 사용하는 대신 컴파일을 생략하는 것입니다. 이는 사전 파싱보다 비용이 더 많이 들기 때문에 전체 AST를 불필요하게 생성하지만, 이 방법으로 실행될 수 있습니다. V8은 정확히 V8 v6.3 / Chrome 63까지 이렇게 실행했습니다.

사전 파서에게 변수에 대해 가르치기

사전 파서에서 변수 선언과 참조를 추적하는 것은 복잡합니다. 왜냐하면 JavaScript에서는 부분 표현식의 의미가 처음부터 명확하지 않을 수 있기 때문입니다. 예를 들어, d라는 매개변수를 가진 함수 f가 있고, 그 안에 표현식이 d를 참조하는 것처럼 보이는 내부 함수 g가 있다고 가정해봅시다.

function f(d) {
function g() {
const a = ({ d }

이는 우리가 본 토큰이 구조 분해 할당 표현식의 일부이기 때문에 d를 참조하게 될 수도 있습니다.

function f(d) {
function g() {
const a = ({ d } = { d: 42 });
return a;
}
return g;
}

또한 구조 분해 매개변수 d를 가진 화살표 함수가 될 수도 있으며, 이 경우 fdg가 참조하지 않습니다.

function f(d) {
function g() {
const a = ({ d }) => d;
return a;
}
return [d, g];
}

처음에 우리의 사전 파서는 너무 많은 공유 없이 독립적인 파서 복사본으로 구현되었으며, 이는 시간이 지남에 따라 두 파서가 점점 달라지게 했습니다. 우리가 파서와 사전 파서를 ParserBase를 기반으로 하는 형태로 재구현함으로써 공유를 극대화함과 동시에 별도의 복사본이 가져오는 성능 이점을 유지할 수 있었습니다. 이를 통해 사전 파서에 전체 변수 추적을 추가하는 작업이 크게 단순화되었습니다. 구현의 상당 부분이 파서와 사전 파서 간에 공유될 수 있기 때문입니다.

사실 최상위 함수라도 변수 선언과 참조를 무시하는 것은 잘못된 것이었습니다. ECMAScript 명세는 다양한 유형의 변수 충돌을 스크립트의 첫 번째 파싱 시 탐지하도록 요구합니다. 예를 들어, 동일한 범위에서 한 변수가 두 번 렉시컬 변수로 선언되면, 이는 초기 SyntaxError에 해당됩니다. 우리의 사전 파서는 단순히 변수 선언을 건너뛰었기 때문에 사전 파싱 중에 코드를 잘못 허용했습니다. 당시에는 성능 향상이 명세 위반을 정당화한다고 여겼습니다. 그러나 이제 사전 파서가 변수를 적절히 추적하기 때문에, 이러한 변수 해결과 관련된 명세 위반 클래스 전체를 제거했으며, 이는 성능에 크게 영향을 미치지 않았습니다.

내부 함수 건너뛰기

앞서 언급했듯이, 사전 파싱된 함수가 처음 호출될 때 우리는 해당 함수를 완전히 파싱하고, 얻어진 AST를 바이트코드로 컴파일합니다.

// 여기는 최상위 범위입니다.
function outer() {
// 사전 파싱됨
function inner() {
// 사전 파싱됨
}
}

outer(); // `outer`를 완전히 파싱 및 컴파일하지만, `inner`는 컴파일하지 않음.

함수는 변수 선언 값이 내부 함수에 제공되어야 하는 외부 컨텍스트를 직접 가리킵니다. 함수의 지연 컴파일을 허용하고 디버거를 지원하려면, 컨텍스트는 ScopeInfo라는 메타데이터 객체를 가리켜야 합니다. ScopeInfo 객체는 컨텍스트에 어떤 변수가 있는지 서술합니다. 이는 내부 함수 컴파일 시, 변수들이 컨텍스트 체인의 어디에 있는지를 계산할 수 있다는 의미입니다.

게으르게 컴파일된 함수 자체가 컨텍스트를 필요로 하는지 여부를 계산하려면 다시 스코프 해결을 수행해야 합니다. 즉, 게으르게 컴파일된 함수 내에 중첩된 함수가 게으른 함수에 의해 선언된 변수를 참조하는지 확인해야 합니다. 이를 알아내기 위해 해당 함수를 다시 준비 파싱(preparsing)합니다. V8은 정확히 V8 v6.3 / Chrome 63까지 이를 수행했습니다. 그러나 이는 성능 측면에서 이상적이지 않습니다. 소스 크기와 파싱 비용 간의 관계를 비선형적으로 만들어 버리기 때문입니다. 이는 함수가 중첩된 횟수만큼 함수 해석을 다시 수행해야 하기 때문입니다. 동적 프로그램의 자연스러운 중첩 외에도, JavaScript 패커는 일반적으로 코드를 “즉시 호출된 함수 표현식” (IIFE)에 래핑합니다. 이로 인해 대부분의 JavaScript 프로그램은 여러 중첩 계층을 가지게 됩니다.

각 재파싱은 최소한 함수 파싱 비용을 추가합니다.

비선형적인 성능 오버헤드를 피하기 위해, 우리는 준비 파싱 동안에도 전체 스코프 해석을 수행합니다. 충분한 메타데이터를 저장하여 이후 내부 함수를 간단히 건너뛸 수 있도록 합니다. 이렇게 하면 다시 준비 파싱을 수행할 필요가 없습니다. 한 가지 방법은 내부 함수가 참조하는 변수명을 저장하는 것입니다. 그러나 이는 저장하는 비용이 비싸고 작업을 여전히 중복하게 만듭니다. 우리는 이미 준비 파싱 동안 변수 해석을 수행했습니다.

대신, 우리는 변수가 할당된 위치를 변수별 플래그의 밀집 배열 형태로 직렬화합니다. 함수가 게으르게 파싱되면, 준비 파서(preparser)가 이를 본 순서대로 변수가 재생성되고 메타데이터를 변수에 간단히 적용할 수 있습니다. 함수가 컴파일된 후에는 변수 할당 메타데이터가 더 이상 필요하지 않으며 가비지 수집됩니다. 실제로 내부 함수를 포함하는 함수에만 이 메타데이터가 필요하므로, 모든 함수 중 상당 부분은 이 메타데이터를 필요로 하지 않아 메모리 오버헤드가 크게 감소합니다.

준비 파싱된 함수의 메타데이터를 추적하여 내부 함수를 완전히 생략할 수 있습니다.

내부 함수를 건너뛰는 성능 영향은 내부 함수를 다시 준비 파싱하는 오버헤드와 마찬가지로 비선형적입니다. 모든 함수를 최상위 스코프로 끌어올리는 사이트도 있습니다. 이러한 사이트의 중첩 수준은 항상 0 이므로 오버헤드도 항상 0 입니다. 그러나 많은 현대 사이트는 실제로 함수를 깊게 중첩합니다. 이러한 사이트에서는 이 기능이 V8 v6.3 / Chrome 63에 도입되었을 때 상당한 개선이 있었습니다. 주요 장점은 코드가 얼마나 깊이 중첩되었는지 더 이상 중요하지 않다는 점입니다. 모든 함수는 최대 한 번 준비 파싱되며, 한 번 완전히 파싱됩니다1.

내부 함수 생략 최적화를 도입하기 전과 후의 주요 스레드와 비주요 스레드 파싱 시간.

호출될 가능성이 있는 함수 표현식

앞서 언급했듯, 패커는 모듈 코드를 클로저에 래핑한 뒤 즉시 호출하여 여러 모듈을 단일 파일로 결합하는 경우가 많습니다. 이는 모듈에 격리를 제공하며, 스크립트 안의 유일한 코드로 실행될 수 있도록 합니다. 이러한 함수는 본질적으로 중첩된 스크립트로, 함수는 스크립트 실행 시 즉시 호출됩니다. 패커는 일반적으로 즉시 호출된 함수 표현식 (IIFE; ‘iffies’로 발음)을 괄호로 감싼 함수로 제공: (function(){…})().

이러한 함수는 스크립트 실행 중 즉시 필요하므로, 이를 준비 파싱하는 것은 이상적이지 않습니다. 스크립트의 최상위 실행 중에 우리는 즉시 컴파일된 함수가 필요하며, 우리는 이 함수를 완전히 파싱 및 컴파일합니다. 이는 이전에 빠른 파싱을 수행하여 시작을 빠르게 하려는 시도가 시작에 대한 추가 비용으로 고정되는 것을 의미합니다.

단순히 호출된 함수를 컴파일하면 되는 것 아니냐고 물을 수 있습니다. 개발자가 함수가 호출되었는지 확인하기는 대체로 간단하지만, 파서에게는 그렇지 않습니다. 파서는 함수를 파싱하기 시작하기 전에 함수의 즉각적 컴파일 여부를 결정해야 합니다. 문법의 애매함 때문에 단순히 함수 끝까지 빠르게 스캔하기가 어렵고, 이는 준비 파싱의 일반적인 비용과 유사하게 빠르게 증가합니다.

이러한 이유로 V8은 단순한 두 패턴을 호출 가능성 있는 함수 표현식 (PIFE; ‘piffies’로 발음)으로 인지하며, 이에 따라 즉시 함수 파싱 및 컴파일을 수행합니다:

  • 함수가 괄호로 감싸진 함수 표현식일 경우, 즉 (function(){…}), 우리는 함수가 호출될 것이라고 가정합니다. 이 패턴의 시작, 즉 (function를 본 즉시 이 가정을 합니다.
  • V8 v5.7 / Chrome 57 이후로 우리는 UglifyJS에서 생성된 !function(){…}(),function(){…}(),function(){…}() 패턴도 감지합니다. 이 감지는 !function, 또는 바로 이어지는 PIFE 뒤의 ,function를 볼 때 시작됩니다.

V8이 PIFE를 즉시 컴파일함에 따라 PIFE는 프로파일 기반 피드백2으로 사용될 수 있으며, 브라우저에게 시작에 필요한 함수 정보를 제공합니다.

V8가 여전히 내부 함수를 다시 파싱하던 시기에, 일부 개발자들은 JS 파싱이 초기 구동에 미치는 영향이 상당히 크다는 것을 발견했습니다. optimize-js 패키지는 정적 휴리스틱을 기반으로 함수들을 PIFE로 변환합니다. 패키지가 만들어졌던 당시에는 V8의 로딩 성능에 큰 영향을 미쳤습니다. V8 v6.1에서 optimize-js에 제공된 벤치마크를 실행해 이러한 결과를 재현했으며, 축소된 스크립트만을 대상으로 했습니다.

PIFE를 적극적으로 파싱 및 컴파일하면 초기 및 재구동(startup 및 재구동)이 약간 더 빨라집니다(첫 번째 및 두 번째 페이지 로드, 총 파싱 + 컴파일 + 실행 시간을 측정). 그러나 V8 v6.1에서는 혜택이 더 컸던 반면, V8 v7.5에서는 parser의 상당한 개선 덕분에 혜택이 훨씬 작아졌습니다.

그러나 이제 우리가 더 이상 내부 함수를 다시 파싱하지 않고, parser가 훨씬 빨라졌기 때문에 optimize-js를 통해 얻은 성능 개선은 크게 감소했습니다. 실제로 v7.5의 기본 설정이 이미 v6.1에서 실행된 최적화된 버전보다 훨씬 빠릅니다. 심지어 v7.5에서도 구동 중에 필요한 코드에 대해 PIFE를 적절히 사용하는 것이 여전히 합리적일 수 있습니다. 우리는 함수가 필요하다는 것을 조기에 학습함으로써 사전 파싱(preparse)을 피합니다.

optimize-js 벤치마크 결과는 실세계 환경을 정확히 반영하지 않습니다. 스크립트가 동기적으로 로드되고 전체 파싱 + 컴파일 시간이 로드 시간에 포함됩니다. 실제 환경에서는 <script> 태그를 사용해 스크립트를 로드하는 경우가 많습니다. 이는 Chrome의 프리로더가 스크립트를 실행 전에 발견하여 해당 스크립트를 다운로드하고 파싱 및 컴파일을 수행하도록 하여 메인 스레드를 차단하지 않도록 합니다. 우리가 적극적으로 컴파일하기로 결정한 모든 것은 자동으로 메인 스레드 외부에서 컴파일되며 초기 구동에 대한 비용은 최소화됩니다. 메인 스레드 외부에서 스크립트를 컴파일할 때 PIFE를 사용하는 효과가 더욱 커집니다.

그러나 여전히 비용이 있으며 특히 메모리 비용이 발생하므로 모든 것을 적극적으로 컴파일하는 것은 좋은 생각이 아닙니다:

모든 JavaScript를 적극적으로 컴파일하면 상당한 메모리 비용이 발생합니다.

초기 구동 중에 필요한 함수에 괄호를 추가하는 것은 좋은 아이디어입니다(예: 초기 프로파일링을 기반으로). 하지만 간단한 정적 휴리스틱을 적용하는 optimize-js와 같은 패키지를 사용하는 것은 좋은 아이디어가 아닙니다. 예를 들어, 함수 호출의 인자로 전달된 함수는 초기 구동 중 호출된다고 가정합니다. 그러나 해당 함수가 나중에만 필요로 하는 전체 모듈을 구현하는 경우에는 너무 많이 컴파일하게 됩니다. 과도한 컴파일은 성능에 나쁜 영향을 끼칩니다: V8은 지연 컴파일 없이 구동 시간이 상당히 퇴보합니다. 또한, optimize-js의 이점 중 일부는 UglifyJS 및 다른 압축기가 PIFE가 아닌 함수 표현식에서 괄호를 제거하여 예를 들어 Universal Module Definition-스타일 모듈에 적용할 수 있었던 유용한 힌트를 제거함으로써 발생합니다. 이는 브라우저에서 PIFE를 적극적으로 컴파일하여 최대 성능을 얻으려는 경우 압축기가 수정해야 할 문제일 가능성이 큽니다.

결론

지연 파싱은 초기 구동을 빠르게 하고 애플리케이션이 필요로 하는 양보다 더 많은 코드를 배송할 때 메모리 오버헤드를 줄여줍니다. 변수 선언 및 참조를 제대로 추적할 수 있도록 사전 파싱기가 필요한데, 이는 사전 파싱을 정확히(규격에 따라) 및 빠르게 수행할 수 있도록 합니다. 사전 파싱기에서 변수를 할당하면 변수 할당 정보를 나중에 parser에서 사용할 수 있도록 직렬화하여 내부 함수를 다시 사전 파싱할 필요 없이 깊게 중첩된 함수의 비선형 파싱 동작을 피할 수 있게 합니다.

파싱기가 인식할 수 있는 PIFE는 초기 사전 파싱 오버헤드를 피하며 초기 구동 중에 즉시 필요한 코드에 유용합니다. 신중한 프로파일 기반 PIFE 사용 또는 패키지에서 사용하면 초기 구동 속도를 개선할 수 있습니다. 그러나 이 휴리스틱을 활성화하기 위해 함수를 불필요하게 괄호로 감싸는 것은 피해야 하며, 이는 더 많은 코드를 적극적으로 컴파일하게 만들어 초기 구동 성능이 악화되고 메모리 사용량이 증가하게 됩니다.

Footnotes

  1. 메모리 문제로 인해 V8은 바이트코드 플러싱을 수행합니다. 일정 기간 사용되지 않으면 해당 바이트코드를 플러싱합니다. 코드가 나중에 다시 필요한 경우 이를 다시 파싱하고 컴파일합니다. 컴파일 중에 변수 메타데이터를 허용하여 사라지게 합니다. 이는 게으른 재컴파일 시 내부 함수를 다시 파싱하게 만듭니다. 그러나 이 시점에서 내부 함수의 메타데이터를 다시 생성하므로 내부 함수의 내부 함수를 다시 준비 파싱할 필요가 없습니다.

  2. PIFE는 프로파일 정보를 기반으로 한 함수 표현식(Profile-Informed Function Expression)으로 볼 수도 있습니다.