Как мы ускорили трассировку стека Chrome DevTools в 10 раз

Бенедикт Мёрер
Benedikt Meurer

Веб-разработчики привыкли практически не ожидать влияния на производительность при отладке своего кода. Однако это ожидание ни в коем случае не является универсальным. Разработчик C++ никогда не ожидал, что отладочная сборка его приложения достигнет производительности, а в первые годы существования Chrome простое открытие DevTools значительно влияло на производительность страницы.

Тот факт, что такое снижение производительности больше не ощущается, является результатом многолетних инвестиций в возможности отладки DevTools и V8 . Тем не менее, мы никогда не сможем свести накладные расходы на производительность DevTools до нуля . Установка точек останова, пошаговое выполнение кода, сбор трассировки стека, запись трассировки производительности и т. д. — все это в разной степени влияет на скорость выполнения. В конце концов, наблюдение за чем-то меняет это .

Но, конечно, накладные расходы DevTools, как и любого отладчика, должны быть разумными. Недавно мы стали свидетелями значительного увеличения количества сообщений о том, что в некоторых случаях DevTools замедляет работу приложения до такой степени, что его становится невозможно использовать. Ниже вы можете увидеть параллельное сравнение из отчета chromium:1069425 , иллюстрирующее снижение производительности при буквальном открытии DevTools.

Как видно из видео замедление составляет порядка 5-10 раз , что явно не приемлемо. Первым шагом было понять, куда уходит все время и что вызывает такое массовое замедление работы, когда DevTools был открыт. Использование Linux perf в процессе Chrome Renderer выявило следующее распределение общего времени выполнения рендеринга:

Время выполнения Chrome Renderer

Хотя мы в некоторой степени ожидали увидеть что-то, связанное со сбором трассировок стека, мы не ожидали, что около 90% общего времени выполнения уходит на обозначение кадров стека. Под символизацией здесь понимается процесс разрешения имен функций и конкретных исходных позиций (номеров строк и столбцов в скриптах) из необработанных кадров стека.

Вывод имени метода

Еще более удивительным был тот факт, что в V8 почти все время уходит на функцию JSStackFrame::GetMethodName() — хотя из предыдущих исследований мы знали, что JSStackFrame::GetMethodName() не новичок в мире проблем с производительностью. Эта функция пытается вычислить имя метода для кадров, которые считаются вызовами метода (кадры, которые представляют вызовы функций в форме obj.func() , а не func() ). Беглый просмотр кода показал, что он работает, выполняя полный обход объекта и цепочки его прототипов и ища

  1. свойства данных, value которых является замыкание func , или
  2. свойства аксессора, где либо get , либо set равны замыканию func .

Хотя само по себе это не звучит особенно дешево, но и не похоже, что это объясняет такое ужасное замедление. Итак, мы начали копаться в примере, описанном в chromium:1069425 , и обнаружили, что трассировки стека собираются для асинхронных задач, а также для сообщений журнала, исходящих из classes.js — файла JavaScript размером 10 МБ. При более внимательном рассмотрении выяснилось, что по сути это была среда выполнения Java плюс код приложения, скомпилированный в JavaScript. Трассировки стека содержали несколько кадров с методами, вызываемыми для объекта A , поэтому мы подумали, что, возможно, стоит понять, с каким объектом мы имеем дело.

складывать следы объекта

Судя по всему, компилятор Java в JavaScript сгенерировал один объект с колоссальными 82 203 функциями — это явно начинало становиться интересным. Затем мы вернулись к JSStackFrame::GetMethodName() версии V8, чтобы понять, есть ли там какой-нибудь низко висящий плод, который мы могли бы сорвать.

  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 .

Ошибка.стек

Мы могли бы положить этому конец. Но произошло что-то подозрительное, поскольку DevTools никогда не использует имя метода для кадров стека. На самом деле класс v8::StackFrame в C++ API даже не предоставляет возможности получить имя метода. Поэтому казалось неправильным, что мы в конечном итоге вызывали JSStackFrame::GetMethodName() . Вместо этого единственное место, где мы используем (и раскрываем) имя метода, — это API трассировки стека JavaScript . Чтобы понять это использование, рассмотрим следующий простой пример error-methodname.js :

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

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

Здесь у нас есть функция foo , которая установлена ​​на object под именем "bar" . Запуск этого фрагмента в Chromium дает следующий результат:

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

Здесь мы видим поиск имени метода в действии: показан самый верхний кадр стека для вызова функции foo в экземпляре Object через метод с именем bar . Таким образом, нестандартное свойство error.stack интенсивно использует JSStackFrame::GetMethodName() и наши тесты производительности также показывают, что наши изменения значительно ускорили работу.

Ускорение микротестов StackTrace

Но вернемся к теме Chrome DevTools: тот факт, что имя метода вычисляется, даже если error.stack не используется, выглядит неправильным. Здесь нам поможет некоторая история: традиционно в V8 существовало два различных механизма для сбора и представления трассировки стека для двух разных API, описанных выше (API C++ v8::StackFrame API и API трассировки стека JavaScript). Наличие двух разных способов сделать (примерно) одно и то же было подвержено ошибкам и часто приводило к несогласованности и ошибкам, поэтому в конце 2018 года мы запустили проект, направленный на устранение одного узкого места для захвата трассировки стека.

