[Перевод] Измерение производительности функций в JavaScript
Производительность всегда играла ключевую роль в программном обеспечении. А в веб-приложениях её значение ещё выше, поскольку пользователи легко могут пойти к конкурентам, если сделанный вами сайт работает медленно. Любой профессиональный веб-разработчик должен об этом помнить. Сегодня по-прежнему можно успешно применять массу старых приёмов оптимизации производительности, вроде минимизации количества запросов, использования CDN и не использования для рендеринга блокирующего кода. Но чем больше разработчики применяют JavaScript, тем важнее становится задача оптимизации его кода.
Вероятно, у вас есть определённые подозрения относительно производительности часто используемых вами функций. Возможно, вы даже прикинули, как можно улучшить ситуацию. Но как вы измерите прирост производительности? Как можно точно и быстро протестировать производительность функций в JavaScript? Идеальный вариант — использовать встроенную функцию performance.now()
и измерять время до и после выполнения ваших функций. Здесь мы рассмотрим, как это делается, а также разберём ряд подводных камней.
В High Resolution Time API есть функция now()
, возвращающая объект DOMHighResTimeStamp. Это число с плавающей точкой, отражающее текущее время в миллисекундах, с точностью до тысячной миллисекунды. Само по себе это число имеет для нас мало ценности, но разница между двумя измеренными значениями описывает, сколько прошло времени.
Помимо того что данный инструмент точнее, чем встроенный объект Date
, он ещё и «монотонный». Если по-простому: на него не влияет коррекция системного времени. То есть, создав две копии Date
и вычислив между ними разницу, мы не получим точного, репрезентативного представления о том, сколько прошло времени.
С точки зрения математики монотонная функция либо только возрастает, либо только убывает. Другой пример, для лучшего понимания: переход на летнее или зимнее время, когда все часы в стране переводятся на час назад или час вперёд. Если мы сравним значения двух копий Date
— до и после перевода часов, то получим, например, разницу »1 час 3 секунды и 123 миллисекунды». А при использовании двух копий performance.now()
— »3 секунды 123 миллисекунды 456 789 тысячных миллисекунды». Не будем здесь подробно разбирать этот API, желающие могут обратиться к статье Discovering the High Resolution Time API.
Итак, теперь мы знаем, что такое High Resolution Time 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
Демо: codepen.io/SitePoint/pen/YXmdNJ
В приведённом выше примере вы могли заметить, что между двумя 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');
Демо: codepen.io/SitePoint/pen/PqMXWv
Но в этом случае мы бы измеряли, сколько времени занял вызов функции 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()
.
Многие проводят лишь одно измерение, складывают общее время и делают далеко идущие выводы. Но ситуация каждый раз может меняться, ведь скорость выполнения сильно зависит от таких факторов, как:
- время компиляции кода в байт-код (время «прогрева» компилятора),
- занятость главного процесса выполнением других задач,
- загруженность ЦПУ чем-то, из-за чего тормозит весь браузер.
Поэтому лучше выполнять не одно измерение, а несколько:
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');
Демо: codepen.io/SitePoint/pen/Qbezpj
Риск данного подхода заключается в том, что браузерный JavaScript-движок может выполнять субоптимизацию, т. е. во второй раз функция будет вызвана с теми же входными данными, которые будут запомнены и использованы в дальнейшем. Чтобы это обойти, можно использовать много разных входных строк вместо того, чтобы раз за разом брать одно и то же значение. Однако при разных входных данных и скорость выполнения функции раз от раза может отличаться.
Итак, целесообразно делать серию измерений, чтобы точнее оценить производительность той или иной функции. Но как определить производительность функции, если при разных входных данных она выполняется с разной скоростью? Давайте сначала поэкспериментируем и измерим время выполнения десять раз с одними и теми же входными данными. Результаты будут выглядеть примерно так:
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
Обратите внимание, насколько самое первое значение отличается от остальных. Скорее всего, причина как раз в проведении субоптимизации и в необходимости «прогрева» компилятора. Мало что можно сделать, чтобы этого избежать, но зато можно обезопасить себе от неверных заключений.
Например, можно исключить первое значение и вычислить среднеарифметическое из остальных девяти. Но лучше взять все результаты и вычислить медиану. Результаты сортируются по порядку, и выбирается средний. Вот где 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');
Теперь мы знаем, что всегда лучше делать несколько измерений и брать среднее. Более того, последний пример говорит о том, что в идеале нужно вместо среднего брать медиану.
Измерение времени выполнения хорошо использовать для выбора наиболее быстрой функции. Допустим, у нас есть две функции, использующие одинаковые входные данные и выдающие одинаковые результаты, но работающие по-разному. Скажем, нам нужно выбрать функцию, которая возвращает true или false, если находит в массиве определённую строку, при этом независимо от регистра. В этом случае мы не можем использовать 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
Теперь посмотрим, какой вариант быстрее. Выполним каждую функцию по десять раз и вычислим «правильные» результаты:
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
Демо: codepen.io/SitePoint/pen/YXmdZJ
Как это понимать? Первая функция оказалась в три раза быстрее. Этого просто не может быть! Объяснение простое, но не очевидное. Первая функция, использующая haystack.forEach
, выигрывает за счёт низкоуровневой оптимизации на уровне браузерного JS-движка, которая не делается при использовании индекса массива. Так что пока не измерите, не узнаете!
Пытаясь продемонстрировать точность измерения производительности в JavaScript с помощью performance.now()
, мы обнаружили, что наша интуиция может подвести нас: эмпирические данные совершенно не совпали с нашими предположениями. Если вы хотите писать быстрые веб-приложения, то JS-код необходимо оптимизировать. А поскольку компьютеры практически живые существа, то они ещё способны быть непредсказуемыми и удивлять нас. Так что лучший способ сделать свой код быстрее — измерить и сравнить.
Ещё одна причина, почему мы не можем знать заранее, какой вариант будет быстрее, заключается в том, что всё зависит от ситуации. В последнем примере мы искали совпадение среди 26 значений вне зависимости от регистра. Но если мы будем искать среди 100 000 значений, то выбор функции может оказаться иным.
Рассмотренные ошибки — не единственные возможные. К ним можно добавить, например, измерение нереалистичных сценариев или измерение только на одном JS-движке. Но важно запомнить главное: если вы хотите создавать быстрые веб-приложения, то инструмента лучше performance.now()
вам не найти. Однако измерение времени выполнения — лишь один аспект. На производительность влияют также использование памяти и сложность кода.