Панель производительности на 400% быстрее благодаря perf-ception

Андрес Оливарес
Andrés Olivares
Нэнси Ли
Nancy Li

Независимо от типа приложения, которое вы разрабатываете, оптимизация его производительности, быстрая загрузка и плавное взаимодействие имеют решающее значение для удобства работы пользователя и успеха приложения. Один из способов сделать это — проверить активность приложения с помощью инструментов профилирования, чтобы увидеть, что происходит внутри приложения во время его работы в течение определенного временного окна. Панель «Производительность» в DevTools — отличный инструмент профилирования для анализа и оптимизации производительности веб-приложений. Если ваше приложение работает в Chrome, оно дает вам подробный визуальный обзор того, что делает браузер во время выполнения вашего приложения. Понимание этого действия может помочь вам выявить закономерности, узкие места и проблемные точки производительности, над которыми можно действовать, чтобы повысить производительность.

В следующем примере показано, как использовать панель «Производительность» .

Настройка и воссоздание нашего сценария профилирования

Недавно мы поставили перед собой цель сделать панель «Производительность» более производительной. В частности, мы хотели, чтобы он быстрее загружал большие объемы данных о производительности. Это имеет место, например, при профилировании длительных или сложных процессов или при сборе данных с высокой степенью детализации. Для этого сначала необходимо было понять, как работает приложение и почему оно работает именно так, что было достигнуто с помощью инструмента профилирования.

Как вы, возможно, знаете, DevTools сам по себе является веб-приложением. Таким образом, его можно профилировать с помощью панели «Производительность» . Чтобы профилировать эту панель, вы можете открыть DevTools, а затем открыть другой экземпляр DevTools, прикрепленный к ней. В Google эта настройка известна как DevTools-on-DevTools .

Когда настройка готова, необходимо воссоздать и записать сценарий, который будет профилироваться. Во избежание путаницы исходное окно DevTools будет называться « первым экземпляром DevTools», а окно, проверяющее первый экземпляр, будет называться « вторым экземпляром DevTools».

Скриншот экземпляра DevTools, проверяющего элементы в самом DevTools.
DevTools-on-DevTools: проверка DevTools с помощью DevTools.

Во втором экземпляре DevTools панель «Производительность» , которая в дальнейшем будет называться панелью производительности, наблюдает за первым экземпляром DevTools, чтобы воссоздать сценарий, который загружает профиль.

На втором экземпляре DevTools запускается живая запись, а на первом экземпляре профиль загружается из файла на диске. Загружается большой файл, чтобы точно профилировать производительность обработки больших входных данных. Когда оба экземпляра завершают загрузку, данные профилирования производительности, обычно называемые трассировкой , отображаются во втором экземпляре DevTools панели производительности, загружающей профиль.

Исходное состояние: выявление возможностей для улучшения

После завершения загрузки на следующем снимке экрана на нашем втором экземпляре панели производительности было замечено следующее. Сосредоточьтесь на активности основного потока, которая отображается под дорожкой с надписью Main . Можно увидеть, что в карте пламени есть пять больших групп активности. К ним относятся задачи, загрузка которых занимает больше всего времени. Общее время выполнения этих задач составило примерно 10 секунд . На следующем снимке экрана панель производительности используется для фокусировки на каждой из этих групп действий и просмотра того, что можно найти.

Снимок экрана панели производительности в DevTools, на котором проверяется загрузка трассировки производительности на панели производительности другого экземпляра DevTools. Загрузка профиля занимает около 10 секунд. На этот раз время в основном разделено на пять основных групп деятельности.

Первая группа действий: ненужная работа

Стало очевидно, что первая группа действий представляла собой устаревший код, который все еще работал, но в действительности не был нужен. По сути, все, что находится под зеленым блоком с надписью processThreadEvents , было напрасной тратой усилий. Это была быстрая победа. Удаление этого вызова функции сэкономило около 1,5 секунд времени. Прохладный!

Вторая группа активности

Во второй группе действий решение было не таким простым, как в первой. Вызов buildProfileCalls занял около 0,5 секунды, и этой задачи нельзя было избежать.

Снимок экрана панели производительности в DevTools, на которой проверяется другой экземпляр панели производительности. Задача, связанная с функцией buildProfileCalls, занимает около 0,5 секунды.

Из любопытства мы включили параметр «Память» на панели производительности для дальнейшего изучения и увидели, что действие buildProfileCalls также использует много памяти. Здесь вы можете увидеть, как синяя линия графика внезапно прыгает во время запуска buildProfileCalls , что указывает на потенциальную утечку памяти.

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

Чтобы проверить это подозрение, мы использовали для расследования панель «Память» (еще одна панель в DevTools, отличная от секции «Память» на панели производительности). На панели «Память» был выбран тип профилирования «Выборка распределения», при котором записывался снимок кучи для панели производительности, загружающей профиль ЦП.

