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. Deweloperzy C++ nie oczekują, że debugowana wersja ich aplikacji osiągnie wydajność wersji produkcyjnej. W pierwszych latach istnienia Chrome samo otwarcie Narzędzi deweloperskich znacząco wpływał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. Niemniej nigdy nie uda nam się zmniejszyć obciążenia wydajnościowego DevTools do zera. 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 obciążenie związane z Narzędziami deweloperskimi – podobnie jak w przypadku każdego debugera – powinno być rozsądne. 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 dewelopera.

Jak widać na filmie, spowolnienie wynosi około 5–10 razy, co jest zdecydowanie nie do przyjęcia. Pierwszym krokiem było zrozumienie, na co schodzi cały czas i co powoduje tak duże spowolnienie po otwarciu DevTools. Użycie Linux perf w procesie renderowania Chrome ujawniło następującą dystrybucję całkowitego czasu wykonywania 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% łącznego 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 (numerów wierszy i kolumn w skryptach) z surowych ramek stosu.

Wywnioskowanie nazwy metody

Jeszcze bardziej zaskakujący był fakt, że prawie cały czas jest to funkcja JSStackFrame::GetMethodName() w V8. Z wcześniejszych analiz wynikało, że JSStackFrame::GetMethodName() nie jest obca w świecie problemów z wydajnością. Ta funkcja próbuje obliczyć nazwę metody w przypadku ramek, które są uważane za wywołania metody (ramki reprezentujące wywołania funkcji w formie obj.func(), a nie func()). Szybkie przejrzenie kodu ujawniło, że działa on poprzez pełne przejście przez obiekt i jego łańcuch prototypów w celu znalezienia

  1. właściwości danych, których value jest func, lub
  2. właściwości akcesora, w których get lub set jest równe zamknięciu func.

Chociaż samo w sobie nie brzmi to szczególnie tanio, to nie wydaje się, żeby to tłumaczyło to straszne spowolnienie. Zaczęliśmy więc analizować przykład zgłoszony w błądzie chromium:1069425 i stwierdziliśmy, że ścieżki stosu zostały zebrane w przypadku zadań asynchronicznych oraz komunikatów logowania pochodzących z classes.js – pliku JavaScript o rozmiary 10 MiB. Po dokładniejszym przyjrzeniu się okazało, że jest to środowisko uruchomieniowe Java z dołączonym kodem aplikacji skompilowanym na JavaScript. Ścieżki stosu zawierały kilka ramek z metodami wywoływanymi na obiekcie A, więc uznaliśmy, że warto się dowiedzieć, z jakim obiektem mamy do czynienia.

ścieżek obiektu,

Kompilator Java na JavaScript wygenerował jeden obiekt z niesamowitą liczbą 82 203 funkcji. 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. Najpierw sprawdza, czy "name" funkcji występuje jako właściwość obiektu, a jeśli tak, to sprawdza, czy wartość tej właściwości jest zgodna z funkcją.
  2. Jeśli funkcja nie ma nazwy lub obiekt nie ma pasującej właściwości, funkcja przechodzi do wyszukiwania odwrotnego, przechodząc przez wszystkie właściwości obiektu i jego prototypy.

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

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

Pierwsze odkrycie polegało na tym, że wyszukiwanie wsteczne zostało podzielone na 2 kroki (wykonane 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 literałów 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 literału funkcji. W naszym przypadku oznacza to, że chociaż nie mieliśmy dokładnej nazwy, którą można by po prostu sprawdzić, mieliśmy coś wystarczająco zbliżonego: w przypadku przykładu A.SDV = function() {...} powyżej mieliśmy nazwę "A.SDV" jako nazwę wywnioskowaną. Mogliśmy wyprowadzić nazwę właściwości z nazwy wywnioskowanej, wyszukując ostatnią kropkę, a potem szukając właściwości "SDV" w 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ś było jednak nie tak, ponieważ narzędzia deweloperskie nigdy nie używają nazwy metody do 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 nielogiczne, że w ogóle skontaktujemy się z firmą 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" na 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 wydajności wskazują, że wprowadzone przez nas zmiany znacznie przyspieszyły działanie.

przyspieszenie mikrotestów StackTrace,

Wracając do tematu Narzędzi deweloperskich w Chrome: fakt, że nazwa metody jest obliczana, mimo że nie jest używana zmienna error.stack, nie wygląda dobrze. 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 (w przybliżeniu) tego samego efektu, narażaliśmy się na błędy i często prowadziliśmy do niespójności i błędów, dlatego pod koniec 2018 r. rozpoczęliśmy projekt, którego celem było znalezienie jednego punktu przeciążenia do rejestrowania zrzutów 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 o ramce stosu są obliczane po pierwszym wywołaniu w niej 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. Wprowadziliśmy nową klasę v8::internal::StackFrameInfo, która zawierała wszystkie informacje o ramce stosu, które były wyświetlane za pomocą interfejsu v8::StackFrame lub error.stack. Zawsze obliczaliśmy superzbiór informacji dostarczonych przez oba interfejsy API, co oznacza, że w przypadku użycia v8::StackFrame (i szczególnie w przypadku DevTools) obliczaliśmy też nazwę metody, gdy tylko zażądano jakichkolwiek 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 usprawnień nadmiar symbolizacji (czas spędzony w v8_inspector::V8Debugger::symbolize) został zmniejszony do około 15% całkowitego czasu wykonywania, a my mogliśmy lepiej zobaczyć, na co V8 poświęca czas podczas zbierania i symbolizacji 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 obliczenie 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ło 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 "displayName"właściwości 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 szukamy standardowej usługi "name" i ponownie sprawdzamy, czy zwraca ona usługę 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 samego 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 korzystania z MutationObserver jest nadal zauważalne, jak zgłaszano w chromium:1077657 – ale na razie udało nam się rozwiązać główne problemy. W przyszłości możemy wrócić do tego tematu, aby jeszcze bardziej usprawnić proces debugowania.

Pobieranie kanałów podglądu

Rozważ użycie jako domyślnej przeglądarki deweloperskiej wersji Canary, Dev lub Beta przeglądarki Chrome. Te kanały wersji wstępnej zapewniają dostęp do najnowszych funkcji DevTools, umożliwiają testowanie najnowocześniejszych interfejsów API platformy internetowej i pomagają znaleźć problemy w witrynie, zanim zrobią to użytkownicy.

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.