Зависимость производительности кода от контекста объявления переменных в JavaScript

udcxhuvjpmyigj19lmdrz8ojyja.jpeg


Изначально эта статья задумывалась как небольшой бенчмарк для собственного использования, да и вообще статьёй быть не планировалась, однако, в процессе проведения замеров всплыли некоторые интересные особенности в реализации архитектуры JavaScript, сильно влияющие на производительность конечного кода в отдельных случаях. Предлагаю, и вам, ознакомится с полученными результатами, попутно также разобрав некоторые смежные темы: циклы for, окружение (контекст выполнения) и блоки.

В конце своей статьи «Использование let объявлений переменных и особенности образуемых при этом замыканий в JavaScript» я вскользь затронул тему сравнения производительности let (LexicalDeclaration) и var (VarDeclaredNames) объявлений переменных в циклах. Для сравнения мы использовали время выполнения ручной (без помощи Array.prototype.sort ()) сортировки массива, так как при его длине в 100 000 мы получили чуть более 5 млрд. итераций за два цикла (внешнего и вложенного), и, данное количество должно было позволить произвести адекватную оценку в итоге.

Для var это была сортировка вида:

for (var i = 0, len = arr.length; i < len-1; i++) {
    var min = arr[i],
        mini = i,
        tmp;

    for (var j = i+1; j < len; j++) {
        if (min > arr[j]) {
            min = arr[j];
            mini = j;
        }
    }

    tmp = arr[i];
    arr[i] = min;
    arr[mini] = tmp;
}
// время выполнения Firefox: 9.082 сек.
// время выполнения Chrome: 10.783 сек.


И для let:

for (let i = 0, len = arr.length; i < len-1; i++) {
    let min = arr[i],
        mini = i,
        tmp;

    for (let j = i+1; j < len; j++) {
        if (min > arr[j]) {
            min = arr[j];
            mini = j;
        }
    }

    tmp = arr[i];
    arr[i] = min;
    arr[mini] = tmp;
}
// время выполнения Firefox: 5.261 сек.
// время выполнения Chrome: 5.391 сек.


Увидев эти цифры, казалось бы, можно однозначно утверждать, что let объявления полностью превосходят var в скорости работы. Но, помимо данного вывода, в воздухе остался подвешен вопрос, а что будет, если вынести объявления let за пределы объявлений циклов for?

Но, прежде, чем сделать это, необходимо глубже вникнуть в работу цикла for, руководствуясь текущей спецификацией ECMAScript 2019 (ECMA-262) :

13.7.4.7Runtime Semantics: LabelledEvaluation
With parameter labelSet.
IterationStatement:for(Expression;Expression;Expression)Statement
    1. If the first Expression is present, then
        a. Let exprRef be the result of evaluating the first Expression.
        b. Perform ? GetValue(exprRef).
    2. Return ? ForBodyEvaluation(the second Expression, the third Expression, Statement, « », labelSet). 
IterationStatement:for(varVariableDeclarationList;Expression;Expression)Statement
    1. Let varDcl be the result of evaluating VariableDeclarationList.
    2. ReturnIfAbrupt(varDcl).
    3. Return ? ForBodyEvaluation(the first Expression, the second Expression, Statement, « », labelSet). 
IterationStatement:for(LexicalDeclarationExpression;Expression)Statement
    1. Let oldEnv be the running execution context's LexicalEnvironment.
    2. Let loopEnv be NewDeclarativeEnvironment(oldEnv).
    3. Let loopEnvRec be loopEnv's EnvironmentRecord.
    4. Let isConst be the result of performing IsConstantDeclaration of LexicalDeclaration.
    5. Let boundNames be the BoundNames of LexicalDeclaration.
    6. For each element dn of boundNames, do
        a. If isConst is true, then
            i. Perform ! loopEnvRec.CreateImmutableBinding(dn, true).
        b. Else,
            i. Perform ! loopEnvRec.CreateMutableBinding(dn, false).
    7. Set the running execution context's LexicalEnvironment to loopEnv.
    8. Let forDcl be the result of evaluating LexicalDeclaration.
    9. If forDcl is an abrupt completion, then
        a. Set the running execution context's LexicalEnvironment to oldEnv.
        b. Return Completion(forDcl).
    10. If isConst is false, let perIterationLets be boundNames; otherwise let perIterationLets be « ».
    11. Let bodyResult be ForBodyEvaluation(the first Expression, the second Expression, Statement, perIterationLets, labelSet).
    12. Set the running execution context's LexicalEnvironment to oldEnv.
    13. Return Completion(bodyResult).

