포렌식과 탐정 작업을 통해 자바스크립트 성능에 대한 미스터리를 풀어보세요.

John McCutchan
John McCutchan

소개

최근 몇 년 동안 웹 애플리케이션의 속도가 상당히 빨라졌습니다. 현재 많은 애플리케이션이 충분히 빠르게 실행되어 일부 개발자들이 "웹은 충분히 빠른가?"라는 질문을 들었습니다. 일부 애플리케이션의 경우에는 가능할 수 있지만 고성능 애플리케이션을 개발하는 개발자에게는 속도가 충분하지 않다는 것을 알고 있습니다. 자바스크립트 가상 머신 기술의 놀라운 발전에도 불구하고 최근 연구에 따르면 Google 애플리케이션은 사용 시간의 50~70%를 V8 내에서 소비합니다. 한 시스템에서 면도 주기를 제거하면 다른 시스템에서 더 많은 작업을 수행할 수 있습니다. 60fps로 실행되는 애플리케이션은 프레임당 16ms만 발생하거나 버벅거림이 발생한다는 점에 유의하세요. 오즈로 가는 길에서 잘 알려지지 않은 성능 문제를 추적하는 V8팀의 성능 탐정들의 이야기에서 JavaScript 및 자바스크립트 애플리케이션 프로파일링에 관해 자세히 알아보세요.

Google I/O 2013 세션

이 자료는 Google I/O 2013에서 발표한 바 있습니다. 아래 동영상을 확인해 보세요.

실적이 중요한 이유

CPU 사이클은 제로섬 게임입니다. 시스템의 한 부분을 적게 사용하면 다른 부분을 더 많이 사용하거나 전체적으로 더 원활하게 실행할 수 있습니다. 더 빠르게 달리고 더 많은 작업을 하는 것은 종종 상충하는 목표입니다. 사용자는 새로운 기능을 요구하는 동시에 애플리케이션이 더 원활하게 실행되기를 기대합니다. JavaScript 가상 머신의 속도는 계속 빨라지고 있지만, 웹 애플리케이션의 성능 문제를 다루는 많은 개발자들이 이미 알고 있는 것처럼, 그렇다고 해서 지금 바로 해결할 수 있는 성능 문제를 무시하는 것은 아닙니다. 실시간 고속 프레임 애플리케이션에서는 버벅거림이 발생하지 않도록 하는 압력이 매우 중요합니다. Insomniac Games는 게임의 성공에 견고하고 지속적인 프레임 속도가 중요하다는 연구를 내놓았습니다. '고정된 프레임 속도는 여전히 전문적이고 잘 만들어진 제품의 신호임'입니다. 웹 개발자들이 주목합니다.

성능 문제 해결

성능 문제를 해결하는 것은 범죄를 해결하는 것과 같습니다. 증거를 신중하게 조사하고, 의심스러운 원인을 확인하고, 다양한 솔루션을 실험해야 합니다. 그 과정에서 실제로 문제를 해결했는지 확인할 수 있도록 측정값을 문서화해야 합니다. 이 방법과 범죄 형사가 사건을 해결하는 방법 사이에는 거의 차이가 없습니다. 형사들은 증거를 조사하고, 용의자를 심문하고, 흡연용 총을 찾기 위해 실험을 진행합니다.

V8 CSI: 오즈

오즈로 가는 길을 만든 멋진 마법사들이 V8팀에 다가와서 스스로 해결할 수 없는 성능 문제를 다루었습니다. 가끔 오즈가 멈추어 버벅거림이 발생할 수 있습니다. Oz 개발자들은 Chrome DevTools타임라인 패널을 사용하여 초기 조사를 완료했습니다. 메모리 사용량을 살펴보면서 무서운 톱니 그래프를 발견했습니다. 1초에 한 번 가비지 컬렉터가 10MB의 가비지를 수집하고 있었으며, 버벅거림에 대응하여 가비지 컬렉션 일시중지가 발생했습니다. Chrome Devtools의 타임라인에서 다음 스크린샷과 유사합니다.

Devtools 타임라인

