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

Почему обычный [object Object] иногда становится дорогим — и как одна строка делает исполнение кода медленнее.
zero deps · talks @zero_deps
кто здесь?

Василий Каменюк

role staff инженер · ONRTools Инструменты для удалённой работы с CAN-шиной автомобилей. Node.js, Electron, Vue.
prod 6 лет, чистый JS Финтех в проде. Сбор данных, reverse engineering и автоматизация внешних систем.
tg @zero_deps Node.js, V8, highload, архитектура.
Всё, что ставим — потом и поддерживаем.
Фиксим то, что сами и сломали.
план

О чём поговорим

01 V8 как адаптивная система
02 Hidden classes, transition tree, inline cache
03 Где теряется shape
04 Цифры
интерактивчик

Где быстрее чтение?

a — literal
const a = {
  id: 1,
  name: 'v',
  age: 30
}
b — stepwise
const b = {}

b.id = 1
b.name = 'v'
b.age = 30
Вспомните! кто писал delete obj.foo?
дисклеймер

Для 99% систем это не нужно

  • hot path — десятки тысяч объектов в секунду
  • library code — ваш код может крутится в чужих hot path
  • v8 perf — собеседования, диагностика и оптимизации
Drake meme: optimize CRUD vs optimize hot path
PiterJS · Алексей Козятинский
Podlodka React Crew · Анастасия Котова
Node Congress · Мерт Джан Алтын
01
V8 — адаптивная система
пайплайн

V8 не компилирует.

V8 адаптируется.

Ignition bytecode interpreter
Sparkplug baseline jit
Maglev mid-tier jit
TurboFan top-tier · цель
  • код прогревается и поднимается по тирам
  • на каждом тире V8 копит feedback
  • TurboFan спекулирует на типах и shape
  • deopt — откат с TurboFan в Ignition
feedback

Feedback Vector

function getId(obj) {
  return obj.id // ← call site
}
  • call site — место чтения поля или вызова
  • feedback — какие формы реально приходили
  • guard — быстрый путь + проверка гипотезы
  • deopt — guard сорвался, прогрев заново
02
Hidden classes
transition tree
inline cache
hidden class

Объект — это не словарь

Map* → shared
id offset 0
name offset 1
age offset 2
  • фиксированный layout — массив значений
  • форма (Map) шарится между объектами одной формы
  • JIT подставляет offset прямо в машинный код
  • один { id, name, age } = одна Map на всех
transition tree

Один shape, разные пути

  • Maps связаны в дерево переходов
  • один порядок полей — одна Map
  • разный порядок — разные Map
Spider-Men pointing
inline cache

IC: четыре формы — потолок

monomorphic прямой offset JIT подставляет offset поля прямо в машинный код. Никаких проверок, никаких ветвлений.
когда один shape всегда
1 shape · ~1.0 ns
polymorphic таблица из ≤4 Маленькая таблица из Map'ов, IC проверяет каждый — guard. Дешевле runtime, но не бесплатно.
когда 2–4 похожие формы
2–4 shapes · ~1.15 ns
megamorphic global stub cache Глобальная хэш-таблица на весь процесс. Промах — runtime call, и IC уже не оптимизируется.
когда 5+ разных shape'ов
5+ shapes · ~3.6 ns
literal vs stepwise

Same picture? Same picture.

// literal — V8 видит сразу всю форму
const a = {
  id: 1,
  name: 'v',
  age: 30
}
Pam: same picture meme
// stepwise — V8 строит форму по полю
const b = {}

b.id = 1
b.name = 'v'
b.age = 30
%HaveSameMap(a, b)false · v8-флаг --allow-natives-syntax
Поля те же, порядок тот же — но Map'ы разные: literal-фабрика идёт по своему пути в transition tree.
На одном call site IC увидит две формы → polymorphic.
// смешали — IC видит две формы
function getId(obj) {
  return obj.id
}

getId(a) // Map#literal   → mono ✓
getId(b) // Map#stepwise  → poly ⚠
03
Где вы теряете shape
условные поля

Условное поле - уже две формы

function makeUser(id, name, isAdmin) {
  const user = {
    id,
    name
  }

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

  return user
}
поля во всех фабриках — в одном порядке.
не знаете значение — запиши null.
This is fine dog
главный моментик

delete — это катастрофа

const user = {
  id: 1,
  name: 'v',
  tmp: 'love ts'
}

delete user.tmp
DictionaryProperties back pointer: undefined · объект выпал из transition tree каждое чтение поля → hash lookup, IC бесполезен
fix: user.tmp = null · поле остаётся в shape, IC живёт
29× медленнее чтение после одного delete
Stonks meme
deopt

Guard сорвался — deopt

  • триггер — delete, условное поле, разный порядок
  • цена — код в мусорку, прогрев заново
  • видно — пила на графике p99
  • лучше прогрева — не сбивать прогретое
04
Цифры
правила
контракт
методология

Как измерял

framework mitata — p50/p75/p99, защита от dead code elimination
setup 100k объектов предсозданы, в hot loop только obj.id
runs Node.js 20 / 22 / 24 = V8 11.3 / 12.4 / 13.6
смотрим пропорции и тренды, не абсолютные ns/iter
цифры

Сводка · все замеры

