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

Chris Wilson
Chris Wilson

Введение

Дэниел Клиффорд (Daniel Clifford) выступил на конференции 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. Чем меньше вы будете допускать расхождения скрытых классов, тем выше будет производительность.

Поэтому

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

iWork Numbers

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) есть два разных JIT-компилятора:

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

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

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

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

Поэтому

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

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

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

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

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

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

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

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

d8 --trace-opt primes.js

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

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

Поэтому

  • Если вы используете блоки try {} catch {}, поместите код, чувствительный к производительности, во вложенную функцию: ```js function perf_sensitive() { // Do performance-sensitive work here }

try { perf_sensitive() } catch (e) { // Handle exceptions here } ```

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

Ссылки