Оптимизация Node.js для большого количества исходящих HTTP-запросов? Оптимизация node js


техники оптимизации сегодня и завтра

Node.js, с момента появления, зависит от JS-движка V8, который обеспечивает исполнение команд языка, который мы все знаем и любим. V8 — это виртуальная машина JavaScript, написанная Google для браузера Chrome. С самого начала V8 создавали для того, чтобы сделать JavaScript быстрым, по крайней мере — обеспечить большую скорость, чем конкурирующие движки. Для динамического языка без строгой типизации достижение высокой производительности — задача непростая. V8 и другие движки развиваются, всё лучше решая эту задачу. Однако, новый движок — это не просто «рост скорости исполнения JS». Это — и необходимость в новых подходах к оптимизации кода. Не всё то, что было сегодня самым быстрым, будет радовать нас максимальной производительностью в будущем. Не всё, что считалось медленным, останется таким.

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

Перед вами — плод совместного труда Дэвида Марка Клементса и Маттео Коллины. Материал проверили Франциска Хинкельманн и Бенедикт Мейрер из команды разработчиков V8.

Центральная часть движка V8, которая позволяет ему исполнять JavaScript на высокой скорости, это компилятор JIT (Just In Time). Это — динамический компилятор, который может оптимизировать код в процессе его выполнения. Когда V8 только был создан, компилятор JIT назвали FullCodeGen, это был (как справедливо отметил Янг Гуо) первый оптимизирующий компилятор для данной платформы. Затем команда V8 создала компилятор Crankshaft, включавший в себя множество оптимизаций производительности, которые не были реализованы в FullCodeGen.

Как человек, который наблюдал за JavaScript с 90-х годов и всё это время пользовался им, я заметил, что часто то, какие участки JS-кода будут работать медленно, а какие быстро, оказывается совершенно неочевидным, независимо от того, какой именно движок используется. Причины, по которым программы исполнялись медленнее, чем ожидалось, часто было сложно понять.

В последние годы я и Маттео Коллина сосредоточились на выяснении того, как писать высокопроизводительный код для Node.js. Естественно, это подразумевает знание того, какие подходы являются быстрыми, а какие — медленными, когда наш код исполняется JS-движком V8.

Теперь пришло время пересмотреть все наши предположения о производительности, так как команда V8 написала новый JIT-компилятор: TurboFan.

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

Конечно, прежде чем оптимизировать код с учётом особенностей V8, мы сначала должны сосредоточиться на дизайне API, алгоритмах и структурах данных. Эти микробенчмарки можно рассматривать как индикаторы того, как меняется исполнение JavaScript в Node. Мы можем использовать эти индикаторы для того, чтобы изменить общий стиль нашего кода и способы, которыми мы улучшаем производительность после применения обычных оптимизаций.

Мы рассмотрим производительность микробенчмарков в версиях V8 5.1, 5.8, 5.9, 6.0, и 6.1.

Для того, чтобы было понятно, как версии V8 связаны с версиями Node, отметим следующее: движок V8 5.1 используется в Node 6, здесь применяется компилятор Crankshaft JIT, движок V8 5.8 используется в версиях Node с 8.0 по 8.2, тут применяется и Crankshaft, и TurboFan.

В настоящий момент ожидается, что в Node 8.3, или, возможно, в 8.4, будет движок V8 версии 5.9 или 6.0. Самая свежая на момент написания этого материала версия V8 — 6.1. Она интегрирована в Node в экспериментальном репозитории node-v8. Другими словами, V8 6.1, в итоге, окажется в какой-то будущей версии Node.

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

Большинство микробенчмарков выполнено на Macbook Pro 2016, 3.3 ГГц Intel Core i7, 16 ГБ 2133 МГц LPDDR3-памяти. Некоторые из них (работа с числами, удаление свойств объектов) были выполнены на MacBook Pro 2014, 3 Ггц Intel Core i7, 16 GB 1600 МГц DDR3-памяти. Замеры производительности для разных версий Node.js выполнялись на одном и том же компьютере. Мы внимательно следили за тем, чтобы на результаты испытаний не повлияли другие программы.

Давайте посмотрим на наши тесты и поговорим о том, что полученные результаты означают для будущего Node. Все испытания выполнялись с использованием пакета benchmark.js, данные на каждой из диаграмм означают число операций в секунду, то есть, чем полученное значение больше — тем лучше.

Проблема try/catch

Один из хорошо известных шаблонов деоптимизации заключается в использовании блоков try/catch.

Обратите внимание на то, что здесь и далее в списках описаний испытаний, в скобках, будут даны краткие названия испытаний на английском. Эти названия применяются для обозначения результатов на диаграммах. Кроме того, они помогут сориентироваться в коде, который использовался в ходе тестов.В этом испытании мы сравним четыре тестовых случая:

→ Код тестов на GitHub

Мы можем видеть, что то, что уже известно о негативном влиянии try/catch на производительность, подтверждается в Node 6 (V8 5.1), а в Node 8.0-8.2 (V8 5.8) try/catch оказывает гораздо меньшее влияние на производительность.

Также следует отметить, что вызов функции из блока try оказывается гораздо более медленным, чем вызов её за пределами try — это справедливо и для Node 6 (V8 5.1), и для Node 8.0-8.2 (V8 5.8).

Однако, в Node 8.3+ вызов функции из блока try на производительность практически не влияет.

Тем не менее, не стоит успокаиваться. Работая над некоторыми материалами для семинара по оптимизации, мы обнаружили ошибку, когда довольно специфическое стечение обстоятельств может привести к бесконечному циклу деоптимизации/реоптимизации в TurboFan. Это вполне можно считать очередным шаблоном-убийцей производительности.

Удаление свойств из объектов

Многие годы команду delete избегал любой, кто хотел писать высокопроизводительный код на JS (ну, по крайней мере, в случаях, когда надо было написать оптимальный код для самых нагруженных частей программ).

Проблема с delete сводится к тому, как V8 обходится с динамической природой объектов JavaScript, и с цепочками прототипов (также потенциально динамическими), которые усложняют поиск свойств на низком уровне реализации движка.

Подход движка V8 к созданию высокопроизводительных объектов со свойствами заключается в создании класса на уровне C++, основываясь на «форме» объекта, то есть — на том, какие ключи и значения имеет объект (включая ключи и значения цепочки прототипов). Эти конструкции известны как «скрытые классы». Однако, этот тип оптимизации производится во время выполнения программы. Если же нет уверенности по поводу формы объекта, у V8 имеется ещё один режим поиска свойств: поиск по хэш-таблице. Такой поиск свойств гораздо медленнее.

Исторически сложилось так, что когда мы удаляем командой delete ключ из объекта, последующие операции доступа к свойствам будут выполняться методом поиска в хэш-таблице. Именно поэтому программисты команду delete стараются не использовать, вместо этого устанавливая свойства в undefined, что, в плане уничтожения значения, ведёт к тому же результату, но добавляет сложностей при проверке существования свойства. Однако, обычно такой подход достаточно хорош, например, при подготовке объектов к сериализации, так как JSON.stringify не включает значения undefined в свой вывод (undefined, в соответствии со спецификацией JSON, не относится к допустимым значениям).

Теперь давайте выясним, решает ли новая реализация TurboFan проблему удаления свойств из объектов.

Тут мы сравним три тестовых случая:

→ Код тестов на GitHub

В V8 6.0 и 6.1 (они ещё не используются ни в одном из релизов Node), удаление последнего свойства, добавленного к объекту, соответствует оптимизированному TurboFan пути выполнения программы, и, таким образом, выполняется даже быстрее, чем установка свойства в undefined. Это очень хорошо, так как говорит о том, что команда разработчиков V8 работает над улучшением производительности команды delete.

Однако, использование этого оператора всё ещё приводит к серьёзному падению производительности при доступе к свойствам, если из объекта было удалено свойство, которое не является последним из добавленных. Это наблюдение нам помог сделать Якоб Куммертов, указавший на особенность наших тестов, в которых был исследован лишь вариант с удалением последнего добавленного свойства. Выражаем ему благодарность. В итоге, как ни хотелось бы нам сказать, что команду delete можно и нужно использовать в коде, написанном для будущих релизов Node, мы вынуждены рекомендовать этого не делать. Команда delete продолжает негативно влиять на производительность.

Утечка и преобразование в массив объекта arguments

Типичная проблема с неявно создаваемым объектом arguments, доступным в обычных функциях (в противовес им, стрелочные функции объекта arguments не имеют), заключается в том, что он похож на массив, но массивом не является.

Для того, чтобы использовать методы массивов или особенности их поведения, индексируемые свойства arguments необходимо скопировать в массив. В прошлом у JS-разработчиков была склонность ставить знак равенства между более коротким и более быстрым кодом. Хотя такой подход, в случае клиентского кода, позволяет достичь снижения объёма данных, которые должен загрузить браузер, то же самое может повлечь проблемы с серверным кодом, где размер программ гораздо менее важен, нежели скорость их выполнения. В результате, соблазнительно короткий способ преобразовать объект arguments в массив стал весьма популярным:

Array.prototype.slice.call(arguments). Такая команда вызывает метод slice объекта Array, передавая объект arguments как контекст this для этого метода. Метод slice видит объект, который похож на массив, после чего делает своё дело. В результате мы получаем массив, собранный из содержимого объекта arguments, похожего на массив.

Однако, когда неявно создаваемый объект arguments передаётся чему-либо, находящемуся вне контекста функции (например, если его возвращают из функции или передают другой функции, как при вызове Array.prototype.slice.call(arguments)), обычно это вызывает падение производительности. Исследуем это утверждение.

Следующий микробенчмарк нацелен на исследование двух взаимосвязанных ситуаций в четырёх версиях V8. А именно, это цена утечки arguments и цена копирования arguments в массив, который потом передаётся за пределы функции вместо объекта arguments.

Вот наши тестовые случаи:

→ Код тестов на GitHub

Взглянем теперь на те же самые данные, представленные в форме линейного графика для того, чтобы подчеркнуть изменения в характеристиках производительности.

Вот какие выводы можно из всего этого сделать. Если нужно писать производительный код, предусматривающий обработку входных данных функции в виде массива (что, по опыту знаю, нужно довольно часто), то в Node 8.3 и выше нужно использовать оператор расширения. В Node 8.2 и ниже следует использовать цикл for для копирования ключей из arguments в новый (заранее созданный) массив (подробности вы можете увидеть в коде тестов).

