짧은 내장 호출
V8 v9.1에서는 데스크톱 환경에서 내장 기능을 일시적으로 비활성화했습니다. 내장 기능은 메모리 사용을 크게 개선하지만, 내장 기능과 JIT(Just-In-Time) 컴파일된 코드 간 함수 호출이 상당한 성능 저하를 일으킬 수 있음을 발견했습니다. 이 비용은 CPU의 마이크로아키텍처에 따라 달라질 수 있습니다. 이 게시물에서는 이러한 현상이 발생하는 이유, 성능 측면에서 어떻게 보이는지, 그리고 장기적으로 이를 해결하기 위한 계획에 대해 설명하겠습니다.
코드 할당
V8의 Just-In-Time(JIT) 컴파일러에서 생성된 머신 코드는 VM이 소유한 메모리 페이지에 동적으로 할당됩니다. V8은 연속된 주소 공간 영역 내에서 메모리 페이지를 할당하며, 이는 메모리의 임의의 위치(예: 주소 공간 레이아웃 무작위화 이유)나 포인터 압축을 위해 할당된 4GiB의 가상 메모리 영역 내에서 위치할 수 있습니다.
V8 JIT 코드에서 내장 기능에 대한 호출은 매우 일반적입니다. 내장 기능은 VM의 일부로 제공되는 머신 코드의 스니펫입니다. 내장 기능에는 Function.prototype.bind
와 같은 JavaScript 표준 라이브러리 함수 전체를 구현하는 것도 있지만, JS의 고급 의미와 CPU의 저수준 기능 사이의 간극을 메우는 머신 코드 스니펫도 많습니다. 예를 들어, JavaScript 함수가 다른 JavaScript 함수를 호출하려고 하면, 함수 구현이 대상 JavaScript 함수가 어떻게 호출되어야 하는지(예: 프록시인지 일반 함수인지, 예상되는 인수 개수 등)를 결정하는 CallFunction
내장 기능을 호출하는 것이 일반적입니다. 이 코드 스니펫은 VM을 생성할 때 이미 알려진 것이므로 크롬 바이너리에 "내장"되어 크롬 바이너리 코드 영역 내에 위치하게 됩니다.
직접 호출과 간접 호출
64비트 아키텍처에서는 이러한 내장 기능이 포함된 크롬 바이너리가 JIT 코드에서 임의로 멀리 떨어질 수 있습니다. x86-64 명령 집합에서는 직접 호출을 사용할 수 없는데, 이는 32비트 부호 있는 즉시 값이 호출 주소의 오프셋으로 사용되며, 2GiB보다 멀리 떨어질 수 있기 때문입니다. 대신 레지스터 또는 메모리 오퍼랜드를 통한 간접 호출에 의존해야 합니다. 이러한 호출은 호출 명령 자체에서 호출 대상이 명확하지 않아 예측에 크게 의존합니다. ARM64에서는 직접 호출이 전혀 불가능하며 범위가 128MiB로 제한됩니다. 따라서 두 경우 모두 CPU의 간접 분기 예측기의 정확성에 의존하게 됩니다.
간접 분기 예측의 한계
x86-64를 대상으로 할 때 직접 호출을 사용하는 것이 이상적일 것입니다. 이는 분기 예측기의 부담을 줄여줄 뿐만 아니라 대상이 레지스터에서 상수나 메모리에서 로드될 필요가 없다는 장점이 있습니다. 하지만 이는 머신 코드에서 볼 수 있는 명확한 차이만이 아니라는 점도 고려해야 합니다.
Spectre v2로 인해 다양한 장치 및 운영 체제 조합에서 간접 분기 예측이 비활성화되었습니다. 이러한 구성에서는 CallFunction
내장 기능에 의존하는 JIT 코드의 함수 호출에서 매우 비싼 지연이 발생합니다.
더 중요한 점은 64비트 명령 집합 아키텍처(“CPU의 고급 언어”)가 멀리 떨어진 주소의 간접 호출을 지원하더라도, 마이크로아키텍처는 임의의 제한을 가진 최적화를 구현할 수 있다는 것입니다. 간접 분기 예측기가 호출 거리가 특정 거리(예: 4GiB)를 초과하지 않는다고 가정하는 경우가 흔한 것으로 보이며, 이는 예측별 메모리 요구량을 줄이기 위함입니다. 예를 들어, 인텔 최적화 매뉴얼은 다음과 같이 명시하고 있습니다:
64비트 애플리케이션에서는 분기의 대상이 분기에서 4GB 이상 떨어져 있는 경우 분기 예측 성능이 부정적인 영향을 받을 수 있습니다.
ARM64에서 직접 호출의 아키텍처적 호출 범위가 128 MiB로 제한되는 동안, Apple의 M1 칩은 간접 호출 예측에 대해 동일한 4 GiB의 마이크로아키텍처적 범위 제한을 가지고 있음이 밝혀졌습니다. 4 GiB를 초과하는 호출 대상에 대한 간접 호출은 항상 잘못 예측되는 것으로 보입니다. M1의 특히 큰 재정렬 버퍼로 인해, 미래의 예측된 명령어를 사전 실행 가능한 상태로 실행하게 하는 CPU의 구성 요소가, 빈번한 잘못된 예측 결과로 매우 큰 성능 페널티를 초래합니다.
임시 해결책: 빌트인 복사
빈번한 잘못된 예측 비용을 피하고, 가능하면 x86-64에서 분기 예측에 불필요하게 의존하는 것을 피하기 위해, 우리는 빌트인을 메모리가 충분한 데스크톱 장치에서 V8의 포인터 압축 케이지 내로 임시로 복사하기로 결정했습니다. 이는 복사된 빌트인 코드를 동적으로 생성된 코드와 가깝게 만듭니다. 성능 결과는 장치 구성에 따라 크게 달라지지만, 다음은 성능 봇에서 얻은 일부 결과입니다:
빌트인을 분리하는 것은 영향을 받는 장치에서 V8 인스턴스당 1.2에서 1.4 MiB 정도의 메모리 사용량을 증가시킵니다. 더 나은 장기적인 해결책으로, JIT 코드를 Chrome 바이너리와 더 가깝게 할당하는 방법을 모색하고 있습니다. 이렇게 하면 메모리 이점을 복구하기 위해 빌트인을 다시 삽입할 수 있을 뿐만 아니라, V8에서 생성된 코드에서 C++ 코드로의 호출 성능도 향상시킬 수 있습니다.