Тегирование указателей и сжатие указателей
Младший тег-бит различает Smi и указатель на HeapObject; движок маскирует его для разыменования. Затем сжатие указателей (V8 8.0, 2020) хранит эти указатели как 32-битные смещения внутри 4GB-клетки, сокращая кучу примерно на 40%
Вы замеряете процесс 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). Идея:
- Разместить всю кучу JS внутри единой выровненной на 4GB области — клетки (cage). Её начальный адрес — это base, хранимый в выделенном регистре.
- Хранить каждый указатель на HeapObject не как полный 64-битный адрес, а как 32-битное смещение от base.
- Чтобы разыменовать, реконструировать настоящий адрес как 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 Прочитать 32-битное значение из слота-указателя
- 2 Проверить младший бит — он 1, значит это указатель (не Smi)
- 3 Сбросить тег-бит, чтобы получить сырое 32-битное смещение
- 4 Реконструировать настоящий адрес как base клетки + offset
- 5 Загрузить слово по смещению 0 этого адреса — указатель на Map
▸Почему это работает
Почему единая выровненная на 4GB клетка, а не произвольный base? Выравнивание позволяет V8 вычислить base простым маскированием младших 32 бит любого адреса внутри клетки и делает сжатие/разжатие чисто битовыми операциями, а не общей арифметикой. Это также значит, что регистр base редко меняется, поэтому предсказатель процессора и перемещение кода компилятором могут считать его почти константой. Клетка — на каждый изолят, поэтому порождение нескольких воркер-потоков (у каждого свой изолят, своя 4GB-клетка) — стандартный способ превысить лимит кучи одного изолята.
- 01Сформулируйте точную договорённость тегирования V8 и что движок делает, чтобы разыменовать указатель.
- 02Что такое сжатие указателей и как оно сокращает размер кучи на ~40%?
- 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.