case
Node.js 20 · V8 11.3
Node.js 22 · V8 12.4
Node.js 24 · V8 13.6
slowdown
state
literal
1.05 ns
1.06 ns
1.02 ns
×1.0
fast path
stepwise
1.03 ns
1.06 ns
1.07 ns
×1.0
fast path
polymorphic IC · 2 shapes
1.13 ns
1.19 ns
1.12 ns
×1.1
poly
megamorphic IC · 6 shapes
3.14 ns
3.30 ns
3.17 ns
×3.1
mega
delete → dictionary
1.06 ns
30.94 ns
14.08 ns
×30 / ×14
slow
literal / stepwise / poly / mega — стабильны от Node.js 20 до 24. Один путь сломан: delete уронил объект в dict mode в Node.js 22 и не вернулся в 24.
регрессия

V8 11.3 → 12.4: оптимизация ушла

Node.js 20 · V8 11.3
const u = { id: 1, tmp: 9 }
delete u.tmp % HasFastProperties(u)
// → true

fast path сохраняется

Node.js 22 · V8 12.4
const u = { id: 1, tmp: 9 }
delete u.tmp % HasFastProperties(u)
// → false

dictionary mode

Node.js 24 · V8 13.6
const u = { id: 1, tmp: 9 }
delete u.tmp % HasFastProperties(u)
// → false

dictionary mode

%HasFastProperties — внутренний предикат V8 (флаг --allow-natives-syntax).
Возвращает false, когда объект ушёл в dict mode. С Node.js 22+ это происходит после одного delete.
лог · node 22 · v8 12.4

delete уносит в dict mode · ×29

Node.js: v22.18.0 | V8: 12.4.254.21-node.27 | Apple M4

benchmark                     avg (min … max) p75 / p99    (min … top 1%)
--------------------------------------------- -------------------------------
• object shape: field read
--------------------------------------------- -------------------------------
literal (1 shape, fast path)     1.06 ns/iter   1.04 ns   █
                       (986.57 ps … 19.65 ns)   1.34 ns   █

stepwise (same order)            1.06 ns/iter   1.04 ns   █▃
                       (986.57 ps … 13.38 ns)   1.37 ns   ██

polymorphic IC (2 shapes)        1.19 ns/iter   1.16 ns  █
                         (1.11 ns … 18.57 ns)   1.70 ns  █

megamorphic IC (6 shapes)        3.30 ns/iter   3.19 ns █
                        (3.09 ns … 313.52 ns)   6.49 ns █

dictionary mode (after delete)  30.94 ns/iter  31.88 ns   █
                       (20.14 ns … 358.08 ns)  91.30 ns  ██▇

summary
  literal (1 shape, fast path)
   1x faster than stepwise (same order)
   1.13x faster than polymorphic IC (2 shapes)
   3.12x faster than megamorphic IC (6 shapes)
   29.31x faster than dictionary mode (after delete)
лог · node 24 · v8 13.6

fast path быстрее, dict — ×14

Node.js: v24.14.1 | V8: 13.6.233.17-node.44 | Apple M4

benchmark                     avg (min … max) p75 / p99    (min … top 1%)
--------------------------------------------- -------------------------------
• object shape: field read
--------------------------------------------- -------------------------------
literal (1 shape, fast path)     1.02 ns/iter   1.01 ns   █
                       (946.04 ps … 19.96 ns)   1.43 ns  ▄█

stepwise (same order)            1.07 ns/iter   1.03 ns  █
                      (956.05 ps … 901.39 ns)   2.11 ns  █

polymorphic IC (2 shapes)        1.12 ns/iter   1.11 ns   █
                         (1.04 ns … 15.59 ns)   1.56 ns   █

megamorphic IC (6 shapes)        3.17 ns/iter   3.11 ns █▅
                         (3.04 ns … 35.13 ns)   4.58 ns ██

dictionary mode (after delete)  14.08 ns/iter  16.30 ns   █▃
                         (7.46 ns … 92.08 ns)  34.50 ns  ▆██▄

summary
  literal (1 shape, fast path)
   1.05x faster than stepwise (same order)
   1.1x faster than polymorphic IC (2 shapes)
   3.12x faster than megamorphic IC (6 shapes)
   13.82x faster than dictionary mode (after delete)
Что же делать?

Что же делать?

Stonks meme
делать
  • литералы в hot pathвсе поля сразу, в одном порядке
  • один порядок во всех фабрикахсравнить makeUser и parseUser — поля идут в одинаковм порядке
  • дефолт вместо отсутствияне знаешь значение — null / undefined, но поле в shape
  • class или одна фабрика на типV8 любит стабильный конструктор
не делать
  • delete obj.propertyзамени на obj.property = null — shape стабильный
  • swiss-knife хелпер на 15 типоводин call site, 15 форм — мега-таблица, мееееедленно
  • Object.create(null) в hot path≈ 9× оверхед против обычного литерала
  • условные поля без дефолтаif (admin) user.role = ... — две формы из одной фабрики
контракт

Объект — это контракт

your part стабильный shape:
один порядок полей,
без delete
Epic handshake
v8's part быстрый offset:
hidden class,
inline cache
mono
poly
mega
dict
спасибо

Вопросы?

QR — github repo Бенчи obj-cost
QR — zero_deps channel канал zero_deps
Node.js быстрый. Просто не мешайте ему.