Скриншот исходного состояния профилировщика памяти. Параметр «Выборка распределения» выделен красной рамкой и указывает, что этот параметр лучше всего подходит для профилирования памяти JavaScript.

На следующем снимке экрана показан собранный снимок кучи.

Снимок экрана профилировщика памяти с выбранной операцией Set с интенсивным использованием памяти.

Из этого снимка кучи было замечено, что класс Set потребляет много памяти. Проверив точки вызова, выяснилось, что мы без необходимости присваивали свойства типа Set объектам, которые создавались в больших объемах. Эти затраты накапливались, и потреблялось много памяти до такой степени, что приложение часто аварийно завершало работу при больших входных данных.

Наборы полезны для хранения уникальных элементов и предоставляют операции, использующие уникальность их содержимого, например дедупликацию наборов данных и обеспечение более эффективного поиска. Однако в этих функциях не было необходимости, поскольку сохраняемые данные гарантированно были уникальными из источника. Таким образом, в наборах изначально не было необходимости. Чтобы улучшить распределение памяти, тип свойства был изменен с Set на простой массив. После применения этого изменения был сделан еще один снимок кучи, и наблюдалось уменьшение выделения памяти. Несмотря на то, что это изменение не привело к значительному увеличению скорости, дополнительным преимуществом было то, что приложение реже зависало.

Скриншот профилировщика памяти. Ранее ресурсоемкая операция Set на основе памяти была изменена на использование простого массива, что значительно снизило затраты на память.

Третья группа действий: взвешивание компромиссов в структуре данных

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

В реализации функции appendEventAtLevel выделяется одна вещь. Для каждой отдельной записи входных данных (которая в коде называется «событием») на карту добавлялся элемент, который отслеживал вертикальное положение записей временной шкалы. Это было проблематично, поскольку количество хранившихся предметов было очень большим. Карты обеспечивают быстрый поиск по ключам, но это преимущество не предоставляется бесплатно. По мере того, как карта становится больше, добавление к ней данных может, например, стать дорогостоящим из-за повторного хеширования. Эта стоимость становится заметной, когда на карту последовательно добавляется большое количество предметов.

/**
 * Adds an event to the flame chart data at a defined vertical level.
 */
function appendEventAtLevel (event, level) {
  // ...

  const index = data.length;
  data.push(event);
  this.indexForEventMap.set(event, index);

  // ...
}

Мы экспериментировали с другим подходом, который не требовал от нас добавления элемента на карту для каждой записи в диаграмме пламени. Улучшение было значительным, подтверждая, что узкое место действительно было связано с накладными расходами, возникающими при добавлении всех данных на карту. Время, которое потребовалось группе действий, сократилось примерно с 1,4 секунды до примерно 200 миллисекунд.

До:

Снимок экрана панели производительности до оптимизации функции AppendEventAtLevel. Общее время выполнения функции составило 1372,51 миллисекунды.

После:

Скриншот панели производительности после оптимизации функции AppendEventAtLevel. Общее время выполнения функции составило 207,2 миллисекунды.

Четвертая группа действий: отсрочка некритической работы и кэширования данных для предотвращения дублирования работы.

Увеличив это окно, можно увидеть, что имеется два практически идентичных блока вызовов функций. Посмотрев на имена вызываемых функций, вы можете сделать вывод, что эти блоки состоят из кода, создающего деревья (например, с такими именами, как refreshTree или buildChildren ). Фактически, соответствующий код создает древовидные представления в нижнем ящике панели. Что интересно, эти древовидные представления не отображаются сразу после загрузки. Вместо этого пользователю необходимо выбрать древовидное представление (вкладки «Снизу вверх», «Дерево вызовов» и «Журнал событий» в ящике) для отображения деревьев. Кроме того, как видно из скриншота, процесс построения дерева выполнялся дважды.

Снимок экрана панели производительности, показывающий несколько повторяющихся задач, которые выполняются, даже если они не нужны. Эти задачи можно было бы отложить для выполнения по требованию, а не заранее.

На этом изображении мы выявили две проблемы:

  1. Некритическая задача тормозила производительность времени загрузки. Пользователям не всегда нужны его результаты. Таким образом, задача не является критичной для загрузки профиля.
  2. Результат этих задач не кэшировался. Поэтому деревья были рассчитаны дважды, хотя данные не менялись.

Мы начали с того, что отложили вычисление дерева до момента, когда пользователь вручную открывал древовидное представление. Только тогда стоит заплатить цену за создание этих деревьев. Общее время выполнения этого дважды составило около 3,4 секунды, поэтому отсрочка существенно увеличила время загрузки. Мы все еще работаем над кэшированием задач такого типа.

