On-Stack Replacement: подмена фрейма посреди цикла
Функция, вызванная однажды, но содержащая длинный горячий цикл, не возвращается, чтобы войти заново, так что обычное повышение яруса не может её ускорить. OSR подменяет исполняющийся фрейм на лету
Вы пишете скрипт с одним гигантским циклом for, перемалывающим десять миллионов строк. Функция вызывается ровно один раз — main() — и не возвращается, пока цикл не закончен. По правилам ярусов, которые вы изучили, эта функция никогда не сможет ускориться: повышение яруса подменяет оптимизированный код при следующем вызове, а следующего вызова нет. И всё же вы её замеряете, и цикл действительно ускоряется на полпути, пока ещё исполняется. Единственный способ, как это может случиться, — если V8 подменил код прямо под ногами работающего цикла. Этот трюк — On-Stack Replacement (замена на стеке).
Проблема, которую обычное повышение яруса не решает
Вспомните, как повышение яруса устанавливает оптимизированный код (урок 01): когда бюджет функции срабатывает, V8 компилирует более быструю версию и подменяет её для следующего входа в функцию. У этой модели есть слепое пятно. Рассмотрим функцию, которая вызывается однажды и проводит всё своё время в одном длинном цикле:
function crunch(rows) {
let acc = 0;
for (let i = 0; i < rows.length; i++) { // 10 000 000 итераций
acc += score(rows[i]);
}
return acc;
}
crunch(tenMillionRows); // вызвана ровно один разЦикл ослепительно горячий — миллионы обратных дуг — так что его счётчик обратных дуг срабатывает по бюджету почти немедленно (урок 01 ввёл этот счётчик ровно для этого случая). V8 хочет оптимизировать. Но обычный механизм здесь бесполезен: он установил бы оптимизированный код для следующего вызова crunch, а crunch на лету посреди единственного вызова, который она когда-либо получит. К моменту возврата crunch работа уже сделана. Ждать повторного входа значит никогда не оптимизировать тот единственный цикл, что важен.
OSR: замена фрейма на лету
On-Stack Replacement — это механизм, разрывающий этот тупик. Вместо ожидания, пока функция вернётся и будет вызвана снова, V8 заменяет исполняющийся фрейм на месте, посреди цикла. Последовательность:
- Счётчик обратных дуг цикла срабатывает по бюджету повышения яруса, пока цикл работает в интерпретаторе (или базовом ярусе).
- V8 компилирует специальную OSR-entry-версию функции — оптимизированный машинный код, чья точка входа не вершина функции, а заголовок цикла. Она специализирована так, чтобы начать исполнение, как будто управление только что пришло к началу итерации цикла, со всеми живыми переменными цикла уже на месте.
- V8 выполняет перенос живого состояния (live-state transfer): он читает текущие значения каждой живой переменной из интерпретаторного фрейма (индекс цикла
i, аккумуляторacc, ссылкаrows) и пишет их в регистры и слоты стека оптимизированного фрейма в тех представлениях, которые ожидает оптимизированный код. Это инверсия трансляции фрейма при деопте из урока 05 — там мы перестраивали интерпретаторный фрейм из оптимизированного; здесь мы строим оптимизированный фрейм из интерпретаторного. - Управление переходит в OSR-entry-оптимизированный код на заголовке цикла, и тот же цикл продолжается — но теперь в оптимизированном машинном коде. Оставшиеся девять с половиной миллионов итераций работают быстро.
- После того как цикл закончен и
crunchв итоге возвращается, обычный оптимизированный код (обычная компиляция со входом сверху) берёт на себя любые последующие вызовы.
Почему микробенчмарки живут и умирают по OSR
Это механизм за печально известной ловушкой бенчмаркинга. Если ваш бенчмарк запускается один раз и крутит один гигантский цикл, спросите себя: была ли ваша функция когда-либо вызвана достаточно раз, чтобы получить обычное повышение яруса? Ответ почти наверняка нет — вы целиком зависите от OSR. Микробенчмарк, оборачивающий свою работу в один огромный цикл — for (let i = 0; i < 1e9; i++) work(), — не разогревает work через повышение яруса по счётчику вызовов обычным образом; он полагается на то, что OSR включится на полпути, чтобы оптимизировать тело цикла на лету. Из этого следуют два последствия для всякого, кто замеряет производительность:
- Первый кусок итераций работает неоптимизированным (интерпретатор/базовый ярус), затем есть разрыв, где срабатывает OSR, и остаток работает быстро. Если вы замеряете весь цикл одним числом, вы смешиваете холодное и горячее исполнение и получаете бессмысленное среднее.
- OSR-entry-код может быть немного хуже обычной оптимизации со входом сверху, ведь он вынужден принять живое состояние цикла таким, каким нашёл, а не от чистого входа в функцию, что ограничивает некоторые оптимизации. Так что замер, разогретый через OSR, может занижать установившуюся скорость, которую вы увидели бы, если бы функцию вызывали много раз обычным образом.
Починка стандартная: разогрейте функцию многими отдельными вызовами до замера (чтобы она получила обычную, не-OSR оптимизацию), и замеряйте установившиеся итерации, а не разогревочный переходный процесс. Вы можете увидеть OSR через --trace-osr; вместе с --trace-opt он показывает OSR-entry-компиляцию отдельно от обычной.
- Запускается
- счётчиком обратных дуг цикла
- Точка входа
- заголовок цикла (не вершина)
- Переносится состояние
- интерпр. фрейм -> оптим.
- Связь с деоптом
- инверсия трансляции фрейма
- OSR-код против обычной опт.
- иногда немного хуже
- После цикла
- обычная опт. для след. вызовов
- Флаг трассировки
- --trace-osr
- Релевантность бенчмаркам
- бенчи с одним гигант. циклом
Почему обычное повышение яруса не может оптимизировать функцию, вызванную однажды и проводящую всё время в одном длинном цикле?
Что должен сделать OSR в момент подмены фрейма и как это связано с деоптом?
Расставьте, как OSR оптимизирует долго работающий цикл в однажды вызванной функции.
- 1 Счётчик обратных дуг цикла срабатывает по бюджету повышения яруса посреди исполнения
- 2 V8 компилирует OSR-entry-версию, чья точка входа — заголовок цикла
- 3 V8 переносит живое состояние цикла из интерпретаторного фрейма в оптимизированный
- 4 Управление возобновляется в оптимизированном коде, и тот же цикл продолжается быстро
- 5 После возврата цикла обычный оптимизированный код обслуживает любые поздние вызовы
▸Почему это работает
Почему OSR-entry-версия иногда оптимизируется немного хуже обычной компиляции? Потому что обычная оптимизация со входом сверху может предполагать чистый вход в функцию — свежие аргументы, никакого живого состояния цикла посреди вычисления — и может разложить всю функцию оптимально. OSR-entry-версия вместо этого вынуждена принять живые значения цикла ровно такими, какими они существуют в точке срабатывания, и начать с заголовка цикла, что закрепляет некоторые представления и ограничивает пару перестановок, которые компилятор иначе бы сделал. Она всё равно гораздо быстрее интерпретатора; просто изредка на волосок позади установившегося кода, который вы получаете от многих обычных вызовов, — ровно поэтому разогрев отдельными вызовами даёт более чистые числа бенчмарка.
- 01Какую проблему OSR решает, а обычное повышение яруса — нет?
- 02Пройдитесь по механизму OSR шаг за шагом.
- 03Почему микробенчмарки с одним гигантским циклом надо разогревать отдельными вызовами и при чём тут OSR?
On-Stack Replacement (замена на стеке — механизм оптимизации уже исполняющегося цикла) позволяет V8 оптимизировать уже работающий цикл, закрывая единственный пробел в обычном повышении яруса. Обычное повышение яруса устанавливает оптимизированный код для следующего входа в функцию, что бесполезно для функции, вызванной однажды и проводящей всё время в одном длинном цикле: цикл интенсивно горяч через свой счётчик обратных дуг, но нет следующего вызова, для которого установить код. OSR чинит это, заменяя исполняющийся фрейм на лету. Когда бюджет обратных дуг срабатывает, V8 компилирует OSR-entry-версию функции, чья точка входа — заголовок цикла, затем выполняет перенос живого состояния — читая каждую живую переменную цикла из интерпретаторного фрейма и записывая её в оптимизированный фрейм в представлениях, которые ожидает оптимизированный код, точная инверсия трансляции фрейма при деопте. Управление прыгает в оптимизированный код на заголовке цикла, и тот же цикл на лету продолжается быстро, а обычный оптимизированный код со входом сверху берёт на себя любые поздние вызовы, как только цикл вернётся. Поскольку микробенчмарки с одним гигантским циклом полагаются на OSR, а не на обычное повышение яруса, и поскольку OSR-entry-код может быть незначительно хуже чистой оптимизации, такие бенчмарки надо разогревать многими отдельными вызовами и замерять в установившемся режиме. Наблюдайте за всем этим через —trace-osr вместе с —trace-opt. Теперь, когда вы пишете бенчмарк и удивляетесь, почему первый миллион итераций медленный, а остальные быстрые, — вы знаете: это граница OSR, и починка — вызвать функцию в цикле разогрева до того, как вы включаете секундомер.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
встречается в184
- Почему GraphQL получает N+1junior
- Механика DataLoader: батчинг на границе тикаmiddle
- Контракты batch-функции: порядок, формы, ошибкиmiddle
- Federation и lookahead: батчинг за пределами DataLoadermiddle
- Защита сложности запросов: depth, cost, persisted queriesmiddle
- Senior GraphQL API: scheduling-контракт, изоляция арендаторов, наблюдаемостьsenior
- Зачем идемпотентность: безопасные retryjunior
- Серверный state machine: четыре состояния idempotency keymiddle
- Outbox и inbox: effectively-once через dual-write границуmiddle
- Конкурентность и архитектура кеша для идемпотентности на масштабеsenior
- Наблюдаемость, production-инциденты и дизайн для глобального масштабаsenior
- Event loop: один поток, три очередиjunior
- Задачи, микрозадачи и scheduler.yield()middle
- Голодание микрозадач, длинные задачи и LoAFsenior
- Event loop Node.js: фазы, nextTick и задержка циклаsenior
- React, Vue и наблюдаемость INP в продакшенеsenior
- Render pipeline: шесть стадий от байтов до пикселейjunior
- Цена стадий и модель процесса рендерераmiddle
- Инвалидация, dirty-биты и containmiddle
- Слои композитора: продвижение, перекрытие и память GPUmiddle
- Флейм-стрип DevTools и жизненный цикл кадраmiddle
- Layout thrash: форсированная синхронная компоновкаsenior
- BeginMainFrame, анимации на потоке compositor и память GPUsenior
- Observability в проде: LoAF, INP и полная поверхность атакиsenior
- Что такое V8 и почему производительность различается в 100 разjunior
- Четырёхуровневый JIT-конвейер V8 и профилированная тиеризацияmiddle
- Hidden classes, деревья переходов и расположение в памятиmiddle
- Inline caches, состояния IC и деоптимизацияmiddle
- Orinoco GC: параллельный scavenger, конкурентная разметка и барьеры записиmiddle
- Спекулятивный движок TurboFan и ловушка deopt-loopsenior
- V8 в production: Isolates, сжатие указателей и реальные аварииsenior
- Жизненный цикл service worker и стратегии кешированияmiddle
- Граничные случаи service worker: version skew, долговременность и ловушка навигацииsenior
- Что делает реконсилер: render vs commitjunior
- Объект fiber и дерево с двойной буферизациейmiddle
- Чистота фазы render и подшаги фазы commitmiddle
- Реконсиляция: эвристики диффа и ловушка ключейmiddle
- Приоритетные lanes, time-slicing и useTransitionmiddle
- Bailout, мемоизация и tearingsenior
- React Profiler, компилятор и продакшн-наблюдаемостьsenior
- Стратегии рендеринга: SSG, SSR, ISR, streaming и гидратацияjunior
- SSG, SSR, ISR, streaming и RSC — как работает каждая стратегияmiddle
- Цена гидратации: selective, progressive, острова, resumabilitymiddle
- Hydration mismatch: причины, обнаружение и правило детерминизмаsenior
- RSC, стратегия на маршрут и production-наблюдаемостьsenior
- Core Web Vitals: что измеряют LCP, INP и CLSjunior
- CLS: почему происходят сдвиги лейаута и как их остановитьmiddle
- Трейдоффы метрик, RUM-атрибуция и цикл CI+полеsenior
- Общая картина: от URL до LCP до INP как эстафетаjunior
- Восемь слоёв трассировки: от service worker до второй навигацииmiddle
- Пять канонических поломок: где производство стабильно ломаетсяsenior
- Метод трёх треков: чтение трасс и построение системы мониторингаsenior
- Что такое cache stampede и почему он делает всё хужеjunior
- Лок и single-flight: ограничение параллельных rebuildmiddle
- XFetch: вероятностное раннее истечение без координацииmiddle
- Stale-while-revalidate и CDN request coalescingmiddle
- Детектирование stampede и дизайн TTL для продакшенаmiddle
- Метастабильный сбой, fencing-токены и production-постмортемыsenior
- Что такое отношение: таблицы, строки, ключи и ограниченияjunior
- Ограничения, ключи и типы данных Postgresmiddle
- Нормальные формы, денормализация и почему схемы «прилипают»middle
- JSONB, массивы и когда side table побеждаетmiddle
- Heap-хранилище, TOAST и выравнивание колонокsenior
- Целостность схемы: deferral, версионирование и сбои в продакшнеsenior
- Реляционная модель vs документные, wide-column, граф и key-valuesenior
- Index-only scan, Visibility Map и INCLUDEsenior
- Типичные сбои в продакшне и аудит индексовsenior
- pg_statistic, ANALYZE и производственная наблюдаемостьmiddle
- Производственные режимы отказа и стабильность плановsenior
- MVCC: как Postgres раздаёт согласованные снимкиjunior
- Заголовок tuple и механика снимковmiddle
- HOT-обновления и уровни изоляцииmiddle
- VACUUM, bloat и autovacuummiddle
- CLOG, XID wraparound и MultiXactsenior
- SSI и production-тюнинг autovacuumsenior
- Реальные провалы MVCC, deployment-паттерны и распределённые снимкиsenior
- Connection pool: зачем амортизировать стоимость backend Postgresjunior
- Режимы PgBouncer: session, transaction и statementmiddle
- Размер пула: формула (ядра × 2) + шпинделей и двухуровневый стекmiddle
- Исчерпание пула и idle-in-transaction: сценарий отказа в 3 ночиmiddle
- Миграция на transaction mode: план развёртывания и prepared statements в PgBouncer 1.21middle
- Процессная модель Postgres и почему увеличение max_connections снижает производительностьsenior
- Ландшафт пулеров 2026, serverless connection storms и полная таксономия отказовsenior
- Что такое миграция схемы и почему она заменяет ad-hoc DDLjunior
- ADD COLUMN: мгновенно в PG 11+ против перезаписи в старом Postgresjunior
- Режим отказа очереди блокировок: почему мгновенный DDL может заморозить базуmiddle
- Безопасные DDL-паттерны: NOT VALID, CONCURRENTLY и исправления небезопасных операцийmiddle
- Expand-contract: нулевой простой для ломающих изменений схемыmiddle
- Advisory-блокировки, инструменты миграций и координация деплояsenior
- Таксономия сбоев миграций и дисциплина продакшнаsenior
- Зачем нужно шардирование: потолок одного Postgresjunior
- Выбор ключа шарда: стратегии hash, range, list и directorymiddle
- Партиционирование против шардирования: одно слово, два разных понятияmiddle
- Ко-локация и Citus: инвариант, делающий шардирование пригодным к использованиюmiddle
- Режим отказа hot shard: обнаружение, изоляция и долгосрочная политикаmiddle
- Schema-based шардирование и альтернативы мультиарендностиsenior
- Онлайн-решардинг, 2PC и операционная стоимость шардированияsenior
- Семь актов: от CREATE TABLE до Citusjunior
- Акты 1–3 в глубину: схема, индексы и статистика планировщикаmiddle
- Акты 4–6 в глубину: MVCC bloat, connection pooling и безопасные миграцииmiddle
- Акт 7 в глубину: шардинг, co-location и семиуровневый каскад трейдоффовmiddle
- Наблюдаемость, антипаттерны и производственный триажsenior
- Роли Raft, term и почему majority-кворум предотвращает split brainjunior
- Как Raft реплицирует log entry и решает, что его безопасно коммититьmiddle
- Выборы лидера в Raft: таймауты, правила голосования и четыре свойства безопасностиmiddle
- Raft в реальном мире: partition, медленный диск и клиентская маршрутизацияmiddle
- Расширения Raft: pre-vote, learner, snapshot и линеаризуемые чтенияsenior
- Raft в production: membership change, Multi-Raft и observabilitysenior
- Где происходит data fetching — и почему это решает LCPjunior
- Fetch waterfall''''ы — диагностика и лечение через Promise.allmiddle
- React Server Components и Suspense streamingmiddle
- Клиентский кэш: TanStack Query, SWR и stale-while-revalidatemiddle
- LCP, prefetch и race conditions в интерактивном fetchingmiddle
- Senior internals: RSC payload, слои кэша и production паденияsenior
- Трёхстороннее рукопожатие TCPjunior
- Номера последовательности и состояние соединенияmiddle
- DNS: что делает и зачем существуетjunior
- Обход резолвера: перенаправления, типы записей и gluemiddle
- TTL, кеширование и распространение DNSmiddle
- Рукопожатие за 1 RTT: key share и ECDHEmiddle
- Возобновление сессии и 0-RTTmiddle
- WebSocket: HTTP-апгрейд до постоянного соединенияjunior
- Формат WebSocket-фрейма: opcodes, маскирование, фрагментацияmiddle
- Backpressure в WebSocket: когда клиенты не успеваютmiddle
- Реконнект: jittered backoff, thundering herd, восстановление сообщенийsenior
- WebSocket в масштабе: HTTP/2 мультиплексирование, permessage-deflate, C10Msenior
- WebSocket в production: прокси, безопасность и распределённая архитектураsenior
- Что делают обратные проксиjunior
- Health checks, connection draining и slow startmiddle
- Session affinity, consistent hashing и правильное решениеmiddle
- Retry-бури, circuit breakers и load sheddingsenior
- Устойчивая архитектура LB: anycast, zone-aware маршрутизация и observabilitysenior
- Почему QUIC, а не TCP+TLSjunior
- Connection ID и миграция сетиmiddle
- Возобновление 0-RTT и шифрование пакетовsenior
- DDoS: что это и почему работаетjunior
- Атаки усиления и истощение состоянияmiddle
- Ограничение скорости: алгоритмы и архитектураmiddle
- WAF, межсетевые экраны, mTLS и HSTSmiddle
- Отравление DNS-кэша и BGP-перехватsenior
- Эшелонированная защита и экономика атакsenior
- DNS, TCP, TLS по очереди: куда уходят миллисекундыmiddle
- Перехват прокси и шлюзы безопасности: rate limiter, WAF, mTLSmiddle
- Альтернативные пути: QUIC 0-RTT, WebSocket upgrade, миграция соединенияmiddle
- Наблюдаемость: распределённые трейсы, USE/RED и семплированиеsenior
- Устойчивость: каскадные повторы, circuit breakers и error budgetsenior
- Что такое три сигнала: метрики, логи, трейсыjunior
- Зачем нужны структурные логи: дневник против таблицыjunior
- Схема продакшн-лога: поля, которые несёт каждая строкаmiddle
- PII-редакция и log injectionsenior
- OTel Logs Data Model и audit-логи как подсистемаsenior
- SLI, SLO и error budget: надёжность в числахjunior
- Error budget policy, latency SLO и составные journeysmiddle
- Продакшн-отказы SLO, самонаблюдаемость, безопасность и общая картинаsenior
- Петля инцидента: от пейджера до постмортема до предотвращенияmiddle
- Cache lines и false sharing: когда параллелизм замедляет кодmiddle
- SIMD и data layout: AoS vs SoA и разница в 4–8xmiddle
- Cache-oblivious алгоритмы, PGO и production failuressenior
- GC в production: наблюдаемость, безопасность, edge cases и управление флотомsenior
- Batching: амортизируй фиксированную цену каждой операцииjunior
- Окно батчинга: размер и время ожиданияmiddle
- Batching в Kafka и Postgresmiddle
- io_uring и наблюдаемость пакетированияmiddle
- От Nagle до io_uring: эволюция пакетированияmiddle
- Backpressure, изоляция сбоев и безопасность батчей в продакшенеsenior
- CI enforcement и RUM: делаем бюджеты рабочимиmiddle
- V8 JIT-пайплайн, HTTP-приоритеты и безопасность bundlesenior
- Цикл performance: дисциплина, а не проектjunior
- Классификация и исправление: сопоставление family bottleneck с методамиmiddle
- Observability-стек и CI gates: ловить регрессии до выпускаmiddle
- От инцидента к enforcement: SLO burn до верифицированного исправления за 35 минутmiddle
- Культура, экономика и масштаб performancesenior
- At-most-once, at-least-once, exactly-once: три контракта доставкиjunior
- Три ножки сбоя — где реально происходят дубликаты и потериmiddle
- Consumer-side dedup: самый дешёвый путь к exactly-once processingmiddle
- Kafka exactly-once semantics: idempotent producer и транзакцииmiddle
- SQS visibility timeout, DLQ и outbox patternmiddle
- Exactly-once в production: impossibility-доказательство, гибридные паттерны и реальные инцидентыsenior
- Что такое OAuth и почему пароли — не ответjunior
- Authorization code flow с PKCEmiddle
- Валидация ID-токена и управление JWKS-кешемmiddle
- Ротация refresh-токенов и scope-based least privilegemiddle
- Sender-constrained токены: DPoP и mTLSsenior
- OAuth в production: audience атаки, observability и реальные провалыsenior
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.