Эта статья — перевод оригинальной статьи Thomas Belin "Get to know your browser's performance profiler"
Также я веду телеграм канал “Frontend по-флотски”, где рассказываю про интересные вещи из мира разработки интерфейсов.
Вступление
В какой-то момент своей карьеры вы, возможно, просматривали вкладку «Производительность» в инструментах разработки вашего любимого браузера. В конце концов вы попытались создать profile, но, вероятно, быстро разочаровались. Высокая плотность отображаемой информации делает ее немного подавляющей и несколько пугающей. Я был там, я понимаю тебя!
Хорошая новость: кривая обучения на самом деле не такая крутая!
Как только вы усвоите несколько концепций, он внезапно станет вашим самым ценным инструментом для устранения узких мест в производительности.
Эта статья даст вам несколько ключей к пониманию того, как работает профайлер и как правильно его использовать.
Давайте полностью забудем о console.log
и console.time
, сегодня мы погрузимся в профилировщик производительности!
Примечание: я не буду слишком углубляться в сложные сценарии, но в конечном итоге напишу дополнительную статью о продвинутых методах.
Модель данных
Первым шагом, который я предпринял, чтобы понять, как работает профилировщик, было чтение документации Mozilla об их новом профилировщике производительности (это отличный документ, прочтите его).
Первый вау-эффект, который у меня был, был, когда я увидел модель данных, которую использовал профайлер. Это на самом деле довольно просто
В документации Mozilla модель данных представлена следующим образом:
A A A
| | |
v v v
B B B
|
v
C
A, B и C — имена функций, а по оси X мы получаем время. По умолчанию профилировщик Firefox и Chrome настроен на создание снимка каждые 1 мс, что означает, что здесь каждый столбец представляет 1 мс.
В этом примере это означает, что стек со временем развивался таким образом.
в 0мс
A
вызывалB
, аB
все еще работал;в 1 мс
B
вызывалC
, аC
все еще работал;в 2 мс
C
закончил свою работу, и мы вернулись вB
;в 3 мс стек оказался пуст.
Из этого профилировщик может сделать следующие выводы:
А
почти сразу вызвалB
;Мы пробыли ~1 мс в B, прежде чем вызвать C;
C потребовалось ~ 1 мс для выполнения;
B
снова потребовалось еще ~ 1 мс после вызова C;А закончил выполнение сразу после вызова
B
.
Имея в виду эту модель, мы можем создать некоторые данные:
A A A A A A A A A
| | | | | | | | |
V V V V V V V V V
B B B B B B B B B
|
V
C
B
занимает немного времени до и после вызова C
. Мы потратили ~1 мс на C
и не потратили время на A
:
A A A A A A A A A
| |
V V
B B
|
V
C
A
занимает некоторое время перед вызовом B
. B
и C
занимают ~1 мс.
Ограничения этой модели
Поскольку профилировщик берет только 1 выборку в мс, это означает, что вызов функции, который занимает менее 1 мс, имеет высокую вероятность не отображаться в сгенерированном профиле.
Представим следующий сценарий:
function A() {
B(); // < takes 0.5ms // snapshot #1
C(); // < takes 0.4ms
D(); // < takes 0.2ms // snapshot #2
E(); // < takes 0.5ms
}
Сгенерированный профиль, скорее всего, будет выглядеть примерно так:
A A
| |
v v
B D
В этом профиле не будет упоминаний о C
или E
.
Но мы здесь для отладки долгих задач, помните? Нет необходимости разбирать эти быстро выполняющиеся функции. Нам до них нет дела!
Собственное время (self time) против общего времени (total time)
Одно немного запутанное понятие в профилировщике — это собственное и общее время.
Однако на самом деле это понятие довольно легко понять.
Их можно определить так:
собственное — время, проведенное в самой функции;
общее — время, проведенное в функции и всех дочерних функциях, которые она вызывает.
Чтобы прочувствовать это, вот конкретный пример:
function superExpensive() {
for (let i = 0; i < 1e10; i++) {
console.log(i);
}
}
function main() {
superExpensiveComputation(); // < takes 1000ms
for (let i = 0; i < 1e6; i++) {
// ^ takes 5ms
console.log(i);
}
}
main
будет иметь собственное время 5мс, но общее время 1005мс. superExpensiveComputation
будет иметь общее и собственное время 1000мс
Общее время помогает выявить проблемные части кода, а собственное время позволяет сузить область поиска до функции, которая действительно требует вашего внимания.
Погружение в UI
Имея в виду эту модель, пользовательский интерфейс начинает обретать смысл. Понятия, которые мы видели ранее, начинают пригодиться для эффективного использования пользовательского интерфейса.
Здесь я сосредоточусь на профилировщике Firefox, но те же принципы применимы и к профилировщику Chrome.
Определение долгих функций верхнего уровня: дерево вызовов
Для начала возьмем очень простой пример кода. Представьте, что где-то есть кнопка, и при нажатии на нее мы запускаем функцию calculateNumber
.
function generateNumber(nbIterations) {
let number = 0;
for (let i = 0; i < nbIterations; i++) {
number += Math.random();
}
return number;
}
function computeNumber() {
console.log(generateNumber(1e9));
}
Вот что мы получим в нашем отчете профилировщика:
[1] Поскольку профилировщик фактически профилирует все процессы Firefox, мы хотим убедиться, что мы просто проверяем текущее веб-приложение, над которым работаем.
[2] Мы здесь веб-разработчики, нам не нужны внутренние трассировки стека браузера, давайте оставим только трассировки стека JS.
[3] Мы ясно видим, что больше всего времени мы тратим на функцию generateNumber (здесь функция появилась в 488 выборках, что означает, что она выполнялась как минимум 488мс).
Дерево вызовов позволит вам быстро определить, какие функции верхнего уровня требуют времени. Это хороший обзор того, с чего начать копать, но он не поможет вам быстро определить вложенные функции, которые имеют большое собственное время работы.
Определение долгих вложенных функций: инвертирование стека вызовов
Теперь рассмотрим следующее:
function computeMultipleNumbers() {
let number = 0;
for (let i = 0; i < 10; i++) {
const fnName = `gen${Math.round(Math.random() * 100)}`; // We create a function with a random name
const fn = new Function(`function ${fnName}() {
return generateNumber(1e7);
} return ${fnName}`);
number += fn()();
}
result.innerText = number;
}
Особенность этой функции в том, что она генерирует именованные функции со случайными именами. Это означает, что теперь generateNumber
будет вызываться из множества различных функций.
Посмотрим, как выглядит результат:
Здесь мы видим, что вызывается много функций, но все они имеют пустое собственное время. А это значит, что это не та функция, на которую мы на самом деле потратили время, они ждали завершения чего-то другого.
Теперь, если мы инвертируем стек.
Тут становится понятно, где мы собственно потратили время: в функции generateNumber
:)
Инверсия фактически сортирует функцию с наибольшим собственным временем и сглаживает их в корне дерева. Это отличный способ идентифицировать трудоемкую функцию, и вы получаете ее стек вызовов прямо рядом с ней. При этом вы точно знаете, какая функция является проблемой и откуда она была вызвана.
Это дерево вызовов:
topLevel // self 0
first // self 0
second // self 0
third // self 10
fourth // self 7
fifth // self 8
Это инверсия стека вызовов:
third //self 10
second
topLevel
fifth // self 8
fourth
second
topLevel
fourth // self 7
second
topLevel
Таким образом, мы можем быстро определить, что мы потратили ~ 10мс на вызов third
из topLevel > second
Заключение
В этой статье мы рассмотрели основные функции профайлера. Мы увидели, как использовать дерево вызовов и перевернутый стек вызовов для быстрого определения функций, требующих много времени, в вашем приложении.
Теперь эти трудоемкие функции не обязательно являются функциями, которые вам нужно оптимизировать. Проблема может заключаться в родительской функции или даже выше в дереве. Перевернутый стек вызовов дает вам хорошую отправную точку для изучения проблемной части вашего приложения.
Мы не рассмотрели здесь, что такое Flame Graph или Stack Chart, как профилировать асинхронный код или продвинутые методы, такие как маркеры. Это то, что я хотел бы осветить в следующей статье.