Статьи

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

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

Предположим, что у вас есть рабочая функция, но вы подозреваете, что она не такая быстрая, как могла бы, и у вас есть план по ее улучшению. Как вы докажете это предположение? Какова лучшая практика для тестирования производительности функций JavaScript сегодня? Как правило, лучший способ решить эту задачу — использовать встроенную функцию performance.now() и измерять время до и после выполнения вашей функции.

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

Performance.now ()

API времени высокого разрешения предлагает функцию с именем now() которая возвращает объект DOMHighResTimeStamp . Это число с плавающей запятой, которое отражает текущее время в миллисекундах с точностью до тысячной доли миллисекунды . Число по отдельности не добавляет значимости вашему анализу, но разница между двумя такими числами дает точное описание того, сколько времени прошло.

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

Математическое определение «монотонности» (функции или величины) меняется таким образом, что оно никогда не уменьшается или никогда не увеличивается .

Другой способ объяснить это, пытаясь представить, что он используется в те времена года, когда часы идут вперед или назад. Например, когда часы в вашей стране все соглашаются пропустить час ради максимального солнечного света. Если бы вам нужно было создать экземпляр Date до того, как часы вернутся на час назад, а другой экземпляр Date после этого, глядя на разницу, он сказал бы что-то вроде «1 час и 3 секунды и 123 миллисекунды». С двумя экземплярами performance.now() разница будет «3 секунды, 123 миллисекунды и 456789 тысяч миллисекунд».

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