Далее, в Node 8.3+ падения производительности при передаче объекта arguments в другие функции не происходит, поэтому тут могут быть другие преимущества в плане производительности, если нам не нужен полный массив и можно работать со структурой, похожей на массив, но массивом не являющейся.

Частичное применение (каррирование) и привязка контекста функций

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

Например:

function add (a, b) {  return a + b } const add10 = function (n) {  return add(10, n) } console.log(add10(20))

В этом примере параметр a функции add частично применён как число 10 в функции add10.

Более краткая форма частичного применения функции стала доступна начиная с EcmaScript 5 благодаря методу bind:

function add (a, b) {  return a + b } const add10 = add.bind(null, 10) console.log(add10(20))

Однако, обычно метод bind не используют, так как он ощутимо медленнее, чем вышеописанный способ с замыканием.

В нашем испытании измеряется разница между использованием bind и замыкания в различных версиях V8. Для сравнения здесь же используется непосредственный вызов исходной функции.

Вот четыре тестовых случая.

→ Код тестов на GitHub

Линейная диаграмма результатов испытаний чётко показывает практически полное отсутствие различий между рассмотренными методами работы с функциями в последних версиях V8. Что интересно, частичное применение с использованием стрелочных функций значительно быстрее, чем использование обычных функций (как минимум, в наших тестах). На самом деле, оно практически совпадает с непосредственным вызовом функции. В V8 5.1 (Node 6) и 5.8 (Node 8.0-8.2) bind очень медленный, и выглядит очевидным, что использование стрелочных функций для этих целей позволяет достичь самой высокой скорости. Однако, производительность при использовании bind, начиная с V8 версии 5.9 (Node 8.3+) значительно растёт. Такой подход оказывается самым быстрым (хотя, разница в производительности тут практически неразличима) в V8 6.1 (Node будущих версий).

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

Размер кода функции

Размер функции, включая её сигнатуру, пробелы и даже комментарии, может повлиять на то, может ли V8 сделать функцию встроенной, или нет. Да, это так: добавление комментариев к функции может примерно на 10% снизить производительность. Изменится ли это в будущем?

В данном испытании мы исследуем три сценария:

→ Код тестов на GitHub

В V8 5.1 (Node 6) тесты sum small function и long all together показывают один и тот же результат. Это отлично иллюстрирует то, как работает встраивание. Когда мы вызываем маленькую функцию, это аналогично тому, что V8 записывает содержимое данной функции в место, откуда её вызывают. Поэтому, когда мы пишем текст функции (даже с добавлением комментариев), мы вручную встраиваем её в место вызова и производительность оказывается одной и той же. Опять же, в V8 5.1 (Node 6) можно видеть, что вызов функции, дополненной комментариями, после достижения функцией определённого размера, ведёт к значительно более медленному выполнению кода.

В Node 8.0-8.2 (V8 5.8) ситуация, в целом, остаётся такой же, за исключением того, что стоимость вызова маленькой функции заметно выросла. Это, вероятно, из-за смешивания элементов Crankshaft и TurboFan, когда одна функция может быть в Crankshaft, а другая — в TurboFan, что приводит к разладу механизмов встраивания (то есть, должен произойти переход между кластерами последовательно встроенных функций).

В V8 5.9 и выше (Node 8.3+) добавление посторонних символов, таких, как пробелы или комментарии, не влияет на производительность функций. Это происходит из-за того, что TurboFan использует для вычисления размера функции абстрактное синтаксическое дерево (AST, Abstract Syntax Tree), вместо того, чтобы как Crankshaft, считать символы. Вместо того, чтобы принимать во внимание число байтов функции, TurboFan анализирует реальные инструкции функции, поэтому начиная с V8 5.9 (Node 8.3+) пробелы, символы, из которых составлены имена переменных, сигнатуры функций и комментарии больше не влияют на то, может ли функция быть встроенной. Кроме того, нельзя не заметить то, что общая производительность функций снижается.

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

32-битные и 64-битные целые числа

Хорошо известно, что в JavaScript есть лишь один числовой тип: Number.

Однако, V8 реализован на C++, поэтому базовый тип числового значения JavaScript — это вопрос выбора.

В случае с целыми числами (то есть, тогда, когда мы задаём числа в JS без десятичной точки), V8 считает все числа 32-х битными — до тех пор, пока они перестанут таковыми являться. Это кажется вполне справедливым выбором, так как во многих случаях числа находятся в диапазоне 2147483648 -2147483647. Если JS-число (целиком) превышает 2147483647, JIT-компилятору приходится динамически менять базовый тип числового значения на тип с двойной точностью (с плавающей запятой) — это может, в потенциале, оказать определённое влияние на другие оптимизации.

В этом испытании мы рассмотрим три сценария:

→ Код тестов на GitHub

Диаграмма позволяет говорить о том, что, идёт ли речь о Node 6 (V8 5.1), или о Node 8 (V8 5.8), или даже о будущих версиях Node, вышеописанное наблюдение остаётся справедливым. А именно, оказывается, что вычисления с использованием целых чисел, превышающих 2147483647, приводят к тому, что функции исполняются со скоростью, находящейся в районе половины или двух третей от максимальной. Поэтому, если у вас есть длинные цифровые ID — помещайте их в строки.

Кроме того, очень заметно, что операции с числами, укладывающимися в 32-битный диапазон, выполняются гораздо быстрее в Node 6 (V8 5.1), а также в Node 8.1 и 8.2 (V8 5.8), чем в Node 8.3+ (V8 5.9+). Однако, операции над числами двойной точности в Node 8.3+ (V8 5.9+) выполняются быстрее. Вероятно, это так из-за замедления в обработке 32-битных чисел, и не относится к скорости вызова функций или циклов for, которые используются в коде тестов.

Якоб Куммертов, Янг Гуо и команда V8 помогли нам сделать результаты этого испытания правильнее и точнее. Мы благодарны им за это.

Перебор свойств объектов

Взятие значений всех свойств объекта и выполнение с ними каких-то действий — распространённая задача. Существует множество способов её решения. Выясним, какой из способов самый быстрый в исследуемых версиях V8 и Node.

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

Кроме того, мы провели три дополнительных теста для V8 версий 5.8, 5.9, 6.0 и 6.1:

Мы не проводили эти тесты в V8 5.1 (Node 6), так как эта версия не поддерживает встроенного метода EcmaScript 2017 Object.values.

→ Код тестов на GitHub

В Node 6 (V8 5.1) и Node 8.0-8.2 (V8 5.8) использование цикла for-in, без сомнения, является самым быстрым способом перебора ключей объекта, и затем — доступа к значениям его свойств. Этот способ даёт примерно 40 миллионов операций в секунду, что в 5 раз быстрее, чем при использовании ближайшего по производительности подхода, предусматривающего использование Object.keys, и дающего примерно 8 миллионов операций в секунду.

В V8 6.0 (Node 8.3) с циклом for-in что-то случилось и производительность упала до всего четвёртой части скорости, достижимой в предыдущих версиях. Однако, это подход остался самым производительным.

В V8 6.1 (то есть, в будущих версиях Node), производительность метода, использующего Object.keys, растёт, этот метод оказывается быстрее метода с циклом for-in, однако, скорость пока даже не приближается к тем результатам, которые были характерны для for-in в V8 5.1 и 5.8 (Node 6, Node 8.0-8.2).

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

Применение Object.values для прямого получения значений свойств медленнее, чем использование Object.keys и доступ к значениям объектов по ключам. Кроме того, процедурные циклы оказываются быстрее, чем функциональный подход. Таким образом, при таком подходе может понадобиться больше работы, когда дело доходит до перебора свойств объектов.

Кроме того, для тех, кто привык пользоваться циклом for-in из-за его высокой производительности, текущее состояние дел может оказаться весьма неприятным. Значительная часть скорости теряется, а никакой доступной альтернативы нам не предлагают.

Создание объектов

Создание объектов в JS — это то, что происходит постоянно, поэтому данный процесс исследовать будет очень полезно.

Мы собираемся провести три набора тестов:

→ Код тестов на GitHub

В Node 6 (V8 5.1) все подходы показывают примерно одинаковые результаты.

В Node 8.0-8.2 (V8 5.8), при создании объектов из классов EcmaScript 2015, производительность составляет менее половины той, которая достижима с использованием объектных литералов или функций-конструкторов. Как вы понимаете, вполне очевидно, чем стоит пользоваться в этих версиях Node.

В V8 5.9 разные способы создания объектов снова показывают одну и ту же производительность.

Затем, в V8 6.0 (надеемся, это будет Node 8.3 или 8.4) и 6.1 (пока эта версия V8 не ассоциируется ни с одним будущим релизом Node), скорость создания объектов оказывается просто сумасшедшей. Более 500 миллионов операций в секунду! Это просто потрясающе.

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

Надо сказать, что Якоб Куммертов отметил здесь, что TurboFan способен оптимизировать выделение объектов в нашем микробенчмарке. Мы планируем это исследовать и обновить результаты испытаний.

Полиморфные и мономорфные функции

Если мы всегда передаём в функцию аргумент одного и того же типа (скажем, это строка), это значит, что мы используем функцию мономорфно. Но некоторые функции рассчитаны на полиморфизм. Это означает, что один и тот же параметр может быть представлен различными скрытыми классами. Возможно, он может обрабатываться, как строка, или как массив, или как какой-то произвольный объект. Такой подход позволяет, в некоторых ситуациях, создавать приятные программные интерфейсы, но плохо влияет на производительность. Испытаем полиморфное и мономорфное использование функций.

Мы собираемся исследовать пять тестовых случаев:

→ Код тестов на GitHub

Данные на диаграмме убедительно показывают то, что мономорфные функции работают быстрее полиморфных во всех исследованных версиях V8.

Разрыв в производительности между мономорфными и полиморфными функциями в V8 6.1 (в движке, который получит одна из будущих версий Node) особенно велик, что усугубляет ситуацию. Однако, стоит отметить, что этот тест использует экспериментальную ветку node-v8, в которой применяется нечто вроде «ночной сборки» V8, поэтому данный результат вполне может не соответствовать реальным характеристикам V8 6.1.

При написании кода, который должен быть оптимальным, то есть, речь идёт о функции, которая будет постоянно вызываться, полиморфизма следует избегать. С другой стороны, если функцию вызывают раз или два, скажем, эта функция используется для подготовки программы к работе, полиморфные API вполне приемлемы.

