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

John McCutchan
John McCutchan

소개

최근 몇 년 동안 웹 애플리케이션의 속도가 크게 빨라졌습니다. 이제 많은 애플리케이션이 충분히 빠르게 실행되어 일부 개발자는 '웹이 충분히 빠른가요?'라고 궁금해합니다. 일부 애플리케이션의 경우 충분할 수 있지만 고성능 애플리케이션을 개발하는 개발자에게는 충분히 빠르지 않습니다. JavaScript 가상 머신 기술이 놀라울 정도로 발전했지만 최근 연구에 따르면 Google 애플리케이션은 전체 시간의 50~70%를 V8 내에서 소비하는 것으로 나타났습니다. 애플리케이션에는 한정된 시간이 있습니다. 한 시스템에서 주기를 줄이면 다른 시스템에서 더 많은 작업을 할 수 있습니다. 60fps로 실행되는 애플리케이션은 프레임당 16ms밖에 없으므로 그렇지 않으면 버벅거림이 발생합니다. Find Your Way to Oz에서 모호한 성능 문제를 추적하는 V8팀의 성능 탐정들의 생생한 이야기에서 JavaScript 최적화 및 JavaScript 애플리케이션 프로파일링에 대해 알아보세요.

Google I/O 2013 세션

이 자료를 Google I/O 2013에서 발표했습니다. 아래 동영상을 확인하세요.

실적이 중요한 이유는 무엇인가요?

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

성능 문제 해결

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

V8 CSI: 오즈

Find Your Way to Oz를 제작하는 멋진 마법사들이 V8팀에 직접 해결할 수 없는 성능 문제를 가지고 문의했습니다. Oz가 정지되어 버벅거림이 발생하는 경우가 있습니다. Oz 개발자는 Chrome DevTools타임라인 패널을 사용하여 몇 가지 초기 조사를 진행했습니다. 메모리 사용량을 살펴보니 두려운 톱니바퀴 그래프가 표시되었습니다. 가비지 컬렉터가 초당 한 번 10MB의 가비지를 수집했으며 가비지 수집 일시중지는 버벅거림과 일치했습니다. Chrome DevTools의 타임라인에서 다음 스크린샷과 유사합니다.

DevTools 타임라인

V8 탐정인 야콥과 양이 이 사건을 맡았습니다. V8팀의 Jakob과 Oz팀의 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은 메모리가 두 개 이상의 세대로 나뉘는 일반적인 가비지 수집 시스템을 사용합니다. 젊은 세대는 최근에 할당된 객체를 보유합니다. 객체가 충분히 오래 유지되면 이전 세대로 이동됩니다.

최신 세대는 이전 세대보다 훨씬 더 자주 수집됩니다. 이는 젊은 세대 수집이 훨씬 저렴하므로 설계상 의도된 것입니다. 잦은 GC 일시중지는 Young 세대 컬렉션으로 인해 발생한다고 가정하는 것이 좋습니다.

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

V8 영 메모리

이 시점에서 from space와 to space가 전환됩니다. to 스페이스였으나 이제 from 스페이스가 된 공간이 처음부터 끝까지 스캔되고 아직 활성 상태인 객체는 to 스페이스로 복사되거나 이전 세대 힙으로 승격됩니다. 자세한 내용은 체니 알고리즘을 참고하세요.

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

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

간단히 말해, 아니요. 개발자는 10MB/초의 가비지를 예상하기 위해 아무것도 하지 않습니다.

용의자

조사의 다음 단계는 잠재적 용의자를 파악한 후 용의자를 좁혀 나가는 것입니다.

용의자 1

프레임 중에 new 호출 할당되는 각 객체는 GC 일시중지에 점점 더 가까워집니다. 특히 프레임 속도가 빠른 애플리케이션은 프레임당 할당을 0으로 유지하기 위해 노력해야 합니다. 일반적으로 이를 위해서는 신중하게 고려된 애플리케이션별 객체 재활용 시스템이 필요합니다. V8 조사팀에서 Oz팀에 확인한 결과, Oz팀에서는 new를 호출하지 않았습니다. 사실 Oz팀은 이미 이 요구사항을 잘 알고 있었으며 '불편할 수 있습니다'라고 말했습니다. 이 항목은 목록에서 삭제하세요.

용의자 #2