Пятая группа действий: по возможности избегайте сложных иерархий вызовов.

Присмотревшись к этой группе, стало ясно, что определенная цепочка вызовов вызывалась неоднократно. Один и тот же паттерн появился 6 раз в разных местах диаграммы пламени, а общая продолжительность этого окна составила около 2,4 секунды!

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

Связанный код, вызываемый несколько раз, — это часть, которая обрабатывает данные, которые будут отображаться на «мини-карте» (обзор активности временной шкалы в верхней части панели). Непонятно, почему это происходило несколько раз, но уж точно не обязательно, чтобы это происходило 6 раз! Фактически, выходные данные кода должны оставаться текущими, если никакой другой профиль не загружен. Теоретически код должен запускаться только один раз.

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

Снимок экрана панели производительности, показывающий шесть отдельных вызовов функций для создания одной и той же мини-карты трассировки, уменьшенной всего до двух раз.

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

Поскольку обе эти мини-карты представляют собой изображения, нарисованные на холсте, пришлось использовать утилиту холста drawImage и последующий запуск кода только один раз, чтобы сэкономить дополнительное время. В результате этих усилий продолжительность группы сократилась с 2,4 секунды до 140 миллисекунд.

Заключение

После применения всех этих исправлений (и еще пары более мелких кое-где) изменение графика загрузки профиля выглядело следующим образом:

До:

Снимок экрана панели производительности, показывающий загрузку трассировки перед оптимизацией. Процесс занял примерно десять секунд.

После:

Снимок экрана панели производительности, показывающий загрузку трассировки после оптимизации. Теперь этот процесс занимает примерно две секунды.

Время загрузки после улучшений составило 2 секунды, а это означает, что улучшение примерно на 80% было достигнуто с относительно небольшими усилиями, поскольку большая часть того, что было сделано, состояла из быстрых исправлений. Конечно, правильное определение того, что делать на начальном этапе, было ключевым моментом, и панель производительности стала для этого подходящим инструментом.

Также важно подчеркнуть, что эти цифры относятся к профилю, используемому в качестве объекта исследования. Профиль был нам интересен тем, что был особенно большим. Тем не менее, поскольку конвейер обработки одинаков для каждого профиля, достигнутое значительное улучшение применимо к каждому профилю, загруженному на панель производительности.

Вынос

Из этих результатов можно извлечь некоторые уроки с точки зрения оптимизации производительности вашего приложения:

1. Используйте инструменты профилирования для выявления закономерностей производительности во время выполнения.

Инструменты профилирования невероятно полезны для понимания того, что происходит в вашем приложении во время его работы, особенно для выявления возможностей повышения производительности. Панель «Производительность» в Chrome DevTools — отличный вариант для веб-приложений, поскольку это встроенный инструмент веб-профилирования в браузере, и он активно поддерживается в актуальном состоянии с учетом новейших функций веб-платформы. Кроме того, теперь это значительно быстрее! 😉

Используйте образцы, которые можно использовать в качестве репрезентативных рабочих нагрузок, и посмотрите, что вы сможете найти!

2. Избегайте сложной иерархии вызовов

По возможности не делайте график вызовов слишком сложным. При наличии сложных иерархий вызовов легко снизить производительность и сложно понять, почему ваш код работает именно так, а это затрудняет внесение улучшений.

3. Определите ненужную работу

Устаревшие кодовые базы обычно содержат код, который больше не нужен. В нашем случае устаревший и ненужный код занимал значительную часть общего времени загрузки. Его удаление было самым простым решением.

4. Используйте структуры данных правильно

Используйте структуры данных для оптимизации производительности, а также учитывайте затраты и компромиссы, которые приносит каждый тип структуры данных, при принятии решения о том, какую из них использовать. Это не только пространственная сложность самой структуры данных, но и временная сложность применимых операций.

5. Кэшируйте результаты, чтобы избежать дублирования работы при выполнении сложных или повторяющихся операций.

Если выполнение операции требует больших затрат, имеет смысл сохранить ее результаты до следующего раза, когда они понадобятся. Это также имеет смысл делать, если операция выполняется много раз, даже если каждое отдельное время не требует особых затрат.

6. Отложите некритическую работу

Если выходные данные задачи не нужны немедленно и выполнение задачи расширяет критический путь, рассмотрите возможность отложить ее, лениво вызывая ее, когда ее выходные данные действительно необходимы.

7. Используйте эффективные алгоритмы для больших входных данных

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

8. Бонус: сравните свои конвейеры

Чтобы убедиться, что ваш развивающийся код остается быстрым, разумно отслеживать его поведение и сравнивать его со стандартами. Таким образом, вы заранее выявляете регрессии и повышаете общую надежность, настраивая себя на долгосрочный успех.