Теперь, когда вы знаете, что такое API высокого разрешения и как его использовать, давайте углубимся в некоторые потенциальные подводные камни. Но перед этим давайте определим функцию makeHash() которую мы будем использовать до конца статьи.

 function makeHash(source) { var hash = 0; if (source.length === 0) return hash; for (var i = 0; i < source.length; i++) { var char = source.charCodeAt(i); hash = ((hash<<5)-hash)+char; hash = hash & hash; // Convert to 32bit integer } return hash; } 

Выполнение такой функции можно измерить, как показано ниже:

 var t0 = performance.now(); var result = makeHash('Peter'); var t1 = performance.now(); console.log('Took', (t1 - t0).toFixed(4), 'milliseconds to generate:', result); 

Если вы запустите этот код в браузере, вы должны увидеть что-то вроде этого:

 Took 0.2730 milliseconds to generate: 77005292 

Демонстрационная версия этого кода показана ниже:

Имея в виду этот пример, давайте начнем наше обсуждение.

Подводный камень # 1 — Случайное измерение неважных вещей

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

 var t0 = performance.now(); console.log(makeHash('Peter')); // bad idea! var t1 = performance.now(); console.log('Took', (t1 - t0).toFixed(4), 'milliseconds'); 

Демонстрационная версия этого фрагмента показана ниже:

Но в этом случае мы будем измерять время, makeHash('Peter') для вызова функции makeHash('Peter') и время, необходимое для отправки и печати этого вывода на консоли. Мы не знаем, сколько времени заняла каждая из этих двух операций. Вы знаете только объединенное время. Кроме того, время, необходимое для отправки и печати выходных данных, будет сильно различаться в зависимости от браузера и даже от того, что в нем происходит в то время.

Возможно, вы прекрасно понимаете, что console.log непредсказуемо медленный. Но было бы в равной степени неправильно выполнять более одной функции, даже если каждая функция не требует ввода-вывода. Например:

 var t0 = performance.now(); var name = 'Peter'; var result = makeHash(name.toLowerCase()).toString(); var t1 = performance.now(); console.log('Took', (t1 - t0).toFixed(4), 'milliseconds to generate:', result); 

Опять же, мы не будем знать, как было распределено время выполнения. Было ли это присваивание переменной, toLowerCase() или вызов toString() ?

Ловушка № 2 — Измерение только один раз

Другая распространенная ошибка — сделать одно измерение, суммировать время и сделать выводы на основе этого. Скорее всего, в разное время все будет по-другому. Время выполнения во многом зависит от различных факторов:

  • Время прогрева компилятора (например, время компиляции кода в байт-код)
  • Основная нить, занятая другими вещами, которые мы не осознавали, продолжалась
  • Процессоры вашего компьютера заняты чем-то, что замедляет весь ваш браузер

Инкрементальное улучшение заключается в повторном выполнении функции, например:

 var t0 = performance.now(); for (var i = 0; i < 10; i++) { makeHash('Peter'); } var t1 = performance.now(); console.log('Took', ((t1 - t0) / 10).toFixed(4), 'milliseconds to generate'); 

Демонстрационная версия этого примера показана ниже:

Риск при таком подходе состоит в том, что механизм JavaScript нашего браузера может выполнять субоптимизацию, что означает, что при повторном вызове функции с одним и тем же вводом может быть полезно запомнить первый вывод и просто использовать его снова. Чтобы решить эту проблему, вы можете использовать много разных строк ввода вместо того, чтобы повторно отправлять одну и ту же строку ввода (например, 'Peter' ). Очевидно, что проблема с тестированием с разными входами заключается в том, что измеряемая нами функция занимает разное время. Возможно, некоторые из входов вызывают больше времени выполнения, чем другие.

Подводный камень № 3 — слишком много полагаясь на среднего

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

 Took 0.2730 milliseconds to generate: 77005292 Took 0.0234 milliseconds to generate: 77005292 Took 0.0200 milliseconds to generate: 77005292 Took 0.0281 milliseconds to generate: 77005292 Took 0.0162 milliseconds to generate: 77005292 Took 0.0245 milliseconds to generate: 77005292 Took 0.0677 milliseconds to generate: 77005292 Took 0.0289 milliseconds to generate: 77005292 Took 0.0240 milliseconds to generate: 77005292 Took 0.0311 milliseconds to generate: 77005292 

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

Одним из способов является вычисление среднего за последние девять раз. Другой, более практичный способ — собрать все результаты и рассчитать медиану . По сути, все результаты выстроены в ряд, отсортированы по порядку и выбраны по среднему. Вот где performance.now() так полезен, потому что вы получаете число, с которым можете делать все, что угодно.

Давайте попробуем еще раз, но на этот раз мы будем использовать медианную функцию:

 var numbers = []; for (var i=0; i < 10; i++) { var t0 = performance.now(); makeHash('Peter'); var t1 = performance.now(); numbers.push(t1 - t0); } function median(sequence) { sequence.sort(); // note that direction doesn't matter return sequence[Math.ceil(sequence.length / 2)]; } console.log('Median time', median(numbers).toFixed(4), 'milliseconds'); 

Подводный камень # 4 — Сравнение функций в предсказуемом порядке

Мы поняли, что всегда полезно измерять что-то много раз и брать среднее значение. Более того, последний пример научил нас, что лучше использовать медиану, а не среднюю.

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

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

 function isIn(haystack, needle) { var found = false; haystack.forEach(function(element) { if (element.toLowerCase() === needle.toLowerCase()) { found = true; } }); return found; } console.log(isIn(['a','b','c'], 'B')); // true console.log(isIn(['a','b','c'], 'd')); // false 

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

 function isIn(haystack, needle) { for (var i = 0, len = haystack.length; i < len; i++) { if (haystack[i].toLowerCase() === needle.toLowerCase()) { return true; } } return false; } console.log(isIn(['a','b','c'], 'B')); // true console.log(isIn(['a','b','c'], 'd')); // false 

Теперь посмотрим, какой из них самый быстрый. Мы делаем это, выполняя каждую функцию 10 раз и собирая все измерения:

 function isIn1(haystack, needle) { var found = false; haystack.forEach(function(element) { if (element.toLowerCase() === needle.toLowerCase()) { found = true; } }); return found; } function isIn2(haystack, needle) { for (var i = 0, len = haystack.length; i < len; i++) { if (haystack[i].toLowerCase() === needle.toLowerCase()) { return true; } } return false; } console.log(isIn1(['a','b','c'], 'B')); // true console.log(isIn1(['a','b','c'], 'd')); // false console.log(isIn2(['a','b','c'], 'B')); // true console.log(isIn2(['a','b','c'], 'd')); // false function median(sequence) { sequence.sort(); // note that direction doesn't matter return sequence[Math.ceil(sequence.length / 2)]; } function measureFunction(func) { var letters = 'a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z'.split(','); var numbers = []; for (var i = 0; i < letters.length; i++) { var t0 = performance.now(); func(letters, letters[i]); var t1 = performance.now(); numbers.push(t1 - t0); } console.log(func.name, 'took', median(numbers).toFixed(4)); } measureFunction(isIn1); measureFunction(isIn2); 

Мы запускаем это и получаем следующий вывод:

 true false true false isIn1 took 0.0050 isIn2 took 0.0150 

Демонстрационная версия этого примера показана ниже:

Что, черт возьми, только что произошло? Первая функция была в три раза быстрее . Этого не должно было случиться!

Объяснение простое, но тонкое. Первая функция, которая использует haystack.forEach извлекает выгоду из некоторых низкоуровневых оптимизаций в движке JavaScript браузера, которые мы не получаем, когда используем технику индекса массива. Это доказывает нашу точку зрения: никогда не узнаешь, пока не измеришь!

Выводы

В нашей попытке продемонстрировать, как использовать performance.now() для получения точного времени выполнения в JavaScript, мы наткнулись на сценарий сравнительного анализа, где наша интуиция оказалась совершенно противоположной тому, к чему пришли наши эмпирические результаты. Дело в том, что если вы хотите писать более быстрые веб-приложения, ваш код JavaScript должен быть оптимизирован. Поскольку компьютеры — (почти) живые, дышащие вещи, они непредсказуемы и удивительны. Самый надежный способ узнать, что наши улучшения кода дают более быстрое выполнение, — это измерить и сравнить.

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

Приведенный выше список не является исчерпывающим, поскольку есть и другие подводные камни, о которых следует знать. Например, измерение нереальных сценариев или измерение только на одном движке JavaScript. Но несомненно то, что большим преимуществом для разработчиков JavaScript, которые хотят писать более быстрые и качественные веб-приложения, является performance.now() . И последнее, но не менее важное: помните, что измерение времени выполнения дает только одно измерение «лучшего кода». Также необходимо учитывать сложность памяти и кода.

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