Вступление


Появилась необходимость обмениваться сообщениями между сервером и клиентом в бинарном виде, но в формате 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)


  1. Akuma
    24.02.2016 14:02
    +7

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

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

    А вообще, вы не открыли америку.


    1. peacecoder85
      24.02.2016 14:13
      +5

      Вы видимо невнимательно читали статью. Да она создается много кратно. И я вывел время ее создания. Это 20 мс/1000000 повторений, а вот массивы создаваемые в ней уже создаются разное время.


  1. kroshanin
    24.02.2016 14:34
    +1

    Хотел поковыряться, но у меня почему-то и ваш начальный код, и ваш конечный код выдают одинаковое время, равное 20 мс, вот ссылка:
    http://jsbin.com/hevikonuhu/edit?html,console,output
    Возможно, я что-то делаю не так?


    1. peacecoder85
      24.02.2016 14:43
      +1

      Попробуйте запустить на Node.js. Не знаю почему, но в браузере значительной разницы не вижу тоже. У меня выдает 776 для начального варианта и 716 для конечного.


      1. Frozik
        24.02.2016 15:33
        +1

        А какая версия ноды используется? Может просто в браузере более свежая версия движка V8?


        1. peacecoder85
          24.02.2016 16:03
          +1

          Тестировал в 4.2.2 (LTS). Сотрудник проводил на 5.x и тоже было такое.


        1. Frozik
          24.02.2016 16:04
          +1

          Хм, действительно странно. Где первая оптимизация с 1908 до 16мс.


          1. Frozik
            24.02.2016 16:09
            +1

            Случайно отправил и не успел отредактировать.
            Хм, действительно странно. Где первая оптимизация с 1908мс до 16мс. У меня на ноде 5.7.0x64 выдает (2573мс и 32мс), а в хроме 48.0.2564.116x64 (1491мс и 1252мс).


  1. bohdan4ik
    24.02.2016 16:44
    +1

    Это же просто замыкания.

    Создание нового замыканя потребляет ресурсы (ЦП+ОЗУ) для захвата области видимости, должно быть очевидно.
    Нужна максимальня производительность — избегайте замыканий в любом виде.


    1. peacecoder85
      24.02.2016 16:48
      +1

      Так в замыкании не используются переменные, которые выше.


      1. bohdan4ik
        24.02.2016 16:49
        +4

        Оптимизатор тупой, не может предугадать, что испльзуется, а что — нет. Может у вас там eval-magic где-то спрятана?

        upd: я без наездов. Сам бы рад, чтобы он захватывал только необходимый минимум из доступных в области видимости данных, но увы. :(


  1. peacecoder85
    24.02.2016 16:50

    Нашел оптимизацию этой особенности: везде в замыканиях используйте new Object() / new Array()


    1. Aquahawk
      24.02.2016 17:25
      +1

      проверил только что на 5.6.0 ноде это дало ускорение. Приём не в замыканиях в тайпскрипте(хотя функции объявлены в в скоупе конструктора так что замыкания.)


    1. zxcabs
      24.02.2016 18:46
      +1

      Не все так однозначно с new Array
      https://gist.github.com/zxcabs/5d75c11f69445c4d9837


      1. peacecoder85
        24.02.2016 19:49
        +1

        Комментарий удален


  1. Shannon
    24.02.2016 18:05
    +6

    Немного не в тему, для замеров времени можно упростить:

    console.time('first test');
    тестируемый код
    console.timeEnd('first test');


    1. Makeomatic
      24.02.2016 23:24
      +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. forgotten
    24.02.2016 18:56
    +1

    О господи, очередные откровения «как нам ускорить JS-код».
    Подымите руку, у кого в стандартной бизнес-логике (сходить в три бэкенда и сшаблонизировать данные) есть самописные циклы на 100 тысяч итераций.


    1. kroshanin
      24.02.2016 20:52
      +9

      Зря вы так. HTML5 предоставляет широчайшие возможности в области работы с канвой. И в модулях отрисовки графики вполне вероятны "битвы" за каждую миллисекунду времени.


      1. forgotten
        24.02.2016 21:05
        +1

        Тут одно из двух. Или ты пользуешься готовой библиотекой, а клиентскую логику пишешь как удобнее и понятнее, а не как «производительнее».
        Или ты сам разрабатываешь такую библиотеку, и тогда подобные советы у тебя вызывают только недоумении «как этого можно не знать».
        Новичкам нужно запомнить ровно одно правило: не занимайся преждевременной оптимизацией.


        1. faiwer
          24.02.2016 21:19
          +12

          Или ты сам разрабатываешь такую библиотеку, и тогда подобные советы у тебя вызывают только недоумении «как этого можно не знать».
          Давайте без лишнего пафоса. Можно писать библиотеку или некую логику, которая будет вызываться и чаще чем миллион раз, и не знать таких вещей. В конце концов, когда говорят об экономии на спичках, обычно упоминают об этом, а не об new `Object vs {}`.


  1. zim32
    25.02.2016 00:56
    +3

    А вы не думали что функция которая возвращает indexOf из глобальной переменной могла просто заинлайниться, убрав в этом случае оверхед на лишних миллион созданий/вызовов внутренней функции?


  1. ckr
    25.02.2016 01:19
    +3

    Зашел на страницу статьи из-за горячего заголовка. Ожидал новую тру-практику. На деле же — просто разбор собственных полетов.

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

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

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


    1. Sayonji
      25.02.2016 01:46
      +1

      Как использование массивов через замыкания или аргументы влияет на стек процесса?


      1. ckr
        25.02.2016 03:13
        +1

        Подробнее здесь https://habrahabr.ru/company/plarium/blog/277129/

        По поводу аргументов — у меня речь шла именно про большИе массивы. Разумеется, никто не запрещает передавать небольшие объекты/массивы, например, в качестве конфига. Дело в том, что в некоторых случаях использования массивов в качестве аргументов вызова функции, доступ к данным массива осуществляется не как через ссылку на исходный массив-объект, а происходит копирование массива и доступ к данным осуществляется уже к копии массива.


        1. Sayonji
          25.02.2016 18:25

          Не подскажете, где почитать про копирование массива при подстановке в функцию?


        1. gearbox
          25.02.2016 18:49

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


        1. zxcabs
          25.02.2016 23:11
          +1

          Вот так живешь живешь, а потом оказывается что в js массивы не по ссылкам передаются, а по значениям. Вы либо выразили свою мысль не правильно, либо несете что то из разряда фантастики.


        1. ckr
          27.02.2016 20:13

          Отвечу на один вопрос, заданный несколько раз выше, здесь.
          Не помню точно, как дошел до этой практики. Уже тоже пруф найти не могу. Помню, дело было за долго до nodejs. Делали web-интерфейс на ExtJS для несложной но ёмкой БД. При активной передаче некоторых массивов описанным выше способом вешался весь браузер.
          Сейчас похожее поведение может проявляться при вызовах из JS функций с кодом, например, написанных на Си.


          1. ckr
            27.02.2016 20:26

            Кстати, справились с той проблемой так: вместо передачи массивов как аргументов, стали объявлять эти массивы как property у объектов-контроллеров, и доступ к ним в методах осуществлялся через this. Это давало значительный прирост производительности в ходе работы с приложением.


  1. RomanYakimchuk
    25.02.2016 10:20
    +2

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

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

    Если есть люди, которые столкнулись с такими задачами, отпишитесь в комментарии, пожалуйста.


  1. RubaXa
    25.02.2016 11:38
    +5

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

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

    image
    Другие примеры в том же духе.


    1. gaelpa
      26.02.2016 21:41

      Было бы здорово, если бы нашелся эксперт (ну или хотя бы не эксперт) по этому и другим инструментам и поделился бы со всеми нами опытом профилирования и оптимизации для ноды.


      1. RubaXa
        27.02.2016 10:09

        Так вот же он mraleph (link), Вячеслав сам к нам пришел.


  1. mraleph
    25.02.2016 18:33
    +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. Вот отсюда и основная разница.


  1. mrsum
    25.02.2016 23:13

    Есть целый огромный пост про оптимизацию итераторов в JS вот тут