Короткий ответ
Одинаковый hidden class не гарантирует стабильность оптимизированного кода: V8 специализируется ещё и на representation поля. Когда числовое поле впервые получает строку, representation расширяется, зависимый машинный код может деоптимизироваться, а дальнейшая цена зависит от функции, версии V8 и нагрузки.Смена типа тоже не бесплатна
Привет, читатель. Давненько из меня не выходило контента. Пора исправляться. Принёс для тебя дополнение к прошлой статье про объекты.
Я разбирал hidden classes, inline cache и форму объектов. Главный практический совет был простой: если поле опциональное, создай его сразу и положи null, чтобы все объекты сохранили одинаковую форму.
Совет рабочий, но неполный. Форма объекта у V8 может остаться прежней, а контракт поля всё равно изменится. И тогда горячая функция тоже поедет на деоптимизацию.
Если через один путь проходят десятки тысяч однотипных объектов, и ты уже полез смотреть за их формой, стоит понимать и вторую половину механики.
Map знает не только форму
У каждого объекта в V8 есть Map, он же hidden class. Map описывает набор полей, их порядок и смещения. Но в дескрипторе поля хранится ещё и его внутренняя representation, то есть способ, которым V8 ожидает хранить значение.
Упрощённо интересующая нас цепочка выглядит так:
Smi маленькое целое, закодированное прямо в tagged-слоте
Double числовое значение с плавающей точкой
HeapObject ссылка на объект в куче: строку, объект, null, undefined и так далее
Tagged широкое представление, которое принимает и Smi, и ссылки
Это внутренний контракт хранения. В самом V8 отдельно существует ещё и field type, но для перехода из number в string нам достаточно representation.
Когда V8 много раз видит { x: 42 }, поле x может получить representation Smi.
Оптимизатор по известному Map читает поле как маленькое целое и строит код под этот контракт.
Потом кто-то делает так:
obj.x = 'boom'
Строка уже не помещается в Smi. V8 расширяет representation до Tagged, потому что теперь поле должно принимать и целые числа, и ссылки на объекты в куче.
Ключевой момент: это не обязательно создаёт новый Map. Для некоторых расширений V8 обновляет дескриптор на месте, и адрес Map остаётся тем же. Но оптимизированный код зависел не только от адреса Map, а ещё и от representation. Контракт изменился, зависимый код больше нельзя считать безопасным для использования.
Вот сам деопт
Минимальный пример на Node 24.17.0 с V8 13.6:
function readX(object) {
return object.x
}
const a = { x: 1 }
const b = { x: 2 }
// Убираем отдельную зависимость от constness поля.
a.x = 3
b.x = 4
for (let i = 0; i < 100_000; i++) {
readX(i & 1 ? a : b)
}
// --allow-natives-syntax
%OptimizeFunctionOnNextCall(readX)
readX(a)
a.x = 'boom'
Запускаем:
node --allow-natives-syntax --trace-deopt demo.js
И получаем:
[marking dependent code ... (readX) for deoptimization,
reason: dependent field representation changed]
То есть одна запись строки действительно инвалидировала уже оптимизированную readX.
В этой версии V8 %HaveSameMap(a, b) остаётся истинным даже после присваивания строки. Форма та же, Map тот же, а деоптимизация всё равно случилась.
%OptimizeFunctionOnNextCall и %HaveSameMap являются внутренними средствами диагностики V8. В прод их, разумеется, тащить не стоит.
Где здесь подвох с null
Вернёмся к совету «создай поле заранее и положи null».
Для формы объекта он по-прежнему полезен:
const user = { id, name, role: null }
if (isAdmin) {
user.role = 'admin'
}
Поле role есть у всех объектов с самого начала. Кроме того, и null, и строка относятся к HeapObject, поэтому именно representation при такой записи расширять не требуется.
С числом история другая:
const stats = { score: null } // HeapObject
stats.score = 42 // нужно принять ещё и Smi, получаем Tagged
Если код успел оптимизироваться между этими двумя состояниями, расширение поля может инвалидировать зависимую оптимизацию.
Для целочисленного поля стоит сразу дать целочисленное значение:
const stats = { score: 0 }
stats.score = 42
С undefined та же проблема, что с null: для V8 это объект в куче, а не числовая заглушка.
Но не стоит превращать это в очередной карго-культ. Если null является честным состоянием бизнес-модели, оставь null. Семантика программы важнее одной потенциальной деоптимизации на прогреве.
А если поле хранит дробные числа, одного 0 тоже недостаточно для идеальной стабильности: переход от Smi к Double способен потребовать ещё одно расширение.
Идеальный вариант для горячей структуры: присвоить реальное значение до того, как код станет горячим.
Расширение происходит не на каждом присваивании
Здесь легко сделать неправильный вывод: будто каждое переключение 42 → 'boom' → 42 снова деоптимизирует функцию из-за representation.
Нет. Representation расширяется до Tagged один раз и самостоятельно обратно до Smi уже не сужается. После этого и число, и строка помещаются в установленный контракт. Повторной генерализации поля на каждом переключении нет.
Деопты всё ещё возможны, если горячий код специализировался на фактических значениях. Например, выражение object.x + 1 сначала выполняет числовое сложение, а со строкой начинает конкатенацию.
Это уже другая причина: обратная связь по типам самой операции плюс возможные аллокации строк. Сваливать всё это в одну корзину с field representation удобно только до первого человека, который полезет в --trace-deopt.
Поэтому я не буду продавать здесь красивое «переключение типов замедляет цикл ровно в два раза». Такой микробенч легко измеряет одновременно чтение поля, смену representation, арифметическую специализацию, конкатенацию и повторную оптимизацию. Цифра получится эффектной, но инженерного смысла в ней будет примерно нисколько.
Защищаемый вывод уже есть в трейсе: первое расширение representation способно инвалидировать весь оптимизированный код, который от неё зависит. Цена конкретного деопта зависит от функции, нагрузки, версии V8 и того, сколько зависимого кода придётся пересобрать.
Что делать-то?
Да, в целом ничего :)
Это просто интересные факты про Node.js, так же, как и про Map в прошлой статье.
Но если ты оптимизациофил, то:
- Создавай горячие объекты с полным и стабильным набором полей.
- Для целочисленного поля используй числовое начальное значение, а не
nullилиundefined. - Присваивай реальное значение до прогрева, особенно если поле может хранить дробные числа.
- Не смешивай число и строку в одном поле без причины.
- Проверяй подозрения через
--trace-deopt.
Что в итоге
Одинаковая форма объекта ещё не гарантирует, что оптимизированный код останется валидным. V8 учитывает representation полей и может встроить это знание в машинный код.
Переход number → string дорог не потому, что строка сама по себе медленная. Он опасен в момент, когда ломается контракт уже скомпилированной функции. V8 расширяет поле, помечает зависимый код на деоптимизацию и затем работает с новым, более широким представлением.
А null ни хороший, ни плохой. Для ссылочного поля это нормальная заглушка. Для числового поля это уже другой внутренний контракт.
JS разрешает нам менять типы как угодно. V8 тоже не против. Но лучше не менять.
Кушайте кашу, читайте книги и не меняйте типы без необходимости.
Что почитать
• Fast properties in V8, официальный разбор Maps, дескрипторов и быстрых свойств • Representation в исходниках V8, актуальная внутренняя модель Smi, Double, HeapObject и Tagged • MapUpdater в исходниках V8, генерализация полей и обновление зависимого кода • V8 internals: hidden classes & inline caches, подробный разбор Mathias Bynens