image

В этом посте изложены советы, как не написать код, производительность которого окажется гораздо ниже ожидаемой. Особенно это касается ситуаций, когда движок V8 (используемый в Node.js, Opera, Chromium и т. д.) отказывается оптимизировать какие-то функции.

Особенности V8


В этом движке нет интерпретатора, но зато есть два разных компилятора: обычный и оптимизирующий. Это означает, что ваш JS-код всегда компилируется и выполняется напрямую как нативный. Думаете, это значит быстро? Ошибаетесь. Компиляция в нативный код не слишком-то улучшает производительность. Мы лишь избавляемся от использования интерпретатора, но неоптимизированный код так и будет работать медленно.

Например, в обычном компиляторе выражение a + b будет выглядеть так:

mov eax, a
mov ebx, b
call RuntimeAdd

Это всего лишь вызов соответствующей функции. Если a и b будут целочисленными, тогда код будет выглядеть так:

mov eax, a
mov ebx, b
add eax, ebx

А этот вариант будет работать гораздо быстрее вызова, который во время выполнения обрабатывает сложную дополнительную JS-семантику. Иными словами, обычный компилятор генерирует неоптимизированный, «сырой» код, а оптимизирующий компилятор доводит его до ума, приводя к финальному виду. При этом производительность оптимизированного кода может раз в 100 превышать производительность «обычного». Но дело в том, что вы не можете просто написать любой JS-код и оптимизировать его. Существует немало шаблонов программирования (часть из них даже идиоматические), которые оптимизирующий компилятор отказывается обрабатывать.

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

Ниже мы рассмотрим большинство шаблонов, чьи функции попадают в «ад деоптимизации». Чаще всего имеет смысл их изменить, и предлагаемые решения могут стать ненужными, когда компилятор научится распознавать всё новые и новые шаблоны.

1. Использование встроенного инструментария


Чтобы определять, как шаблоны влияют на оптимизацию, вы должны уметь использовать Node.js с некоторыми флагами V8. Создаёте функцию с неким шаблоном, вызываете её со всевозможными типами данных, а затем вызываете внутреннюю функцию V8 для проверки и оптимизации:

test.js:
// Function that contains the pattern to be inspected (using with statement)
function containsWith() {
    return 3;
    with({}) {}
}

function printStatus(fn) {
    switch(%GetOptimizationStatus(fn)) {
        case 1: console.log("Function is optimized"); break;
        case 2: console.log("Function is not optimized"); break;
        case 3: console.log("Function is always optimized"); break;
        case 4: console.log("Function is never optimized"); break;
        case 6: console.log("Function is maybe deoptimized"); break;
        case 7: console.log("Function is optimized by TurboFan"); break;
        default: console.log("Unknown optimization status"); break;
    }
}

// Fill type-info
containsWith();
// 2 calls are needed to go from uninitialized -> pre-monomorphic -> monomorphic
containsWith();

%OptimizeFunctionOnNextCall(containsWith);
// The next call
containsWith();

// Check
printStatus(containsWith);

Запуск:

$ node --trace_opt --trace_deopt --allow-natives-syntax test.js
Function is not optimized

Чтобы проверить работоспособность, закомментируйте выражение with и перезапустите:

$ node --trace_opt --trace_deopt --allow-natives-syntax test.js
[optimizing 000003FFCBF74231 <JS Function containsWith (SharedFunctionInfo 00000000FE1389E1)> - took 0.345, 0.042, 0.010 ms]
Function is optimized

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

2. Неподдерживаемый синтаксис


Некоторые конструкции явным образом не поддерживаются оптимизирующим компилятором, поскольку используют неоптимизируемый синтаксис.

Важно: даже если конструкция недоступна или не выполняется, она всё равно не позволяет оптимизировать содержащую её функцию.

Например, бесполезно делать так:

if (DEVELOPMENT) {
    debugger;
}

Этот код повлияет на всю функцию, даже если выражение debugger так и не будет выполняться.

На данный момент не оптимизируются:

  • функции-генераторы;
  • функции, содержащие выражение for-of;
  • функции, содержащие выражение try-catch;
  • функции, содержащие выражение try-finally;
  • функции, содержащие составной оператор присваивания let;
  • функции, содержащие составной оператор присваивания const;
  • функции, содержащие объектные литералы, которые, в свою очередь, содержат объявления __proto__, get или set.

