Перейти к основному содержимому

Встроенные функции

· 10 мин. чтения
Якоб Грубер ([@schuay](https://twitter.com/schuay))

Встроенные функции V8 (builtins) потребляют память в каждом экземпляре V8. Число встроенных функций, средний размер и количество экземпляров V8 на вкладке браузера Chrome значительно выросли. В этом посте мы расскажем, как смогли уменьшить медианный размер кучи V8 на сайте на 19% за последний год.

Фоновая информация

V8 поставляется с обширной библиотекой встроенных функций JavaScript (JS) built-in functions. Многие встроенные функции непосредственно доступны разработчикам JS как функции, установленные на встроенных объектах JS, таких как RegExp.prototype.exec и Array.prototype.sort; другие встроенные функции реализуют различные внутренние функции. Машинный код для встроенных функций генерируется собственным компилятором V8 и загружается в состояние управляемой кучи для каждого экземпляра V8 (Isolate) при инициализации. Экземпляр изолята (Isolate) представляет изолированный экземпляр движка V8, и каждая вкладка браузера Chrome содержит по крайней мере один экземпляр. Каждый изолят имеет свою собственную управляемую кучу и, соответственно, свою копию всех встроенных функций.

В 2015 году встроенные функции в основном были реализованы на самохостинговом JS, родной ассемблерной программе или на C++. Они были довольно маленькими, и создание копии для каждого изолята было менее проблематичным.

За последние годы многое изменилось.

В 2016 году V8 начал экспериментировать со встроенными функциями, реализованными в CodeStubAssembler (CSA). Это оказалось удобным (независимость от платформы, читаемость) и позволило создавать эффективный код, поэтому встроенные функции CSA стали широко распространены. По ряду причин встроенные функции CSA, как правило, генерируют более крупный код, а размер встроенных функций V8 примерно утроился по мере увеличения числа портированных в CSA. К середине 2017 года их затраты на изолят выросли значительно, и мы начали думать о систематическом решении.

Размер снимка V8 (включая встроенные функции) с 2015 по 2017

В конце 2017 года мы реализовали ленивую десериализацию встроенных функций (и обработчиков байт-кодов) как первый шаг. Наш начальный анализ показал, что большинство сайтов используют менее половины всех встроенных функций. При ленивой десериализации встроенные функции загружаются по запросу, а неиспользуемые встроенные функции никогда не загружаются в изолят. Ленивую десериализацию мы выпустили в Chrome 64, обеспечивая перспективные сбережения памяти. Однако: затраты памяти встроенных функций всё ещё росли линейно в зависимости от числа изолятов.

Затем был раскрыт Spectre, и Chrome в конечном итоге включил изоляцию сайта для смягчения его воздействия. Изоляция сайта ограничивает процесс рендерера Chrome документами только одного источника. Таким образом, с изоляцией сайта многие вкладки создают больше процессов рендереров и больше изолятов V8. Хотя управление накладными расходами на изолят всегда было важно, изоляция сайта сделала это ещё более необходимым.

Встроенные функции в коде

Целью этого проекта было полностью устранить затраты встроенных функций на изолят.

Идея за этим была проста. Концептуально встроенные функции идентичны для всех изолятов и привязаны к изоляту только из-за особенностей реализации. Если бы мы могли сделать встроенные функции полностью независимыми от изолятов, мы могли бы сохранить одну копию в памяти и поделиться ею между всеми изолятами. А если бы мы могли сделать их независимыми от процесса, они могли бы даже быть разделены между процессами.

На практике мы столкнулись с несколькими вызовами. Сгенерированный код встроенных функций был не независимым от изолятов и процессов из-за встроенных указателей на данные, специфичные для изолятов и процессов. В8 не имел концепции выполнения сгенерированного кода, расположенного вне управляемой кучи. Встроенные функции должны были быть разделены между процессами, желательно с повторным использованием существующих механизмов операционной системы. И, наконец (это оказалось долгим хвостом), производительность не должна заметно снижаться.

Следующие разделы описывают наше решение подробно.

Код, независимый от изолята и процесса

Встроенные функции генерируются внутренним конвейером компилятора V8, который вставляет ссылки на константы кучи (расположенные в управляемой куче изолята), цели вызова (объекты Code, также на управляемой куче) и на адреса, специфичные для изолята и процесса (например, функции C runtime или указатель на сам изолят, также называемые 'внешними ссылками') непосредственно в код. В x64 assembly загрузка такого объекта может выглядеть следующим образом:

// Загрузка встроенного адреса в регистр rbx.
REX.W movq rbx,0x56526afd0f70

В V8 используется перемещаемый сборщик мусора, и местоположение целевого объекта может изменяться со временем. Если объект будет перемещён во время сборки мусора, сборщик обновляет созданный код, чтобы указать на новое местоположение.

На x64 (и большинстве других архитектур) вызовы к другим объектам Code используют эффективную инструкцию вызова, которая определяет целевой адрес вызова через смещение от текущего счетчика программы (интересная деталь: V8 резервирует весь CODE_SPACE в управляемой области памяти при запуске, чтобы обеспечить возможность нахождения всех объектов Code в адресуемом диапазоне друг относительно друга). Соответствующая часть последовательности вызова выглядит следующим образом:

// Инструкция вызова, расположенная на [pc + <offset>].
call <offset>

Вызов относительно PC

Сами объекты Code располагаются в управляемой области памяти и являются перемещаемыми. При их перемещении сборщик мусора обновляет смещение во всех соответствующих местах вызова.

Чтобы сделать встроенные функции доступными между процессами, созданный код должен быть неизменяемым, а также независимым от изоляторов и процессов. Оба приведённых выше примера последовательности инструкций не соответствуют этим требованиям: они напрямую встраивают адреса в код, и сборщик мусора изменяет их во время выполнения.

Чтобы решить обе проблемы, мы ввели косвенную адресацию через специальный регистр корней, который содержит указатель на известное местоположение в текущем изоляторе.

Структура изолятора

Класс Isolate в V8 содержит таблицу корней, которая сама содержит указатели на корневые объекты в управляемой области памяти. Регистр корней постоянно содержит адрес таблицы корней.

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

// Загрузить постоянный адрес, расположенный на заданном
// смещении от корней.
REX.W movq rax,[kRootRegister + <offset>]

Константы для кучи корней можно загружать напрямую из списка корней, как указано выше. Другие константы для кучи используют дополнительную косвенную адресацию через глобальный пул констант встроенных функций, который сам хранится в списке корней:

// Загрузить пул констант встроенных функций, затем
// желаемую константу.
REX.W movq rax,[kRootRegister + <offset>]
REX.W movq rax,[rax + 0x1d7]

Для целевых объектов Code мы первоначально переключились на более сложную последовательность вызовов, которая загружает целевой объект Code из глобального пула констант встроенных функций, как указано выше, загружает целевой адрес в регистр, а затем выполняет косвенный вызов.

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

Совместное использование между процессами

Первоначально мы оценили два альтернативных подхода. Встроенные функции могли либо делиться через mmap, добавляя файл данных в память; либо быть встроенными непосредственно в исполняемый файл. Мы выбрали второй подход, поскольку он обеспечивал автоматическое использование стандартных механизмов ОС для совместного доступа к памяти между процессами, и изменение не требовало дополнительной логики от встраивателей V8, таких как Chrome. Мы были уверены в этом подходе, поскольку AOT-компиляция Dart уже успешно внедрила сгенерированный код в бинарный файл.

Исполняемый бинарный файл делится на несколько секций. Например, ELF бинарный файл содержит данные в секциях .data (инициализированные данные), .ro_data (инициализированные только для чтения данные) и .bss (неинициализированные данные), в то время как нативный исполняемый код размещается в .text. Наша цель заключалась в упаковке кода встроенных функций в секцию .text наряду с нативным кодом.

Секции исполняемого бинарного файла

Это было сделано путем добавления нового этапа сборки, который использует внутренний конвейер компилятора V8 для генерации нативного кода для всех встроенных функций и вывода их содержимого в embedded.cc. Этот файл затем компилируется в финальный бинарный файл V8.

Упрощенный процесс сборки встроенных функций в V8

Сам файл embedded.cc содержит как метаданные, так и сгенерированный машинный код встроенных функций в виде серии директив .byte, которые инструктируют компилятор C++ (в нашем случае clang или gcc) разместить указанную последовательность байтов непосредственно в выходной объектный файл (а затем в исполняемый файл).

// Информация о встроенных функциях включена в
// таблицу метаданных.
V8_EMBEDDED_TEXT_HEADER(v8_Default_embedded_blob_)
__asm__(".byte 0x65,0x6d,0xcd,0x37,0xa8,0x1b,0x25,0x7e\n"
[snip metadata]

// За которыми следует сгенерированный машинный код.
__asm__(V8_ASM_LABEL("Builtins_RecordWrite"));
__asm__(".byte 0x55,0x48,0x89,0xe5,0x6a,0x18,0x48,0x83\n"
[snip builtins code]

Содержимое секции .text мапируется в память, доступную только для чтения и выполнения, во время выполнения, а операционная система будет делить память между процессами, пока она содержит только код, независимый от позиции, без релокируемых символов. Это именно то, чего мы хотели.

Но объекты Code в V8 состоят не только из потока инструкций, но также имеют различные части метаданных (иногда зависящих от изоляции). Обычные объекты Code содержат как метаданные, так и поток инструкций в объекте Code переменной длины, который расположен в управляемой куче.

Макет объекта Code в куче

Как мы видели, встроенные объекты имеют свой собственный поток инструкций, расположенный вне управляемой кучи, встроенный в секцию .text. Чтобы сохранить их метаданные, каждый встроенный объект также имеет небольшой связанный с ним объект Code на управляемой куче, называемый трамплином вне кучи. Метаданные хранятся в трамплине, как и для стандартных объектов Code, в то время как встраиваемый поток инструкций содержит лишь короткую последовательность, которая загружает адрес встроенных инструкций и переходит к ним.

Макет объекта Code вне кучи

Трамплин позволяет V8 обрабатывать все объекты Code единообразно. Для большинства целей не имеет значения, ссылается ли данный объект Code на стандартный код в управляемой куче или на встроенный объект.

Оптимизация производительности

С решением, описанным в предыдущих разделах, встроенные объекты были по сути полностью функциональны, но тесты производительности показали, что они привели к значительному замедлению. Например, наше первоначальное решение снизило производительность Speedometer 2.0 более чем на 5% в целом.

Мы начали искать возможности для оптимизации и выявили основные источники замедления. Сгенерированный код был медленнее из-за частых косвенных вызовов для доступа к объектам, зависящим от изоляции и процесса. Константы корня загружались из списка корней (1 уровень косвенности), другие константы кучи — из общего пула констант встроенных объектов (2 уровня косвенности), а внешние ссылки дополнительно распаковывались из объекта в куче (3 уровня косвенности). Наибольшую проблему вызывала наша новая последовательность вызовов, которая должна была загружать объект Code трамплина, вызывать его, а затем переходить на конечный адрес. Наконец, оказалось, что вызовы между управляемой кучей и встроенным бинарным кодом были по своей природе медленнее, возможно, из-за большого расстояния прыжка, мешающего предсказанию ветвления процессором.

Наша работа сосредоточилась на 1) уменьшении косвенных вызовов и 2) улучшении последовательности вызовов встроенных объектов. Чтобы решить первую проблему, мы изменили макет объекта Isolate, чтобы большинство загрузок объектов превратить в одно корнерелативное чтение. Общий пул констант встроенных объектов все еще существует, но теперь содержит только редко используемые объекты.

Оптимизированный макет Isolate

Последовательности вызовов были значительно улучшены по двум направлениям. Вызовы встроенных объектов друг к другу были преобразованы в одну инструкцию вызова с учетом относительного смещения по адресу программы (pc-relative call). Это было невозможно для JIT-кода, сгенерированного во время выполнения, поскольку относительное смещение по адресу программы могло превысить максимальное 32-битное значение. В этом случае мы встроили трамплин вне кучи во все точки вызова, сократив последовательность вызовов с 6 до всего 2 инструкций.

С этими оптимизациями нам удалось ограничить регрессии в Speedometer 2.0 примерно до 0,5%.

Результаты

Мы оценили влияние встроенных объектов на архитектуре x64 через топ-10k самых популярных веб-сайтов и сравнили с ленивой и жадной десериализацией (описанными выше).

Снижение размера кучи V8 по сравнению с жадной и ленивой десериализацией

Раньше Chrome приходил с снимком памяти, который мы десериализовали для каждой изоляции, теперь этот снимок заменен встроенными объектами, которые также отображаются в память, но не требуют десериализации. Стоимость встроенных объектов ранее была c*(1 + n), где n — количество изоляций, а c — память, необходимая для всех встроенных объектов, тогда как теперь это просто c * 1 (на практике остается небольшая накладная стоимость на изоляцию для трамплинов вне кучи).

По сравнению с жадной десериализацией мы сократили медианный размер кучи V8 на 19%. Медианный размер процесса Chrome для рендеринга на сайт уменьшился на 4%. В абсолютных числах 50-й перцентиль экономит 1,9 МБ, 30-й перцентиль экономит 3,4 МБ, а 10-й перцентиль экономит 6,5 МБ на сайт.

Ожидается значительная дополнительная экономия памяти, как только обработчики байт-кода также будут встроены в бинарные файлы.

Встроенные объекты будут запущены на x64 в Chrome 69, а мобильные платформы последуют в Chrome 70. Поддержка ia32 ожидается к концу 2018 года.

примечание

Примечание: Все диаграммы были созданы с использованием потрясающего инструмента Shaky Diagramming Вячеслава Егоров.