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

Ссылки:


AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

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
                Сначала мы дадим вам возможность сохранять в переменную значение любого типа.

                Потом начнем бить по рукам, чтобы вы этого не делали.
                • UFO just landed and posted this here
                    –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.