Быстрее вызовы JavaScript
JavaScript позволяет вызывать функцию с количеством аргументов, отличающимся от ожидаемого числа параметров, т.е. можно передавать меньше или больше аргументов, чем объявлено в формальных параметрах. Первый случай называется недо-применением, а второй - пере-применением.
В случае недо-применения оставшимся параметрам присваивается значение undefined. В случае пере-применения оставшиеся аргументы могут быть доступны через параметр rest и свойство arguments
, или они просто являются избыточными и могут быть проигнорированы. Многие веб- и Node.js-фреймворки сегодня используют эту особенность JS для принятия необязательных параметров и создания более гибкого API.
До недавнего времени V8 имел специальный механизм для обработки несоответствия размера аргументов: фрейм адаптера аргументов. К сожалению, адаптация аргументов вызывает затраты на производительность, но часто необходима в современных фронтенд- и серверных фреймворках. Однако оказалось, что с умным трюком мы можем убрать этот дополнительный фрейм, упростить базу кодов V8 и избавиться от практически всех накладных расходов.
Мы можем рассчитать влияние на производительность удаления фрейма адаптера аргументов через микро-бенчмарк.
console.time();
function f(x, y, z) {}
for (let i = 0; i < N; i++) {
f(1, 2, 3, 4, 5);
}
console.timeEnd();
График показывает, что больше нет накладных расходов при работе в режиме без JIT (Ignition) с улучшением производительности на 11,2%. При использовании TurboFan мы получаем ускорение до 40%.
Этот микро-бенчмарк был специально разработан для максимизации влияния фрейма адаптера аргументов. Тем не менее, мы наблюдали значительное улучшение во многих тестах, таких как наш внутренний тест JSTests/Array benchmark (7%) и Octane2 (4,6% в Richards и 6,1% в EarleyBoyer).
TL;DR: Перевернуть аргументы
Основная цель этого проекта состояла в удалении фрейма адаптера аргументов, который обеспечивает согласованный интерфейс для вызываемой функции при доступе к её аргументам в стеке. Чтобы сделать это, нам нужно было перевернуть аргументы в стеке и добавить новый слот в фрейме вызываемой функции, содержащий фактическое количество аргументов. На рисунке ниже показан пример типичного фрейма до и после изменения.
Ускорение вызовов JavaScript
Чтобы понять, что мы сделали для ускорения вызовов, давайте посмотрим, как V8 выполняет вызов и как работает фрейм адаптера аргументов.
Что происходит внутри V8, когда мы выполняем вызов функции в JS? Представим следующий JS-скрипт:
function add42(x) {
return x + 42;
}
add42(3);
Ignition
V8 - это многослойная виртуальная машина. Её первый слой называется Ignition, это стековая машина байткодов с аккумуляторным регистром. V8 начинает с компиляции кода в байткоды Ignition. Указанный вызов компилируется следующим образом:
0d LdaUndefined ;; Загрузить undefined в аккумулятор
26 f9 Star r2 ;; Сохранить в регистре r2
13 01 00 LdaGlobal [1] ;; Загрузить глобальный объект, указанный константой 1 (add42)
26 fa Star r1 ;; Сохранить в регистре r1
0c 03 LdaSmi [3] ;; Загрузить маленькое целое число 3 в аккумулятор
26 f8 Star r3 ;; Сохранить в регистре r3
5f fa f9 02 CallNoFeedback r1, r2-r3 ;; Выполнить вызов
Первый аргумент вызова обычно называется получателем. Получатель - это объект this
внутри JSFunction, и каждый вызов функции JS должен иметь один. Обработчик байткода CallNoFeedback
должен вызвать объект r1
с аргументами из списка регистров r2-r3
.
Прежде чем мы углубимся в обработчик байт-кода, обратим внимание на то, как регистры кодируются в байт-коде. Они представляют собой отрицательные однобайтовые целые числа: r1
кодируется как fa
, r2
как f9
, а r3
как f8
. Мы можем ссылаться на любой регистр ri как fb - i
, а точная кодировка, как мы увидим, будет - 2 - kFixedFrameHeaderSize - i
. Списки регистров кодируются, используя первый регистр и размер списка, например r2-r3
соответствует f9 02
.
Существует множество обработчиков вызовов байт-кода в Ignition. Вы можете увидеть их список здесь. Они немного различаются между собой. Есть байт-коды, оптимизированные для вызовов с undefined
в качестве получателя, для вызовов свойств, для вызовов с фиксированным количеством параметров или для общих вызовов. Здесь мы анализируем CallNoFeedback
, который представляет собой общий вызов, в котором мы не накапливаем обратную связь от выполнения.
Обработчик этого байт-кода довольно прост. Он написан с использованием CodeStubAssembler
, вы можете ознакомиться с ним здесь. По существу, он совершает tailcall к встроенной функции, зависящей от архитектуры InterpreterPushArgsThenCall
.
Эта встроенная функция извлекает адрес возврата в временный регистр, помещает все аргументы (включая получателя) и возвращает адрес возврата на место. В данный момент мы не знаем, является ли вызываемый объект вызываемым объектом или сколько аргументов он ожидает, то есть количество его формальных параметров.
В конечном итоге выполнение переходит к встроенной функции Call
. Там проверяется, является ли цель правильной функцией, конструктором или любым другим вызываемым объектом. Также считывается структура shared function info
, чтобы узнать количество формальных параметров.
Если вызываемый объект является объектом функции, то производится переход к встроенной функции CallFunction
, где проводится ряд проверок, включая наличие объекта undefined
в качестве получателя. Если получатель — это объект undefined
или null
, его необходимо заменить на объект глобального прокси, согласно спецификации ECMA.
Далее выполнение переходит к встроенной функции InvokeFunctionCode
, которая при отсутствии несоответствия аргументов просто вызывает то, что указывается в поле Code
вызываемого объекта. Это может быть либо оптимизированная функция, либо встроенная функция InterpreterEntryTrampoline
.
Если мы предполагаем, что мы вызываем функцию, которая еще не была оптимизирована, то Ignition trampoline настроит InterpreterFrame
. Краткое описание типов фреймов в V8 можно найти здесь.
Не вдаваясь в детали того, что происходит дальше, мы можем увидеть снимок кадра интерпретатора во время выполнения вызываемого объекта.
Мы видим, что во фрейме есть фиксированное количество слотов: адрес возврата, указатель на предыдущий фрейм, контекст, текущий объект функции, которую мы выполняем, массив байт-кодов этой функции и смещение текущего байт-кода, который мы выполняем. Наконец, есть список регистров, посвященных этой функции (вы можете рассматривать их как локальные переменные функции). Функция add42
на самом деле не имеет регистров, но вызывающий объект имеет похожий фрейм с 3 регистрами.
Как ожидалось, add42
— это простая функция:
25 02 Ldar a0 ;; Загрузить первый аргумент в аккумулятор
40 2a 00 AddSmi [42] ;; Добавить к нему 42
ab Return ;; Вернуть аккумулятор
Обратите внимание, как мы кодируем аргумент в байт-коде Ldar
(Load Accumulator Register): аргумент 1
(a0
) кодируется с числом 02
. На самом деле кодировка любого аргумента просто [ai] = 2 + parameter_count - i - 1
, а получателя [this] = 2 + parameter_count
, или в данном примере [this] = 3
. Количество параметров здесь не включает получателя.
Теперь мы можем понять, почему кодируем регистры и аргументы таким образом. Они просто обозначают смещение от указателя кадра. Затем мы можем обрабатывать загрузку и хранение аргументов/регистров одинаково. Смещение последнего аргумента от указателя кадра равно 2
(предыдущий указатель кадра и адрес возврата). Это объясняет 2
в кодировании. Фиксированная часть кадра интерпретатора состоит из 6
слотов (4
от указателя кадра), поэтому регистр ноль находится на смещении -5
, то есть fb
, регистр 1
на fa
. Умно, правда?
Обратите внимание, однако, чтобы иметь возможность получить доступ к аргументам, функция должна знать, сколько аргументов находится в стеке! Индекс 2
указывает на последний аргумент независимо от того, сколько их там!
Обработчик байткода Return
завершит работу вызовом встроенной функции LeaveInterpreterFrame
. Эта встроенная функция фактически считывает объект функции, чтобы получить количество параметров из кадра, извлекает текущий кадр, восстанавливает указатель кадра, сохраняет адрес возврата в резервный регистр, извлекает аргументы в соответствии с количеством параметров и переходит по адресу в резервных регистрах.
Весь этот процесс замечателен! Но что происходит, когда мы вызываем функцию с меньшим или большим количеством аргументов, чем количество ее параметров? Умный доступ к аргументам/регистрам будет ошибочным, и как нам очистить аргументы в конце вызова?
Кадр адаптера аргументов
Теперь вызовем add42
с меньшим и большим количеством аргументов:
add42();
add42(1, 2, 3);
Разработчики JS среди нас знают, что в первом случае x
будет присвоено значение undefined
, и функция вернет undefined + 42 = NaN
. Во втором случае x
будет присвоено значение 1
, и функция вернет 43
, оставшиеся аргументы будут проигнорированы. Заметьте, что вызывающий код не знает, произойдет ли это. Даже если вызывающий код проверит количество параметров, вызываемая функция может использовать параметр rest или объект arguments, чтобы получить доступ ко всем остальным аргументам. На самом деле объект arguments даже может быть доступен за пределами add42
в нестрогом режиме.
Если следовать тем же шагам, что и ранее, сначала мы вызовем встроенную функцию InterpreterPushArgsThenCall
. Она поместит аргументы в стек так:
Продолжив ту же процедуру, что и ранее, мы проверим, является ли вызываемая часть объектом функции, узнаем количество ее параметров и присвоим получателю глобальный прокси. В конце концов мы достигнем InvokeFunctionCode
.
Здесь, вместо перехода к Code
в вызываемом объекте, мы проверяем, есть ли несоответствие между размером аргументов и количеством параметров и производим переход к ArgumentsAdaptorTrampoline
.
В этой встроенной функции мы создаем дополнительный кадр, печально известный кадр адаптера аргументов. Вместо объяснения того, что происходит внутри встроенной функции, я просто покажу вам состояние кадра перед вызовом встроенной функции вызываемого Code
. Обратите внимание, что это правильный x64 вызов
(а не jmp
), и после выполнения вызываемого мы вернемся к ArgumentsAdaptorTrampoline
. Это контрастирует с InvokeFunctionCode
, который выполняет хвостовой вызов.
Как вы видите, мы создаем другой кадр, который копирует все необходимые аргументы, чтобы на вершине кадра вызываемого функции точно соответствовало количество параметров. Он создает интерфейс для вызываемой функции, так что последняя не нуждается в знании количества аргументов. Вызываемая функция всегда сможет получить доступ к своим параметрам по той же формуле, что и ранее, то есть [ai] = 2 + parameter_count - i - 1
.
V8 имеет специальные встроенные функции, которые учитывают кадр адаптера в случаях, когда необходимо получить доступ к оставшимся аргументам через параметр rest или объект arguments. Они всегда должны проверять тип кадра адаптера на вершине кадра вызываемого и действовать соответствующим образом.
Как вы видите, проблему доступа к аргументам/регистрам мы решаем, но создаем множество сложностей. Каждая встроенная функция, которая должна получить доступ ко всем аргументам, должна учитывать и проверять наличие кадра адаптера. Более того, мы должны быть осторожны, чтобы не получить доступ к устаревшим и старым данным. Рассмотрим следующие изменения в add42
:
function add42(x) {
x += 42;
return x;
}
Теперь массив байткода выглядит так:
25 02 Ldar a0 ;; Загрузить первый аргумент в аккумулятор
40 2a 00 AddSmi [42] ;; Добавить к нему 42
26 02 Star a0 ;; Сохранить аккумулятор в первую ячейку аргументов
ab Return ;; Вернуться с аккумулятором
Как видите, теперь мы изменяем a0
. Так, в случае вызова add42(1, 2, 3)
ячейка в кадре адаптера аргументов будет изменена, но кадр вызывающего кода все еще будет содержать число 1
. Мы должны быть осторожны, чтобы объект arguments получал доступ к измененному значению, а не к устаревшему.
Возвращение из функции просто, хотя и медленно. Помните, что делает LeaveInterpreterFrame
? Он фактически удаляет кадр вызываемого и аргументы до количества параметров. Поэтому когда мы возвращаемся к заглушке адаптера аргументов, стек выглядит так:
Нам просто нужно извлечь количество аргументов, извлечь адаптационный кадр, извлечь все аргументы в соответствии с фактическим количеством аргументов и вернуться к выполнению вызывающей функции.
Краткий обзор: механизм адаптации аргументов не только сложен, но и затратен.
Удаление адаптационного кадра аргументов
Можно ли сделать лучше? Можно ли удалить адаптационный кадр? Оказывается, это действительно возможно.
Давайте рассмотрим наши требования:
- Мы должны иметь возможность беспрепятственного доступа к аргументам и регистраторам, как и раньше. Никаких проверок при доступе к ним проводиться не должно. Это будет слишком дорого.
- Мы должны иметь возможность создавать параметр rest и объект arguments из стека.
- Мы должны иметь возможность легко очистить неизвестное количество аргументов при возврате из вызова.
- И, конечно, мы хотим сделать это без дополнительного кадра!
Если мы хотим устранить дополнительный кадр, то нам нужно решить, куда поместить аргументы: либо в кадр вызываемой функции, либо в кадр вызывающей функции.
Аргументы в кадре вызываемой функции
Предположим, мы поместим аргументы в кадр вызываемой функции. Это кажется хорошей идеей, так как всякий раз, когда мы извлекаем кадр, мы также извлекаем все аргументы сразу!
Аргументы необходимо разместить где-то между сохранённым указателем кадра и концом кадра. Это означает, что размер кадра не будет статически известен. Доступ к аргументу по-прежнему будет простым, это простой смещение от указателя кадра. Но доступ к регистрам теперь гораздо сложнее, поскольку он зависит от количества аргументов.
Указатель стека всегда указывает на последний регистр, мы могли бы использовать его для доступа к регистраторам, не зная количества аргументов. Этот подход может действительно работать, но у него есть серьёзный недостаток. Это потребует дублирования всех байт-кодов, которые могут обращаться к регистраторам и аргументам. Нам понадобятся LdaArgument
и LdaRegister
вместо простого Ldar
. Конечно, мы также могли бы проверять, обращаемся ли мы к аргументу или регистратору (положительные или отрицательные смещения), но это потребует проверки при каждом доступе к аргументам и регистраторам. Очевидно, это слишком дорого!
Аргументы в кадре вызывающей функции
Хорошо… а что, если мы оставим аргументы в кадре вызывающей функции?
Вспомните, как вычислить смещение аргумента i
в кадре: [ai] = 2 + parameter_count - i - 1
. Если у нас есть все аргументы (не только параметры), то смещение будет [ai] = 2 + argument_count - i - 1
. То есть для каждого обращения к аргументу нам нужно будет загрузить фактическое количество аргументов.
Но что произойдет, если мы перевернём аргументы? Теперь смещение можно вычислить просто как [ai] = 2 + i
. Нам не нужно знать, сколько аргументов находится в стеке, но если мы можем гарантировать, что в стеке всегда будет как минимум количество формальных параметров, то мы всегда можем использовать эту схему для вычисления смещения.
Другими словами, количество аргументов, которые помещаются в стек, всегда будет максимумом между количеством аргументов и количеством формальных параметров, и оно будет дополнено неопределёнными объектами при необходимости.
У этого подхода есть ещё один бонус! Приёмник всегда находится в одном и том же смещении для любой функции JS, прямо над адресом возврата: [this] = 2
.
Это чистое решение для наших требований номер 1
и номер 4
. А как насчет остальных двух требований? Как мы можем создать параметр rest и объект arguments? И как очистить аргументы в стеке при возврате к вызывающей функции? Для этого нам нужно только количество аргументов. Нам нужно будет сохранить его где-то. Выбор здесь немного произвольный, если эту информацию легко получить. Два основных варианта: поместить его сразу после приёмника в кадре вызывающей функции или как часть кадра вызываемой функции в фиксированной заголовочной части. Мы реализовали последний вариант, поскольку он объединяет фиксированную заголовочную часть интерпретируемых и оптимизированных кадров.
Если мы запустим наш пример в V8 v8.9, мы увидим следующий стек после InterpreterArgsThenPush
(обратите внимание, что аргументы теперь перевёрнуты):
Всё выполнение следует аналогичному пути, пока мы не дойдём до InvokeFunctionCode. Здесь мы модифицируем аргументы в случае недостаточного применения, добавляя столько неопределённых объектов, сколько необходимо. Обратите внимание, что мы ничего не изменяем в случае избыточного применения. Наконец, мы передаём количество аргументов в Code
вызываемой функции через регистр. В случае x64
используется регистр rax
.
Если функция ещё не была оптимизирована, мы достигаем InterpreterEntryTrampoline
, который создаёт следующий кадр стека.
Кадр вызываемой функции имеет дополнительный слот, содержащий количество аргументов, которое можно использовать для создания параметра rest или объекта arguments, а также для очистки аргументов в стеке перед возвращением к вызывающей функции.
Для возврата мы модифицируем LeaveInterpreterFrame
, чтобы считать количество аргументов в стеке и удалить максимальное число между количеством аргументов и количеством формальных параметров.
TurboFan
А что насчет оптимизированного кода? Давайте немного изменим наш первоначальный скрипт, чтобы заставить V8 скомпилировать его с использованием TurboFan:
function add42(x) { return x + 42; }
function callAdd42() { add42(3); }
%PrepareFunctionForOptimization(callAdd42);
callAdd42();
%OptimizeFunctionOnNextCall(callAdd42);
callAdd42();
Здесь мы используем внутренние функции V8, чтобы заставить V8 оптимизировать вызов, иначе V8 будет оптимизировать нашу маленькую функцию только если она станет «горячей» (используется очень часто). Мы вызываем её один раз до оптимизации, чтобы собрать некоторую информацию о типах, которая может быть использована для управления компиляцией. Подробнее о TurboFan читайте здесь.
Я покажу вам здесь только ту часть сгенерированного кода, которая имеет отношение к нам.
movq rdi,0x1a8e082126ad ;; Загрузить объект функции <JSFunction add42>
push 0x6 ;; Поместить SMI 3 в аргументы
movq rcx,0x1a8e082030d1 ;; <JSGlobal Object>
push rcx ;; Поместить приёмник (глобальный прокси-объект)
movl rax,0x1 ;; Сохранить количество аргументов в rax
movl rcx,[rdi+0x17] ;; Загрузить поле {Code} объекта функции в rcx
call rcx ;; Наконец, вызвать объект кода!
Хотя это написано на ассемблере, данный код не должен быть трудным для понимания, если следовать моим комментариям. По сути, при компиляции вызова, TF должен выполнять всю работу, которая была выполнена в InterpreterPushArgsThenCall
, Call
, CallFunction
и встроенных функциях InvokeFunctionCall
. Надеемся, что у него больше статической информации, чтобы делать это, и генерировать меньше инструкций.
TurboFan с рамкой адаптации аргументов
Теперь посмотрим случай несоответствия между количеством аргументов и параметров. Рассмотрим вызов add42(1, 2, 3)
. Это скомпилируется в:
movq rdi,0x4250820fff1 ;; Загрузить объект функции <JSFunction add42>
;; Поместить приемник и аргументы SMI 1, 2 и 3
movq rcx,0x42508080dd5 ;; <JSGlobal Object>
push rcx
push 0x2
push 0x4
push 0x6
movl rax,0x3 ;; Сохранить количество аргументов в rax
movl rbx,0x1 ;; Сохранить количество формальных параметров в rbx
movq r10,0x564ed7fdf840 ;; <ArgumentsAdaptorTrampoline>
call r10 ;; Вызвать ArgumentsAdaptorTrampoline
Как вы видите, добавить поддержку TF для несоответствия количества аргументов и формальных параметров несложно. Просто вызовите trampoline адаптера аргументов!
Однако это дорого. Для каждого оптимизированного вызова нам теперь нужно перейти в trampoline адаптера аргументов и преобразовать рамку так же, как в не оптимизированном коде. Это объясняет, почему прирост производительности от удаления рамки адаптера в оптимизированном коде намного выше, чем в Ignition.
Тем не менее, сгенерированный код очень прост. А возвращение из него чрезвычайно легко (эпилог):
movq rsp,rbp ;; Очистить рамку вызываемой функции
pop rbp
ret 0x8 ;; Удалить один аргумент (приёмник)
Мы удаляем нашу рамку и вводим инструкцию возврата в соответствии с количеством параметров. Если количество аргументов и количество параметров не совпадают, trampoline адаптера рамки решит эту проблему.
TurboFan без рамки адаптации аргументов
Сгенерированный код фактически идентичен вызову с совпадающим количеством аргументов. Рассмотрим вызов add42(1, 2, 3)
. Это сгенерирует:
movq rdi,0x35ac082126ad ;; Загрузить объект функции <JSFunction add42>
;; Поместить приемник и аргументы 1, 2 и 3 (обратный порядок)
push 0x6
push 0x4
push 0x2
movq rcx,0x35ac082030d1 ;; <JSGlobal Object>
push rcx
movl rax,0x3 ;; Сохранить количество аргументов в rax
movl rcx,[rdi+0x17] ;; Загрузить поле {Code} объекта функции в rcx
call rcx ;; Наконец, вызвать объект кода!
А как насчет эпилога функции? Мы больше не возвращаемся в trampoline адаптера аргументов, поэтому эпилог действительно несколько сложнее, чем раньше.
movq rcx,[rbp-0x18] ;; Загрузить количество аргументов (из рамки вызываемой функции) в rcx
movq rsp,rbp ;; Удалить рамку вызываемой функции
pop rbp
cmpq rcx,0x0 ;; Сравнить количество аргументов с количеством формальных параметров
jg 0x35ac000840c6 <+0x86>
;; Если количество аргументов меньше (или равно) количеству формальных параметров:
ret 0x8 ;; Возврат как обычно (количество параметров известно статически)
;; Если у нас больше аргументов в стеке, чем формальных параметров:
pop r10 ;; Сохранить адрес возврата
leaq rsp,[rsp+rcx*8+0x8] ;; Удалить все аргументы согласно rcx
push r10 ;; Восстановить адрес возврата
retl