V8의 최적화 방식 히든클래스와 인라인 캐싱

·2023년 8월 20일
20

자바스크립트

목록 보기
2/2
post-thumbnail

왜 자바스크립트는 최적화가 필요할까요?

동적언어

우리 모두 알다시피 자바스크립트는 동적 타입 언어입니다.

*동적 타입 언어(dynamic typing language)는 프로그램에서 변수의 타입(type)을 변수를 생성하거나 사용할 때가 아닌, 실행 시점(runtime)에 결정하는 프로그래밍 언어를 가리킵니다.

이러한 언어에서는 변수가 어떤 타입의 데이터를 저장하고 있는지를 프로그램 코드 상에서 명시적으로 선언하지 않습니다. 대신 변수에 할당된 값의 타입을 기반으로 변수의 타입이 동적으로 결정됩니다. 이 때문에 객체의 프로퍼티에 접근하는 속도 면에서 정적 타이핑 언어의 코드와 비교했을 때 불리해질 수 있습니다.

정적 타이핑 언어

정적 타이핑(static typing) 언어를 사용하면 프로퍼티(혹은 구조체의 멤버)의 메모리 offset(위치) 을 컴파일 시에 결정할 수 있습니다. 프로퍼티를 선언할 때 오프셋 값을 어딘가에 저장해 둔 뒤 각 프로퍼티의 값이 필요할 때 offset을 값을 그대로 사용 할 수 있습니다.

동적 타이핑 언어

반면에 데이터 타입이 동적으로 정해진다면 프로퍼티의 메모리 offset을 컴파일할 때 결정하는 것이 불가능합니다. (프로퍼티의 데이터 타입이나 순서가 실제로 프로퍼티 값을 접근할 때는 달라질 수 있기 때문) 프로퍼티를 선언했을 때의 offset 값은 참조할 수 없게 되고 프로퍼티 값을 읽어야 할 때마다 프로퍼티를 찾아내야 합니다.

히든클래스

JavaScript Engine은 프로퍼티를 저장하기 위해서 사전식 데이터 구조를 이용하지만 V8은 hidden class를 이용합니다. 객체에 새로운 프로퍼티를 추가할 때 hidden class를 생성하고 hidden class에 프로퍼티의 정적인 위치를 저장함으로써 실제 데이터가 저장되어 이는 위치에 대한 Pointer를 제공합니다.

Object 구조

const user = {
 name : "홍길동",
 age: 30
};

자바스크립트 엔진은 객체의 obejct 구조(모양) 을 따로 저장하고 이 “모양” 에다가 객체의 모든 프로퍼티 이름과 그에 대한 속성 정보를 저장합니다. 단, 속성 정보를 저장할 때 [[Value]] 는 “모양”이 아니라 객체에 저장되고 “모양” 에는 객체 내에서의 해당 값의 인덱스(오프셋)를 저장합니다. 그리고 “모양” 인스턴스에는 프로퍼티의 이름과 해당 이름에 대한 프로퍼티 속성 정보 및 실제 객체에서 해당 프로퍼티가 몇 번째 인덱스(오프셋)인지를 나타냅니다. 그리고 이 인덱스를 통해 자바스크립트 엔진이 실제 객체에서 프로퍼티의 값을 어떻게 찾을지 알 수 있는 것입니다.

조금 쉽게 말하자면 object 구조를 기억해 offset (위치) 를 저장하고 그 위치로 값을 찾아내는 것입니다. 만약 똑같은 모양을 가진 여러 객체들이 존재하는 경우는 어떨까요 ? 각 객체들의 프로퍼티 이름과 그에 대한 프로퍼티 속성 정보를 한 번만 저장하면 된다는 이점이 있습니다.

Object 구조를 맞춘 히든클래스

function Student(name, age, grade) {
    this.name = name;   //2
    this.age = age;     //3
   this.grade = grade; //4
}
var Jim = new Student('hoon', 19, 'A'); //1
var Mary = new Student("seorim", 25, 'c'); //5
var Karin = new Student('hoon', 25, ['B','C']);

*메모리탭에서 보다 빠른 서칭을 위해 생성자로 객체를 생성했습니다.

1. 해당 코드를 console 창에 입력해보세요.

