Jak 10-krotnie przyspieszyliśmy zrzuty stosu w Narzędziach deweloperskich w Chrome

Benedikt Meurer
Benedikt Meurer

Deweloperzy oczekują, że debugowanie kodu nie będzie miało wpływu na wydajność lub będzie on znikomy. Nie jest to jednak uniwersalne oczekiwanie. Deweloper języka C++ nigdy by się nie spodziewał, że kompilacja aplikacji do debugowania osiągnie wydajność produkcyjną, a w pierwszych latach użytkowania Chrome samo otwarcie Narzędzi deweloperskich znacząco wpłynęło na wydajność strony.

Fakt, że nie odczuwasz już spadku wydajności, jest wynikiem wieloletnich inwestycji w możliwości debugowania w narzędziach DevTools i w obiekcie V8. Mimo to nigdy nie będziemy w stanie zmniejszyć do zera nakładów związanych z wydajnością Narzędzi deweloperskich. Ustawianie punktów przerwania, przechodzenie przez kod, zbieranie śladów stosu, rejestrowanie śladów wydajności itd. wpływa w różnym stopniu na szybkość wykonywania. W końcu obserwowanie czegoś zmienia to coś.

Oczywiście narzut związany z Narzędziami deweloperskimi – jak w przypadku każdego debugera – powinien być rozsądny. Ostatnio odnotowaliśmy znaczny wzrost liczby zgłoszeń, że w niektórych przypadkach narzędzia dla deweloperów spowalniały działanie aplikacji do tego stopnia, że nie nadawała się ona już do użytku. Poniżej możesz zobaczyć porównanie z raportu chromium:1069425, które pokazuje obciążenie wydajnościowe związane z otwartymi Narzędziami deweloperskimi.

Jak widać na filmie, spowolnienie wynosi około 5–10 razy, co jest zdecydowanie nie do przyjęcia. Pierwszym krokiem było znalezienie informacji o tym, dokąd stale ucieka i co spowodowało to ogromne spowolnienie, gdy otwarto Narzędzia deweloperskie. Użycie narzędzia Linux perf w procesie renderowania Chrome spowodowało następujący rozkład łącznego czasu wykonywania mechanizmu renderowania:

Czas wykonywania w renderze Chrome

Spodziewaliśmy się, że zobaczymy coś związanego z zbieraniem śladów stosu, ale nie spodziewaliśmy się, że około 90% całkowitego czasu wykonania zajmuje symbolizacja ramek stosu. Słownictwo odnosi się tutaj do działania polegającego na rozwiązywaniu nazw funkcji i konkretnych pozycji źródłowych (numery wierszy i kolumn w skryptach) z surowych ramek stosu.

Wywnioskowanie nazwy metody

Jeszcze bardziej zaskoczyło mnie to, że prawie cały czas używa się funkcji JSStackFrame::GetMethodName() w wersji 8. Mimo że z wcześniejszych badań dowiedzieliśmy się, że JSStackFrame::GetMethodName() nie jest obcym w krainie problemów z wydajnością. Ta funkcja próbuje obliczyć nazwę metody dla ramek uważanych za wywołania metody (ramki, które reprezentują wywołania funkcji postaci obj.func(), a nie func()). Szybkie przyjrzenie się kodowi pokazuje, że działa, wykonując pełne przemierzenie obiektu i jego prototypowego łańcucha oraz wyszukując

  1. właściwości danych, których value jest func, lub
  2. właściwości akcesora, gdzie get lub set oznacza zamknięcie func.

Choć sama w sobie nie wydaje się to zbyt tania, to nie brzmi też, jakbyśmy wyjaśniałyby to straszne spowolnienie. Zaczęliśmy więc analizować przykład opisany w chromium:1069425 i odkryliśmy, że zrzuty stosu są zbierane w przypadku zadań asynchronicznych oraz komunikatów dziennika pochodzących z classes.js – pliku JavaScript o wielkości 10 MiB. Po dokładniejszym przyjrzeniu się okazało, że jest to środowisko uruchomieniowe Java z dodatkiem kodu aplikacji skompilowanego na JavaScript. Ścieżki stosu zawierały kilka ramek z metodami wywoływanymi na obiekcie A, więc uznaliśmy, że warto się przyjrzeć, z jakim obiektem mamy do czynienia.

