company_banner

Измерение производительности JavaScript-функций

Автор оригинала: Felix Gerschau
  • Перевод
Измерение времени, которое уходит на выполнение функции — это хороший способ доказательства того, что одна реализация некоего механизма является более производительной, чем другая. Это позволяет удостовериться в том, что производительность функции не пострадала после неких изменений, внесённых в код. Это, кроме того, помогает искать узкие места производительности приложений.

Если веб-проект обладает высокой производительностью — это вносит вклад в его позитивное восприятие пользователями. А если пользователям понравилось работать с ресурсом — они имеют свойство возвращаться. Например, в этом исследовании показано, что 88% онлайн-клиентов менее склонны возвращаться на ресурсы, при работе с которыми они столкнулись с какими-то неудобствами. Эти неудобства вполне могут быть вызваны проблемами с производительностью.

Именно поэтому в деле веб-разработки важны инструменты, помогающие находить узкие места производительности и измерять результаты улучшений, вносимых в код. Подобные инструменты особенно актуальны в JavaScript-разработке. Здесь важно знать о том, что каждая строка JavaScript-кода может, в потенциале, заблокировать DOM, так как JavaScript — это однопоточный язык.



В этом материале я расскажу о том, как измерять производительность функций, и о том, что делать с результатами измерений.

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

Метод performance.now()


Интерфейс Performance даёт доступ к значению типа DOMHighResTimeStamp через метод performance.now(). Этот метод возвращает временную метку, указывающую на время в миллисекундах, прошедшее с момента начала существования документа. Причём, точность этого показателя составляет порядка 5 микросекунд (доли миллисекунды).

Для того чтобы измерить производительность фрагмента кода, пользуясь методом performance.now(), нужно выполнить два измерения времени, сохранить результаты этих измерений в переменных, а затем вычесть из результатов второго измерения результаты первого:

const t0 = performance.now();
for (let i = 0; i < array.length; i++) 
{
  // какой-то код
}
const t1 = performance.now();
console.log(t1 - t0, 'milliseconds');

В Chrome после выполнения этого кода можно получить примерно такой результат:

0.6350000001020817 "milliseconds"

В Firefox — такой:

1 milliseconds

Как видно, результаты измерений, полученные в разных браузерах, серьёзно различаются. Дело в том, что в Firefox 60 точность результатов, возвращаемых API Performance, снижена. Мы ещё поговорим об этом.

Интерфейс Performance обладает гораздо большими возможностями, чем только возврат некоей временной метки. К ним относятся измерение различных аспектов производительности, представленные такими расширениями этого интерфейса, как API Performance Timeline, Navigation Timing, User Timing, Resource Timing. Вот материал, в котором можно найти подробности об этих API.

В нашем случае речь идёт об измерении производительности функций, поэтому нам достаточно возможностей, которые даёт метод performance.now().

Date.now() и performance.now()


Тут у вас может возникнуть мысль о том, что для измерения производительности можно пользоваться и методом Date.now(). Это и правда возможно, но у такого подхода есть недостатки.

Метод Date.now() возвращает время в миллисекундах, прошедшее с эпохи Unix (1970-01-01T00:00:00Z) и зависит от системных часов. Это означает не только то, что этот метод не так точен, как performance.now(), но и то, что он, в отличие от performance.now(), возвращает значения, которые в определённых условиях могут быть основаны на неправильных показателях часов. Вот что об этом говорит Рич Джентлкор — программист, имеющий отношение к движку WebKit: «Возможно, программисты реже думают о том, что показания, возвращаемые при обращении к Date, основанные на системном времени, совершенно нельзя назвать идеальными для мониторинга реальных приложений. В большинстве систем работает демон, который регулярно синхронизирует время. Подстройка системных часов на несколько миллисекунд каждые 15-20 минут — это обычное дело. При такой частоте настройки часов около 1% измерений 10-секундных интервалов окажутся неточными».

Метод console.time()


Измерение времени с использованием этого API производится крайне просто. Достаточно, перед кодом, производительность которого нужно оценить, вызвать метод console.time(), а после этого кода — метод console.timeEnd(). При этом и тому и другому методам нужно передать один и тот же строковой аргумент. На одной странице одновременно можно использовать до 10000 подобных таймеров.

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

console.time('test');
for (let i = 0; i < array.length; i++) {
  // какой-то код
}
console.timeEnd('test');

