Погружение в v8. Часть 5. Скрытые оптимизации.

Погружение в v8. Часть 5. Скрытые оптимизации.

Anastasia Kotova

Предыдущие части

Погружение в V8. Часть 1. История.

Погружение в V8. Часть 2. Из чего состоит движок.

Погружение в v8. Часть 3. Парсинг, AST и анализ кода.

Погружение в v8. Часть 4. Управление памятью и сборка мусора.


Введение

После парсинга и генерации байткода V8 начинает исполнять JavaScript и «учиться» на нём. Всё, что мы пишем в коде, проходит через сложный цикл интерпретации, оптимизации и возможной деоптимизации. JavaScript — динамический язык, где объекты могут меняться на лету, типы — подменяться, а функции — вызываться с любыми аргументами. Однако V8 научился превращать это в предсказуемый и быстрый машинный код.

В этой части мы разберём, как движок оптимизирует работу с объектами, свойствами и функциями, как он строит внутренние представления данных и как работает оптимизирующий компилятор TurboFan. А также посмотрим на рекомендации, которые даёт команда V8, чтобы ваш код оптимизировался движком по максимуму.

Числа и указатели

В 2014 году Chrome перешёл с 32-битной на 64-битную архитектуру. Это повысило безопасность, стабильность и производительность Chrome, но привело к увеличению потребления памяти, поскольку каждый указатель теперь занимает восемь байт вместо четырёх. Поэтому в 2020 году команда V8 представила концепцию сжатия указателей (Pointer Compression). Цель была проста — вернуть эффективный размер указателя к 32 битам. Мы уже упоминали эту концепцию в предыдущей части, но сейчас рассмотрим некоторые нюансы.

Куча V8 содержит множество элементов: значения с плавающей запятой, строковые символы, байткод интерпретатора. Но около 70% кучи V8 обычно занимают тэгированные значения (tagged values) — особый способ кодирования, при котором несколько битов слова зарезервированы для хранения информации о типе.

В V8 младший бит определяет, является ли значение указателем на объект в куче или простым целым числом. Благодаря этому целое число можно хранить непосредственно в теге, не выделяя для него дополнительную память и не прибегая к лишним проверкам. Простые целые числа, которые хранятся таким образом, называются Smi (Small Integers). Это позволяет экономить память и ускорять доступ к значениям, которые чаще всего встречаются в JavaScript, — целым числам небольшого диапазона.

В случае указателей доступно 31 бит полезной нагрузки под адрес, что даёт 4 ГБ адресного пространства. При этом значение хранится как смещение относительно базового адреса кучи.

В итоге сжатие указателей уменьшило размер кучи V8 до 43% и объём памяти, потребляемой процессом рендеринга Chrome, до 20% на десктопе.

Но есть нюанс: при использовании сжатия указателей объём кучи автоматически ограничивается. В частности, эта оптимизация в Node.js не будет работать при размере кучи больше 4 ГБ.

Массивы

Отдельного внимания заслуживают массивы. В V8 они представляют собой особый тип объектов, оптимизированный под хранение однотипных данных. Для ускорения работы движок использует систему Elements Kinds — внутренние категории, определяющие, как именно хранятся элементы массива в памяти и как к ним обращаться. Эти категории позволяют V8 избежать лишних проверок и выбрать наиболее эффективный способ доступа к данным.

Когда массив создаётся, движок анализирует его содержимое. Если все элементы — маленькие целые числа, массив получает тип PACKED_SMI_ELEMENTS, где значения хранятся компактно и напрямую.

const array = [1, 2, 3];

Если среди элементов появляются числа с плавающей запятой, V8 переводит массив в PACKED_DOUBLE_ELEMENTS, а если в него добавляются объекты — в PACKED_ELEMENTS.

const array = [1, 2, 3];
// elements kind: PACKED_SMI_ELEMENTS
array.push(4.56);
// elements kind: PACKED_DOUBLE_ELEMENTS
array.push('x');
// elements kind: PACKED_ELEMENTS

Появление хотя бы одной «дыры» (элемента с undefined или пропущенным индексом) заставляет движок перейти на версию с приставкой HOLEY, например HOLEY_SMI_ELEMENTS.