Этот проект имел большой успех и значительно сократил количество проблем, связанных со сбором трассировки стека. Большая часть информации, предоставляемой через нестандартное свойство error.stack , также вычислялась лениво и только тогда, когда это действительно было необходимо, но в рамках рефакторинга мы применили тот же трюк к объектам v8::StackFrame . Вся информация о кадре стека вычисляется при первом вызове любого метода.

В целом это повышает производительность, но, к сожалению, это несколько противоречит тому, как эти объекты C++ API используются в Chromium и DevTools. В частности, поскольку мы представили новый класс v8::internal::StackFrameInfo , который хранил всю информацию о кадре стека, которая была предоставлена ​​либо через v8::StackFrame , либо через error.stack , мы всегда вычисляли супермножество информации, предоставляемой обоими API, а это означало, что при использовании v8::StackFrame (и, в частности, для DevTools) мы также вычисляли имя метода, как только запрашивалась какая-либо информация о кадре стека. Оказывается, DevTools всегда сразу запрашивает информацию об исходниках и скриптах.

Основываясь на этом понимании, мы смогли провести рефакторинг и радикально упростить представление кадров стека и сделать его еще более ленивым, так что использование V8 и Chromium теперь оплачивает только затраты на вычисление той информации, которую они запрашивают. Это дало огромный прирост производительности для DevTools и других вариантов использования Chromium, которым требуется лишь часть информации о кадрах стека (по сути, просто имя сценария и расположение источника в виде смещения строки и столбца), и открыло двери для большего. улучшения производительности.

Имена функций

Благодаря устранению вышеупомянутых рефакторингов накладные расходы на символизацию (время, затраченное на v8_inspector::V8Debugger::symbolize ) сократились примерно до 15 % от общего времени выполнения, и мы смогли более четко видеть, на что V8 тратил время, когда (сбор и) обозначение кадров стека для использования в DevTools.

Стоимость символизации

Первое, что бросалось в глаза, — это совокупная стоимость вычисления номера строки и столбца. Самая затратная часть здесь — это вычисление смещения символов внутри скрипта (на основе смещения байт-кода, которое мы получаем из V8), и оказалось, что из-за нашего рефакторинга, описанного выше, мы сделали это дважды: один раз при вычислении номера строки, а другой раз при вычислении номера столбца. Кэширование исходной позиции в экземплярах v8::internal::StackFrameInfo помогло быстро решить эту проблему и полностью исключило v8::internal::StackFrameInfo::GetColumnNumber из любых профилей.

Более интересным для нас открытием было то, что v8::StackFrame::GetFunctionName был на удивление высоким во всех профилях, которые мы просмотрели. Копнув глубже, мы поняли, что вычисление имени, которое мы будем показывать для функции во фрейме стека в DevTools, было бы неоправданно затратным.

  1. сначала ищем нестандартное свойство "displayName" , и если это дает свойство данных со строковым значением, мы бы использовали его,
  2. в противном случае вернемся к поиску стандартного свойства "name" и еще раз проверим, дает ли это свойство данных, значение которого является строкой,
  3. и в конечном итоге возвращается к внутреннему отладочному имени, которое выводится синтаксическим анализатором V8 и сохраняется в функциональном литерале.

Свойство "displayName" было добавлено в качестве обходного пути для свойства "name" в экземплярах Function , доступных только для чтения и не настраиваемых в JavaScript, но никогда не было стандартизировано и не нашло широкого распространения, поскольку разработчик браузера В инструменты добавлен вывод имени функции, который выполняет работу в 99,9% случаев. Вдобавок ко всему, ES2015 сделал свойство "name" в экземплярах Function настраиваемым, полностью устранив необходимость в специальном свойстве "displayName" . Поскольку отрицательный поиск "displayName" довольно затратен и в действительности не нужен (ES2015 был выпущен более пяти лет назад), мы решили удалить поддержку нестандартного свойства fn.displayName из V8 (и DevTools).

После устранения отрицательного поиска "displayName" половина стоимости v8::StackFrame::GetFunctionName была удалена. Другая половина уходит на общий поиск свойства "name" . К счастью, у нас уже была некоторая логика, позволяющая избежать дорогостоящих поисков свойства "name" в (нетронутых) экземплярах Function , которую мы представили в V8 некоторое время назад, чтобы ускорить саму Function.prototype.bind() . Мы перенесли необходимые проверки , которые позволяют нам пропустить дорогостоящий общий поиск, в результате чего v8::StackFrame::GetFunctionName больше не отображается ни в каких профилях, которые мы рассматривали.

Заключение

Благодаря вышеуказанным улучшениям мы значительно сократили накладные расходы DevTools с точки зрения трассировки стека.

Мы знаем, что все еще существуют различные возможные улучшения - например, накладные расходы при использовании MutationObserver все еще заметны, как сообщается в chromium:1077657 - но на данный момент мы устранили основные болевые точки и, возможно, вернемся к будущем для дальнейшей оптимизации производительности отладки.

Загрузите предварительный просмотр каналов

Рассмотрите возможность использования Chrome Canary , Dev или Beta в качестве браузера для разработки по умолчанию. Эти каналы предварительного просмотра дают вам доступ к новейшим функциям DevTools, тестируют передовые API-интерфейсы веб-платформы и находят проблемы на вашем сайте раньше, чем это сделают ваши пользователи!

Связь с командой Chrome DevTools

Используйте следующие параметры, чтобы обсудить новые функции и изменения в публикации или что-либо еще, связанное с DevTools.