2. 메모리 탭을 누른 후 'Take sapShot' 버튼 눌러 스냅샷을 찍습니다.

3. 생성자로 생성한 객체를 검색하세요.

4. map :: system / Map 부분을 확인해보세요.

{name : '',
age : '',
grade :''}

같은 구조를 가진 객체들은 같은 히든클래스를 참조하고 있습니다.

Grade 탭을 문자열을 넣은 곳은 @97 값을 가지고 있지만 배열을 대입한 곳은 @42961 을 가지고 있습니다.

function Student(name, age, grade) {
    this.name = name;   //2
    this.age = age;     //3
   this.grade = grade; //4
}
var Jim = new Student('hoon', 19, 'A'); //1
var Mary = new Student("seorim", 25, 'c'); //5
var Karin = new Student('hoon', 25, ['B','C']);

해당 코드를 다시 보면 알 수 있습니다. grade key 에 ['B','C'] 라는 array 타입을 사용했기 때문에 새로운 히든클래스가 생성 되는 것을 알 수 있습니다.

직접 코드를 작성해 메모리 탭을 활용해 디버깅 하시면 더 빠른 이해가 가능합니다 !

Transition 체인

자바스크립트는 동적 타입 언어이므로 런타임에 객체 프로퍼티를 추가(혹은 제거)할 수도 있습니다. 이러면 객체의 모양이 바뀔 텐데 엔진은 이를 어떻게 처리할까요?

*https://mathiasbynens.be/notes/shapes-ics 을 참고해서 그렸습니다.

