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

Benedikt Meurer
Benedikt Meurer

Programiści stron internetowych często spodziewają się niewielkiego lub zerowego wpływu debugowania kodu na wydajność. Oczekiwania te nie są jednak powszechne. Programista C++ nigdy nie spodziewał się, że kompilacja do debugowania aplikacji osiągnie wydajność produkcyjną, a we wczesnych latach istnienia Chrome samo otwarcie Narzędzi deweloperskich w znacznym stopniu wpływało na wydajność strony.

Spadek wydajności nie jest już efektem wieloletnich inwestycji w możliwości debugowania narzędzi DevTools i V8. Jednak nigdy nie będziemy w stanie zmniejszyć do zera narzutu wydajności działania Narzędzi deweloperskich. Ustawienie punktów przerwania, przechodzenie przez kod, zbieranie zrzutów stosu, rejestrowanie danych śledzenia wydajności itp. – wszystko to w różnym stopniu wpływa na szybkość wykonywania działań. Obserwacja czegoś zmienia.

Jednak koszty działania Narzędzi deweloperskich – jak każdego debugera – powinny być uzasadnione. Ostatnio odnotowaliśmy znaczny wzrost liczby zgłoszeń, że w niektórych przypadkach zastosowanie Narzędzi deweloperskich może spowolnić działanie aplikacji do takiego stopnia, że nie będzie już można jej używać. Poniżej znajdziesz porównanie z raportu chromium:1069425, które obrazuje ogólne porównanie wydajności związane z otwarciem Narzędzi deweloperskich.

Na filmie widać, że spowolnienie mieści się w przedziale 5–10x, co jest zdecydowanie niedopuszczalne. Pierwszym krokiem było zrozumienie, co się dzieje w tym czasie i co powoduje tak ogromne spowolnienie po uruchomieniu Narzędzi deweloperskich. Korzystanie z funkcji Linux z wydajnością w procesie renderowania Chrome ujawniło następujący rozkład łącznego czasu wykonywania mechanizmu renderowania:

Czas wykonywania mechanizmu renderowania Chrome

Choć spodziewaliśmy się, że pojawi się coś związanego ze zbieraniem zrzutów stosu, nie spodziewaliśmy się, że około 90% łącznego czasu wykonywania będzie dotyczyło symbolizacji ramek stosu. Symbolizacja odnosi się do rozpoznawania nazw funkcji i konkretnych pozycji źródeł (numerów wierszy i kolumn w skryptach) na podstawie nieprzetworzonych ramek stosu.

Wnioskowanie nazwy metody

Jeszcze bardziej zaskakujące było to, że w wersji 8 prawie przez cały czas wykonywana jest funkcja JSStackFrame::GetMethodName(). Z poprzednich badań już wiemy, że w obszarze problemów z wydajnością JSStackFrame::GetMethodName() nie jest niczym dziwnym. Ta funkcja próbuje obliczyć nazwę metody dla ramek, które są uważane za wywołania metod (ramki reprezentujące wywołania funkcji obj.func() zamiast func()). Szybkie spojrzenie w kod wykazuje, że działa on, wykonując pełne przemierzanie obiektu i jego prototypu, szukając

  1. właściwości danych, których value to zamknięcie func lub
  2. właściwości akcesorów, w których get lub set to zamknięcie func.

Choć sama opcja nie brzmi zbyt tania, to również nie brzmi, jakby uzasadniało to straszne spowolnienie. Zaczęliśmy więc zapoznawać się z przykładem podanym w materiale chromium:1069425 i zauważyliśmy, że zrzuty stosu zostały zebrane w przypadku zadań asynchronicznych oraz komunikatów logu pochodzących z classes.js – pliku JavaScript o rozmiarze 10 MiB. Bliższe przyjrzenie pokazało, że jest to w zasadzie środowisko wykonawcze Java oraz kod aplikacji skompilowany na język JavaScript. Zrzuty stosu zawierały kilka ramek z metodami wywoływanymi w obiekcie A, dlatego uznaliśmy, że warto zastanowić się, jakiego rodzaju obiektem mamy do czynienia.

ślady stosu obiektu