После выполнения подобного кода система автоматически выведет в консоль сведения о прошедшем времени.

В Chrome это будет выглядеть примерно так:

test: 0.766845703125ms

В Firefox — так:

test: 2ms - timer ended

Собственно говоря, тут всё очень похоже на то, что мы видели, работая с performance.now().

Сильная сторона метода console.time() заключается в простоте его использования. А именно, речь идёт о том, что его применение не требует объявления вспомогательных переменных и нахождения разницы между записанными в них показателями.

Сниженная точность временных показателей


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

Причиной этого является то, что браузеры пытаются защитить пользователей от атак, основанных на анализе времени, и от механизмов идентификации браузеров (Browser Fingerprinting). Если результаты измерения времени окажутся слишком точными, это может дать злоумышленникам возможность, например, идентифицировать пользователей.

В Firefox 60, как уже было сказано, точность результатов измерения времени снижена. Это сделано с помощью установки значения свойства privacy.reduceTimerPrecision в значение 2 мс.

Кое-что, о чём стоит помнить, тестируя производительность


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

▍Разделяй и властвуй


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

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

Для того чтобы увидеть общую картину происходящего, сначала стоит, воспользовавшись console.time() и console.timeEnd(), оценить производительность блока кода, который, предположительно, плохо влияет на производительность. Затем надо посмотреть на скорость работы отдельных частей этого блока. Если одна из них выглядит заметно медленнее, чем другие, нужно уделить ей особое внимание и как следует её проанализировать.

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

▍Учитывайте особенности поведения функций при разных входных значениях


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

Функции при исследовании производительности нужно вызывать с входными данными, максимально напоминающими реальные.

▍Запускайте функции по много раз


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

Вот два варианта подобной функции:

function testForEach(x) {
  console.time('test-forEach');
  const res = [];
  x.forEach((value, index) => {
    res.push(value / 1.2 * 0.1);
  });

  console.timeEnd('test-forEach')
  return res;
}

function testFor(x) {
  console.time('test-for');
  const res = [];
  for (let i = 0; i < x.length; i ++) {
    res.push(x[i] / 1.2 * 0.1);
  }

  console.timeEnd('test-for')
  return res;
}

Протестируем функции:

const x = new Array(100000).fill(Math.random());
testForEach(x);
testFor(x);

После запуска кода мы получаем следующие результаты:

test-forEach: 27ms - timer ended
test-for: 3ms - timer ended

Похоже, цикл forEach оказался гораздо медленнее цикла for. Ведь результаты тестирования указывают именно на это?

На самом деле, после однократного испытания рано делать подобные выводы. Попробуем вызвать функции по два раза:

testForEach(x);
testForEach(x);
testFor(x);
testFor(x);

Получим следующее:

test-forEach: 13ms - timer ended
test-forEach: 2ms - timer ended
test-for: 1ms - timer ended
test-for: 3ms - timer ended

Получается, что функция, в которой используется forEach, вызванная второй раз, оказывается такой же быстрой, как и та, в которой применяется for. Но, учитывая то, что при первом вызове forEach-функция работает заметно медленнее, её, возможно, всё же использовать не стоит.

▍Тестируйте производительность в разных браузерах


Вышеприведённые тесты выполнялись в Firefox. А что если выполнить их в Chrome? Результаты будут совсем другими:

test-forEach: 6.156005859375ms
test-forEach: 8.01416015625ms
test-for: 4.371337890625ms
test-for: 4.31298828125ms

Дело в том, что браузеры Chrome и Firefox основаны на разных JavaScript-движках, в которых реализованы разные оптимизации производительности. Об этих различиях весьма полезно знать.

В данном случае в Firefox наблюдается лучшая оптимизация forEach при сходных входных данных. А цикл for оказывается быстрее чем forEach и в Chrome, и в Firefox. В результате, вероятно, лучше остановиться именно на варианте функции с for.

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

▍Применяйте искусственные ограничения системных ресурсов


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

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

При таком подходе 10 или 50 миллисекунд легко могут превратиться в 500.

▍Измеряйте относительную производительность


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

Итоги


В этом материале мы рассмотрели некоторые JavaScript-API, предназначенные для измерения производительности. Мы поговорили и о том, как использовать их для анализа реального кода. Я полагаю, что для того чтобы выполнить какие-то простые измерения, проще всего пользоваться console.time().

