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

Original author: Chris Wilson
  • Translation
  • Tutorial

Предисловие


Дэниел Клиффорд сделал на 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, но не забывайте, что часто полезнее оптимизировать алгоритм программы, а не подстраиваться под конкретный движок.

Ссылки:


Share post

Comments 62

    0
    Я правильно понимаю, что, теоретически, это может дать прирост и в интерпретаторах других браузеров?
      +11
      И практически. Весь этот текст можно сжать до фразы «Пишем как можно более типизированно» (типизированные массивы, не перегруженные функции, статический набор полей у объектов).
      Тот же TypeScript кроме сахаров помогает писать быстрый код за счет статической проверки типов(именно помогает, а не делает).
        +1
        Почему JavaScript изначально такой? Как хорошо было бы, если бы он был типизированным.
          0
          Так исторически сложилось. (с)
          • UFO just landed and posted this here
              0
              Есть Dart…
                0
                Он есть, но его и нет. Точнее он пока в стадии «Посмотреть и поиграться», а JavaScript есть уже сейчас и с ним надо что-то делать.
                  0
                  Ну как, всё, кроме HTTP-клиента/сервера и сокетов для нативной части реализовали. Библиотека обширная, интероп с JS умеет. Так что можно использовать.
                    0
                    К сожалению, это только хром. Трансляции в JS он, конечно, поддается, но с костылями. Например, попробуйте транслировать генераторы в JS кроме них есть еще много других примеров. Dart язык очень хороший, но распостраненность — это его огромная проблема (которая в ближейшие лет 5 точно не решится).
                      0
                      как это только хром? постоянно гоняем тесты dart2js на FF, Safari, IE: build.chromium.org/p/client.dart/console
                        0
                        dart2js конечно хорошо, но в Dart есть такие штуки не свойственные для JavaScript и dart2js делает их костылями. Нет генераторов, нет Image, нет декларативных классов, нет честных изолятов (изоляты воркерах не могут трогать DOM). Ты же сам прекрасно знаешь их фундаментальные различия :)

                        Dart это очень хорошо, но я бы пока не стал его использовать в продакшене.
                        0
                        > К сожалению, это только хром. Трансляции в JS он, конечно, поддается, но с костылями.

                        Байка. Рекомендую почитать вот этот пост Сета Лэдда: blog.sethladd.com/2012/10/9-dart-myths-debunked.html
                  0
                  Вот AS3 тоже ECMAScript, но с типами — совсем другая песня :)
              0
              Вполне. Почитайте комментарии к оригиналу — там как раз первый вопрос на ту же тему.
                0
                Упс, Акела промахнулся — это был ответ на первый комментарий.
                –3
                Сначала мы дадим вам возможность сохранять в переменную значение любого типа.

                Потом начнем бить по рукам, чтобы вы этого не делали.
                  0
                  Никто по рукам не бьет. V8 компилирует как может. Но вы можете написать более оптимизированный код, если знаете, как он компилируется.
                    –1
                    Это не упрек, это на тему, что, если плохо придумано сначала, то надо сломать и переделать.
                      0
                      «Плохо» — «кому?», «с какой целью?», «в каких обстоятельствах?»
                      Не стоит делать абсолютизированных обобщений без уточнения контекста — это верный путь к совершению ошибки ;-)
                      Предельная скорость выполнения не всегда и не везде нужна, бывают и другие «кейсы».
                    +1
                    Не бить по рукам, а всего лишь толсто намекать, что использование этой опции имеет очень ненулевую цену…
                    +3
                    Местами не правильно перевели, например:
                    1. Массивы бывают двух видов — хранящие 31 битные инты и указатели на другие обьекты(этой разницей бит и кушается) и только числа двойной точности.
                    Чем плохо — был массив с двумя интами, под него выделилилось место для хранения 4х. Вы добавили double и массив перепаковался в типизированный. Старые данные уходят в GC. Добавили строку — обратная перепаковка. Ваш double теперь доступен через указатель, что сильно медленно

                    2. Полиморфные функции не медленее чем мономорфные. Это буду две разные функции, каждая из которых потребует своей оптимизации( 0.5-1мсек на функцию, это не мало). IC(inline cache) может чуть раньше выбрать какую он будет использовать.

                    3. Огромная Сила в inline функциях! Жалко что хром не инлайнит функции которые требуют переключение контекста. Да, именно в JS функциональное программирование реально быстрее чем старый добрый ООП.

                    Вообще профилирование через d8 или запуск хрома с флагами чтука относительно бесполезная. Позволяет оттюнить только синтетические примеры и реально узкие места.
                      +2
                      1. По типу содержимого быстрые элементы бывают трех видов и трансформируются в одном направлении от менее общих к более общим: smi (small integer) -> double -> object. Бывают еще дырявые (holey) и непрерывные (packed). Еще бывают медленные элементы — представляются словарем.

                      2. Рекомендация соблюдать мономорфизм относится не к функциям, а к отдельным операциям, например, оператору умножения, оператору [] или вызову метода. Операции работают быстрее, когда они мономорфны — выполнятся над объектами одного и того же типа/скрытого класса. Как вы определяете «полиморфную функцию»?

                      3. V8 инлайнит функции, которые требуют переключения контекста на ia32 (Mac, Windows), но не инлайнит на x64/arm. Что касается функционального программирования, то зависит от того какие именно паттерны мы сравниваем. В большинстве случаев ООП основанное на связке constructor + prototype chain имеет больше шансов быть хорошо заоптимизированным. Можно почитать мой блог о том, какие именно проблемы возникают если опираться на замыкания: mrale.ph/blog/2012/09/23/grokking-v8-closures-for-fun.html

                      А собственно кроме узких мест ничего никогда тюнить и не надо. Все советы по оптимизации бесполезны в 99% случаев, пока вы не наткнетесь на случай попадающий в злые 1%, где надо костьми лечь но выжать все что можно.
                        0
                        Вы давно видели 32х битные Маки?
                        Долго копал и свой код, и исходники v8 чтобы понять как можно заставить заинланиться некоторые мои функции (например математические).
                        Но у вас в блоге заметил странную магию с «use strict», который не дает захватывать контекст.
                        function sk(x, y) { // x and y are not context allocated "use strict"; return arguments[0] * arguments[1]; }
                        Можете эту магию прокоментировать, и вообще дать пару разъяснений про strict mode. Это лексический режим, или оно влияет и на генерируемый ассемблер?
                          +1
                          > Вы давно видели 32х битные Маки?

                          Chrome на Mac по сей день 32-битный

                          omega ~ ∳ file /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome 
                          /Applications/Google Chrome.app/Contents/MacOS/Google Chrome: Mach-O executable i386
                          


                          64-битная сборка только на Linux используется AFAIK

                          > Но у вас в блоге заметил странную магию с «use strict», который не дает захватывать контекст.

                          В данном конкретном примере "use strict" разрывает связь между arguments и формальными параметрами. Это убирает создание контекста и позволяет инлайнить этот код (если функция создает контекст, то такую пока V8 не инлайнит).

                          Strict mode описан в стандарте: es5.github.com/#C

                          > как можно заставить заинланиться некоторые мои функции (например математические).

                          Если вы набросаете примеры кода, то можно будет обсудить почему что-то не инлайнится и какая будет польза от инлайна :-)
                            0
                            пример банален
                            var vector = { add: function (a,b){ return [a[0]+b[0],a[1]+b[1]] } } //и где-то в совершенно другом месте vector.add не инлайнится При этом, если бы я использовал просто функцию vector_add в глобальном scope - инлайн был бы возможен.
                            Кстати, пользуясь, случаяем хотел бы уточнить насчет хранения двордов. Что же лучше — массив(который будет создан уже перепакованным?) или хэш с .x\.y(который тоже вообще бинарная структура вроде)?
                              0
                              эта функция не заинлайнится из-за array literal даже если будет в глобальном scope: code.google.com/p/v8/issues/detail?id=1322

                              если vector существует в единственном числе, то тут разницы между глобальным и не глобальным scope не должно быть.

                              Хранение 32-битных величин сложный вопрос. На ia32 те из них которые не влезают в smi (31-bit signed integer) превратятся в полновесные числа с плавающей точкой. Зависит от многих факторов: как много тех кто не влезает в 31бит, как они будут использоваться и т.д. Int32Array может оказаться оптимальным в некоторых случаях.
                        0
                        Можно поподробнее про силу инлайна?
                          0
                          var a = [1, 2, 3, 4];
                          
                          // Если выполнять это честно, то функция будет вызвана на каждом элементе - это дорого
                          // Но такая запись более читаема
                          a.forEach(function (item) {
                              console.log(item);
                          });
                          
                          // Такие экспрешены инлайнится (где это возможно) в стейтмент for () {}
                          for (var i = 0; i < a.length; i++) {
                              console.log(a[i]);
                          }
                          
                            0
                            только вот такой оптимизации V8 не делает пока :-)
                              0
                              В общем самое вкусное покуда не инлайнится.
                                0
                                А тогда каким образом это работает быстро? Не над каждым же элементом честно функция вызывается.
                                  0
                                  следует определить понятие быстро :-)

                                  над каждым элементом функция честно вызывается. в зависимости от того, что функция делает цена этого вызова может быть заметна, а может быть амортизированна и не очень заметна.
                                    0
                                    быстро всмысле сопоставимо со стейтментом for
                                      0
                                      ну по сравнению с циклом for если зарядить на длинном массиве, много-много раз пустую функцию то будет видна цена вызова.
                                        0
                                        Кстати, а что мешает заинлайнить такой цикл? (понятно, что мой пример был предельно тривиальный)
                                          0
                                          ничто не мешает, просто не реализованна нормальная протяжка констант. а без протяжки констант на одном локальном type feedback тут далеко не уедешь из-за полиморфизма точки вызова внутри цикла, который внутри forEach.
                                            0
                                            Понятно, как обычно дело времени :)
                                          0
                                          Другой вопрос, а что мешает заинлайнить пустые функции? Точнее привести их к виду — не вызывать, все undefined возвращать. Ведь функции — immutable и тело у них самом собой появиться не может.
                            0
                            А что нужно делать вместо удаления элементов из массивов?
                              0
                              Либо использовать хэши, либо закрывать дырку в массиве, либо ответить на вопрос зачем удаляем.
                                0
                                А вы сами используете массивы, только когда не предполагается удалять из них элементы?
                                  0
                                  Я использую массивы для разных целей.
                                  Но никогда не оставляю в них дырки.
                                  Тут лучше обойтись хэшом.
                                    0
                                    А удаление с конца массива тоже оставляет дырки в его внутреннем представлении? Где можно подробнее почитать на этот счет?
                                      +1
                                      когда говорится, что лучше не удалять элементы, имеется ввиду удаление многих элементво из середины оператором delete.
                                        0
                                        вы уверены про delete? он не удаляет элемент, а прописывает undefined
                                        splice удаляет элементы из середины
                                          +3
                                          мы, видимо, по разному определяем понятие удалить.

                                          прежде всего delete не прописывает undefined, он удаляет свойство полностью оставляя вместо него дырку. разницу просто увидеть на примере кода:

                                          a = [1];
                                          b = [1];
                                          Array.prototype[0] = 42;
                                          console.log(a[0] + " " + b[0]);  // 1 1
                                          delete a[0];
                                          b[0] = undefined;
                                          console.log(a[0] + " " + b[0]);  // 42 undefined
                                          


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

                                          splice же, который удаляет и сдвигает элементы не оставляет после себя дырок, поэтому безобиден в данном отношении. Следует, впрочем, всегда помнить, что он имеет линейную сложность по количеству сдвигаемых элементов.
                                            0
                                            признаю, был не прав, вспылил :)
                                    0
                                    Можно юзать как стек или очередь.
                                  –1
                                  А что нужно делать вместо удаления элементов из массивов?
                                  Удалять-то можно; а нужно не оставлять дырки.

                                  Поэтому вместо «delete arrayName[index]» следует использовать «arrayName.splice(index, 1)», когда это возможно.
                                    +1
                                    я бы сказал, что это спорная рекомендация и они совсем не эквивалентны. все конечно же зависит от конкретного кода, splice, например, линейная операция по количеству сдвигаемых элементов. в каких-то случаях может лучше arr[i] = null; делать (хоть оно и не эквивалентно delete)
                                      0
                                      Я как раз и использую splice. Однако он медленнее, чем просто устанавливать значение равным null, либо делать delete. Но ведь это тоже не всегда приемлимо. Было бы хорошо услышать реальные примеры задач и те цифры, которые мы выигрываем/проигрываем, используя один или другой метод.
                                        0
                                        Все зависит от конкретной задачи, на самом деле. Каждый код очень индивидуален.
                                    0
                                    Замечательная статья! Сразу вспомнились все абстракции с jQuery и JS и так же конечно JavaScript в нашем случаем с VM.
                                    0
                                    Спасибо за перевод! Собрал d8, но похоже это было самой простой задачей)

                                    --trace-opt, --trace-deopt — ничего не выводят, и так и эдак пробовал. Есть какой-нибудь код, который гарантировано выведет инфу?
                                    --trace-bailout — похоже выпилили. В Changelog (Version 3.13.4): Print reason for disabling optimization. Kill --trace-bailout flag. По крайней мере сейчас, ругается на несуществующий флаг.
                                      0
                                      --trace-deopt действительно выпилили.

                                      Что касается --trace-opt/--trace-deopt то они ничего не выводят если ничего не оптимизируется/деоптимизируется. Попробуйте какой-нибудь горячий цикл.

                                    • UFO just landed and posted this here
                                        +2
                                        > добавить оптимизации таких случаев в компилятор же?

                                        Вы действительно считаете, что то, что тривиально человеку — машине тоже тривиально? ;-)
                                        0
                                        > В частности, оптимизирующий компилятор пропускает любые функции, содержащие блоки try/catch
                                        Интересно, значит ли это, что jQuery толком не оптимизируется? или для него сделано исключение?
                                        Просто у меня стойкая ассоциация в мозгу, что
                                        jQuery('...') === try { jQuery('..') } catech(e) {};
                                          0
                                          > Инициализируйте все объекты в конструкторах, чтобы они как можно меньше менялись в дальнейшем.

                                          Спасибо, погуглил, ускорил стандартный setTimeout в node.js на 15-20%.
                                          github.com/joyent/node/issues/4182
                                            0
                                            Хе, в master-бранче уже есть примерно такое же :)
                                              0
                                              Таки получилось ускорить в 1.5 раза.

                                          Only users with full accounts can post comments. Log in, please.