ścieżek obiektów,

Wygląda na to, że kompilator Java na JavaScript wygenerował jeden obiekt z aż 82 203 funkcjami. To było naprawdę interesujące. Następnie wróciliśmy do JSStackFrame::GetMethodName() w V8, aby sprawdzić, czy nie ma tam owoców nisko wiszących, które moglibyśmy zebrać.

  1. Funkcja ta najpierw wyszukuje "name" funkcji jako właściwość obiektu, a jeśli znajdzie, sprawdza, czy wartość właściwości pasuje do funkcji.
  2. Jeśli funkcja nie ma nazwy lub obiekt nie ma pasującej właściwości, to wraca do wyszukiwania wstecznego, czyli przemierza wszystkie właściwości obiektu i jego prototypów.

W naszym przykładzie wszystkie funkcje są anonimowe i mają puste właściwości "name".

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

Pierwsze spostrzeżenie: odwrotne wyszukiwanie zostało podzielone na 2 kroki (wykonywane w przypadku samego obiektu i każdego obiektu w łańcuchu prototypów):

  1. wyodrębnij nazwy wszystkich właściwości, które można wyliczyć, oraz
  2. Przeprowadź wyszukiwanie ogólnych właściwości dla każdej nazwy, aby sprawdzić, czy uzyskana wartość właściwości pasuje do poszukiwanej funkcji zamykającej.

Wyglądało to na dość łatwe zadanie, ponieważ wyodrębnienie nazw wymaga przejrzenia wszystkich właściwości. Zamiast 2 przechodów – O(N) na wyodrębnienie nazwy i O(N log(N)) na testy – możemy wykonać wszystko w jednym przejściu i bezpośrednio sprawdzić wartości właściwości. Dzięki temu cała funkcja działała 2–10 razy szybciej.

Drugie spostrzeżenie było jeszcze ciekawsze. Chociaż funkcje były technicznie anonimowe, silnik V8 zarejestrował dla nich tak zwaną nazwę wywnioskowaną. W przypadku literali funkcji, które występują po prawej stronie przypisania w formie obj.foo = function() {...}, parsujący V8 zapamiętuje "obj.foo" jako wywnioskowaną nazwę dla literalu funkcji. W naszym przypadku oznacza to, że chociaż nie mieliśmy nazwy własnej, którą moglibyśmy po prostu wyszukać, mamy coś wystarczająco zbliżonego: w powyższym przykładzie A.SDV = function() {...} otrzymaliśmy "A.SDV" jako przypuszczalną nazwę, więc mogliśmy poznać nazwę właściwości, szukając ostatniej kropki, a następnie szukać właściwości "SDV" przy obiekcie. W prawie wszystkich przypadkach zastępowało kosztowne pełne przejście przez tablicę za pomocą pojedynczego wyszukiwania właściwości. Te 2 ulepszenia zostały wprowadzone w ramach tego CL i znacznie zmniejszyły spowolnienie w przypadku przykładu zgłoszonego w chromium:1069425.

Error.stack

Moglibyśmy zakończyć na tym. Coś dziwnie jednak poszło, bo Narzędzia deweloperskie nigdy nie używają nazwy metody dla ramek stosu. W rzeczywistości klasa v8::StackFrame w interfejsie C++ API nie udostępnia nawet sposobu na uzyskanie nazwy metody. Wydawało się więc, że nie powinniśmy w ogóle dzwonić do JSStackFrame::GetMethodName(). Zamiast tego jedynym miejscem, w którym używamy (i wyświetlamy) nazwę metody, jest interfejs JavaScript stack trace API. Aby lepiej zrozumieć to zastosowanie, rozważ ten prosty przykład:error-methodname.js

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

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

Tutaj mamy funkcję foo zainstalowaną pod nazwą "bar" w: object. Uruchomienie tego fragmentu kodu w Chromium spowoduje wyświetlenie tego komunikatu:

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

