Советы по производительности JavaScript в V8

Крис Уилсон
Chris Wilson

Введение

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

Самый важный совет

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

Лучший базовый совет для достижения хорошей производительности веб-приложений:

  • Будьте готовы до того, как у вас возникнет (или заметится) проблема
  • Затем определите и поймите суть вашей проблемы.
  • Наконец, исправьте то, что важно

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

Итак, переходим к советам по V8!

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

JavaScript имеет ограниченную информацию о типах во время компиляции: типы могут быть изменены во время выполнения, поэтому естественно ожидать, что рассуждать о типах JS во время компиляции будет затратно. Это может заставить вас задаться вопросом, как производительность JavaScript может когда-либо приблизиться к C++. Однако в V8 есть скрытые типы, созданные внутри объектов во время выполнения; объекты с одним и тем же скрытым классом могут затем использовать один и тот же оптимизированный сгенерированный код.

Например:

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

var p1 = new Point(11, 22);
var p2 = new Point(33, 44);
// At this point, p1 and p2 have a shared hidden class
p2.z = 55;
// warning! p1 and p2 now have different hidden classes!```

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

Поэтому

  • Инициализируйте все члены объекта в функциях-конструкторах (чтобы экземпляры не меняли тип позже)
  • Всегда инициализируйте члены объекта в одном и том же порядке.

Числа

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

Например:

var i = 42;  // this is a 31-bit signed integer
var j = 4.2;  // this is a double-precision floating point number```

Поэтому

  • Предпочитайте числовые значения, которые могут быть представлены как 31-битные целые числа со знаком.

Массивы

Для обработки больших и разреженных массивов существует два типа внутреннего хранилища массивов:

  • Fast Elements: линейное хранилище для компактных наборов ключей
  • Элементы словаря: хранение хэш-таблицы в противном случае

Лучше не переключать хранилище массива с одного типа на другой.

Поэтому

  • Используйте смежные ключи, начиная с 0, для массивов.
  • Не выделяйте заранее большие массивы (например, > 64 КБ элементов) до максимального размера, вместо этого увеличивайте их по мере продвижения.
  • Не удаляйте элементы в массивах, особенно числовых массивах.
  • Не загружайте неинициализированные или удаленные элементы:
for (var b = 0; b < 10; b++) {
  a[0] |= b;  // Oh no!
}
//vs.
a = new Array();
a[0] = 0;
for (var b = 0; b < 10; b++) {
  a[0] |= b;  // Much better! 2x faster.
}

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

var a = new Array();
a[0] = 77;   // Allocates
a[1] = 88;
a[2] = 0.5;   // Allocates, converts
a[3] = true; // Allocates, converts```

менее эффективен, чем:

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

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

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

JavaScript-компиляция

Хотя JavaScript — очень динамичный язык, и первоначальные его реализации представляли собой интерпретаторы, современные механизмы выполнения JavaScript используют компиляцию. Фактически, V8 (JavaScript Chrome) имеет два разных компилятора Just-In-Time (JIT):

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

Полный компилятор

В V8 полный компилятор работает со всем кодом и начинает его выполнять как можно скорее, быстро генерируя хороший, но не очень хороший код. Этот компилятор почти ничего не предполагает о типах во время компиляции — он ожидает, что типы переменных могут и будут меняться во время выполнения. Код, сгенерированный полным компилятором, использует встроенные кэши (IC) для уточнения знаний о типах во время работы программы, повышая эффективность на лету.

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

Поэтому

  • Мономорфное использование операций предпочтительнее полиморфных операций.

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

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

add(1, 2);      // + in add is monomorphic
add("a", "b");  // + in add becomes polymorphic```

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

Параллельно с полным компилятором V8 перекомпилирует «горячие» функции (то есть функции, которые выполняются много раз) с помощью оптимизирующего компилятора. Этот компилятор использует обратную связь по типам, чтобы ускорить скомпилированный код — фактически, он использует типы, взятые из микросхем, о которых мы только что говорили!

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

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

d8 --trace-opt primes.js

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

Однако не все функции могут быть оптимизированы - некоторые функции не позволяют оптимизирующему компилятору запускать данную функцию («спасение»). В частности, оптимизирующий компилятор в настоящее время отказывается от функций с блоками try {} catch {}!

Поэтому

  • Поместите код, чувствительный к производительности, во вложенную функцию, если у вас есть блоки try {} catch {}: ```js function perf_sensitivity() { // Здесь выполняется работа, чувствительная к производительности }

try { perf_sensitivity() } catch (e) { // Здесь обрабатываем исключения } ```

Это руководство, вероятно, изменится в будущем, поскольку мы включим блоки try/catch в оптимизирующем компиляторе. Вы можете проверить, как оптимизирующий компилятор освобождает функции, используя опцию «--trace-opt» с d8, как указано выше, которая дает вам больше информации о том, какие функции были освобождены:

d8 --trace-opt primes.js

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

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

Поэтому

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

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

d8 --trace-deopt primes.js

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

Кстати, вы также можете передать параметры трассировки V8 в Chrome при запуске:

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

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

% out/ia32.release/d8 primes.js --prof

При этом используется встроенный профилировщик выборки, который делает выборку каждую миллисекунду и записывает v8.log.

В итоге

Чтобы подготовиться к созданию высокопроизводительного JavaScript, важно определить и понять, как движок V8 работает с вашим кодом. Еще раз основной совет:

  • Будьте готовы до того, как у вас возникнет (или заметится) проблема
  • Затем определите и поймите суть вашей проблемы.
  • Наконец, исправьте то, что важно

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

Рекомендации