공부하는 블로그

JavaScript essentials: 엔진 동작을 알아야 하는 이유 본문

자바스크립트

JavaScript essentials: 엔진 동작을 알아야 하는 이유

devtimothy 2018. 10. 4. 14:00

JavaScript essentials: 엔진 동작을 알아야 하는 이유

원본 글은 이곳에서 확인하실 수 있으며, 원작자의 동의를 얻고 쓴 글입니다.

글을 읽기 전에 광고 배너를 한번씩 눌러주신다면 블로그 운영에 힘이 될 것 같습니다! :D

Photo by Moto “Club4AG” Miwa on Flickr

이 글은 스페인어로도 제공됩니다.

이 글에서는 자바스크립트 개발자들이 자신이 작성한 코드가 올바르게 실행되도록 엔진에 대해 알아야하는 것을 설명하고자합니다. 아래 코드에서는 전달 된 인수의 lastName 프로퍼티를 반환하는 한 줄짜리 함수를 볼 수 있습니다. 각 오브젝트에 하나의 프로퍼티를 추가하기만 해도 성능이 700% 이상 떨어집니다.

자세히 설명하겠지만, 정적 타입이 없는 자바스크립트는 이러한 동작을 유발합니다. C#이나 Java와 같은 다른 언어에 비해 이점으로 여겨지면 "Faustian bargain"이 될 수 있습니다.

(역자 주: Faustian bargain은 어떤 것에 대해서 무한한 탐욕을 느껴 이를 충족시키기위해서 무엇이든 희생할 수 있는 것을 뜻합니다. 문맥 상에서는 '자바스크립트를 잘 쓸 수 있을 것이다' 정도의 뉘앙스로 생각됩니다. 아니라면 댓글 달아주세요 T_T)

최고 속도에서 급제동

일반적으로 우리는 코드를 실행하는 엔진의 내부를 알 필요는 없습니다. 브라우저 벤더 업체는 엔진 실행 속도를 빠르게 만드는 데 많은 투자를 합니다.

좋네요! 그런데 다른 사람들이 이 무거운 짐을 들게하는게 나을텐데, 왜 우리가 엔진 동작 방식에 대해 걱정해야 할까요?

아래 코드 예제에서는 스타워즈 캐릭터들의 이름과 성을 저장하는 다섯 개의 오브젝트가 있습니다. getName 함수는 lastname의 값을 반환합니다. 이 함수가 10억회 실행하는 데 걸리는 총 시간을 측정합니다.

(() => { 
 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");
})();

Intel i7 4510U에서 실행 시간은 약 1.2 초입니다. 그럭저럭 괜찮죠. 이제 각 오브젝트에 다른 프로퍼티를 추가하고 다시 실행합니다.

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

이번 실행 시간은 8.5초로, 첫 번째 버전보다 7배 정도 느립니다. 이것은 최고 속도에서 브레이크를 급제동 하는것처럼 느껴집니다. 어떻게 그럴 수 있죠?

엔진을 자세히 볼 시간이네요.

연합군 : 인터프리터와 컴파일러

엔진은 소스 코드를 읽고 실행하는 부분입니다. 주요 브라우저 공급 업체마다 자체 엔진이 있습니다. Mozilla Firefox에는 Spidermonkey, Microsoft Edge에는 Chakra / ChakraCore, Apple Safari에는 엔진 JavaScriptCore가 있습니다. Google 크롬은 Node.js의 엔진이기도 한 V8을 사용합니다.

2008년 V8 출시는 엔진 역사에서 매우 중요한 순간이었습니다. V8은 자바스크립트의 상대적으로 느린 브라우저 인터프리팅을 대체했습니다.

이 대규모 개혁의 원인은 주로 인터프리터와 컴파일러의 연합에 있습니다. 오늘날 4개의 엔진 모두 이 기술을 사용합니다.

인터프리터는 소스코드를 거의 즉시 실행합니다. 컴파일러는 사용자의 시스템에서 직접 실행하는 기계 코드를 생성합니다.

