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

On-Stack Replacement: подмена фрейма посреди цикла

Функция, вызванная однажды, но содержащая длинный горячий цикл, не возвращается, чтобы войти заново, так что обычное повышение яруса не может её ускорить. OSR подменяет исполняющийся фрейм на лету

JSE Senior ◷ 14 min
Уровень
ОсновыJuniorMiddleSenior

Вы пишете скрипт с одним гигантским циклом 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 заменяет исполняющийся фрейм на месте, посреди цикла. Последовательность:

  1. Счётчик обратных дуг цикла срабатывает по бюджету повышения яруса, пока цикл работает в интерпретаторе (или базовом ярусе).
  2. V8 компилирует специальную OSR-entry-версию функции — оптимизированный машинный код, чья точка входа не вершина функции, а заголовок цикла. Она специализирована так, чтобы начать исполнение, как будто управление только что пришло к началу итерации цикла, со всеми живыми переменными цикла уже на месте.
  3. V8 выполняет перенос живого состояния (live-state transfer): он читает текущие значения каждой живой переменной из интерпретаторного фрейма (индекс цикла i, аккумулятор acc, ссылка rows) и пишет их в регистры и слоты стека оптимизированного фрейма в тех представлениях, которые ожидает оптимизированный код. Это инверсия трансляции фрейма при деопте из урока 05 — там мы перестраивали интерпретаторный фрейм из оптимизированного; здесь мы строим оптимизированный фрейм из интерпретаторного.
  4. Управление переходит в OSR-entry-оптимизированный код на заголовке цикла, и тот же цикл продолжается — но теперь в оптимизированном машинном коде. Оставшиеся девять с половиной миллионов итераций работают быстро.
  5. После того как цикл закончен и 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 с одного взгляда
Запускается
счётчиком обратных дуг цикла
Точка входа
заголовок цикла (не вершина)
Переносится состояние
интерпр. фрейм -> оптим.
Связь с деоптом
инверсия трансляции фрейма
OSR-код против обычной опт.
иногда немного хуже
После цикла
обычная опт. для след. вызовов
Флаг трассировки
--trace-osr
Релевантность бенчмаркам
бенчи с одним гигант. циклом
Викторина

Почему обычное повышение яруса не может оптимизировать функцию, вызванную однажды и проводящую всё время в одном длинном цикле?

Викторина

Что должен сделать OSR в момент подмены фрейма и как это связано с деоптом?

Расставь шаги по порядку

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

  1. 1 Счётчик обратных дуг цикла срабатывает по бюджету повышения яруса посреди исполнения
  2. 2 V8 компилирует OSR-entry-версию, чья точка входа — заголовок цикла
  3. 3 V8 переносит живое состояние цикла из интерпретаторного фрейма в оптимизированный
  4. 4 Управление возобновляется в оптимизированном коде, и тот же цикл продолжается быстро
  5. 5 После возврата цикла обычный оптимизированный код обслуживает любые поздние вызовы
Почему это работает

Почему OSR-entry-версия иногда оптимизируется немного хуже обычной компиляции? Потому что обычная оптимизация со входом сверху может предполагать чистый вход в функцию — свежие аргументы, никакого живого состояния цикла посреди вычисления — и может разложить всю функцию оптимально. OSR-entry-версия вместо этого вынуждена принять живые значения цикла ровно такими, какими они существуют в точке срабатывания, и начать с заголовка цикла, что закрепляет некоторые представления и ограничивает пару перестановок, которые компилятор иначе бы сделал. Она всё равно гораздо быстрее интерпретатора; просто изредка на волосок позади установившегося кода, который вы получаете от многих обычных вызовов, — ровно поэтому разогрев отдельными вызовами даёт более чистые числа бенчмарка.

Вспомните перед уходом
  1. 01
    Какую проблему OSR решает, а обычное повышение яруса — нет?
  2. 02
    Пройдитесь по механизму OSR шаг за шагом.
  3. 03
    Почему микробенчмарки с одним гигантским циклом надо разогревать отдельными вызовами и при чём тут OSR?
Итог

On-Stack Replacement (замена на стеке — механизм оптимизации уже исполняющегося цикла) позволяет V8 оптимизировать уже работающий цикл, закрывая единственный пробел в обычном повышении яруса. Обычное повышение яруса устанавливает оптимизированный код для следующего входа в функцию, что бесполезно для функции, вызванной однажды и проводящей всё время в одном длинном цикле: цикл интенсивно горяч через свой счётчик обратных дуг, но нет следующего вызова, для которого установить код. OSR чинит это, заменяя исполняющийся фрейм на лету. Когда бюджет обратных дуг срабатывает, V8 компилирует OSR-entry-версию функции, чья точка входа — заголовок цикла, затем выполняет перенос живого состояния — читая каждую живую переменную цикла из интерпретаторного фрейма и записывая её в оптимизированный фрейм в представлениях, которые ожидает оптимизированный код, точная инверсия трансляции фрейма при деопте. Управление прыгает в оптимизированный код на заголовке цикла, и тот же цикл на лету продолжается быстро, а обычный оптимизированный код со входом сверху берёт на себя любые поздние вызовы, как только цикл вернётся. Поскольку микробенчмарки с одним гигантским циклом полагаются на OSR, а не на обычное повышение яруса, и поскольку OSR-entry-код может быть незначительно хуже чистой оптимизации, такие бенчмарки надо разогревать многими отдельными вызовами и замерять в установившемся режиме. Наблюдайте за всем этим через —trace-osr вместе с —trace-opt. Теперь, когда вы пишете бенчмарк и удивляетесь, почему первый миллион итераций медленный, а остальные быстрые, — вы знаете: это граница OSR, и починка — вызвать функцию в цикле разогрева до того, как вы включаете секундомер.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.