image.png

  • 자바스크립트 엔진의 인터프리터 / 컴파일러 파이프라인
  • 인라인 캐시 inline cache

자바스크립트 엔진의 인터프리터 / 컴파일러 파이프라인

즉 자바스크립트 엔진이 어떻게 자바스크립트 코드를 수행하는지 그 과정을 아주 얕게 살펴보겠습니다.

서버 코드를 타입스크립트로 구현하려고 구글링하다가 새로운 사실을 알게 되었습니다.
자바스크립트 엔진에 관련된 것인데요. 아주 잘못 알고 있었습니다.

V8은 이전에 블로그에서도 말씀 드렸듯이, 자바스크립트를 기계어로 바꾸어 프로그램이 동작하게 하는 것이고, "자바스크립트 엔진"이라고 합니다.
V8은 Chrome의 자바스크립트 엔진이며, Node.js의 엔진이기도 합니다.

우선 자바스크립트 코드를 실제로 실행하는 파이프라인(결국 코드는 명령어와 피연산자의 조합으로 바뀝니다. 이러한 명령어를 효율적으로 수행하는 구조를 가리킵니다.)에 포커스를 맞춰서 자바스크립트 엔진 특히 V8만 이해를 해보겠습니다.

자바스크립트 코드가 내부적으로 어떻게 동작하는지 보여주는 개념도 입니다.
자바스크립트 엔진의 인터프리터 / 컴파일러 파이프라인
image.png
출처 : JavaScript engine fundamentals: Shapes and Inline Caches - mathiasbynens.be

interpreter는 바이트 코드 (특정 하드웨어가 아닌 가상 컴퓨터에서 돌아가는 실행 프로그램을 위한 이진 표현법이다. 하드웨어가 아닌 소프트웨어에 의해 처리되기 때문에, 보통 기계어보다 더 추상적이다.)를 생성합니다. 바이트코드를 생성하는 시점에서 자바스크립트 엔진은 실제로 Javascript 코드를 실행한다고 하네요.

bytecode

image.png
출처: Understanding V8’s Bytecode - Franziska Hinkelmann

위에 자바스크립트 엔진의 인터프리터 / 컴파일러 파이프라인 개념도에서
interpreter와 optimizing compiler를 파이프라인이라고 부른다네요.

interpreter는 최적화되지 않은 bytecode를 빠르게 생성한다네요.
optimizing compiler는 좀더 시간이 걸리지만 최적화된 머신코드를 생성한답니다.
최적화된 머신코드생성은 결국은 실행 속도를 높이기 위함이겠죠.

아래의 구조는 v8엔진입니다.
image.png
Ignition은 점화죠. 이 친구는 위에 인터프리터 컴파일러 파이프라인 구조에서 인터프리터위치에 대응됩니다. 하는일은 바이트코드를 생성하고, 바이트코드를 실행합니다.
바이트코드를 실행하면서, profiling data를 모은데요. profiling data? 그냥 (어떤 함수는 수행되는데 얼마나 걸렸다는 데이터? 추측입니다.) 데이터라고 생각을 해볼게요.
그 profiling data를 Optimizing Compiler에 대응되는 TurboFan으로 전달된다네요. 예를 들어서, 자주 실행되는 함수가 hot해져요. hot해진다는 얘기는 수행이 자주 된다는 얘기겠죠. 위에 profiling data가 구체적으로 무엇인지는 모르나 그 것이 TurboFan으로 전달되고요. 그 이후에 profiling data를 기반으로 최적화된 머신코드를 생성한답니다.

최적화된 머신코드가 생성되고 deoptimize 화살표가 bytecode로 가죠?
위에서 interpreter는 빠르게 최적화되지 않은 bytecode를 생성한다 했어요.
그러니까, 최적화 안됐다. -> 연산방법이 구리다. 느리다. 라는 말이겠죠. 그래서 최적화되지 않았던 코드를 최적화된 머신코드가 interpreter에게 다시 간다네요. 다시 간다는 얘기는 hot한 function 즉, 반복 수행되는 코드는 최적화된 코드로 수행한다고 이해할 수 있습니다.

번역을 한거라, 어색한 부분이 있을 수 있습니다.
여기서 원문을 읽어보시길 바랍니다.

