Всем привет! Меня зовут Илья Алонов. Я фронтенд-инженер и уже второй год занимаюсь производительностью фронта в Авито в платформенной команде Perfomance. 

Хочу рассказать про профилирование фронтденда в продакшене с использованием JS Self-Profiling API. Этот инструмент я откопал, когда пытался ускорить исполнение клиентского JS в Авито. Если интересуетесь перформансом веб-приложений — эта статья для вас.

С этим материалом я выступал на митапе MoscowJS. Запись моего выступления с докладом «Профилирование фронтенда в проде» можно посмотреть на YouTube-канале AvitoTech.

Что такое профилирование

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

Профилирование бывает двух видов: инструментальное и сэмплирующее.

Инструментальный профайлер программно дополняет таймингами каждый вызов на стеке. Эти тайминги вычисляют время исполнения того или иного кода. 
Сэмплирующий профайлер раз в N времени берёт снэпшот (сэмпл) стека вызовов и вычисляет из них время исполнения кода.

Под профилированием фронтенда в основном подразумевают использование инструментов браузера, таких как профайлеры в Chrome или Firefox. 

Зачем нужно профилирование

Есть метрика Total Blocking Time (TBT). Она отражает, как долго исполнение JS блокирует главный поток браузера — то есть время, в течение которого браузер не отвечает на взаимодействия пользователя с сайтом. У нас оно выглядело вот так:

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

Подробнее о Total Blocking Time →

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

С такими мыслями я начал искать способ декомпозировать TBT и набрёл на JS Self-Profiling API.

Что такое JS Self-Profiling API

JS Self-Profiling API — это браузерный API, который позволяет запускать сэмплирующий профайлер. Не стоит путать с профайлерами, доступными в devtools — это разные вещи.

Профайлеры сэмплирующего класса собирают сэмплы стека вызовов, из которых строят трейс — то, что профайлер выплёвывает как результат:

Трейс исполнения кода с 3 задачами. Профайлер снимает снэпшоты раз в 5 единиц времени. А вызывает B, B вызывает C
Трейс исполнения кода с 3 задачами. Профайлер снимает снэпшоты раз в 5 единиц времени. А вызывает B, B вызывает C

JS Self-Profiling API позволяет запускать и останавливать профилирование в любой момент жизненного цикла страницы.

Как использовать JS Self-Profiling API

Давайте рассмотрим, как это работает на практике. Идея такая: инициализируем профайлер, в нужный момент останавливаем его и получаем трейс.

Важно: на момент выхода статьи JS Self-Profiling API доступен в Chrome 94 и выше. Проверьте, совместим ли инструмент с вашим браузером.

1. Инициализируем профайлер:

if (windows.Profiler) {
	const profilier = new Profiler({
		sampleRate: 10,
		maxBufferSize: Number.MAX_SAFE_INTEGER
	});

	// … когда придет время … 
	const trace = await profiler.stop();
	doStuff(trace);
}

Для инициализации нужно 2 параметра:  

  • sampleRate — частота сэмплирования в миллисекундах. Любой number. На деле для *nix-систем минимальное значение — 10 мс, для Windows — 16 мс. Если поставить значения меньше минимальных, браузер под капотом всё равно выставит нужные. 

  • maxBufferSize — предельное количество сэмплов, которое мы хотим снять. 

2. После профилирования получаем объект Trace с 4 полями: 

  • resources — список url-ов исполненных JS-файлов;

  • frames — список фреймов с информацией об исполняемом коде: имена функций и их позиции в загруженном файле;

  • stacks — список стеков вызовов. Элементы списка состоят из двух полей: frameId — ссылка на фрейм из списка frames, parentId — ссылка на вызывающий стек. Фактически в этом списке хранятся деревья вызовов;

  • samples — список всех собранных сэмплов: состоит из timestamp и ссылки на стек. 

Вот как это выглядит:

{
  "resources": [
    "https://domain.com/static/abc.js",
	"https://domain.com/static/123.js"
  ],
  "frames": [
    {
      "name": "a", "resourceId": 6,
      "line": 23, "column": 169
	},
	{
      "name": "b", "resourceId": 1,
      "line": 313, "column": 1325
    },
  ],
  "stacks": [
	{ "frameId": 0 },
	{ "frameId": 1, "parentId": 0 },
	{ "frameId": 2, "parentId": 1 }
  ],
  "samples": [
    { "timestamp": 1551.73499998637, "stackId": 2 },
	{ "timestamp": 1576.83999999426, "stackId": 1 },
  ]
}

