Тонкости Javascript/Node.js. Увеличиваем производительность в десятки раз

Вступление


Появилась необходимость обмениваться сообщениями между сервером и клиентом в бинарном виде, но в формате JSON в конечном итоге. Начал я гуглить, какие существуют библиотеки упаковки в бинарный вид. Пересмотрел немало: MesssagePack, Bson, protobuf, capnproto.org и другие. Но эти все библиотеки позволяют паковать и распаковывать готовые бинарные пакеты. Не очень копался, возможно ли делать парсер входящего трафика по кускам. Но суть не в этом. С такой задачей никогда не сталкивался и решил поиграться с нодой и сделать свой. Куда же без костылей и велосипедов? И вот с какими особенностями Node.js я столкнулся…

Написал я пакер и запустил…

var start = Date.now();

for (i=0; i < 1000000; i++) {
    packer.pack({abc: 123, cde: 5});
}

console.log(Date.now() - start);

Выдал ~4300. Удивился… Почему так долго? В то время, как код:

var start = Date.now();

for (i=0; i < 1000000; i++) {
    JSON.stringify({abc: 123, cde: 5});
}

console.log(Date.now() - start);

Выдал ~350. Не понял. Начал копать свой код и искать, где же много ресурсов используется. И нашел.

Запустим этот код:

function find(val){

    function index (value) {
        return [1,2,3].indexOf(value);
    }

    return index(val);
}

var start = Date.now();

for (i=0; i < 1000000; i++) {
    find(2);
}

console.log(Date.now() - start);

Выдает 1908. Вы скажете: да это не много на 1000000 повторений. А если я скажу, что много? Выполним такой код:

function index (value) {
    return [1,2,3].indexOf(value);
}

function find(val){

    return index(val);

}

var start = Date.now();

for (i=0; i < 1000000; i++) {
    find(2);
}

console.log(Date.now() - start);

Выдает 16. Мои коллеги тоже возмутились, но и заметили, что функция же создается динамически и сразу уничтожается, ты ее вынес и нет такой нагрузки. Из эксперимента вывод: динамические фунции не кешируюся в бинарном виде. Я согласился и возразил: да, но нет ни переменных в SCOPE ничего используемого внутри нее. Похоже, движок гугла всегда копирует SCOPE.

Ок. Провел оптимизацию этой фунциональности и запустил… и все равно. Выдал ~3000. Опять удивился. И снова полез копать… и обнаружил уже другой прикол.

Запустим этот код:

function test (object) {

    var a = 1,
        b = [],
        c = 0

    return {
        abc: function (val) {
               
        }
    }
}

var start = Date.now();

for (i=0; i < 1000000; i++) {
    var a = test();
    a.abc();
}

console.log(Date.now() - start);

Выдал 34. Теперь, допустим, нам надо внутри abc создать Array:

function test (object) {

    var a = 1,
        b = [],
        c = 0

    return {
        abc: function () {
            var arr1 = [];
        }
    }
}

var start = Date.now();

for (i=0; i < 1000000; i++) {
    var a = test();
    a.abc();
}

console.log(Date.now() - start);

Выдал 1826. Смеркалось… А если нам надо 3 массива?

function test (object) {

    var a = 1,
        b = [],
        c = 0

    return {
        abc: function () {
            var arr1 = [], arr2 = [], arr3 = [];
        }
    }
}

var start = Date.now();

for (i=0; i < 1000000; i++) {
    var a = test();
    a.abc();
}

console.log(Date.now() - start);

Выдал 5302! Вот это приколы. Казалось, SCOPE мы не используем, а создание пустого массива должно занимать вообще копейки. Не тут то было.

Думаю… А заменю-ка я на объекты. Результат получше, но не намного. Выдал 1071.

А теперь фокус. Многие скажут: ты же опять выносишь функцию. Да. Но фокус в другом.


function abc () {
     var arr1 = [], arr2 = [], arr3 = [];
}

function test (object) {

    var a = 1,
        b = [],
        c = 0

    return {
        abc: abc
    }
}

var start = Date.now();

for (i=0; i < 1000000; i++) {
    var a = test();
    a.abc();
}

console.log(Date.now() - start);

