В этом посте будут объяснены две стратегии оптимизации WebAssembly, которые не так давно были реализованы в движке V8 и вошли в версию браузера Google Chrome M137. Речь пойдёт о спекулятивном встраивании call_indirect inlining и о поддержке деоптимизации в WebAssembly. В сочетании два этих приёма помогают генерировать более качественный машинный код, так как основаны на допущениях, которые строятся, исходя из обратной связи, поступающей от среды исполнения. Благодаря этому WebAssembly выполняется быстрее, особенно это касается программ WasmGC. Например, проверив эти приёмы на наборе микробенчмарков, написанных на Dart, видим, что комб��нация двух оптимизаций даёт ускорение в среднем более чем на 50%. В более крупных и реалистичных приложениях и на тех бенчмарках, которые рассмотрены ниже, достигается ускорение между 1% и 8%. Деоптимизации — также очень важная составляющая для дальнейшей оптимизации в будущем.
Контекст
Чтобы код на JavaScript выполнялся быстро, приходится сильно полагаться на спекулятивные оптимизации. То есть в процессе генерации машинного кода JIT-компиляторы заранее предполагают, каким он может быть, основываясь при этом на обратной связи, собранной в ходе предыдущих прогонов такого кода. Например, есть выражение a + b. Встретив его, компилятор может сгенерировать машинный код для сложения целых чисел, если по имеющейся обратной связи можно заключить, что a и b — это целые числа (а не строки, числа с плавающей точкой или другие объекты). Если бы такие допущения не делались, то компилятору пришлось бы выдать универсальный код, который обрабатывал бы все варианты поведения оператора + в языке JavaScript — такой код будет более сложным и, следовательно, гораздо более медленным. Если впоследствии программа поведёт себя не так, как ожидалось, и поэтому не оправдает допущений, сделанных на этапе генерации оптимизированного кода, то V8 выполнит деоптимизацию. Это означает, что оптимизированный код будет просто отброшен, и далее программа перейдёт к выполнению неоптимизированного кода (попутно собирая дополнительную обратную связь, чтобы, если получится, применить оптимизации «уровнем выше»).
WebAssembly, в отличие от JavaScript, выполняется быстро, не требуя при этом спекулятивной оптимизации и деоптимизации. В частности, потому, что программы на WebAssembly могут поступать на выполнение уже в хорошо оптимизированном виде, поскольку статически доступно гораздо больше информации — ведь и функции, и инструкции, и переменные типизируются статически. Также дело в том, что двоичные файлы WebAssembly часто компилируются из C, C++ или Rust. Исходный код на этих языках также лучше поддаётся статическому анализу, чем JavaScript. Следовательно, такие инструментарии как Emscripten (на основе LLVM) или Binaryen уже умеют оптимизировать программу в опережающем режиме. В результате получаются качественно оптимизированные двоичные файлы, рассчитанные на работу с, как минимум, WebAssembly 1.0 — эта версия используется с 2017 года.
Обоснование
Почему же в V8 теперь активнее применяются спекулятивные оптимизации для WebAssembly? Отчасти это связано с развитием WebAssembly, например с появлением WasmGC, это решение для сборки мусора в WebAssembly. Теперь лучше поддерживается компиляция в WebAssembly таких управляемых языков как Java, Kotlin или Dart. В результат�� получается байт-код WasmGC, более высокоуровневый, чем Wasm 1.0. Он поддерживает, например, насыщенные типы, такие как структуры и массивы, создание подтипов и операции над такими типами. Следовательно, сгенерированный машинный код для WasmGC сильнее выигрывает от спекулятивных оптимизаций.
Одна из наиважнейших оптимизаций — это встраивание (инлайнинг). При встраивании инструкция вызова заменяется телом той функции, которой адресован вызов. В таком случае удаётся не только избавиться от накладных расходов, затрачиваемых на управление самим вызовом (у очень маленьких функций они могут быть даже выше, чем совершаемая ими полезная работа). Более того, встраивание открывает путь к дальнейшим оптимизациям, позволяющим «насквозь просмотреть» вызов функции — пусть даже эти оптимизации не являются межпроцедурными. Неудивительно, что ещё в 1971 году встраивание было признано одной из важнейших оптимизаций, об этом написал Френсис Аллен в своей эпохальной статье “Catalogue of Optimizing Transformations”.
Одно из осложнений, возникающих при встраивании, связано с непрямыми вызовами — такими, при которых адресат вызова становится известен только во время выполнения, и в таком качестве может выступать одна из многих потенциальных целей. Это особенно актуально для языков, компилируемых в WasmGC. Возьмём, к примеру, Java или Kotlin, где методы по умолчанию являются виртуальными (virtual). Напротив, при работе с C++ программисту приходится специально отметить, что их нужно сделать явными. Если нет единственного статически узнаваемого адресата, то и встраивание внедрить не так просто.
Спекулятивное встраивание
Как раз здесь нам и пригодится спекулятивное встраивание. Теоретически непрямые вызовы могут быть адресованы множеству различных функций, но на практике они всё равно зачастую направлены всего к одной целевой функции (в таком случае речь идёт о мономорфном вызове функции) или к нескольким специально выбранным (полиморфный вызов). При выполнении неоптимизированного кода эти цели записываются, после чего при генерации оптимизированного кода можно встроить до четырёх таких функций-адресатов.

