본문으로 건너뛰기

Static Roots: 컴파일 시간에 고정된 주소를 가지는 객체

· 약 4분
Olivier Flückiger

undefined, true와 같은 핵심 JavaScript 객체들이 어디에서 오는지 궁금해해 본 적이 있나요? 이러한 객체들은 사용자가 정의한 객체의 원소이며, 먼저 존재해야 합니다. V8은 이들을 이동 불가, 불변의 루트로 간주하며, 읽기 전용 힙이라는 자체 힙에 저장합니다. 이들이 끊임없이 사용되기 때문에, 빠른 액세스가 필수적입니다. 그렇다면 이들의 메모리 주소를 컴파일 시간에 정확히 추측할 수 있다면 얼마나 빠를까요?

예를 들어, 매우 일반적인 IsUndefined API 함수가 있습니다. 참조를 위해 undefined 객체의 주소를 찾아야 하는 대신, 객체의 포인터가 예를 들어 0x61로 끝나는지 확인하여 undefined인지 알 수 있다면 어떨까요? 이것이 바로 V8의 static roots 기능이 달성하는 것입니다. 이 게시물에서는 여기에 도달하기 위해 극복해야 했던 장애물들을 탐구합니다. 이 기능은 Chrome 111에 도입되어 전체 VM 전반에 걸쳐 성능 향상을 가져왔으며 특히 C++ 코드와 내장 함수의 속도를 높였습니다.

읽기 전용 힙 부트스트래핑

읽기 전용 객체를 생성하는 데는 시간이 걸립니다. 따라서 V8은 컴파일 시간에 이를 생성합니다. V8을 컴파일하기 위해 먼저 mksnapshot이라는 최소한의 proto-V8 바이너리를 컴파일합니다. 이 바이너리는 모든 공유 읽기 전용 객체뿐만 아니라 내장 함수의 네이티브 코드를 생성하고 이를 스냅샷에 기록합니다. 그런 다음 실제 V8 바이너리를 컴파일하고 스냅샷과 함께 번들링합니다. V8을 시작하기 위해 스냅샷을 메모리에 로드하면 즉시 내용을 사용할 수 있습니다. 다음 다이어그램은 독립형 d8 바이너리의 간소화된 빌드 프로세스를 보여줍니다.

d8이 실행되고 나면 모든 읽기 전용 객체는 메모리에서 고정된 위치를 가지며 절대 이동하지 않습니다. 코드 JIT 시, 예를 들어 undefined를 주소로 참조할 수 있습니다. 그러나 스냅샷을 생성할 때와 libv8의 C++ 코드를 컴파일할 때는 주소를 아직 알 수 없습니다. 이는 빌드 시간에 알려지지 않은 두 가지 요인에 따라 달라집니다. 첫 번째는 읽기 전용 힙의 바이너리 레이아웃이고 두 번째는 메모리 공간에서 읽기 전용 힙의 위치입니다.

주소를 예측하는 방법?

V8은 포인터 압축을 사용합니다. 전체 64비트 주소 대신, 우리는 4GB 메모리 영역에 대한 32비트 오프셋을 통해 객체를 참조합니다. 프로퍼티 로드나 비교와 같은 여러 연산에 대해, 이러한 케이지 내 32비트 오프셋만으로 객체를 고유하게 식별하기에 충분합니다. 따라서 메모리 공간에서 읽기 전용 힙의 위치를 알 수 없다는 두 번째 문제는 실제로 문제가 아닙니다. 우리는 단순히 읽기 전용 힙을 매 포인터 압축 케이지의 시작 위치에 배치하여 이를 알려진 위치로 제공합니다. 예를 들어 V8 힙의 모든 객체 중, undefined는 항상 압축된 주소가 가장 작으며 0x61 바이트에서 시작합니다. 이렇게 해서 모든 JS 객체의 전체 주소 하위 32비트가 0x61이면 이는 undefined임을 알 수 있습니다.

이것만으로도 유용하지만, 우리는 이 주소를 스냅샷과 libv8에서도 사용하고 싶습니다. 이는 겉보기엔 순환적인 문제처럼 보입니다. 하지만 mksnapshot이 비트 단위 동일한 읽기 전용 힙을 결정론적으로 생성하도록 보장하면 이러한 주소를 빌드 간에 재사용할 수 있습니다. libv8 자체에서 이를 사용하기 위해 우리는 기본적으로 V8을 두 번 빌드합니다:

첫 번째 단계에서 mksnapshot을 호출하면 생성되는 유일한 아티팩트는 읽기 전용 힙의 모든 객체에 대해 케이지 기본값에 상대적인 주소들을 포함한 파일입니다. 빌드의 두 번째 단계에서는 libv8을 다시 컴파일하며 플래그를 통해 undefined를 참조할 때마다 정확히 cage_base + StaticRoot::kUndefined를 사용하도록 보장합니다. undefined의 정적 오프셋은 물론 static-roots.h 파일에 정의되어 있습니다. 많은 경우 이것은 libv8을 생성하는 C++ 컴파일러와 mksnapshot의 내장 컴파일러가 훨씬 효율적인 코드를 생성할 수 있도록 합니다. 대안은 항상 루트 객체 배열에서 주소를 로드하는 것입니다. 우리는 undefined의 압축된 주소가 0x61로 하드코딩된 d8 바이너리로 끝납니다.

원칙적으로 이것이 모든 것이 작동하는 방식이지만, 실제로는 V8을 한 번만 빌드합니다. 그렇게 할 시간이 없기 때문입니다. 생성된 static-roots.h 파일은 소스 리포지토리에 캐시되고, 읽기 전용 힙의 레이아웃이 변경된 경우에만 다시 생성해야 합니다.

추가적인 응용

실용적인 관점에서 말하자면, 정적 루트는 더 많은 최적화를 가능하게 합니다. 예를 들어, 우리는 공통 객체들을 그룹화하여 일부 작업을 주소 범위 확인으로 구현할 수 있게 되었습니다. 예를 들면 모든 문자열 맵(즉, 다양한 문자열 유형의 레이아웃을 설명하는 hidden-class 메타 객체)이 서로 인접해 있으므로 맵 압축 주소가 0xdd에서 0x49d 사이인 경우 해당 객체는 문자열입니다. 또는, 참 값을 가진 객체는 주소가 최소 0xc1 이상이어야 합니다.

V8에서 JIT 코드의 성능만이 중요한 것은 아닙니다. 이 프로젝트가 보여주듯이, C++ 코드에 상대적으로 작은 변화가 큰 영향을 미칠 수 있습니다. 예를 들어 Speedometer 2는 V8 API와 V8 및 그것의 임베더 간의 상호작용을 테스트하는 벤치마크로, M1 CPU에서 정적 루트 덕분에 약 1%의 점수 향상을 얻었습니다.