open atlas
↑ К треку
Внутренности движка JavaScript JSE · 02 · 03

Тегирование указателей и сжатие указателей

Младший тег-бит различает Smi и указатель на HeapObject; движок маскирует его для разыменования. Затем сжатие указателей (V8 8.0, 2020) хранит эти указатели как 32-битные смещения внутри 4GB-клетки, сокращая кучу примерно на 40%

JSE Middle ◷ 13 min
Уровень
ОсновыJuniorMiddleSenior

Вы замеряете процесс Node 12 и процесс Node 14, исполняющие идентичную нагрузку, насыщенную объектами. Node 14 использует примерно на 40% меньше кучи на те же данные — те же объекты, тот же код. Никто не менял программу. V8 изменил то, как хранит указатель: с полных 8 байт до 4-байтного смещения в фиксированную область. Это единственное изменение представления — один из крупнейших выигрышей по памяти в истории V8, и у него есть острый край.

Схема тегирования, точно

Мы видели, что младший бит слова-значения — это тег. Теперь точная договорённость, обратная тому, что предполагают новички:

  • Smi: младший бит 0. Целое занимает старшие биты. Маскирование не нужно, чтобы использовать целое в тегированной арифметике.
  • Указатель на HeapObject: младший бит 1. Настоящий адрес — это слово с этим сброшенным битом.

Это работает, потому что аллокации в куче выровнены (минимум на границу слова), так что настоящий адрес всегда оканчивается на …000. V8 прибавляет 1, помечая его как указатель. Чтобы разыменовать, движок сбрасывает младший бит — один AND / вычитание:

// Концептуальное снятие тега (настоящее разыменование в V8 — одна инструкция):
const TAG = 1;
function isPointer(word) { return (word & TAG) === 1; }
function untag(word)     { return word & ~TAG; }   // сбросить тег-бит -> настоящий адрес
// затем загрузить по полученному адресу

Итак, цена динамической типизации: один битовый тест для классификации и одна маска перед любым обращением к объекту. Всё в V8 устроено так, чтобы эти две операции были по сути бесплатными на горячем пути.

Почему указатели были крупнейшей статьёй расхода кучи

Объектно-ориентированный JavaScript насыщен указателями. Подумайте, что держит типичный граф объектов: первое слово каждого HeapObject — это указатель (его Map). Каждое свойство, которое само является объектом, — указатель. Массивы объектов — это массивы указателей. Связанный список, дерево, граф fibre в React — подавляюще указатели.

На 64-битной машине каждый из этих указателей — 8 байт. Но почти никакой реальной программе не нужно 64-битное адресное пространство под её кучу JS — нескольких гигабайт вполне достаточно. Поэтому 4 из этих 8 байт на практике всегда нули. На графе с миллионами указателей это много потраченной памяти и потраченного кэша: каждая кэш-линия держит вдвое меньше полезных указателей, чем могла бы.

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

V8 8.0 (вышел в 2020, по умолчанию в Chrome и Node с тех пор) исправил это сжатием указателей (pointer compression). Идея:

  1. Разместить всю кучу JS внутри единой выровненной на 4GB областиклетки (cage). Её начальный адрес — это base, хранимый в выделенном регистре.
  2. Хранить каждый указатель на HeapObject не как полный 64-битный адрес, а как 32-битное смещение от base.
  3. Чтобы разыменовать, реконструировать настоящий адрес как base + offset — один add относительно регистра base.

Вместе эти три шага означают, что через память путешествует только 32-битное смещение — общий base никогда не покидает свой регистр. Без шага 1 (выровненной клетки) не было бы общего base; без шага 3 нельзя было бы восстановить полный адрес без его хранения. Уберите любой из шагов — и вы теряете либо экономию в 4 байта, либо возможность разыменовывать вообще.

Сжатый указатель — 4 байта вместо 8. Тег Smi/указателя по-прежнему живёт в младшем бите 32-битного значения, поэтому схема чисто композируется с тегированием.

Выигрыш велик и был измерен на реальных нагрузках:

  • Размер кучи упал ~40% на типичных веб- и Node-нагрузках — потому что куча так сильно состоит из указателей.
  • Локальность кэша улучшилась, поскольку вдвое больше указателей умещается в кэш-линию, что часто ускоряет код, гоняющийся за указателями, несмотря на лишний add.

Цена: лимит 4GB и один add

