[번역] 새로운 클래스 필드를 통한 더 빠른 인스턴스 초기화

박종훈·2022년 7월 5일
8
post-thumbnail

원문: https://v8.dev/blog/faster-class-features

V8은 클래스 필드를 v7.2부터 지원해왔고, 프라이빗 클래스 메소드를 v8.4부터 지원해왔습니다. 2021년에 제안서가 스테이지 4에 도달하고나서, V8에 새로운 클래스 기능 지원을 개선하기 위한 작업이 시작됐습니다. 이전까진, 두 가지 주요 이슈가 클래스 피처 사용 결정에 영향을 주고 있었습니다.

  1. 클래스 필드와 프라이빗 메소드의 초기화가 일반적인 프로퍼티 할당보다 훨씬 느렸습니다.
  2. 클래스 필드 초기화문이 Node.js와 Deno와 같은 엠베더(embedder)들이 자체 속도나 사용자 애플리케이션의 부트스트랩을 빠르게 하는데 사용하는 스타트업 스냅샷에서 작동하지 않았습니다.

첫 번째 이슈는 V8 v9.7에서 고쳐졌고, 두 번째 이슈에 대한 픽스(fix)는 V8 v10.0 때 릴리즈되었습니다. 이 글은 첫 번째 이슈가 어떻게 고쳐졌는지를 다룰 것이고, 스냅샷 이슈 픽스에 관한 글을 읽고 싶으시면 이 글을 참조해주세요.

클래스 필드 최적화하기

일반 프로퍼티 할당과 클래스 필드 초기화 사이의 성능 격차를 없애기 위해, 우리는 기존의 인라인 캐시(inline cache, IC) 시스템이 후자와 함께 작동하도록 업데이트를 했습니다. v9.7 전까지, V8은 클래스 필드 초기화 때마다 비싼 런타임 호출을 이용했습니다. v9.7에서는 V8이 초기화 패턴이 충분히 예측 가능하다고 판단하면, 새로운 IC를 이용해 마치 일반 프로퍼티 할당에서처럼 연산 속도를 높였습니다.

초기화 성능, 최적화 시

초기화 성능, 인터프리트 시

클래스 필드의 기존 구현

프라이빗 필드 구현을 위해, V8은 내부 프라이빗 심볼을 이용합니다. 내부 프라이빗 심볼은 V8이 사용하는 내부 자료구조이며, Symbols와 비슷하지만 프로퍼티 키로 사용될 경우 나열 가능하지 않습니다. 이 클래스를 예로 봅시다.

class A {
  #a = 0;
  b = this.#a;
}

