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

Static Roots: Объекты с Константными Адресами во Времени Компиляции

· 4 мин. чтения
Оливье Флюкигер

Вы когда-нибудь задумывались, откуда взялись undefined, true и другие основные объекты JavaScript? Эти объекты являются основами любых пользовательских объектов и должны существовать первыми. V8 называет их неизменяемыми корнями, которые располагаются в собственной куче – только для чтения. Так как они постоянно используются, быстрый доступ крайне важен. А что может быть быстрее правильного угадывания их адреса в памяти на этапе компиляции?

Например, возьмем крайне часто используемую функцию API IsUndefined API function. Вместо того, чтобы искать адрес объекта undefined для ссылки, что, если бы мы просто проверяли, заканчивается ли указатель объекта, скажем, на 0x61, чтобы узнать, является ли он undefined? Именно это и реализуется в функции static roots в V8. В этой статье описываются сложности, которые мы преодолели для реализации этого. Функция появилась в Chrome 111 и привнесла выгоды в производительности во всей VM, в частности ускорив выполнение кода на C++ и встроенные функции.

Инициализация Кучи Только для Чтения

Создание объектов только для чтения занимает некоторое время, поэтому V8 создает их на этапе компиляции. Для компиляции V8 сначала компилируется минимальный бинарный файл proto-V8 с названием mksnapshot. Он создает все разделяемые объекты только для чтения, а также машинный код встроенных функций и записывает их в снимок. Затем компилируется основная версия бинарного файла V8 и объединяется со снимком. Для запуска V8 снимок загружается в память, и мы можем сразу начать использовать его содержимое. Следующая диаграмма показывает упрощенный процесс сборки для автономного бинарного файла d8.

Как только d8 запущен, все объекты только для чтения занимают фиксированные места в памяти и больше не перемещаются. Когда мы JIT-компилируем код, мы, например, можем напрямую ссылаться на undefined по его адресу. Однако при создании снимка и при компиляции C++ для libv8 адрес еще неизвестен. Он зависит от двух неизвестных на этапе сборки вещей. Первое – это бинарная структура кучи только для чтения, второе – это место в памяти, где находится эта куча.

Как Предсказать Адреса?

V8 использует сжатие указателей. Вместо полных 64-битных адресов мы обращаемся к объектам через 32-битное смещение в 4ГБ области памяти. Для многих операций, таких как загрузка свойств или сравнения, это смещение достаточно для уникальной идентификации объекта. Следовательно, вторая проблема – неизвестность того, где в памяти находится куча только для чтения – на самом деле не является проблемой. Мы просто размещаем кучу только для чтения в начале каждого блока сжатия указателей, таким образом задавая ей известное место. Например, из всех объектов в куче V8, undefined всегда имеет самый маленький сжатый адрес, начиная с 0x61 байт. Таким образом, если младшие 32 бита полного адреса любого JS-объекта равны 0x61, то этот объект – undefined.

Это уже полезно, но мы хотим иметь возможность использовать этот адрес в снимке и в libv8 – это кажется нерешаемой задачей. Тем не менее, если мы обеспечим, что mksnapshot создаст битовый идентичный снимок кучи только для чтения, то мы сможем повторно использовать эти адреса между сборками. Для использования их в самом libv8 мы, по сути, компилируем V8 дважды:

В первый раз при вызове mksnapshot единственным производимым артефактом является файл, содержащий адреса относительно базы блока всех объектов в куче только для чтения. На втором этапе сборки мы снова компилируем libv8, и флаг гарантирует, что всякий раз, когда мы ссылаемся на undefined, мы буквально используем cage_base + StaticRoot::kUndefined; статическое смещение undefined, конечно, определяется в файле static-roots.h. Во многих случаях это позволит компилятору C++ для libv8 и компилятору встроенных функций в mksnapshot генерировать более эффективный код, так как альтернативой всегда было бы загрузка адреса из глобального массива корневых объектов. В итоге мы получаем бинарный файл d8, где сжатый адрес undefined задан как 0x61.

По сути, все работает именно так, но на практике V8 компилируется только один раз – никто не хочет тратить на это больше времени. Сгенерированный файл static-roots.h кэшируется в исходном репозитории и должен быть создан заново только при изменении структуры кучи только для чтения.

Применения

Говоря о практических аспектах, статические корни позволяют еще больше оптимизировать. Например, мы сгруппировали общие объекты, что позволяет реализовать некоторые операции в виде проверки диапазона их адресов. Например, все карты строк (т.е. hidden-class мета-объекты, описывающие макеты различных типов строк) находятся рядом друг с другом, следовательно, объект является строкой, если его карта имеет сжатый адрес между 0xdd и 0x49d. Или, объекты истинности должны иметь адрес, который как минимум 0xc1.

Не все зависит от производительности JIT-кода в V8. Как показал этот проект, относительно небольшое изменение в коде на C++ также может оказать значительное влияние. Например, Speedometer 2, эталонный тест, который проверяет API V8 и взаимодействие между V8 и его встраивателем, увеличил свой рейтинг примерно на 1% на процессоре M1 благодаря статическим корням.