(번역) V8 함수 최적화
설치 (선택)
당신의 컴퓨터에서 실행하길 원하지 않는다면, 이 섹션을 건너뛰세요.
먼저 전체 패키지(노드 혹은 웹 브라우저) 없이 실행할 수 있도록 V8을 설치해야 합니다. Linux 사용자를 위한 프로세스는 이 gist를 참고하세요.
https://gist.github.com/burnpiro/d85d836200df93af892877c2cf37f12c
맥을 사용하는 경우에도 동일하게 적용하면 됩니다. 설치 과정에 문제가 있는 경우 공식 문서를 참조하세요.
설치 후 아래와 같은 코드를 실행할 수 있어야 합니다.
이렇게 호출해서요.
d8 index.js
우리가 최적화하려는 것은 무엇인가요?
저희의 테스트 함수는 다음과 같이 생겼습니다.
이 함수는 스크립트 실행 중에 수천 번 호출 되어야 하므로 빠르게 실행하는 것이 중요하다고 가정해 보겠습니다. V8에서 최적화된 방법을 설명하기 전에 Shape가 무엇이며 인라인 캐시(IC)가 어떻게 동작하는지 알아야 합니다.
Shape (V8의 Map)
Shape의 학술적 이름은 “Hidden Class”지만, JS에서는 특히 헷갈릴 수 있어 사람마다 다르게 부릅니다. 하지만 FF 팀에서 소개한 이름으로 이야기하는 것이 가장 좋아 보입니다.
Shape는 많은 표지(staff)가 있지만, 사람들이 이를 사용할 때 대부분 객체 프로퍼티를 위한 디스크립터(descriptors) 테이블로 참조합니다. Shape는 객체의 크기, 생성자와 프로토타입을 가리키는 포인터와 같은 정보도 저장합니다. 자 이제 예제를 살펴보겠습니다.
간단한 객체부터 시작해보겠습니다.
V8에서는 다음과 같이 나타냅니다.
자세히 보면 객체 값과 설명에 명백히 분리되어 있는 것을 확인할 수 있습니다. 해당 객체의 모든 프로퍼티는 Shape에 정의된 offset에 따라 메모리에 저장됩니다. 예제의 경우 속성 x는 offset:12로 저장되어 v8에게 obj.x
값을 찾을 때는 포인터를 12 offset 만큼 이동해 찾도록 합니다.
네. 이제 Shape가 뭔지 알게 되었습니다. 근데 이게 왜 유용할까요?
Shape의 유용함
객체를 생성할 때, 유사한 객체가 있는 경우 해당 객체에 대한 모든 정보를 시스템에 다시 저장하고 싶지 않을 겁니다. 그게 V8이 Shape를 재사용하는 이유입니다.
위 그림에 따른 코드는 아래와 같을 것 입니다.
같은 “구조”를 갖는 모든 객체들이 같은 Shape를 갖는 것은 아닙니다
여기 두 객체를 비교해보겠습니다.
보기에는 비슷해 보여도, 이 둘은 다른 Shape를 갖습니다. 첫 번째 객체에 대한 Shape는 이미 이야기 했으니, 두번째 객체의 Shape에 대해 살펴보겠습니다.
코드가 실행되는 동안 V8은 최종 결과를 만들기까지 총 3개의 다른 Shape와 트랜지션을 생성합니다.
클래스 생성도 동일하게 진행될 것입니다.
사실 V8은 트랜지션 간의 전체 Shape를 저장하지 않습니다.
x 프로퍼티에 대한 정보를 M2 Shape로 복사하지 않습니다. 이런 이유로 다른 객체를 가질 때 트리와 같은 구조로 만들게 됩니다.
obj3과 obj4에서 세 번째 프로퍼티를 같은 이름으로 사용하더라도 둘은 다른 Shape을 갖는다는 것을 주목할 필요가 있습니다. 그 이유는 Shape이 트랜지션과 관련이 있기 때문입니다. k 프로퍼티를 나타내는 전역 Shape를 갖는다면, 정의된 offset 프로퍼티가 필요합니다. 이는 메모리에서 동일한 객체 구조일 때만 작동할 수 있습니다.(offset은 프로퍼티 이전에 객체의 나머지 공간이 얼마나 차지하냐에 따라 결정됩니다) 따라서 obj4와 같은 객체가 있는 경우 프로퍼티 k는 obj3의 프로퍼티와 오프셋이 다를 수 있습니다.(M7 Shape로의 트랜지션은 다릅니다)
인라인 캐시(Inline Cache, IC)
이번에는 코드부터 살펴볼까요?
이 코드를 아래 명령어로 실행해보겠습니다.
d8 --trace-ic index.js
참고: 왜 우리가 함수를 그렇게 많이 실행하는지 궁금할 것 같습니다. 왜냐하면 V8은 해당 함수가 hot으로 표시되지 않는 한 함수 최적화를 시도하지 않기 때문입니다. 함수를 여러 번 실행해야 hot 상태가 됩니다.
이제 path/to/v8/tools/ic-explorer.html
를 브라우저에서 열 수 있게 되었습니다. 해당 페이지는 인라인 캐시에서 무슨 일이 일어나고 있는지를 확인할 수 있게 해줍니다.
중요한 부분은 함수는 다음 중 하나의 상태일 것이라는 내용입니다.
- 0 uninitialized
- . premonomorphic
- 1 monomorphic
- ^ recompute handler
- P polymorphic
- N megamorphic
- G generic
예시의 경우 상태는 monomorphic으로 설정되어 있습니다. 즉, 하나의 정의된 모양을 가진 객체만 받도록 기능이 최적화 되어 있습니다.
자 이제 실제 IC에서 어떤 일이 있는지 정의해보도록 하겠습니다…
불행히도 저희는 바이트코드를 살펴보면 어려운 방식으로 확인해야 합니다. 일부 바이트코드(관심이 있는 경우 Franziska Hinkelmann가 작성한 바이트코드에 대한 이해에 관한 블로그 글을 살펴보시는 걸 추천해 드립니다.)를 살펴보는 것으로요. 아래 명령어를 실행해보겠습니다.
d8 --print-bytecode index.js
결과는 꽤 길지만, 저희는 마지막 부분만 보면 됩니다. getMeName 함수에 대해 생성된 바이트코드를 보겠습니다.
...
[generated bytecode for function: getMeName]
Parameter count 2
Register count 0
Frame size 0
69 E> 0x2d34c959f9d6 @ 0 : a5 StackCheck
86 S> 0x2d34c959f9d7 @ 1 : 28 02 00 00 LdaNamedProperty a0, [0], [0]
91 S> 0x2d34c959f9db @ 5 : a9 Return
Constant pool (size = 1)
0x2d34c959f989: [FixedArray] in OldSpace
- map: 0x0741c8840789 <Map>
- length: 1
0: 0x0741c8843eb9 <String[#4]: name>
Handler Table (size = 0)
LdaNamedProperty를 보세요. 이 메서드는 a0(인자 0)에서 명명된 프로퍼티를 추출하는 역할을 합니다. 프로퍼티 이름(이 경우 name)은 상수 풀(pool)의 [0] 상수에 의해 결정됩니다.
- map: 0x0741c8840789 <Map>
- length: 1
0: 0x0741c8843eb9 <String[#4]: name>
인자에서 프로퍼티 값을 가져온 뒤, 함수는 이 값을 어큐뮬레이터(accumulator)에 저장합니다.(함수는 마지막에 어큐뮬레이터를 반환함)
이 과정은 인라인 캐시(IC)라고 하는 sth를 생성합니다. 함수가 다른 객체 Shape로 실행될 때마다 새 IC 항목이 생성됩니다.
Shape가 M0인 객체의 getMeName을 호출했습니다. 함수의 첫 실행은 앞에서 설명한 것처럼 작동합니다. V8은 a0에 명명된 프로퍼티를 찾아 acc에 저장합니다. 이후 바이트코드를 실행한 뒤 두 가지를 포함하는 IC를 생성합니다.
- Shape
- 프로퍼티를 얻는 방법
이제, 같은 Shape를 갖는 객체로 같은 함수를 다시 호출하면 아래와 같습니다.
V8은 현재 Shape을 IC에 저장된 Shape과 동일한 Shape인지를 비교합니다. 만약 같다면 LdaNamedProperty를 호출하는 과정을 건너뛰고 IC에 저장된 “지름길(shortcut)”을 사용합니다. 그렇게 하면 함수 호출을 멋지게 최적화할 수 있습니다. 그러나 다른 객체(다른 Shape)로 해당 함수를 호출하면 어떻게 될까요?
V8이 M1 Shape에 대한 또 다른 IC를 만든 것을 확인할 수 있습니다. 이제 두 타입의 Shape에 대한 “지름길”을 갖게 되었습니다. V8이 하나의 기능에 대해 최대 몇 개의 IC를 생성할 수 있을까요? 많이 가질 수 있습니다… 유일한 문제는 그럴 때마다 최적화 해제가 진행된다는 것입니다.
함수 상태
함수 상태는 여러가지가 될 수 있지만, 저희가 주로 관심 있는 상태는 3가지입니다.
- Monomorphic — 1 IC
- Polymorphic — 2–4 IC
- Megamorphic →5 IC
V8은 함수 최적화 여부를 결정하면서 현재 함수 상태를 확인합니다. 여기서 Polymorphic과 Monomorphic 두 가지 상태만 최적화할 수 있습니다. 5개의 IC에 도달하는 것은 V8에서 기본적으로 **”내가 무엇을 하고 있는지 전혀 모르니, 오늘은 그만해”**라는 의미입니다. 이 시점에서 TurboFan은 IC에 아무것도 저장하지 않고 글로벌 캐시로 돌아갑니다. 당신의 함수가 Megamorphic인지 아닌지는 중요하지 않지만, 만약 그 함수가 매우 자주 실행된다면, 수용되는 Shape 수를 줄여 최적화하는 방법을 생각해 볼 수 있습니다.
예제 함수로 돌아와서
이 코드를 실행하면 이런 결과를 나타냅니다.
test with one shape: 3015 ms.
test with multiple shape: 12329 ms.
무슨 일이 일어난 걸까요? 먼저 모든 테스트(6 x 10000 x 10000)에 대한 작업 수를 6*10⁸로 설정했습니다. 첫 번째 함수 test
는 동일한 Shape로 호출됩니다.(다른 객체, 동일한 Shape) 두 번째 함수 test2
는 6가지 다른 Shape로 호출되며, 그 때문에 V8이 최적화하지 않습니다.
v8/tools/ic-explorer.html
을 열고 d8 --trace-ic index.js
로 위 코드를 실행하면 ic-explorer
에 v8.log
를 로드할 수 있습니다. 하기 싫은 분들을 위해 스크린샷을 올립니다.
test(obj)
test2(obj)
테스트 결과는 명백하게 test()
가 test2()
보다 7.8배 빠르게 실행되었습니다.
똑같이 생긴 test2()
함수를 왜 다시 만들었는지 궁금할 수 있습니다. 왜냐하면 test()
가 하나의 Shape으로 실행된 뒤 이미 최적화되어있기 때문입니다. 두 번째 실행의 성능에 영향을 미치고 싶지 않지만 하나의 파일에 유지하고 싶어 별도로 생성했습니다.
추가로
저는 Shape이 추가된 프로퍼티에 대한 정보 그 이상을 포함한다고 언급했습니다. 아래는 let b
에 대해 생성된 Shape의 예 입니다.
만약 당신이 원한다면, 아래 코드를 실행해 생성할 수 있습니다.
d8 --trace-maps index.js
그리고 v8.log
를 v8/tools/map-processor.html
에 업로드해야 합니다. 차트가 생성되면 Transition을 클릭하고 페이지 하단에서 트랜지션 목록을 탐색하면 됩니다.
🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article(https://kofearticle.substack.com/)을 구독해주세요!