Chrome DevTools 스택 트레이스의 속도를 10배 높인 방법

Benedikt Meurer
Benedikt Meurer

웹 개발자는 코드를 디버그할 때 성능에 미치는 영향이 거의 없거나 전혀 없을 것으로 예상합니다. 그러나 이러한 기대는 결코 보편적이지 않습니다. C++ 개발자는 애플리케이션의 디버그 빌드가 프로덕션 성능에 도달할 것으로 기대하지 않습니다. Chrome 초기에는 DevTools를 열기만 해도 페이지 성능에 상당한 영향을 미쳤습니다.

이러한 성능 저하가 더 이상 느껴지지 않는 것은 수년간 DevToolsV8의 디버깅 기능에 투자한 결과입니다. 그럼에도 불구하고 DevTools의 성능 오버헤드를 0으로 만들 수는 없을 것입니다. 중단점 설정, 코드 단계별 실행, 스택 트레이스 수집, 성능 트레이스 캡처 등은 모두 실행 속도에 어느 정도 영향을 미칩니다. 결국 관찰하면 대상이 달라집니다.

물론 다른 디버거와 마찬가지로 DevTools의 오버헤드는 합리적이어야 합니다. 최근에 DevTools가 애플리케이션을 더 이상 사용할 수 없는 정도까지 느려지게 하는 보고서 수가 크게 늘어난 것을 확인했습니다. 아래는 DevTools를 열어 두는 것만으로도 발생하는 성능 오버헤드를 보여주는 보고서 chromium:1069425의 측정항목을 나란히 비교한 이미지입니다.

동영상에서 볼 수 있듯이 속도가 5~10배 정도 느려지며 이는 분명히 용납할 수 없습니다. 첫 번째 단계는 DevTools가 열려 있을 때 항상 어디로 가는지, 이렇게 엄청난 속도 저하의 원인을 파악하는 것이었습니다. Chrome 렌더러 프로세스에서 Linux perf를 사용한 결과 전체 렌더러 실행 시간의 다음과 같은 분포가 확인되었습니다.

Chrome 렌더러 실행 시간

스택 트레이스 수집과 관련된 문제가 있을 것으로 예상했지만 전체 실행 시간의 약 90%가 스택 프레임의 기호화에 사용될 것으로는 예상하지 못했습니다. 여기서 기호화는 원시 스택 프레임에서 함수 이름과 구체적인 소스 위치(스크립트의 줄 및 열 번호)를 확인하는 작업을 의미합니다.

메서드 이름 추론

더 놀라운 점은 V8의 JSStackFrame::GetMethodName() 함수에 거의 모든 시간이 소요된다는 사실입니다. 이전 조사에서 JSStackFrame::GetMethodName()가 성능 문제와 관련이 있다는 사실을 알고 있었지만 말입니다. 이 함수는 메서드 호출로 간주되는 프레임 (func()이 아닌 obj.func() 형식의 함수 호출을 나타내는 프레임)의 메서드 이름을 계산합니다. 코드를 간단히 확인해 보니 객체와 프로토타입 체인의 전체 순회를 실행하고

  1. valuefunc 클로저인 데이터 속성 또는
  2. get 또는 setfunc 폐쇄와 동일한 접근자 속성

이 자체로는 그렇게 저렴하지는 않지만 이처럼 심각한 속도 저하를 설명하기에는 충분하지 않은 것 같습니다. 이에 따라 chromium:1069425에 보고된 예시를 조사한 결과, 비동기 작업뿐 아니라 10MiB JavaScript 파일인 classes.js에서 발생한 로그 메시지에 대한 스택 트레이스가 수집된 것으로 확인되었습니다. 자세히 살펴보니 기본적으로 Java 런타임과 JavaScript로 컴파일된 애플리케이션 코드였습니다. 스택 트레이스에는 A 객체에서 호출되는 메서드가 있는 프레임이 여러 개 포함되어 있으므로 다루는 객체의 종류를 파악하는 것이 좋습니다.