На приведённой иллюстрации эта картина показана в общем виде. Начинаем с левого верхнего угла, где приведён неоптимизированный код для функции func_a, сгенерированный Liftoff — это наш базовый компилятор для работы с WebAssembly. В каждой точке вызова Liftoff также выдаёт код для обновления вектора обратной связи. Такой массив метаданных существует в одном экземпляре на каждую функцию, и в нём содержится по одной записи на каждую инструкцию вызова в рассматриваемой функции. Каждая запись фиксирует адресаты вызовов и ведёт счёт для данной конкретной точки вызова. Пример обратной связи — в нижней части рисунка показана мономорфная запись для call_indirect и func_a; здесь для целевой точки требовалось 1337 раз вызвать func_b.
Когда функция используется достаточно активно, чтобы повысить её до TurboFan, т.e., собирается при помощи нашего оптимизированного компилятора — переходим ко второму этапу. TurboFan считывает вектор обратной связи и решает, какие цели встраивать в каждой точке вызова и встраивать ли вообще. Решение о том, стоит ли встраивать одну или несколько функций-адресатов, принимается на основе разнообразной эвристики. Например, большие функции не встраиваются никогда, а крошечные встраиваются почти всегда. Как правило, предусматривается максимальный бюджет на встраивание, по исчерпании которого встраивание кода в функции прекращается, поскольку такие операции также имеют свою цену в пересчёте на время компиляции и размер сгенерированного машинного кода. Как это часто бывает при работе с компиляторами, в особенности с многоуровневыми JIT-компиляторами, эти компромиссы достаточно сложные, и со временем их требуется дополнительно настраивать. TurboFan решает встроить func_b в func_a.
В правой верхней части рисунка показан результат спекулятивного встраивания в сгенерированном оптимизированном коде. Код не делает непрямой вызов, а вместо этого сначала проверяет, совпадает ли цель во время выполнения с той, которая предполагалась на этапе компиляции. Если совпадает, то мы продолжаем выполнять встроенное тело соответствующей функции. Последующие оптимизации могут и далее преобразовать встроенный код, с учётом появившегося окружающего контекста. Например, просачивание и свёртка констант позволяют специализировать код для данной конкретной точки вызова, или GVN (глобальная нумерация значений) позволяет вынести повторяющиеся вычисления в отдельную категорию. При обработке полиморфной обратной связи TurboFan также может выдать целую серию проверок целей и встроенных тел функций — а не всего в одном экземпляре, как показано в этом примере.
Технические подробности
Итак, в общем представлении проблемы разобрались. Для тех, кого интересует реализация, рассмотрим в этом разделе более мелкие детали и конкретный пример кода.
На вышеприведённом рисунке вектор обратной связи концептуально представлен как массив записей, причём видим записи лишь одного рода. Ниже будет показано, как каждая запись в процессе выполнения может пройти через четыре стадии. Сначала все записи неинициализированы (для каждой насчитывается ноль вызовов) и потенциально могут перейти в категорию мономорфных (для вызова каждой функции записана всего одна цель), полиморфных (до четырёх вызываемых целей) и, наконец, мегаморфных (более четырёх целей; на данном этапе мы больше ничего не встраиваем и, следовательно, не должны вести счёт вызовов под запись). Фактически, каждая запись представляет собой такую пару объектов, что в самом распространённом мономорфном случае в векторе можно хранить как количество вызовов, так и встроенную цель — то есть не выделять дополнительную память. В полиморфном случае информация, полученная в качестве обратной связи, хранится в массиве, выделяемом вне очереди, как показано ниже. Вставка, обновляющая запись об обратной связи (соответствует функции update_feedback() на первом рисунке) написана на Torque. (Достаточно легко читается, попробуйте!) Сначала она проверяет, есть ли мономорфные или полиморфные «попадания», и если есть — то увеличить нужно только значение счёта. Опять же, дело в том, что это наиболее распространённые случаи, которые, следовательно, серьёзно сказываются на производительности. Вектор обратной связи и содержащиеся в нём записи — это объекты JavaScript (напр., количество вызовов содержится в Smis), поэтому они находятся в управляемой куче. Такая куча входит в состав песочницы V8 и автоматически очищается, если оказывается, что соответствующий ей экземпляр Wasm (см. ниже) более не доступен.

