Как Ignition оптимизирует forEach() в bytecode v8

Как Ignition оптимизирует forEach() в bytecode v8

Юрий Карпов

Yuriy Karpov

Всем привет! Я программирую на javascript уже давно и помню времена, когда forEach() был значительно медленнее, чем обычный for. Я измерял их скорость выполнения, а также потребление памяти и процессорных ресурсов – результаты для forEach() были далеко не впечатляющими. Но сейчас forEach() и for почти не отличаются по скорости.

И просто померить мне уже не так интересно, как посмотреть хотя бы поверхностно на оптимизации.

В этом посте я расскажу, первой части: что такое bytecode, что такое Ignition, а во второй на примерах for и forEach посмотрим оптимизацию. Если вы знакомы с Ignition и bytecode - переходите ко второй части и читайте с главы "Практика" (якорей и меню нет в этом инструменте)

bytecode

Первая часть

Что такое bytecode

bytecode (байт-код) - это промежуточное представление исходного кода, которое используется для оптимизации исполнения программ. Он не является машинным кодом, непосредственно выполняемым процессором, и не является конечным результатом компиляции Just-In-Time (JIT). Вместо этого байт-код предназначен для выполнения виртуальными машинами, такими как V8, JVM (Java Virtual Machine), CLR (Common Language Runtime) и другими.

javascript - bytecode - machine code (assembler)

Если говорить очень просто когда движок V8 получает JavaScript-код, он проходит несколько этапов:

  1. Парсинг: JavaScript-код анализируется и преобразуется в абстрактное синтаксическое дерево (AST).
  2. Генерация байткода: AST преобразуется в байткод, который представляет собой упрощенный и оптимизированный формат инструкций, близкий к машинному коду, но независимый от конкретной архитектуры. (про него и поговорим)
  3. Исполнение: Байткод выполняется в интерпретаторе V8 (Ignition). Если интерпретатор обнаруживает "горячие" участки кода, которые часто исполняются, они передаются в JIT-компилятор (TurboFan) для компиляции в нативный машинный код.
V8’s compilation pipeline with Ignition enabled

Это известная диаграмма на которой присутствуют три незнакомых имени собственного: Ignition, Crankshaft, и TurboFan.

Ignition - это интерпретатор v8, Crankshaft - компилятор старшего поколения, а TurboFan - более новый. В современных браузерах на V8 остался только TurboFan.

Не только у chromium v8 есть такие оптимизации, например Safari использует свой движок JSCore, который имеет три оптимизатора: Baseline, DFG и FTL. А Firefox со своим SpiderMonkey имеет тоже JIT, Baseline Interpreter, Baseline Compiler, но особо много я нечего не знаю про SpiderMonkey, надо будет изучить этот вопрос, поэтому тут я пишу про v8

Зачем был создан Ignition?

До появления Ignition, V8 использовал более сложную архитектуру, которая сразу пыталась компилировать JavaScript в нативный машинный код с помощью JIT-компилятора. Это подход имел свои недостатки:

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

Ignition был создан для решения этих проблем. Основные цели:

  • Экономия ресурсов: Байткод более компактный и быстрее генерируется, чем нативный машинный код. Это снижает потребление памяти и ускоряет запуск скриптов.
  • Гибкость: Ignition позволяет динамически оптимизировать код в процессе выполнения. Например, редко исполняемые участки остаются в виде байткода, а "горячие" функции могут быть оптимизированы с помощью TurboFan.

Процесс выглядит так:

  1. JavaScript-код парсится и превращается в байткод.
  2. Ignition интерпретирует этот байткод.
  3. Если функция часто вызывается, Ignition собирает профили и передает код TurboFan для JIT-компиляции.
  4. TurboFan оптимизирует код и генерирует машинный код, который выполняется напрямую.

источник и больше информации тут в видео


Вторая часть

Практика

Для получения bytecode нам понадобиться d8 (https://v8.dev/docs/d8) - консольная обёртка над v8. Для этого, по инструкции этой и этой для mac собрал (скомпилировал) v8. Вы же можете поступить проще и использовать jsvu, он позволяет легко устанавливать последние версии различных движков JavaScript без необходимости компилировать их из исходного кода. Так же можно запустить Chromium из командной строки с ключом --js-flags="--print-bytecode" вот ссылка, но я не пробовал этот способ.

Тут я буду использовать фильтр --print-bytecode-filter на функцию 'a', т.е. мы будем видеть bytecode только внутри функции, тк если запускать и читать весь файл - мы будем видеть лишний bytecode связанный с глобал, а нам пока это не интересно, можете поэкспериментировать самостоятельно)

Файл simple.js

function a() {
 let a = 42;
 a = a + 1;
}
a();

Вызов для печати в консоль bytecode с фильтром по функции:

d8 -print-bytecode --print-bytecode-filter=a simple.js

Результат:

[generated bytecode for function: a (0x333100198a99 <SharedFunctionInfo a>)]
Bytecode length: 9
Parameter count 1
Register count 1
Frame size 8
     0x2ae900040078 @  0 : 0d 2a       LdaSmi [42]
     0x2ae90004007a @  2 : ca          Star0
     0x2ae90004007b @  3 : 47 01 00    AddSmi [1], [0]
     0x2ae90004007e @  6 : ca          Star0
     0x2ae90004007f @  7 : 0e          LdaUndefined
     0x2ae900040080 @  8 : af          Return
Constant pool (size = 0)
Handler Table (size = 0)
Source Position Table (size = 0)


Ignition - это регистровая машина с накопительным регистром (он же аккумулятор), если вы учили в университете assembler, то читать bytecode для вас не составит труда, если нет я постараюсь объяснить, но очень поверхностно.


LdaSmi - загружает в аккумулятор малое целое число анг. Small Integer (Smi)

Star0 - сохраняет из аккумулятора в регистр r0, (ранее Star0 назывался Star r0, ссылка на изменения)

AddSmi - добавить (сложить) малое целое число 1 в аккумулятор, последний [0] - это так называемый вектор обратный связи (feedback vector), необходим для дальнейшей оптимизации производительности.

Star0 - снова, сохраняет из аккумулятора в регистр r0

LdaUndefined - загружаем в аккумулятор undefined, тк мы ничего не возвращаем в Return

Return - возвращаем из аккумулятора undefined

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

Пример с for:

Напишем простой код для поиска наибольшего числа в массиве полным перебором.

function findMax(arr) {
 let max = arr[0];
 for (let i = 1; i < arr.length; i++) {
  if (arr[i] > max) {
   max = arr[i];
  }
 }
 return max;
}

findMax([1, 2, 3, 4, 6, 5]);

Теперь выведем это в bytecode, на этот раз без фильтра

d8 -print-bytecode for.js

получаем следующий bytecode

(извините за длинный текст, тут нет скролов и колапсеров)

// [generated bytecode for function:  (0x322700198a49 <SharedFunctionInfo>)]
// Bytecode length: 26
// Parameter count 1
// Register count 3
// Frame size 24
//     0x214f0004004c @    0 : 13 00             LdaConstant [0]
//     0x214f0004004e @    2 : c9                Star1
//     0x214f0004004f @    3 : 19 fe f7          Mov <closure>, r2
//     0x214f00040052 @    6 : 68 67 01 f8 02    CallRuntime [DeclareGlobals], r1-r2
//     0x214f00040057 @   11 : 21 01 00          LdaGlobal [1], [0]
//     0x214f0004005a @   14 : c9                Star1
//     0x214f0004005b @   15 : 7e 02 02 25       CreateArrayLiteral [2], [2], #37
//     0x214f0004005f @   19 : c8                Star2
//     0x214f00040060 @   20 : 65 f8 f7 03       CallUndefinedReceiver1 r1, r2, [3]
//     0x214f00040064 @   24 : ca                Star0
//     0x214f00040065 @   25 : af                Return
// Constant pool (size = 3)
// Handler Table (size = 0)
// Source Position Table (size = 0)
[generated bytecode for function: findMax (0x322700198aa9 <SharedFunctionInfo findMax>)]
Bytecode length: 48
Parameter count 2
Register count 3
Frame size 24
    0x214f0004009c @    0 : 0c                LdaZero
    0x214f0004009d @    1 : 31 03 00          GetKeyedProperty a0, [0]
    0x214f000400a0 @    4 : ca                Star0
    0x214f000400a1 @    5 : 0d 01             LdaSmi [1]
    0x214f000400a3 @    7 : c9                Star1
    0x214f000400a4 @    8 : 2f 03 00 02       GetNamedProperty a0, [0], [2]
    0x214f000400a8 @   12 : 71 f8 04          TestLessThan r1, [4]
    0x214f000400ab @   15 : 9f 1e             JumpIfFalse [30] (0x214f000400c9 @ 45)
    0x214f000400ad @   17 : 0b f8             Ldar r1
    0x214f000400af @   19 : 31 03 05          GetKeyedProperty a0, [5]
    0x214f000400b2 @   22 : c8                Star2
    0x214f000400b3 @   23 : 0b f9             Ldar r0
    0x214f000400b5 @   25 : 72 f7 07          TestGreaterThan r2, [7]
    0x214f000400b8 @   28 : 9f 08             JumpIfFalse [8] (0x214f000400c0 @ 36)
    0x214f000400ba @   30 : 0b f8             Ldar r1
    0x214f000400bc @   32 : 31 03 08          GetKeyedProperty a0, [8]
    0x214f000400bf @   35 : ca                Star0
    0x214f000400c0 @   36 : 0b f8             Ldar r1
    0x214f000400c2 @   38 : 53 0a             Inc [10]
    0x214f000400c4 @   40 : c9                Star1
    0x214f000400c5 @   41 : 8e 21 00 0b       JumpLoop [33], [0], [11] (0x214f000400a4 @ 8)
    0x214f000400c9 @   45 : 0b f9             Ldar r0
    0x214f000400cb @   47 : af                Return
Constant pool (size = 1)
Handler Table (size = 0)
Source Position Table (size = 0)

Первую часть я закомментировал, нас это не интересует в данном контексте, там идёт работа с глобал и вызов функции findMax

Давайте рассмотрим байткод функции findMax, я попробую рассказать о новых интрукциях, которые мы тут видим:


LdaZero Загрузите литерал "0" в аккумулятор.

GetKeyedProperty <object> <slot> Вызывает KeyedLoadIC в слоте FeedBackVector для объекта и ключа в аккумуляторе,  те используется для доступа к свойству объекта по ключу. 

Star0 - сохраняем в регистре r0

LdaSmi - загружаем в аккумулятор целове число

Star1 - сохраняем его в регистор r1

GetNamedProperty a0, [0], [2] - Вызывает параметр загрузки в слоте FeedBackVector для объекта и имя в записи пула констант name_index. Проще говоря, получает значение указанного свойства из переданного объекта

JumpIfFalse [30] (0x214f000400c9 @ 45) - выполняет переход на количество байт, представленное непосредственным операндом, если в аккумуляторе содержится значение false.

TestLessThan r1 - проверяет, не меньше ли значение в регистре r1, чем в аккумуляторе

Ldar r1 - загружает в аккумулятор из r1

GetKeyedProperty a0, [8] - получаем доступ к свойству объекта по ключу 

Star2 - сохраняем в регистре r2

Ldar r0 - загружаем в аккумулятор (далее acc) из регистра r0

TestGreaterThan r2, [7] - проверяет, не превышает ли значение в регистре r2 значение в acc.

JumpIfFalse [8] (0x214f000400c0 @ 36) - если в acc false прыгаем на указанное количество байт

Ldar r1 загружаем из r1 в acc

Inc - инкрементируем acc (увеличиваем на один аккумулятор)

Star1 - сохраняем в r1

JumpLoop [33], [0], [11] (0x214f000400a4 @ 8) - Инструкция для перехода к определенной метке в коде (смещение на определённое количество байт). Также выполняет проверку вложенности цикла, проверку стека, те будет выполняться до тех пор, пока условие не станет ложным, и так же потенциально вызывает OSR ("Out of stack range". Это ошибка, которая возникает, когда стек переполнен и компьютер не может выполнить операцию, требующую дополнительного места на стеке.)

Ldar r0 - загружаем в аккумулятор из r0

Return возвращаем аккумулятор


Пример с forEach

Пишем такую же функцию поиска наибольшего числа, но на forEach

function findMax(arr) {
  let max = arr[0];
  arr.forEach(el => {
    if (el > max) {
      max = el;
    }
  });
  return max;
}
findMax([1, 2, 3, 4, 6, 5]);

получаем bytecode, опять же, что бы не мешать я закомментировал первую глобальную функцию, на неё мы не смотрим, а начинаем читать с findMax:

// [generated bytecode for function:  (0x232e00198a5d <SharedFunctionInfo>)]
// Bytecode length: 26
// Parameter count 1
// Register count 3
// Frame size 24
//     0x1e140004004c @    0 : 13 00             LdaConstant [0]
//     0x1e140004004e @    2 : c9                Star1
//     0x1e140004004f @    3 : 19 fe f7          Mov <closure>, r2
//     0x1e1400040052 @    6 : 68 67 01 f8 02    CallRuntime [DeclareGlobals], r1-r2
//     0x1e1400040057 @   11 : 21 01 00          LdaGlobal [1], [0]
//     0x1e140004005a @   14 : c9                Star1
//     0x1e140004005b @   15 : 7e 02 02 25       CreateArrayLiteral [2], [2], #37
//     0x1e140004005f @   19 : c8                Star2
//     0x1e1400040060 @   20 : 65 f8 f7 03       CallUndefinedReceiver1 r1, r2, [3]
//     0x1e1400040064 @   24 : ca                Star0
//     0x1e1400040065 @   25 : af                Return
Constant pool (size = 3)
Handler Table (size = 0)
Source Position Table (size = 0)
[generated bytecode for function: findMax (0x232e00198abd <SharedFunctionInfo findMax>)]
Bytecode length: 32
Parameter count 2
Register count 4
Frame size 32
    0x1e14000400a4 @    0 : 88 00 01          CreateFunctionContext [0], [1]
    0x1e14000400a7 @    3 : 1a f9             PushContext r0
    0x1e14000400a9 @    5 : 10                LdaTheHole
    0x1e14000400aa @    6 : 25 02             StaCurrentContextSlot [2]
    0x1e14000400ac @    8 : 0c                LdaZero
    0x1e14000400ad @    9 : 31 03 00          GetKeyedProperty a0, [0]
    0x1e14000400b0 @   12 : 25 02             StaCurrentContextSlot [2]
    0x1e14000400b2 @   14 : 2f 03 01 02       GetNamedProperty a0, [1], [2]
    0x1e14000400b6 @   18 : c9                Star1
    0x1e14000400b7 @   19 : 85 02 00 02       CreateClosure [2], [0], #2
    0x1e14000400bb @   23 : c7                Star3
    0x1e14000400bc @   24 : 61 f8 03 f6 04    CallProperty1 r1, a0, r3, [4]
    0x1e14000400c1 @   29 : 16 02             LdaCurrentContextSlot [2]
    0x1e14000400c3 @   31 : af                Return
Constant pool (size = 3)
Handler Table (size = 0)
Source Position Table (size = 0)
[generated bytecode for function:  (0x232e00198c19 <SharedFunctionInfo>)]
Bytecode length: 15
Parameter count 2
Register count 0
Frame size 0
    0x1e14000400f8 @    0 : 16 02             LdaCurrentContextSlot [2]
    0x1e14000400fa @    2 : b0 00             ThrowReferenceErrorIfHole [0]
    0x1e14000400fc @    4 : 72 03 00          TestGreaterThan a0, [0]
    0x1e14000400ff @    7 : 9f 06             JumpIfFalse [6] (0x1e1400040105 @ 13)
    0x1e1400040101 @    9 : 0b 03             Ldar a0
    0x1e1400040103 @   11 : 25 02             StaCurrentContextSlot [2]
    0x1e1400040105 @   13 : 0e                LdaUndefined
    0x1e1400040106 @   14 : af                Return
Constant pool (size = 1)
Handler Table (size = 0)
Source Position Table (size = 0)

На этот раз мы видим три функции (учитывая закомментированную), функцию findMax и аннонимную 0x232e00198c19 <SharedFunctionInfo>, даже по байткоду видим, что она не особо больше чем for (а даже на 1 байт меньше, но это не имеет особого значения)

Начнем с findMax

CreateFunctionContext [0], [1] - создаём новый контекст с количеством интервалов для закрытия функции.

PushContext r0 - сохраняем текущий контекст в r0 и помещает аккумулятор в качестве нового текущего контекста.

LdaTheHole - используется для загрузки специального значения "The Hole" в стек. "The Hole" представляет собой специальное значение, которое используется для обозначения отсутствия значения в коде или данных. Это может быть полезно в некоторых случаях, например, при работе с функциями, которые могут возвращать неопределенное значение, подробнее ссылка

StaCurrentContextSlot [2] - сохраняем текущий контекст выполнения в специальном слоте текущего контекста. Контекст выполнения включает в себя информацию о текущем методе, его аргументах, стеке вызовов и других данных, которые нужны для выполнения метода.

LdaZero - загружаем 0 в аккумулятор

GetKeyedProperty a0, [0] - получаем доступ к свойству объекта по ключу

StaCurrentContextSlot [2] - сохраняем текущий контекст выполнения в специальном слоте текущего контекста

GetNamedProperty a0, [1], [2] - получаем значение указанного свойства из переданного объекта

Star1 - сохраняем из acc в регистр r1

CreateClosure [2], [0], #2 - Создает новое закрытие для SharedFunctionInfo по индексу позиции в пуле констант и с предварительной настройкой, контролируемой флагами, те происходит создания нового экземпляра функции-замыкания. Функция-замыкание - это функция, которая имеет доступ к переменным из внешнего контекста, даже после окончания выполнения функции.

Star3 - сохраняем в регистре r3

CallProperty1 r1, a0, r3, [4] - <function_id> <first_arg> <arg_count> вызываем метода объекта. Этот метод должен быть определен в объекте и иметь один аргумент. После выполнения инструкции CallProperty1, метод будет вызван с указанным аргументом и результат будет помещен на вершину стека acc. Те тут мы вызываем третью анонимную функцию


тут мы смотрим другую функцию 0x232e00198c19 <SharedFunctionInfo>

LdaCurrentContextSlot [2] - загружаем контекст

ThrowReferenceErrorIfHole [0] - используется для генерации ошибки ReferenceError, если на вершине стека находится "The Hole"

TestGreaterThan a0, [0] - проверяем, не превышает ли значение в регистре a0 значение в acc, результат сохранит в acc

JumpIfFalse [6] (0x1e1400040105 @ 13) - если в acc false прыгаем на указанное количество байт

Ldar a0 - загружаем из регистра a0 в acc

StaCurrentContextSlot [2] - сохраняем текущий контекст выполнения в специальном слоте текущего контекста

LdaUndefined - загружаем в acc undefined

Return - возвращаем acc, те undefined


Вернёмся к нашей функции findMax

LdaCurrentContextSlot [2] - загружаем контекст в acc

Return возвращаем содержимое acc (аккумулятора)


Вывод

По этому bytecode видно, как forEach оптимизирован, и теперь понятно, почему он стал такой же быстрый как и обычный for, в итоге инструкций у forEach получается приблизительно столько же как и у for.


А ещё есть один миф: если в forEach передавать указатель на функцию (ссылку) arr.forEach(fn), то это лучше для производительности, мол, forEach создаёт каждый раз новую функцию, если там будет анонимная, прим. arr.forEach(e=>{....}), как мы видим по байткоду - это миф, быстрее работать не будет, ignition сам справляется с этим и оптимизирует за вас

UPD

Полезные ссылки от комментариев и Дениса:
описание инструкций байткодов и флаги в v8

end

Это не перевод статьи, поэтому тут могут быть опечатки и не точности, я специально стараюсь как можно больше источников указать, если ты нашёл неточности, ошибки или что-то я написал непонятно - пиши в комментарии или мне в личку - я буду рад сделать материал лучше.
Вы можете копировать полностью или частично материал к себе статьи/каналы, но хотя бы подпишись), а если оставишь ссылку на канал - я буду очень рад)

Подписывайся https://t.me/frontend_bookmark




Report Page