생성자 외부에서 객체의 '모양'을 수정합니다. 이는 생성자 외부의 객체에 새 속성이 추가될 때마다 발생합니다. 이렇게 하면 객체의 새 숨겨진 클래스가 생성됩니다. 최적화된 코드가 이 새 숨겨진 클래스를 감지하면 역최적화가 트리거되고, 코드가 핫으로 분류되고 다시 최적화될 때까지 최적화되지 않은 코드가 실행됩니다. 이러한 최적화 해제,재최적화 과정에서 버벅거림이 발생하지만 과도한 가비지 생성과는 엄격히 연관되지 않습니다. 코드를 신중하게 감사한 결과 객체 도형이 정적이라는 것이 확인되어 용의자 2는 배제되었습니다.

용의자 3

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

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

HeapNumber 객체 5개가 생성됩니다. 처음 세 개는 변수 a, b, c를 나타냅니다. 4번째는 익명의 값 (a * b)이고 5번째는 #4 * c의 값입니다. 5번째 값은 궁극적으로 point.x에 할당됩니다.

Oz는 프레임당 이러한 작업을 수천 번 실행합니다. 이러한 계산이 최적화되지 않은 함수에서 발생하는 경우 가비지가 발생할 수 있습니다. 최적화되지 않은 계산은 임시 결과에도 메모리를 할당하기 때문입니다.

용의자 #4

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

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

최적화된 코드에서 x에 새로 계산된 값이 할당될 때마다, 무해해 보이는 문장인 새 HeapNumber 객체가 암시적으로 할당되어 가비지 컬렉션 일시중지를 앞당깁니다.

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

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

과학 수사

이 시점에서 수사관은 힙 숫자를 객체 속성으로 저장하는 것과 최적화되지 않은 함수 내에서 이루어지는 산술 계산이라는 두 가지 용의자를 의심합니다. 이제 실험실로 가서 어떤 용의자가 유죄인지 확실히 결정할 때가 되었습니다. 참고: 이 섹션에서는 실제 Oz 소스 코드에서 발견된 문제를 재현하여 사용합니다. 이 재현은 원래 코드보다 훨씬 작으므로 추론하기가 더 쉽습니다.

실험 #1

의심스러운 항목 3 (최적화되지 않은 함수 내의 산술 계산)을 확인합니다. V8 JavaScript 엔진에는 내부에서 발생하는 상황에 대한 유용한 정보를 제공할 수 있는 로깅 시스템이 내장되어 있습니다.

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 함수의 텍스트 기반 표를 표시합니다.

[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이라는 세 가지 함수가 있습니다. 최적화된 함수의 이름 옆에는 별표 (*)가 표시됩니다. opt 함수는 최적화되고 unopt 함수는 최적화되지 않습니다.

V8 디텍터의 도구 상자에 있는 또 다른 중요한 도구는 plot-timer-event입니다. 다음과 같이 실행할 수 있습니다.

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

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

타이머 이벤트

하단의 그래프를 제외하고 데이터는 행으로 표시됩니다. X축은 시간 (ms)입니다. 왼쪽에는 각 행의 라벨이 포함되어 있습니다.

타이머 이벤트 Y축

V8.Execute 행에는 V8이 JavaScript 코드를 실행한 각 프로필 틱에 검은색 세로선이 그려집니다. V8.GCScavenger에는 V8이 새 세대 수집을 실행한 각 프로필 틱에 파란색 세로선이 그려집니다. 나머지 V8 상태도 마찬가지입니다.

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

실행 중인 코드 종류

이 선이 녹색으로 표시되면 좋지만 즉시 표시되지는 않습니다. 즉, 프로그램이 최적화된 안정적인 상태로 전환되었음을 의미합니다. 최적화되지 않은 코드는 항상 최적화된 코드보다 느리게 실행됩니다.

이렇게까지 왔다면 애플리케이션이 v8 디버그 셸(d8)에서 실행될 수 있도록 리팩터링하면 훨씬 더 빠르게 작업할 수 있습니다. d8을 사용하면 tick-processor 및 plot-timer-event 도구를 통해 반복 속도가 빨라집니다. 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을 잘 알고 있는 이들은 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 숫자가 여전히 속성으로 저장되고 있음을 알 수 있습니다. 프로파일링 결과 그럴 만한 가치가 있는 것으로 나타나면 위치를 배열형 데이터 배열 또는 유형 지정된 데이터 배열로 변경하면 생성되는 객체 수가 더 줄어듭니다.

에필로그

Oz 개발자는 여기서 멈추지 않았습니다. V8 디텍터가 공유한 도구와 기법을 사용하여 최적화 해제 지옥에 갇힌 다른 함수를 몇 개 더 찾고 계산 코드를 최적화된 리프 함수로 분해하여 성능을 더욱 개선할 수 있었습니다.

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