Предисловие
Дэниел Клиффорд сделал на 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