Performance Tips for JavaScript in V8

HTML5 Rocks

소개

Daniel Clifford는 V8의 JavaScript 처리 성능을 향상을 위한 팁과 트릭이라는 주제로 Google I/O에서 멋진 발표를 했습니다. Daniel은 C++과 JavaScript 간의 성능 차이를 주의 깊게 분석하고 JavaScript의 동작 방식을 유념하여 코드를 작성하면 더 빠른 속도를 낼 수 있다고 말합니다. Daniel의 이야기 중 핵심을 요약해서 이 글에 담았는데요, 성능에 대한 지침이 바뀌면 이 글의 내용 역시 업데이트하겠습니다.

가장 중요하게 생각해야 할 것

이 글에 성능에 대한 모든 조언을 넣는 것은 중요한 일입니다. 그러나 성능을 진단하는 일은 중독성이 있어서, 때로는 너무 깊은 부분에 초점을 맞추려다가 실질적인 문제를 놓치기도 합니다. 전체적인 관점에서 웹 애플리케이션의 성능을 바라볼 필요가 있습니다. 이러한 성능 팁에 초점을 맞추기 전에 PageSpeed 같은 도구로 코드를 분석해서 점수를 높히는 게 좋습니다. 성급한 최적화를 피할 수 있기 때문입니다.

좋은 성능을 얻기 위해 가장 기본적으로 생각해야 할 것은 세가지입니다.

  • 문제가 발생하기 전(또는 발견하기 전)에 준비합니다.
  • 그 다음에, 문제의 핵심을 파악하고 이해하세요.
  • 마지막으로 문제가 되는 것을 수정합니다.

위의 단계를 수행하기 위해서, V8이 JS 코드를 최적화하는 과정을 이해하는 일은 중요합니다. JS 런타임 설계를 고려하는 코드를 작성할 수 있기 때문이죠. 어떤 도구를 이용할 수 있는지, 그리고 어떤 도움을 받을 수 있는지 알아두는 것 또한 중요한 일입니다. Daniel은 개발자 도구 사용법에 대해서 몇 가지를 더 설명하고 있습니다만, 이 글에서는 V8 엔진 설계의 중요한 부분 몇 개만 다루겠습니다.

자, 그럼 V8 사용 팁 속으로 들어가보시죠!

Hidden Classes

JavaScript 코드를 컴파일 할 때 이용할 데이터 타입에 대한 정보는 제한적입니다. JavaScript는 런타임에 데이터 타입을 변경할 수 있기 때문에 컴파일 시에 JS 코드의 데이터 타입을 추론하는 비용이 비쌀 것이라고 예측해 볼 수 있습니다. 아마도 JavaScript 성능을 C++의 성능에 근접한 수준으로 끌어올릴 수 있을까? 하는 의문이 생길 것입니다. V8은 런타임에 객체를 처리할 때 사용하기 위해 내부적으로 hidden class를 만들어서 가지고 있습니다. 같은 hidden class를 가지고 있는 객체는 다음 번에도 동일한 최적화된 코드를 사용할 수 있습니다.

예제:

function Point(x, y) {
  this.x = x;
  this.y = y;
}

var p1 = new Point(11, 22);
var p2 = new Point(33, 44);
// 여기에서 p1과 p2는 hidden class를 공유합니다.
p2.z = 55;
// 경고! p1과 p2는 이제 다른 hidden class를 갖습니다.

객체 인스턴스 p2에 멤버 ".z"를 추가하기 전까지, p1과 p2는 내부적으로 같은 hidden class를 갖습니다. 이로써 V8은 p1이나 p2를 다루는 JavaScript 코드에 대한 단일 버전의 최적화된 어셈블리어를 만들 수 있습니다. 같은 JavaSript 코드의 hidden class가 서로 달라지는 원인을 찾아서 막을 수 있다면, 더 좋은 성능을 얻을 수 있습니다.