Теперь давайте рассмотрим, как операция встраивания сказывается на обычно�� программе, написанной на WebAssembly. Приведённая ниже функция example циклически выполняет 200M непрямых вызовов, направленных к одной цели inlinee, содержащей операцию сложения. Определённо, это достаточно упрощённый бенчмарк, но с его помощью удобно продемонстрировать, в чём польза спекулятивного встраивания.
(func $example (param $iterations i32) (result i32)
(local $sum i32)
block $block
loop $loop
local.get $iterations
i32.eqz
br_if $block ;; завершить цикл
local.get $iterations ;; обновить счётчик цикла
i32.const 1
i32.sub
local.set $iterations
i32.const 7 ;; аргумент вызова функции
i32.const 1 ;; табличный индекс, ссылающийся на $inlinee
call_indirect (param i32) (result i32)
local.get $sum
i32.add
local.set $sum
br $loop ;; повтор
end
end
local.get $sum
)
...
(func $inlinee (param $x i32) (result i32)
local.get $x
i32.const 37
i32.add
)
Читателям, которые не знакомы с текстовым форматом WebAssembly, предлагаю примерно эквивалентный код той же программы, переписанный на C:
int inlinee(int x) {
return x + 37;
}
int (*func_ptr)(int) = inlinee;
int example(int iterations) {
int sum = 0;
while (iterations != 0) {
iterations--;
sum += func_ptr(7);
}
return sum;
}
На следующем рисунке показаны выдержки из промежуточного представления TurboFan для функции example, визуализированные при помощи Turbolizer. Спекулятивное встраивание и деоптимизация Wasm включены справа, а слева выключены. В обеих версиях приходится проверять, находится ли в допустимых границах аргумент из табличного индекса для инструкции call_indirect, так получается в соответствии с семантикой WebAssembly (в обоих случаях — первая красная рамка). Без встраивания также приходится проверять, имеет ли расположенная по данному индексу функция правильную сигнатуру, и лишь если это так — вызывать её (вторая красная рамка слева). Наконец, зелёная рамка слева означает непрямой вызов, а вторая зелёная рамка — это плюсование результата указанного вызова. Как видим в зелёной рамке справа, после встраивания и дальнейших оптимизаций вызов в inlinee и сложение в example путём свёртки констант сливаются в одну операцию сложения с константой. В целом, применительно к данному конкретному микробенчмарку, встраивание, деоптимизация и последующие оптимизации позволяют сократить время выполнения программы с 675 до 90 мс, если выполнение идёт на рабочей станции x64. В данном случае оптимизированный машинный код с применённым встраиванием оказывается даже меньше, чем без встраивания (968 байт против 1572), хотя, определённо, так не должно быть.

