Управление сложностью архитектуры в V8 — CodeStubAssembler
В этом посте мы хотели бы представить CodeStubAssembler (CSA), компонент в V8, который оказался очень полезным инструментом для достижения некоторых больших улучшений производительности достижений за последние несколько выпусков V8. CSA также значительно улучшил способность команды V8 быстро оптимизировать функции JavaScript на низком уровне с высокой степенью надежности, что повысило скорость разработки команды.
Краткая история встроенных функций и ручной сборки в V8
Чтобы понять роль CSA в V8, важно немного разобраться в контексте и истории, которые привели к его разработке.
V8 извлекает производительность из JavaScript, используя сочетание различных методов. Для JavaScript-кода, который выполняется долго, оптимизирующий компилятор V8 TurboFan отлично справляется с ускорением всей функциональности ES2015+ для достижения пиковых показателей. Однако V8 также нужно эффективно исполнять кратковременные JavaScript программы для хорошей базовой производительности. Это особенно важно для так называемых встроенных функций на предопределенных объектах, которые доступны всем JavaScript-программам, как указано в спецификации ECMAScript.
Исторически сложилось так, что многие из этих встроенных функций самохостились, то есть они были написаны разработчиком V8 на JavaScript — хотя и на специальном внутреннем диалекте V8. Чтобы достичь хорошей производительности, эти самохостящиеся встроенные функции используют те же механизмы, которые V8 применяет для оптимизации пользовательского JavaScript-кода. Как и пользовательский код, самохостящиеся встроенные функции требуют фазы «разогрева», в ходе которой собирается обратная связь по типам, и они должны быть скомпилированы оптимизирующим компилятором.
Хотя этот метод обеспечивает хорошую производительность встроенных функций в некоторых ситуациях, можно достичь и лучших результатов. Точные семантики предопределенных функций на Array.prototype
указаны с изумительной детализацией в спецификации. Для важных и общих особых случаев разработчики V8 заранее знают, как именно эти встроенные функции должны работать, опираясь на спецификацию, и используют эти знания для тщательной разработки, настройки и оптимизации встроенных функций. Эти оптимизированные встроенные функции обрабатывают общие случаи без «разогрева» или необходимости вызова оптимизирующего компилятора, поскольку их базовая производительность оптимальна с первого вызова.
Для того, чтобы выжать максимальную производительность из ручного написания встроенных функций JavaScript (и другого быстрого кода V8, который также называют встроенными функциями), разработчики V8 традиционно писали оптимизированные встроенные функции на языках ассемблера. Используя ассемблер, ручные встроенные функции были особенно быстры, среди прочего избегая дорогих вызовов C++ кода V8 через trampolines и используя преимущество настраиваемого регистрационного ABI, который V8 применяет внутри для вызова JavaScript-функций.
Благодаря преимуществам написания ассемблера вручную, V8 за годы накопила буквально десятки тысяч строк кода на ассемблере для встроенных функций… для каждой платформы. Все эти ручные сборки ассемблерных встроенных функций были отличны для повышения производительности, но новые языковые функции постоянно стандартизируются, а поддержка и расширение этого написанного вручную кода оказалось трудоемким и подверженным ошибкам.
Встречайте CodeStubAssembler
Разработчики V8 много лет сталкивались с дилеммой: возможно ли создать встроенные функции, которые обладают преимуществами написания вручную на ассемблере, но при этом не являются хрупкими и сложными в поддержке?
С появлением TurboFan ответ на этот вопрос наконец стал «да». Задняя часть TurboFan использует кроссплатформенное промежуточное представление (IR) для низкоуровневых машинных операций. Это низкоуровневое машинное IR является входными данными для селектора инструкций, распределителя регистров, планировщика инструкций и генератора кода, которые создают очень качественный код для всех платформ. Задняя часть также знает о многих трюках, которые используются в рукописных сборках V8, например о том, как использовать и вызывать пользовательскую ABI на основе регистров, как поддерживать машинные хвостовые вызовы и как избегать создания стековых фреймов в листовых функциях. Эти знания делают заднюю часть TurboFan особенно подходящей для генерации быстрого кода, который хорошо интегрируется с остальной частью V8.
Эта комбинация функциональности впервые сделала возможной надежную и поддерживаемую альтернативу рукописным сборкам. Команда разработала новый компонент V8—названный CodeStubAssembler или CSA—который определяет переносимый язык сборки, основанный на задней части TurboFan. CSA добавляет API для генерирования машинного IR TurboFan непосредственно, без необходимости писать и разбирать JavaScript или применять JavaScript-оптимизации TurboFan. Хотя этот быстрый путь к генерации кода могут использовать только разработчики V8 для ускорения внутренней работы V8, этот эффективный способ генерации оптимизированного ассемблерного кода кроссплатформенным способом напрямую улучшает код JavaScript всех разработчиков в рамках встроенных функций, построенных с использованием CSA, включая критически важные по производительности обработчики байткода для интерпретатора V8, Ignition.
Интерфейс CSA включает операции, которые являются очень низкоуровневыми и знакомыми для любого, кто когда-либо писал ассемблерный код. Например, он включает такие функции, как «загрузить этот указатель объекта по данному адресу» и «умножить эти два 32-битных числа». CSA имеет проверку типов на уровне IR, чтобы выявлять многие ошибки корректности на этапе компиляции, а не во время выполнения. Например, это позволяет гарантировать, что разработчик V8 случайно не использует указатель объекта, загруженный из памяти, в качестве входных данных для 32-битного умножения. Такая проверка типов просто невозможна в рукописных сборках.
Пробная поездка с CSA
Чтобы лучше понять, что предлагает CSA, давайте рассмотрим быстрый пример. Мы добавим новую встроенную функцию в V8, которая возвращает длину строки из объекта, если этот объект является строкой. Если входной объект не является строкой, встроенная функция вернет undefined
.
Сначала мы добавим строку в макрос BUILTIN_LIST_BASE
в файле builtin-definitions.h
V8, которая объявляет новую встроенную функцию под названием GetStringLength
и указывает, что она имеет один входной параметр, идентифицированный константой kInputObject
:
TFS(GetStringLength, kInputObject)
Макрос TFS
объявляет встроенную функцию как TurboFan встроенную функцию, используя стандартную связь CodeStub, что означает, что она использует CSA для генерации своего кода и ожидает передачи параметров через регистры.
Затем мы можем определить содержимое встроенной функции в файле builtins-string-gen.cc
:
TF_BUILTIN(GetStringLength, CodeStubAssembler) {
Label not_string(this);
// Получаем входной объект с использованием определенной выше константы
// для первого параметра.
Node* const maybe_string = Parameter(Descriptor::kInputObject);
// Проверяем, является ли входной объект Smi (особым представлением
// малых чисел). Это необходимо сделать до проверки IsString
// ниже, так как IsString предполагает, что ее аргумент является
// указателем на объект, а не Smi. Если аргумент действительно является
// Smi, переходим к метке |not_string|.
GotoIf(TaggedIsSmi(maybe_string), ¬_string);
// Проверяем, является ли входной объект строкой. Если нет, то переходим
// к метке |not_string|.
GotoIfNot(IsString(maybe_string), ¬_string);
// Загружаем длину строки (после проверки выше, что она строка)
// и возвращаем ее, используя CSA "макрос" LoadStringLength.
Return(LoadStringLength(maybe_string));
// Определяем местоположение метки, являющейся целью
// неудачной проверки IsString выше.
BIND(¬_string);
// Входной объект не является строкой. Возвращаем JavaScript-константу undefined.
Return(UndefinedConstant());
}
Обратите внимание, что в приведенном выше примере используются два типа инструкций. Есть примитивные инструкции CSA, которые непосредственно переводятся в одну или две ассемблерные инструкции, такие как GotoIf
и Return
. Существует фиксированный набор предопределенных примитивных инструкций CSA, которые примерно соответствуют наиболее часто используемым ассемблерным инструкциям, встречающимся в одной из поддерживаемых чиповых архитектур V8. Другие инструкции в примере—это макроинструкции, такие как LoadStringLength
, TaggedIsSmi
и IsString
, которые представляют собой удобные функции для вывода одной или нескольких примитивных или макроинструкций на месте. Макроинструкции используются для инкапсуляции часто используемых приемов реализации V8 для удобного повторного использования. Они могут быть произвольно длинными, и новые макроинструкции могут легко определяться разработчиками V8 по мере необходимости.
После компиляции V8 с вышеуказанными изменениями мы можем запустить mksnapshot
, инструмент, который компилирует встроенные функции для подготовки их к снапшоту V8, с опцией командной строки --print-code
. Эта опция выводит сгенерированный ассемблерный код для каждой встроенной функции. Если использовать команду grep
для поиска GetStringLength
в выводе, мы получим следующий результат на x64 (код немного очищен для лучшей читаемости):
test al,0x1
jz not_string
movq rbx,[rax-0x1]
cmpb [rbx+0xb],0x80
jnc not_string
movq rax,[rax+0xf]
retl
not_string:
movq rax,[r13-0x60]
retl
На платформах ARM с 32-битной архитектурой mksnapshot
генерирует следующий код:
tst r0, #1
beq +28 -> not_string
ldr r1, [r0, #-1]
ldrb r1, [r1, #+7]
cmp r1, #128
bge +12 -> not_string
ldr r0, [r0, #+7]
bx lr
not_string:
ldr r0, [r10, #+16]
bx lr
Несмотря на то, что наша новая встроенная функция использует нестандартное соглашение вызова (по крайней мере не стандартное для C++), возможно написать тесты для неё. Следующий код можно добавить в файл test-run-stubs.cc
, чтобы протестировать встроенную функцию на всех платформах:
TEST(GetStringLength) {
HandleAndZoneScope scope;
Isolate* isolate = scope.main_isolate();
Heap* heap = isolate->heap();
Zone* zone = scope.main_zone();
// Тестируем случай, когда входные данные являются строкой
StubTester tester(isolate, zone, Builtins::kGetStringLength);
Handle<String> input_string(
isolate->factory()->
NewStringFromAsciiChecked("Oktoberfest"));
Handle<Object> result1 = tester.Call(input_string);
CHECK_EQ(11, Handle<Smi>::cast(result1)->value());
// Тестируем случай, когда входные данные не являются строкой (например, undefined)
Handle<Object> result2 =
tester.Call(factory->undefined_value());
CHECK(result2->IsUndefined(isolate));
}
Для получения дополнительной информации о работе с CSA для различных видов встроенных функций и других примеров см. эту википедию.
Ускорение разработки V8
CSA — это не просто универсальный ассемблерный язык, предназначенный для нескольких платформ. Он обеспечивает значительно более быструю разработку новых функций по сравнению с ручным написанием кода для каждой архитектуры, как это делалось раньше. Это достигается за счёт следующих преимуществ:
- С использованием CSA разработчики могут писать код встроенных функций с применением кроссплатформенных низкоуровневых примитивов, которые непосредственно переводятся в ассемблерные инструкции. Селектор инструкций CSA обеспечивает оптимальность этого кода на всех платформах, для которых предназначен V8, без необходимости разработчикам V8 быть экспертами в каждой из этих платформенных ассемблерных языков.
- Интерфейс CSA имеет опциональные типы, чтобы гарантировать, что значения, обрабатываемые низкоуровневым сгенерированным ассемблером, имеют ожидаемые типы.
- Распределение регистров между ассемблерными инструкциями выполняется CSA автоматически, включая построение стековых фреймов и сброс значений в стек, если встроенная функция использует больше регистров, чем доступно, или делает вызов. Это устраняет целый класс тонких, труднонаходимых ошибок, которые преследовали встроенные функции, написанные вручную на ассемблере. За счёт создания менее хрупкого генерируемого кода CSA значительно сокращает время, необходимое для написания корректных низкоуровневых встроенных функций.
- CSA понимает соглашения вызовов ABI — как стандартные C++, так и внутренние регистровые соглашения V8, что позволяет легко взаимодействовать между кодом, сгенерированным CSA, и другими частями V8.
- Поскольку код CSA написан на C++, легко инкапсулировать общие паттерны генерации кода в макросы, которые могут быть легко повторно использованы во многих встроенных функциях.
- Поскольку V8 использует CSA для генерации обработчиков байт-кода для Ignition, очень легко встроить функциональность встроенных функций на основе CSA непосредственно в обработчики для повышения производительности интерпретатора.
- Тестовая структура V8 поддерживает тестирование функциональности CSA и встроенных функций, сгенерированных CSA, из C++ без необходимости написания адаптеров на ассемблере.
В конечном счёте, CSA стал поворотным моментом для разработки V8. Он значительно улучшил способность команды оптимизировать V8. Это означает, что мы можем быстрее оптимизировать больше возможностей языка JavaScript для эмбеддеров V8.