객체의 스택 트레이스

Java 대 JavaScript 컴파일러가 무려 82,203개의 함수가 포함된 단일 객체를 생성한 것으로 보입니다. 이제 재미있어지기 시작했습니다. 그런 다음 V8의 JSStackFrame::GetMethodName()로 돌아가서 쉽게 얻을 수 있는 이점이 있는지 확인했습니다.

  1. 먼저 함수의 "name"를 객체의 속성으로 찾은 후 속성이 발견되면 속성 값이 함수와 일치하는지 확인합니다.
  2. 함수에 이름이 없거나 객체에 일치하는 속성이 없는 경우 객체와 프로토타입의 모든 속성을 탐색하여 역방향 조회로 대체합니다.

이 예에서 모든 함수는 익명이며 빈 "name" 속성을 갖습니다.

A.SDV = function() {
   // ...
};

첫 번째 발견사항은 역방향 조회가 두 단계로 분할되었다는 점입니다(객체 자체와 프로토타입 체인의 각 객체에 대해 실행됨).

  1. 열거 가능한 모든 속성의 이름 추출
  2. 각 이름에 대해 일반 속성 조회를 실행하여 결과 속성 값이 찾고 있는 폐쇄 함수와 일치하는지 테스트합니다.

이름을 추출하려면 이미 모든 속성을 살펴봐야 하므로 비교적 쉬운 작업으로 보였습니다. 이름 추출을 위한 O(N)과 테스트를 위한 O(N log(N))이라는 두 번의 패스를 실행하는 대신 한 번의 패스로 모든 작업을 실행하고 속성 값을 직접 확인할 수 있습니다. 이렇게 하여 전체 함수가 2~10배 더 빨라졌습니다.

두 번째 발견은 더 흥미로웠습니다. 이러한 함수는 기술적으로 익명 함수이지만 V8 엔진은 이러한 함수에 추론된 이름이라고 하는 이름을 기록했습니다. 할당의 오른쪽에 obj.foo = function() {...} 형식으로 표시되는 함수 리터럴의 경우 V8 파서는 "obj.foo"를 함수 리터럴의 추론된 이름으로 저장합니다. 따라서 이 경우에는 단순히 조회할 수 있는 올바른 이름은 없었지만 충분히 비슷한 값을 가졌습니다. 위의 A.SDV = function() {...} 예의 경우 "A.SDV"를 추론된 이름으로 사용했고 마지막 점을 찾아 추론된 이름에서 속성 이름을 파생한 다음 객체에서 "SDV" 속성을 찾습니다. 이렇게 하면 비용이 많이 드는 전체 트래버스를 단일 속성 조회로 대체하여 거의 모든 경우에 문제가 해결되었습니다. 이 두 가지 개선사항은 이 CL의 일부로 도입되었으며 chromium:1069425에서 보고된 예의 속도 저하를 크게 줄였습니다.

Error.stack

여기서 하루를 마무리할 수도 있었습니다. 하지만 DevTools는 스택 프레임에 메서드 이름을 사용하지 않으므로 문제가 있는 것 같습니다. 실제로 C++ API의 v8::StackFrame 클래스는 메서드 이름에 도달하는 방법조차 노출하지 않습니다. 따라서 처음에 JSStackFrame::GetMethodName()를 호출하는 것은 잘못된 것처럼 보였습니다. 대신 메서드 이름을 사용하고 노출하는 유일한 위치는 JavaScript 스택 트레이스 API입니다. 이 사용법을 이해하려면 다음과 같은 간단한 예시 error-methodname.js를 살펴보세요.

function foo() {
    console.log((new Error).stack);
}

var object = {bar: foo};
object.bar();

여기서는 object"bar"라는 이름으로 설치된 foo 함수가 있습니다. Chromium에서 이 스니펫을 실행하면 다음과 같은 출력이 생성됩니다.

Error
    at Object.foo [as bar] (error-methodname.js:2)
    at error-methodname.js:6