По поводу этого испытания хотим отметить, что команда V8 сообщила нам о том, что им не удалось надёжно воспроизвести результаты этого теста, используя их внутреннюю систему исполнения, d8. Однако, эти тесты удаётся воспроизвести на Node. Результаты теста следует рассматривать, исходя из предположения, что ситуация может измениться в обновлениях Node (основываясь на том, как Node интегрируется с V8). Этот вопрос требует дополнительного анализа. Благодарим Якоба Куммертова за то, что обратил на это наше внимание.

Ключевое слово debugger

И, наконец, поговорим о ключевом слове debugger.

Не забудьте убрать это ключевое слово из продакшн-кода. Иначе ни о какой производительности и речи быть не может.

Тут мы исследовали два тестовых случая:

→ Код тестов на GitHub

Говорить тут особо нечего. Все версии V8 показывают сильнейшее падение производительности при использовании ключевого слова debugger.

Тут можно обратить внимание на то, что линия графика для теста without debugger заметно идёт вниз в более свежих версиях V8.

Испытание на реальной задаче: сравнение логгеров

В дополнение к микробенчмаркам, мы можем взглянуть на то, как разные версии V8 решают практические задачи. Для этого используем несколько популярных логгеров для Node.js, которые мы с Маттео исследовали, создавая логгер Pino.

На нижеприведённой гистограмме показано время, необходимое наиболее популярным логгерам для вывода 10 тысяч строк (чем столбик ниже — тем лучше) в Node.js 6.11 (Crankshaft).

Вот — то же самое, но уже с использованием V8 6.1 (TurboFan).

В то время, как все логгеры показали примерно двукратный рост производительности, Winston извлёк максимум пользы из нового JIT-компилятора TurboFan. Похоже, в данном случае на производительность повлияли сразу несколько факторов, которые, по отдельности, проявлялись в наших микробенчмарках. Самые медленные способы работы в Crankshaft оказываются значительно быстрее в TurboFan, в то время как то, что быстрее всего работает при использовании Crankshaft, оказывается в TurboFan немного медленнее. Логгер Winston, который был самым неторопливым, вероятно, использует техники, которые являются самыми медленными в Crankshaft, но оказываются гораздо быстрее в TurboFan. В то же время, Pino оптимизирован в расчёте на максимальную производительность в Crankshaft. Он показывает сравнительно небольшой прирост производительности.

Итоги

Некоторые из тестов показывают, что то, что было медленным в V8 5.1, 5.8 и 5.9, оказывается быстрее благодаря полноценному использованию TurboFan в V8 6.0 и 6.1. В то же время, то, что было самым быстрым, теряет в производительности, нередко показывая те же результаты, что и более медленные варианты после роста их скорости.

В основном это связано со стоимостью выполнения вызовов функций в TurboFan (V8 6.0 и выше). Основная идея при работе над TurboFan заключалась в оптимизации того, что используется наиболее часто, а так же в том, чтобы существующие «убийцы производительности V8» не влияли бы слишком сильно на скорость выполнения программ. Это привело к общему росту производительности браузерного (Chrome) и серверного (Node) кода. Компромисс, похоже, заключается в падении производительности тех подходов, которые ранее были самыми быстрыми. Надеемся, это временное явление. При сравнении производительности логгеров было выяснено, что общий эффект от использования TurboFan заключается в значительном росте производительности приложений с очень разной кодовой базой (например, это касается Winston и Pino).

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

Уважаемые читатели! Какие подходы к оптимизации JavaScript используете вы?

Автор: RUVDS.com

Источник

www.pvsm.ru

техники оптимизации сегодня и завтра / Блог компании RUVDS.com / Хабр

Node.js, с момента появления, зависит от JS-движка V8, который обеспечивает исполнение команд языка, который мы все знаем и любим. V8 — это виртуальная машина JavaScript, написанная Google для браузера Chrome. С самого начала V8 создавали для того, чтобы сделать JavaScript быстрым, по крайней мере — обеспечить большую скорость, чем конкурирующие движки. Для динамического языка без строгой типизации достижение высокой производительности — задача непростая. V8 и другие движки развиваются, всё лучше решая эту задачу. Однако, новый движок — это не просто «рост скорости исполнения JS». Это — и необходимость в новых подходах к оптимизации кода. Не всё то, что было сегодня самым быстрым, будет радовать нас максимальной производительностью в будущем. Не всё, что считалось медленным, останется таким.

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

Перед вами — плод совместного труда Дэвида Марка Клементса и Маттео Коллины. Материал проверили Франциска Хинкельманн и Бенедикт Мейрер из команды разработчиков V8.

Центральная часть движка V8, которая позволяет ему исполнять JavaScript на высокой скорости, это компилятор JIT (Just In Time). Это — динамический компилятор, который может оптимизировать код в процессе его выполнения. Когда V8 только был создан, компилятор JIT назвали FullCodeGen, это был (как справедливо отметил Ян Го) первый оптимизирующий компилятор для данной платформы. Затем команда V8 создала компилятор Crankshaft, включавший в себя множество оптимизаций производительности, которые не были реализованы в FullCodeGen.

Как человек, который наблюдал за JavaScript с 90-х годов и всё это время пользовался им, я заметил, что часто то, какие участки JS-кода будут работать медленно, а какие быстро, оказывается совершенно неочевидным, независимо от того, какой именно движок используется. Причины, по которым программы исполнялись медленнее, чем ожидалось, часто было сложно понять.

В последние годы я и Маттео Коллина сосредоточились на выяснении того, как писать высокопроизводительный код для Node.js. Естественно, это подразумевает знание того, какие подходы являются быстрыми, а какие — медленными, когда наш код исполняется JS-движком V8.

Теперь пришло время пересмотреть все наши предположения о производительности, так как команда V8 написала новый JIT-компилятор: TurboFan.

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

Конечно, прежде чем оптимизировать код с учётом особенностей V8, мы сначала должны сосредоточиться на дизайне API, алгоритмах и структурах данных. Эти микробенчмарки можно рассматривать как индикаторы того, как меняется исполнение JavaScript в Node. Мы можем использовать эти индикаторы для того, чтобы изменить общий стиль нашего кода и способы, которыми мы улучшаем производительность после применения обычных оптимизаций.

Мы рассмотрим производительность микробенчмарков в версиях V8 5.1, 5.8, 5.9, 6.0, и 6.1.

Для того, чтобы было понятно, как версии V8 связаны с версиями Node, отметим следующее: движок V8 5.1 используется в Node 6, здесь применяется компилятор Crankshaft JIT, движок V8 5.8 используется в версиях Node с 8.0 по 8.2, тут применяется и Crankshaft, и TurboFan.

В настоящий момент ожидается, что в Node 8.3, или, возможно, в 8.4, будет движок V8 версии 5.9 или 6.0. Самая свежая на момент написания этого материала версия V8 — 6.1. Она интегрирована в Node в экспериментальном репозитории node-v8. Другими словами, V8 6.1, в итоге, окажется в какой-то будущей версии Node.

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

Большинство микробенчмарков выполнено на Macbook Pro 2016, 3.3 ГГц Intel Core i7, 16 ГБ 2133 МГц LPDDR3-памяти. Некоторые из них (работа с числами, удаление свойств объектов) были выполнены на MacBook Pro 2014, 3 Ггц Intel Core i7, 16 GB 1600 МГц DDR3-памяти. Замеры производительности для разных версий Node.js выполнялись на одном и том же компьютере. Мы внимательно следили за тем, чтобы на результаты испытаний не повлияли другие программы.

Давайте посмотрим на наши тесты и поговорим о том, что полученные результаты означают для будущего Node. Все испытания выполнялись с использованием пакета benchmark.js, данные на каждой из диаграмм означают число операций в секунду, то есть, чем полученное значение больше — тем лучше.

Проблема try/catch

Один из хорошо известных шаблонов деоптимизации заключается в использовании блоков try/catch.

Обратите внимание на то, что здесь и далее в списках описаний испытаний, в скобках, будут даны краткие названия испытаний на английском. Эти названия применяются для обозначения результатов на диаграммах. Кроме того, они помогут сориентироваться в коде, который использовался в ходе тестов. В этом испытании мы сравним четыре тестовых случая:

→ Код тестов на GitHub

Мы можем видеть, что то, что уже известно о негативном влиянии try/catch на производительность, подтверждается в Node 6 (V8 5.1), а в Node 8.0-8.2 (V8 5.8) try/catch оказывает гораздо меньшее влияние на производительность.

Также следует отметить, что вызов функции из блока try оказывается гораздо более медленным, чем вызов её за пределами try — это справедливо и для Node 6 (V8 5.1), и для Node 8.0-8.2 (V8 5.8).

Однако, в Node 8.3+ вызов функции из блока try на производительность практически не влияет.

Тем не менее, не стоит успокаиваться. Работая над некоторыми материалами для семинара по оптимизации, мы обнаружили ошибку, когда довольно специфическое стечение обстоятельств может привести к бесконечному циклу деоптимизации/реоптимизации в TurboFan. Это вполне можно считать очередным шаблоном-убийцей производительности.

Удаление свойств из объектов

Многие годы команду delete избегал любой, кто хотел писать высокопроизводительный код на JS (ну, по крайней мере, в случаях, когда надо было написать оптимальный код для самых нагруженных частей программ).

Проблема с delete сводится к тому, как V8 обходится с динамической природой объектов JavaScript, и с цепочками прототипов (также потенциально динамическими), которые усложняют поиск свойств на низком уровне реализации движка.

Подход движка V8 к созданию высокопроизводительных объектов со свойствами заключается в создании класса на уровне C++, основываясь на «форме» объекта, то есть — на том, какие ключи и значения имеет объект (включая ключи и значения цепочки прототипов). Эти конструкции известны как «скрытые классы». Однако, этот тип оптимизации производится во время выполнения программы. Если же нет уверенности по поводу формы объекта, у V8 имеется ещё один режим поиска свойств: поиск по хэш-таблице. Такой поиск свойств гораздо медленнее.

Исторически сложилось так, что когда мы удаляем командой delete ключ из объекта, последующие операции доступа к свойствам будут выполняться методом поиска в хэш-таблице. Именно поэтому программисты команду delete стараются не использовать, вместо этого устанавливая свойства в undefined, что, в плане уничтожения значения, ведёт к тому же результату, но добавляет сложностей при проверке существования свойства. Однако, обычно такой подход достаточно хорош, например, при подготовке объектов к сериализации, так как JSON.stringify не включает значения undefined в свой вывод (undefined, в соответствии со спецификацией JSON, не относится к допустимым значениям).

Теперь давайте выясним, решает ли новая реализация TurboFan проблему удаления свойств из объектов.

Тут мы сравним три тестовых случая:

→ Код тестов на GitHub

