본문으로 건너뛰기

V8의 성능 가속화 – 가변 힙 숫자를 활용한 개선

· 약 5분
[Victor Gomes](https://twitter.com/VictorBFG), 비트 건너기 전문

V8에서는 JavaScript 성능을 지속적으로 개선하고 있습니다. 이러한 노력의 일환으로 최근 JetStream2 벤치마크 스위트를 재검토하여 성능 급락 현상을 제거했습니다. 이 글에서는 async-fs 벤치마크에서 2.5배라는 획기적인 성능 향상을 가져온 특정 최적화 작업을 다룹니다. 이 작업은 전체 점수의 괄목할 만한 상승에 기여했습니다. 최적화는 벤치마크에서 영감을 받았지만, 이러한 패턴은 실제 코드에서도 나타나고 있습니다.

async-fs 벤치마크는 이름처럼 비동기 작업을 중심으로 하는 JavaScript 파일 시스템 구현입니다. 그러나 놀랍게도 성능 병목 지점이 있습니다. 바로 Math.random의 구현입니다. 일관된 결과를 위해 이 벤치마크는 맞춤형 결정적 Math.random 구현을 사용합니다. 구현은 다음과 같습니다:

let seed;
Math.random = (function() {
return function () {
seed = ((seed + 0x7ed55d16) + (seed << 12)) & 0xffffffff;
seed = ((seed ^ 0xc761c23c) ^ (seed >>> 19)) & 0xffffffff;
seed = ((seed + 0x165667b1) + (seed << 5)) & 0xffffffff;
seed = ((seed + 0xd3a2646c) ^ (seed << 9)) & 0xffffffff;
seed = ((seed + 0xfd7046c5) + (seed << 3)) & 0xffffffff;
seed = ((seed ^ 0xb55a4f09) ^ (seed >>> 16)) & 0xffffffff;
return (seed & 0xfffffff) / 0x10000000;
};
})();

여기서 핵심 변수는 seed입니다. 이는 Math.random이 호출될 때마다 업데이트되며, 의사 무작위 순서를 생성합니다. 중요한 점은 seedScriptContext에 저장된다는 것입니다.

ScriptContext는 특정 스크립트 내에서 접근 가능한 값을 저장하는 위치입니다. 내부적으로, 이 컨텍스트는 V8의 태그된 값 배열로 나타납니다. 기본 64비트 시스템용 V8 설정에서 이러한 태그된 값 각각은 32비트를 차지합니다. 값의 가장 낮은 비트는 태그 역할을 합니다. 0은 31비트 소형 정수(SMI)를 나타냅니다. 실제 정수 값은 직접 저장되며, 한 비트 왼쪽으로 시프트됩니다. 1은 힙 객체로의 압축 포인터를 나타내며, 압축 포인터 값은 하나 증가합니다.

ScriptContext 구조. 파란 슬롯은 컨텍스트 메타데이터와 글로벌 객체(NativeContext)를 가리키는 포인터를 나타냅니다. 노란 슬롯은 태그되지 않은 이중 정밀 부동 소수점 값을 나타냅니다.

이 태그 방식은 숫자가 저장되는 방식을 구분합니다. SMIScriptContext 내부에 직접 저장됩니다. 더 큰 숫자 또는 소수 부분을 포함한 숫자는 힙의 불변 HeapNumber 객체(64비트 부동 소수점)로 간접적으로 저장되며, ScriptContext는 그에 대한 압축 포인터를 포함합니다. 이러한 접근 방식은 다양한 숫자 유형을 효율적으로 처리하면서 일반적인 SMI 사례에 대해 최적화를 제공합니다.

병목 현상

Math.random의 프로파일링 결과 두 가지 주요 성능 문제가 발생했습니다:

  • HeapNumber 할당: 스크립트 컨텍스트에서 seed 변수에 할당된 슬롯은 기본적으로 불변 HeapNumber를 가리킵니다. Math.random 함수가 seed를 업데이트 할 때마다 새 HeapNumber 객체를 힙에 할당해야 하며, 이는 상당한 할당과 가비지 수집 부담을 초래합니다.

  • 부동 소수점 계산: Math.random 내부 계산은 기본적으로 정수 작업(비트 시프트 및 추가 작업)인데도 컴파일러가 이를 충분히 활용하지 못합니다. seed가 일반적인 HeapNumber로 저장되기 때문에 생성된 코드가 느린 부동 소수점 명령을 사용합니다. 컴파일러는 seed가 항상 정수로 표현 가능한 값을 가질 것이라는 것을 증명할 수 없습니다. 컴파일러가 잠재적으로 32비트 정수 범위를 추정할 수는 있지만, V8은 주로 SMI에 중점을 둡니다. 32비트 정수 추정을 하더라도 64비트 부동 소수점에서 32비트 정수로 비용이 많이 드는 변환과 무결확성이 여전히 필요합니다.

해결책

이 문제를 해결하기 위해, 두 부분으로 이루어진 최적화를 구현했습니다:

  • 슬롯 유형 추적 / 변경 가능한 힙 번호 슬롯: 우리는 스크립트 컨텍스트 상수값 추적 (초기화되었지만 수정되지 않은 let 변수)에서 유형 정보를 포함하도록 확장했습니다. 해당 슬롯 값이 상수인지, SMI, HeapNumber 또는 일반적인 태그 값인지 추적합니다. 또한 JSObjects변경 가능한 힙 번호 필드와 유사하게, 스크립트 컨텍스트 내에서 변경 가능한 힙 번호 슬롯 개념을 도입했습니다. 불변 HeapNumber를 가리키는 대신, 스크립트 컨텍스트 슬롯은 HeapNumber를 소유하며 그 주소를 유출하지 않아야 합니다. 이는 최적화된 코드에서 매 업데이트마다 새 HeapNumber를 할당할 필요성을 제거합니다. 소유한 HeapNumber 자체가 제자리에서 수정됩니다.

  • 변경 가능한 힙 Int32: 우리는 스크립트 컨텍스트 슬롯 유형을 강화하여 숫자 값이 Int32 범위에 속하는지 추적합니다. 값이 Int32 범위 내에 있다면, 변경 가능한 HeapNumber가 값을 원시 Int32로 저장합니다. 필요할 경우, double로의 전환은 HeapNumber 재할당을 요구하지 않는 추가적인 이점을 제공합니다. Math.random의 경우, 컴파일러는 이제 seed가 일관되게 정수 작업으로 업데이트됨을 관찰할 수 있으며 해당 슬롯을 변경 가능한 Int32로 표시할 수 있습니다.

슬롯 유형 상태 머신. 녹색 화살표는 SMI 값을 저장함으로써 트리거되는 전환을 나타냅니다. 파란색 화살표는 Int32 값을 저장함으로써 트리거되는 전환을 나타내며, 빨간색 화살표는 배정도 부동 소수점 값을 저장함으로써 트리거됩니다. Other 상태는 싱크 상태로 작동하여 추가 전환을 방지합니다.

컨텍스트 슬롯에 저장되는 값 유형에 대한 코드 의존성이 이 최적화로 인해 도입된다는 점이 중요합니다. JIT 컴파일러에 의해 생성된 최적화된 코드는 슬롯이 특정 유형 (여기서는 Int32)을 포함하고 있는 것을 기반으로 합니다. 만약 어떤 코드가 seed 슬롯에 유형을 변경하는 값 (예: 부동 소수점 숫자 또는 문자열)을 쓰게 되면, 최적화된 코드는 디옵티마이징을 해야 합니다. 이는 올바름을 보장하기 위해 필요합니다. 그러므로 슬롯에 저장된 유형의 안정성은 최적 성능을 유지하는 데 매우 중요합니다. Math.random의 경우, 알고리즘 내 비트마스킹 덕분에 seed 변수는 항상 Int32 값을 가집니다.

결과

이러한 변경은 특이한 Math.random 함수의 실행 속도를 크게 개선합니다:

  • 할당 없음 / 빠른 제자리 업데이트: seed 값은 스크립트 컨텍스트에서 변경 가능한 슬롯 내에서 직접 업데이트됩니다. Math.random 실행 중에는 새로운 객체가 할당되지 않습니다.

  • 정수 연산: 슬롯이 Int32 값을 포함한다는 정보를 바탕으로 컴파일러는 매우 최적화된 정수 명령어 (시프트, 덧셈 등)를 생성할 수 있습니다. 이는 부동 소수점 산술의 오버헤드를 피할 수 있습니다.

Mac M1에서의 async-fs 벤치마크 결과. 높은 점수가 더 좋습니다.

이 최적화의 결합 효과는 ~2.5배async-fs 벤치마크 속도 향상을 가져옵니다. 이는 전체 JetStream2 점수에서 ~1.6%의 개선으로 기여합니다. 이는 간단해 보이는 코드가 예상치 못한 성능 병목을 일으킬 수 있으며, 작은 목표형 최적화가 벤치마크에만 국한되지 않고 큰 영향을 미칠 수 있다는 것을 보여줍니다.