Убийцы оптимизации

в 12:14, , рубрики: javascript, V8, Блог компании Mail.Ru Group, Веб-разработка, оптимизация

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 ();. Если условие выхода расположено в начале, то поместите его в условную часть тела цикла. Если условие выхода расположено в середине, то можете поиграться с кодом: при каждом перемещении части кода из верхней строки в нижнюю оставляйте копию строки над циклом. После того как условие выхода может быть проверено с помощью условного или хотя бы простого логического теста, цикл больше не должен подпадать под деоптимизацию.

Автор: Mail.Ru Group

Источник

Поделиться новостью

* - обязательные к заполнению поля