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

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

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

Ошибка № 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');

Демо: 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().

Ошибка № 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');

Демо: codepen.io/SitePoint/pen/Qbezpj

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

Ошибка № 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

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

Например, можно исключить первое значение и вычислить среднеарифметическое из остальных девяти. Но лучше взять все результаты и вычислить медиану. Результаты сортируются по порядку, и выбирается средний. Вот где 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.

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() вам не найти. Однако измерение времени выполнения — лишь один аспект. На производительность влияют также использование памяти и сложность кода.

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


  1. dom1n1k
    02.12.2015 16:40

    А console.time хуже?


    1. AloneCoder
      02.12.2015 16:47

      Не хуже, но если важна точность измерений, то нужно использовать performance.now()
      Почитать про точность временных функций можно в этой статье


  1. arusakov
    02.12.2015 17:20
    +9

    Статья требует апдейта из комментариев к оригиналу.
    Во-первых, в последнем примере sort() без параметром сортирует числа как строки, что может привести к абсолютно неверному нахождению медианы.
    Во-вторых, isIn2 быстрее, чем isIn1, а не наоборот. Почему ошибся автор? Он запускал код в CodePen, который в каждый цикл внедряет специальный код, не позволящий уйти в бесконечность. Так что мир не перевернулся, не пугайтесь: цикл for быстрее, чем forEach.
    Так что лучший способ сделать свой код быстрее — подумать головой, измерить и подумать еще раз)


    1. dom1n1k
      02.12.2015 20:46

      В моих собственных тестах (локально, без codepen) цикл forEach частенько оказывается быстрее. Не всегда, но частенько.


      1. IncorrecTSW
        02.12.2015 21:39
        +3

        По примеру из статьи.

        лиса и хром:
        isIn1 took 0.0050
        isIn2 took 0.0000

        ИЕ:
        isIn1 took 0.0088
        isIn2 took 0.0040

        нода (через process.hrtime())
        isIn1 took 0.0060
        isIn2 took 0.0026

        Статья вводит в заблуждение.


        1. taliban
          03.12.2015 15:55

          image
          Статья говорит:

          лучший способ сделать свой код быстрее — измерить и сравнить

          Так что, чтоб знать наверняка, нужно взять и сравнить, а не писать что статья неправильная и врет.


          1. IncorrecTSW
            03.12.2015 16:18

            Так что, чтоб знать наверняка, нужно взять и сравнить

            Именно это и было сделано. Не однократно и на разных конфигах.
            Ваш скриншот вызывает недоумение и желание узнать в чем и на чем это было сделано.


            1. IncorrecTSW
              03.12.2015 16:24

              Если запускалось в codepen то вам нужно прочитать комментарий arusakov

              К слову это то что выполняет codepen
              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 (window.CP.shouldStopExecution(1)) {
                          break;
                      }
                      if (haystack[i].toLowerCase() === needle.toLowerCase()) {
                          return true;
                      }
                  }
                  return false;
                  window.CP.exitedLoop(1);
              }
              console.log(isIn1([
                  'a',
                  'b',
                  'c'
              ], 'B'));
              console.log(isIn1([
                  'a',
                  'b',
                  'c'
              ], 'd'));
              console.log(isIn2([
                  'a',
                  'b',
                  'c'
              ], 'B'));
              console.log(isIn2([
                  'a',
                  'b',
                  'c'
              ], 'd'));
              function median(sequence) {
                  sequence.sort();
                  return sequence[Math.ceil(sequence.length / 2)];
              }
              function measureFunction(f) {
                  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++) {
                      if (window.CP.shouldStopExecution(2)) {
                          break;
                      }
                      var t0 = performance.now();
                      f(letters, letters[i]);
                      var t1 = performance.now();
                      numbers.push(t1 - t0);
                  }
                  window.CP.exitedLoop(2);
                  console.log(f.name, 'took', median(numbers).toFixed(4));
              }
              measureFunction(isIn1);
              measureFunction(isIn2);
              


            1. taliban
              03.12.2015 23:27

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


              1. IncorrecTSW
                03.12.2015 23:44

                В комментарии выше ясно видно как codepen изменил код. Как следствие все тесты в нем ложные.


          1. arusakov
            03.12.2015 16:21
            +1

            Так статья врет. И самое ужасное то, что кто-то действительно прочитает ее, не будет читать комментарии здесь или к оригиналу и пойдет по своему коду изменять for на forEach с твердой уверенностью, что все делает правильно.


  1. gro
    02.12.2015 17:37

    Разве Date не в юникс-таймстампе время держит, которое тоже от перевода на летнее время не меняется?


    1. taliban
      03.12.2015 16:00

      Date содержит инфо не в таймстампе, его можно выводить в таймстамп, но так как Date содержит в себе еще и смещение зоны, то оно может показывать не очевидный вариант


  1. g1t5
    02.12.2015 18:53

    По мне так профилировщик (если он есть) куда больше подходит для анализа производительности и поиска узких мест.
    А вот скажем для сбора статистики от конечных пользователей вполне себе интересный вариант.


  1. Vest
    03.12.2015 18:29

    Возможно, читателям пригодится интересный ресурс для сравнения скорости алгоритмов: jsPerf — JavaScript performance playground.