Сервис развивается: тестируем формат, собираем идеи, улучшаем сервис. Есть идеи?

Написать
Войти
Дайджесты
Иллюстрация к материалу: Pointer compression в движке V8: оптимизация потребления оперативной памяти

Pointer compression в движке V8: оптимизация потребления оперативной памяти

Разбор внутренней архитектуры сжатия указателей (Pointer Compression) в JavaScript-движке V8. Использование 32-битных смещений относительно базового адреса кучи позволяет сократить размер кучи V8 на 40% и снизить потребление памяти Chrome до 20%, сохранив высокую скорость выполнения кода.

Pointer compression в движке V8: оптимизация потребления оперативной памяти

Разработчики программного обеспечения часто сталкиваются с необходимостью балансировать между производительностью приложения и объемом потребляемой им оперативной памяти. Как правило, ускорение выполнения кода требует выделения дополнительных ресурсов памяти, и наоборот. Одним из наиболее показательных и успешных примеров решения этой дилеммы в системном программировании является технология сжатия указателей (Pointer Compression), реализованная в JavaScript-движке V8, который лежит в основе браузера Google Chrome и серверной платформы Node.js.

Внедрение сжатия указателей позволило разработчикам сократить размер кучи (heap) V8 на величину до 43%, а общее потребление оперативной памяти процессами рендеринга Chrome — в среднем на 20%. При этом инженерам удалось сохранить паритет производительности, преодолев первоначальное падение скорости выполнения JavaScript на 35%.

Как V8 представляет данные: механизм разметки указателей

Чтобы понять физику сжатия указателей, необходимо рассмотреть, как JavaScript-движок V8 хранит переменные в оперативной памяти. JavaScript — это динамически типизированный язык, где переменная может содержать число, строку, массив или объект. В V8 любые значения представляются в виде объектов, распределяемых в куче. Соответственно, любая переменная на низком уровне является указателем на область памяти, где лежат свойства этого объекта.

Однако для простых операций, таких как инкремент счетчика в цикле, выделение полноценного объекта в куче привело бы к колоссальным накладным расходам. Чтобы избежать этого, V8 использует классическую технику разметки указателей (Value Tagging). Младшие биты адреса в памяти используются в качестве тегов, определяющих тип данных. Поскольку объекты в куче всегда выравниваются по границе машинного слова (например, по 4 или 8 байт), несколько младших бит адреса всегда равны нулю и могут быть задействованы под служебную информацию.

V8 разделяет типы данных на две категории:

  1. Smi (Small Integer) — малые целые числа: Они хранятся непосредственно внутри самого указателя, без создания отдельного объекта в куче.
  2. HeapObject — указатели на объекты в куче: Это реальные физические адреса в памяти.

На 64-битных архитектурах без сжатия указатель занимает 8 байт (64 бита). При этом под значение Smi отводится 32 бита, а под адрес HeapObject — все 64 бита. Исследования реальных веб-страниц показали, что подобные размеченные значения составляют около 70% от всего содержимого кучи V8.

Суть сжатия указателей: 32-битные смещения

Когда в 2014 году браузер Chrome перешел на 64-битные процессы ради повышения безопасности и стабильности, разработчики столкнулись с неприятным побочным эффектом: размер указателей вырос с 4 до 8 байт, что привело к росту потребления памяти браузером на 30–40%.

Идея технологии сжатия указателей предельно проста: вместо хранения полных 64-битных адресов в памяти можно хранить 32-битные смещения (offsets) относительно некоего базового адреса начала кучи.

Чтобы эта схема работала, куча одного изолята V8 ограничивается максимальным размером в 4 гигабайта (объем памяти, который можно адресовать с помощью 32-битного беззнакового числа). При старте процесса V8 резервирует contiguous-область виртуальной памяти размером 4 ГБ. Базовый адрес этой области сохраняется в выделенном регистре процессора (например, r13 на архитектуре x64).

В памяти хранятся только младшие 32 бита адреса (смещение). При записи значения в память происходит его усечение (truncation) до 32 бит. При чтении значения из памяти (декомпрессии) движок берет 32-битное смещение и прибавляет его к базовому адресу, хранящемуся в регистре процессора, восстанавливая полный 64-битный адрес для работы CPU.

История оптимизации: от регрессии к победе

Первая рабочая реализация сжатия указателей показала удручающие результаты: производительность выполнения JavaScript на популярном бенчмарке Octane упала на 35%. Разработчикам V8 пришлось пройти долгий путь оптимизации компилятора и ассемблерного кода для восстановления скорости.

Оптимизация 1: Выбор в пользу ветвления

Первоначальная гипотеза разработчиков состояла в том, что декомпрессия указателей должна выполняться без использования ветвлений (branchless-код) с помощью побитовых масок. Однако практические замеры на архитектурах x64 и ARM64 показали, что классический вариант с условным переходом (branchful) работает на 7% быстрее. Современные предсказатели переходов в процессорах оказались настолько эффективными, что короткий путь выполнения кода с ветвлением превзошел по скорости «магию битовых масок», которая перегружала процессор лишними инструкциями и требовала дополнительных регистров.

Оптимизация 2: Устранение лишней декомпрессии в TurboFan

Оптимизирующий компилятор V8 TurboFan использует граф зависимостей «Sea of Nodes». Разработчики внедрили в компилятор фазу «Decompression Elimination». Если компилятор видит, что сжатое значение загружается из памяти только для того, чтобы быть записанным в другое место без изменений, он исключает фазы декомпрессии и повторного сжатия, позволяя пересылать 32-битные данные напрямую. Это дало 2% прироста производительности.

Оптимизация 3: Искажение Smi (Smi-corrupting)

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

Разработчики изменили все внутренние функции работы с Sми-числами так, чтобы они полностью игнорировали верхние 32 бита 64-битного регистра при выполнении математических операций. Это позволило делать безусловное сложение с базовым адресом для любых значений (и указателей, и чисел Smi): uncompressed_tagged = base + compressed_tagged Устранение проверки типа данных сократило размер ассемблерного кода декомпрессии с 13 до 7 байт и дало 2.5% прироста скорости.

Компромиссы и ограничения технологии

Для достижения экономии памяти разработчикам пришлось пожертвовать двумя важными оптимизациями, которые были доступны в чистом 64-битном режиме:

  1. Отказ от 32-битных Smi: Поскольку 32-битное поле теперь должно вмещать тег указателя, размер полезной нагрузки Smi сократился до 31 бита. Это означает, что целые числа, выходящие за пределы диапазона 31-битного знакового числа, больше не могут храниться в указателе напрямую и требуют выделения объекта в куче (boxing), что снижает производительность таких вычислений на 1%.
  2. Отключение Double Field Unboxing: В стандартном 64-битном режиме числа с плавающей запятой (double, 8 байт) можно хранить непосредственно в полях объектов без выделения отдельной памяти. При сжатии указателей размер полей объектов сокращается до 32 бит, и 64-битное число double физически туда не помещается. Из-за этого вещественные числа приходится оборачивать в отдельные объекты в памяти, что снижает скорость математических расчетов на 3%.

Несмотря на эти компромиссы, технология Pointer Compression доказала свою высочайшую эффективность. Сжатие указателей позволило радикально сократить объем потребления оперативной памяти веб-приложениями, открыв возможность комфортной работы с тяжелыми скриптами даже на мобильных устройствах с небольшим объемом ОЗУ. Сегодня эта технология включена по умолчанию во всех дистрибутивах Chrome, Node.js и Electron.