따라서

  • 모든 객체 멤버를 생성자 함수 안에서 초기화하세요(그래야 나중에 인스턴스가 멤버의 데이터 타입을 변경하지 않습니다).
  • 항상 같은 순서로 객체 멤버를 초기화합니다.

Numbers

V8은 데이터 타입이 변할 때 값을 효율적으로 나타내는 태그를 사용합니다. 사용자가 사용하고 있는 값을 통해서 어떤 number 타입을 다루고 있는지 추론합니다. 이러한 데이터 타입은 동적으로 변할 수 있기 때문에, 일단 V8은 추론을 해서 데이터 타입을 결정하고나면, 효율적으로 값을 나타낼 수 있는 태그를 사용합니다. 그러나, 이러한 데이터 타입을 나타내는 태그를 변경하는 데에 때때로 비용이 들기 때문에, number 타입을 지속적으로 사용하는 것이 가장 좋습니다. 일반적으로 적합하다면, 31비트 부호있는 정수를 사용하는 것이 최적입니다.

예제:

var i = 42;  // 31비트 부호있는 정수입니다.
var j = 4.2;  // 이 값은 double 타입의 부동 소수점 숫자 데이터입니다.

따라서

  • 31비트 부호있는 정수로 나타낼 수 있는 숫자 값 사용을 우선적으로 검토하세요.

Arrays

큰 희소 배열 처리를 위한 두 가지 유형의 배열 저장소가 내부에 있습니다.

  • 빠른 요소 : 키 값이 빈틈없이 채워진 경우에 사용하는 선형 저장소
  • 사전식 요소 : 그렇지 않은 경우 사용하는 해시 테이블 저장소

배열 저장소가 한 유형에서 다른 유형으로 변경되지 않게 하는 것이 가장 좋습니다.

따라서

  • 배열의 0번째 인덱스부터 시작하는 연속된 키 값을 사용합니다.
  • 최대 사이즈에 달하는 커다란 배열(예: > 64K 원소)을 미리 할당하지 말고, 사용하면서 크기를 점차적으로 늘립니다.
  • 배열의 요소를 삭제하지 마세요, 특히 숫자 배열을 조심해야합니다.
  • 초기화되지 않았거나 삭제된 요소를 불러오지 않습니다.
a = new Array();
for (var b = 0; b < 10; b++) {
  a[0] |= b;  // 안 좋아요!
}
//vs.
a = new Array();
a[0] = 0;
for (var b = 0; b < 10; b++) {
  a[0] |= b;  // 훨씬 좋습니다. 2배 더 빨라요.
}

또한, double 타입의 배열은 더 빠릅니다. - 배열의 hidden class가 원소의 데이터 타입을 추적하여, double 타입의 값만을 원소로 갖는 배열을 언박싱(hidden class 변경을 일으키는) 처리하기 때문이죠. 그러나, 부주의한 배열 조작은 박싱과 언박싱으로 인한 추가 연산의 원인이 될 수도 있습니다.

var a = new Array();
a[0] = 77;   // 할당합니다.
a[1] = 88;
a[2] = 0.5;   // 할당하고, 변환홥니다.
a[3] = true; // 할당하고, 변환합니다. 

위의 코드는 아래의 코드보다 덜 효율적입니다.

var a = [77, 88, 0.5, true];

첫 번째 예제에서 각각의 할당이 차례대로 수행되는데, a[2]에 값을 할당하는 과정에서 배열이 언박싱 double 배열로 변환됩니다. 그러나 이후에 a[3]에 값을 할당할 때 또 다시 원래대로 어떠한 값(Number 또는 Object)도 가질 수 있는 배열로 변환됩니다. 두번째의 경우에는 컴파일러가 모든 원소의 타입을 리터럴 값을 통해 알 수 있어, hidden class를 미리 결정할 수 있습니다.