Наконец, хочется вкратце объяснить, как в Wasm выполняется проверка экземпляра и проверка цели — именно эти операции совершает код со спекулятивным встраиванием, показанный справа. С семантической точки зрения функции Wasm являются замыканиями, каждое из которых охватывает экземпляр Wasm (в котором «содержится» актуальное состояние глобальных переменных, таблиц, импорты с хоста и т.д.). Следовательно, для правильного встраивания функций, относящихся к другому экземпляру (например, вызываемых через импортируемую таблицу) потребуются дополнительные вычислительные механизмы. Также придётся устранить некоторые препятствия, возникающие при обработке сгенерированного кода по общим правилам. К счастью, большинство вызовов так или иначе выполняется в пределах одного экземпляра, поэтому пока будем лишь проверять, совпадает ли с актуальным экземпляром тот экземпляр, к которому направлен вызов. Благодаря этому компилятор может упрощённо допустить, что оба экземпляра одинаковы. Если это не подтвердится, то он сделает деоптимизацию в блоке 8 (поскольку не подходит экземпляр) или в блоке 6 (поскольку не подходит цель).
Такая дополнительная проверка экземпляра Wasm была специально введена для встраивания нового call_indirect. В WebAssembly есть и другая разновидность непрямых вызовов, call_ref, для которых уже была добавлена поддержка встраивания на этапе выпуска этой реализации WasmGC. Быстрый путь при встраивании call_ref обходится без явной проверки экземпляра, поскольку объект WasmFuncRef, подаваемый call_ref в качестве ввода, уже содержит экземпляр, замыкаемый в функцию. Поэтому проверка цели на равенство подсуммирует обе проверки.
Деоптимизация
До сих пор мы рассматривали встраивание, обращая внимание на то, как оно позволяет улучшить оптимизированный код. Но что произойдёт, если мы не сможем продолжить движение по быстрому пути, то есть если во время выполнения окажется ложным то или иное допущение, сделанное на этапе оптимизации? Именно здесь в дело пойдёт деоптимизация.
В самом общем виде мы проиллюстрировали деоптимизацию уже на первой картинке к этому посту: бывает, что невозможно продолжать выполнение оптимизированного кода, так как при оптимизации были сделаны некоторые допущения, которые во время выполнения не подтвердились. Поэтому приходится вернуться к неоптимизированной базовой версии кода. Самое важное, что такой переход к неоптимизированному коду происходит прямо посреди выполнения текущей функции, то есть когда оптимизированный код уже успел выполнить некоторые операции, приводящие к побочным эффектам (допустим, вызывал операционную систему, в которой выполняется программа). Отменить такое невозможно, и тем временем промежуточные значения хранятся в регистрах процессора и в стеке. Поэтому при деоптимизации мало просто вернуться к началу неоптимизированной функции; вместо этого делаются гораздо более интересные вещи:
Сначала мы сохраняем актуальное состояние программы. Для этого вызываем из оптимизированного кода среду выполнения. Затем Deoptimizer сериализует текущее состояние программы во внутреннюю структуру данных FrameDescription. Для этого приходится прочитать регистры стека ЦП и проверить кадр стека той функции, которую предполагается деоптимизировать.
Далее преобразуем это состояние так, чтобы оно соответствовало неоптимизированному коду, то есть помещаем значения в нужные регистры и ячейки стека, чтобы прийти к состоянию, которое код, сгенерированный Liftoff, ожидает встретить в точке начала деоптимизации. Например, значение, которое код TurboFan записал в регистр, теперь может оказаться в ячейке стека. Компоновка стека и ожидаемые свойства базового кода (если хотите, его «соглашения о вызовах» с точки зрения деоптимизации) считываются из метаданных, генерируемых Liftoff в процессе компиляции.
Наконец, мы заменяем оптимизированный кадр стека соответствующим(и) неоптимизированным(и) кадром/амии стека и переходим в ту точку посреди базовоq версии кода, которая соответствует точке в оптимизированном коде, с которой началась деоптимизация.
Конечно же, все эти механизмы достаточно сложны, напрашивается вопрос: зачем сознательно идти на такие мытарства, а просто не сгенерировать код, рассчитанный на выполнение по медленному пути и рассчитанный для этой цели? Давайте сравним промежуточное представление приведённого выше примера на WebAssembly: слева без деоптимизации, а справа с ней. Три красных рамки (границы таблицы / экземпляр Wasm / проверка цели) фундаментально не изменились. Разница есть только в том коде, который выполняется по медленному пути. Без деоптимизации у нас нет возможности вернуться к базовому коду, поэтому оптимизированный код должен обрабатывать полное и всестороннее поведение опосредованного вызова, как показано в жёлтой рамке слева. К сожалению, это мешает дальнейшим оптимизациям, поэтому весь код выполняется медленнее.

