본문으로 건너뛰기

Sparkplug — 최적화를 하지 않는 JavaScript 컴파일러

· 약 10분
[Leszek Swirski](https://twitter.com/leszekswirski) — 어쩌면 가장 똑똑한 스파크는 아닐지 모르지만, 적어도 가장 빠른 스파크

고성능의 JavaScript 엔진을 작성한다는 것은 단순히 TurboFan과 같은 고도로 최적화된 컴파일러를 가지는 것 이상이 필요합니다. 특히 웹사이트를 로드하거나 커맨드라인 도구를 사용하는 짧은 세션에서는 최적화 컴파일러가 최적화를 시작하기도 전에 많은 작업이 이루어집니다. 최적화된 코드를 생성하는 데까지는 시간이 걸립니다.

이것이 바로 우리가 2016년부터 실제 세계 성능을 측정하는 것으로 초점을 전환했고, 최적화 컴파일러 외부 JavaScript 성능 향상을 위해 열심히 노력해 온 이유입니다. 여기에는 파서 작업, 스트리밍, 객체 모델, 가비지 컬렉터의 동시성, 컴파일된 코드 캐시 작업 등이 포함되어 있습니다. 말하자면, 우리는 결코 지루하지 않았습니다.

하지만 실제 초기 JavaScript 실행 성능을 개선하려다 보면, 인터프리터 최적화에서 한계에 부딪히게 됩니다. V8의 인터프리터는 고도로 최적화되어 매우 빠르지만, 인터프리터는 고유한 오버헤드가 있어서 제거할 수 없습니다. 바이트코드 디코딩 오버헤드 또는 디스패치 오버헤드 같은 것들이 인터프리터 기능의 본질적인 부분입니다.

현재의 두 개의 컴파일러 모델로는 최적화된 코드로의 계층 전환을 더 빠르게 할 수는 없습니다. 우리는 최적화를 더 빠르게 만들기 위해 작업하고 있지만, 최적화 과정을 제거하여 최고 성능을 줄이는 것 외에는 더 빠르게 할 수 없습니다. 더 나쁜 것은, 안정적인 객체 모양 피드백을 아직 얻지 못했기 때문에 더 일찍 최적화를 시작할 수는 없습니다.

여기 Sparkplug가 등장합니다: V8 v9.1과 함께 출시되는 새로운 최적화 없는 JavaScript 컴파일러로, Ignition 인터프리터와 TurboFan 최적화 컴파일러 사이를 메우고 있습니다.

새로운 컴파일러 파이프라인

빠른 컴파일러

Sparkplug는 빠르게 컴파일하도록 설계되었습니다. 매우 빠르게. 그래서 우리가 원할 때마다 거의 컴파일할 수 있으며, TurboFan 코드보다 훨씬 더 적극적으로 Sparkplug 코드로 계층을 전환할 수 있습니다.

Sparkplug 컴파일러를 빠르게 만드는 몇 가지 트릭이 있습니다. 첫 번째는 속이는 것입니다. Sparkplug가 컴파일하는 함수는 이미 바이트코드로 컴파일되었으며, 바이트코드 컴파일러가 변수 해결, 괄호가 실제로 화살표 함수인지 알아내기, 구조 분해 문장 디슈가링과 같은 힘든 작업 대부분을 이미 완료했습니다. Sparkplug는 JavaScript 소스가 아니라 바이트코드에서 컴파일하므로 이러한 작업을 신경 쓸 필요가 없습니다.

두 번째 트릭은 Sparkplug가 대부분의 컴파일러가 가지는 중간 표현(IR)을 생성하지 않는다는 것입니다. 대신, Sparkplug는 바이트코드를 단일 선형 패스로 처리하며 해당 바이트코드의 실행에 맞는 코드를 생성하며 직접 기계 코드로 컴파일합니다. 사실, 전체 컴파일러는 for 루프 안의 switch를 사용하며, 고정된 바이트코드별 기계 코드 생성 함수로 디스패치합니다.

// Sparkplug 컴파일러 (축약됨).
for (; !iterator.done(); iterator.Advance()) {
VisitSingleBytecode();
}

IR의 부재는 컴파일러가 매우 지역적인 구멍 비최적화(peehole optimizations)를 넘어 제한된 최적화 기회를 가진다는 것을 의미합니다. 또한 중간 단계에서 아키텍처 간독립의 중간 단계를 사용할 수 없기 때문에 지원하는 각 아키텍처마다 전체 구현을 별도로 포팅해야 합니다. 그러나 이러한 문제는 실제로 문제가 되지 않습니다: 빠른 컴파일러는 간단한 컴파일러이며, 코드가 포팅하기에 매우 쉽습니다. 또한 Sparkplug는 무거운 최적화를 필요로 하지 않는데, 파이프라인에서 나중에 훌륭한 최적화 컴파일러를 보유하고 있기 때문입니다.

::: note 기술적으로 우리는 현재 바이트코드에 대해 두 번의 단계를 수행합니다. 하나는 루프를 발견하기 위한 것이고, 다른 하나는 실제 코드를 생성하는 단계입니다. 그러나 우리는 결국 첫 번째 단계를 제거할 계획입니다. :::

인터프리터-호환 프레임

기존의 성숙한 자바스크립트 VM에 새로운 컴파일러를 추가하는 것은 어려운 작업입니다. 표준 실행 외에도 지원해야 할 다양한 것이 있습니다. V8에는 디버거, 스택-워킹 CPU 프로파일러, 예외를 위한 스택 트레이스, 티어-업 통합, 핫 루프에 대해 최적화된 코드로 대체하는 스택 교체 등이 있습니다. 이는 많은 작업입니다.

Sparkplug는 “인터프리터-호환 스택 프레임”을 유지한다는 깔끔한 속임수를 사용하여 이러한 문제 대부분을 간단히 해결합니다.

조금 되돌아보겠습니다. 스택 프레임은 코드 실행이 함수 상태를 저장하는 방법입니다. 새로운 함수를 호출할 때마다 해당 함수의 지역 변수를 위한 새로운 스택 프레임을 생성합니다. 스택 프레임은 프레임 포인터(시작점 표시)와 스택 포인터(종료점 표시)에 의해 정의됩니다:

스택 프레임, 스택 및 프레임 포인터

::: note

이 시점에서 여러분 중 대략 절반은 "이 다이어그램은 말이 안 됩니다, 스택은 분명히 반대 방향으로 성장합니다!"라고 소리칠 것입니다. 걱정하지 마세요, 저는 여러분을 위한 버튼을 만들었습니다:

:::

함수가 호출되면 반환 주소가 스택에 푸시됩니다; 이는 함수가 반환할 때 팝되어 반환할 위치를 알게 됩니다. 그런 다음 해당 함수가 새 프레임을 생성하면 이전 프레임 포인터를 스택에 저장하고 새 프레임 포인터를 자신의 스택 프레임 시작으로 설정합니다. 따라서 스택에는 프레임의 시작점을 표시하고 이전 프레임을 가리키는 프레임 포인터의 체인이 있습니다:

여러 호출에 대한 스택 프레임

::: note 엄밀히 말하면, 이것은 생성된 코드에서 따르는 관례일 뿐 요구 사항은 아닙니다. 그러나 이는 매우 보편적인 관례입니다; 스택 프레임이 완전히 생략되었거나 디버깅 사이드 테이블을 사용하여 스택 프레임을 탐색할 때만 이러한 관례가 깨집니다. :::

이것은 모든 유형의 함수에 대한 일반적인 스택 레이아웃입니다; 그런 다음 인수가 전달되는 방식과 함수가 프레임에서 값을 저장하는 방식에 대한 관례가 있습니다. V8에서는 함수가 호출되기 전에 인수(수신자를 포함)가 스택에 역순으로 푸시된다는 자바스크립트 프레임용 관례가 있습니다. 그리고 스택의 첫 번째 몇 개 슬롯은 다음과 같습니다: 현재 호출 중인 함수, 해당 함수와 함께 호출되는 컨텍스트, 전달된 인수의 수. 이것이 우리의 “표준” JS 프레임 레이아웃입니다:

V8 자바스크립트 스택 프레임

이 JS 호출 관례는 최적화된 프레임과 해석된 프레임 간에 공유되며, 예를 들어 디버거의 성능 패널에서 코드를 프로파일링할 때 스택을 거의 오버헤드 없이 탐색할 수 있도록 합니다.

Ignition 인터프리터의 경우, 관례가 더 명시적으로 됩니다. Ignition은 레지스터 기반 인터프리터로, 자바스크립트 함수의 로컬 변수(var/let/const 선언)와 임시 값 등을 포함하여 인터프리터의 현재 상태를 저장하는 가상 레지스터(머신 레지스터와 혼동하지 마세요)를 보유합니다. 이들 레지스터는 실행 중인 바이트코드 배열에 대한 포인터와 해당 배열 내 현재 바이트코드의 오프셋과 함께 인터프리터의 스택 프레임에 저장됩니다:

V8 인터프리터 스택 프레임

Sparkplug는 의도적으로 인터프리터의 프레임과 일치하는 프레임 레이아웃을 생성하고 유지합니다; 인터프리터가 레지스터 값을 저장했을 때, Sparkplug도 동일하게 저장합니다. Sparkplug가 이를 수행하는 이유는 다음과 같습니다:

  1. Sparkplug 컴파일을 단순화합니다; Sparkplug는 인터프리터의 동작을 간단히 반영함으로써 인터프리터 레지스터에서 Sparkplug 상태로의 매핑을 유지할 필요가 없습니다.
  2. 컴파일 속도를 또한 향상시킵니다, 바이트코드 컴파일러가 이미 레지스터 할당의 어려운 작업을 완료했기 때문입니다.
  3. 시스템과의 통합을 거의 쉽게 만듭니다; 디버거, 프로파일러, 예외 스택 스택 해제, 스택 트레이스 출력 등의 모든 작업은 실행 중인 함수의 현재 스택을 발견하기 위해 스택 워크를 수행하며, Sparkplug는 인터프리터 프레임처럼 보이므로 이러한 작업이 거의 변경 없이 계속 작동합니다.
  4. 이것은 스택 상 교체(On-Stack Replacement, OSR)를 쉽게 만듭니다. OSR은 현재 실행 중인 함수가 실행 중에 교체되는 경우를 말합니다. 현재는 인터프리터된 함수가 핫 루프 내에 있을 때(해당 루프에 대해 최적화된 코드로 업그레이드됨)와 최적화된 코드가 비최적화 코드로 다운그레이드되며 함수 실행을 인터프리터에서 계속할 때 발생합니다. Sparkplug 프레임이 인터프리터 프레임을 반영함으로써, 인터프리터에 작동하는 어떤 OSR 로직도 Sparkplug에서 작동합니다. 더 나아가 거의 프레임 변화 비용 없이 인터프리터와 Sparkplug 코드 간에 교체할 수 있습니다.

인터프리터 스택 프레임에 약간의 변경을 가했는데, 이는 Sparkplug 코드 실행 중에는 바이트코드 오프셋(bytecode offset)을 최신 상태로 유지하지 않는 것입니다. 대신, Sparkplug 코드 주소 범위에서 해당되는 바이트코드 오프셋으로의 양방향 매핑을 저장합니다. 이 매핑은 Sparkplug 코드가 바이트코드 위로 선형적으로 진행되며 생성되기 때문에 비교적 단순한 매핑입니다. 스택 프레임 액세스를 통해 Sparkplug 프레임의 “바이트코드 오프셋”을 알아야 할 때, 현재 실행 중인 명령어를 이 매핑에서 찾아보고 해당 바이트코드 오프셋을 반환합니다. 이와 유사하게, 인터프리터에서 Sparkplug로 OSR을 하고자 할 때, 현재 바이트코드 오프셋을 매핑에서 찾아보고 해당되는 Sparkplug 명령으로 점프할 수 있습니다.

이제 우리가 바이트코드 오프셋이 있을 자리에 스택 프레임의 사용되지 않은 슬롯이 생긴 것을 알 수 있습니다. 그러나 스택의 나머지 부분은 변경 없이 유지하고자 하기 때문에 이 슬롯을 없앨 수는 없습니다. 대신, 이 스택 슬롯을 “피드백 벡터(feedback vector)”를 캐싱하는 용도로 다시 사용합니다. 피드백 벡터는 객체 형태 데이터를 저장하며 대부분의 작업에서 로드해야 합니다. 우리가 해야 할 일은 올바른 바이트코드 오프셋이나 올바른 피드백 벡터를 이 슬롯에 교체하도록 OSR 주위를 조금 신경 쓰는 것입니다.

따라서 Sparkplug 스택 프레임은 다음과 같습니다:

V8 Sparkplug 스택 프레임

내장 기능 위임

사실 Sparkplug는 자체 코드를 거의 생성하지 않습니다. JavaScript의 의미론은 복잡하며, 가장 간단한 작업조차 수행하기 위해 많은 코드가 필요합니다. Sparkplug가 각 컴파일 시 이러한 코드를 다시 생성하도록 강제한다면, 여러 가지 이유로 나쁘게 작용합니다:

  1. 필요 코드를 생성해야 하는 sheer 양 때문에 컴파일 시간이 눈에 띄게 증가할 것입니다,
  2. Sparkplug 코드의 메모리 소비가 증가할 것입니다,
  3. JavaScript의 여러 기능에 대해 Sparkplug용 코드 생성을 다시 구현해야 하며, 이는 더 많은 버그와 더 큰 보안 위협 표면으로 이어질 가능성이 있습니다.

따라서 이 모든 대신에 대부분의 Sparkplug 코드는 수행해야 할 실제 작업을 위해 바이너리에 내장된 작은 머신 코드 조각인 “내장 함수(builtins)”를 호출합니다. 이 내장 함수는 인터프리터가 사용하는 것과 동일하거나, 적어도 인터프리터의 바이트코드 핸들러 코드의 대부분을 공유합니다.

실제로 Sparkplug 코드는 기본적으로 내장 함수 호출과 제어 흐름으로 구성됩니다:

여기서 여러분은 아마도 이렇게 생각할 것입니다, “그럼 이 모든 것의 요점이 뭐야? Sparkplug도 인터프리터와 똑같은 일을 하고 있는 게 아닌가?” — 그리고 여러분이 완전히 틀렸다고 할 수는 없습니다. 여러 면에서 Sparkplug는 인터프리터 실행을 단순히 직렬화한 것이며, 똑같은 내장 함수를 호출하고 동일한 스택 프레임을 유지합니다. 그럼에도 불구하고, 이 자체만으로도 가치가 있습니다. 왜냐하면 피할 수 없는 인터프리터 오버헤드(연산자 디코딩과 다음 바이트코드 디스패치 등)를 제거하거나, 정확히 말하면 미리 컴파일하기 때문입니다.

결국 인터프리터는 많은 CPU 최적화를 방해합니다: 정적 연산자가 인터프리터에 의해 동적으로 메모리에서 읽히며, CPU가 스톨하거나 값이 무엇일지 추측해야 하기 때문입니다. 다음 바이트코드를 디스패치하려면 성능을 유지하기 위해 성공적인 분기 예측이 필요하며, 심지어 예측과 추정이 정확하더라도 이 모든 디코딩과 디스패치 코드를 실행하고 여전히 다양한 버퍼와 캐시에 값을 계속 차지합니다. CPU는 효과적으로 머신 코드의 인터프리터입니다. 이런 관점에서 볼 때, Sparkplug는 Ignition 바이트코드에서 CPU 바이트코드로 변환하는 “트랜스파일러”로서, 여러분의 함수를 “에뮬레이터”에서 실행되는 상태에서 “네이티브”로 실행되는 상태로 전환하는 역할을 합니다.

성능

그렇다면 Sparkplug는 실제로 얼마나 잘 작동할까요? Sparkplug 포함 여부에 따라 Chrome 91을 몇 가지 성능 측정 봇에서 몇 가지 벤치마크와 함께 실행시켜 그 영향을 확인했습니다.

스포일러 경고: 우리는 꽤 만족하고 있습니다.

::: note 아래 벤치마크는 다양한 운영 체제를 실행하는 여러 봇을 나열합니다. 봇의 이름에서 운영 체제가 두드러지지만, 실제로 결과에 큰 영향을 미친다고 생각하지 않습니다. 오히려 다른 머신은 또한 서로 다른 CPU 및 메모리 구성도 가지고 있어 차이의 주요 원인이라 봅니다. :::

Speedometer

Speedometer는 TODO 목록 추적 웹앱을 몇 가지 인기 프레임워크를 사용하여 빌드하고, TODO 추가 및 삭제 시 해당 앱의 성능을 스트레스 테스트함으로써 실제 웹사이트 프레임워크 사용을 에뮬레이트하도록 설계된 벤치마크입니다. 우리는 이 벤치마크가 현실 세계의 로드 및 상호작용 동작을 매우 잘 반영한다고 보고 있으며, Speedometer 개선은 실세계 메트릭에서도 꾸준히 반영되고 있다는 것을 확인했습니다.

Sparkplug를 사용하면 어떠한 봇을 보느냐에 따라 Speedometer 점수가 5-10% 개선됩니다.

스피도미터 점수 향상 중간값 - Sparkplug 사용, 여러 퍼포먼스 봇에서 측정. 오류 막대는 사분위 범위를 나타냅니다.

브라우징 벤치마크

스피도미터는 훌륭한 벤치마크이지만 전체 이야기를 보여주는 것은 아닙니다. 우리는 추가로 '브라우징 벤치마크' 세트를 보유하고 있습니다. 이는 실제 웹사이트 세트를 녹화하여 재생, 약간의 상호작용 스크립트 실행을 통해 다양한 메트릭이 실제 세계에서 어떻게 작동하는지 더 현실적으로 파악할 수 있게 해주는 것입니다.

이 벤치마크에서 우리는 'V8 메인 스레드 시간' 메트릭을 선택하여 측정했습니다. 이는 메인 스레드에서 V8에서 소비한 총 시간을 측정하는 것으로, 여기에는 컴파일 및 실행 시간이 포함되고 스트리밍 파싱이나 백그라운드 최적화 컴파일은 제외됩니다. 이는 다른 벤치마크 노이즈 소스를 배제하면서 Sparkplug의 성능이 얼마나 잘 효과를 내는지 확인할 수 있는 최고의 방법입니다.

결과는 다양하며 기계와 웹사이트에 매우 의존적이지만 전반적으로 훌륭해 보입니다: 대략 5–15%의 향상이 관찰됩니다.

::: figure 브라우징 벤치마크에서 10번 반복 실행 시 V8 메인 스레드 시간 개선 중간값. 오류 막대는 사분위 범위를 나타냅니다. Linux-perf 봇의 결과 Win-10-perf 봇의 결과 benchmark-browsing-mac-10_13_laptop_high_end-perf 봇의 결과 Mac-10_12_laptop_low_end-perf 봇의 결과 Mac-m1_mini_2020 봇의 결과 :::

결론적으로: V8에는 새로운 초고속 비최적화 컴파일러가 있으며, 이는 실제 세계 벤치마크에서 V8 성능을 5–15% 향상시킵니다. 이 컴파일러는 이미 V8 v9.1에서 --sparkplug 옵션으로 사용할 수 있으며 Chrome 91에서 이를 배포할 예정입니다.