V8의 성능 가속화 – 가변 힙 숫자를 활용한 개선
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이 호출될 때마다 업데이트되며, 의사 무작위 순서를 생성합니다. 중요한 점은 seed가 ScriptContext에 저장된다는 것입니다.
ScriptContext는 특정 스크립트 내에서 접근 가능한 값을 저장하는 위치입니다. 내부적으로, 이 컨텍스트는 V8의 태그된 값 배열로 나타납니다. 기본 64비트 시스템용 V8 설정에서 이러한 태그된 값 각각은 32비트를 차지합니다. 값의 가장 낮은 비트는 태그 역할을 합니다. 0은 31비트 소형 정수(SMI)를 나타냅니다. 실제 정수 값은 직접 저장되며, 한 비트 왼쪽으로 시프트됩니다. 1은 힙 객체로의 압축 포인터를 나타내며, 압축 포인터 값은 하나 증가합니다.
이 태그 방식은 숫자가 저장되는 방식을 구분합니다. SMI는 ScriptContext 내부에 직접 저장됩니다. 더 큰 숫자 또는 소수 부분을 포함한 숫자는 힙의 불변 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로 표시할 수 있습니다.
컨텍스트 슬롯에 저장되는 값 유형에 대한 코드 의존성이 이 최적화로 인해 도입된다는 점이 중요합니다. JIT 컴파일러에 의해 생성된 최적화된 코드는 슬롯이 특정 유형 (여기서는 Int32)을 포함하고 있는 것을 기반으로 합니다. 만약 어떤 코드가 seed 슬롯에 유형을 변경하는 값 (예: 부동 소수점 숫자 또는 문자열)을 쓰게 되면, 최적화된 코드는 디옵티마이징을 해야 합니다. 이는 올바름을 보장하기 위해 필요합니다. 그러므로 슬롯에 저장된 유형의 안정성은 최적 성능을 유지하는 데 매우 중요합니다. Math.random의 경우, 알고리즘 내 비트마스킹 덕분에 seed 변수는 항상 Int32 값을 가집니다.
결과
이러한 변경은 특이한 Math.random 함수의 실행 속도를 크게 개선합니다:
-
할당 없음 / 빠른 제자리 업데이트:
seed값은 스크립트 컨텍스트에서 변경 가능한 슬롯 내에서 직접 업데이트됩니다.Math.random실행 중에는 새로운 객체가 할당되지 않습니다. -
정수 연산: 슬롯이
Int32값을 포함한다는 정보를 바탕으로 컴파일러는 매우 최적화된 정수 명령어 (시프트, 덧셈 등)를 생성할 수 있습니다. 이는 부동 소수점 산술의 오버헤드를 피할 수 있습니다.

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