Медленный путь без деоптимизации препятствует дальнейшим оптимизациям по двум причинам. Во-первых, в нём содержится больше операций, в частности, есть вызов, который, теоретически, может пойти куда угодно и давать любые побочные эффекты. Во-вторых, обратите внимание: за операцией Deoptimize справа в блоке 8 в графе потока управления ничего не следует, а на медленном пути (слева), обозначенном жёлтым — следует. В частности, что касается циклов, узел/блок деоптимизации не породит никаких сущностей потока данных (в смысле, следующих из анализа потока данных и соответствующих оптимизаций, например динамических переменных, устранения загрузок или escape-анализа), которые проникали бы на следующую итерацию цикла. В сущности, точка деоптимизации «просто» завершает выполнение функции, что почти не влияет на окружающий код. Окружающий код вполне может подвергаться дальнейшим оптимизациям.
Наконец, это также объясняет, почему так полезно сочетание спекулятивных оптимизаций (например, встраивания) и деоптимизации. Первые, отталкиваясь от спекулятивных допущений, открывают путь быстрого выполнения кода, а вторая позволяет компилятору не обращать особого внимания на те случаи, в которых сделанные допущения не оправдались. Если быть точным, в случае с рассмотренным выше микробенчмарком на 200M непрямых вызовов, если ограничиться лишь спекулятивным встраиванием, то выполнение программы ускоряется «всего» примерно до 180 мс. Сравните этот показатель с 90 мс при применении как встраивания, так и деоптимизации, и с 675 мс без обеих этих операций.
Технические подробности
Для тех, кто заинтересовался, вновь рассмотрим пример с подробным разбором технических деталей; на этот раз разберём деоптимизацию.
Допустим, мы выполняли оптимизированный код, приведённый выше, но тем временем успела измениться функция, сохранённая в таблице по индексу 1. Проверка границ таблицы и проверка экземпляра Wasm пройдут успешно, но встроенная цель будет отличаться от фигурирующей в таблице. Поэтому в нынешнем состоянии программу придётся деоптимизировать. Именно для этого в коде содержатся так называемые деоптимизационные выходы. Проверка цели переходит по условию именно к такой точке, которая сама собой является вызовом к вставке DeoptimizationEntry. Эта вставка сначала сохраняет все значения из регистров, сливая их в стек. Кстати, поскольку для Wasm предусмотрено расширение SIMD, сюда также включаются все 128-разрядные векторные регистры, используемые TurboFan. Затем в C++ выделяется объект Deoptimizer, а также объект FrameDescription, поступающий ему на вход. Вставка копирует слитые в стек регистры, а все остальные ячейки стека из оптимизированного кадра в FrameDescription на куче, и в процессе этой работы такие значения выталкиваются из стека. Обратите внимание: выполнение продолжается в пределах всё той же вставки, хотя она уже успела убрать из стека собственный адрес возврата и приступила к размотке вызывающего кадра! Затем вставка вычисляет выходные кадры. С этой целью деоптимизатор загружает DeoptimizationData для оптимизированного кадра, извлекает информацию для точки деоптимизации и перекомпилирует в данной точке вызова каждую встроенную функцию с использованием Liftoff. Поскольку встраивание бывает вложенным, встроенных функций может быть несколько, и при встраивании хвостовых вызовов та оптимизированная функция, к которой номинально относится оптимизированный кадр стека, даже может не относиться к тем неоптимизированным кадрам стека, которые требуется собрать.
В ходе компиляции Liftoff вычисляет ожидаемую компоновку кадров стека, а деоптимизатор преобразует описание оптимизированного кадра в те варианты компоновки, которые Liftoff счёл желательными. Он возвращается к вставке, которая затем читает эти полученные на выход объекты FrameDescription и записывает их значения в стек. Наконец, вставка заполняет регистры в соответствии с расположенным сверху выходным FrameDescription.
Что касается вышеприведённого примера, внутренняя трассировка, которую мы выполнили при помощи --trace-deopt-verbose, показывает следующее:
[bailout (kind: deopt-eager, reason: wrong call target, type: Wasm): begin. deoptimizing example, function index 2, bytecode offset 134, deopt exit 0, FP to SP delta 32, pc 0x14886e50cbb4]
reading input for Liftoff frame => bailout_id=134, height=4, function_id=2 ; inputs:
0: 4 ; rdx (int32)
1: 0 ; rbx (int32)
2: (wasm int32 literal 7)
3: (wasm int32 literal 1)
Liftoff stack & register state for function index 2, frame size 48, total frame size 64
0: i32:rax
1: i32:s0x28
2: i32:c7
3: i32:c1
[bailout end. took 0.082 ms]
Сначала видим, что деоптимизация начинается потому, что в Wasm указана неверная цель вызова для функции example. Затем в трассировке приводится информация о входном (оптимизированном) кадре, содержащем четыре значения. iterations рассматриваемой функции (значение 4, хранится в регистре rdx), локальная сумма sum (0, хранится в rbx), литерал 7 и литерал 1 — это два аргумента инструкции call_indirect. В этом простом примере есть только один неоптимизированный выходной кадр, поэтому два кадра соответствуют друг другу как 1:1. Значение iterations должно храниться в регистре rax, тогда как значение sum должно оказаться в ячейке стека s0x28. Две константы Liftoff также правильно распознаёт как константы, и их не приходится переносить ни в регистры, ни в ячейки стека.
Бывает и так, что значения являются константными в оптимизированной версии, уже после свёртки констант, но должны быть материализованы для Liftoff в ячейках стека или регистрах. Поэтому эти константные значения необходимо хранить в составе данных деоптимизации.
После того, как будут сделаны все эти преобразования, вставка «возвращается» к наиболее глубокому неоптимизированному кадру, который вызывает последнюю вставку. Задача последней вставки — прибрать объект Deoptimizer и выделить в управляемой куче все нужные фрагменты памяти. Пока идёт деоптимизация, мы не можем выделять память в куче, поскольку из-за этого может сработать сборщик мусора, а стек находится в таком состоянии, в котором сборщик мусора не может его проверить. Сборщику мусора нужно было бы проследовать по всем ссылкам, ведущим из стека в кучу и потенциально обновить их при перемещении объекта. Наконец, продолжим выполнять неоптимизированный код, в данном случае, выполним call_indirect, которая также прямо запишет в своём векторе обратной связи новую цель. Поэтому в дальнейшем об этой цели будет оповещён любой код, расположенный уровнем выше.
Результаты
Кроме технического описания и примеров также хотим продемонстрировать полезность встраивания call_indirect и деоптимизации Wasm на материале некоторых измерений. Измерения выполнялись на рабочей станции x64; на рисунках показана медиана в N=21 повторов.
Сначала рассмотрим подборку микробенчмарков Dart, показанную на следующем рисунке. Здесь взаимно сравниваются три конфигурации: все указанные числа — это ускорения относительно V8 и поведения Chrome до встраивания call_indirect и деоптимизации Wasm (т.e., ускорение в 2 раза означает, что выполнение прошло вдвое быстрее, чем по базовому сценарию). Голубые полоски соответствуют конфигурации с активным встраиванием call_indirect, но без деоптимизации Wasm — т.e., когда в оптимизированном коде предусмотрен универсальный медленный путь выполнения. По некоторым из данных микробенчмарков это уже позволяет добиться (иногда существенного) ускорения. Что касается трёх элементов Matrix4Benchmark, демонстрирующих небольшой регресс, при включённом встраивании call_indirect наша эвристика предпочитает встраивать из всех точек прямого вызова именно конкретные 16 точек. Из-за этого бюджет на встраивание исчерпывается, так как результирующий код получается слишком большим, и встраивание прекращается. Получается, что удаётся встроить меньше прямых вызовов, чем ранее. В данном случае эвристика не позволяет точно спрогнозировать, насколько выгоднее выполнить именно один вариант встраивания, а не другой, и результат получается неоптимальным. Здесь открывается интересное поле для доработок.
В среднем в пересчёте на все элементы встраивание call_indirect позволяет ускорить выполнение в 1,19 раза по сравнению с базовой версией, где встраивание не применяется. Наконец, красные полоски соответствуют конфигурации, которая действительно идёт в работу — в ней включены как деоптимизация Wasm, так и встраивание call_indirect. В среднем такая конфигурация даёт ускорение в 1,59 раза по сравнению с базовой версией и демонстрирует, что особенно полезно поддерживать одновременно спекулятивные оптимизации и деоптимизацию.