Скорее всего, неоптимизируемы:

  • функции, содержащие выражение debugger;
  • функции, вызывающие eval();
  • функции, содержащие выражение with.

Чтобы не было недопонимания: если функция содержит что-то из перечисленного ниже, то она не будет оптимизироваться целиком:

function containsObjectLiteralWithProto() {
    return {__proto__: 3};
}

function containsObjectLiteralWithGetter() {
    return {
        get prop() {
            return 3;
        }
    };
}

function containsObjectLiteralWithSetter() {
    return {
        set prop(val) {
            this.val = val;
        }
    };
}

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

Обходное решение: от некоторых из этих выражений нельзя отказаться в коде готового продукта. Например, от try-finally или try-catch. Для минимизации пагубного влияния их следует изолировать в рамках небольших функций:

var errorObject = {value: null};
function tryCatch(fn, ctx, args) {
    try {
        return fn.apply(ctx, args);
    }
    catch(e) {
        errorObject.value = e;
        return errorObject;
    }
}

var result = tryCatch(mightThrow, void 0, [1,2,3]);
// Unambiguously tells whether the call threw
if(result === errorObject) {
    var error = errorObject.value;
}
else {
    // Result is the returned value
}

3. Использование arguments


Существует немало способов использовать arguments так, что оптимизировать функцию будет невозможно. Так что при работе с arguments следует быть особенно осторожными.

3.1. Переприсвоение заданного параметра при условии использования arguments в теле функции (только в нестабильном режиме (sloppy mode))


Типичный пример:

function defaultArgsReassign(a, b) {
     if (arguments.length < 2) b = 5;
}

В данном случае можно сохранить параметр в новую переменную:

function reAssignParam(a, b_) {
    var b = b_;
    // Unlike b_, b can safely be reassigned
    if (arguments.length < 2) b = 5;
}

Если бы это был единственный способ применения arguments в функции, то его можно было бы заменить проверкой на undefined:

function reAssignParam(a, b) {
    if (b === void 0) b = 5;
}

Если есть вероятность, что arguments будет использован позже в функции, то можно не беспокоиться о переприсвоении.

Другой способ решения проблемы: включить строгий режим ('use strict') для файла или функции.

3.2. Утекающие аргументы


function leaksArguments1() {
    return arguments;
}

function leaksArguments2() {
    var args = [].slice.call(arguments);
}

function leaksArguments3() {
    var a = arguments;
    return function() {
        return a;
    };
}

Объект arguments не должен никуда передаваться.

Проксирование можно осуществить с помощью создания внутреннего массива:

function doesntLeakArguments() {
                    // .length is just an integer, this doesn't leak
                    // the arguments object itself
    var args = new Array(arguments.length);
    for(var i = 0; i < args.length; ++i) {
                // i is always valid index in the arguments object
        args[i] = arguments[i];
    }
    return args;
}

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

Однако если ваш проект находится на стадии сборки, то этого можно достичь с помощью макроса, не требующего использования source maps и позволяющего сохранить исходный код в виде нормального JavaScript.

function doesntLeakArguments() {
    INLINE_SLICE(args, arguments);
    return args;
}

Эта методика используется в bluebird, и на стадии сборки код превращается в такой:

function doesntLeakArguments() {
    var $_len = arguments.length;var args = new Array($_len); for(var $_i = 0; $_i < $_len; ++$_i) {args[$_i] = arguments[$_i];}
    return args;
}

3.3. Присвоение аргументам


Это можно сделать только в нестабильном режиме:

function assignToArguments() {
    arguments = 3;
    return arguments;
}

Способ решения: просто не пишите такой идиотский код. В строгом режиме подобное творчество приведёт к исключению.