재생 시 메서드 이름 조회를 볼 수 있습니다. 최상위 스택 프레임은 bar라는 메서드를 통해 Object 인스턴스에서 foo 함수를 호출하도록 표시됩니다. 따라서 비표준 error.stack 속성은 JSStackFrame::GetMethodName()를 많이 사용합니다. 실제로 성능 테스트에서도 변경사항으로 인해 속도가 크게 빨라졌음을 알 수 있습니다.

StackTrace 마이크로 벤치마크 속도 향상

그러나 Chrome DevTools의 주제로 돌아가 error.stack를 사용하지 않더라도 메서드 이름이 계산된다는 사실이 옳지 않은 것 같습니다. 여기에서 도움이 되는 몇 가지 기록이 있습니다. 전통적으로 V8에는 위에 설명된 두 가지 API(C++ v8::StackFrame API 및 JavaScript 스택 트레이스 API)의 스택 트레이스를 수집하고 표시하는 두 가지 고유한 메커니즘이 있었습니다. 두 가지 방법으로 동일한 작업을 할 경우 (대부분) 오류가 발생하기 쉬웠고 종종 비일관성과 버그가 발생했기 때문에 2018년 말에 스택 트레이스 캡처를 위한 단일 병목 현상을 해결하기 위한 프로젝트를 시작했습니다.

이 프로젝트는 큰 성공을 거두었고 스택 트레이스 수집과 관련된 문제의 수를 크게 줄였습니다. 비표준 error.stack 속성을 통해 제공되는 대부분의 정보도 실제로 필요할 때만 지연 컴퓨팅되었지만 리팩터링의 일환으로 v8::StackFrame 객체에도 동일한 트릭을 적용했습니다. 스택 프레임에 관한 모든 정보는 메서드가 처음 호출될 때 계산됩니다.

이렇게 하면 일반적으로 성능이 향상되지만 안타깝게도 이러한 C++ API 객체가 Chromium 및 DevTools에서 사용되는 방식과 다소 상반되는 것으로 나타났습니다. 특히 v8::StackFrame 또는 error.stack를 통해 노출된 스택 프레임에 관한 모든 정보를 보유하는 새 v8::internal::StackFrameInfo 클래스를 도입했기 때문에 항상 두 API에서 제공한 정보의 상위 집합을 계산합니다. 즉, v8::StackFrame (특히 DevTools)의 경우 스택 프레임에 관한 정보가 요청되는 즉시 메서드 이름도 계산합니다. DevTools는 항상 소스 및 스크립트 정보를 즉시 요청하는 것으로 나타났습니다.

이를 바탕으로 스택 프레임 표현을 리팩터링하고 대폭 단순화하고 더욱 게으르게 만들 수 있었습니다. 이제 V8 및 Chromium 전반에서 요청하는 정보의 계산 비용만 지불하면 됩니다. 이를 통해 스택 프레임에 관한 정보의 일부(기본적으로 줄 및 열 오프셋 형식의 스크립트 이름 및 소스 위치)만 필요한 DevTools 및 기타 Chromium 사용 사례의 성능이 크게 향상되었으며, 더 많은 성능 개선의 문이 열렸습니다.

함수 이름

위에서 언급한 리팩터링을 완료한 후에는 기호화 오버헤드(v8_inspector::V8Debugger::symbolize에서 소비한 시간)가 전체 실행 시간의 약 15%로 줄었고, DevTools에서 소비하기 위해 스택 프레임을 (수집하고) 기호화할 때 V8이 시간을 소비하는 위치를 더 명확하게 확인할 수 있었습니다.

기호화 비용