const array = [1, 2, 3, 4.56, 'x'];
// elements kind: PACKED_ELEMENTS
array.length; // 5
array[9] = 1;
// elements kind: HOLEY_ELEMENTS

Эти переходы, называемые elements-kind transitions, происходят автоматически и влияют на производительность. Когда движок вынужден работать с «дырявыми» массивами или с неоднородными типами элементов, он больше не может использовать быстрый доступ по смещению и вынужден выполнять дополнительные проверки при каждом чтении и записи. Кроме того, такие массивы лишаются некоторых оптимизаций JIT-компилятора, и их операции выполняются через более универсальные, но медленные пути.

Чтобы минимизировать количество переходов, V8 старается сохранять исходный тип элементов как можно дольше. Например, если массив изначально был числовым, но позже в него добавили строку, движок будет вынужден изменить способ хранения для всех элементов. Поэтому чем стабильнее структура данных и чем меньше смешанных типов внутри массива, тем быстрее он работает.

Объекты и Hidden Classes

Не менее важная структура в JavaScript — объект. В отличие от статически типизированных языков, где структура объектов фиксирована, JavaScript позволяет в любой момент добавить или удалить свойство. Если бы движок каждый раз создавал уникальную структуру для каждого объекта, это решение не могло бы быть производительным. Поэтому V8 использует Hidden Classes (или Maps) — внутренние шаблоны, описывающие расположение свойств в памяти. Когда создаётся объект, движок формирует для него скрытый класс, где хранится информация о последовательности свойств и их смещениях.

Каждый раз, когда в объект добавляется новое свойство, создаётся новая версия класса, а старая связывается с ней через transition. Например, если у нас есть объект {a: 1}, а затем мы добавляем b, движок создаёт новую карту для состояния {a, b} и хранит переход от старой к новой. Таким образом, объекты с одинаковым порядком свойств делят один и тот же Hidden Class, что позволяет V8 обращаться к свойствам напрямую по смещению — как в структурах на C++.

Если же два объекта создаются с одинаковыми свойствами, но в разном порядке, они получат разные карты, и движок не сможет оптимизировать доступ одинаково. Это одна из причин, почему рекомендуется инициализировать все свойства в конструкторе и избегать добавления новых свойств динамически. Чем больше объектов делят одну карту, тем предсказуемее их поведение для JIT-компилятора и тем быстрее работает код.

Hidden Classes тесно связаны с другим механизмом — Inline Caching, который позволяет V8 запоминать шаблоны доступа к свойствам и повторно использовать их.

Inline Caching (IC)

Inline Caching — способ, с помощью которого движок запоминает результаты предыдущих обращений к свойствам или функциям.

В JavaScript код obj.a не означает, что можно просто взять значение a из объекта. Во-первых, здесь может вызываться геттер. Если же значения или геттера нет в самом объекте, нужно пройтись по цепочке прототипов и т.д.

При первом доступе к свойству движок выполняет полное разрешение — проходит по всем вариантам и цепочкам и находит нужное значение. После этого он сохраняет результат вместе с информацией о типе объекта. При следующем обращении V8 проверяет, совпадает ли Hidden Class объекта с тем, что был закеширован ранее. Если совпадает, движок обращается к свойству напрямую, минуя всю цепочку поиска.

Существует несколько уровней IC. Monomorphic cache означает, что свойство всегда запрашивается у объектов одного типа. Если движок сталкивается с несколькими типами, кеш становится polymorphic. Когда число различных карт превышает определённый порог, кеш объявляется megamorphic, и дальнейшие оптимизации становятся невозможны. Такой сценарий особенно часто встречается при работе с динамическими структурами данных или кодом, где один и тот же метод вызывается на разных типах объектов.

Понимание работы IC помогает избегать ситуаций, где код становится слишком «мегаморфным». Если функции вызываются с разными типами объектов, движок тратит больше времени на проверки и в итоге может деоптимизировать код. Поэтому стоит писать предсказуемый код, где одна и та же функция обрабатывает данные схожей структуры.

TurboFan и промежуточное представление