짤로 설명해둔 gif도 있습니다.
제가 설명한 내용이 차례대로 나옵니다. JIT는 위에서 optimizing compiler로 대응해서 이해했습니다.
img
출처 :JavaScript essentials: why you should know how the engine works - Rainer Hahnekamp

영어를 잘하시면 간단하게 이 영상을 시청하시면 좋을 것 같아요.
전 자막을 켜놓고 봤습니다.

정리

자바스크립트 엔진은 생각보다~ 빠르답니다. 그리고, 계속 빠르게 구조가 바뀐다네요.
그리고 자바스크립트 정적 타이핑 언어처럼 사용한다면 자바스크립트도 빠르답니다. 타입스크립트 써야겠죠?

레퍼런스 : JavaScript engine fundamentals: Shapes and Inline Caches - Heekyum
레퍼런스 : JavaScript engine fundamentals: Shapes and Inline Caches - Benedikt and Mathias
레퍼런스 : JavaScript 엔진 톺아보기 (1) - godori
레퍼런스 : JavaScript essentials: 엔진 동작을 알아야 하는 이유 - 공부하는 블로그
레퍼런스 : JavaScript essentials: why you should know how the engine works - freeCodeCamp
레퍼런스 : V8 by example: A journey through the compilation pipeline by Ujjwas Sharma at FrontCon 2019 - DevClub_lv
레퍼런스 : 자바스크립트 엔진의 최적화 기법 (1) - JITC, Adaptive Compilation - 정원기 NHN엔터테인먼트 / TOAST앱개발팀

인라인 캐시 inline cache

다음과 같은 코드를 보시면

function test { 
  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("engine");
  for(var i = 0; i < 1000 * 1000 * 1000; i++) { 
    getName(people[i & 7]); 
  }
  console.timeEnd("engine"); 
}

test();

함수를 실행한 결과, 10억번을 getName하면 약 1초정도 시간이 걸리네요.

image.png

person 객체에 프로퍼티를 한개씩만 추가해볼게요.

function test2() {
    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 = { firstname: "", lastname: "Yoda" };
    const obi2 = { firstname: "Obi2", lastname: "ND" };
    const people = [
        han, luke, leia, obi,
        yoda, luke, leia, obi2,
    ];
    const getName = (person) => person.lastname;
    console.time("engine");
    for (var i = 0; i < 1000 * 1000 * 1000; i++) {
        getName(people[i & 7]);
    }
    console.timeEnd("engine");
};

test2();

약 7초정도 걸렸죠. 고작 몇개의 프로퍼티만 추가했는데요.
image.png

엄청나게 비효율적입니다. 맨날 O(N)에서 N의 크기가 10000 이 정도일때는요. 크게 문제가 되지 않지만요. N이 1억이 넘어가는 순간에 시간복잡도에 민감해질 수 밖에 없겠죠. 여기선 10억번을 수행했습니다.

ICs(인라인 캐시)는 JavaScript를 신속하게 실행할 수 있게하는 핵심 요소입니다. JavaScript 엔진은 ICs를 사용하여 object에서 property를 찾을 수 있는 위치에 대한 정보를 암기하여, 높은 cost를 가지는 조회 횟수를 줄입니다.

어떻게요? 라는 생각이 들죠.
구글에서 V8을 개발한 개발자가 컨퍼런스에서 설명한 내용을 번역한 블로그가 있습니다. 여기에서 Inline Caches (ICs)를 검색해서 Shape와 ICs 그림을 천천히 살펴보시길 바랍니다.
제가 이해한 바를 설명해보면요, 명령문을 실행할때(자바스크립트 코드도 결국은 연산자(명령어)와 피연산자로 해석된다고 했죠) 프로퍼티를 다 접근한데요. 당연히 그래야겠죠. 그런데 같은 구조의 명령문일때는요(같은 구조의 함수나 객체의 경우). 모든 프로퍼티를 조회안하고 필요한 프로퍼티만 기억해서 조회한다는 내용입니다.

Polymorphic and Megamorphic in Action

무슨말일까요. 용어에 대해서는 아래에서 설명하겠습니다.

저한테 최고의 설명은 그림입니다. 짤이죠.
두가지 짤을 볼게요.

img
출처 : JavaScript essentials: why you should know how the engine works - Rainer Hahnekamp

img
출처 : JavaScript essentials: why you should know how the engine works - Rainer Hahnekamp