따라서

  • 배열 리터럴을 사용해서 작은 고정 크기 배열을 초기화합니다.
  • 사용하기 전에 정확한 사이즈를 작은 배열에(<64k) 미리 할당합니다.
  • 숫자 배열에 비숫자 값(객체)을 저장하지 마세요.
  • 리터럴을 사용하지 않고 배열을 초기화할 때, 작은 배열이 재변환되지 않도록 조심합니다.

JavaScript Compilation

JavaScript는 매우 동적인 언어이고, 원래 인터프리터 방식으로 구현되는 언어지만, 최근의 JavaScript 런타임 엔진은 컴파일 방식을 사용합니다. 사실 V8(크롬의 JavaScript 엔진)은 두가지의 서로 다른 Just-In-Time(JIT) 컴파일러를 가지고 있습니다.

  • 모든 JavaScript 코드를 좋은 코드로 변환하는 전체 컴파일러(Full Compiler)
  • 최적화 컴파일러(Optimizing Compiler)는 대부분의 JavaScript 코드를 대단한 코드로 컴파일 하지만, 시간은 더 오래 걸립니다.

전체 컴파일러(The Full Compiler)

V8의 전체 컴파일러는 모든 코드를 처리합니다. 가능한 모든 코드를 실행하고, 대단하지는 않지만 좋은 코드를 빠르게 만들어냅니다. 이 컴파일러는 컴파일 시점에 데이터 타입에 대해서는 거의 아무 것도 가정하지 않습니다. 변수의 데이터 타입은 런타임 시에 변경될 수 있고, 변경될 것이라고 여기기 때문입니다. 전체 컴파일러가 만든 코드는 인라인 캐시(ICs)를 사용하여 프로그램이 실행되는 중에 타입에 대한 정보를 구체화합니다. 이를 통해 즉석에서 효율성을 높입니다.

데이터 타입 의존적인 코드를 캐싱함으로써, 효율적으로 데이터 타입을 처리하는 것이 인라인 캐시의 목적입니다. 코드가 실행되면, 가장 먼저 데이터 타입 추정의 유효성을 확인합니다. 그 다음에 처리해야 할 연산으로 바로 가기 위해 인라인 캐시를 이용합니다. 그러나, 여러 데이터 타입을 처리해야하는 경우에, 이런 연산 방식은 오히려 성능을 떨어뜨릴 수 있습니다.

따라서

  • 다형적(polymorphic) 연산 보다는 단형적(monomorphic) 연산을 이용하는 것이 더 좋습니다.

입력한 값의 hidden class가 항상 같다면 단형적 연산입니다. 그렇지 않고 입력 값이 변하면 다형적 연산으로, 이는 일부 인수가 다른 연산을 호출함으로써 타입이 변할 수 있다는 것을 의미합니다. 예를 들어, 이 예제의 두번째 add() 호출은 다형적 연산의 원인이 됩니다.

function add(x, y) {
  return x + y;
}

add(1, 2);      // add 함수의 더하기는 단형적 연산입니다.
add("a", "b");  // add 함수의 더하기는 다형적 연산이 됩니다.

최적화 컴파일러(The Optimizing Compiler)

전체 컴파일러의 처리와 병렬적으로, V8은 최적화 컴파일러를 이용해서 "hot" 함수(여러번 실행되는 함수를 의미합니다)를 재컴파일 합니다. 이 컴파일러는 컴파일된 코드를 더 빠르게 만들기 위해서 타입 피드백을 이용합니다. 사실 좀 전에 이야기했던 ICs를 통해서 얻은 데이터 타입을 이용하는 거죠.

최적화 컴파일러의 연산은 추론을 통해서 인라인(호출된 곳에 코드를 직접 가져다 두는 것)됩니다. 이렇게 하면 실행을 빠르게 할 수 있을 뿐만 아니라, 다른 최적화도 할 수 있습니다. 단형적인 함수와 생성자도 완전히 인라인될 수 있습니다(왜 단형성이 V8에서 좋은 방법인지를 말해주는 또 다른 이유입니다).