컴파일러가 기계 코드 단(on the machine code generation)에서 동작하므로, 최적화가 적용됩니다. 컴파일 및 최적화 결과는 컴파일 단계에서 추가 시간이 필요함에도 불구하고 보다 빠른 코드 실행을 가능하게합니다.

현대 엔진의 기본 개념은 두 세계의 장점을 결합하는 것입니다.

  • 인터프리터의 빠른 애플리케이션 시작.

  • 컴파일러의 빠른 실행.

현대 엔진은 인터프리터와 컴파일러를 사용합니다. 출처: imgflip

두 가지 목표를 달성하는 것은 인터프리터에서부터 시작됩니다. 엔진은 자주 실행되는 코드 부분을 "Hot path"로 플래그 지정하고, 실행 중에 수집 된 컨텍스트 정보와 함께 컴파일러에 전달합니다. 이 프로세스를 통해 컴파일러는 현재 컨텍스트에 맞게 코드를 조정하고 최적화 할 수 있습니다.

우리는 컴파일러의 행동을 "Just In Time" 또는 간단히 JIT라고 부릅니다.

(*JIT: 재고 비용을 최소화하기 위해 자재 또는 구성 요소가 필요한 바로 전에 전달되는 제조 시스템을 나타냅니다.)

엔진이 잘 실행되면 자바스크립트가 C ++보다 우수한 특정 시나리오를 기대할 수 있습니다. 대부분의 엔진 작업이 "컨텍스트별 최적화"로 진행되는 것은 놀라운 일이 아닙니다.

인터프리터와 컴파일러 간의 상호 작용

런타임에서의 정적 타입 : 인라인 캐싱

인라인 캐싱은 JavaScript 엔진의 주요 최적화 기술입니다. 인터프리터는 오브젝트의 프로퍼티에 액세스하기 전에 검색을 수행해야합니다. 이 프로퍼티는 오브젝트의 프로토타입의 일부이거나 getter 메서드가 있거나 심지어 프록시를 통해 액세스 할 수 있습니다. 프로퍼티 검색은 실행 속도면에서 상당히 비용이 많이 듭니다.

엔진은 각 오브젝트를 런타임 중에 생성하는 "타입"에 할당합니다. V8은 ECMAScript 표준, 숨겨진 클래스 또는 오브젝트 형태(shape)의 일부가 아닌 이러한 "타입"을 호출합니다. 두 오브젝트가 동일한 오브젝트 형태(shape)를 공유하려면 두 오브젝트 모두 동일한 순서로 정확히 같은 프로퍼티를 가져야합니다. 그래서 {firstname : "Han", lastname : "Solo"} 오브젝트는 {lastname : "Solo", firstname : "Han"}와 다른 클래스에 할당됩니다.

오브젝트 형태의 도움으로 엔진은 각 프로퍼티의 메모리 위치를 알고 있습니다. 엔진은 해당 위치를 해당 프로퍼티에 액세스하는 함수에 하드코딩합니다.

인라인 캐싱은 조회(lookup) 작업을 없앱니다. 이것이 엄청난 성능 향상을 가져 온다는 사실은 놀라운 일이 아닙니다.

이전 예제로 돌아갑시다. 첫 번째 실행의 모든 오브젝트는 동일한 순서로 firstnamelastname라는 두 개의 프로퍼티만 갖습니다. 이 오브젝트 형태의 내부 이름이 p1이라고 가정해 봅시다. 컴파일러가 인라잉 캐싱을 적용할 때 함수는 오브젝트 형태 p1을 전달하고 lastname의 값을 즉시 반환한다고 가정합니다.

동작중인 인라인 캐싱 (Monomorphic)

그러나 두 번째 실행에서는 5 가지 다른 오브젝트 형태를 처리했습니다. 각 오브젝트에는 추가 프로퍼티가 있고 yoda에는 firstname이 완전히 누락되었습니다. 여러 오브젝트 형태를 처리하면 어떻게 될까요?

오리 vs 다양한 유형

좋은 코드 품질로 여러 유형을 처리 할 수있는 함수를 요구하는 함수형 프로그래밍에는 "덕 타이핑"이라는 잘 알려진 개념이 있습니다. 우리의 경우 전달 된 오브젝트가 lastname이라는 프로퍼티를 가진다면 모든 것이 정상입니다.