함수형 프로그래밍에서 잘 알려진 개념 "덕 타이핑" 이 있습니다.
여러 타입을 핸들링 할 수 있는 함수를 좋은 코드 퀄리티의 호출 방법입니다.
짤을 자세히 보시면 function getName이 있어요. 프로퍼티가 lastname만 있다면 문제가 없습니다.

인라인 캐싱은 불필요한 메모리 접근에 대한 조회를 제거한다는 거였죠.
같은 형태의 객체 형태면 아주 좋아요. 이런 형태를 monomorphic 인라인 캐시 라고 부른대요.

서로 다른 형태의 4가지 객체가 있다고 가정해볼게요. 이런 상태를 polymorphic 인라인 캐시 라고 부른대요. monomorphic처럼 최적화된 코드에서 4개의 객체의 메모리 위치를 알고 있대요. 그런데 getName이란 함수에서 전달된 인자가 4개중 어떤 객체가 오는지를 체크하는데에서는 퍼포먼스가 감소할겁니다.

서로 다른 객체의 수가 늘어날 수록 극적으로 비효율적인 구조겠죠. 이렇게 서로다른 형태의 객체가 많을때를 megamorphic 인라인 캐시라고 부른대요. 더이상 메모리에 캐시할 곳이 없는거죠. 대신에 전역 캐시에서 조회를 하는 겁니다. 퍼모먼스가 아주 안 좋다네요.

img
다시 봐도 느린게 보이시죠. 객체가 함수로 전달될때마다 반복해서 전역 캐시를 들르죠.

자바스크립트 클래스를 통해 이런 상황을 헤쳐나갑시다!

위에 사람들 getName() 10억번 하는 코드 있었죠. 프로퍼티가 늘어남에 따라 실행속도가 엄청나게 차이가 났죠.

클래스를 이용해서 실행해보겠습니다.

class Person {
    constructor(
        firstname,
        lastname,
        spaceship,
        job,
        gender,
        retired = false
    ) {
        this.firstname = firstname || "",
            this.lastname = lastname || "",
            this.spaceship = spaceship || "",
            this.job = job || "",
            this.gender = gender || "",
            this.retired = retired || false
    }
}

const han = new Person({ firstname: 'Han', lastname: 'Solo', spaceship: 'Falcon' });
const luke = new Person({ firstname: 'Luke', lastname: 'Skywalker', job: 'Jedi' });
const leia = new Person({ firstname: 'Leia', lastname: 'Organa', gender: 'female' });
const obi = new Person({ firstname: 'Obi', lastname: 'Wan', retired: true });
const yoda = new Person({ lastname: 'Yoda' });
const people = [han, luke, leia, obi, yoda, luke, leia, obi];
const getName = person => person.lastname;
console.time('engine');
for (var i = 0; i < 1000 * 1000 * 1000; i++) { getName(people[i & 7]); }
console.timeEnd('engine');

image.png

ES6부터는 클래스를 사용할 수 있게 되었죠. 이렇게 의미있는 속도 향상을 가져다 주는 클래스를 안 쓸 이유가 없을 것 같습니다.

잘 이해가 안되시면 여기에서 Shapes 부분만 이해를 해보시면 됩니다. Shapes는 자바스크립트 엔진이 객체를 효율적으로 저장하고, 조회할 수 있는 자료구조 형태를 말합니다.

정리

인라인 캐싱은 강력한 최적화 기술입니다. 다만, 같은 형태의 객체를 사용할때에만 최적화 코드로 인해 빠르게 수행될 수 있습니다.

자바스크립트 클래스를 사용함으로써 typescript 같은 정적 타입의 코드의 경우

자바스크립트 클래스를 사용하는 것이 좋습니다. 속도가 빠릅니다. 클래스 사용합시다!

타입스크립트와 같은 정적 타입화 된 트랜스파일러는 monomorphic 인라인 캐싱의 가능성을 높입니다. 타입스크립트 사용합시다!

원문 레퍼런스 : JavaScript essentials: why you should know how the engine works - Rainer Hahnekamp
번역된 레퍼런스 : JavaScript essentials: 엔진 동작을 알아야 하는 이유 - devtimothy
번역된 레퍼런스 : JavaScript engine fundamentals: Shapes and Inline Caches - Heekyum
원문 레퍼런스 : JavaScript engine fundamentals: Shapes and Inline Caches - Benedikt and Mathias