V8 엔진의 스탠드 얼론 버전인 "d8"을 이용하면 최적화된 내용을 기록할 수 있습니다.

d8 --trace-opt primes.js
(최적화 한 함수의 이름을 stdout에 로그로 남깁니다.)

그러나 모든 함수를 최적화 할 수는 없습니다. 일부 기능은 최적화 컴파일러가 특정 함수를 처리하는 것을 막습니다("최적화 지원을 하지 않음"). 특히, 최적화 컴파일러는 현재 try {} catch {} 블럭이 있는 함수를 최적화하지 않습니다!

따라서

  • try {} catch {} 블록을 사용해야 한다면, 성능에 민감한 코드를 중첩 함수에 넣습니다.
    function perf_sensitive() {
      // 성능에 민감한 작업을 여기에서 처리합니다.
    }
    
    try {
      perf_sensitive()
    } catch (e) {
      // 예외를 여기에서 처리합니다.
    }

아마 향후에 최적화 컴파일러로 try/catch 블럭을 최적화 할 수 있게 되면, 이 가이드는 변경될 것입니다. 어떻게 최적화 컴파일러가 함수 최적화를 건너뛰는지에 대한 내용과 그 외에 더 많은 정보를 위에서 이야기했던 d8의 "--tract-opt" 옵션을 사용해서 얻을 수 있습니다.

d8 --trace-opt primes.js

역최적화(De-optimization)

결국, 이 컴파일러는 추론을 통해서 최적화를 수행합니다. 이 작업은 때때로 효과적이지 않아서, 이전 상태로 다시 되돌려야 합니다. "역최적화"는 최적화된 코드를 버리고 전체 컴파일러의 정위치에서 실행을 재개하는 과정입니다. 나중에 다시 최적화를 하게되면 잠시 동안 실행 속도가 느려질 수 있습니다. 특히, 함수가 최적화된 후에 변수의 hidden class 변경하는 일이 역최적화를 일으킵니다.

따라서

  • 최적화된 함수의 hidden class 변경을 피하세요.

다른 최적화와 마찬가지로, V8이 로깅 플래그를 이용해서 역최적화해야 했었던 함수의 로그를 볼 수 있습니다.

d8 --trace-deopt primes.js

다른 V8 도구

Chrome을 시작할 때 또한 V8 추적 옵션을 줄 수 있습니다.

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --js-flags="--trace-opt --trace-deopt"

또한 개발자 도구 프로파일링을 외에 d8을 사용해서 프로파일링 할 수도 있습니다.

% out/ia32.release/d8 primes.js --prof

이는 내장 샘플링 프로파일러를 사용합니다. 샘플을 만들 때마다 밀리 세컨드가 소요되고, v8.log 파일을 작성합니다.

요약...

고성능의 JavaScript 코드를 준비하기 위해서 V8 엔진이 어떻게 코드를 처리하는지 파악하고 이해하는 것은 중요합니다. 한번 더, 기본적인 조언을 정리해보겠습니다.

  • 문제가 발생하기 전(또는 발견하기 전)에 준비합니다.
  • 그 다음에, 문제의 핵심을 파악하고 이해하세요.
  • 마지막으로 문제가 되는 것을 수정합니다.

이것은 여러분이 PageSpeed 같은 도구를 이용해서 작성한 JavaScript 코드에 있는 문제를 분명히 하는 일을 우선 해야 한다는 것을 의미합니다. 지표를 수집하기 전에 완전한 JavaScript(DOM이 아닌)로 변경을 하고 난 다음에, 이러한 지표를 이용해서 병목 현상이 생기는 지점을 정확하게 찾아 원인을 제거합니다. Daniel의 이야기(그리고 이 글)은 여러분이 V8이 어떻게 JavaScript를 실행하는지 더 잘 이해하는 데 도움을 줄 것입니다. 하지만 자신의 알고리즘을 최적화는 데 초점을 맞추는 것 역시 중요한 일이라는 것을 명심하세요!

참고

Comments

0