V8 형사, 제이콥, 양이 사건을 맡았습니다. V8 팀과 오즈 팀의 Jakob과 Yang이 오가는 시간이 길었습니다. 이 문제를 추적하는 데 도움이 된 중요한 사건에 대해 대화를 나누었습니다.

증거

첫 번째 단계는 초기 증거를 수집하고 연구하는 것입니다.

어떤 유형의 애플리케이션이 사용되고 있나요?

Oz 데모는 대화형 3D 애플리케이션입니다. 이 때문에 가비지 컬렉션으로 인한 일시중지에 매우 민감합니다. 60fps로 실행되는 대화형 애플리케이션은 모든 JavaScript 작업을 수행하는 데 16ms가 소요되며, Chrome에서 그래픽 호출을 처리하고 화면을 그릴 수 있도록 이 시간 중 일부를 남겨두어야 합니다.

Oz는 double 값에 대해 많은 산술 계산을 실행하고 WebAudio 및 WebGL을 자주 호출합니다.

어떤 종류의 성능 문제가 발생하나요?

프레임 드롭이라고도 하는 버벅거림이라고도 하는 일시중지가 발생하고 있습니다. 이러한 일시중지는 가비지 컬렉션 실행과 관련이 있습니다.

개발자가 권장사항을 따르고 있나요?

예, Oz 개발자는 JavaScript VM 성능 및 최적화 기술에 매우 능숙합니다. Oz 개발자들이 CoffeeScript를 소스 언어로 사용하고 CoffeeScript 컴파일러를 통해 JavaScript 코드를 생성하고 있었다는 사실은 주목할 만합니다. 이로 인해 Oz 개발자가 작성한 코드와 V8에서 사용 중인 코드가 일치하지 않아 조사가 더 까다로웠습니다. 이제 Chrome DevTools에서 소스 맵을 지원하므로 이를 더 쉽게 수행할 수 있습니다.

가비지 컬렉터가 실행되는 이유는 무엇인가요?

JavaScript의 메모리는 개발자가 VM을 통해 자동으로 관리합니다. V8은 메모리를 두 개 (또는 그 이상)의 generations로 나누는 공통 가비지 컬렉션 시스템을 사용합니다. 새로운 세대는 최근에 할당된 객체를 보유합니다. 객체가 충분히 오래 살아남으면 이전 세대로 이동합니다.

젊은 세대는 이전 세대보다 훨씬 더 자주 수집됩니다. 젊은 세대의 컬렉션이 훨씬 저렴하기 때문에 이는 고안된 것입니다. 잦은 GC 일시중지가 새로운 세대의 수집으로 인해 발생한다고 생각하는 것이 안전합니다.

V8에서 Young 메모리 공간은 크기가 동일한 두 개의 연속된 메모리 블록으로 나뉩니다. 이 두 메모리 블록 중 하나만 특정 시점에 사용 중이며 이를 공간이라고 합니다. 공간에 남아 있는 메모리가 있지만 새 객체를 할당하는 것은 비용이 저렴합니다. to 공간에 있는 커서가 새 객체에 필요한 바이트 수 앞으로 이동합니다. 이 과정은 공간이 소진될 때까지 계속됩니다. 이 시점에서 프로그램이 중지되고 수집이 시작됩니다.

V8 Young 메모리

이 시점에서 공간과 공간이 서로 바뀝니다. to 스페이스였고 지금은 from space가 되면 처음부터 끝까지 스캔되며 아직 살아 있는 객체는 공간에 복사되거나 이전 세대 힙으로 승격됩니다. 자세한 내용은 치니의 알고리즘을 읽어 보세요.

직관적으로는 객체가 암시적으로 또는 명시적으로 할당될 때마다 (new, [] 또는 {} 호출을 통해) 애플리케이션이 가비지 수집에 점점 가까워지고 지루한 애플리케이션 일시 중지에 가까워진다는 점을 이해해야 합니다.

이 애플리케이션에 초당 10MB의 가비지가 예상되나요?

간단히 말하면, 아닙니다. 개발자는 초당 10MB의 가비지를 예상하는 어떠한 작업도 하고 있지 않습니다.

용의자

조사의 다음 단계는 잠재적 용의자를 파악한 후 이를 제거하는 것입니다.

용의자 #1

