open atlas
↑ К треку
Внутренности движка JavaScript JSE · 02 · 02

Smi, double и HeapNumber

Smi — это 31-битное знаковое целое, упакованное в 64-битное слово: арифметика за одну инструкцию, без аллокации. Шаг за пределы диапазона Smi или дробь — и число становится HeapNumber в куче, хранящим double. Этот переход — классический триггер деопта V8, и его можно обойти.

JSE Middle ◷ 13 min
Уровень
ОсновыJuniorMiddleSenior

Цикл-счётчик работает на полной скорости часами, потом однажды значение перешагивает два миллиарда — и тот же цикл вдруг становится на 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.
  • Особый doubleNaN, 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-битным целым; результаты | 0 V8 обрабатывает эффективно).
  • Специализируйтесь на 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 против HeapNumber в цифрах
Диапазон 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. 1 TurboFan компилирует цикл, специализируясь на «аккумулятор — Smi»
  2. 2 Итерации идут как целочисленная математика в одну инструкцию, без аллокаций
  3. 3 Значение аккумулятора превышает примерно ±2³⁰
  4. 4 Страж Smi в скомпилированном коде падает -> деопт обратно в Ignition
  5. 5 Значение перепаковывается в HeapNumber, хранящий double
Граничные случаи

-0 и NaN — это double, не Smi, поэтому Object.is(-0, 0) равно false, а [-0].includes(-0) работает, тогда как рассуждать о них как о целых нельзя. Виды элементов массива тоже это учитывают: массив целых — это PACKED_SMI_ELEMENTS; добавьте один 0.5, и он расширяется — в одну сторону — до PACKED_DOUBLE_ELEMENTS, после чего даже целые в нём хранятся как сырые double. Переход расширения необратим для этого массива.

Вспомните перед уходом
  1. 01
    Что именно такое Smi на 64-битном V8 и почему арифметика Smi так дёшева?
  2. 02
    Когда число становится HeapNumber и чего это стоит?
  3. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.