TurboFan — сердце производительности V8, оптимизирующий компилятор, который долгое время использовал архитектуру Sea of Nodes. В этой модели программа представляется не в виде привычных строк кода или последовательных инструкций, а как граф зависимостей между узлами. Каждый узел — это операция (например, сложение, сравнение, чтение переменной), а рёбра графа отражают зависимости трёх типов: value edges, control edges и effect edges.

Value edges описывают передачу данных: результат одной операции используется в другой. Например, если у нас есть x + y, то узлы x и y соединяются со сложением через value-рёбра. Control edges определяют порядок выполнения — что должно произойти раньше, а что позже (например, тело if зависит от результата проверки). Effect edges фиксируют побочные эффекты: чтение и запись переменных, обращение к памяти, вызовы функций, которые могут что-то изменить. Благодаря разделению этих трёх типов зависимостей компилятор может свободно переставлять чистые (без побочных эффектов) операции и устранять ненужные вычисления, не нарушая корректность программы.

Рассмотрим простой пример:

let x = 1;
let y = 2;
let z = x + y;

В Sea of Nodes это не три строки, а сеть узлов: 1 и 2 — константные узлы, их значения идут по value-рёбрам к узлу Add, результат которого идёт по value-рёбрам к узлу Store[z]. Порядок не задаётся явно — его определяют control- и effect-рёбра. Рёбра control обеспечивают, чтобы Store[z] не выполнялся до вычисления Add, а effect — что запись в память произойдёт после всех вычислений, влияющих на неё. Такая структура даёт гибкость для оптимизаций: TurboFan может, например, удалить неиспользуемые узлы, объединить одинаковые выражения или переставить независимые операции.

Однако у этой модели есть слабые стороны. Когда в коде появляются ветвления, исключения или асинхронные операции, граф обрастает сложными цепочками зависимостей: каждое условие, каждый побочный эффект тянет за собой control- и effect-связи, из-за чего перестановки становятся ограниченными, а сам граф — плохо читаемым и хуже оптимизируемым. Добавить новую ветку выполнения или новое состояние памяти в такую структуру сложно: приходится перестраивать часть графа и пересчитывать зависимости.

Чтобы решить эти проблемы, команда V8 начала переход к архитектуре на основе CFG (Control Flow Graph), реализованной в проекте Turboshaft. CFG — классическое представление программы как набора базовых блоков (basic blocks) — линейных последовательностей инструкций без ветвлений, связанных между собой переходами (edges), описывающими возможный ход выполнения. В отличие от Sea of Nodes порядок выполнения здесь задан явно: каждый блок знает, кто идёт до него и после него. Это делает оптимизации, связанные с управлением потоком, проще и предсказуемее, а работу компилятора — более управляемой.

Если переписать предыдущий пример на уровне CFG, получится линейная структура:

Block0:
  x = 1
  y = 2
  z = x + y
  goto Block1
Block1:
  return

Никаких «плавающих» зависимостей — всё последовательно и прозрачно.

А если добавить условие, например:

if (x > 0) {
  y = y + 1;
} else {
  y = y - 1;
}

в Sea of Nodes это будет один граф, где узлы сравнения, прибавления и вычитания соединены control- и effect-рёбрами, указывающими, какие из них активны в зависимости от результата проверки. В CFG же структура делится на блоки:

Block0:
  t0 = x > 0
  if t0 goto Block1 else goto Block2

Block1:
  y = y + 1
  goto Block3

Block2:
  y = y - 1
  goto Block3

Block3:
  return

Такое представление делает ветвления и переходы очевидными и позволяет компилятору легко анализировать пути выполнения, объединять блоки, устранять мёртвый код и применять классические оптимизации на уровне потока управления.

В итоге команда V8 приняла решение уйти от Sea of Nodes: весь JavaScript-бэкенд уже перешёл на Turboshaft, WebAssembly тоже полностью на Turboshaft, а части Sea of Nodes постепенно вырезаются.

Рекомендации по написанию кода