Трейс загрузки главной странцы Авито весит ~90Kb. Эта информация понадобится нам чуть позже.

Что там с оверхедом

Главное стоп-слово для разработчиков, желающих включить профилирование (необязательно в браузере) — оверхед. Давайте смотреть, что с ним у нашей технологии.

Во-первых, есть исследование от ребят из Facebook* об оверхеде от JS Self-Profiling API. Они пишут, что в их случае оверхед составляет 1% в скорости загрузки. 
Во-вторых, я сам провёл A/B-тест в Авито и тоже обнаружил низкий оверхед. На мобильной версии сайта он в среднем составил 1–2% от скорости загрузки и Total Blocking Time при частоте сэмплирования 10 мс. 

Итого оверхед у JS Self-Profiling API незначительный, следовательно, можно развлекаться дальше.

Слева — оверхед загрузки, справа — оверхед Total Blocking Time
Слева — оверхед загрузки, справа — оверхед Total Blocking Time

Дизайн инструмента

С оверхедом разобрались, можно приступать к дизайну. Что можно сделать с полученным трейсом? Первое, что приходит в голову — слить его на сервер и там как-то обрабатывать. План выглядит надёжно, давайте посчитаем математику:

  • предположим, что у нас 10K RPM,

  • трейс загрузки в проде весит 90 Кб.

Несложными вычислениями получаем 1 Тб в день. И это при условии, что все данные нормализованные:

90Kb / 1024^2 * 10000 * 60 * 24 = 1 Tb/day

Это, конечно, можно процессить на сервере и даже хранить, но для этого нужна инфраструктура. А зачем делать инфраструктуру, если можно её не делать?

Упрощаем себе задачу

Я решил уменьшить масштаб задачи и обрабатывать данные только о лонгтасках, из которых складывается Total Blocking Time.

Чтобы это получилось, нужно вытащить лонгтаски с помощью PerformanceObserver и смержить их с трейсом профилирования:

const trace = await profiler.stop();

const longtasks = getLongtasks(); // via PerformanceObserver

function merge(trace, longtasks) { … }

const richLongtasks = merge(trace, longtasks);

Этот алгоритм хорошо описал Nic Jansma в своей статье.

В результате получаются обогащённые лонгтаски — со временем начала, конца и списком причин. Вот как это выглядит:

{
  start: 1,
  end: 11,
  reasons: [
    {
      duration: 6,
      frames: [
        {
          url: 'https://domain.com/static/abc.js',
          line: 23, column: 169
        },
        {
          url: 'https://domain.com/static/123.js',
          line: 313, column: 1325
        },
      ]
    },
    …
  ]
}

Превращаем обфусцированный код в исходники

Мы получили обогащённые таски со списком причин. Проблема в том, что файлы, на которые ссылаются фреймы, — это обфусцированный исходный код. 

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

Давайте подумаем о том, как обычно выглядит фронтендерский проект. Он состоит из:

  • NPM-пакетов: проприетарных или open source;

  • модулей: компонентов, утилит;

  • стороннего кода: рекламы, трекеров, счётчиков и прочего подобного.

Зафиксируем эту мысль в виде конфига:

// profiling.config.json
{
	"internal": [
		"@avito/packageA",
		"@avito/packageB"
		"src/components/x",
		…
	],
	"external": [
		"yastatic",
		"me.yandex.ru",
		"googletagmanager",
		…
	]
}

Так мы заранее знаем, профилирование каких модулей нам интересно. Чтобы всё это заработало, на этапе сборки мы вытащим из Source Map информацию о том, куда были собраны нужные нам модули. Тогда для каждого чанка получится сгенерировать некий json-файл, который мы назовём profiling map. Каждая такая мапа будет содержать информацию об интересующих нас модулях, попавших в соответствующий чанк.

Генерация Profiling Map
Генерация Profiling Map