Wygląda na to,że kompilator z języków Java na JavaScript wygenerował pojedynczy obiekt z ogromną liczbą 82 203 funkcji – to staje się coraz bardziej interesujące. Następnie wróciliśmy do modelu JSStackFrame::GetMethodName() w V8, aby dowiedzieć się, czy można tu zebrać nisko wiszący owoc.

  1. Działa to, wyszukując najpierw właściwość "name" funkcji jako właściwość w obiekcie, a jeśli ją znajdzie, sprawdza, czy wartość właściwości odpowiada tej funkcji.
  2. Jeśli funkcja nie ma nazwy lub obiekt nie ma pasującej właściwości, wraca do odwrotnego wyszukiwania, przemierzając 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 wyniki polegały na tym, że wyszukiwanie odwrotne zostało podzielone na 2 etapy (wykonane dla samego obiektu i każdego obiektu w łańcuchu prototypu):

  1. Wyodrębnij nazwy wszystkich właściwości wyliczeniowych oraz
  2. Wykonaj ogólne wyszukiwanie właściwości dla każdej nazwy i sprawdź, czy ostateczna wartość właściwości odpowiada kryteriom zamknięcia.

Wyglądało to na stosunkowo nisko wiszący owoc, ponieważ wyodrębnienie nazw wymaga wcześniejszego przejścia przez wszystkie właściwości. Zamiast wykonywać 2 przebiegi – O(N) dla wyodrębnienia nazwy i O(N log(N)) w przypadku testów – możemy wykonać wszystko w ramach jednego przebiegu i bezpośrednio sprawdzić wartości właściwości. Dzięki temu cała funkcja około 2–10 razy przyspieszyła.

Drugi wynik był jeszcze bardziej interesujący. Te funkcje były technicznie anonimowe, jednak mechanizm V8 zarejestrował dla nich nazwą wywnioskowaną. W przypadku literałów funkcji, które pojawiają się po prawej stronie przypisań w postaci obj.foo = function() {...}, parser V8 zapamiętuje "obj.foo" jako wywnioskowaną nazwę literału funkcji. W naszym przypadku oznacza to, że chociaż nie mieliśmy nazwy własnej, którą po prostu szukaliśmy, mamy coś dostatecznie zbliżonego. W przykładzie powyżej A.SDV = function() {...} mieliśmy nazwę "A.SDV" jako wnioskowaną nazwę. Możemy więc wywnioskować nazwę właściwości z domniemanej nazwy, wyszukując ostatnią kropkę, a następnie szukać właściwości "SDV" dotyczącej obiektu. Takie rozwiązanie sprawdzało się w prawie wszystkich przypadkach, ponieważ zastąpiło kosztowne pełne przemierzanie jednym wyszukiwaniem właściwości. Te 2 ulepszenia zostały wprowadzone w ramach tej listy zmian i znacznie spowolniły spowolnienie działania w przypadku przykładu wskazanego w artykule chromium:1069425.

Error.stack

Trzeba było określić dzień. Stało się jednak coś podejrzanego, ponieważ Narzędzia deweloperskie nigdy nie używają nazwy metody dla ramek stosu. Wręcz przeciwnie, klasa v8::StackFrame w interfejsie C++ API nie udostępnia nawet sposobu na dostęp do nazwy metody. Wydawało się to niewłaściwe, bo ostatecznie zadzwonimy pod numer JSStackFrame::GetMethodName(). Zamiast tego jedynego miejsca, w którym stosujemy (i udostępniamy) nazwę metody, jest interfejs API zrzutu stosu JavaScript. Przeanalizuj ten prosty przykład error-methodname.js, aby zrozumieć, na czym polega ich użycie:

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

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

Oto funkcja foo zainstalowana object pod nazwą "bar". Uruchomienie tego fragmentu kodu w Chromium daje takie dane wyjściowe:

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

Widzimy tu wyszukiwanie nazwy metody podczas odtwarzania: Najwyższa ramka stosu jest pokazana w celu wywołania funkcji foo w wystąpieniu Object za pomocą metody o nazwie bar. Dlatego niestandardowa właściwość error.stack intensywnie korzysta z JSStackFrame::GetMethodName(), a nasze testy wydajności również wskazują, że wprowadzone przez nas zmiany przyspieszyły jej działanie.

