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%
의 개선으로 기여합니다. 이는 간단해 보이는 코드가 예상치 못한 성능 병목을 일으킬 수 있으며, 작은 목표형 최적화가 벤치마크에만 국한되지 않고 큰 영향을 미칠 수 있다는 것을 보여줍니다.