В V8 6.0 и 6.1 (они ещё не используются ни в одном из релизов Node), удаление последнего свойства, добавленного к объекту, соответствует оптимизированному TurboFan пути выполнения программы, и, таким образом, выполняется даже быстрее, чем установка свойства в undefined. Это очень хорошо, так как говорит о том, что команда разработчиков V8 работает над улучшением производительности команды delete.

Однако, использование этого оператора всё ещё приводит к серьёзному падению производительности при доступе к свойствам, если из объекта было удалено свойство, которое не является последним из добавленных. Это наблюдение нам помог сделать Якоб Куммеров, указавший на особенность наших тестов, в которых был исследован лишь вариант с удалением последнего добавленного свойства. Выражаем ему благодарность. В итоге, как ни хотелось бы нам сказать, что команду delete можно и нужно использовать в коде, написанном для будущих релизов Node, мы вынуждены рекомендовать этого не делать. Команда delete продолжает негативно влиять на производительность.

Утечка и преобразование в массив объекта arguments

Типичная проблема с неявно создаваемым объектом arguments, доступным в обычных функциях (в противовес им, стрелочные функции объекта arguments не имеют), заключается в том, что он похож на массив, но массивом не является.

Для того, чтобы использовать методы массивов или особенности их поведения, индексируемые свойства arguments необходимо скопировать в массив. В прошлом у JS-разработчиков была склонность ставить знак равенства между более коротким и более быстрым кодом. Хотя такой подход, в случае клиентского кода, позволяет достичь снижения объёма данных, которые должен загрузить браузер, то же самое может повлечь проблемы с серверным кодом, где размер программ гораздо менее важен, нежели скорость их выполнения. В результате, соблазнительно короткий способ преобразовать объект arguments в массив стал весьма популярным:

Array.prototype.slice.call(arguments). Такая команда вызывает метод slice объекта Array, передавая объект arguments как контекст this для этого метода. Метод slice видит объект, который похож на массив, после чего делает своё дело. В результате мы получаем массив, собранный из содержимого объекта arguments, похожего на массив.

Однако, когда неявно создаваемый объект arguments передаётся чему-либо, находящемуся вне контекста функции (например, если его возвращают из функции или передают другой функции, как при вызове Array.prototype.slice.call(arguments)), обычно это вызывает падение производительности. Исследуем это утверждение.

Следующий микробенчмарк нацелен на исследование двух взаимосвязанных ситуаций в четырёх версиях V8. А именно, это цена утечки arguments и цена копирования arguments в массив, который потом передаётся за пределы функции вместо объекта arguments.

Вот наши тестовые случаи:

→ Код тестов на GitHub

Взглянем теперь на те же самые данные, представленные в форме линейного графика для того, чтобы подчеркнуть изменения в характеристиках производительности.

Вот какие выводы можно из всего этого сделать. Если нужно писать производительный код, предусматривающий обработку входных данных функции в виде массива (что, по опыту знаю, нужно довольно часто), то в Node 8.3 и выше нужно использовать оператор расширения. В Node 8.2 и ниже следует использовать цикл for для копирования ключей из arguments в новый (заранее созданный) массив (подробности вы можете увидеть в коде тестов).

Далее, в Node 8.3+ падения производительности при передаче объекта arguments в другие функции не происходит, поэтому тут могут быть другие преимущества в плане производительности, если нам не нужен полный массив и можно работать со структурой, похожей на массив, но массивом не являющейся.

Частичное применение (каррирование) и привязка контекста функций

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

Например:

function add (a, b) {  return a + b } const add10 = function (n) {  return add(10, n) } console.log(add10(20)) В этом примере параметр a функции add частично применён как число 10 в функции add10.

Более краткая форма частичного применения функции стала доступна начиная с EcmaScript 5 благодаря методу bind:

function add (a, b) {  return a + b } const add10 = add.bind(null, 10) console.log(add10(20)) Однако, обычно метод bind не используют, так как он ощутимо медленнее, чем вышеописанный способ с замыканием.

В нашем испытании измеряется разница между использованием bind и замыкания в различных версиях V8. Для сравнения здесь же используется непосредственный вызов исходной функции.

Вот четыре тестовых случая.

→ Код тестов на GitHub

Линейная диаграмма результатов испытаний чётко показывает практически полное отсутствие различий между рассмотренными методами работы с функциями в последних версиях V8. Что интересно, частичное применение с использованием стрелочных функций значительно быстрее, чем использование обычных функций (как минимум, в наших тестах). На самом деле, оно практически совпадает с непосредственным вызовом функции. В V8 5.1 (Node 6) и 5.8 (Node 8.0-8.2) bind очень медленный, и выглядит очевидным, что использование стрелочных функций для этих целей позволяет достичь самой высокой скорости. Однако, производительность при использовании bind, начиная с V8 версии 5.9 (Node 8.3+) значительно растёт. Такой подход оказывается самым быстрым (хотя, разница в производительности тут практически неразличима) в V8 6.1 (Node будущих версий).

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

Размер кода функции

Размер функции, включая её сигнатуру, пробелы и даже комментарии, может повлиять на то, может ли V8 сделать функцию встроенной, или нет. Да, это так: добавление комментариев к функции может примерно на 10% снизить производительность. Изменится ли это в будущем?

В данном испытании мы исследуем три сценария:

→ Код тестов на GitHub

В V8 5.1 (Node 6) тесты sum small function и long all together показывают один и тот же результат. Это отлично иллюстрирует то, как работает встраивание. Когда мы вызываем маленькую функцию, это аналогично тому, что V8 записывает содержимое данной функции в место, откуда её вызывают. Поэтому, когда мы пишем текст функции (даже с добавлением комментариев), мы вручную встраиваем её в место вызова и производительность оказывается одной и той же. Опять же, в V8 5.1 (Node 6) можно видеть, что вызов функции, дополненной комментариями, после достижения функцией определённого размера, ведёт к значительно более медленному выполнению кода.

В Node 8.0-8.2 (V8 5.8) ситуация, в целом, остаётся такой же, за исключением того, что стоимость вызова маленькой функции заметно выросла. Это, вероятно, из-за смешивания элементов Crankshaft и TurboFan, когда одна функция может быть в Crankshaft, а другая — в TurboFan, что приводит к разладу механизмов встраивания (то есть, должен произойти переход между кластерами последовательно встроенных функций).

В V8 5.9 и выше (Node 8.3+) добавление посторонних символов, таких, как пробелы или комментарии, не влияет на производительность функций. Это происходит из-за того, что TurboFan использует для вычисления размера функции абстрактное синтаксическое дерево (AST, Abstract Syntax Tree), вместо того, чтобы как Crankshaft, считать символы. Вместо того, чтобы принимать во внимание число байтов функции, TurboFan анализирует реальные инструкции функции, поэтому начиная с V8 5.9 (Node 8.3+) пробелы, символы, из которых составлены имена переменных, сигнатуры функций и комментарии больше не влияют на то, может ли функция быть встроенной. Кроме того, нельзя не заметить то, что общая производительность функций снижается.

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

32-битные и 64-битные целые числа

Хорошо известно, что в JavaScript есть лишь один числовой тип: Number.

Однако, V8 реализован на C++, поэтому базовый тип числового значения JavaScript — это вопрос выбора.

В случае с целыми числами (то есть, тогда, когда мы задаём числа в JS без десятичной точки), V8 считает все числа 32-х битными — до тех пор, пока они перестанут таковыми являться. Это кажется вполне справедливым выбором, так как во многих случаях числа находятся в диапазоне 2147483648 -2147483647. Если JS-число (целиком) превышает 2147483647, JIT-компилятору приходится динамически менять базовый тип числового значения на тип с двойной точностью (с плавающей запятой) — это может, в потенциале, оказать определённое влияние на другие оптимизации.

В этом испытании мы рассмотрим три сценария:

→ Код тестов на GitHub

Диаграмма позволяет говорить о том, что, идёт ли речь о Node 6 (V8 5.1), или о Node 8 (V8 5.8), или даже о будущих версиях Node, вышеописанное наблюдение остаётся справедливым. А именно, оказывается, что вычисления с использованием целых чисел, превышающих 2147483647, приводят к тому, что функции исполняются со скоростью, находящейся в районе половины или двух третей от максимальной. Поэтому, если у вас есть длинные цифровые ID — помещайте их в строки.

Кроме того, очень заметно, что операции с числами, укладывающимися в 32-битный диапазон, выполняются гораздо быстрее в Node 6 (V8 5.1), а также в Node 8.1 и 8.2 (V8 5.8), чем в Node 8.3+ (V8 5.9+). Однако, операции над числами двойной точности в Node 8.3+ (V8 5.9+) выполняются быстрее. Вероятно, это так из-за замедления в обработке 32-битных чисел, и не относится к скорости вызова функций или циклов for, которые используются в коде тестов.

Якоб Куммеров, Ян Го и команда V8 помогли нам сделать результаты этого испытания правильнее и точнее. Мы благодарны им за это.

Перебор свойств объектов

Взятие значений всех свойств объекта и выполнение с ними каких-то действий — распространённая задача. Существует множество способов её решения. Выясним, какой из способов самый быстрый в исследуемых версиях V8 и Node.

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

Кроме того, мы провели три дополнительных теста для V8 версий 5.8, 5.9, 6.0 и 6.1: Мы не проводили эти тесты в V8 5.1 (Node 6), так как эта версия не поддерживает встроенного метода EcmaScript 2017 Object.values.

→ Код тестов на GitHub

В Node 6 (V8 5.1) и Node 8.0-8.2 (V8 5.8) использование цикла for-in, без сомнения, является самым быстрым способом перебора ключей объекта, и затем — доступа к значениям его свойств. Этот способ даёт примерно 40 миллионов операций в секунду, что в 5 раз быстрее, чем при использовании ближайшего по производительности подхода, предусматривающего использование Object.keys, и дающего примерно 8 миллионов операций в секунду.

В V8 6.0 (Node 8.3) с циклом for-in что-то случилось и производительность упала до всего четвёртой части скорости, достижимой в предыдущих версиях. Однако, это подход остался самым производительным.

В V8 6.1 (то есть, в будущих версиях Node), производительность метода, использующего Object.keys, растёт, этот метод оказывается быстрее метода с циклом for-in, однако, скорость пока даже не приближается к тем результатам, которые были характерны для for-in в V8 5.1 и 5.8 (Node 6, Node 8.0-8.2).

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

Применение Object.values для прямого получения значений свойств медленнее, чем использование Object.keys и доступ к значениям объектов по ключам. Кроме того, процедурные циклы оказываются быстрее, чем функциональный подход. Таким образом, при таком подходе может понадобиться больше работы, когда дело доходит до перебора свойств объектов.