Бесплатного нет. Цена сжатия указателей:

  • Жёсткий лимит кучи 4GB на изолят. 32-битное смещение может адресовать только 4GB. Если вам действительно нужна большая куча JS в одном изоляте, придётся собрать V8 со сжатием, выключенным (или использовать несколько изолятов / воркер-потоков, у каждого своя клетка). Почти для всех приложений 4GB — гораздо больше, чем нужно; для немногих гигантов обработки данных — это реальная стена.
  • Шаг разжатия. Каждая загрузка указателя теперь делает base + offset вместо использования хранимого значения напрямую. Это один дешёвый add, и выигрыш в локальности обычно более чем окупает его — но он не нулевой, и поэтому сжатие — это компромисс, а не чистый выигрыш.

Сравните со старой полной 64-битной схемой (до 8.0): указатели хранились как полные адреса, поэтому не было ни add при разжатии, ни лимита 4GB — но каждый указатель стоил 8 байт, а кэш-линии были использованы наполовину. V8 рассудил, что выигрыш по памяти и локальности стоит лимита для подавляющего большинства пользователей.

Сжатие указателей в цифрах
Размер хранимого указателя, до 8.0
8 байт (полный 64-бит)
Размер хранимого указателя, со сжатием
4 байта (32-бит смещение)
Типичное сокращение кучи
около 40%
Размер клетки
4 GB, выровнена
Лимит кучи на изолят (сжатие)
4 GB
Стоимость разжатия на загрузку
один add (base + offset)
Викторина

В схеме тегирования V8 что означает младший бит слова-значения, равный 1, и что движок должен сделать перед разыменованием?

Викторина

Какую главную цену V8 принимает в обмен на ~40% сокращение кучи от сжатия указателей?

Расставь шаги по порядку

Расставьте шаги разыменования сжатого тегированного указателя на HeapObject и чтения его Map.

  1. 1 Прочитать 32-битное значение из слота-указателя
  2. 2 Проверить младший бит — он 1, значит это указатель (не Smi)
  3. 3 Сбросить тег-бит, чтобы получить сырое 32-битное смещение
  4. 4 Реконструировать настоящий адрес как base клетки + offset
  5. 5 Загрузить слово по смещению 0 этого адреса — указатель на Map
Почему это работает

Почему единая выровненная на 4GB клетка, а не произвольный base? Выравнивание позволяет V8 вычислить base простым маскированием младших 32 бит любого адреса внутри клетки и делает сжатие/разжатие чисто битовыми операциями, а не общей арифметикой. Это также значит, что регистр base редко меняется, поэтому предсказатель процессора и перемещение кода компилятором могут считать его почти константой. Клетка — на каждый изолят, поэтому порождение нескольких воркер-потоков (у каждого свой изолят, своя 4GB-клетка) — стандартный способ превысить лимит кучи одного изолята.

Вспомните перед уходом
  1. 01
    Сформулируйте точную договорённость тегирования V8 и что движок делает, чтобы разыменовать указатель.
  2. 02
    Что такое сжатие указателей и как оно сокращает размер кучи на ~40%?
  3. 03
    Чего стоит сжатие указателей и как обойти лимит?
Итог

Договорённость тегирования V8 точна: младший бит слова-значения равен 0 для Smi (целое в старших битах) и 1 для указателя на HeapObject, что работает потому, что выровненные аллокации делают настоящий адрес оканчивающимся нулевыми битами, так что V8 прибавляет 1, помечая указатель, и сбрасывает его одной маской для разыменования. Объектно-ориентированные кучи доминируются указателями — каждый указатель на Map, каждое свойство-объект, каждый массив объектов — и на 64-битной машине каждый указатель был полными 8 байтами, чьи старшие 4 байта почти всегда нули. Сжатие указателей, вышедшее в V8 8.0 в 2020, размещает всю кучу JS в одной выровненной на 4GB клетке с base в регистре и хранит каждый указатель как 32-битное смещение, реконструируя настоящий адрес как base + offset на каждой загрузке. Это вдвое сокращает ширину указателя, урезая типичные насыщенные объектами кучи примерно на 40% и улучшая локальность кэша настолько, что код, гоняющийся за указателями, часто становится быстрее, несмотря на лишний add. Цены — жёсткий лимит кучи 4GB на изолят (обходится отключением сжатия или несколькими изолятами/воркерами) и один add на разыменование — что делает сжатие осознанным компромиссом «память против лимита», который V8 счёл оправданным почти для каждого пользователя. Теперь, когда встретишь Node-процесс, упирающийся в 4GB без видимой причины в коде, или удивишься, почему переход с Node 12 на Node 14 снизил потребление памяти без единой правки — ты знаешь, какой коммит V8 за этим стоит и почему это был правильный выбор.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки
опирается на

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.