Ленивая десериализация
TL;DR: Ленивая десериализация была недавно включена по умолчанию в V8 v6.4, снижая потребление памяти V8 в среднем на более чем 500 КБ на вкладку браузера. Читайте дальше, чтобы узнать больше!
Введение в функции снапшотов V8
Но сначала давайте отступим назад и посмотрим, как V8 использует снапшоты памяти для ускорения создания новых изолятов (что примерно соответствует вкладке в браузере Chrome). Мой коллега Ян Гуо дал хороший обзор на эту тему в своей статье о настраиваемых снапшотах запуска:
Спецификация JavaScript включает множество встроенного функционала — от математических функций до полнофункционального движка регулярных выражений. Каждый вновь созданный контекст V8 имеет эти функции доступными с самого начала. Для этого глобальный объект (например, объект
window
в браузере) и весь встроенный функционал должны быть настроены и инициализированы в памяти V8 в момент создания контекста. Делать это с нуля занимает довольно много времени.К счастью, V8 использует способ, ускоряющий процесс: как разморозка предварительно приготовленных блюд для быстрого ужина, мы десериализуем заранее подготовленный снапшот напрямую в память для получения инициализированного контекста. На обычном настольном компьютере это может сократить время на создание контекста с 40 мс до менее чем 2 мс. На среднем мобильном телефоне разница может составлять от 270 мс до 10 мс.
Подведем итоги: снапшоты критически важны для производительности при запуске, и они десериализуются для создания начального состояния памяти V8 для каждого изолята. Таким образом, размер снапшота определяет минимальный размер памяти V8, а большие снапшоты напрямую приводят к большему потреблению памяти каждым изолятом.
Снапшот содержит все, что нужно для полной инициализации нового изолята, включая языковые константы (например, значение undefined
), внутренние обработчики байткода, которые используются интерпретатором, встроенные объекты (например, String
) и функции, установленные на встроенных объектах (например, String.prototype.replace
) вместе с их исполняемыми объектами Code
.
За последние два года размер снапшота почти утроился, увеличившись с примерно 600 КБ в начале 2016 года до более чем 1500 КБ сегодня. Основная часть этого увеличения связана с сериализованными объектами Code
, количество которых выросло (например, за счет недавних дополнений к языку JavaScript по мере эволюции и роста его спецификации); а также их размером (встроенные объекты, созданные с помощью новой сборочной системы CodeStubAssembler, отображаются как машинный код в отличие от более компактных форматов байткода или минимизированного JavaScript).
Это плохие новости, так как мы хотели бы сохранять потребление памяти как можно ниже.
Ленивая десериализация
Одной из главных болевых точек было то, что мы копировали весь контент снапшота в каждый изолят. Особенно это было расточительным для встроенных функций, которые загружались безусловно, но могли никогда не быть использованными.
И здесь вступает в дело ленивая десериализация. Концепция довольно простая: что если десериализовать встроенные функции только непосредственно перед их вызовом?
Быстрое исследование некоторых из самых популярных веб-сайтов показало, что этот подход довольно привлекателен: в среднем используется только 30% всех встроенных функций, причем некоторые сайты используют только 16%. Это казалось многообещающим, особенно учитывая, что большинство этих сайтов интенсивно используют JavaScript, и эти числа можно рассматривать как (нечеткую) нижнюю границу потенциальной экономии памяти для веба в целом.
Когда мы начали работать в этом направлении, оказалось, что ленивая десериализация хорошо интегрируется с архитектурой V8, и понадобилось всего несколько в основном неинвазивных изменений в дизайне для запуска:
- Хорошо известные позиции внутри снапшота. До ленивой десериализации порядок объектов внутри сериализованного снапшота был не важен, так как мы десериализовали всю память сразу. Ленивая десериализация должна быть способна десериализовать любую встроенную функцию отдельно, и поэтому ей необходимо знать местоположение функции внутри снапшота.
- Десериализация одиночных объектов. Снимки (snapshots) V8 изначально были разработаны для десериализации полного кучи, и добавление поддержки десериализации отдельных объектов потребовало решения ряда особенностей, таких как несмежное расположение данных снимков (сериализованные данные одного объекта могли быть перемешаны с данными других объектов) и так называемые обратные ссылки (которые могут непосредственно ссылаться на объекты, ранее десериализованные в текущем запуске).
- Механизм ленивой десериализации. Во время выполнения обработчик ленивой десериализации должен уметь a) определить, какой объект кода десериализовать, b) выполнить фактическую десериализацию, и c) прикрепить сериализованный объект кода ко всем соответствующим функциям.
Наше решение первых двух пунктов заключалось в добавлении новой специальной области встроенных функций в снимок, которая может содержать только сериализованные объекты кода. Сериализация происходит в строго определенном порядке, и начальное смещение каждого объекта Code
хранится в специальной секции внутри области встроенных функций снимка. Обратные ссылки и перемешанные данные объектов запрещены.
Ленивая десериализация встроенных функций обрабатывается функцией с подходящим названием DeserializeLazy
, которая устанавливается на все функции ленивых встроенных объектов во время десериализации. При вызове во время выполнения она десериализует соответствующий объект Code
и, наконец, устанавливает его как на JSFunction
(представляющий объект функции), так и на SharedFunctionInfo
(общее между функциями, созданными из одного и того же функционального литерала). Каждая встроенная функция десериализуется максимум один раз.
Кроме встроенных функций, мы также реализовали ленивую десериализацию для обработчиков байткода. Обработчики байткода — это объекты кода, содержащие логику выполнения каждого байткода в интерпретаторе V8 Ignition. В отличие от встроенных функций, у них нет прикрепленных JSFunction
или SharedFunctionInfo
. Вместо этого их объекты кода напрямую хранятся в таблице диспетчеризации, в которой интерпретатор выполняет индексирование при переходе к следующему обработчику байткодов. Ленивая десериализация аналогична встроенным функциям: обработчик DeserializeLazy
определяет, какой обработчик десериализовать, проверяя массив байткодов, десериализует объект кода и, наконец, сохраняет десериализованный обработчик в таблице диспетчеризации. Снова, каждый обработчик десериализуется не более одного раза.
Результаты
Мы оценили экономию памяти, загрузив 1000 самых популярных веб-сайтов с помощью Chrome 65 на устройстве Android с использованием и без использования ленивой десериализации.
В среднем размер кучи V8 уменьшился на 540 KB, причем на 25% проверенных сайтов экономия составила больше 620 KB, на 50% — больше 540 KB, а на 75% — больше 420 KB.
Производительность во время выполнения (измеренная на стандартных JS-бенчмарках, таких как Speedometer, а также на широком наборе популярных веб-сайтов) осталась неизменной при использовании ленивой десериализации.
Следующие шаги
Ленивая десериализация гарантирует, что каждое изолированное окружение загружает только те встроенные объекты кода, которые действительно используются. Это уже большое достижение, но мы считаем, что возможно сделать еще один шаг вперед и снизить (связанные с встроенными функциями) расходы каждого изолированного окружения фактически до нуля.
Мы надеемся представить вам обновления в этой области позже в этом году. Следите за новостями!