Przyspieszenie działania mikrotestów StackTrace

Wracając jednak do Narzędzi deweloperskich w Chrome, to, że nazwa metody jest obliczana, mimo że error.stack nie jest używana, nie wygląda na prawidłową. Mamy tu do zaoferowania pewną historię: tradycyjnie V8 miał 2 różne mechanizmy zbierania i reprezentowania zrzutu stosu dla dwóch opisanych powyżej interfejsów API (interfejsu API C++ v8::StackFrame i interfejsu API zrzutu stosu JavaScript). Wykorzystanie 2 różnych (mniej więcej) sposobów na taki sam efekt był podatne na błędy i często prowadziło do niespójności i błędów. Pod koniec 2018 roku rozpoczęliśmy projekt mający na celu skupienie się na pojedynczym wąskim gardle w zakresie przechwytywania zrzutów stosu.

Projekt okazał się wielkim sukcesem i znacznie ograniczył liczbę problemów związanych ze zbieraniem zrzutów stosu. Większość informacji przekazanych przez niestandardową właściwość error.stack również została obliczona leniwie i tylko wtedy, gdy była to naprawdę potrzebna. W ramach refaktoryzacji zastosowaliśmy tę samą sztuczkę do obiektów v8::StackFrame. Wszystkie informacje o ramce stosu są obliczane przy pierwszym wywołaniu jej metody.

Ogólnie rzecz biorąc, zwiększa to wydajność, ale okazało się, że jest nieco sprzeczne ze sposobem, w jaki te obiekty interfejsu C++ API są używane w Chromium i Narzędziach deweloperskich. Od kiedy wprowadziliśmy nową klasę v8::internal::StackFrameInfo, która zawierała wszystkie informacje o ramce stosu ujawnionej za pomocą v8::StackFrame lub error.stack, zawsze obliczaliśmy superzbiór danych dostarczanych przez oba interfejsy API, co oznaczało, że w przypadku użycia v8::StackFrame (a zwłaszcza narzędzi dla deweloperów) obliczaliśmy też nazwę metody, gdy tylko jakiekolwiek informacje o ramce stosu zostaną wyświetlone. Okazuje się, że Narzędzia deweloperskie zawsze natychmiast żądają informacji o źródle i skrypcie.

W ten sposób udało nam się zrefaktoryzować i znacznie uprościć reprezentowanie ramek stosu. W efekcie wersja 8 i Chromium płacą teraz tylko za przetwarzanie informacji, o które poprosili. To spowodowało ogromny wzrost wydajności w Narzędziach deweloperskich i innych przypadkach użycia Chromium, które wymagają jedynie ułamka informacji o ramkach stosu (czyli tylko nazwy skryptu i lokalizacji źródłowej w postaci przesunięcia linii i kolumny). Umożliwiło to także zwiększenie wydajności.

Nazwy funkcji

Po wspomnianych wyżej refaktoryzacji czas potrzebny na symbolizację (czas spędzony w v8_inspector::V8Debugger::symbolize) został zmniejszony do około 15% ogólnego czasu wykonywania. Widać też, gdzie V8 spędza czas podczas (zbierania i symbolizacji klatek stosu do wykorzystania w Narzędziach deweloperskich).

Koszt symbolu

Pierwszą rzeczą, która się zwróciła, był łączny koszt za numer linii i kolumny. Długotrwała część to obliczenie przesunięcia znaków w skrypcie (w oparciu o przesunięcie kodu bajtowego dostępne w wersji V8). Okazało się, że dzięki powyższej refaktoryzacji wykonaliśmy tę czynność dwukrotnie: raz podczas obliczania numeru wiersza i ponownie przy obliczaniu numeru kolumny. Zapisywanie pozycji źródła w pamięci podręcznej w v8::internal::StackFrameInfo instancjach pomogło szybko rozwiązać ten problem i całkowicie wyeliminować v8::internal::StackFrameInfo::GetColumnNumber z wszystkich profili.