Естественно, микробенчмарки изолируют и значительно акцентируют эффекты оптимизации. Они полезны на этапе разработки или при необходимости получить сильный сигнал при зашумленных измерениях. Но более реалистичны результаты, получаемые на более крупных приложениях и полноценных бенчмарках, как показано на следующем рисунке. С левого края показано ускорение на 2% для среды выполнения richards-wasm, рабочая нагрузка получена из комплекта бенчмарков JetStream. Далее наблюдаем 1% ускорение для сборки Wasm на примере широко распространённой базы данных SQLite 3 и 8%-е ускорение для Dart Flute, бенчмарка WasmGC, который эмулирует рабочую нагрузку, типичную для пользовательского интерфейса, подобного написанному на Flutter. Последние два результата получены на основе внутренних бенчмарков, применяемых для калькулятора, встроенного в Google Sheets, работающего при поддержке WasmGC. Здесь благодаря деоптимизации ускорение достигает до 7% (в данном случае важна лишь деоптимизация, поскольку последнее приложение использует call_refs лишь для диспетчеризации во время выполнения, т.e., не имеет call_indirects).

Заключение и обзор
Вот и всё, что мы хотели рассказать о двух новых оптимизациях в движке V8 для WebAssembly. В этом посте было показано:
Как при помощи спекулятивной оптимизации можно встраивать функции даже при наличии непрямых вызовов,
что такое в данном случае «обратная связь», как эта информация используется и обновляется,
что делать в случаях, когда во время выполнения не подтверждаются допущения, сделанные при оптимизации,
как деоптимизация позволяет выйти из оптимизированного когда и вернуться к базовому варианту, не прерывая выполнения функции; и, наконец,
как таким образом существенно улучшить выполнение реальных рабочих нагрузок.