Profiling map — это JSON с двумя полями: 

  • modules — список профилируемых модулей;

  • intervals — отсортированный список интервалов. Каждый интервал состоит из 3 значений: начало, конец, индекс модуля из списка modules.

Вот как это выглядит:

// some-chunk.hash.profiling-map.json
{
	"modules": [
		"@avito/packageA",
		"@avito/packageB"
		"src/components/x"
	]
	"intervals": [
		[154, 201, 1], // [start, end, moduleIdx]
		[45886, 46813, 0],
		[55574, 57004, 0]
		[55574, 57004, 2],
	],
}

Весит Profiling Map немного, в том числе за счёт нормализации данных. Для главной мобильной страницы Авито — всего 500 байт. 

Собираем воедино

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

В начале загрузки страницы:

  1. Запустить PerfomanceObserver. 

  2. Запустить Profiler.

Запускаем PerfomanceObserver и Profiler на клиенте
Запускаем PerfomanceObserver и Profiler на клиенте

В конце загрузки страницы: 

  1. Остановить PerfomanceObserver и получить лонгтаски.

  2. Остановить Profiler и получить трейс. 

  3. Слить лонгтаски и трейс, получить обогащённые лонгтаски.

  4. Параллельно с мержем лонгтасок и трейса запросить profiling map оттуда, где они лежат. Я положил рядом со статикой.

  5. Достать из обогащённых лонгтасок то, что указано в profiling map’ах.

Слева — получаем обогащенные лонгтаски, справа — получаем Profiling Map
Слева — получаем обогащенные лонгтаски, справа — получаем Profiling Map
Сливаем обогащенные лонгтаски и Profiling Map
Сливаем обогащенные лонгтаски и Profiling Map

Ограничения JS Self-Profiling API и как их обойти

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

При сэмплирующем профилировании можно пропустить какие-то таски
При сэмплирующем профилировании можно пропустить какие-то таски
  1. CORS. Для всех ресурсов с доменов, отличных от текущего, должны быть настроены cross-origin политики. В противном случае JS Self-Profiling втихую удалит информацию о них из профилирования. 

  2. Инструмент воспринимает только исполнение JavaScript. Информацию о сборке мусора, парсинге HTML, вычислениях стилей и лейаута он не даст. Сейчас этого нет, но на GitHub есть предложение о добавлении такой информации в JS Self-Profiling API в виде маркеров на стеке. 

Вот как это будет выглядеть:

   "stacks" : [   
        {"marker" : "idle"}, // id: 0
        {"marker" : "script"}, // id: 1
        {"marker" : "parse"}, // id: 2
        {"marker" : "gc"}, // id: 3
        {"marker" : "paint"}, // id: 4
        {"marker" : "other"}, // id: 5
        {"frameId" : 1, "marker": "script"}, // id: 6
        {"frameId" : 2, "parentId" : 6, "marker": "script"}, // id: 7
        {"frameId" : 2, "parentId" : 6, "marker": "gc"}, // id: 8
        {"frameId" : 3, "parentId" : 8, "marker": "script"} // id: 9
    ]

Что в итоге

Мы создали инструмент, который: 

  • умеет собирать метрики интересующих нас модулей,

  • почти не влияет на производительность,

  • прост в использовании.

Вот как выглядит график метрик пакета avito/core:

Данные для графика собраны моим инструментом, который я создал с помощью JS Self-Profiling API

Сейчас я развиваю инструмент дальше. Уже появилась поддержка микрофронтов и сильно улучшился DX — теперь включать профилирование пакетов можно из внутреннего дашборда, не запариваясь изменениями конфига.

Также у нас есть планы:

  • автоматизировать мониторинг метрик,

  • использовать технологию в NFR.

Полезные материалы 

  1. Запись моего выступления «Профилирование фронтенда в проде» на YouTube

  2. Статья JS Self-Profiling API In Pratice, на которую я опирался

  3. Драфт спецификации JS Self Profiling API

  4. Репозиторий JS Self Profiling API на GitHub

* Компания Meta, которой принадлежит Facebook, признана экстремистской и запрещена в России

Предыдущая статья: «Всё происходит само собой, когда тебе не всё равно»: как из игрока в покер стать руководителем тимлидов

Комментарии (0)