V8 активно развивается и поддерживает новые стандарты JavaScript, а устройства пользователей вышли на новый уровень по процессорным мощностям и памяти, поэтому нам уже не нужно держать в голове таблицы с «убийцами производительности». Однако, исходя из всего, что мы обсуждали выше, можно собрать некоторые рекомендации, чтобы код работал ещё более оптимально и использовал максимум возможностей движка.

Держите массивы «упакованными» и без дыр. Массивы делятся на packed/holey. Любая «дыра» (пропущенный индекс) переводит массив в holey-вариант, что добавляет проверок при доступе. Старайтесь не создавать разрежённые массивы присваиванием далеко за текущий length, не использовать delete, инициализируйте массив целиком, если знаете размер.

// хуже: создаём дыру, массив станет HOLEY_*
const a = [1, 2, 3];
a[10] = 42;        // много "пустых" слотов между 3 и 10

// лучше: растим без дыр
const b = [];
b.push(1); b.push(2); b.push(3); // остаётся PACKED_*

Сохраняйте тип элементов стабильным. Как только в SMI-массив попадёт число с плавающей точкой, он перейдёт в DOUBLE-вариант; если прилетит объект/null/undefined, станет PACKED_ELEMENTS. Избегайте непреднамеренных апгрейдов типа.

// хуже: деградация SMI -> DOUBLE
const xs = [1, 2, 3];  // PACKED_SMI_ELEMENTS
xs.push(3.14);         // теперь PACKED_DOUBLE_ELEMENTS

// лучше: сразу определись с типом
const ys = [1, 2, 3, 4];        // все целые
const zs = [1.0, 2.0, 3.5];     // всё double, без переключений

Если знаете размер — заполняйте, а не оставляйте «дыры». new Array(n) создаёт массив с «пустыми» слотами (holey) до тех пор, пока их явно не заполнить. Заполните сразу, чтобы остаться в packed-режиме.

// хуже: HOLEY пока элементы не присвоены
const tmp = new Array(3);   // [ <3 empty items> ]

// лучше: сразу PACKED_SMI
const ok = new Array(3).fill(0); // [0, 0, 0]

Старайтесь сохранять мономорфность. Inline Cache запоминает «форму» объектов (их hidden class) в месте доступа (call-site). Если в одном и том же месте кода часто встречаются объекты разных форм, IC становится полиморфным, а при больших различиях — мегаморфным, и оптимизации обесцениваются. Конструируйте объекты в одном порядке и не добавляйте поля «на лету».

// хуже: один и тот же call-site видит разные формы
function getX(o) { return o.x; }    // хотим monomorphic IC

const a1 = {}; a1.x = 1;            // форма: {x}
const b1 = {}; b1.y = 2; b1.x = 3;  // форма: {y,x} (другой порядок)

getX(a1); // IC #1
getX(b1); // IC видит другую карту -> polymorphic

// лучше: фиксируйте форму через конструктор/класс
function A(x) { this.x = x; this.y = 0; } // порядок един
const a2 = new A(1);
const b2 = new A(3);
getX(a2); getX(b2); // monomorphic IC

Нормализуйте входы «горячих» функций. Если в один и тот же горячий участок кода приходят объекты разной формы, вставьте лёгкую нормализацию «до» или разделите путь на несколько функций (по формам), чтобы каждая точка доступа оставалась мономорфной. Это лучше, чем одна «универсальная» точка, скатывающаяся в мегаморфизм.

// разделение путей помогает удержать monomorphic IC в каждом
function readX_A(o /* форма A */) { return o.x; }
function readX_B(o /* форма B */) { return o.x; }

function readX(o) {
  return (o.hasOwnProperty('y') ? readX_A(o) : readX_B(o));
}

Помните, что элементы и свойства — разные хранилища. Числовые ключи идут в «elements store», именованные — в «properties store»; смешивание паттернов доступа может приводить к менее предсказуемым переходам карт. Это ещё один довод не превращать массивы в словари и не использовать «дырявые» индексы.

Эти практики позволяют не мешать оптимизатору. Тогда V8 сможет чаще генерировать прямой доступ к нужным данным в объектах и массивах и реже сваливаться в деоптимизацию.


Следующие части

Погружение в v8. Часть 6. От среды к среде.


Report Page