Smi, double и HeapNumber
Smi — это 31-битное знаковое целое, упакованное в 64-битное слово: арифметика за одну инструкцию, без аллокации. Шаг за пределы диапазона Smi или дробь — и число становится HeapNumber в куче, хранящим double. Этот переход — классический триггер деопта V8, и его можно обойти.
Цикл-счётчик работает на полной скорости часами, потом однажды значение перешагивает два миллиарда — и тот же цикл вдруг становится на 30% медленнее, навсегда, до перезапуска процесса. В вашем коде ничего не менялось. Изменилось то, что ваше целое перестало быть Smi и стало упакованным HeapNumber, а скомпилированный код, предполагавший «это всегда малое целое», вынужден был откатиться. Этот урок — про линию, которую пересекло целое.
Smi: целое, которое ничего не стоит
Вспомним из прошлого урока: слово-значение — 64 бита, а младший бит — тег. У Smi тег-бит равен 0, и целое живёт в старших 32 битах слова. Это оставляет место для 31-битного знакового целого на 64-битном V8 — диапазон примерно ±2³⁰, то есть около ±1,07 миллиарда. (Нижние 32 бита — нули; иногда это описывают как «значение сдвинуто влево на 32».)
Почему это так важно? Потому что Smi не нужны ни аллокация в куче, ни разыменование. Число — в слове. Сложить два Smi — по сути одна инструкция процессора: движок использует нативную целочисленную арифметику, лишь сохраняя тег удобным для неё. Объект не создаётся, память не трогается, мусор не порождается. Это самое быстрое представление числа в JavaScript, и именно поэтому циклы, насыщенные целыми, могут соперничать с C.
let sum = 0;
for (let i = 0; i < 1000; i++) sum += i;
// i и sum остаются глубоко внутри диапазона Smi:
// каждое += — целочисленная математика над тегированным словом, ноль аллокаций.HeapNumber: коробка в куче
Как только число не укладывается в форму Smi, V8 обязан представить его как полный IEEE-754 double (64 бита). Но double не оставляет места под тег-бит в том же трюке. Поэтому V8 его упаковывает: он аллоцирует HeapNumber — крошечный HeapObject, чья полезная нагрузка — 64-битный double, — а слово-значение становится указателем на эту коробку.
Число становится HeapNumber, когда оно одно из:
- За пределами диапазона Smi — по модулю больше примерно ±2³⁰ (например,
2 ** 31, большой timestamp в миллисекундах, крупный счётчик). - Не целое — что-либо с дробной частью, как
0.5илиMath.PI. - Особый double —
NaN,Infinity,-0.
Вместе эти три случая означают: любое число с реальной прикладной «идентичностью» — UTC-временная метка, показание датчика, специальный сентинел — всегда будет HeapNumber. Если вы видите такое в горячем цикле, это ваш аллокационный бюджет тает на глазах.
Теперь чтение числа стоит того же, что и любой HeapObject: разыменование указателя и загрузка из памяти, плюс изначальная стоимость аллокации и будущая стоимость сборки этой коробки GC. HeapNumber не катастрофа для разового значения, но в горячем цикле аллокация одного на итерацию — это реальный налог.
Unboxed double: запасной выход V8
Если бы каждое дробное число означало аллокацию HeapNumber, числовые массивы были бы непригодны. V8 избегает этого через unboxed double (распакованные, или «вынутые из коробки», double): значение хранится как сырые 64 бита IEEE-754 прямо на месте, без HeapObject-обёртки и без указателя на неё. Когда V8 знает, что место хранения будет содержать только double, он хранит сырой 64-битный double напрямую, без коробки и без указателя:
- Массив
PACKED_DOUBLE_ELEMENTS(создаётся, когда каждый элемент — double) хранит сырые double подряд, как C-шныйdouble[]. - double-поле объекта: когда свойство держало только double, V8 хранит сырой double прямо в объекте (представление MutableHeapNumber / double-field), обновляя его на месте вместо перепаковки.
Float64Array(типизированный массив) хранит сырые double по определению — HeapNumber там нет никогда.
Подвох с double-полями объекта: поскольку хранится сырой double, а не тегированное слово, hidden class объекта записывает «это поле — double». Если потом сохранить туда не-число, V8 придётся мигрировать форму объекта — ещё одна причина держать типы полей стабильными.
Классический деопт: переполнение Smi в горячем цикле
Вот военная история. TurboFan наблюдает за горячим циклом, видит, что переменная всегда была Smi, и компилирует специализированный целочисленный машинный код со стражем: «считаем, что это остаётся Smi». Этот код быстр именно потому, что полностью пропускает путь с плавающей точкой.
Затем одно значение переполняет ±2³⁰. Страж падает. V8 деоптимизирует: выбрасывает специализированный код, откатывается в интерпретатор (Ignition), и значение становится HeapNumber. Если переполнение продолжается, V8 может переоптимизировать на пути double — но вы уже заплатили за деопт и теперь аллоцируете HeapNumber.
// --trace-deopt показал бы откат при первом переполнении `acc` за диапазон Smi.
function hash(arr) {
let acc = 0;
for (let i = 0; i < arr.length; i++) {
acc = (acc * 31 + arr[i]); // acc быстро растёт за 2**30 -> переполнение Smi -> деопт
}
return acc;
}Фиксы в порядке предпочтения:
- Ограничьте диапазон целого. Держите аккумуляторы в диапазоне Smi — например, маскируйте через
| 0/>>> 0или берите остаток на каждом шаге, чтобы значение никогда не переполнялось (acc = (acc * 31 + arr[i]) | 0оставляет его 32-битным целым; результаты| 0V8 обрабатывает эффективно). - Специализируйтесь на double с самого начала. Если данные действительно с плавающей точкой, храните их в
Float64Arrayили засейте массив значением double, чтобы V8 сразу выбралPACKED_DOUBLE_ELEMENTSи никогда не предполагал Smi. - Используйте
Math.fround, когда нужна семантика 32-битного float; это также сигнализирует V8 специализироваться на float, а не колебаться между Smi и double.
Подтвердить всё это можно через node --trace-deopt --trace-opt your.js или в d8 через %OptimizeFunctionOnNextCall(fn) + %GetOptimizationStatus(fn) под --allow-natives-syntax.
- Диапазон Smi (64-бит V8)
- 31-бит знаковое, около ±2³⁰
- Стоимость арифметики Smi
- ~1 инструкция процессора
- Аллокация Smi
- 0 (живёт в слове)
- Стоимость чтения HeapNumber
- alloc + разыменование
- Нагрузка HeapNumber
- 64-бит IEEE-754 double
- Элемент Float64Array
- сырой double, без коробки
Какое из этих значений хранится как Smi (внутри слова, без аллокации) на 64-битном V8?
Скомпилированный TurboFan цикл работает быстро миллионы итераций, потом деоптится, когда аккумулятор переходит ~2³⁰. Что произошло?
Расставьте события, когда аккумулятор горячего целочисленного цикла наконец переполняет диапазон Smi.
- 1 TurboFan компилирует цикл, специализируясь на «аккумулятор — Smi»
- 2 Итерации идут как целочисленная математика в одну инструкцию, без аллокаций
- 3 Значение аккумулятора превышает примерно ±2³⁰
- 4 Страж Smi в скомпилированном коде падает -> деопт обратно в Ignition
- 5 Значение перепаковывается в HeapNumber, хранящий double
▸Граничные случаи
-0 и NaN — это double, не Smi, поэтому Object.is(-0, 0) равно false, а [-0].includes(-0) работает, тогда как рассуждать о них как о целых нельзя. Виды элементов массива тоже это учитывают: массив целых — это PACKED_SMI_ELEMENTS; добавьте один 0.5, и он расширяется — в одну сторону — до PACKED_DOUBLE_ELEMENTS, после чего даже целые в нём хранятся как сырые double. Переход расширения необратим для этого массива.
- 01Что именно такое Smi на 64-битном V8 и почему арифметика Smi так дёшева?
- 02Когда число становится HeapNumber и чего это стоит?
- 03Почему целое, переполняющее диапазон Smi, вызывает деопт TurboFan и как этого избежать?
На 64-битном V8 число представляется одним из двух способов. Smi — это 31-битное знаковое целое (диапазон около ±2³⁰), упакованное в старшие биты слова-значения с младшим тег-битом 0; ему не нужны ни аллокация, ни разыменование, поэтому арифметика Smi — примерно одна инструкция процессора и не порождает мусора. Любое число, что не укладывается — за пределами диапазона Smi, дробное или особое вроде NaN, Infinity, -0, — обязано быть 64-битным IEEE-754 double, который V8 упаковывает в HeapNumber в куче, достигаемый через указатель, ценой аллокации, разыменования на каждое чтение и последующей сборки GC. Чтобы держать double дёшево, V8 хранит их распакованными там, где знает тип: элементы Float64Array, массивы PACKED_DOUBLE_ELEMENTS и double-поля объектов содержат сырые double без коробки. Классический боевой деопт — горячий цикл, где TurboFan специализировался на предположении Smi, а значение переполняет ±2³⁰: страж падает, V8 откатывается в интерпретатор, и значение становится HeapNumber. Фиксы — ограничить диапазоны целых (маскировать через | 0, брать остаток), чтобы значения оставались Smi, либо специализироваться на double заранее через Float64Array или Math.fround — это проверяемо через —trace-deopt и —trace-opt. Теперь, когда встретишь цикл, который замедляется после часов работы, или флеймграф с неожиданными аллокациями HeapNumber — ты знаешь, какая строка данных пересекла границу и какой однострочный фикс возвращает скорость.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.