прим: насколько я могу судить, в стандарте допущена опечатка, присутствующая как в более ранних версиях (от 2015 года), так и в текущей (от 2019 года), в выражении for (LexicalDeclarationExpression; Expression)Statement явно не хватает точки с запятой после LexicalDeclaration и данная опечатка присутствует во всех пунктах стандарта, описывающих работу цикла for при LexicalDeclaration. Тем не менее, при цитировании, я буду использовать вариант написания взятый из стандарта.

Здесь, как мы видим, присутствуют три варианта вызова и дальнейшей работы цикла for:

  • при for (Expression; Expression; Expression)Statement
    ForBodyEvaluation(the second Expression, the third Expression, Statement,» », labelSet).
  • при for(varVariableDeclarationList; Expression; Expression)Statement
    ForBodyEvaluation(the first Expression, the second Expression, Statement,» », labelSet).
  • при for(LexicalDeclarationExpression; Expression)Statement
    ForBodyEvaluation(the first Expression, the second Expression, Statement, perIterationLets, labelSet)


В последнем, третьем варианте, в отличие от первых двух, 4-ый параметр, не пустой — perIterationLets — это собственно и есть те самые let-объявления в первом, передаваемом циклу for, параметре. Они задаются в пункте 10:
 — If isConst is false, let perIterationLets be boundNames; otherwise let perIterationLets be » ».
В случае, если в for была передана константа, а не переменная, параметр perIterationLets становится пустым.

Также, в третьем варианте, необходимо обратить внимание на пункт 2:
 — Let loopEnv be NewDeclarativeEnvironment(oldEnv).

8.1.2.2NewDeclarativeEnvironment ( E )
When the abstract operation NewDeclarativeEnvironment is called with a Lexical Environment as argument E the following steps are performed:
1.	Let env be a new Lexical Environment.
2.	Let envRec be a new declarative Environment Record containing no bindings.
3.	Set env's EnvironmentRecord to envRec.
4.	Set the outer lexical environment reference of env to E.
5.	Return env. 


Здесь, в качестве параметра E принимается окружение из которого был вызван цикл for (глобальное, какая-либо функция и т. п.), и, создаётся новое окружение для выполнения цикла for со ссылкой на внешнее окружение, его создавшее (пункт 4). Нас интересует данный факт в связи с тем, что окружение это контекст выполнения.

А мы помним, что let и const объявления переменных контекстно привязаны к блоку в котором они объявлены.

13.2.14Runtime Semantics: BlockDeclarationInstantiation ( code, env )
Note
When a Block or CaseBlock is evaluated a new declarative Environment Record is created and bindings for each block scoped variable, constant, function, or class declared in the block are instantiated in the Environment Record.
BlockDeclarationInstantiation is performed as follows using arguments code and env. code is the Parse Node corresponding to the body of the block. env is the Lexical Environment in which bindings are to be created.
    1. Let envRec be env's EnvironmentRecord.
    2. Assert: envRec is a declarative Environment Record.
    3. Let declarations be the LexicallyScopedDeclarations of code.
    4. For each element d in declarations, do
        a. For each element dn of the BoundNames of d, do
            i. If IsConstantDeclaration of d is true, then
                1. Perform ! envRec.CreateImmutableBinding(dn, true).
            ii. Else,
                1. Perform ! envRec.CreateMutableBinding(dn, false).
        b. If d is a FunctionDeclaration, a GeneratorDeclaration, an AsyncFunctionDeclaration, or an AsyncGeneratorDeclaration, then
            i. Let fn be the sole element of the BoundNames of d.
            ii. Let fo be the result of performing InstantiateFunctionObject for d with argument env.
            iii. Perform envRec.InitializeBinding(fn, fo).


прим.: так как в первых двух вариантах вызова цикла for, отсутствовали подобные объявления, не было и надобности в создании для них нового окружения.

Идём дальше, и, рассмотрим, что же собой представляет ForBodyEvaluation:

13.7.4.8Runtime Semantics: ForBodyEvaluation ( test, increment, stmt, perIterationBindings, labelSet )
The abstract operation ForBodyEvaluation with arguments test, increment, stmt, perIterationBindings, and labelSet is performed as follows:
    1. Let V be undefined.
    2. Perform ? CreatePerIterationEnvironment(perIterationBindings).
    3. Repeat,
        a. If test is not [empty], then
            i. Let testRef be the result of evaluating test.
            ii. Let testValue be ? GetValue(testRef).
            iii. If ToBoolean(testValue) is false, return NormalCompletion(V).
        b. Let result be the result of evaluating stmt.
        c. If LoopContinues(result, labelSet) is false, return Completion(UpdateEmpty(result, V)).
        d. If result.[[Value]] is not empty, set V to result.[[Value]].
        e. Perform ? CreatePerIterationEnvironment(perIterationBindings).
        f. If increment is not [empty], then
            i. Let incRef be the result of evaluating increment.
            ii. Perform ? GetValue(incRef).