Кроме того, для тех, кто привык пользоваться циклом for-in из-за его высокой производительности, текущее состояние дел может оказаться весьма неприятным. Значительная часть скорости теряется, а никакой доступной альтернативы нам не предлагают.

Создание объектов

Создание объектов в JS — это то, что происходит постоянно, поэтому данный процесс исследовать будет очень полезно.

Мы собираемся провести три набора тестов:

→ Код тестов на GitHub

В Node 6 (V8 5.1) все подходы показывают примерно одинаковые результаты.

В Node 8.0-8.2 (V8 5.8), при создании объектов из классов EcmaScript 2015, производительность составляет менее половины той, которая достижима с использованием объектных литералов или функций-конструкторов. Как вы понимаете, вполне очевидно, чем стоит пользоваться в этих версиях Node.

В V8 5.9 разные способы создания объектов снова показывают одну и ту же производительность.

Затем, в V8 6.0 (надеемся, это будет Node 8.3 или 8.4) и 6.1 (пока эта версия V8 не ассоциируется ни с одним будущим релизом Node), скорость создания объектов оказывается просто сумасшедшей. Более 500 миллионов операций в секунду! Это просто потрясающе.

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

Надо сказать, что Якоб Куммеров отметил здесь, что TurboFan способен оптимизировать выделение объектов в нашем микробенчмарке. Мы планируем это исследовать и обновить результаты испытаний.

Полиморфные и мономорфные функции

Если мы всегда передаём в функцию аргумент одного и того же типа (скажем, это строка), это значит, что мы используем функцию мономорфно. Но некоторые функции рассчитаны на полиморфизм. Это означает, что один и тот же параметр может быть представлен различными скрытыми классами. Возможно, он может обрабатываться, как строка, или как массив, или как какой-то произвольный объект. Такой подход позволяет, в некоторых ситуациях, создавать приятные программные интерфейсы, но плохо влияет на производительность. Испытаем полиморфное и мономорфное использование функций.

Мы собираемся исследовать пять тестовых случаев:

→ Код тестов на GitHub

Данные на диаграмме убедительно показывают то, что мономорфные функции работают быстрее полиморфных во всех исследованных версиях V8.

Разрыв в производительности между мономорфными и полиморфными функциями в V8 6.1 (в движке, который получит одна из будущих версий Node) особенно велик, что усугубляет ситуацию. Однако, стоит отметить, что этот тест использует экспериментальную ветку node-v8, в которой применяется нечто вроде «ночной сборки» V8, поэтому данный результат вполне может не соответствовать реальным характеристикам V8 6.1.

При написании кода, который должен быть оптимальным, то есть, речь идёт о функции, которая будет постоянно вызываться, полиморфизма следует избегать. С другой стороны, если функцию вызывают раз или два, скажем, эта функция используется для подготовки программы к работе, полиморфные API вполне приемлемы.

По поводу этого испытания хотим отметить, что команда V8 сообщила нам о том, что им не удалось надёжно воспроизвести результаты этого теста, используя их внутреннюю систему исполнения, d8. Однако, эти тесты удаётся воспроизвести на Node. Результаты теста следует рассматривать, исходя из предположения, что ситуация может измениться в обновлениях Node (основываясь на том, как Node интегрируется с V8). Этот вопрос требует дополнительного анализа. Благодарим Якоба Куммерова за то, что обратил на это наше внимание.

Ключевое слово debugger

И, наконец, поговорим о ключевом слове debugger.

Не забудьте убрать это ключевое слово из продакшн-кода. Иначе ни о какой производительности и речи быть не может.

Тут мы исследовали два тестовых случая:

→ Код тестов на GitHub

Говорить тут особо нечего. Все версии V8 показывают сильнейшее падение производительности при использовании ключевого слова debugger.

Тут можно обратить внимание на то, что линия графика для теста without debugger заметно идёт вниз в более свежих версиях V8.

Испытание на реальной задаче: сравнение логгеров

В дополнение к микробенчмаркам, мы можем взглянуть на то, как разные версии V8 решают практические задачи. Для этого используем несколько популярных логгеров для Node.js, которые мы с Маттео исследовали, создавая логгер Pino.

На нижеприведённой гистограмме показано время, необходимое наиболее популярным логгерам для вывода 10 тысяч строк (чем столбик ниже — тем лучше) в Node.js 6.11 (Crankshaft).

Вот — то же самое, но уже с использованием V8 6.1 (TurboFan). В то время, как все логгеры показали примерно двукратный рост производительности, Winston извлёк максимум пользы из нового JIT-компилятора TurboFan. Похоже, в данном случае на производительность повлияли сразу несколько факторов, которые, по отдельности, проявлялись в наших микробенчмарках. Самые медленные способы работы в Crankshaft оказываются значительно быстрее в TurboFan, в то время как то, что быстрее всего работает при использовании Crankshaft, оказывается в TurboFan немного медленнее. Логгер Winston, который был самым неторопливым, вероятно, использует техники, которые являются самыми медленными в Crankshaft, но оказываются гораздо быстрее в TurboFan. В то же время, Pino оптимизирован в расчёте на максимальную производительность в Crankshaft. Он показывает сравнительно небольшой прирост производительности.

Итоги

Некоторые из тестов показывают, что то, что было медленным в V8 5.1, 5.8 и 5.9, оказывается быстрее благодаря полноценному использованию TurboFan в V8 6.0 и 6.1. В то же время, то, что было самым быстрым, теряет в производительности, нередко показывая те же результаты, что и более медленные варианты после роста их скорости.

В основном это связано со стоимостью выполнения вызовов функций в TurboFan (V8 6.0 и выше). Основная идея при работе над TurboFan заключалась в оптимизации того, что используется наиболее часто, а так же в том, чтобы существующие «убийцы производительности V8» не влияли бы слишком сильно на скорость выполнения программ. Это привело к общему росту производительности браузерного (Chrome) и серверного (Node) кода. Компромисс, похоже, заключается в падении производительности тех подходов, которые ранее были самыми быстрыми. Надеемся, это временное явление. При сравнении производительности логгеров было выяснено, что общий эффект от использования TurboFan заключается в значительном росте производительности приложений с очень разной кодовой базой (например, это касается Winston и Pino).

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

Уважаемые читатели! Какие подходы к оптимизации JavaScript используете вы?

habr.com

В чем секрет скорости NodeJS? / Блог компании Voximplant / Хабр

Предлагаем вам перевод статьи Евгения Обрезкова, в которой он кратко и по делу рассказывает о причинах высокой скорости NodeJS: потоки, event loop, оптимизирующий компилятор и, конечно же, сравнение с PHP. Куда уж без него.

В очередной статьей о NodeJS хочу поговорить об ещё одном преимуществе программной платформы: о скорости выполнения кода.

Что мы имеем в виду под скоростью выполнения

Вычислить последовательность Фибоначи или отправить запрос к базе данных?

Когда мы говорим о веб-сервисах, скорость выполнения включает все действия, которые необходимы для того, чтобы выполнить запрос и отослать его обратно клиенту. NodeJS отличает высокая скорость – начиная с открытия соединения и заканчивая отправкой ответа.

Как только вы поймёте, что происходит в сервере NodeJS во время выполнения запроса, вам станет ясно, почему это происходит так быстро.

Но сначала давайте обратимся к тому, как обрабатываются запросы на других языках. PHP – лучший пример, потому что он очень популярен и не предлагает никаких оптимизаций по умолчанию.

От чего страдает PHP

Вот список того, что уменьшает скорость выполнения кода в PHP: Это самые критичные минусы PHP. Но, по моему мнению, их намного больше.

Теперь мы посмотрим, как NodeJS справляется с подобными задачами.

Магия NodeJS

NodeJS однопоточна и асинхронна. Любая операция ввода-вывода не блокирует работу. Это значит, что ты можешь читать файлы, отправлять электронные письма, запрашивать базу данных и совершать другие действия… одновременно.

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

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

По сути, здесь были изложены преимущества асинхронной модели. Позвольте мне объяснить, как это работает в NodeJS.

Понимайте вашу асинхронность

Вашему вниманию предлагаю пример концепции асинхронной обработки (спасибо Кириллу Яковенко).

Представьте, что у вас есть 1000 шаров на вершине горы. И ваша задача – толкнуть все шары, чтобы они оказались у её основания. Вы не можете толкнуть одновременно тысячу шаров, только каждый по отдельности. Но это не значит, что вы должны ждать, когда шар достигнет основания, чтобы толкнуть следующий.

Синхронное выполнение означает для вас потерю времени. Вы ждете, когда шар окажется у основания.

Асинхронное выполнение похоже на то, что у вас появляется 1000 дополнительных рук. И вы можете запустить все шары одновременно. После чего ждете только сообщения о том, что все они внизу, и собираете результаты.

Как асинхронное выполнение помогает веб-сервису работать?

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

В реальной жизни, когда у вас много соединений, это значительно ускоряет работу.

Как асинхронный способ реализован в NodeJS?

Event Loop

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

Наиболее развернутое определение event loop:

Event loop, message dispatcher, message loop, message pump или run loop – это программная конструкция, которая ожидает и обрабатывает события или сообщения в программе. Конструкция работает путем создания запросов к внутренней или внешней службе доставки сообщений (которая обычно блокирует запрос до тех пор, пока сообщение не получено), после чего она вызывает обработчик соответствующего события («обрабатывает событие»). Event loop может быть использована в связке с reactor, если источник событий имеет такой же интерфейс как и файлы, к которому можно сделать запрос вида select или poll (poll в смысле Unix system call). Event loop почти всегда работает асинхронно по отношению к источнику сообщений.

Давайте посмотрим на простую иллюстрацию, которая объясняет, как event loop работает в NodeJS.

Event Loop в NodeJS

Когда веб-сервис получает запрос, тот отправляется в event loop. Event loop регистрирует операцию в пуле потоков с нужным коллбеком. Коллбек будет вызван, когда обработка запроса завершится. Ваш коллбек может также делать другие «тяжелые» операции, такие как запросы в базу данных. Но делает это таким же способом – регистрирует операцию в пуле потоков с нужным коллбеком.

Как насчет выполнения кода и его скорости? Мы собираемся поговорить о виртуальной машине и о том, как она выполняет JavaScript код. То есть о V8.

Как V8 оптимизирует ваш код?

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

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

V8 имеет три типа компилятора, но обсудим только два: Full и Crankshaft (третий компилятор называется Turbofun).

