← 목록으로 돌아가기

V8 Inline Cache와 Monomorphism

javascript v8 inline cache monomorphism

Inline Cache와 Monomorphism

들어가며

What’s up with monomorphism? 를 읽고 설명을 더해 정리한 글입니다. JavaScript 성능에 대한 글들은 종종 monomorphic 코드의 중요성을 강조하는데요. 이 글에서는 monomorphism과 polymorphism이 무엇이고, 왜 중요한지에 대해 V8의 Inline Cache와 함께 이 개념들을 알아보려고 합니다.

전통적인 프로그래밍에서 다형성은 보통 기본 클래스의 동작을 오버라이드할 수 있는 능력을 의미하는데요. 이 글에서 언급하는 다형성은 기존의 개념과는 조금 다른 call site polymorphism을 의미합니다. 쉽게 말해서, ‘이 코드 라인이 얼마나 다양한 객체 형태를 받는지’를 의미합니다.

동적 탐색의 문제

JavaScript에서 o.x 같은 프로퍼티 접근을 생각해봅시다:

function f(o) {
  return o.x
}

f({ x: 1 })
f({ x: 2 })

가장 단순한 구현은 매번 ECMAScript 스펙의 [[Get]] 알고리즘을 실행하는 거예요. 하지만 이 방식은 너무 느립니다. 인터프리터가 매번 프로퍼티를 처음부터 찾아야 하니까요.

V8은 다른 방식을 씁니다. **“이전에 본 객체의 shape를 기억해두고, 비슷한 객체가 오면 빠른 경로를 타자”**는 전략이에요. 이게 바로 **Inline Cache(IC)**입니다.

Inline Cache의 세 가지 상태

각 IC는 캐시이기 때문에 크기(현재 캐시된 항목 수)와 용량(최대 캐시 가능 항목 수)이 있습니다.

Monomorphic

function f(o) {
  return o.x
}

f({ x: 1 })
f({ x: 2 })
// IC 상태: monomorphic

{ x: 1 }{ x: 2 }같은 shape를 가집니다. IC는 하나의 shape만 봤으므로 monomorphic 상태예요. mono-(하나) + -morphic(형태)라는 뜻이죠.

Polymorphic

f({ x: 3 }) // 여전히 monomorphic
f({ x: 3, y: 1 }) // polymorphic, degree 2

{ x: 3 }{ x: 3, y: 1 }다른 shape입니다. 이제 IC는 두 개의 캐시 항목을 가지게 되고, polymorphic 상태가 됩니다.

Megamorphic

다른 shape의 객체들로 계속 호출하면:

f({ x: 4, y: 1 }) // polymorphic, degree 2
f({ x: 5, z: 1 }) // polymorphic, degree 3
f({ x: 6, a: 1 }) // polymorphic, degree 4
f({ x: 7, b: 1 }) // megamorphic

V8의 프로퍼티 로드 IC는 최대 4개까지만 캐시합니다. 그 이상이 되면 megamorphic 상태로 전환돼요. “너무 많은 shape를 봤으니 추적을 포기한다”는 의미입니다.

성능에 미치는 영향

상태동작성능
Monomorphic캐시 히트 시 바로 접근🚀 가장 빠름
Polymorphic캐시된 항목들을 선형 탐색🐢 느려짐
Megamorphic글로벌 해시테이블 조회🐌 더 느림
IC Miss런타임으로 폴백💀 가장 느림

하지만 이건 절반의 이야기입니다.

최적화 컴파일러와의 관계

IC는 단순히 캐시 역할만 하는 게 아닙니다. 최적화 컴파일러의 스파이 역할도 해요.

V8의 최적화 컴파일러는 함수를 최적화할 때 IC가 수집한 정보를 활용합니다:

Monomorphic IC가 있을 때

컴파일러는 **“이 shape만 온다”**고 가정하고, 특화된 코드를 생성합니다:

// 컴파일러가 생성하는 최적화된 코드 (의사 코드)
function f_optimized(o) {
  // shape 체크
  if (o.shape !== expected_shape) {
    return deoptimize() // 최적화 해제
  }
  // 직접 오프셋으로 접근 (매우 빠름)
  return o[offset_of_x]
}

Polymorphic IC가 있을 때

컴파일러는 타입 체크 분기문을 생성합니다:

// 의사 코드
function f_optimized(o) {
  if (o.shape === shape_A) {
    return o[offset_A]
  } else if (o.shape === shape_B) {
    return o[offset_B]
  } else {
    return deoptimize()
  }
}

분기가 많아질수록 느려지겠죠?

Megamorphic IC가 있을 때

컴파일러는 포기하고 generic한 코드를 생성합니다. 최적화의 이점을 거의 받지 못해요.

실수로 Polymorphism 만들기

같은 프로퍼티를 가져도 V8 관점에서 shape가 다를 수 있습니다:

function A() {
  this.x = 1
}
function B() {
  this.x = 1
}

var a = new A()
var b = new B()
var c = { x: 1 }
var d = { x: 1, y: 1 }
delete d.y

// a, b, c, d 전부 다른 shape!

조건부로 프로퍼티를 추가해도 마찬가지예요:

function A() {
  this.x = 1
}

var a = new A()
var b = new A() // a와 같은 shape

if (something) {
  a.y = 2 // a의 shape가 b와 달라짐!
}

함수 호출의 특수 케이스

함수 호출 IC는 프로퍼티 접근 IC와 다르게 동작합니다. 중간 polymorphic 상태가 없어요:

function inv(cb) {
  return cb(0)
}

function F(v) {
  return v
}
function G(v) {
  return v + 1
}

inv(F) // monomorphic, F를 가리킴
inv(G) // 바로 megamorphic!

monomorphic 상태에서 최적화되면 컴파일러가 함수를 인라인할 수 있어서 매우 빨라집니다. megamorphic이 되면 인라인이 불가능해요.

메서드 호출은 다릅니다

반면 o.m() 같은 메서드 호출은 프로퍼티 접근처럼 polymorphic 상태를 가집니다:

function inv(o) {
  return o.cb(0)
}

var f = {
  cb: function F(v) {
    return v
  },
}
var g = {
  cb: function G(v) {
    return v + 1
  },
}

inv(f) // monomorphic
inv(f)
inv(g) // polymorphic, degree 2

재미있는 점은 fg다른 shape를 가진다는 거예요. V8은 함수를 프로퍼티에 할당할 때, 가능하면 그 함수를 shape 자체에 붙입니다. f의 shape는 {cb: F}, g의 shape는 {cb: G}가 되는 거죠. 마치 Java의 클래스처럼요.

결론

저자의 조언을 인용하면:

“다형성 걱정은 대부분 쓸데없다. 실제 데이터로 벤치마크하고, 핫스팟을 프로파일링하고, IR에서 XYZGeneric이나 changes[*]가 보일 때만 걱정하라.”

Premature optimization은 피하되, 성능이 중요한 핫 패스에서는:

  1. 같은 shape의 객체를 일관되게 사용
  2. 조건부로 프로퍼티를 추가하지 않기
  3. 콜백 함수는 가능하면 같은 함수 사용
  4. 내장 메서드 활용 (이미 polymorphism 처리가 최적화됨)

이런 점들을 고려하면 V8의 최적화를 최대한 활용할 수 있습니다.