Независимо от того, какой тип приложения вы разрабатываете, оптимизация его производительности, быстрая загрузка и плавное взаимодействие имеют решающее значение для удобства пользователя и успеха приложения. Один из способов сделать это — проверить активность приложения с помощью инструментов профилирования, чтобы увидеть, что происходит внутри приложения во время его работы в течение определенного временного окна. Панель «Производительность» в DevTools — отличный инструмент профилирования для анализа и оптимизации производительности веб-приложений. Если ваше приложение работает в Chrome, оно дает вам подробный визуальный обзор того, что делает браузер во время выполнения вашего приложения. Понимание этого действия может помочь вам выявить закономерности, узкие места и проблемные точки производительности, над которыми можно действовать, чтобы повысить производительность.
В следующем примере показано, как использовать панель «Производительность» .
Настройка и воссоздание нашего сценария профилирования
Недавно мы поставили перед собой цель сделать панель «Производительность» более производительной. В частности, мы хотели, чтобы он быстрее загружал большие объемы данных о производительности. Это имеет место, например, при профилировании длительных или сложных процессов или при сборе данных с высокой степенью детализации. Для этого сначала необходимо было понять, как работает приложение и почему оно работает именно так, что было достигнуто с помощью инструмента профилирования.
Как вы, возможно, знаете, DevTools сам по себе является веб-приложением. Таким образом, его можно профилировать с помощью панели «Производительность» . Чтобы профилировать эту панель, вы можете открыть DevTools, а затем открыть другой экземпляр DevTools, прикрепленный к ней. В Google эта настройка известна как DevTools-on-DevTools .
Когда настройка готова, необходимо воссоздать и записать сценарий, который будет профилироваться. Во избежание путаницы исходное окно DevTools будет называться « первым экземпляром DevTools», а окно, проверяющее первый экземпляр, будет называться « вторым экземпляром DevTools».
Во втором экземпляре DevTools панель «Производительность », которая в дальнейшем будет называться панелью производительности , наблюдает за первым экземпляром DevTools, чтобы воссоздать сценарий, который загружает профиль.
На втором экземпляре DevTools запускается живая запись, а на первом экземпляре профиль загружается из файла на диске. Загружается большой файл, чтобы точно профилировать производительность обработки больших входных данных. Когда оба экземпляра завершают загрузку, данные профилирования производительности, обычно называемые трассировкой , отображаются во втором экземпляре DevTools панели производительности, загружающей профиль.
Исходное состояние: выявление возможностей для улучшения
После завершения загрузки на следующем снимке экрана на нашем втором экземпляре панели производительности было замечено следующее. Сосредоточьтесь на активности основного потока, которая отображается под дорожкой с надписью Main . Можно увидеть, что в карте пламени есть пять больших групп активности. К ним относятся задачи, загрузка которых занимает больше всего времени. Общее время выполнения этих задач составило примерно 10 секунд . На следующем снимке экрана панель производительности используется для фокусировки на каждой из этих групп действий и просмотра того, что можно найти.
Первая группа действий: ненужная работа
Стало очевидно, что первая группа действий представляла собой устаревший код, который все еще работал, но в действительности не был нужен. По сути, все, что находится под зеленым блоком с надписью processThreadEvents
было напрасной тратой усилий. Это была быстрая победа. Удаление этого вызова функции сэкономило около 1,5 секунд времени. Прохладный!
Вторая группа активности
Во второй группе действий решение было не таким простым, как в первой. Вызов buildProfileCalls
занял около 0,5 секунды, и этой задачи нельзя было избежать.
Из любопытства мы включили параметр «Память» на панели производительности для дальнейшего изучения и увидели, что действие buildProfileCalls
также использует много памяти. Здесь вы можете видеть, как синяя линия графика внезапно прыгает во время запуска buildProfileCalls
, что указывает на потенциальную утечку памяти.
Чтобы проверить это подозрение, мы использовали для расследования панель «Память» (еще одна панель в DevTools, отличная от секции «Память» на панели производительности). На панели «Память» был выбран тип профилирования «Выборка распределения», при котором записывался снимок кучи для панели производительности, загружающей профиль ЦП.
На следующем снимке экрана показан собранный снимок кучи.
Из этого снимка кучи было замечено, что класс 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 миллисекунд.
До:
После:
Четвертая группа действий: отсрочка некритической работы и кэширования данных для предотвращения дублирования работы.
Увеличив это окно, можно увидеть, что имеется два практически идентичных блока вызовов функций. Посмотрев на имена вызываемых функций, вы можете сделать вывод, что эти блоки состоят из кода, строящего деревья (например, с такими именами, как refreshTree
или buildChildren
). Фактически, соответствующий код создает древовидные представления в нижнем ящике панели. Что интересно, эти древовидные представления не отображаются сразу после загрузки. Вместо этого пользователю необходимо выбрать древовидное представление (вкладки «Снизу вверх», «Дерево вызовов» и «Журнал событий» в ящике) для отображения деревьев. Более того, как видно из скриншота, процесс построения дерева выполнялся дважды.
На этом изображении мы выявили две проблемы:
- Некритическая задача тормозила производительность времени загрузки. Пользователям не всегда нужны его результаты. Таким образом, задача не является критичной для загрузки профиля.
- Результат этих задач не кэшировался. Поэтому деревья были рассчитаны дважды, хотя данные не менялись.
Мы начали с того, что отложили вычисление дерева до момента, когда пользователь вручную открывал древовидное представление. Только тогда стоит заплатить цену за создание этих деревьев. Общее время двукратного запуска составило около 3,4 секунды, поэтому отсрочка существенно увеличила время загрузки. Мы все еще работаем над кэшированием задач такого типа.
Пятая группа действий: по возможности избегайте сложных иерархий вызовов.
Присмотревшись к этой группе, стало ясно, что определенная цепочка вызовов вызывалась неоднократно. Один и тот же паттерн появился 6 раз в разных местах диаграммы пламени, а общая продолжительность этого окна составила около 2,4 секунды!
Связанный код, вызываемый несколько раз, — это часть, которая обрабатывает данные, которые будут отображаться на «мини-карте» (обзор активности временной шкалы в верхней части панели). Непонятно, почему это происходило несколько раз, но уж точно не обязательно, чтобы это происходило 6 раз! Фактически, выходные данные кода должны оставаться текущими, если никакой другой профиль не загружен. Теоретически код должен запускаться только один раз.
В ходе расследования было обнаружено, что связанный код был вызван из-за того, что несколько частей в конвейере загрузки прямо или косвенно вызывали функцию, вычисляющую мини-карту. Это связано с тем, что сложность графа вызовов программы со временем менялась, и по незнанию добавлялись новые зависимости к этому коду. Быстрого решения этой проблемы не существует. Способ решения зависит от архитектуры рассматриваемой кодовой базы. В нашем случае пришлось немного уменьшить сложность иерархии вызовов и добавить проверку, предотвращающую выполнение кода, если входные данные остались неизменными. После реализации мы получили такой взгляд на график:
Обратите внимание, что выполнение рендеринга мини-карты происходит дважды, а не один раз. Это связано с тем, что для каждого профиля рисуются две мини-карты: одна для обзора в верхней части панели, а другая для раскрывающегося меню, которое выбирает видимый в данный момент профиль из истории (каждый элемент в этом меню содержит обзор профиль, который он выбирает). Тем не менее, эти два имеют одно и то же содержимое, поэтому один из них можно использовать повторно для другого.
Поскольку обе эти мини-карты представляют собой изображения, нарисованные на холсте, пришлось использовать утилиту холста drawImage
и последующий запуск кода только один раз, чтобы сэкономить дополнительное время. В результате этих усилий продолжительность группы сократилась с 2,4 секунды до 140 миллисекунд.
Заключение
После применения всех этих исправлений (и еще пары более мелких кое-где) изменение графика загрузки профиля выглядело следующим образом:
До:
После:
Время загрузки после улучшений составило 2 секунды, а это означает, что улучшение примерно на 80% было достигнуто с относительно небольшими усилиями, поскольку большая часть того, что было сделано, состояла из быстрых исправлений. Конечно, правильное определение того, что делать на начальном этапе, было ключевым моментом, и панель производительности стала для этого подходящим инструментом.
Также важно подчеркнуть, что эти цифры относятся к профилю, используемому в качестве объекта исследования. Профиль был нам интересен тем, что был особенно большим. Тем не менее, поскольку конвейер обработки одинаков для каждого профиля, достигнутое значительное улучшение применимо к каждому профилю, загруженному на панель производительности.
Вынос
Из этих результатов можно извлечь некоторые уроки с точки зрения оптимизации производительности вашего приложения:
1. Используйте инструменты профилирования для выявления закономерностей производительности во время выполнения.
Инструменты профилирования невероятно полезны для понимания того, что происходит в вашем приложении во время его работы, особенно для выявления возможностей повышения производительности. Панель «Производительность» в Chrome DevTools — отличный вариант для веб-приложений, поскольку это встроенный инструмент веб-профилирования в браузере, и он активно поддерживается в актуальном состоянии с учетом новейших функций веб-платформы. Кроме того, теперь это значительно быстрее! 😉
Используйте образцы, которые можно использовать в качестве репрезентативных рабочих нагрузок, и посмотрите, что вы сможете найти!
2. Избегайте сложной иерархии вызовов
По возможности не делайте график вызовов слишком сложным. При наличии сложных иерархий вызовов легко снизить производительность и сложно понять, почему ваш код работает именно так, а это затрудняет внесение улучшений.
3. Определите ненужную работу
Устаревшие кодовые базы обычно содержат код, который больше не нужен. В нашем случае устаревший и ненужный код занимал значительную часть общего времени загрузки. Его удаление было самым простым решением.
4. Используйте структуры данных правильно
Используйте структуры данных для оптимизации производительности, а также учитывайте затраты и компромиссы, которые приносит каждый тип структуры данных, при принятии решения о том, какую из них использовать. Это не только пространственная сложность самой структуры данных, но и временная сложность применимых операций.
5. Кэшируйте результаты, чтобы избежать дублирования работы при выполнении сложных или повторяющихся операций.
Если выполнение операции требует больших затрат, имеет смысл сохранить ее результаты до следующего раза, когда они понадобятся. Это также имеет смысл делать, если операция выполняется много раз, даже если каждое отдельное время не требует особых затрат.
6. Отложите некритическую работу
Если выходные данные задачи не нужны немедленно и выполнение задачи расширяет критический путь, рассмотрите возможность отложить ее, лениво вызывая ее, когда ее выходные данные действительно необходимы.
7. Используйте эффективные алгоритмы для больших входных данных
Для больших входных данных решающее значение приобретают алгоритмы оптимальной временной сложности. В данном примере мы не рассматривали эту категорию, но их важность вряд ли можно переоценить.
8. Бонус: сравните свои конвейеры
Чтобы убедиться, что ваш развивающийся код остается быстрым, разумно отслеживать его поведение и сравнивать его со стандартами. Таким образом, вы заранее выявляете регрессии и повышаете общую надежность, настраивая себя на долгосрочный успех.