프레임 내에 새로 전화를 겁니다. 할당된 각 객체는 GC 일시중지에 점점 더 가까이 움직인다는 점을 기억하세요. 특히 고속 프레임으로 실행되는 애플리케이션은 프레임당 0을 할당하기 위해 노력해야 합니다. 일반적으로 이를 위해서는 신중하게 설계된 애플리케이션별 객체 재활용 시스템이 필요합니다. V8 탐정이 오즈팀에 확인해 보니 새로 전화한 것은 아니었습니다. 실제로 오즈 팀은 이미 이 요구사항을 잘 알고 있었고 "부끄러울 것 같다"고 말했습니다. 목록에서 이걸 그립니다.

용의자 #2

생성자 외부에서 객체의 '모양' 수정 이는 새 속성이 생성자 외부의 객체에 추가될 때마다 발생합니다. 이렇게 하면 객체의 새로운 숨겨진 클래스가 생성됩니다. 최적화된 코드가 이 새로운 숨겨진 클래스를 확인하면 해제가 트리거되고 코드가 핫 및 최적화로 다시 분류될 때까지 최적화되지 않은 코드가 실행됩니다. 이러한 탈최적화,재최적화 이탈은 버벅거림을 유발하지만 과도한 가비지 생성과 밀접한 관련이 없습니다. 코드를 신중하게 감사한 후 객체 모양이 정적인 것으로 확인되었으므로 의심가는 #2가 제외되었습니다.

용의자 #3

최적화되지 않은 코드의 산술 최적화되지 않은 코드에서는 모든 계산 결과에 실제 객체가 할당됩니다. 예를 들어 다음 스니펫은 다음과 같습니다.

var a = p * d;
var b = c + 3;
var c = 3.3 * dt;
point.x = a * b * c;

결과적으로 5개의 HeapNumber 객체가 생성됩니다. 처음 3개는 변수 a, b, c에 관한 것입니다. 4번째는 익명 값 (a &ast; b)에 대한 것이며 5번째는 #4 <ast; c의 값입니다. 5번째는 궁극적으로 point.x에 할당됩니다.

오즈는 프레임당 수천 가지 작업을 수행합니다. 최적화되지 않은 함수에서 이러한 계산이 발생하면 가비지의 원인이 될 수 있습니다. 최적화되지 않은 연산은 일시적인 결과에도 메모리를 할당하기 때문입니다.

용의자 #4

배정밀도 숫자를 속성에 저장하기 HeapNumber 객체를 만들어 숫자와 이 새로운 객체를 가리키도록 변경된 속성을 저장해야 합니다. HeapNumber를 가리키도록 속성을 변경해도 가비지가 생성되지 않습니다. 그러나 많은 배정밀도 숫자가 객체 속성으로 저장될 수 있습니다. 이 코드는 다음과 같은 문으로 구성됩니다.

sprite.position.x += 0.5 * (dt);

최적화된 코드에서는 x에 새로 계산된 값, 즉 무해한 문이 할당될 때마다 새 HeapNumber 객체가 암시적으로 할당되므로 가비지 컬렉션 일시중지에 가까워집니다.

유형이 지정된 배열 (또는 double만 보유하는 일반 배열)을 사용하면 이 특정 문제를 완전히 피할 수 있습니다. 배정밀도 숫자의 저장소는 한 번만 할당되며 값을 반복적으로 변경하면 새 저장소를 할당할 필요가 없기 때문입니다.

용의자 #4일 가능성이 있습니다.

과학 수사

이 시점에서 형사들은 힙 번호를 객체 속성으로 저장하는 것과 최적화되지 않은 함수 내에서 발생하는 산술 계산을 저장하는 두 가지 용의자를 마주할 수 있습니다. 실험실로 가서 어떤 용의자가 유죄인지 확실히 판단할 시간이었습니다. 참고: 이 섹션에서는 실제 Oz 소스 코드에서 발견된 문제를 재현해 봅니다. 이 재현은 원본 코드보다 몇 자릿수 더 작기 때문에 추론하기가 더 쉽습니다.

실험 #1

