Объекты в JS не бесплатные

_Hidden classes, inline cache и почему shape объекта важнее, чем кажется._

Продолжу про микро-оптимизаций. После Date.now() и parseInt/parseFloat — про, казалось бы, самую безобидную вещь: обычный {}.

Сразу оговорка: для 99% систем это не имеет значения. А вот если у тебя hot path, через который проходят десятки тысяч объектов в секунду, или ты пишешь библиотечный код, стоит знать, что V8 на самом деле делает с твоими объектами.

И чего он там с ними делает?

Когда ты создаёшь объект, V8 не выделяет «мешок ключей». Он строит для него hidden class (_map_ внутри V8) структуру, которая описывает layout: какие у объекта поля, в каком порядке они лежат, какого типа, по каким офсетам читать. Сам объект это компактный массив значений, как struct в C. Hidden class хранится отдельно, и каждый объект держит на него ссылку.

Hidden classes связаны между собой через transitions и вместе образуют transition tree. Когда ты добавляешь поле к объекту, V8 идёт по исходящему переходу из текущего hidden class: если переход с таким полем уже есть переключается на существующий дочерний, если нет создаёт новый hidden class и новую ветку дерева.

Если несколько объектов идут по одним и тем же переходам в одинаковом порядке они разделяют один hidden class. Это даёт V8 базу для inline cache: «по этому офсету всегда лежит string, читаем напрямую, без проверок».

Когда hidden classes начинают расходиться (по порядку, по типам, по добавлению/удалению), inline cache переходит: – monomorphic (один shape — fast path), – polymorphic (до 4 shapes — V8 проверяет каждый, всё ещё быстро), – megamorphic (4+ — V8 сдаётся, идёт через generic lookup).

Стоимость растёт на каждом шаге.

Где ты теряешь?

Классический пример постепенная сборка vs литерал:

const a = {}
a.id = 1
a.name = 'jopa'

const b = { id: 1, name: 'jopa' }

Если порядок добавления полей совпадает, оба варианта в итоге попадают на один hidden class. У литерала есть бонус V8 запоминает финальный shape как boilerplate и при повторных вызовах аллоцирует объект сразу с готовым layout, без прохода по transition tree. Но в hot loop разница в установившемся режиме копеечная.

Хуже, если порядок полей в разных фабриках не совпадает:

function makeA(id, name) { return { id, name } }
function makeB(name, id) { return { name, id } }

Это уже два разных hidden class. Любой код, который читает obj.id через эти фабрики, видит два shape на одном call site и IC становится polymorphic. Само по себе ещё не больно, но если таких разных объектов не два, а шесть, то добро пожаловать в megamorphic.

Ещё кейс условное добавление полей:

const user = { id, name }

if (isAdmin) {
  user.role = 'admin'
}

Часть объектов имеет shape {id, name}, часть — {id, name, role}. Уже polymorphic. Лучше класть role: null сразу и потом присвоить значение при необходимости.

delete — отдельная история

delete user.name

Вот тут V8 уже не прощает. delete переводит объект в dictionary mode (он же slow mode, normalized properties). Это hashmap вместо фиксированного layout. Ни inline cache, ни офсетов, каждое чтение поля идёт через hash lookup в C++ runtime. И обратно V8 объект уже не вернёт, даже если форма стабилизировалась.

Вроде как память освободил. А по факту превратил объект в Map без типизации.

Если поле больше не нужно лучше присвой null или undefined. Shape сохранится, движок не потеряет в оптимизации.

А что по цифрам?

Стенд: Node 24, миллион объектов с тремя полями, в hot loop читаем obj.id, меряем ns/op. По три прогона, чтобы JIT успел устаканиться.

— literal (same shape): 3.99 нс — stepwise (same order): 4.08 нс — poly IC (2 shape): 4.65 нс — megamorphic (6 shapes): 8.51 нс — dict mode (after delete): 56.12 нс

Что видно: – literal vs stepwise — разницы практически нет. V8 одинаково хорошо справляется с обоими. – polymorphic IC (2 shape) — ~15% оверхед. V8 спокойно тянет до 4 shape на одном call site. – megamorphic (6 shape)x2+ к чтению. Уже серьёзно. – delete (dictionary mode)x14 медленнее. Больно :)

На 100k RPS × 50 чтений полей на запрос с dict-mode объектами вместо нормальных — потеря ~260 мс CPU/с.

А что было на Node 22?

Для наглядности — те же сценарии на Node 22:

— literal: 11.0 → 4.0 нс (x2.8) — stepwise: 11.1 → 4.1 нс (x2.7) — poly (2 shape): 10.3 → 4.7 нс (x2.2) — megamorphic: 14.1 → 8.5 нс (x1.7) — dict mode: 56.8 → 56.1 нс (=)

Что интересно:

Fast path между релизами стал ~x2.5 быстрее. TurboFan и Maglev продолжают тюниться, монохромное чтение поля на Node 24 — 4 нс против 11 на Node 22. – Dict mode — константа. 56 нс на обеих версиях. Это C++ hashmap lookup, JIT там не играет, оптимизировать нечего. – Относительная разница fast/slow растёт. На Node 22 delete был x5 медленнее literal'а, на Node 24 — уже x14. Fast path уходит вперёд, slow path стоит.

Вывод: с каждым новым V8 сидеть в dictionary mode или megamorphic IC становится всё дороже _относительно_ правильного кода.

А что делать то?

– В hot path — литералы, со всеми полями сразу. Не знаешь значение полож null. – Поля во всех фабриках — в одном порядке. Делаешь { id, name }, делай везде { id, name }. – delete — не нужон. Только obj.field = null (или undefined). – Если объект сложный и часто создаётся — класс или фабрика. Один shape гарантированно. – Не стоит превращать один call site в универсальную точку на все случаи жизни: четыре shape потолок V8, дальше generic.

Что в итоге?

Объект в JS не «просто словарь». Это контракт с V8: ты обещаешь стабильный shape, V8 обещает быструю работу через hidden classes и inline cache. Нарушаешь — съезжаешь в polymorphic, потом megamorphic, потом dictionary mode. С каждым шагом дороже.

В обычном CRUD это не заметно. На hot path — это десятки процентов CPU и заметные хвосты по p99.

Date.now() тратил CPU на syscall, parseFloat — на парсинг, плохой shape — на cache miss. А потом говорят: "ой да ну какой Node, он же медленный, давайте напишем на Go и пойдем за ванильным лате на растительном"

Node быстрый. Просто не мешай ему.

Что еще почитать?V8 internals: hidden classes & inline caches — Mathias Bynens, must-read – Fast properties in V8 — официальный блог V8 – Elements kinds in V8 — про массивы и их shapes