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

베네딕트 뫼러
베네딕트 뫼러

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

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

그러나 당연히 다른 디버거와 마찬가지로 DevTools의 오버헤드도 합당해야 합니다. 최근에는 경우에 따라 DevTools로 인해 애플리케이션 속도가 느려져 더 이상 사용할 수 없게 되는 보고서 수가 크게 증가했습니다. 아래에서 chromium:1069425 보고서를 나란히 비교한 내용을 확인할 수 있습니다. 이 보고서는 말 그대로 DevTools를 열어 두었을 때의 성능 오버헤드를 보여줍니다.

동영상에서 볼 수 있듯이 감속이 5~10배 정도이며 이는 명백히 허용되지 않습니다. 첫 번째 단계는 DevTools가 열려 있을 때 늘 어디로 가고 있는지, 그리고 무엇이 엄청난 감속을 야기하는지 파악하는 것이었습니다. Chrome 렌더기 프로세스에서 Linux perf를 사용하면 전체 렌더기 실행 시간의 다음과 같은 분포를 확인할 수 있습니다.

Chrome 렌더기 실행 시간

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

메서드 이름 추론

훨씬 더 놀라운 점은 이전 조사를 통해 JSStackFrame::GetMethodName()가 성능 문제에서 낯선 것이 아님을 알았지만 거의 항상 JSStackFrame::GetMethodName() 함수가 V8의 JSStackFrame::GetMethodName() 함수로 작동한다는 점입니다. 이 함수는 메서드 호출로 간주되는 프레임 (func()보다는 obj.func() 형식의 함수 호출을 나타내는 프레임)의 메서드 이름을 계산하려고 시도합니다. 코드를 간단히 살펴보면 객체와 프로토타입 체인의 전체 순회를 수행하고

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

이 방법 자체는 그다지 저렴해 보이지는 않지만 이 끔찍한 침체를 설명해 줄 것 같지 않습니다. 따라서 chromium:1069425에 보고된 예를 자세히 살펴보기 시작했으며, 스택 트레이스가 비동기 작업은 물론 classes.js(10MiB 자바스크립트 파일)에서 발생하는 로그 메시지에 대해서도 수집되었음을 확인했습니다. 자세히 살펴보면 이것이 기본적으로 자바 런타임과 자바스크립트로 컴파일된 애플리케이션 코드라는 것을 알 수 있었습니다. 스택 트레이스에는 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" 속성은 자바스크립트에서 읽기 전용이고 구성 불가능한 Function 인스턴스에서 "name" 속성의 해결 방법으로 추가되었지만 브라우저 개발자 도구가 사례의 99.9% 에서 작업을 실행하는 함수 이름 추론을 추가했기 때문에 표준화되지 않았으며 광범위하게 사용되지 않았습니다. 또한 ES2015에서는 Function 인스턴스의 "name" 속성을 구성할 수 있도록 하여 특수 "displayName" 속성이 필요하지 않게 했습니다. "displayName"의 네거티브 조회는 비용이 많이 들고 실제로 필요하지 않으므로 (ES2015가 5년 전 출시됨) V8 (및 DevTools)에서 비표준 fn.displayName 속성에 대한 지원을 중단하기로 결정했습니다.

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

결론

위와 같은 개선을 통해 스택 트레이스 측면에서 DevTools의 오버헤드를 크게 줄였습니다.

chromium:1077657에 보고된 것처럼 MutationObserver를 사용할 때의 오버헤드가 여전히 눈에 띄는 등 여러 가지 가능한 개선사항이 아직 있다는 것을 알고 있습니다. 하지만 Google은 당분간 주요 문제를 해결했으며, 향후 디버깅 성능을 더욱 간소화하기 위해 다시 방문할 수 있습니다.

미리보기 채널 다운로드

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

Chrome DevTools 팀에 문의하기

다음 옵션을 사용하여 게시물의 새로운 기능과 변경사항 또는 DevTools와 관련된 다른 모든 것에 대해 논의합니다.

  • crbug.com을 통해 제안이나 의견을 제출해 주세요.
  • DevTools에서 옵션 더보기   더보기   > 도움말 > DevTools 문제 보고를 사용하여 DevTools 문제를 신고합니다.
  • @ChromeDevTools로 트윗을 보냅니다.
  • DevTools의 새로운 기능 YouTube 동영상 또는 DevTools 팁 YouTube 동영상에 의견을 남겨주세요.