Full-компилятор работает быстро и производит «типовой код». У функции Javascript он берет AST (Abstract Syntax Tree) и переводит его в типовой нативный код. На этом этапе применяется только одна оптимизация – инлайн кэширование.

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

После того, как V8 определила, какие функции используются часто, и получила отчет об использовании типов, она старается запустить модифицированный AST через оптимизирующий компилятор – Crankshaft.

В отличие от Full-компилятора, Crunshaft работает не так быстро, но пытается производить оптимизированный код. Cranshaft состоит из двух компонентов: Hydrogen и Lithium.

Hydrogen-компилятор создает CFG (Control Flow Graph) из AST (на основании отчета об использовании типов). Этот граф представлен в форме SSA (Static Single Assignment). На основании простой структуры HIR (High-Level Intermediate Representation) и формы SSA, компилятор может применять много оптимизаций, таких как constant folding, method inlining, и так далее…

Lithium-компиллятор переводит оптимизированный HIR в LIR (Low-Level Intermediate Representation). LIR концептуально похож на машинный код, но в большинстве случае не зависит от платформы. В противоположность HIR, форма LIR — ближе к three-address коду.

Только после этого, оптимизированный код может заменить старый неоптимизированный и продолжить выполнять ваше приложение намного быстрее.

Полезные ссылки

habr.com

Zane Claes: Оптимизация Node.js для большого количества исходящих HTTP-запросов? - Найдено 2 ответов

Мой сервер node.js испытывает время, когда он становится медленным или не отвечает, даже изредка приводя к таймаутам 503 шлюза при попытке подключиться к серверу.

Я на 99% уверен (на основе тестов, которые я выполнил), что это отставание происходит именно из большого количества исходящих запросов, которые я делаю с помощью node-oauth module , чтобы связаться с внешними API (Facebook, Twitter и многие другие). По общему признанию, количество исходящих запросов делается относительно большим (порядка 30 или около того в минуту). Хуже того, это часто означает, что соответствующие входящие запросы на мой сервер могут занять ~ 5-10 секунд. Тем не менее, у меня была предыдущая версия моего API, которую я написал на PHP, который мог обрабатывать это количество исходящих запросов без каких-либо проблем. Фактически, использование ЦП для тех же самых (или даже меньше) запросов с моим Node.js API примерно в 5 раз выше, чем у моего PHP API.

Итак, я пытаюсь изолировать, где я могу улучшить это, и, самое главное, убедиться, что 503 тайм-аута не происходит. Вот некоторые материалы, о которых я читал или экспериментировал:

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

Заранее благодарим за любые мысли или вклады.

FWIW, я также использую express и mongoose, а мои серверы размещены на Amazon Cloud (2x M1.Large для серверов узлов, 2x балансировщика нагрузки и 3x экземпляра M1.Small MongoDB).

    

askdev.info

техники оптимизации сегодня и завтра / СоХабр

Node.js, с момента появления, зависит от JS-движка V8, который обеспечивает исполнение команд языка, который мы все знаем и любим. V8 — это виртуальная машина JavaScript, написанная Google для браузера Chrome. С самого начала V8 создавали для того, чтобы сделать JavaScript быстрым, по крайней мере — обеспечить большую скорость, чем конкурирующие движки. Для динамического языка без строгой типизации достижение высокой производительности — задача непростая. V8 и другие движки развиваются, всё лучше решая эту задачу. Однако, новый движок — это не просто «рост скорости исполнения JS». Это — и необходимость в новых подходах к оптимизации кода. Не всё то, что было сегодня самым быстрым, будет радовать нас максимальной производительностью в будущем. Не всё, что считалось медленным, останется таким.

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

Перед вами — плод совместного труда Дэвида Марка Клементса и Маттео Коллины. Материал проверили Франциска Хинкельманн и Бенедикт Мейрер из команды разработчиков V8.

Центральная часть движка V8, которая позволяет ему исполнять JavaScript на высокой скорости, это компилятор JIT (Just In Time). Это — динамический компилятор, который может оптимизировать код в процессе его выполнения. Когда V8 только был создан, компилятор JIT назвали FullCodeGen, это был (как справедливо отметил Ян Го) первый оптимизирующий компилятор для данной платформы. Затем команда V8 создала компилятор Crankshaft, включавший в себя множество оптимизаций производительности, которые не были реализованы в FullCodeGen.

Как человек, который наблюдал за JavaScript с 90-х годов и всё это время пользовался им, я заметил, что часто то, какие участки JS-кода будут работать медленно, а какие быстро, оказывается совершенно неочевидным, независимо от того, какой именно движок используется. Причины, по которым программы исполнялись медленнее, чем ожидалось, часто было сложно понять.

В последние годы я и Маттео Коллина сосредоточились на выяснении того, как писать высокопроизводительный код для Node.js. Естественно, это подразумевает знание того, какие подходы являются быстрыми, а какие — медленными, когда наш код исполняется JS-движком V8.

Теперь пришло время пересмотреть все наши предположения о производительности, так как команда V8 написала новый JIT-компилятор: TurboFan.

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

Конечно, прежде чем оптимизировать код с учётом особенностей V8, мы сначала должны сосредоточиться на дизайне API, алгоритмах и структурах данных. Эти микробенчмарки можно рассматривать как индикаторы того, как меняется исполнение JavaScript в Node. Мы можем использовать эти индикаторы для того, чтобы изменить общий стиль нашего кода и способы, которыми мы улучшаем производительность после применения обычных оптимизаций.

Мы рассмотрим производительность микробенчмарков в версиях V8 5.1, 5.8, 5.9, 6.0, и 6.1.

Для того, чтобы было понятно, как версии V8 связаны с версиями Node, отметим следующее: движок V8 5.1 используется в Node 6, здесь применяется компилятор Crankshaft JIT, движок V8 5.8 используется в версиях Node с 8.0 по 8.2, тут применяется и Crankshaft, и TurboFan.

В настоящий момент ожидается, что в Node 8.3, или, возможно, в 8.4, будет движок V8 версии 5.9 или 6.0. Самая свежая на момент написания этого материала версия V8 — 6.1. Она интегрирована в Node в экспериментальном репозитории node-v8. Другими словами, V8 6.1, в итоге, окажется в какой-то будущей версии Node.

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

Большинство микробенчмарков выполнено на Macbook Pro 2016, 3.3 ГГц Intel Core i7, 16 ГБ 2133 МГц LPDDR3-памяти. Некоторые из них (работа с числами, удаление свойств объектов) были выполнены на MacBook Pro 2014, 3 Ггц Intel Core i7, 16 GB 1600 МГц DDR3-памяти. Замеры производительности для разных версий Node.js выполнялись на одном и том же компьютере. Мы внимательно следили за тем, чтобы на результаты испытаний не повлияли другие программы.

Давайте посмотрим на наши тесты и поговорим о том, что полученные результаты означают для будущего Node. Все испытания выполнялись с использованием пакета benchmark.js, данные на каждой из диаграмм означают число операций в секунду, то есть, чем полученное значение больше — тем лучше.

Проблема try/catch

Один из хорошо известных шаблонов деоптимизации заключается в использовании блоков try/catch.

Обратите внимание на то, что здесь и далее в списках описаний испытаний, в скобках, будут даны краткие названия испытаний на английском. Эти названия применяются для обозначения результатов на диаграммах. Кроме того, они помогут сориентироваться в коде, который использовался в ходе тестов. В этом испытании мы сравним четыре тестовых случая:

→ Код тестов на GitHub

Мы можем видеть, что то, что уже известно о негативном влиянии try/catch на производительность, подтверждается в Node 6 (V8 5.1), а в Node 8.0-8.2 (V8 5.8) try/catch оказывает гораздо меньшее влияние на производительность.

Также следует отметить, что вызов функции из блока try оказывается гораздо более медленным, чем вызов её за пределами try — это справедливо и для Node 6 (V8 5.1), и для Node 8.0-8.2 (V8 5.8).

Однако, в Node 8.3+ вызов функции из блока try на производительность практически не влияет.

Тем не менее, не стоит успокаиваться. Работая над некоторыми материалами для семинара по оптимизации, мы обнаружили ошибку, когда довольно специфическое стечение обстоятельств может привести к бесконечному циклу деоптимизации/реоптимизации в TurboFan. Это вполне можно считать очередным шаблоном-убийцей производительности.

Удаление свойств из объектов

Многие годы команду delete избегал любой, кто хотел писать высокопроизводительный код на JS (ну, по крайней мере, в случаях, когда надо было написать оптимальный код для самых нагруженных частей программ).

Проблема с delete сводится к тому, как V8 обходится с динамической природой объектов JavaScript, и с цепочками прототипов (также потенциально динамическими), которые усложняют поиск свойств на низком уровне реализации движка.

Подход движка V8 к созданию высокопроизводительных объектов со свойствами заключается в создании класса на уровне C++, основываясь на «форме» объекта, то есть — на том, какие ключи и значения имеет объект (включая ключи и значения цепочки прототипов). Эти конструкции известны как «скрытые классы». Однако, этот тип оптимизации производится во время выполнения программы. Если же нет уверенности по поводу формы объекта, у V8 имеется ещё один режим поиска свойств: поиск по хэш-таблице. Такой поиск свойств гораздо медленнее.

Исторически сложилось так, что когда мы удаляем командой delete ключ из объекта, последующие операции доступа к свойствам будут выполняться методом поиска в хэш-таблице. Именно поэтому программисты команду delete стараются не использовать, вместо этого устанавливая свойства в undefined, что, в плане уничтожения значения, ведёт к тому же результату, но добавляет сложностей при проверке существования свойства. Однако, обычно такой подход достаточно хорош, например, при подготовке объектов к сериализации, так как JSON.stringify не включает значения undefined в свой вывод (undefined, в соответствии со спецификацией JSON, не относится к допустимым значениям).

Теперь давайте выясним, решает ли новая реализация TurboFan проблему удаления свойств из объектов.

Тут мы сравним три тестовых случая:

→ Код тестов на GitHub

В V8 6.0 и 6.1 (они ещё не используются ни в одном из релизов Node), удаление последнего свойства, добавленного к объекту, соответствует оптимизированному TurboFan пути выполнения программы, и, таким образом, выполняется даже быстрее, чем установка свойства в undefined. Это очень хорошо, так как говорит о том, что команда разработчиков V8 работает над улучшением производительности команды delete.