인라인 캐싱은 프로퍼티의 메모리 위치에 대한 값 비싼 조회(lookup)를 제거합니다. 각 프로퍼티에 액세스 할 때 오브젝트의 오브젝트 형태가 같을 때 가장 효과적입니다. 이를 monomorphic 인라인 캐싱이라고합니다.

우리가 최대 4 개의 다른 오브젝트 형태를 가지고 있다면, 우리는 polymorphic 인라인 캐싱 상태에 있습니다. monomorphic과 마찬가지로 최적화 된 기계 코드는 이미 4개의 모든 위치를 "인식"합니다. 하지만 전달 된 인수가 속한 네 가지 가능한 오브젝트 형태 중 어느 것인지 확인해야합니다. 이로 인해 성능이 저하됩니다.

우리가 4의 임계치를 초과하면, 그것은 극적으로 악화됩니다. 우리는 이제 소위 megamorphic 인라인 캐싱에 있습니다. 이 상태에서는 더 이상 메모리 위치의 로컬 캐싱이 없습니다. 대신 글로벌 캐시에서 조회해야합니다. 결과적으로 위에 언급한 극단적인 성능 저하가 발생합니다.

polymorphic과 Megamorphic 액션

아래에는 2 개의 다른 오브젝트 형태를 가진 polymorphic(polymorphic) 인라인 캐시가 있습니다.

polymorphic 인라인 캐시

그리고 코드 예제에서 보여준 5 개의 다른 오브젝트 형태를 가진 megamorphic 인라인 캐시

Megamorphic 인라인 캐시

자바스크립트 클래스로 구제하라

좋습니다, 그래서 우리는 5 개의 오브젝트 형태를 가지고 있고 megamorphic 인라인 캐싱을 만났습니다. 어떻게 해결할 수 있을까요?

우리는 엔진이 5 개의 모든 오브젝트를 동일한 오브젝트 형태으로 표시했는지 확인해야합니다. 즉, 우리가 만드는 오브젝트에는 가능한 모든 프로퍼티가 포함되어야합니다. 우리는 오브젝트 리터럴을 사용할 수 있지만 자바스크립트 클래스가 더 나은 해결책이라고 생각합니다.

정의되지 않은 프로퍼티의 경우 단순히 "null"을 전달하거나 그대로 두십시오. 생성자는 이러한 필드가 값으로 초기화되는지 확인합니다.

(() => {
 class Person {
   constructor({
     firstname = '',
     lastname = '',
     spaceship = '',
     job = '',
     gender = '',
     retired = false
  } = {}) {
     Object.assign(this, {
       firstname,
       lastname,
       spaceship,
       job,
       gender,
       retired
    });
  }
}
 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');
})();

이 함수를 다시 실행하면 실행 시간이 1.2 초로 돌아갑니다. 작업 완료!

요약

  • 최신 자바스크립트 엔진은 인터프리터와 컴파일러의 장점 인 빠른 애플리케이션 시작과 빠른 코드 실행을 결합합니다.

  • 인라인 캐싱은 강력한 최적화 기술입니다. 단일 오브젝트 형태만 최적화 된 기능으로 넘어갈 때 가장 효과적입니다.

  • 필자의 예제는 인라인 캐싱의 다양한 타입과 megamorphic 캐시의 성능 저하에 따른 영향을 보여주었습니다.

  • 자바스크립트 클래스를 사용하는 것이 좋습니다. TypeScript와 같은 정적 타입화 된 트랜스파일러는 monomorphic 인라인 캐싱의 가능성을 높입니다.

추가적으로 읽을만한 글

Benedikt Meurer에게 감사합니다.

글에 monomorphic, polymorphic, megamorphic 등의 단어가 사용되었습니다. polymorphic(다형성) 을 기준으로 monomorphic: 단일형, megamorphic: 초다형성으로 번역할수 있으나 글의 가독성을 위해 원어 그대로 사용하였습니다.


Comments