V8은 클래스 필드 초기화문(#a = 0b = this.#a)을 수집하여, 초기화문을 본문으로 하는 합성 인스턴스 멤버 함수를 생성합니다. 이 합성 함수로부터 생성된 바이트코드는 다음과 같았었습니다.

// 프라이빗 이름 심볼 `#a`를 r1에 로드합니다
LdaImmutableCurrentContextSlot [2]
Star r1

// 0을 r2에 로드합니다
LdaZero
Star r2

// 타겟을 r0으로 이동시킵니다
Mov <this>, r0

// %AddPrivateField() 런타임 함수를 이용해 인스턴스에서
// `#a` 프라이빗 이름 심볼로 색인된 프로퍼티의 값으로 0을 저장합니다.
// 즉, `#a = 0`.
CallRuntime [AddPrivateField], r0-r2

// 프로퍼티 이름 `b`를 r1에 로드합니다
LdaConstant [0]
Star r1

// `#a`를 위한 프라이빗 이름 심볼을 로드합니다
LdaImmutableCurrentContextSlot [2]

// 인스턴스의 `#a`로 색인된 프로퍼티의 값을 r2에 로드합니다
LdaKeyedProperty <this>, [0]
Star r2

// 타겟을 r0로 이동시킵니다
Mov <this>, r0

// %CreateDataProperty() 런타임 함수를 이용해 `#a`로 색인된 프로퍼티의 값을
// `b`로 색인된 프로퍼티의 값으로 저장합니다. 즉, `b = this.#a`
CallRuntime [CreateDataProperty], r0-r2

위 코드 스니펫의 클래스와 다음과 같은 클래스를 비교해보세요.

class A {
  constructor() {
    this._a = 0;
    this.b = this._a;
  }
}

this.#athis._a의 가시성 차이를 무시하더라도, 기술적으로 이 두 클래스는 같지 않습니다. 기술 명세서는 "set" 시맨틱 대신 "define" 시맨틱을 사용할 것을 강제합니다. 즉, 클래스 필드 초기화는 세터(setter)나 set 프록시 트랩을 발동시키지 않습니다. 그래서 첫 클래스의 유사 객체는 프로퍼티 초기화를 위해 단순한 할당이 아니라 Object.defineProperty()를 사용해야 합니다. 이에 더해, 이미 인스턴스에 같은 프라이빗 필드가 이미 존재하면 에러를 발생해야 합니다 (초기화 대상이 베이스 생성자에서 오버라이드되어 다른 인스턴스가 될 경우).

class A {
  constructor() {
    // %AddPrivateField() 호출은 대략 다음처럼 해석됩니다:
    const _a = %PrivateSymbol('#a')
    if (_a in this) {
      throw TypeError('Cannot initialize #a twice on the same object');
    }
    Object.defineProperty(this, _a, {
      writable: true,
      configurable: false,
      enumerable: false,
      value: 0
    });
    // %CreateDataProperty() 호출은 대략 다음처럼 해석됩니다:
    Object.defineProperty(this, 'b', {
      writable: true,
      configurable: true,
      enumerable: true,
      value: this[_a]
    });
  }
}

제안서가 완료되기 전에 특정된 시맨틱을 구현하기 위해, V8은 런타임 함수 호출을 이용했습니다. 런타임 함수가 보다 유연하기 때문이었습니다. 위의 바이트코드에서 볼 수 있듯이, 퍼블릭 필드의 초기화는 %CreateDataProperty() 런타임 호출을 이용해서 구현되었고, 프라이빗 필드의 초기화는 $AddPrivateField()를 이용해 구현되었습니다. 런타임 호출은 상당한 오버헤드를 발생시키기 때문에 클래스 필드의 초기화는 일반적인 객체 프로퍼티 할당보다 훨씬 느렸습니다.

하지만 대부분의 사용 사례에서 의미론적 차이는 중요하지 않습니다. 이런 사례에서는, 최적화된 프로퍼티 할당의 성능을 제공하는 것이 좋을 것입니다. 그래서 제안서가 완료된 후에 보다 최적인 구현이 만들어졌습니다.

프라이빗 클래스 필드와 반응형 퍼블릭 클래스 필드 최적화하기

프라이빗 클래스 필드와 반응형 퍼블릭 클래스 필드의 초기화 속도를 높이기 위해, 인라인 캐시 (IC) 시스템에 관련 연산을 처리를 위한 새로운 장치가 도입됐습니다. 이 새로운 장치는 세 가지의 협동하는 부품으로 이루어집니다.

  • 바이트코드 생성기에서, 새로운 바이트코드 DefineKeyedOwnProperty가 도입됐습니다. 이 바이트코드는 클래스 필드 초기화문을 의미하는 ClassLiteral::Property AST 노드를 위한 코드를 생성할 때 발생합니다.
  • TurboFan JIT에서, 대응되는 IR 명령코드(opcode) JSDefineKeyedOwnProperty가 도입됐습니다. 이 IR 명령코드는 새로 도입된 바이트코드로 컴파일 될 수 있습니다.
  • IC 시스템에서, 새로운 DefineKeyedOwnIC가 도입됐습니다. DefineKeyedOwnIC는 새로운 바이트코드를 포함해 새로운 IR 명령코드로부터 컴파일된 코드의 인터프리터 핸들러에서 사용됩니다. 구현을 단순화하기 위해, 새로운 IC는 일반적인 프로퍼티 저장소를 위해 만들어진 KeyedStoreIC 코드 일부를 재사용합니다.

이제 V8이 다음 클래스를 만나면,

class A {
  #a = 0;
}

#a = 0 초기화문을 위해 다음의 바이트코드를 생성합니다.

// `#a`를 위한 프라이빗 이름 심볼을 r1에 로드합니다
LdaImmutableCurrentContextSlot [2]
Star0


// DefineKeyedOwnProperty 바이트코드를 사용해 0을 인스턴스에서
// 프라이빗 이름 심볼 `#a`로 색인된 프로퍼티의 값으로 저장합니다.
// 즉, `#a = 0`.
LdaZero
DefineKeyedOwnProperty <this>, r0, [0]

초기화문이 충분히 많이 실행되면, V8은 초기화되는 각 필드마다 피드백 벡터 슬롯을 하나씩 할당합니다. 이 슬롯은 추가할 필드의 키(프라이빗 필드의 경우, 프라이빗 이름 심볼)와 필드 초기화의 결과로 인스턴스가 이전될 때를 위한 히든 클래스 한 쌍을 포함합니다. 이어지는 초기화에서, IC는 피드백을 이용해 같은 히든 클래스와 같은 순서로 인스턴스에 필드가 초기화되는지 확인합니다. 만약 대개 그렇듯이 초기화 순서가 V8이 이미 봤던 패턴과 같다면, V8은 런타임 호출을 하는 대신 미리 생성된 코드를 이용하여 초기화를 진행합니다. 만약 초기화 순서가 V8이 본 적이 없는 패턴이라면, 더 느린 방식인 런타임 호출을 이용합니다.

명명된 퍼블릭 클래스 필드 최적화하기

명명된 퍼블릭 클래스 필드 초기화 속도를 높이기 위해, 우리는 기존의 DefineNamedOwnProperty 바이트코드를 재사용했습니다. DefineNamedOwnProperty는 인터프리터나 JSDefineNamedOwnProperty IR 명령코드로부터 컴파일 된 코드를 통해 DefineNamedOwnIC를 호출합니다.

이제 V8이 다음 클래스를 마주하면,

class A {
  #a = 0;
  b = this.#a;
}

b = this.#a 초기화문을 위해 다음의 바이트코드를 생성합니다.

// `#a`를 위한 프라이빗 이름 심볼을 로드합니다
LdaImmutableCurrentContextSlot [2]

// 인스턴스에서 `#a` 프라이빗 이름 심볼로 색인된 프로퍼티의 값을 r2에 로드합니다
// 참고: 리팩토링 과정에서 LdaKeyedProperty는 GetKeyedProperty로 이름이 바뀌었습니다
GetKeyedProperty <this>, [2]

// DefineKeyedOwnProperty 바이트코드를 사용해 `#a`로 색인된 프로퍼티를
// `b`로 색인된 프로퍼티의 값으로 저장합니다. 즉, `b = this.#a;`
DefineNamedOwnProperty <this>, [0], [4]

기존의 DefineNamedOwnIC는 원래 객체 리터럴 초기화를 위해 만들어졌어서, 명명된 퍼블릭 클래스 필드를 처리하는데 바로 사용될 수 없었습니다. 이전에는 이 IC는 생성 후 사용자에 의해 수정되지 않은 객체를 타겟으로 상정했었고, 객체 리터럴은 항상 이 가정을 만족했습니다. 하지만 클래스 필드는 클래스가 생성자가 대상을 오버라이드하는 베이스 클래스를 확장하는 경우, 사용자가 정의한 객체에 기반해 초기화될 수 있습니다.

class A {
  constructor() {
    return new Proxy(
      { a: 1 },
      {
        defineProperty(object, key, desc) {
          console.log('object:', object);
          console.log('key:', key);
          console.log('desc:', desc);
          return true;
        }
      });
  }
}

class B extends A {
  a = 2;
  #b = 3;  // 관찰 가능하지 않습니다.
}

// object: { a: 1 },
// key: 'a',
// desc: {value: 2, writable: true, enumerable: true, configurable: true}
new B();

이런 대상에 대응하기 위해, IC가 초기화되는 객체가 프록시일 경우 또는 만약 정의되는 필드가 이미 객체에 존재하거나, 객체가 IC가 본 적 없는 히든 클래스를 가질 경우에 런타임 호출로 폴백(fallback)하게 패치했습니다. 만약 충분히 반복되면 엣지 케이스도 여전히 최적화 가능하지만, 지금까지는 구현의 단순성을 위해 이런 경우의 성능을 내놓는 것이 더 좋아보입니다.

프라이빗 메소드 최적화하기

프라이빗 메소드의 구현

명세서에는, 프라이빗 메소드가 마치 인스턴스에는 설치되지만 클래스에는 설치되지 않는 것처럼 설명되어 있습니다. 하지만 메모리를 절약하기 위해서 V8의 구현은 프라이빗 메소드를 클래스와 관련된 컨텍스트에 프라이빗 브랜드 심볼과 함께 저장합니다. 생성자가 호출되면, V8은 인스턴스에 프라이빗 브랜드 심볼을 키로하여, 컨텍스트로의 레퍼런스를 저장할 뿐입니다.

프라이빗 메소드를 가진 클래스의 이벨류에이션(evaluation)과 인스턴스 생성

프라이빗 메소드에 접근하면, V8은 실행 컨텍스트부터 클래스 컨텍스트를 찾기 위해 컨텍스트 체인을 순회하고, 찾은 컨텍스트로부터 정적으로 알려진 슬롯을 읽어 해당 클래스를 위한 프라이빗 브랜드 슬롯을 얻어낸 후에, 인스턴스가 이 브랜드 심볼로 색인된 프로퍼티를 갖고 있는지 확인하여 인스턴스가 클래스로부터 생성됐는지 확인합니다. 만약 브랜드 검사가 통과하면, V8은 프라이빗 메소드를 같은 컨텍스트의 다른 알려진 슬롯으로부터 불러와 접근을 마무리합니다.

프라이빗 메소드 접근

다음 스니펫을 예로 봅시다.

class A {
  #a() {}
}

V8은 A의 생성자를 위해 다음의 바이트코드를 생성해왔습니다.

// 컨텍스트로부터 클래스 A의 프라이빗 브랜드 심볼을 로드하여
// r1에 저장합니다.
LdaImmutableCurrentContextSlot [3]
Star r1

// 대상을 r0으로 로드합니다.
Mov <this>, r0
// 현재 컨텍스트를 r2에 로드합니다.
Mov <context>, r2
// %AddPrivateBrand() 런타임 함수를 호출해 프라이빗 브랜드를 키로하여 
// 인스턴스에 컨텍스트를 저장합니다.
CallRuntime [AddPrivateBrand], r0-r2

%AddPrivateBrand() 런타임 함수 호출이 있었기 때문에, 오버헤드로 인해 생성자가 퍼블릭 메소드만 가진 클래스의 생성자보다 훨씬 느렸습니다.

프라이빗 브랜드 초기화 최적화하기

프라이빗 브랜드 설치 속도를 높이기 위해, 대부분의 경우 프라이빗 필드 최적화를 위해 도입된 DefineKeyedOwnProperty 장치를 재사용했습니다.

// 컨텍스트로부터 클래스 A를 위한 프라이빗 브랜드 심볼을 로드하여
// r1에 저장합니다
LdaImmutableCurrentContextSlot [3]
Star0

// DefineKeyedOwnProperty 바이트코드를 사용해 프라이빗 브랜드를 키로하여 
// 인스턴스에 컨텍스트를 저장합니다
Ldar <context>
DefineKeyedOwnProperty <this>, r0, [0]

서로 다른 메소드를 가진 클래스의 인스턴스 초기화 성능

하지만 주의점이 있습니다. 만약 클래스가 생성자에서 super()를 호출하는 파생 클래스라면, 우리의 경우 프라이빗 브랜드 심볼 설치 과정인 프라이빗 메소드 초기화가 super()가 반환된 후에 일어나야 합니다.

class A {
  constructor() {
    // 아직 super()가 반환하지 않았기 때문에 new B() 호출 시에 에러를 발생시킵니다.
    this.callMethod();
  }
}

class B extends A {
  #method() {}
  callMethod() { return this.#method(); }
  constructor(o) {
    super();
  }
};

전에 설명한 대로, 브랜드를 초기화할 때, V8은 인스턴스에도 클래스 컨텍스트로의 레퍼런스를 저장합니다. 이 레퍼런스는 브랜드 검사에 쓰이지 않고, 대신에 디버거가 인스턴스가 어떤 클래스로부터 만들어졌는지 몰라도 프라이빗 메소드 목록을 얻을 수 있도록 합니다. 생성자에서 super()가 직접 호출되면, V8은 초기화를 수행하기 위해 바로 컨텍스트 레지스터로부터 컨텍스트를 로드할 수 있습니다(위의 바이트코드에서 Mov <context>, r2 또는 Ldar <context>가 하는 일). 하지만 super()는 중첩된 화살표 함수에서도 호출될 수 있습니다. 즉, 다른 컨텍스트에서 호출될 수도 있습니다. 이 경우에는, V8이 컨텍스트 레지스터에 의존하는 대신 (여전히 %AddPrivateBrand()라는 이름을 가진) 런타임 함수를 호출하여 컨텍스트 체인에서 클래스 컨텍스트를 찾습니다. 예로 아래의 callSuper 함수를 봅시다.

class A extends class {} {
  #method() {}
  constructor(run) {
    const callSuper = () => super();
    // ...뭔가 합니다.
    run(callSuper)
  }
};

new A((fn) => fn());

V8은 이제 다음 바이트코드를 생성합니다.

// super 생성자를 호출해 인스턴스를 구축하고
// r3에 저장합니다.
...

// 현재 컨텍스트로부터 깊이 1인 클래스 컨텍스트의 프라이빗 브랜드 심볼을 
// 로드하여 r4에 저장합니다
LdaImmutableContextSlot <context>, [3], [1]
Star4

// 깊이 1을 Smi(small integer)로서 r6에 로드합니다
LdaSmi [1]
Star6

// 현재 컨텍스트를 r5에 로드합니다
Mov <context>, r5

// %AddPrivateBrand()를 사용해 현재 컨텍스트로부터
// 클래스 컨텍스트 위치를 특정하고 프라이빗 브랜드 심볼을 키로하여
// 인스턴스에 저장합니다
CallRuntime [AddPrivateBrand], r3-r6

이 경우에 런타임 호출 비용이 다시 발생하기 때문에 클래스 인스턴스 초기화가 퍼블릭 메소드만 있는 클래스의 인스턴스 초기화보다 여전히 느릴 것입니다. %AddPrivateBrand가 하는 일을 위한 바이트코드를 따로 만들 수도 있지만, super()를 중첩된 화살표 함수에서 호출하는 것은 상당히 희귀하기 때문에, 한 번 더 구현의 단순성을 위해 퍼포먼스를 교환했습니다.

마침말

이 글에서 언급된 작업은 Node.js 18.0.0 릴리즈에도 포함되어 있습니다. 이미 Node.js는 프라이빗 필드를 이용하는 몇 가지 빌트인 클래스에 심볼 프로퍼티를 도입해 임베드된 부트스트랩 스냅샷에 프라이빗 필드를 포함시키고 생성자 성능을 개선한 바가 있습니다(더 맥락을 이해하기 위해선 이 글을 봐주세요). V8의 개선된 클래스 기능 지원에 Node.js는 이 클래스들에 대해 다시 프라이빗 클래스 필드로 회귀했고, 이 변경은 어떤 성능 저하도 발생시키지 않았습니다.

구현에 기여해준 Igalia와 Bloomberg에 감사를 표합니다!

profile
유쾌한 동행과, 함께하는 성장을 사랑하는 Arch 리눅스 유저입니다.

1개의 댓글

comment-user-thumbnail
2022년 7월 9일

와 재밌는 글 잘 두고두고 봐야겠네요. 공유 감사합니다!

답글 달기