의심스러운 #3 확인 (최적화되지 않은 함수 내 산술 계산) V8 자바스크립트 엔진에는 내부에서 일어나는 일에 대한 유용한 정보를 제공할 수 있는 로깅 시스템이 내장되어 있습니다.

Chrome이 아예 실행되지 않는 상태에서 다음 플래그를 사용하여 Chrome을 시작합니다.

--no-sandbox --js-flags="--prof --noprof-lazy --log-timer-events"

그런 다음 Chrome을 완전히 종료하면 현재 디렉터리에 v8.log 파일이 생성됩니다.

v8.log의 콘텐츠를 해석하려면 Chrome에서 사용 중인 것과 동일한 버전의 v8을 다운로드하고 (about:version 확인) 빌드해야 합니다.

v8을 성공적으로 빌드한 후에는 틱 프로세서를 사용하여 로그를 처리할 수 있습니다.

$ tools/linux-tick-processor /path/to/v8.log

플랫폼에 따라 Linux 대신 Mac 또는 Windows를 사용하세요. (이 도구는 v8의 최상위 소스 디렉터리에서 실행해야 합니다.)

틱 프로세서는 틱이 가장 많은 자바스크립트 함수로 구성된 텍스트 기반 표를 표시합니다.

[JavaScript]:
ticks  total  nonlib   name
167   61.2%   61.2%  LazyCompile: *opt demo.js:12
 40   14.7%   14.7%  LazyCompile: unopt demo.js:20
 15    5.5%    5.5%  Stub: KeyedLoadElementStub
 13    4.8%    4.8%  Stub: BinaryOpStub_MUL_Alloc_Number+Smi
  6    2.2%    2.2%  Stub: BinaryOpStub_ADD_OverwriteRight_Number+Number
  4    1.5%    1.5%  Stub: KeyedStoreElementStub
  4    1.5%    1.5%  KeyedLoadIC:  {12}
  2    0.7%    0.7%  KeyedStoreIC:  {13}
  1    0.4%    0.4%  LazyCompile: ~main demo.js:30

demo.js에opt, unopt, main의 세 가지 함수가 있는 것을 볼 수 있습니다. 최적화된 함수에는 이름 옆에 별표 (*)가 표시됩니다. 함수 최적화는 최적화되고 unopt는 최적화되지 않습니다.

V8 탐정의 도구 가방에 있는 또 다른 중요한 도구는 plot-timer-event입니다. 다음과 같이 실행할 수 있습니다.

$ tools/plot-timer-event /path/to/v8.log

실행 후에는 타이머-events.png라는 png 파일이 현재 디렉터리에 있습니다. 파일을 열면 다음과 같이 표시됩니다.

타이머 이벤트

하단의 그래프를 제외하고 데이터가 행으로 표시됩니다. X축은 시간 (밀리초)입니다. 왼쪽에는 각 행에 대한 라벨이 포함됩니다.

타이머 이벤트 Y축

V8.Execute 행에 V8이 JavaScript 코드를 실행하는 각 프로필 틱에서 검은색 수직선이 그려져 있습니다. V8.GCScavenger에는 V8이 새로운 세대의 컬렉션을 수행하는 각 프로필 틱에서 파란색 수직선이 그려져 있습니다. 나머지 V8 상태에서도 마찬가지입니다.

가장 중요한 행 중 하나는 '실행 중인 코드 종류'입니다. 최적화된 코드가 실행될 때는 녹색으로 표시되고 최적화되지 않은 코드가 실행될 때는 빨간색과 파란색이 섞여 표시됩니다. 다음 스크린샷은 최적화에서 최적화되지 않은 코드로 전환했다가 최적화된 코드로의 전환을 보여줍니다.

실행 중인 코드 종류

이 줄이 녹색으로 채워져 있는 것이 가장 좋지만 즉시는 안 됩니다. 프로그램이 최적화된 안정적인 상태로 전환되었음을 의미합니다. 최적화되지 않은 코드는 항상 최적화된 코드보다 느리게 실행됩니다.