Как можно безопасно использовать arguments?


  • Применяйте arguments.length.
  • Применяйте arguments[i], где i всегда является правильным целочисленным индексом в arguments и не может быть вне его границ.
  • Никогда не используйте arguments напрямую без .length или [i].
  • Можно применять fn.apply(y, arguments) в строгом режиме. И больше ничего другого, в особенности .slice. Function#apply.
  • Помните, что добавление свойств функциям (например, fn.$inject =...) и ограниченным функциям (bound functions) (например, результат работы Function#bind) приводит к созданию скрытых классов, следовательно, это небезопасно при использовании #apply.

Если вы будете соблюдать всё перечисленное, то использование arguments не приведёт к выделению памяти для этого объекта.

4. Switch-case


Выражение switch-case на сегодняшний день может иметь до 128 пунктов case, и если превысить это количество, то содержащая данное выражение функция не сможет быть оптимизирована.

function over128Cases(c) {
    switch(c) {
        case 1: break;
        case 2: break;
        case 3: break;
        ...
        case 128: break;
        case 129: break;
    }
}

Удерживайте количество case в пределах 128 штук с помощью массива функций или if-else.

5. For-in


Выражение For-in может несколькими способами помешать оптимизации функции.

5.1. Ключ не является локальной переменной


function nonLocalKey1() {
    var obj = {}
    for(var key in obj);
    return function() {
        return key;
    };
}

var key;
function nonLocalKey2() {
    var obj = {}
    for(key in obj);
}

Ключ не может быть из верхней области видимости, как и не может ссылаться на нижнюю. Он должен быть исключительно локальной переменной.

5.2. Итерируемый объект не является «простым перечисляемым»


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

function hashTableIteration() {
    var hashTable = {"-": 3};
    for(var key in hashTable);
}

Объект может перейти в режим хэш-таблицы, к примеру, когда вы динамически добавляете слишком много свойств (вне конструктора), delete свойства, используете свойства, которые не являются корректными идентификаторами, и т. д. Другими словами, если вы используете объект так, словно это хэш-таблица, то он и превращается в хэш-таблицу. Ни в коем случае нельзя передавать такие объекты в for-in. Чтобы узнать, находится ли объект в режиме хэш-таблицы, можно вызвать console.log(%HasFastProperties(obj)) при активированном в Node.js флаге --allow-natives-syntax.

5.2.2. В цепочке прототипов объекта есть поля с перечисляемыми значениями

Object.prototype.fn = function() {};

Эта строка наделяет свойством перечисляемого цепочку прототипов всех объектов (за исключением Object.create(null)). Таким образом, любая функция, содержащая выражение for-in, становится неоптимизируемой (если только они не выполняют перебор объектов Object.create(null)).

С помощью Object.defineProperty вы можете присвоить неперечисляемые свойства. Не рекомендуется делать это во время выполнения. А вот для эффективного определения статических вещей вроде свойств прототипа — самое то.

5.2.3. Объект содержит перечисляемые индексы массива

Надо сказать, что свойства индекса массива определены в спецификации ECMAScript:

Имя свойства Р (в виде строки) является индексом массива тогда и только тогда, если ToString(ToUint32(P)) равно Р, а ToUint32(P) не равно 232 ? 1. Свойство, чьё имя является индексом массива, также называется элементом.

Как правило, это относится к массивам, но обычные объекты также могут обладать индексами массива:

normalObj[0] = value;
function iteratesOverArray() {
    var arr = [1, 2, 3];
    for (var index in arr) {

    }
}

Перебор массива с помощью for-in получается медленнее, чем с помощью for, к тому же функция, содержащая for-in, не подвергается оптимизации.

Если передать в for-in объект, не являющийся простым перечисляемым, то это окажет негативное влияние на функцию.

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

function inheritedKeys(obj) {
    var ret = [];
    for(var key in obj) {
        ret.push(key);
    }
    return ret;
}

6. Бесконечные циклы со сложной логикой условий выхода либо с неясными условиями выхода


Иногда при написании кода вы понимаете, что нужно сделать цикл, но не представляете, что в него поместить. Тогда вы вводите while (true) { или for (;;) {, а потом вставляете в цикл break, о котором вскоре забываете. Приходит время рефакторинга, когда выясняется, что функция выполняется медленно или вообще наблюдается деоптимизация. Причина может оказаться в забытом условии прерывания.

Рефакторинг цикла ради помещения условия выхода в условную часть выражения цикла может оказаться нетривиальным. Если условие является частью выражения if в конце цикла и код должен быть выполнен хотя бы один раз, то рефакторьте цикл до do{ } while ();. Если условие выхода расположено в начале, то поместите его в условную часть тела цикла. Если условие выхода расположено в середине, то можете поиграться с кодом: при каждом перемещении части кода из верхней строки в нижнюю оставляйте копию строки над циклом. После того как условие выхода может быть проверено с помощью условного или хотя бы простого логического теста, цикл больше не должен подпадать под деоптимизацию.

Комментарии (23)


  1. Borz
    04.01.2016 15:23
    +2

    128 case? куда столько сразу-то?


    1. asdf404
      04.01.2016 16:38
      +6

      Разные потребности бывают :)


    1. hell0w0rd
      04.01.2016 16:42

      Например можно различные генераторы конечных автоматов должны это учитывать. Парсеры и тп.


  1. dom1n1k
    04.01.2016 15:50

    Что подразумевается под составным оператором присваивания? Несколько переменных через запятую? Или так: let x = y = 1?


    1. Myshov
      04.01.2016 17:54

      Это все операторы вида +=, -=, *=, /= etc. Compound assignment.


      1. dom1n1k
        04.01.2016 18:12

        А какой смысл использовать их при объявлении переменной (let)?


        1. Myshov
          04.01.2016 19:02

          В оригинале написано:

          Functions that contain a compound let assignment
          Functions that contain a compound const assignment

          Скорее всего, имеется в виду составное присваивание переменной/константы после объявления с помощью let/const:

          let n = 1;
          n++;
          

          Здесь вместо let можно написать const, переменная просто не обновится.


          1. Myshov
            04.01.2016 19:24

            Выше ошибка, правильно так:

            let n = 1;
            n += 4;
            


  1. PerlPower
    04.01.2016 15:58

    Я почему-то всегда думал, что тормоза JS на клиенте в основном связаны либо с работой с DOM, либо с чем-то что упирается в работу с DOM, а сам V8 — самый быстрый интерпретатор в мире среди скриптовых языков со сравнимыми возможностями. А оптимизации в посте будут полезны лишь в случае написания очередного mp3 декодера.


    1. hell0w0rd
      04.01.2016 16:43

      Оригинальный автор — создатель библиотеки bluebird, фанат оптимизаций. Промисы в bluebird ближе всего к колбэкам, сильно обногяют нативные промисы, так что он знает о чем говорит. Ну и если вы пишите какую-то библиотеку — вам следует об этом знать.


      1. vlreshet
        04.01.2016 17:32

        Промисы со сторонней библиотеки быстрее чем нативные в самом языке? О_о


        1. uSide
          04.01.2016 18:10
          +8

          Посмотрите ещё на github.com/codemix/fast.js/tree/master ;)


          1. vlreshet
            04.01.2016 19:27
            +2

            Охренеть.


          1. QtRoS
            04.01.2016 20:28

            Интереснейшая вещь, спасибо!
            BTW — больше всего заинтересовал метод «try» в это библиотечке, и его реализация как раз точно совпадает с описанным статье — позволяет отделить функцию и блок try, в итоге функция оптимизируется.


            1. Nashev
              05.01.2016 00:39

              Интересно, что мешает авторам V8 встроить подобные трюки себе и начать всё это оптимизировать самим?


              1. baka_cirno
                05.01.2016 11:10
                +4

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


                1. Mingun
                  05.01.2016 14:49

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


        1. QtRoS
          04.01.2016 20:33
          +1

          Добро пожаловать в реальный мир JavaScript!
          Сам впервые был в шоке от этого, когда написанная на коленке реализация heapSort в 5 раз обогнала стандартный sort.


        1. hell0w0rd
          05.01.2016 13:05

          «Нативное» для JS — это тот же JS код, заранее распарсенный и оптимизированный. Через 3-10 прогонов обычный код оптимизируется и работает с такой же скоростью.


  1. matmuchrapna
    04.01.2016 16:28
    +7

    На Frontender Magazine перевод был опубликован 1,5 года назад


    1. matmuchrapna
      04.01.2016 16:40
      +5

      frontender.info/optimization-killers/

      теги и ссылки как-то режутся


    1. Apathetic
      04.01.2016 18:20

      То-то я думаю…


      1. dom1n1k
        04.01.2016 19:50

        То-то у меня тоже чувство дежавю