Tutaj widzimy wyszukiwanie nazwy metody: pokazano najwyższy element stosu wywołania funkcji foo w instancji Object za pomocą metody o nazwie bar. Niestandardowa właściwość error.stack intensywnie korzysta z właściwości JSStackFrame::GetMethodName(). Nasze testy szybkości wskazują, że wprowadzone przez nas zmiany znacznie przyspieszyły działanie.

Przyspieszanie na mikrotestach porównawczych StackTrace

W przypadku Narzędzi deweloperskich w Chrome to, że nazwa metody jest obliczana, mimo że nie jest używana metoda error.stack. Tutaj przydaje się historia: tradycyjnie V8 używało 2 różnych mechanizmów do zbierania i reprezentowania ścieżki stosu dla 2 opisanych powyżej interfejsów API (interfejsu API C++ v8::StackFrame i interfejsu API ścieżki stosu JavaScript). Mając 2 różne sposoby na osiągnięcie (mniej więcej) tego samego efektu, narażaliśmy się na błędy i często mieliśmy do czynienia z niespójnościami i błędami. Dlatego pod koniec 2018 r. rozpoczęliśmy projekt, którego celem było znalezienie jednego punktu przeznaczenia dla zrzutu stosu.

Ten projekt okazał się wielkim sukcesem i drastycznie zmniejszył liczbę problemów związanych z zbieraniem ścieżki zgłaszania błędów. Większość informacji udostępnianych za pomocą niestandardowej właściwości error.stack była również obliczana leniwie i tylko wtedy, gdy była naprawdę potrzebna. W ramach refaktoryzacji zastosowaliśmy ten sam trik w przypadku obiektów v8::StackFrame. Wszystkie informacje dotyczące ramki stosu są obliczane przy pierwszym wywołaniu dowolnej metody.

Zwiększa to ogólnie wydajność, ale okazało się, że jest to w pewnym stopniu sprzeczne z sposobem używania tych obiektów interfejsu API w Chromium i Narzędziach dla programistów. Ze względu na to, że wprowadziliśmy nową klasę v8::internal::StackFrameInfo, która zawiera wszystkie informacje o ramce stosu ujawnionej za pomocą v8::StackFrame lub error.stack, zawsze obliczaliśmy superzestaw informacji dostarczanych przez oba interfejsy API. Oznaczało to, że na potrzeby zastosowania v8::StackFrame (a zwłaszcza w Narzędziach deweloperskich) mogliśmy obliczyć nazwę metody, gdy tylko zażądano informacji o ramce stosu. Okazuje się, że Narzędzia deweloperskie zawsze natychmiast wysyłają żądanie informacji o źródle i skrypcie.

Dzięki temu mogliśmy przerobić i drastycznie uprościć reprezentację ramki stosu oraz jeszcze bardziej ją zoptymalizować, tak aby użytkownicy V8 i Chromium płacili tylko za obliczenie informacji, których potrzebują. To znacznie zwiększyło wydajność DevTools i innych zastosowań Chromium, które potrzebują tylko ułamka informacji o ramkach stosu (zawierających głównie nazwę skryptu i lokalizację źródła w postaci przesunięcia wiersza i kolumny) i otworzyło drogę do dalszego zwiększania wydajności.

nazwy funkcji,

Po usunięciu wspomnianych powyżej optymalizacji nakłady związane z symbolizacją (czas spędzony w v8_inspector::V8Debugger::symbolize) zostały zredukowane do około 15% łącznego czasu wykonywania, a my mogliśmy lepiej zobaczyć, na co V8 poświęca czas podczas zbierania i symbolizowania ramek stosu do wykorzystania w DevTools.

Koszt symbolizacji

