Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Архітектура V8: від коду до машинних інструкцій». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Архітектура V8** компілює JavaScript через 4 рівні: Parser будує AST з початкового коду, Ignition перетворює його у байт-код і збирає type feedback, Sparkplug компілює гарячий байт-код у машинний, TurboFan генерує оптимізований машинний код на основі зібраного feedback. При порушенні припущень про типи V8 деоптимізує до байт-коду. **Ключове:** стабільні типи аргументів на call site тримають оптимізації TurboFan активними.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Архітектура 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 з чистим типом.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.