Однако, использование этого оператора всё ещё приводит к серьёзному падению производительности при доступе к свойствам, если из объекта было удалено свойство, которое не является последним из добавленных. Это наблюдение нам помог сделать Якоб Куммеров, указавший на особенность наших тестов, в которых был исследован лишь вариант с удалением последнего добавленного свойства. Выражаем ему благодарность. В итоге, как ни хотелось бы нам сказать, что команду delete можно и нужно использовать в коде, написанном для будущих релизов Node, мы вынуждены рекомендовать этого не делать. Команда delete продолжает негативно влиять на производительность.

Утечка и преобразование в массив объекта arguments

Типичная проблема с неявно создаваемым объектом arguments, доступным в обычных функциях (в противовес им, стрелочные функции объекта arguments не имеют), заключается в том, что он похож на массив, но массивом не является.

Для того, чтобы использовать методы массивов или особенности их поведения, индексируемые свойства arguments необходимо скопировать в массив. В прошлом у JS-разработчиков была склонность ставить знак равенства между более коротким и более быстрым кодом. Хотя такой подход, в случае клиентского кода, позволяет достичь снижения объёма данных, которые должен загрузить браузер, то же самое может повлечь проблемы с серверным кодом, где размер программ гораздо менее важен, нежели скорость их выполнения. В результате, соблазнительно короткий способ преобразовать объект arguments в массив стал весьма популярным:

Array.prototype.slice.call(arguments). Такая команда вызывает метод slice объекта Array, передавая объект arguments как контекст this для этого метода. Метод slice видит объект, который похож на массив, после чего делает своё дело. В результате мы получаем массив, собранный из содержимого объекта arguments, похожего на массив.

Однако, когда неявно создаваемый объект arguments передаётся чему-либо, находящемуся вне контекста функции (например, если его возвращают из функции или передают другой функции, как при вызове Array.prototype.slice.call(arguments)), обычно это вызывает падение производительности. Исследуем это утверждение.

Следующий микробенчмарк нацелен на исследование двух взаимосвязанных ситуаций в четырёх версиях V8. А именно, это цена утечки arguments и цена копирования arguments в массив, который потом передаётся за пределы функции вместо объекта arguments.

Вот наши тестовые случаи:

→ Код тестов на GitHub

Взглянем теперь на те же самые данные, представленные в форме линейного графика для того, чтобы подчеркнуть изменения в характеристиках производительности.

Вот какие выводы можно из всего этого сделать. Если нужно писать производительный код, предусматривающий обработку входных данных функции в виде массива (что, по опыту знаю, нужно довольно часто), то в Node 8.3 и выше нужно использовать оператор расширения. В Node 8.2 и ниже следует использовать цикл for для копирования ключей из arguments в новый (заранее созданный) массив (подробности вы можете увидеть в коде тестов).

Далее, в Node 8.3+ падения производительности при передаче объекта arguments в другие функции не происходит, поэтому тут могут быть другие преимущества в плане производительности, если нам не нужен полный массив и можно работать со структурой, похожей на массив, но массивом не являющейся.

Частичное применение (каррирование) и привязка контекста функций

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

Например:

function add (a, b) {  return a + b } const add10 = function (n) {  return add(10, n) } console.log(add10(20)) В этом примере параметр a функции add частично применён как число 10 в функции add10.

Более краткая форма частичного применения функции стала доступна начиная с EcmaScript 5 благодаря методу bind:

function add (a, b) {  return a + b } const add10 = add.bind(null, 10) console.log(add10(20)) Однако, обычно метод bind не используют, так как он ощутимо медленнее, чем вышеописанный способ с замыканием.

В нашем испытании измеряется разница между использованием bind и замыкания в различных версиях V8. Для сравнения здесь же используется непосредственный вызов исходной функции.

Вот четыре тестовых случая.

→ Код тестов на GitHub

Линейная диаграмма результатов испытаний чётко показывает практически полное отсутствие различий между рассмотренными методами работы с функциями в последних версиях V8. Что интересно, частичное применение с использованием стрелочных функций значительно быстрее, чем использование обычных функций (как минимум, в наших тестах). На самом деле, оно практически совпадает с непосредственным вызовом функции. В V8 5.1 (Node 6) и 5.8 (Node 8.0-8.2) bind очень медленный, и выглядит очевидным, что использование стрелочных функций для этих целей позволяет достичь самой высокой скорости. Однако, производительность при использовании bind, начиная с V8 версии 5.9 (Node 8.3+) значительно растёт. Такой подход оказывается самым быстрым (хотя, разница в производительности тут практически неразличима) в V8 6.1 (Node будущих версий).

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

Размер кода функции

Размер функции, включая её сигнатуру, пробелы и даже комментарии, может повлиять на то, может ли V8 сделать функцию встроенной, или нет. Да, это так: добавление комментариев к функции может примерно на 10% снизить производительность. Изменится ли это в будущем?

В данном испытании мы исследуем три сценария:

→ Код тестов на GitHub

В V8 5.1 (Node 6) тесты sum small function и long all together показывают один и тот же результат. Это отлично иллюстрирует то, как работает встраивание. Когда мы вызываем маленькую функцию, это аналогично тому, что V8 записывает содержимое данной функции в место, откуда её вызывают. Поэтому, когда мы пишем текст функции (даже с добавлением комментариев), мы вручную встраиваем её в место вызова и производительность оказывается одной и той же. Опять же, в V8 5.1 (Node 6) можно видеть, что вызов функции, дополненной комментариями, после достижения функцией определённого размера, ведёт к значительно более медленному выполнению кода.

В Node 8.0-8.2 (V8 5.8) ситуация, в целом, остаётся такой же, за исключением того, что стоимость вызова маленькой функции заметно выросла. Это, вероятно, из-за смешивания элементов Crankshaft и TurboFan, когда одна функция может быть в Crankshaft, а другая — в TurboFan, что приводит к разладу механизмов встраивания (то есть, должен произойти переход между кластерами последовательно встроенных функций).

В V8 5.9 и выше (Node 8.3+) добавление посторонних символов, таких, как пробелы или комментарии, не влияет на производительность функций. Это происходит из-за того, что TurboFan использует для вычисления размера функции абстрактное синтаксическое дерево (AST, Abstract Syntax Tree), вместо того, чтобы как Crankshaft, считать символы. Вместо того, чтобы принимать во внимание число байтов функции, TurboFan анализирует реальные инструкции функции, поэтому начиная с V8 5.9 (Node 8.3+) пробелы, символы, из которых составлены имена переменных, сигнатуры функций и комментарии больше не влияют на то, может ли функция быть встроенной. Кроме того, нельзя не заметить то, что общая производительность функций снижается.

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

32-битные и 64-битные целые числа

Хорошо известно, что в JavaScript есть лишь один числовой тип: Number.

Однако, V8 реализован на C++, поэтому базовый тип числового значения JavaScript — это вопрос выбора.

В случае с целыми числами (то есть, тогда, когда мы задаём числа в JS без десятичной точки), V8 считает все числа 32-х битными — до тех пор, пока они перестанут таковыми являться. Это кажется вполне справедливым выбором, так как во многих случаях числа находятся в диапазоне 2147483648 -2147483647. Если JS-число (целиком) превышает 2147483647, JIT-компилятору приходится динамически менять базовый тип числового значения на тип с двойной точностью (с плавающей запятой) — это может, в потенциале, оказать определённое влияние на другие оптимизации.

В этом испытании мы рассмотрим три сценария:

→ Код тестов на GitHub

Диаграмма позволяет говорить о том, что, идёт ли речь о Node 6 (V8 5.1), или о Node 8 (V8 5.8), или даже о будущих версиях Node, вышеописанное наблюдение остаётся справедливым. А именно, оказывается, что вычисления с использованием целых чисел, превышающих 2147483647, приводят к тому, что функции исполняются со скоростью, находящейся в районе половины или двух третей от максимальной. Поэтому, если у вас есть длинные цифровые ID — помещайте их в строки.

Кроме того, очень заметно, что операции с числами, укладывающимися в 32-битный диапазон, выполняются гораздо быстрее в Node 6 (V8 5.1), а также в Node 8.1 и 8.2 (V8 5.8), чем в Node 8.3+ (V8 5.9+). Однако, операции над числами двойной точности в Node 8.3+ (V8 5.9+) выполняются быстрее. Вероятно, это так из-за замедления в обработке 32-битных чисел, и не относится к скорости вызова функций или циклов for, которые используются в коде тестов.

Якоб Куммеров, Ян Го и команда V8 помогли нам сделать результаты этого испытания правильнее и точнее. Мы благодарны им за это.

Перебор свойств объектов

Взятие значений всех свойств объекта и выполнение с ними каких-то действий — распространённая задача. Существует множество способов её решения. Выясним, какой из способов самый быстрый в исследуемых версиях V8 и Node.

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

Кроме того, мы провели три дополнительных теста для V8 версий 5.8, 5.9, 6.0 и 6.1: Мы не проводили эти тесты в V8 5.1 (Node 6), так как эта версия не поддерживает встроенного метода EcmaScript 2017 Object.values.

→ Код тестов на GitHub

В Node 6 (V8 5.1) и Node 8.0-8.2 (V8 5.8) использование цикла for-in, без сомнения, является самым быстрым способом перебора ключей объекта, и затем — доступа к значениям его свойств. Этот способ даёт примерно 40 миллионов операций в секунду, что в 5 раз быстрее, чем при использовании ближайшего по производительности подхода, предусматривающего использование Object.keys, и дающего примерно 8 миллионов операций в секунду.

В V8 6.0 (Node 8.3) с циклом for-in что-то случилось и производительность упала до всего четвёртой части скорости, достижимой в предыдущих версиях. Однако, это подход остался самым производительным.

В V8 6.1 (то есть, в будущих версиях Node), производительность метода, использующего Object.keys, растёт, этот метод оказывается быстрее метода с циклом for-in, однако, скорость пока даже не приближается к тем результатам, которые были характерны для for-in в V8 5.1 и 5.8 (Node 6, Node 8.0-8.2).

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

Применение Object.values для прямого получения значений свойств медленнее, чем использование Object.keys и доступ к значениям объектов по ключам. Кроме того, процедурные циклы оказываются быстрее, чем функциональный подход. Таким образом, при таком подходе может понадобиться больше работы, когда дело доходит до перебора свойств объектов.

Кроме того, для тех, кто привык пользоваться циклом for-in из-за его высокой производительности, текущее состояние дел может оказаться весьма неприятным. Значительная часть скорости теряется, а никакой доступной альтернативы нам не предлагают.

Создание объектов

Создание объектов в JS — это то, что происходит постоянно, поэтому данный процесс исследовать будет очень полезно.