아래 그림처럼 해당 모양 인스턴스가 생성될 때 추가된 프로퍼티에 관한 정보만을 저장합니다. 그리고 (마치 프로토타입 체인처럼) 특정 모양 인스턴스에 존재하지 않는 프로퍼티를 찾기 위해, 다음 모양 인스턴스 에서 이전 모양 인스턴스 를 가리키는 포인터가 추가됩니다. (기존의 단방향 포인터에서 양방향 포인터가 되는 것입니다.

하지만 아래와 같은 경우 transition 체인을 생성할 수 없습니다.

데이터 구조

const user1 = {};
user1.name = '홍길동'

const user2 = {};
user1.age = 35

처음에는 같은 모양이었다가 서로 완전히 다른 모양으로 분기한 경우 기존의 transition 체인을 형성할 수 없습니다. 대신 이 경우엔 transition 트리를 형성합니다.

Transition 트리

하지만 그렇다고 항상 빈 모양({})에서 시작하는 것은 아닙니다. 다음 예시를 살펴봅시다:

데이터 구조

const user1 = {};
user1.name = '홍길동'

const user2 = { name:'윤뫄뫄' };

user1 객체의 경우, 우리가 이전에 본 것처럼 빈 모양({})에서 시작하여 { name: ... } 모양으로 “전이”합니다. 하지만 user2 객체의 경우 빈 모양에서 시작하는 것이 아니라 처음부터 { name: ...} 모양에서 시작합니다.

🗞️ 히든 클래스를 좀 더 쉽게 이해할 수 있는 tip

먼저 동적/정적 언어가 offset을 저장하는 방식을 먼저 이해해야 합니다. 동적 언어는 언어 특성상 변수나 속성에 접근할때 마다 lookup 과정을 거쳐야 하므로 실행 속도가 상대적으로 느립니다. 히든 클래스는 이러한 "문제점" 을 해결하기 위해 고안된 방법입니다.

*룩업(lookup) 과정은 프로그램에서 변수나 속성에 접근할 때 해당 변수나 속성의 위치를 찾는 과정을 의미합니다.

인라인캐싱

히든 클래스를 바탕으로 인라인 캐싱(Inline Caching)이라는 또다른 최적화 처리가 진행됩니다. 인라인 캐싱은 이 오프셋 값을 캐싱하겠다는 이야기입니다.인라인 캐시는 객체의 프로퍼티를 어디서 찾아야 하는지에 대한 정보를 캐싱함으로써 자바스크립트의 성능을 최적화하는 주된 요소입니다.

(() => {
  const han = { firstname: "Han", lastname: "Solo" };
  const luke = { firstname: "Luke", lastname: "Skywalker" };
  const leia = { firstname: "Leia", lastname: "Organa" };
  const obi = { firstname: "Obi", lastname: "Wan" };
  const yoda = { firstname: "", lastname: "Yoda" };
  const people = [han, luke, leia, obi, yoda, luke, leia, obi];
  const getName = (person) => person.lastname;

  console.time("engine1");
  for (var i = 0; i < 1000 * 1000 * 1000; i++) {
    getName(people[i % 8]);
  }
  console.timeEnd("engine1");
})();

(() => {
  const han = { firstname: "Han", lastname: "Solo", spacecraft: "Falcon" };
  const luke = { firstname: "Luke", lastname: "Skywalker", job: "Jedi" };
  const leia = { firstname: "Leia", lastname: "Organa", gender: "female" };
  const obi = { firstname: "Obi", lastname: "Wan", retired: true };
  const yoda = { lastname: "Yoda" };
  const people = [han, luke, leia, obi, yoda, luke, leia, obi];
  const getName = (person) => person.lastname;

  console.time("engine2");
  for (var i = 0; i < 1000 * 1000 * 1000; i++) {
    getName(people[i % 8]);
  }
  console.timeEnd("engine2");
})();

결과 확인

engine1: 871.944ms // 0.871944초
engine2: 3.911s //3.766초

초기값 할당

(() => {
  const han = {
    firstname: "Han",
    lastname: "Solo",
    spacecraft: "Falcon",
    job: "",
    retired: false,

  };
  const luke = {
    firstname: "Luke",
    lastname: "Skywalker",
    spacecraft: "",
    job: "Jedi",
    retired: false,
  };
  const leia = {
    firstname: "Leia",
    lastname: "Organa"
    spacecraft: "Falcon",
    job: "",
    retired: false,
  
  };
  const obi = {
    firstname: "Obi",
    lastname: "Wan",
    spacecraft: "Falcon",
    job: "",
    retired: false,
  };
  const yoda = {
    firstname: "Yoda",
    lastname: "Wan",
    spacecraft: "Falcon",
    job: "",
    retired: false,
  };
  const people = [han, luke, leia, obi, yoda, luke, leia, obi];
  const getName = (person) => person.lastname;

  console.time("engine3");
  for (var i = 0; i < 1000 * 1000 * 1000; i++) {
    getName(people[i % 8]);
  }
  console.timeEnd("engine3");
})();

결과 확인

engine3: 1.389s //1.389초

engine2와 비교해 내부 프로퍼티가 더 많음에도 불구하고 더 빠른 실행 속도를 보였습니다. 이것이 가능한 이유는 함수가 실행될 때 인스턴스 모양을 찾아가서 그 곳의 히든클래스에 들어가서 값을 가져오기 때문입니다.

함수 실행될 때 함수 자체에 인스턴스 모양과 해당 히든클래스의 offset(위치) 을 캐싱합니다. 그래서 그 후에 접근 하려는 객체가 그 전과 똑같은 인스턴스 모양이라면 캐싱해뒀던 offset을 통해서 값을 가져 올 수 있습니다. 이로써 초기값을 명시적으로 설정하고 object의 구조를 일관성 있게 맞춰 주는 것이 코드의 성능 향상에 도움이 될 수 있음을 알 수 있습니다.

레퍼런스

https://www.freecodecamp.org/news/javascript-essentials-why-you-should-know-how-the-engine-works-c2cc0d321553/
https://mrclap.github.io/posts/js/2019-08-09-javascript-inline-caching-/
https://jaehyeon48.github.io/javascript/hidden-class-ic/
https://engineering.linecorp.com/ko/blog/v8-hidden-class

profile
My Island

4개의 댓글

comment-user-thumbnail
2023년 8월 21일

좋은 정보 감사합니다 ^ㅇ^

답글 달기
comment-user-thumbnail
2023년 8월 29일

너무 좋은 글이네용... 추천

답글 달기
comment-user-thumbnail
2023년 8월 31일

함수가 실행될 때 순회하려는 객체 내부의 히든클래스 offset을 캐싱하기에, 객체들의 프로퍼티를 동일하게 만들어준다면 V8 엔진이 캐싱해뒀던 offset을 통해 값을 가져옴으로써 코드 성능 향상에 도움이 되는 것이군요..!
너무 좋은글이었습니다!! 감사합니다!!

1개의 답글