У меня есть такое ощущение, что многие фронтенд-разработчики не уделяют достаточно внимания измерению производительности своих проектов. А им стоило бы постоянно следить за соответствующими показателями, так как производительность влияет на успешность и прибыльность проектов.

Уважаемые читатели! Если вы постоянно контролируете производительность своих проектов, просим рассказать о том, как вы это делаете.

RUVDS.com
RUVDS – хостинг VDS/VPS серверов

Похожие публикации

Комментарии 8

    +3
    Проблема с бенчмаркингом js заключается в том, что ты никогда не знаешь, что именно оптимизировал компилятор — твой код или (гораздо более вероятно) комбинацию твоего кода и твоего бенчмарка. Так запуск функции в цикле и измерение производительности в лоб почти наверняка не сработает — много чего надо учесть для того, чтобы цикл не был оптимизирован до пары inline инструкций.
    Всем, кому хочется кровавых деталей, я советую во всех отношениях чудесный доклад Вячеслава Егорова — Производительность JavaScript через подзорную трубу
      +1

      Прекрасный доклад. Очень доходчиво и ясно объясняет, почему нельзя просто взять и сунуть код в цикл и измерить производительность. Хотя после доклада я совсем не знаю как можно более-менее точно сравнить производительность кроме как по временной сложности алгоритма.

        0
        Как я понял посыл лектора — да в общем-то никак.
          0
          Доклад очень интересный, смотрел его пару лет назад, но ведь глобально он ничего не опровергает.
          Практически все приведённые автором примеры жестоко-контринтуитивного поведения — это банально следствия багов в V8, которые давно уже исправлены.
          А там, где багов нет, поведение JIT более или менее предсказуемо. Да, есть много нюансов, как впрочем и везде. Но в целом предположения о перфомансе, основанные на классических теориях, обычно работают даже в JS.
            0
            Багов? Мне показалось, что не багов, а нормальных таких оптимизаций для случаев, когда бенчмаркинг слишком уж наивный.
              0
              Ну да, во всех приведённых докладчиком примерах, где творилась какая-то непостижимая магия — виноваты были баги компилятора.
              А там, где нормальные оптимизации — они более-менее предсказуемы и их более-менее возможно учесть. Если знать базовую теорию, конечно. Ну камон, удаление мертвого кода, развёртку циклов или распространение констант придумали лет 40 назад, если не больше.
          +1
          Проблема с бенчмаркингом js заключается в том, что ты никогда не знаешь, что именно оптимизировал компилятор — твой код или (гораздо более вероятно) комбинацию твоего кода и твоего бенчмарка

          Всегда можно совершенно точно узнать что наоптимизировал компилятор, достаточно посмотреть ассемблерный код в который компилируется js (через комманду node --print-opt-code --code-comments code.js) и там легко увидеть (по call инструкциям и комментариям) что заинлайнилось а что нет, либо есть инструмент для визуального отображения различных оптимизаций v8 — Turbolizer (оригинальный репозиторий тут а описание и онлайн-версию можно найти тут). А для того чтобы код бенчмарка не влиял на производительность тестируемого кода то можно не мерять производительность а просто прогнать оптимизации для конкретной функции и посмотреть на то что и как инлайнится и для этого просто нужную функцию вызываем через специальную встроенную глобальную функцию %OptimizeFunctionOnNextCall(fn) (которая будет доступна после запуска ноды с флагом node --allow-natives-syntax code.js)


          Ну а после того как вы убедились что нужный код заинлайнен в инструкции ассемблера (а иначе он наверняка будет работать медленно так как v8 на текущий момент научился инлайнить очень многое — не только различные циклы, но и методы работы с массивами — всякие .map(), .filter(), .find(), а также создание объектов и функций) то дальше можно совершенно точно (вплоть до тактов) предсказать время работы кода — достаточно понять как работают кеши процессора (https://www.youtube.com/watch?v=JU_RAcsfQVs), немного про то как работает виртуальная память (https://www.youtube.com/watch?v=dFquxC6qTSA) ну и конечно же дока по процессору (https://software.intel.com/en-us/articles/intel-sdm) где с точностью до тактов расписано время работы каждой ассемблерной инструкции

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

          Рискну прослыть капитаном — но выяснять, какая часть кода работает медленно, нужно с помощью профайлера. Иначе — удачи с добавлением time / timeEnd по всей кодовой базе (и да, все равно тормоза рендеринга, который зачастую инициируется фрейворком, а не вашим кодом, не будут видны таким образом)

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое