Как Ignition оптимизирует forEach() в bytecode v8
Юрий КарповВсем привет! Я программирую на javascript уже давно и помню времена, когда forEach() был значительно медленнее, чем обычный for. Я измерял их скорость выполнения, а также потребление памяти и процессорных ресурсов – результаты для forEach() были далеко не впечатляющими. Но сейчас forEach() и for почти не отличаются по скорости.
И просто померить мне уже не так интересно, как посмотреть хотя бы поверхностно на оптимизации.
В этом посте я расскажу, первой части: что такое bytecode, что такое Ignition, а во второй на примерах for и forEach посмотрим оптимизацию. Если вы знакомы с Ignition и bytecode - переходите ко второй части и читайте с главы "Практика" (якорей и меню нет в этом инструменте)

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

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

Это известная диаграмма на которой присутствуют три незнакомых имени собственного: 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.
Процесс выглядит так:
- JavaScript-код парсится и превращается в байткод.
- Ignition интерпретирует этот байткод.
- Если функция часто вызывается, Ignition собирает профили и передает код TurboFan для JIT-компиляции.
- 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