Многие заметят и скажут: будет такое же время. А не тут то было. Выдал 25. Хотя массивы создавались столько же раз. Делаем вывод: создание массивов в динамической функции тратит много ресурсов. Вопрос: почему?

Теперь вернемся к первой проблеме. Но с другой стороны. Вынесем Array:

var indexes = [1,2,3];

function find(val){

    function index (value) {
        return indexes.indexOf(value);
    }

    return index(val);
}

var start = Date.now();

for (i=0; i < 1000000; i++) {
    find(2);
}

console.log(Date.now() - start);

И я был прав. Выдал 58. С выносом всей фунции выдавал 16. Т.е. создание функции не особо ресурсоемкий процесс. Также опровергаем прошлый вывод:
бинарный код функций все же кешируется в памяти. А создание объектов в динамической функции занимает много времени.

Я раньше предполагал по-другому: все static/expression объекты, создаваемые временно, компилируются сразу как код функции. А, оказывается, нет. Делаем вывод:
движок гугла при каждом запуске создает новые объекты и заполняет необходимыми значениями, а потом уже вычисляет выражение, что не хорошо.

А с какими тонкостями сталкивались вы? Комментарии приветствуются.
Поделиться публикацией
Похожие публикации
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама
Комментарии 36
  • +7
    > Многие заметят и скажут: будет такое же время. А не тут то было. Выдал 25. Хотя массивы создавались столько же раз.

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

    А вообще, вы не открыли америку.
    • +5
      Вы видимо невнимательно читали статью. Да она создается много кратно. И я вывел время ее создания. Это 20 мс/1000000 повторений, а вот массивы создаваемые в ней уже создаются разное время.
    • +1
      Хотел поковыряться, но у меня почему-то и ваш начальный код, и ваш конечный код выдают одинаковое время, равное 20 мс, вот ссылка:
      http://jsbin.com/hevikonuhu/edit?html,console,output
      Возможно, я что-то делаю не так?
      • +1
        Попробуйте запустить на Node.js. Не знаю почему, но в браузере значительной разницы не вижу тоже. У меня выдает 776 для начального варианта и 716 для конечного.
        • +1
          А какая версия ноды используется? Может просто в браузере более свежая версия движка V8?
          • +1
            Тестировал в 4.2.2 (LTS). Сотрудник проводил на 5.x и тоже было такое.
            • +1
              Хм, действительно странно. Где первая оптимизация с 1908 до 16мс.
              • +1
                Случайно отправил и не успел отредактировать.
                Хм, действительно странно. Где первая оптимизация с 1908мс до 16мс. У меня на ноде 5.7.0x64 выдает (2573мс и 32мс), а в хроме 48.0.2564.116x64 (1491мс и 1252мс).
        • +1
          Это же просто замыкания.

          Создание нового замыканя потребляет ресурсы (ЦП+ОЗУ) для захвата области видимости, должно быть очевидно.
          Нужна максимальня производительность — избегайте замыканий в любом виде.
          • +1
            Так в замыкании не используются переменные, которые выше.
            • +4
              Оптимизатор тупой, не может предугадать, что испльзуется, а что — нет. Может у вас там eval-magic где-то спрятана?

              upd: я без наездов. Сам бы рад, чтобы он захватывал только необходимый минимум из доступных в области видимости данных, но увы. :(
          • 0
            Нашел оптимизацию этой особенности: везде в замыканиях используйте new Object() / new Array()
            • +1
              проверил только что на 5.6.0 ноде это дало ускорение. Приём не в замыканиях в тайпскрипте(хотя функции объявлены в в скоупе конструктора так что замыкания.)
              • +1
                Не все так однозначно с new Array
                https://gist.github.com/zxcabs/5d75c11f69445c4d9837
            • +6
              Немного не в тему, для замеров времени можно упростить:

              console.time('first test');
              тестируемый код
              console.timeEnd('first test');
              • +3
                1. канонично в node.js использовать process.hrtime() для измерения относительных промежутков времени — функция значительно точнее.

                  const start = process.hrtime();
                  // do op
                  const end = process.hrtime(start);
                  console.info("Время исполнения (hr): %ds %dms", end[0], end[1]/1000000);

                2. var a внутри цикла for — постоянное переобъявление переменной, используйте let если нужно ограничить scope, или объявите до цикла

                3. штудируем https://github.com/petkaantonov/bluebird/wiki/Optimization-killers касательно оптимизаций — многие вопросы отпадут сами собой
              • +1
                О господи, очередные откровения «как нам ускорить JS-код».
                Подымите руку, у кого в стандартной бизнес-логике (сходить в три бэкенда и сшаблонизировать данные) есть самописные циклы на 100 тысяч итераций.
                • +9
                  Зря вы так. HTML5 предоставляет широчайшие возможности в области работы с канвой. И в модулях отрисовки графики вполне вероятны "битвы" за каждую миллисекунду времени.
                  • +1
                    Тут одно из двух. Или ты пользуешься готовой библиотекой, а клиентскую логику пишешь как удобнее и понятнее, а не как «производительнее».
                    Или ты сам разрабатываешь такую библиотеку, и тогда подобные советы у тебя вызывают только недоумении «как этого можно не знать».
                    Новичкам нужно запомнить ровно одно правило: не занимайся преждевременной оптимизацией.
                    • +12
                      Или ты сам разрабатываешь такую библиотеку, и тогда подобные советы у тебя вызывают только недоумении «как этого можно не знать».
                      Давайте без лишнего пафоса. Можно писать библиотеку или некую логику, которая будет вызываться и чаще чем миллион раз, и не знать таких вещей. В конце концов, когда говорят об экономии на спичках, обычно упоминают об этом, а не об new `Object vs {}`.
                • +3
                  А вы не думали что функция которая возвращает indexOf из глобальной переменной могла просто заинлайниться, убрав в этом случае оверхед на лишних миллион созданий/вызовов внутренней функции?
                  • +3
                    Зашел на страницу статьи из-за горячего заголовка. Ожидал новую тру-практику. На деле же — просто разбор собственных полетов.

                    Во-первых, js-движку по барабану динамическая ли функция или не динамическая. По состоянию, важному для производительности, можно выделить откомпилированные функции и неоткомпилированные. Обычно функции компилируются при первом выполнении. Существуют и способы определения откомпилированных функций и без выполнения функции. Например, через new Function(..).

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

                    Стоит также отметить, что ни в коем случае нельзя пользоваться большими массивами через замыкания или параметры вызова функций. Это переполняет стек процесса и создает немалую нагрузку на процессор.
                    • +1
                      Как использование массивов через замыкания или аргументы влияет на стек процесса?
                      • +1
                        Подробнее здесь https://habrahabr.ru/company/plarium/blog/277129/

                        По поводу аргументов — у меня речь шла именно про большИе массивы. Разумеется, никто не запрещает передавать небольшие объекты/массивы, например, в качестве конфига. Дело в том, что в некоторых случаях использования массивов в качестве аргументов вызова функции, доступ к данным массива осуществляется не как через ссылку на исходный массив-объект, а происходит копирование массива и доступ к данным осуществляется уже к копии массива.
                        • 0
                          Не подскажете, где почитать про копирование массива при подстановке в функцию?
                          • 0
                            внимательно перечитал статью — не нашел там упоминания о передаче массива по значению. Вообще в моем понимании javascript этого не должно происходить ни при каком случае — объекты всегда передаются по ссылке, скаляры — по значению.
                            если я не прав — мое понимание javascript требует пересмотра с основ.
                            • +1
                              Вот так живешь живешь, а потом оказывается что в js массивы не по ссылкам передаются, а по значениям. Вы либо выразили свою мысль не правильно, либо несете что то из разряда фантастики.
                              • 0
                                Отвечу на один вопрос, заданный несколько раз выше, здесь.
                                Не помню точно, как дошел до этой практики. Уже тоже пруф найти не могу. Помню, дело было за долго до nodejs. Делали web-интерфейс на ExtJS для несложной но ёмкой БД. При активной передаче некоторых массивов описанным выше способом вешался весь браузер.
                                Сейчас похожее поведение может проявляться при вызовах из JS функций с кодом, например, написанных на Си.
                                • 0
                                  Кстати, справились с той проблемой так: вместо передачи массивов как аргументов, стали объявлять эти массивы как property у объектов-контроллеров, и доступ к ним в методах осуществлялся через this. Это давало значительный прирост производительности в ходе работы с приложением.
                          • +2
                            Конечно, оптимизация функций имеет место быть (в некоторых случаях она необходима), но в общем случае оптимизация архитектуры приложения даст вам намного больший прирост в производительности, чем чрезмерная оптимизация тела некоторых функций.

                            Статья интересная, но на практике это пригодится для узкого круга задач, и при условии что подходящего инструмента для решения задачи нет.

                            Если есть люди, которые столкнулись с такими задачами, отпишитесь в комментарии, пожалуйста.
                            • +5
                              Я конечно не эксперт в использовании IRHydra, но если ваш первый пример посмотреть через неё, то там сразу видно, что функция find не может быть оптимизирована (graph):

                              image
                              А вот второй вариант не просто оптимизирован, а успешно заинлайнен (graph):

                              image
                              Другие примеры в том же духе.
                              • 0
                                Было бы здорово, если бы нашелся эксперт (ну или хотя бы не эксперт) по этому и другим инструментам и поделился бы со всеми нами опытом профилирования и оптимизации для ноды.
                                • 0
                                  Так вот же он mraleph (link), Вячеслав сам к нам пришел.
                              • +4
                                Нельзя делать выводы на основе простых измерений, надо хотя бы профилировать и пытаться понять, что же на самом-то деле происходит внутри. Иначе получаются неправильные выводы.

                                Дело здесь в следующем — создание функций, которые содержат в себе литералы (например, array literal или там object literal), это более тяжелая операция по сравнению с созданием функций, которые в себе литералов не содержат.

                                Если взять и просто сравнить два профиля, то все тайное становится явным

                                function find(val){
                                    function index (value) {
                                        return [1, 2, 3].indexOf(value);
                                    }
                                
                                    return index(val);
                                }

                                    8.29%  67  | LazyCompile:*InnerArrayIndexOf native array.js:1020
                                *   7.67%  62  | v8::internal::JSFunction::set_literals
                                *   7.05%  57  | v8::internal::Factory::NewFunctionFromSharedFunctionInfo
                                *   5.81%  47  | v8::internal::Factory::NewFunction
                                *   4.58%  37  | v8::internal::Factory::New<v8::internal::JSFunction
                                *   4.33%  35  | v8::internal::Runtime_NewClosure
                                    4.21%  34  | Stub:FastCloneShallowArrayStub
                                    4.08%  33  | v8::internal::Heap::AllocateRaw
                                    3.34%  27  | LazyCompile:~index test.js:2
                                *   3.34%  27  | v8::internal::Factory::NewFunctionFromSharedFunctionInfo
                                    3.34%  27  | Builtin:ArgumentsAdaptorTrampoline
                                    3.09%  25  | v8::internal::Heap::Allocate
                                *   3.09%  25  | v8::internal::SharedFunctionInfo::SearchOptimizedCodeMap    
                                

                                function foo() {
                                  return [1, 2, 3];
                                }
                                
                                function find(val){
                                    function index (value) {
                                        return foo().indexOf(value);
                                    }
                                
                                    return index(val);
                                }

                                   13.58%  58  | LazyCompile:*InnerArrayIndexOf native array.js:1020
                                    9.82%  42  | Builtin:ArgumentsAdaptorTrampoline
                                    7.49%  32  | LazyCompile:~index test1.js:6
                                    7.01%  30  | LoadIC:A load IC from the snapshot
                                *   6.08%  26  | Stub:FastNewClosureStub
                                    5.62%  24  | Builtin:CallFunction_ReceiverIsNullOrUndefined
                                    3.74%  16  | LazyCompile:*foo test1.js:1
                                    3.51%  15  | Builtin:Call_ReceiverIsNullOrUndefined
                                    3.51%  15  | LazyCompile:*indexOf native array.js:1065        
                                

                                В первом случае мы ходим много в среду исполнения и там занимаемся всякой тяжелой и малополезной работой (например, клонированием массива литералов привязанного к замыканию), а во втором случае мы быстренько создаем замыкание с помощью FastNewClosureStub. Вот отсюда и основная разница.
                                • 0
                                  Есть целый огромный пост про оптимизацию итераторов в JS вот тут

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