Мы собираемся провести три набора тестов:

→ Код тестов на GitHub

В Node 6 (V8 5.1) все подходы показывают примерно одинаковые результаты.

В Node 8.0-8.2 (V8 5.8), при создании объектов из классов EcmaScript 2015, производительность составляет менее половины той, которая достижима с использованием объектных литералов или функций-конструкторов. Как вы понимаете, вполне очевидно, чем стоит пользоваться в этих версиях Node.

В V8 5.9 разные способы создания объектов снова показывают одну и ту же производительность.

Затем, в V8 6.0 (надеемся, это будет Node 8.3 или 8.4) и 6.1 (пока эта версия V8 не ассоциируется ни с одним будущим релизом Node), скорость создания объектов оказывается просто сумасшедшей. Более 500 миллионов операций в секунду! Это просто потрясающе.

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

Надо сказать, что Якоб Куммеров отметил здесь, что TurboFan способен оптимизировать выделение объектов в нашем микробенчмарке. Мы планируем это исследовать и обновить результаты испытаний.

Полиморфные и мономорфные функции

Если мы всегда передаём в функцию аргумент одного и того же типа (скажем, это строка), это значит, что мы используем функцию мономорфно. Но некоторые функции рассчитаны на полиморфизм. Это означает, что один и тот же параметр может быть представлен различными скрытыми классами. Возможно, он может обрабатываться, как строка, или как массив, или как какой-то произвольный объект. Такой подход позволяет, в некоторых ситуациях, создавать приятные программные интерфейсы, но плохо влияет на производительность. Испытаем полиморфное и мономорфное использование функций.

Мы собираемся исследовать пять тестовых случаев:

→ Код тестов на GitHub

Данные на диаграмме убедительно показывают то, что мономорфные функции работают быстрее полиморфных во всех исследованных версиях V8.

Разрыв в производительности между мономорфными и полиморфными функциями в V8 6.1 (в движке, который получит одна из будущих версий Node) особенно велик, что усугубляет ситуацию. Однако, стоит отметить, что этот тест использует экспериментальную ветку node-v8, в которой применяется нечто вроде «ночной сборки» V8, поэтому данный результат вполне может не соответствовать реальным характеристикам V8 6.1.

При написании кода, который должен быть оптимальным, то есть, речь идёт о функции, которая будет постоянно вызываться, полиморфизма следует избегать. С другой стороны, если функцию вызывают раз или два, скажем, эта функция используется для подготовки программы к работе, полиморфные API вполне приемлемы.

По поводу этого испытания хотим отметить, что команда V8 сообщила нам о том, что им не удалось надёжно воспроизвести результаты этого теста, используя их внутреннюю систему исполнения, d8. Однако, эти тесты удаётся воспроизвести на Node. Результаты теста следует рассматривать, исходя из предположения, что ситуация может измениться в обновлениях Node (основываясь на том, как Node интегрируется с V8). Этот вопрос требует дополнительного анализа. Благодарим Якоба Куммерова за то, что обратил на это наше внимание.

Ключевое слово debugger

И, наконец, поговорим о ключевом слове debugger.

Не забудьте убрать это ключевое слово из продакшн-кода. Иначе ни о какой производительности и речи быть не может.

Тут мы исследовали два тестовых случая:

→ Код тестов на GitHub

Говорить тут особо нечего. Все версии V8 показывают сильнейшее падение производительности при использовании ключевого слова debugger.

Тут можно обратить внимание на то, что линия графика для теста without debugger заметно идёт вниз в более свежих версиях V8.

Испытание на реальной задаче: сравнение логгеров

В дополнение к микробенчмаркам, мы можем взглянуть на то, как разные версии V8 решают практические задачи. Для этого используем несколько популярных логгеров для Node.js, которые мы с Маттео исследовали, создавая логгер Pino.

На нижеприведённой гистограмме показано время, необходимое наиболее популярным логгерам для вывода 10 тысяч строк (чем столбик ниже — тем лучше) в Node.js 6.11 (Crankshaft).

Вот — то же самое, но уже с использованием V8 6.1 (TurboFan). В то время, как все логгеры показали примерно двукратный рост производительности, Winston извлёк максимум пользы из нового JIT-компилятора TurboFan. Похоже, в данном случае на производительность повлияли сразу несколько факторов, которые, по отдельности, проявлялись в наших микробенчмарках. Самые медленные способы работы в Crankshaft оказываются значительно быстрее в TurboFan, в то время как то, что быстрее всего работает при использовании Crankshaft, оказывается в TurboFan немного медленнее. Логгер Winston, который был самым неторопливым, вероятно, использует техники, которые являются самыми медленными в Crankshaft, но оказываются гораздо быстрее в TurboFan. В то же время, Pino оптимизирован в расчёте на максимальную производительность в Crankshaft. Он показывает сравнительно небольшой прирост производительности.

Итоги

Некоторые из тестов показывают, что то, что было медленным в V8 5.1, 5.8 и 5.9, оказывается быстрее благодаря полноценному использованию TurboFan в V8 6.0 и 6.1. В то же время, то, что было самым быстрым, теряет в производительности, нередко показывая те же результаты, что и более медленные варианты после роста их скорости.

В основном это связано со стоимостью выполнения вызовов функций в TurboFan (V8 6.0 и выше). Основная идея при работе над TurboFan заключалась в оптимизации того, что используется наиболее часто, а так же в том, чтобы существующие «убийцы производительности V8» не влияли бы слишком сильно на скорость выполнения программ. Это привело к общему росту производительности браузерного (Chrome) и серверного (Node) кода. Компромисс, похоже, заключается в падении производительности тех подходов, которые ранее были самыми быстрыми. Надеемся, это временное явление. При сравнении производительности логгеров было выяснено, что общий эффект от использования TurboFan заключается в значительном росте производительности приложений с очень разной кодовой базой (например, это касается Winston и Pino).

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

Уважаемые читатели! Какие подходы к оптимизации JavaScript используете вы?

sohabr.net

node.js - Оптимизация веб-поиска Node.js

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

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

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

Итак, я бы, наверное, начинал с такого дизайна:

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

Затем создайте еще один процесс node.js, который многократно захватывает файлы с диска, анализирует их, очищает данные и сохраняет данные в вашей базе данных SQL.

Запустите первый процесс node.js сам по себе и пусть он запустится до тех пор, пока он не соберет ни 1000 веб-страниц, ни 15 минут (в зависимости от того, что наступит раньше), чтобы определить, на какой скорости вы изначально способны. Во время работы обратите внимание на использование ЦП и использование сети на вашем компьютере. Если вы уже находитесь в фокусе того, что вам может понадобиться для этого первого процесса node.js, то вы закончите с первым процессом node.js. Если вы хотите, чтобы он шел намного быстрее, вам нужно выяснить, где находится ваше узкое место. Если вы привязаны к процессору (маловероятно для этой задачи ввода-вывода), вы можете сгруппировать и запустить несколько из этих процессов node.js, предоставляя каждому набор URL-адресов для извлечения и отдельное место для записи собранных данных. Скорее всего, вы связаны с I/O. Это может быть связано либо с тем, что вы не полностью насыщаете существующее сетевое соединение (процесс node.js тратит слишком много времени на ожидание ввода-вывода), либо вы уже насытили свое сетевое соединение, и теперь это узкое место. Вам нужно будет выяснить, кто из них. Если вы добавите несколько одновременных веб-страниц, и производительность не увеличится или даже не снизится, значит, вы уже уже насытили свое веб-соединение. Вам также нужно будет следить за насыщением подсистемы ввода-вывода файла в node.js, которая использует пул потоков ограничений для реализации async I/O.

Для второго процесса node.js вы выполняете аналогичный процесс. Дайте ему 1000 веб-страниц и посмотрите, как быстро они могут обрабатывать их все. Поскольку у вас есть I/O для чтения диска с форматом файлов и для записи в базу данных, вы захотите иметь одновременно несколько парсов, чтобы вы могли максимизировать использование CPU, когда одна страница читается или записывается вне. Вы можете либо написать один процесс node.js для обработки нескольких проектов разбора сразу, либо вы можете сгруппировать один процесс node.js. Если у вас несколько процессоров на вашем сервере, вы захотите иметь как минимум столько же процессов, сколько у вас есть процессоры. В отличие от процесса отбора URL-адресов, код для синтаксического анализа, скорее всего, может быть серьезно оптимизирован, чтобы быть быстрее. Но, как и другие проблемы с производительностью, не пытайтесь чрезмерно оптимизировать этот код, пока не узнаете, что вы связаны с процессором, и он удерживает вас.

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

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

FYI, некоторые интернет-провайдеры интернет-провайдера могут устанавливать некоторые аварийные сигналы с объемом и скоростью ваших запросов данных. То, что они будут делать с этой информацией, может сильно варьироваться от одного провайдера к другому. Я бы подумал, что в конечном итоге у некоторых есть возможность ограничить ваше соединение, чтобы защитить качество обслуживания для других, которые используют один и тот же канал, но я не знаю, когда/если они это сделают.

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

qaru.site

node.js - Оптимизация Node.js для большого количества исходящих HTTP-запросов?

Мой сервер node.js испытывает время, когда он становится медленным или не отвечает, даже изредка приводя к 503 задержкам шлюза при попытке подключиться к серверу.

Я на 99% уверен (на основе тестов, которые я выполнил), что это отставание происходит именно из большого количества исходящих запросов, которые я делаю с помощью node -oauth модуль, чтобы связаться с внешними API (Facebook, Twitter и многие другие). По общему признанию, количество исходящих запросов делается относительно большим (порядка 30 или около того в минуту). Хуже того, это часто означает, что соответствующие входящие запросы на мой сервер могут занять ~ 5-10 секунд. Тем не менее, у меня была предыдущая версия моего API, которую я написал на PHP, который мог обрабатывать это количество исходящих запросов без каких-либо проблем. Фактически, использование ЦП для тех же самых (или даже меньше) запросов с моим API node.js примерно в 5 раз выше, чем у моего PHP API.

Итак, я пытаюсь изолировать, где я могу улучшить это, и, самое главное, убедиться, что 503 тайм-аута не происходит. Вот некоторые вещи, о которых я читал или экспериментировал с:

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

Заранее благодарим за любые мысли или вклады.

FWIW, я также использую express и mongoose, а мои серверы размещены на Amazon Cloud (2x M1.Large для серверов node, 2x балансировщика нагрузки и 3x экземпляра M1.Small MongoDB).

qaru.site


Prostoy-Site | Все права защищены © 2018 | Карта сайта