Pierwszą rzeczą, która rzucała się w oczy, był łączny koszt obliczenia wiersza i numeru kolumny. Najdroższym działaniem jest obliczanie przesunięcia znaków w skrypcie (na podstawie przesunięcia bajtowego, które otrzymujemy z V8). Okazało się, że z powodu refaktoryzacji, którą przeprowadziliśmy, wykonaliśmy to dwukrotnie: raz podczas obliczania numeru wiersza i jeszcze raz podczas obliczania numeru kolumny. Buforowanie pozycji źródła w przypadku instancji v8::internal::StackFrameInfo pomogło szybko rozwiązać ten problem i całkowicie wyeliminować v8::internal::StackFrameInfo::GetColumnNumber z wszystkich profili.

Bardziej interesujące było dla nas to, że we wszystkich zbadanych profilach v8::StackFrame::GetFunctionName był zaskakująco wysoki. Po dokładniejszym przyjrzeniu się temu problemowi stwierdziliśmy, że obliczanie nazwy, którą wyświetlamy dla funkcji w ramce stosu w DevTools, jest niepotrzebnie kosztowne.

  1. najpierw szukamy niestandardowej właściwości "displayName" i jeśli zwróci ona właściwość danych o wartości ciągu znaków, użyjemy tej wartości,
  2. w przeciwnym razie następuje wyszukanie standardowej właściwości "name" i ponowne sprawdzenie, czy zwracana jest właściwość danych, której wartość jest ciągiem znaków,
  3. i ostatecznie wraca do wewnętrznej nazwy debugowania, która jest wywnioskowana przez parsujący V8 i zapisana w funkcji dosłownej.

Właściwość "displayName" została dodana jako obejście dla właściwości "name" w przypadku wystąpień Function, które są tylko do odczytu i nie można ich skonfigurować w JavaScript, ale nigdy nie została sformalizowana i nie była szeroko stosowana, ponieważ narzędzia dla programistów w przeglądarce dodały inferencję nazwy funkcji, która spełnia swoje zadanie w 99,9% przypadków. Ponadto w wersji ES2015 właściwości "name" w przypadku instancji Function można skonfigurować, co całkowicie eliminuje potrzebę korzystania z specjalnej właściwości "displayName". Wyszukiwanie negatywne dla "displayName" jest dość kosztowne i nie jest tak naprawdę konieczne (ES2015 zostało wydane ponad 5 lat temu), dlatego postanowiliśmy usunąć obsługę niestandardowej właściwości fn.displayName z V8 (i DevTools).

Po usunięciu wyszukiwania ujemnego "displayName" została usunięta połowa kosztu v8::StackFrame::GetFunctionName. Druga połowa trafia do ogólnego wyszukiwania właściwości "name". Na szczęście mieliśmy już pewną logikę, która pozwala uniknąć kosztownych wyszukiwań właściwości "name" w (niezmienionych) instancjach Function. Wprowadziliśmy ją jakiś czas temu w wersji 8, aby przyspieszyć działanie Function.prototype.bind(). Przenośliśmy niezbędne kontrole, dzięki którym możemy pominąć kosztowne wyszukiwanie ogólne, co oznacza, że v8::StackFrame::GetFunctionName nie pojawia się już w żadnych z rozważanych przez nas profili.

Podsumowanie

Dzięki tym ulepszeniom znacznie zmniejszyliśmy obciążenie Narzędzi deweloperskich w zakresie ścieżek stosu.

Wiemy, że wciąż istnieją możliwości ulepszenia – na przykład obciążenie podczas używania MutationObserver jest nadal zauważalne, jak zgłaszano w chromium:1077657. Jednak na razie udało nam się rozwiązać główne problemy, a w przyszłości możemy wrócić do dalszego ulepszania wydajności debugowania.

Pobieranie kanałów podglądu

Rozważ użycie przeglądarki Chrome Canary, Dev lub Beta jako domyślnej przeglądarki deweloperskiej. Te kanały wersji testowej dają dostęp do najnowszych funkcji Narzędzi deweloperskich, umożliwiają testowanie najnowocześniejszych interfejsów API platform internetowych i pomagają w wykrywaniu problemów w witrynie przed użytkownikami.

Kontakt z zespołem Narzędzi deweloperskich w Chrome

Aby omówić nowe funkcje, aktualizacje lub inne kwestie związane z Narzędziami deweloperskimi, skorzystaj z tych opcji.