여기까지 진행했다면 v8 디버그 셸인 d8에서 실행될 수 있도록 애플리케이션을 리팩터링하면 훨씬 빠르게 작업할 수 있습니다. d8을 사용하면 틱 프로세서 및 플롯 타이머 이벤트 도구로 더 빠르게 반복할 수 있습니다. d8을 사용하면 또 다른 부작용은 실제 문제를 더 쉽게 분리하여 데이터에 존재하는 노이즈 양을 줄이는 것입니다.

Oz 소스 코드의 타이머 이벤트 플롯은 최적화된 코드에서 최적화되지 않은 코드로의 전환을 보여주었습니다. 최적화되지 않은 코드를 실행하는 동안 다음 스크린샷과 같이 많은 새로운 세대 컬렉션이 트리거되었습니다 (중간에서 시간은 삭제됨).

타이머 이벤트 도표

자세히 살펴보면 V8이 JavaScript 코드를 실행할 때를 나타내는 검은색 줄이 새로운 세대 컬렉션과 정확히 동일한 프로필 틱 시간 (파란색 선)에 누락된 것을 확인할 수 있습니다. 이는 가비지가 수집되는 동안 스크립트가 일시중지됨을 명확하게 보여줍니다.

Oz 소스 코드의 틱 프로세서 출력을 살펴보면 최상위 함수 (updateSprites)는 최적화되지 않았습니다. 즉, 프로그램이 가장 많은 시간을 소비한 함수도 최적화되지 않았습니다. 이는 용의자 3이 범인임을 강력하게 나타냅니다. updateSprites의 소스에는 다음과 같은 루프가 포함되어 있습니다.

function updateSprites(dt) {
    for (var sprite in sprites) {
        sprite.position.x += 0.5 * dt;
        // 20 more lines of arithmetic computation.
    }
}

이들은 V8과 V8을 알아본 결과 for-i-in 루프 구조가 V8에 의해 최적화되지 않는 경우도 있다는 사실을 즉시 알게 되었습니다. 즉, 함수에 for-i-in 루프 구조가 포함되어 있으면 최적화되지 않을 수 있습니다. 이는 현재 특별한 사례이며 향후 변경될 가능성이 높습니다. 즉, V8이 언젠가 이 루프 구성을 최적화할 수 있습니다. V8 탐정이 아니고 V8도 모르기 때문에 updateSprites가 최적화되지 않은 이유를 어떻게 판단할 수 있을까요?

실험 #2

이 플래그로 Chrome을 실행하면

--js-flags="--trace-deopt --trace-opt-verbose"

최적화 및 최적화 해제 데이터에 대한 상세 로그를 표시합니다. updateSprites의 데이터를 검색하면 다음과 같은 사실을 알게 되었습니다.

[updateSprites 최적화 사용 중지됨, 이유: ForInStatement가 빠른 사례가 아님]

형사들이 세운 가설과 마찬가지로 for-i-in 루프 구조가 원인이었습니다.

케이스 종료됨

updateSprites가 최적화되지 않은 이유를 발견한 후, 해결 방법은 간단했습니다. 계산을 자체 함수로 이동하기만 하면 됩니다.

function updateSprite(sprite, dt) {
    sprite.position.x += 0.5 * dt;
    // 20 more lines of arithmetic computation.
}

function updateSprites(dt) {
    for (var sprite in sprites) {
        updateSprite(sprite, dt);
    }
}

updateSprite가 최적화되므로 HeapNumber 객체가 훨씬 적어지므로 GC 일시중지 횟수가 줄어듭니다. 새 코드로 동일한 실험을 수행하여 이를 쉽게 확인할 수 있습니다. 주의 깊게 읽을 경우 이중 숫자가 여전히 속성으로 저장된다는 것을 알 수 있습니다. 프로파일링할 가치가 있다고 나타난 경우 위치를 double의 배열이나 유형이 지정된 데이터 배열로 변경하면 생성되는 객체 수가 더 줄어듭니다.

에필로그

오즈 개발자들은 거기서 멈추지 않았습니다. V8 탐정들이 공유한 툴과 기술로 무장한 이들은 탈최적화 지옥에 갇혀 있는 몇 가지 다른 함수를 찾을 수 있었고, 연산 코드를 최적화된 리프 함수에 통합하여 성능이 훨씬 더 향상되었습니다.

밖으로 나가서 성능 범죄를 해결해 보세요.