На что надо в первую очередь обратить внимание:

  • описание входящих параметров:
    • test: выражение проверяемое на истинность перед выполнением очередной итерации тела цикла (например: i < len);
    • increment: выражение вычисляемое в начале каждой новой итерации (кроме первой) (например: i++);
    • stmt: тело цикла;
    • perIterationBindings: переменные объявленные с помощью let в первом параметре for (например: let i = 0 || let i || let i, j);
    • labelSet: метка цикла;
  • пункт 2: здесь, в случае, если передан непустой параметр perIterationBindings, для выполнения начального прохода цикла создаётся второе окружение;
  • пункт 3.a: проверка на заданное условие продолжения выполнения цикла;
  • пункт 3.b: выполнение тела цикла;
  • пункт 3.e: создание нового окружения.


Ну, и, непосредственно, алгоритм создания внутренних окружений цикла for:

13.7.4.9Runtime Semantics: CreatePerIterationEnvironment ( perIterationBindings )
1. The abstract operation CreatePerIterationEnvironment with argument perIterationBindings is performed as follows:
    1. If perIterationBindings has any elements, then
        a. Let lastIterationEnv be the running execution context's LexicalEnvironment.
        b. Let lastIterationEnvRec be lastIterationEnv's EnvironmentRecord.
        c. Let outer be lastIterationEnv's outer environment reference.
        d. Assert: outer is not null.
        e. Let thisIterationEnv be NewDeclarativeEnvironment(outer).
        f. Let thisIterationEnvRec be thisIterationEnv's EnvironmentRecord.
        g. For each element bn of perIterationBindings, do
            i. Perform ! thisIterationEnvRec.CreateMutableBinding(bn, false).
            ii. Let lastValue be ? lastIterationEnvRec.GetBindingValue(bn, true).
            iii. Perform thisIterationEnvRec.InitializeBinding(bn, lastValue).
        h. Set the running execution context's LexicalEnvironment to thisIterationEnv.
    2. Return undefined. 


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

В качестве примера рассмотрим подобное выражение:

let arr = [];

for (let i = 0; i < 3; i++) {
    arr.push(i);
}

console.log(arr); // Array(3) [ 0, 1, 2 ]


А вот как его можно разложить без использования for (с известной долей условности):

let arr = [];

// создаётся первый контекст
{
    let i = 0; // первый параметр передаваемый в for
}

// первый проход цикла, второй контекст
{
    let i = 0; // последнее значение переменной i из предыдущего окружения
    if (i < 3) arr.push(i);
}

// второй проход цикла
{
    let i = 0; // последнее значение переменной i из предыдущего окружения
    i++;
    if (i < 3) arr.push(i);
}

// третий проход цикла
{
    let i = 1; // последнее значение переменной i из предыдущего окружения
    i++;
    if (i < 3) arr.push(i);
}

// четвертый проход цикла
{
    let i = 2; // последнее значение переменной i из предыдущего окружения
    i++;
    if (i < 3) arr.push(i);
}

console.log(arr); // Array(3) [ 0, 1, 2 ]


Фактически мы приходим к тому, что на каждый контекст, а тут у нас их пять, мы делаем новые привязки для let переменных объявленных первым параметром в for (важно: это не распространяется на let объявления непосредственно в теле цикла).

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

let arr2 = [];

var i = 0;
if (i < 3) arr.push(i);

i++;
if (i < 3) arr.push(i);

i++;
if (i < 3) arr.push(i);

i++;
if (i < 3) arr.push(i);

console.log(arr); // Array(3) [ 0, 1, 2 ]


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

Давайте попробуем сделать это, на примере всё той же сортировки массива на 100 000 элементов, и для красоты также вынесем перед for определение всех остальных переменных:

let i, j, min, mini, tmp, len = arr.length;

for (i = 0; i < len-1; i++) {
    min = arr[i];
    mini = i;

    for (j = i+1; j < len; j++) {
        if (min > arr[j]) {
            min = arr[j];
            mini = j;
        }
    }

    tmp = arr[i];
    arr[i] = min;
    arr[mini] = tmp;
}
// время выполнения Firefox: 34.246 сек.
// время выполнения Chrome: 10.803 сек.


Неожиданный результат… Прямо противоположный ожидаемому, если быть точным. Особенно поражает просадка Firefox в данном тесте.