가장 먼저 눈에 띄는 것은 행 및 열 번호를 계산하는 데 드는 누적 비용입니다. 여기서 비용이 많이 드는 부분은 실제로 V8에서 가져온 바이트 코드 오프셋을 기반으로 스크립트 내에서 문자 오프셋을 계산하는 것입니다. 위의 리팩터링으로 인해 한 번은 줄 번호를 계산할 때, 다른 한 번은 열 번호를 계산할 때 이 작업을 두 번 실행한 것으로 나타났습니다. v8::internal::StackFrameInfo 인스턴스에서 소스 위치를 캐시하여 이 문제를 빠르게 해결하고 모든 프로필에서 v8::internal::StackFrameInfo::GetColumnNumber를 완전히 삭제했습니다.

더 흥미로운 발견은 살펴본 모든 프로필에서 v8::StackFrame::GetFunctionName이 놀라울 정도로 높았다는 점입니다. 자세히 살펴본 결과 DevTools의 스택 프레임에 함수 이름을 표시하는 데 불필요한 비용이 소요된다는 사실을 발견했습니다.

  1. 먼저 비표준 "displayName" 속성을 찾아 문자열 값이 있는 데이터 속성을 얻었다면 이를 사용합니다.
  2. 그렇지 않은 경우에는 표준 "name" 속성을 찾고 값이 문자열인 데이터 속성이 생성되는지 다시 확인합니다.
  3. 그리고 결국 V8 파서에 의해 추론되고 함수 리터럴에 저장된 내부 디버그 이름으로 대체됩니다.

"displayName" 속성은 JavaScript에서 Function 인스턴스의 "name" 속성이 읽기 전용이며 구성할 수 없는 문제를 해결하기 위한 해결 방법으로 추가되었지만, 브라우저 개발자 도구에서 99.9%의 경우 이 작업을 실행하는 함수 이름 추론을 추가했기 때문에 표준화되지 않았고 널리 사용되지 않았습니다. 또한 ES2015에서는 Function 인스턴스의 "name" 속성을 구성 가능하도록 하여 특별한 "displayName" 속성의 필요성을 완전히 없앴습니다. "displayName"의 제외 조회는 비용이 많이 들고 실제로는 필요하지 않으므로(ES2015가 5년 전에 출시됨) V8(및 DevTools)에서 비표준 fn.displayName 속성에 대한 지원을 삭제하기로 결정했습니다.

"displayName"의 음수 조회가 제거되어 v8::StackFrame::GetFunctionName의 비용 절반이 삭제되었습니다. 나머지 절반은 일반 "name" 속성 조회로 이동합니다. 다행히 (변경되지 않은) Function 인스턴스에서 비용이 많이 드는 "name" 속성 조회를 방지하기 위한 로직이 이미 있었습니다. 이 로직은 Function.prototype.bind() 자체를 더 빠르게 만들기 위해 얼마 전에 V8에서 도입되었습니다. 처음부터 비용이 많이 드는 일반 조회를 건너뛸 수 있도록 필요한 검사를 포팅했으며, 그 결과 v8::StackFrame::GetFunctionName가 더 이상 고려한 프로필에 표시되지 않습니다.

결론

위의 개선 사항 덕분에 스택 트레이스 측면에서 DevTools의 오버헤드를 크게 줄였습니다.

아직 개선할 수 있는 사항이 많이 있습니다. 예를 들어 MutationObserver를 사용할 때의 오버헤드가 여전히 눈에 띄게 나타납니다(chromium:1077657 참고). 하지만 당분간은 주요 문제점을 해결했으며 향후 디버깅 성능을 더욱 간소화하기 위해 다시 돌아올 수 있습니다.

미리보기 채널 다운로드

Chrome Canary, Dev 또는 베타를 기본 개발 브라우저로 사용하는 것이 좋습니다. 이러한 미리보기 채널을 사용하면 최신 DevTools 기능에 액세스하고, 최신 웹 플랫폼 API를 테스트하고, 사용자가 발견하기 전에 사이트에서 문제를 찾을 수 있습니다.

Chrome DevTools 팀에 문의하기

다음 옵션을 사용하여 DevTools와 관련된 새로운 기능, 업데이트 또는 기타 사항을 논의하세요.