Skip to main content

Архітектура V8: від коду до машинних інструкцій

Архітектура V8 описує 4-етапний пайплайн перетворення JavaScript-коду у машинні інструкції: Parser (AST), Ignition (байт-код), Sparkplug (базовий машинний код), TurboFan (оптимізований машинний код), з деоптимізацією як запасним виходом при порушенні припущень про типи під час виконання.

Теорія

TL;DR

  • V8 схожий на коробку передач: починає на малій передачі (інтерпретатор Ignition) для миттєвого старту, перемикається вище по мірі "прогріву" коду
  • Головний потік: source -> AST -> байт-код -> базовий машинний код -> оптимізований машинний код, з деоптимізацією при порушенні type assumptions
  • TurboFan вмикається приблизно після 1000+ викликів зі стабільним type feedback
  • Правило: тримай типи аргументів функції стабільними - один тип на call site дає TurboFan змогу повністю оптимізувати
  • Для діагностики деоптимізацій: node --trace-deopt; шукай "type mismatch" і "wrong map"

Короткий приклад

javascript
function add(a, b) { return a + b; } for (let i = 0; i < 10000; i++) { add(1, 2); // виклики 1-100: байт-код Ignition // виклики ~100-1000: базовий Sparkplug // виклики 1000+: оптимізований TurboFan } add("x", "y"); // DEOPT - V8 очікував number+number

Спочатку V8 запускає цикл через інтерпретатор, потім поступово компілює add до швидшого машинного коду. Останній виклик з рядками порушує припущення про типи і запускає деоптимізацію назад до байт-коду.

Пайплайн

Parser читає текст JavaScript і будує AST (Abstract Syntax Tree, абстрактне синтаксичне дерево). Дерево описує структуру програми без жодної інформації про типи або значення:

json
{ "type": "FunctionDeclaration", "id": { "name": "add" }, "params": [{ "name": "a" }, { "name": "b" }], "body": { "type": "ReturnStatement", "argument": { "type": "BinaryExpression", "operator": "+", "left": { "name": "a" }, "right": { "name": "b" } } } }

Ignition компілює AST у компактний байт-код і одразу запускає виконання. Байт-код для add(a, b):

Ldar a0 // завантажити аргумент 0 в акумулятор Add a1, [0] // додати аргумент 1 Return // повернути результат

Під час виконання Ignition записує type feedback на кожній інструкції: "ця операція Add бачила два цілих числа (Smi)." Цей feedback пізніше читає TurboFan.

Sparkplug (з'явився у V8 9.1) компілює гарячий байт-код у машинний код у відношенні 1:1 - без профілювання, без спекуляцій. Приблизно вдвічі швидший за Ignition при мінімальних витратах на компіляцію. Швидкий проміжний шар між інтерпретатором і повним оптимізатором.

TurboFan читає зібраний type feedback, робить ставку на незмінність типів і генерує повністю оптимізований машинний код: спеціалізація типів, інлайнінг функцій, розгортання циклів, видалення мертвого коду. Прискорення відносно чистої інтерпретації - приблизно 10x. Але TurboFan - це ставка. Якщо типи змінились, V8 платить штраф за деоптимізацію.

Hidden Classes та Inline Caching

V8 не зберігає метадані про властивості прямо на об'єктах. Натомість використовуються Hidden Classes (вони ж Maps або Shapes у вихідному коді V8). Два об'єкти з однаковими властивостями в однаковому порядку поділяють один Hidden Class, і V8 може кешувати зсуви (offsets) властивостей для цього класу.

javascript
class Point { constructor(x, y) { this.x = x; // перехід: C0 -> C1 this.y = y; // перехід: C1 -> C2 } } const p1 = new Point(1, 2); // Hidden Class C2 const p2 = new Point(3, 4); // той самий Hidden Class C2

Inline Caching (IC) будується поверх Hidden Classes. Коли getX(point) виконується вперше, V8 записує: "для Hidden Class C2 властивість x знаходиться за зсувом 0." Наступний виклик з тим самим класом пропускає пошук властивості повністю.

javascript
function getX(point) { return point.x; } getX({ x: 1, y: 2 }); // V8: x за зсувом 0 для класу C2 getX({ x: 5, y: 9 }); // кеш-хіт - без пошуку

IC має три стани:

  • Monomorphic: один Hidden Class на call site. TurboFan повністю інлайнить доступ до властивості. Це те, до чого варто прагнути.
  • Polymorphic: 2-4 класи. V8 тримає невелику таблицю диспетчеризації. Повільніше, але ще прийнятно.
  • Megamorphic: 5+ класів. V8 відмовляється від кешування і робить повний пошук властивості на кожному виклику. Найгірший сценарій.

На практиці megamorphic call sites найчастіше з'являються в загальних утиліт-функціях, куди передають об'єкти з будь-якою структурою. Після деградації до megamorphic єдиний реальний фікс - змусити всіх викликачів використовувати однакову форму об'єктів.

Деоптимізація

TurboFan компілює з припущеннями. Коли припущення порушуються під час виконання, V8 деоптимізує: замінює поточний оптимізований фрейм на неоптимізований прямо посеред виконання через OSR (On-Stack Replacement) і повертається до байт-коду.

Вартість деоптимізації реальна: перемотка стеку, відкинутий скомпільований код, і ще один раунд збору feedback перш ніж TurboFan зможе спробувати знову. Додатково: мапи деоптимізацій зберігаються. Якщо call site вже деоптимізувався через невідповідність типів, V8 з обережністю ставиться до повторної оптимізації.

javascript
function counter(x) { return x + 1; } for (let i = 0; i < 1e6; i++) counter(i); // TurboFan: оптимізовано для int counter("a"); // DEOPT for (let i = 0; i < 1e6; i++) counter(i); // повільніше - мапа деопт зберіглась

node --trace-deopt app.js покаже назву функції, причину деоптимізації і позицію в байт-коді.

Генераційний збирач сміття

V8 ділить heap на дві зони: Young Generation (~1-8 МБ) і Old Generation (~100 МБ+). Базова ідея: більшість об'єктів живуть недовго.

Scavenge GC обслуговує Young Generation. Копіює живі об'єкти в To-Space, решту відкидає. Швидко - зазвичай 1-2мс. Об'єкти що пережили два Scavenge-цикли переходять в Old Generation.

Mark-Sweep-Compact обслуговує Old Generation. Позначає живі об'єкти, підбирає мертві, компактує heap. Пауза може досягати 50-100мс - тому важливо тримати короткоживучі об'єкти короткоживучими в latency-sensitive коді.

javascript
function processItem(data) { const temp = { result: transform(data) }; // живе і помирає тут return temp.result; } // temp збирає Scavenge GC const appCache = new Map(); // виживає, переходить в Old Generation

Типові помилки

Поліморфні аргументи в гарячих функціях:

javascript
function serialize(val) { return val + ""; } for (let i = 0; i < 1e6; i++) serialize(i); // TurboFan: числовий шлях serialize(true); // вже поліморфний serialize({ x: 1 }); // наближається до megamorphic

Фікс: окремі функції на кожен тип, або валідація вхідних даних до гарячого шляху.

try/catch навколо гарячого циклу:

javascript
// Погано - вимикає інлайнінг всередині циклу try { for (let i = 0; i < 1e6; i++) compute(i); } catch (e) { handleError(e); } // Добре - звужуй try до того що реально може кинути виняток for (let i = 0; i < 1e6; i++) { compute(i); // без try - залишається інлайнінговим }

delete на гарячих об'єктах:

javascript
const obj = { x: 1, y: 2 }; delete obj.y; // новий Hidden Class - ломає IC для всього коду що читає obj // Замість: obj.y = undefined; // той самий Hidden Class, властивість ще є

Динамічне додавання властивостей після створення:

javascript
// Погано - кожне присвоєння переходить до нового класу const config = {}; config.host = "localhost"; config.port = 3000; // Добре - один клас одразу const config = { host: "localhost", port: 3000 };

Де застосовується

  • React: цикли реконсиляції запускаються гарячими в TurboFan. Поліморфні форми пропсів між компонентами деградують IC. useMemo стабілізує форму об'єктів між рендерами.
  • Node.js / Express: обробники запитів прогріваються швидко - Sparkplug на перших кількох сотнях запитів, TurboFan після. Профілюй з node --trace-opt --trace-deopt на staging.
  • TensorFlow.js: числові ядра використовують Float32Array і Int32Array для типізованих шляхів виконання, обходячи накладні витрати generic-об'єктів.
  • Chrome DevTools: CPU-профайлер розрізняє фрейми TurboFan і Ignition. TurboFan-фрейми позначені як "optimized" у flame chart.

Питання на співбесіді

Q: Опиши шлях функції від JS-коду до виконання.
A: Parser читає текст і будує AST. Ignition компілює AST до байт-коду і одразу виконує, збираючи type feedback. Якщо функція викликається достатньо разів, Sparkplug компілює байт-код до базового машинного коду. З більшою кількістю викликів і стабільним feedback TurboFan генерує повністю оптимізований код. Будь-яка невідповідність типів під час виконання запускає деоптимізацію назад до байт-коду.

Q: Що запускає TurboFan?
A: V8 використовує порогові значення кількості викликів (приблизно 1000-2000) і час виконання байт-коду. TurboFan також потребує достатнього type feedback, щоб спекулятивна оптимізація виправдала вартість компіляції.

Q: Що таке деоптимізація і чому вона відбувається?
A: TurboFan компілює код під припущення про типи. Коли припущення порушується під час виконання (наприклад, аргумент завжди був цілим числом, а тепер прийшов рядок), V8 деоптимізує: замінює оптимізований фрейм на неоптимізований і повертається до байт-коду. Функція може бути повторно оптимізована пізніше, але мапа деоптимізації зберігається.

Q: Monomorphic, polymorphic, megamorphic - яка реальна різниця в продуктивності?
A: Monomorphic call sites дозволяють TurboFan повністю інлайнити і спеціалізувати. Polymorphic (2-4 класи) використовують невелику таблицю диспетчеризації - повільніше, але ще кешується. Megamorphic (5+ класів) дає повний generic-пошук на кожному виклику. У гарячих циклах різниця між monomorphic і megamorphic може бути 10x і більше.

Q (senior): Як OSR працює при деоптимізації і чому це важливо для async-коду?
A: On-Stack Replacement дозволяє V8 замінити фрейм виконання з оптимізованого на неоптимізований прямо поки він ще на стеку - без розмотування всього call stack. Для генераторів і async-функцій можуть існувати частково виконані фрейми у стані призупинення. Без OSR деоптимізація призупиненого генератора означала б повний демонтаж і перезапуск контексту виконання, що надто дорого для довготривалих async-задач.

Приклади

Базовий: Фази оптимізації в циклі

javascript
function square(n) { return n * n; } // Фаза 1: байт-код Ignition (перші ~100 викликів) square(2); square(3); // Фаза 2: базовий Sparkplug (~100-1000 викликів) for (let i = 0; i < 500; i++) square(i); // Фаза 3: TurboFan оптимізований (1000+ викликів, всі числа) for (let i = 0; i < 10000; i++) square(i); console.log(square(9)); // 81 - виконується у нативному машинному коді

Всі виклики повертають однаковий результат. Різниця - у швидкості виконання. На першому виклику функція інтерпретується. На десятитисячному - виконується у нативному коді з цілочисельною арифметикою. Жодних змін у коді не потрібно - V8 робить це автоматично на основі того, як функцію викликають.

Проміжний: Стабільність Hidden Class в обробнику сервера

javascript
// Погано: форма залежить від умови function formatUser(user, isAdmin) { const result = { id: user.id, name: user.name }; if (isAdmin) result.role = "admin"; // іноді додає властивість return result; } // Два Hidden Classes: один з 'role', один без // Код що читає ці об'єкти переходить до polymorphic IC // Добре: завжди однакова форма function formatUser(user, isAdmin) { return { id: user.id, name: user.name, role: isAdmin ? "admin" : null // завжди є }; } // Один Hidden Class завжди // IC залишається monomorphic під навантаженням

На сервері що обробляє 10к запитів на секунду погана версія створює два Hidden Classes залежно від гілки isAdmin. Код що читає ці об'єкти переходить до polymorphic IC. Хороша версія щоразу дає один клас, IC тримається monomorphic, і TurboFan може повністю оптимізувати викликачів.

Продвинутий: Пастка збережених мап деоптимізацій

javascript
function process(val) { return val * 2; } // TurboFan оптимізує для Smi (малих цілих чисел) for (let i = 0; i < 1e6; i++) process(i); // Один виклик з float запускає деопт process(1.5); // 3 - правильний результат, але деоптимізація // Мапа деоптимізації тепер встановлена для цього call site // Навіть при поверненні до цілих, повторна оптимізація повільніша for (let i = 0; i < 1e6; i++) process(i); // правильно, але деопт зберігся // Діагностика: // node --trace-deopt script.js // Вивід: [deoptimize: process, reason: not a Smi] // Фікс: окремі call sites на кожен тип function processInt(val) { return val * 2; } // тримає int-оптимізацію function processFloat(val) { return val * 2; } // окремий feedback

Результати завжди правильні. Змінюється який саме машинний код їх виконує. Цей патерн з'являється в числовому обчислювальному коді де один крайній випадок (float від юзера, null з API) деоптимізує функцію що виконується мільйони разів на секунду. Фікс не про коректність - він про те, щоб тримати call sites з чистим типом.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?