Ок. Это не сработало, давайте вернём объявление i и j переменных обратно в параметры соответствующих циклов:

let min, mini, tmp, len = arr.length;

for (let i = 0; i < len-1; i++) {
    min = arr[i];
    mini = i;

    for (let j = i+1; j < len; j++) {
        if (min > arr[j]) {
            min = arr[j];
            mini = j;
        }
    }

    tmp = arr[i];
    arr[i] = min;
    arr[mini] = tmp;
}
// время выполнения Firefox: 6.575 сек.
// время выполнения Chrome: 6.749 сек.


Хм. Казалось бы, технически, единственное отличие последнего примера от примера в начале статьи — это вынесенные объявления переменных min, mini и tmp за пределы цикла for и хоть контекстно разница всё-таки присутствует, но она сейчас нас не особо интересует, и, кроме того, мы избавились от необходимости 99 999 раз объявлять эти переменные в теле цикла верхнего уровня, что опять же, в теории, скорее должно увеличить производительность, а не снизить его более чем на секунду.

То есть получается, что каким-то образом, работа с переменными объявленными в параметре или теле цикла for происходит существенно быстрее, чем в не его.

Но мы вроде не видели в спецификации к циклу for каких-либо «турбо» инструкций, которые могли бы навести нас на подобную мысль. Получается дело не в специфике работы конкретно цикла for, а в чём-то другом… Например, в особенностях работы let объявлений: какая основная черта отличает let от var? Блочный контекст выполнения! А в двух наших последних примерах мы использовали объявления вне блока. Но, что если вместо переноса этих объявлений обратно в for мы просто выделим отдельный блок для них?

{
    let i, j, min, mini, tmp, len = arr.length;

    for (i = 0; i < len-1; i++) {
        min = arr[i];
        mini = i;

        for (j = i+1; j < len; j++) {
            if (min > arr[j]) {
                min = arr[j];
                mini = j;
            }
        }

        tmp = arr[i];
        arr[i] = min;
        arr[mini] = tmp;
    }
}
// время выполнения Firefox: 5.262 сек.
// время выполнения Chrome: 5.405 сек.


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

В примере в начале статьи время сортировки с использованием var показало крайне плачевный результат, относительно let. Но, если присмотреться к этому примеру внимательней, можно обнаружить, что ввиду отсутствия у var переменных блочных привязок, фактический контекст переменных был глобальным. А мы, на примере let, уже обнаружили, как это может влиять на производительность (и, что характерно, при использовании let, просадка в скорости оказалась сильнее чем в случае с var, особенно в Firefox). Поэтому, справедливости ради, выполним пример с var создав для переменных новый контекст:

function test() {
    var i, j, min, mini, tmp, len = arr.length;

    for (i = 0; i < len-1; i++) {
        min = arr[i];
        mini = i;

        for (j = i+1; j < len; j++) {
            if (min > arr[j]) {
                min = arr[j];
                mini = j;
            }
        }

        tmp = arr[i];
        arr[i] = min;
        arr[mini] = tmp;
    }
}

test();
// время выполнения Firefox: 5.255 сек.
// время выполнения Chrome: 5.411 сек.


И, мы получили результат практически идентичный тому, что был при использовании let.

Подведём итоги


  1. Обращение к переменным из глобального контекста происходит значительно медленней, чем из любого другого. Принимая это во внимание, можно оптимизировать код в соответствующих ситуациях, создав отдельный блок или функцию, в том числе, для объявления переменных, вместо выполнения части кода в глобальном контексте. Да, почти в любом учебнике можно встретить рекомендации делать как можно меньше глобальных привязок, но, обычно в качестве причины указывается, только засорение глобального пространства имён, и, ни слова про возможные проблемы с производительностью.
  2. Несмотря на то, что выполнение циклов с let объявлением в первом параметре for создаётся большое количество окружений, на производительности это практически не сказывается, в отличие от ситуаций, когда мы выносим подобные объявления за пределы цикла. Тем не менее, не стоит исключать вероятность существования экзотических ситуаций, когда данный фактор скажется на производительности более существенно.
  3. Производительность var переменных, всё-таки не уступает производительности let переменных, однако и не превосходит их (опять же, в общем случае), что подводит нас к очередному выводу об отсутствии целесообразности использования var объявлений кроме как в целях совместимости. Однако, если необходимо манипулировать глобальными переменными, вариант с var в плане производительности будет предпочтительней.


Ссылки


ECMAScript 2019 (ECMA-262)
Использование let объявлений переменных и особенности образуемых при этом замыканий в JavaScript

© Habrahabr.ru