Bardziej interesujące było odkrycie, że v8::StackFrame::GetFunctionName ma zaskakująco wysoką pozycję we wszystkich sprawdzanych profilach. Po głębszym zbadaniu sprawy zdaliśmy sobie sprawę, że obliczenie nazwy wyświetlanej funkcji w ramce stosu w Narzędziach deweloperskich jest niepotrzebnie kosztowne.

  1. w pierwszej kolejności szukać niestandardowej właściwości "displayName". Jeśli za jej pomocą otrzymasz właściwość danych z wartością ciągu znaków, użyjemy jej,
  2. w przeciwnym razie wróć do standardowej właściwości "name" i ponownie sprawdź, czy generuje to właściwość danych, której wartość jest ciągiem znaków,
  3. a potem wraca do wewnętrznej nazwy debugowania, która zostanie wywnioskowana przez parser V8 i zapisana w literalu funkcji.

Dodano właściwość "displayName" jako obejście problemu z właściwością "name" w instancjach Function, które są dostępne tylko do odczytu i nie można ich skonfigurować w JavaScript.Jednak nigdy nie została ona ustandaryzowana i nie była powszechnie używana, ponieważ narzędzia dla programistów przeglądarki dodały funkcję wnioskowania na podstawie nazw funkcji, która spełnia te zadania w 99,9% przypadków. Poza tym w ES2015 umożliwiliśmy skonfigurowanie właściwości "name" w Function instancjach, dzięki czemu nie jest już potrzebna specjalna właściwość "displayName". Wyszukiwanie wykluczające dla adresu "displayName" jest dość kosztowne i nie jest aż tak niezbędne (ES2015 został wydany ponad 5 lat temu), dlatego zdecydowaliśmy się usunąć obsługę niestandardowej właściwości fn.displayName z wersji 8 (i Narzędzi deweloperskich).

Ze względu na wyeliminowanie wyniku wyszukiwania "displayName" połowa kosztów v8::StackFrame::GetFunctionName została usunięta. Druga połowa odbywa się za pomocą ogólnego wyszukiwania właściwości "name". Na szczęście wiemy już, jak uniknąć kosztownych wyszukiwań właściwości "name" w niezmienionych instancjach Function. Zostały one wprowadzone jakiś czas temu w wersji 8, aby przyspieszyć działanie usługi Function.prototype.bind(). Dodaliśmy niezbędne mechanizmy kontroli, dzięki którym uniknęliśmy kosztownych wyszukiwań, co sprawiło, że adres v8::StackFrame::GetFunctionName nie pojawia się już w żadnych profilach, które braliśmy pod uwagę.

Podsumowanie

Dzięki powyższym ulepszeniom znacznie ograniczyliśmy nakład pracy związany z Narzędziami deweloperskimi, jeśli chodzi o zrzuty stosu.

Wiemy, że wciąż możemy wprowadzić różne ulepszenia – na przykład dodatkowe koszty związane z używaniem MutationObserver są nadal zauważalne, jak podano na chromium:1077657. Na razie rozwiązaliśmy już najważniejsze problemy i możemy wrócić do usprawnienia procesu debugowania w przyszłości.

Pobieranie kanałów podglądu

Jako domyślnej przeglądarki programistycznej możesz użyć Chrome Canary, Dev lub Beta. Te kanały podglądu dają dostęp do najnowszych funkcji Narzędzi deweloperskich, testują nowoczesne interfejsy API platform internetowych oraz wykrywają problemy w witrynie, zanim zrobią to użytkownicy.

Kontakt z zespołem Narzędzi deweloperskich w Chrome

Użyj tych opcji, aby omówić nowe funkcje i zmiany w poście lub wszelkich innych sprawach związanych z Narzędziami dla programistów.

  • Sugestię lub opinię możesz przesłać na stronie crbug.com.
  • Aby zgłosić problem z Narzędziami deweloperskimi, kliknij Więcej opcji   Więcej   > Pomoc > Zgłoś problemy z Narzędziami deweloperskimi.
  • zatweetować na @ChromeDevTools.
  • Komentarze do filmów o narzędziach dla deweloperów w YouTube lub filmach w YouTube ze wskazówkami dotyczącymi Narzędzi deweloperskich.