Оптимизируем производительность JavaScript для V8

в 11:39, , рубрики: Google Chrome, javascript, node.js, оптимизация кода, производительность javascript

Предисловие

Дэниел Клиффорд сделал на Google I/O прекрасный доклад, посвященный особенностям оптимизации кода JavaSсript для движка V8. Дэниел призвал нас стремиться к большей скорости, тщательно анализировать отличия между С++ и JavaScript, и писать код, помня о том, как работает интерпретатор. Я собрал в этой статье резюме самых главных моментов выступления Дэниела, и буду обновлять её по мере того, как движок будет меняться.

Самый главный совет

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

Лучшая стратегия, ведущая к созданию быстрого веб-приложения выглядит так:

  • Продумайте всё заранее, до того как столкнётесь с проблемами.
  • Тщательно разберитесь и проникните в суть проблемы.
  • Исправляйте только то, что имеет значение.

Чтобы придерживаться этой стратегии, важно понимать, как V8 оптимизирует JS, представлять, как всё происходит во время выполнения. Так же важно владеть правильными инструментами. В своём выступлении Дэниел посвятил больше времени инструментам разработчика; в этой статье я в основном рассматриваю особенности архитектуры V8.

Итак, приступим.

Скрытые классы

На этапе компиляции информация о типах в JavaScript очень ограничена: во время исполнения типы могут меняться, так что вполне естественно ожидать, что при компиляции трудно делать предположения о них. Возникает вопрос — как в таких условиях можно хотя бы приблизиться к скорости С++? Тем не менее, V8 ухитряется создавать скрытые классы для объектов во время выполнения. Объекты, имеющие один и тот же класс, разделяют один и тот же оптимизированный код.

Например:

function Point(x, y) {
  this.x = x;
  this.y = y;
}

var p1 = new Point(11, 22);
var p2 = new Point(33, 44);
// В этой точке p1 и p2 относятся к одному и тому же скрытому классу
p2.z = 55;
// Внимание! Здесь p1 и p2 становятся экземплярами разных классов!

Пока к p2 не было добавлено свойство ".z", p1 и p2 внутри компилятора имели один и тот же скрытый класс, и V8 мог использовать один и тот же оптимизированный машинный код для обоих объектов. Чем реже вы будете менять скрытый класс, тем лучше будет производительность.

Выводы:

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

Числа

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

Например:

var i = 42;  // это 31-битное целое со знаком
var j = 4.2;  // а это число двойной точности с плавающей запятой

Выводы:

  • Старайтесь использовать 31-битные целые со знаком везде, где это возможно.

Массивы

V8 использует два вида внутреннего представления массивов:

  • Настоящие массивы для компактных последовательных наборов ключей.
  • Хэш-таблицы в остальных случаях.

Выводы:

  • Не стоит заставлять массивы перепрыгивать из одной категории в другую.
  • Используйте непрерывную нумерацию индексов, начиная с 0 (в точности как в С).
  • Не заполняйте предварительно большие массивы (содержащие больше 64K элементов) — это ничего не даст.
  • Не удаляйте элементы из массивов (особенно числовых).
  • Не обращайтесь к неинициализированным или удалённым элементам. Пример:
    a = new Array();
    for (var b = 0; b < 10; b++) {
      a[0] |= b;  // Ни в коем случае!
    }
    //vs.
    a = new Array();
    a[0] = 0;
    for (var b = 0; b < 10; b++) {
      a[0] |= b;  // Вот так гораздо лучше! Быстрее в два раза.
    }
    

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

    var a = new Array();
    a[0] = 77;   // Выделение памяти
    a[1] = 88;
    a[2] = 0.5;  // Выделение памяти, распаковка
    a[3] = true; // Выделение памяти, упаковка
    

    Гораздо быстрее будет так:

    var a = [77, 88, 0.5, true];
    

    В первом примере индивидуальные присваивания происходят последовательно, и в тот момент, когда a[2] получает значение, компилятор преобразует a в массив распакованных чисел с двойной точностью, а когда a[3] инициализируется нечисловым элементом, происходит обратное преобразование. Во втором примере компилятор сразу выберет нужный тип массива.

Таким образом:

  • Маленькие фиксированные массивы лучше всего инициализировать, используя литерал массива.
  • Заполняйте маленькие массивы (<64К) перед использованием.
  • Не храните нечисловые значения в числовых массивах.
  • Старайтесь избегать преобразований при инициализации не через литералы.

Компиляция JavaScript

Хотя JavaScript — динамический язык, и изначально он интерпретировался, все современные движки на самом деле являются компиляторами. В V8 работают сразу два компилятора:

  • Базовый компилятор, который генерирует код для всего скрипта.
  • Оптимизирующий компилятор, который генерирует очень быстрый код для самых «горячих» участков. Такая компиляция занимает больше времени.

Базовый компилятор

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

Выводы:

  • Предпочитайте мономорфные операторы полиморфным.

Оператор является мономорфным, если скрытый тип операндов всегда одинаков, и полиморфным, если он может меняться. Например, второй вызов add() делает код полиморфным:

function add(x, y) {
  return x + y;
}

add(1, 2);      // + внутри add() мономорфен
add("a", "b");  // + внутри add() становится полиморфным

Оптимизирующий компилятор

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

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

Вы можете посмотреть, что именно оптимизируется в вашем коде, используя автономную версию движка d8:

d8 --trace-opt primes.js

(имена оптимизированных функций будут выведены в stdout)

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

Выводы:

Если необходимо использовать блок try/catch, помещайте критичный к производительности код снаружи. Пример:

function perf_sensitive() {
  // Критичный к скорости код
}

try {
  perf_sensitive()
} catch (e) {
  // Обрабатываем исключения здесь
}

Возможно, в будущем ситуация изменится, и мы сможем компилировать блоки try/catch оптимизирующим компилятором. Вы можете посмотреть, какие именно функции игнорируются, указав опцию --trace-bailout при запуске d8:

d8 --trace-bailout primes.js

Деоптимизация

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

Выводы:

  • Избегайте изменения скрытых классов в оптимизированных функциях.

Вы можете посмотреть, какие именно функции подвергаются деоптимизации, запустив d8 с опцией --trace-deopt:

d8 --trace-deopt primes.js

Другие инструменты V8

Перечисленные выше функции могут быть переданы Google Chrome при запуске:

/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --js-flags="--trace-opt --trace-deopt --trace-bailout

В d8 тоже есть профилировщик:

d8 primes.js --prof

Сэмплирующий профилировщик d8 делает снимки каждую миллисекунду и пишет в v8.log.

Резюме

Важно понимать, как устроен движок V8, чтобы писать хорошо оптимизированный код. И не забывайте об общих принципах, описанных в начале статьи:

  • Продумайте всё заранее, до того как столкнётесь с проблемами.
  • Тщательно разберитесь и проникните в суть проблемы.
  • Исправляйте только то, что имеет значение.

Это значит, что вы должны убедиться, что дело именно в JavaScript, с помощью таких инструментов, как PageSpeed. Возможно стоит избавиться от обращений к DOM, прежде чем искать узкие места. Надеюсь, что выступление Дэниела (и эта статья) поможет вам лучше понять работу V8, но не забывайте, что часто полезнее оптимизировать алгоритм программы, а не подстраиваться под конкретный движок.

Ссылки:

Автор: ilya42

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js