Maglev - V8의 가장 빠른 최적화 JIT
Chrome M117에서 우리는 새로운 최적화 컴파일러인 Maglev을 소개했습니다. Maglev은 기존의 Sparkplug와 TurboFan 컴파일러 사이에 자리 잡으며, 적당히 좋은 코드와 적당히 빠른 속도로 생성하는 빠른 최적화 컴파일러 역할을 합니다.
2021년까지 V8에는 두 가지 주요 실행 계층이 있었습니다: 인터프리터 Ignition 및 TurboFan, V8의 성능 극대화를 목표로 한 최적화 컴파일러. 모든 JavaScript 코드는 먼저 Ignition 바이트코드로 컴파일되고 인터프리팅되어 실행됩니다. 실행되는 동안 V8은 프로그램의 행동을 추적하며 객체의 형태와 타입을 추적합니다. 실행 메타데이터와 바이트코드는 최적화 컴파일러에 전달되어 인터프리터보다 훨씬 빠르게 실행되는 고성능의 종종 추측적인 머신 코드로 생성됩니다.
이 개선은 JetStream과 같은 벤치마크에서 명확히 드러납니다. 이 벤치마크는 시작, 지연 및 최고 성능을 측정하는 순수한 JavaScript 벤치마크 모음입니다. TurboFan은 V8이 이 모음을 4.35배 빠르게 실행할 수 있도록 돕습니다! JetStream은 과거의 벤치마크(예: 은퇴한 Octane 벤치마크)와 비교하여 안정 상태 성능에 대한 강조가 줄었지만, 간단한 항목 때문에 최적화된 코드가 여전히 대부분의 시간을 차지합니다.
Speedometer는 JetStream과 다른 종류의 벤치마크 모음입니다. 이것은 웹 애플리케이션의 응답성을 측정하기 위해 사용자 상호작용을 시뮬레이션하여 시간을 측정하도록 설계되었습니다. 작은 정적 독립 실행형 JavaScript 앱 대신 모음은 대부분 인기 있는 프레임워크를 사용하여 제작된 전체 웹 페이지로 구성됩니다. 대부분의 웹 페이지 로드 중에 Speedometer 항목은 타이트한 JavaScript 루프를 실행하는 데 훨씬 적은 시간을 소비하며 브라우저의 다른 부분과 상호작용하는 많은 코드를 실행합니다.
TurboFan은 Speedometer에서도 여전히 큰 영향을 미칩니다: 실행 속도가 1.5배 이상 빠릅니다! 하지만 JetStream에 비해 영향은 훨씬 더 적습니다. 이러한 차이의 일부는 전체 페이지가 순수 JavaScript에 소비하는 시간이 적기 때문입니다. 그러나 일부는 벤치마크가 TurboFan에 의해 최적화될 만큼 뜨거워지지 않는 기능에 많은 시간을 소비하기 때문입니다.
::: note 이 게시물의 모든 벤치마크 점수는 13” M2 MacBook Air에서 Chrome 117.0.5897.3으로 측정되었습니다. :::
Ignition과 TurboFan 사이에서 실행 속도와 컴파일 시간의 차이가 매우 크기 때문에, 2021년에 Sparkplug라는 새로운 기본 JIT을 도입했습니다. 이것은 바이트코드를 동등한 머신 코드로 거의 즉각적으로 컴파일하도록 설계되었습니다.
JetStream에서는 Sparkplug가 Ignition에 비해 성능을 상당히 향상시키고 (+45%), TurboFan도 포함된 상태에서도 여전히 견고한 성능 향상을 보여줍니다 (+8%). Speedometer에서는 Ignition보다 41% 향상되어 TurboFan 성능에 가까워지고 Ignition + TurboFan보다 22% 향상됩니다! Sparkplug는 매우 빠르기 때문에 널리 배포하여 일관된 속도 향상을 쉽게 얻을 수 있습니다. 코드가 쉽게 최적화되는 긴 실행 타이트한 JavaScript 루프에만 의존하지 않는다면 훌륭한 추가 요소입니다.
Sparkplug의 단순함은 제공할 수 있는 속도 향상의 상한선을 상대적으로 낮게 만듭니다. 이는 Ignition + Sparkplug와 Ignition + TurboFan 사이의 큰 간극에서 명확히 드러납니다.
이런 상황에서 Maglev이 등장합니다. Maglev은 Sparkplug 코드보다 훨씬 빠른 코드를 생성하지만, TurboFan보다 훨씬 빠르게 생성하는 새로운 최적화 JIT입니다.
Maglev: 간단한 SSA 기반 JIT 컴파일러
이 프로젝트를 시작할 때 Sparkplug와 TurboFan 간의 격차를 메우기 위해 두 가지 길이 있다고 생각했습니다. Sparkplug에서 사용하는 단일 패스 접근 방식을 사용하여 더 나은 코드를 생성하려고 시도하거나, 중간 표현(IR)을 사용하는 JIT을 구축하는 방법이 있었습니다. 컴파일 중에 IR이 전혀 없으면 컴파일러가 크게 제한될 가능성이 높다고 판단했기 때문에, TurboFan의 더 유연하지만 캐시 친화적이지 않은 sea-of-nodes 표현 대신 CFG(제어 흐름 그래프)를 사용하는 다소 전통적인 정적 단일 할당(SSA) 기반 접근 방식을 선택했습니다.
컴파일러 자체는 빠르고 작업하기 쉽게 설계되었습니다. 최소한의 통과 집합과 단순하면서 특정 자바스크립트 의미론을 인코딩한 단일 IR을 가지고 있습니다.
사전 패스
먼저 Maglev는 바이트코드를 사전 패스하여 루프를 포함한 분기 대상 및 루프 내 변수에 대한 할당을 찾습니다. 이 패스는 변수의 어느 값이 어느 표현식에 걸쳐 여전히 필요한지를 인코딩하여 생명주기 정보를 수집합니다. 이 정보는 이후에 컴파일러가 추적해야 할 상태의 양을 줄일 수 있습니다.
SSA
Maglev는 프레임 상태의 추상 해석을 수행하여 표현식 결과를 나타내는 SSA 노드를 생성합니다. 변수 할당은 해당 SSA 노드를 추상 해석기 레지스터에 저장함으로써 모방됩니다. 분기 및 스위치의 경우 모든 경로가 평가됩니다.
여러 경로가 병합될 때, 추상 해석기 레지스터의 값은 런타임에 따라 선택할 값을 알고 있는 소위 Phi 노드를 삽입하여 병합됩니다.
루프의 경우, 루프 본문에서 변수가 할당되는 경우 데이터가 루프 끝에서 루프 헤더로 역방향으로 흐릅니다. 사전 패스 데이터는 여기서 유용하게 작용합니다: 우리는 이미 루프 내부에 할당된 변수를 알고 있기 때문에, 루프 본문을 처리하기 시작하기도 전에 루프 Phi를 미리 생성할 수 있습니다. 루프가 끝날 때, 우리는 올바른 SSA 노드로 Phi 입력을 채울 수 있습니다. 이를 통해 SSA 그래프 생성을 단일 정방향 패스로 만들 수 있으며, 루프 변수를 "수정"할 필요 없이 또한 필요한 Phi 노드의 양을 최소화할 수 있습니다.
알려진 노드 정보
가능한 한 빠르게 하기 위해, Maglev는 가능한 한 많은 작업을 즉시 수행합니다. 나중에 최적화 단계에서 일반적인 자바스크립트 그래프를 생성한 뒤 이를 낮추는 대신, 이론적으로는 깨끗하지만 계산적으로는 비싼 접근 방식으로 그래프를 빌드하는 동안 Maglev는 가능한 한 많은 작업을 바로 수행합니다.
그래프를 빌드하는 동안 Maglev는 비최적화 실행 중 수집된 런타임 피드백 메타데이터를 확인하고, 관찰된 유형에 대한 특화된 SSA 노드를 생성합니다. Maglev가 o.x
를 보고 o
가 항상 특정한 형태를 가진다는 런타임 피드백을 알게 되면, 런타임에 o
가 여전히 예상된 형태를 가지고 있는지 확인하는 SSA 노드를 생성한 후, 오프셋을 통해 간단하게 접근하는 저렴한 LoadField
노드를 생성합니다.
또한, Maglev는 o
의 형태를 이미 알고 있으므로 나중에 다시 확인할 필요가 없는 사이드 노드를 생성합니다. Maglev가 나중에 어떤 이유로든 피드백이 없는 o
에 대한 연산을 만나면, 컴파일 중 학습된 이러한 정보를 두 번째 피드백 소스로 사용할 수 있습니다.
런타임 정보는 다양한 형태로 제공될 수 있습니다. 이전에 설명한 형태 검사와 같이, 일부 정보는 런타임에 확인해야 합니다. 다른 정보는 런타임에 의존성을 등록하여 런타임 검사 없이 사용할 수 있습니다. 사실상 상수(초기화 후 값이 Maglev에 의해 관찰되기 전까지 변경되지 않는)인 글로벌이 여기에 해당합니다: Maglev는 이들의 정체성을 동적으로 로드하고 확인하기 위한 코드를 생성할 필요가 없습니다. Maglev는 컴파일 시 값을 로드하고 이를 머신 코드에 직접 포함시킬 수 있으며, 런타임이 글로벌을 변형한다면, 해당 머신 코드를 무효화하고 최적화 해제하는 것도 처리합니다.
일부 형태의 정보는 '불안정'합니다. 그러한 정보는 컴파일러가 해당 정보가 변경되지 않을 것이라고 확신할 수 있는 정도까지만 사용될 수 있습니다. 예를 들어, 우리가 막 객체를 할당했다면, 그것이 새로운 객체라는 것을 알고 있으며 비용이 많이 드는 기록 배리어를 완전히 건너뛸 수 있습니다. 다른 잠재적인 할당이 발생한 후에야, 가비지 컬렉터가 객체를 이동했을 가능성이 있기 때문에 이제 그러한 검사가 필요하게 됩니다. 다른 정보는 '안정적'입니다: 우리가 어떤 객체도 특정 형태를 벗어나지 않는 것을 한 번도 본 적이 없다면, 이 이벤트(특정 형태를 벗어나는 모든 객체)에 대한 의존성을 등록하고, 알 수 없는 함수의 부작용 후에도 객체의 형태를 다시 확인할 필요가 없습니다.
최적화 해제
Maglev는 실행 시간에 확인하는 추측적 정보를 사용할 수 있으므로, Maglev 코드에는 비최적화로 되돌리는 기능이 필요합니다. 이를 위해 Maglev는 비최적화가 나타날 수 있는 노드에 추상 인터프리터 프레임 상태를 연결합니다. 이 상태는 인터프리터 레지스터를 SSA 값에 매핑합니다. 이 상태는 코드 생성 중 메타데이터로 변환되어 최적화된 상태와 비최적화된 상태 간의 매핑을 제공합니다. Deoptimizer는 이 데이터를 해석하여, 인터프리터 프레임과 머신 레지스터에서 값을 읽고 이를 해석에 필요한 위치에 배치합니다. 이는 TurboFan에서 사용되는 동일한 비최적화 메커니즘을 기반으로 하며, 대부분의 논리를 공유하고 기존 시스템의 테스트 혜택을 활용할 수 있게 합니다.
표현 선택
JavaScript 숫자는 명세에 따르면 64비트 부동소수점 값으로 표현됩니다. 하지만 실제로 많은 숫자가 작은 정수(예: 배열 인덱스)로 나타나기 때문에 엔진이 항상 이를 64비트 부동소수점으로 저장해야 한다는 의미는 아닙니다. V8은 메모리를 절약하기 위해 숫자를 31비트로 태그된 정수(내부적으로 “Small Integers” 또는 "Smi"라 불림)로 인코딩하려고 합니다(32비트는 포인터 압축으로 인해 가능). 또한, 성능 면에서도 정수 연산이 부동소수점 연산보다 빠르기 때문에 효율적입니다.
숫자 관련 연산이 많은 JavaScript 코드의 실행 속도를 높이기 위해서는 값 노드에 대해 최적의 표현을 선택하는 것이 중요합니다. 인터프리터 및 Sparkplug와는 달리, 최적화 컴파일러는 값의 타입을 알게 되면 이를 언박싱하여 JavaScript 값 대신 순수 숫자를 다룰 수 있으며, 꼭 필요한 경우에만 값을 다시 박싱합니다. 부동소수점 값은 힙 객체를 할당하지 않고 직접 부동소수점 레지스터에 전달될 수 있습니다.
Maglev는 주로 런타임 피드백(예: 이항 연산)을 관찰하여 SSA 노드 표현에 대해 학습하고, 이를 Known Node Info 메커니즘을 통해 앞으로 전달합니다. 특정 표현을 가진 SSA 값이 Phis로 흘러들어올 때는 모든 입력을 지원하는 올바른 표현을 선택해야 합니다. 루프 Phi는 입력이 루프 내부에서 나오기 때문에 표현을 선택해야 하는 시점 이후에 확인되므로 복잡합니다. 이는 그래프 빌딩과 마찬가지로 "시간 역행" 문제입니다. 따라서 Maglev는 그래프 빌딩 후 루프 Phi에 대한 표현 선택을 수행하는 별도의 단계를 갖추고 있습니다.
레지스터 할당
그래프 빌딩 및 표현 선택이 완료된 후, Maglev는 생성할 코드 유형에 대해 거의 모든 것을 알고 있으며, 고전적 최적화 관점에서는 "완료"된 상태가 됩니다. 그러나 기계 코드를 실행할 때 SSA 값이 실질적으로 어디에 저장될지를 결정해야 합니다. 값이 머신 레지스터에 저장되는지 스택에 저장되는지를 결정하는 작업이 바로 레지스터 할당입니다.
각 Maglev 노드는 입력 및 출력 요구 사항, 그리고 필요한 임시 값 요구 사항을 포함하고 있습니다. 레지스터 할당기는 그래프를 한 번 앞으로 훑으면서, 그래프 빌딩 동안 유지된 추상 인터프리터 상태와 비슷한 추상 머신 레지스터 상태를 유지합니다. 그런 다음 이러한 요구 사항을 충족시키고, 노드 요구 사항을 실제 위치로 교체합니다. 이 위치는 코드 생성에 사용될 수 있습니다.
먼저, 프리패스가 그래프를 훑으며 노드의 선형 생존 범위를 찾아냅니다. 이를 통해 특정 SSA 노드가 더 이상 필요하지 않을 때 레지스터를 해제할 수 있습니다. 프리패스는 또한 사용 체인을 추적합니다. 값이 미래에 얼마나 더 필요한지 아는 것은, 레지스터가 부족할 때 어떤 값을 우선적으로 보존하고 어떤 값을 제거할지를 결정하는 데 유용합니다.
프리패스 후에는 레지스터 할당이 실행됩니다. 레지스터 할당은 몇 가지 간단한 지역 규칙을 따릅니다: 만약 값이 이미 레지스터에 있다면, 해당 레지스터가 가능할 경우 사용됩니다. 노드는 그래프를 따라 이동하면서 자신이 저장된 레지스터를 추적합니다. 만약 해당 노드에 아직 레지스터가 할당되지 않았지만 빈 레지스터가 있다면, 그 레지스터가 선택됩니다. 노드는 해당 레지스터에 저장되었다고 업데이트되고, 추상 레지스터 상태는 해당 노드를 포함하고 있다고 업데이트됩니다. 만약 사용할 수 있는 레지스터가 없고 레지스터가 필요하다면, 다른 값을 레지스터에서 밀어냅니다. 이상적으로는, 이미 다른 레지스터에 있는 노드를 밀어내 "무료로" 처리하거나, 오래도록 필요하지 않을 값을 선택해 이를 스택으로 스필합니다.
분기 병합 시, 들어오는 분기에서 추상 레지스터 상태가 병합됩니다. 가능한 한 많은 값을 레지스터에 유지하려고 합니다. 이 과정에서 레지스터 간 이동을 도입하거나 스택에서 값을 복구(스필 해제)해야 할 수도 있습니다. 이 작업에는 "갭 이동"이라고 불리는 이동을 사용합니다. 분기 병합에 Phi 노드가 있는 경우, 레지스터 할당기는 Phi에 출력 레지스터를 할당합니다. Maglev는 이동을 최소화하기 위해 Phi의 입력과 동일한 레지스터에 출력을 할당하려고 합니다.
만약 SSA 값이 레지스터보다 더 많이 활성화되어 있으면, 일부 값을 스택에 스필해야 하고 나중에 이를 언스필해야 할 것입니다. Maglev의 정신에 따라 단순함을 유지했습니다: 값이 스필될 필요가 있으면, 정의 시점에서 즉시 스필하도록 역으로 알려주고(값이 생성된 직후), 코드 생성은 스필 코드를 처리합니다. 정의는 값의 모든 사용을 '지배'하도록 보장됩니다(사용에 도달하기 위해 정의를 통과했으며, 따라서 스필 코드를 통과했음을 의미). 이는 또한 스필된 값이 코드 전체 지속 시간 동안 정확히 하나의 스필 슬롯만 가지게 된다는 것을 의미하며, 수명이 겹치는 값들은 따라서 겹치지 않는 스필 슬롯을 가지게 됩니다.
표현 선택으로 인해, Maglev 프레임의 일부 값들은 V8의 GC가 이해하고 고려해야 하는 태그된 포인터일 수 있고, 일부는 GC가 확인하지 않아야 하는 태그되지 않은 값일 수 있습니다. TurboFan은 어떤 스택 슬롯이 태그된 값을 포함하고, 어떤 슬롯이 태그되지 않은 값을 포함하는지를 정확히 추적하여 이를 처리하며, 실행 도중 다른 값들에 대해 슬롯이 재사용됨에 따라 변할 수 있습니다. Maglev에서는 이를 더 간단하게 유지하기로 결정했으며, 이를 추적하기 위해 필요한 메모리를 줄이기 위해: 스택 프레임을 태그된 영역과 태그되지 않은 영역으로 분류하고 이 분리 포인트만 저장합니다.
코드 생성
생성하고자 하는 표현식과 그 출력 및 입력을 위치시킬 장소를 알고 나면 Maglev는 코드 생성을 준비합니다.
Maglev 노드는 “매크로 어셈블러”를 사용하여 직접 어셈블리 코드를 생성하는 방법을 알고 있습니다. 예를 들어 CheckMap
노드는 입력 객체의 형태(내부적으로는 “맵”이라고 함)를 알려진 값과 비교하는 어셈블리 명령을 생성하고, 객체의 형태가 잘못된 경우 코드를 디옵티마이즈하는 방법을 알고 있습니다.
다소 까다로운 코드는 갭 이동을 처리합니다: 레지스터 할당기가 생성한 요청된 이동은 값이 어디에 있으며 어디로 이동해야 하는지를 알고 있습니다. 하지만 이러한 이동이 연속적으로 발생할 경우, 이전 이동이 이후 이동이 필요로 하는 입력을 덮어쓸 수 있습니다. 병렬 이동 해결자는 모든 값이 올바른 위치에 도착하도록 안전하게 이동하는 방법을 계산합니다.
결과
방금 소개한 컴파일러는 Sparkplug보다 훨씬 더 복잡하지만 TurboFan보다는 훨씬 더 간단하다는 것이 명확합니다. 그 결과는 어떨까요?
컴파일 속도 측면에서 우리는 Sparkplug보다 대략 10배 느리고 TurboFan보다 10배 빠른 JIT을 구축하는 데 성공했습니다.
이는 우리가 TurboFan을 배포하려는 시점보다 훨씬 더 일찍 Maglev을 배포할 수 있게 해줍니다. Maglev이 의존하는 피드백이 아직 안정적이지 않더라도 디옵티마이즈하고 나중에 다시 컴파일하여도 큰 비용이 들지 않습니다. 또한 TurboFan을 약간 더 늦게 사용할 수 있기 때문에 Sparkplug보다 훨씬 빠르게 실행됩니다.
Sparkplug와 TurboFan 사이에 Maglev을 배치하면 눈에 띄는 벤치마크 개선이 이루어집니다:
Maglev을 실제 데이터로 검증한 결과, Core Web Vitals에서 좋은 개선을 목격했습니다.
Maglev은 훨씬 빠르게 컴파일되었으며, 이제 우리는 TurboFan으로 함수들을 컴파일하기 전에 더 오래 기다릴 여유가 있습니다. 이는 표면에서 그다지 눈에 띄지 않는 부차적인 이점을 가져옵니다. 벤치마크는 주 스레드 대기시간에 초점을 맞추지만, Maglev은 또한 오프 스레드 CPU 시간을 덜 사용함으로써 V8의 전체 리소스 소비를 크게 줄입니다. 프로세스의 에너지 소비는 taskinfo
를 사용해 M1- 또는 M2 기반 Macbook에서 쉽게 측정할 수 있습니다.
벤치마크 | 에너지 소비 감소 |
---|---|
JetStream | -3.5% |
Speedometer | -10% |
Maglev은 아직 완벽하지 않습니다. 여전히 해야 할 작업이 많고 시도해 볼 아이디어가 많으며, 쉽게 얻을 수 있는 저지형 과실도 많이 있습니다 — Maglev이 더 완성되면 더 높은 점수를 기대할 수 있고 에너지 소비도 더 줄어들 것으로 예상됩니다.
Maglev은 이제 데스크톱 Chrome에서 사용할 수 있으며, 곧 모바일 기기로도 출시될 예정입니다.