Инженерия интерпретатора Wasmi 2.0
- title
- Инженерия интерпретатора Wasmi 2.0
- type
- summary
- summary
- Как Wasmi 2.0 стал быстрее в ~2,2 раза: threaded-диспетчеризация, регистры-аккумуляторы, плоская раскладка экземпляров и ловушка кодогенерации Rust
- parent
- webassembly
- tags
- webassembly, interpreters, rust, performance, microarchitecture
- sources
- wasmi-2-engineering
- created
- 2026-09-14
- updated
- 2026-09-14
- lang
- ru
- translation_of
- wasmi-2-interpreter-engineering
- source_updated
- 2026-09-14
- translated
- 2026-09-14
- translator
- lllm/antigravity/gemini-3.7-flash-medium
Wasmi - написанный на Rust интерпретатор WebAssembly, используемый во встраиваемых средах для плагинов (Typst, Zellij, Josh), платформах смарт-контрактов (Soroban, Ripple) и на компактных устройствах. Пост Робина Фрайлера (Robin Freyler) о релизе Wasmi 2.0 - результате восьми месяцев работы при поддержке Stellar Development Foundation - сообщает об ускорении в ~2,2 раза по сравнению с Wasmi 1.0 (среднее геометрическое по набору тестов wasmi-benchmarks на Apple M2 Pro) и объясняет, за счёт чего это получилось wasmi-2-engineering.
Все сравнения в статье - это замеры автором собственного интерпретатора на собственном наборе тестов. Этот набор публичен и рассчитан на воспроизводимость, а в тексте прямо говорится, что Wasmi 2.0 заимствовал идеи у каждого из соперников, с которыми сравнивается: Wasm3, быстрого интерпретатора WAMR, Pulley из Wasmtime и Stitch от Makepad. Результаты соперников приведены только в виде графиков на трёх машинах (Apple M2 Pro, AMD EPYC 7763, Intel Xeon Platinum 8370C); вывод автора состоит в том, что Wasmi 2.0 "определённо относится к категории самых быстрых переносимых интерпретаторов Wasm", при этом время запуска практически не изменилось по сравнению с версией 1.0. К этому стоит относиться как к утверждениям разработчика, подкреплённым публичным бенчмарком.
Архитектурные решения интереснее рейтингов. Wasm3 и Stitch во многом разделяют одну и ту же архитектуру, и в статье детально описано, где и почему Wasmi намеренно от них отличается.
Режимы диспетчеризации
Перед выполнением Wasmi транслирует байт-код Wasm в собственное промежуточное представление (IR), где у каждой инструкции есть функция-обработчик. В версии 2.0 поддерживаются четыре способа перехода от одного обработчика к другому с одной и той же логикой исполнения:
| Режим | Как устроен переход | Флаги crate |
|---|---|---|
| Direct-threaded code | Указатели на функции-обработчики встроены в IR; каждый обработчик выполняет хвостовой вызов следующего. Используется в Wasm3 и Stitch. | нет |
| Indirect-threaded code | Опкоды в IR, таблица переходов сопоставляет каждый с его обработчиком. Примерно на 10-15% медленнее, но IR заметно компактнее. | indirect-dispatch |
| Switch-loop | Цикл вокруг match, как в Wasmi 1.0. Необходим там, где недоступны хвостовые вызовы; особенно медленно работает на Apple Silicon. |
portable-dispatch + indirect-dispatch |
| Call-loop | Цикл, вызывающий каждый обработчик без хвостовых вызовов. Медленный и прожорливый по памяти, не рекомендуется к использованию; существует только потому, что эти два флага независимы. | portable-dispatch |
Флаг auto-dispatch выбирает threaded-режим, если платформа это позволяет. Сама техника известна как threaded code.
Единая сигнатура обработчика и бюджет регистров под аргументы
Все обработчики принимают девять одинаковых аргументов: нетипизированное хранилище (type-erased store), указатель инструкций, указатель стека значений, указатель и длину для линейной памяти по умолчанию (memory 0), текущий экземпляр и три регистра-аккумулятора (ireg для целых чисел и ссылок, freg32, freg64). Они возвращают битовую маску Done, сообщающую причину остановки выполнения.
fn(
store: &mut PrunedStore,
ip: Ip,
sp: Sp,
mem0: Mem0Ptr,
mem0_len: Mem0Len,
instance: Inst,
ireg: Ireg,
freg32: Freg32,
freg64: Freg64,
) -> Done;
Семь из этих аргументов требуют регистров общего назначения (GPR), но соглашение sysv64 передаёт в регистрах только шесть целочисленных аргументов. Седьмой сбрасывался бы в стек при каждом переходе. Stitch и Wasm3 избегают этой проблемы, используя шесть и четыре GPR соответственно. Wasmi взамен передаёт указатель instance в регистре с плавающей точкой: обращение к экземпляру происходит только в уже и без того тяжёлых операциях, и, судя по бенчмаркам в статье, такой перенос почти ничего не стоит. Нестабильный ABI preserve_none в Rust в будущем может снять это ограничение.
Регистры-аккумуляторы
В Wasmi 1.0 каждый операнд и результат адресовались как слот в стеке. Обработчик i64.add декодировал слот результата, слот левого операнда и непосредственное значение (immediate), читал со стека, складывал и записывал обратно. В 2.0 результат и зачастую один из операндов неявно живут в регистре-аккумуляторе, который соглашение о вызовах удерживает в реальном аппаратном регистре:
fn i64_add(ip: Ip, sp: Sp, ireg: i64, ..) -> Done {
let rhs: i64 = decode_i64(ip);
ireg = ireg + rhs;
ip.offset(encode_size::<i64_add>);
next!(ip, ireg, ..)
}
На aarch64 весь обработчик укладывается в четыре инструкции: загрузка следующего обработчика со сдвигом ip, загрузка непосредственного значения, сложение и переход.
Цена этого - инструкции копирования, которые в старой схеме были не нужны. Выражение (local 0) + 10 с сохранением в (local 1) в версии 1.0 было одной инструкцией, а в 2.0 превратилось в две (i32_add_rsi, затем u64_copy_sr). Если последующее вычисление перезаписывает ireg, пока предыдущий результат ещё ожидает на стеке операндов Wasm, транслятор сначала должен скопировать его в слот. Wasmi снижает эти накладные расходы двумя путями. Во-первых, добавлены специализированные инструкции копирования для фиксированных небольших индексов локальных переменных (u64_copy_sNr для N = 0..10, аналоги для чисел с плавающей точкой, и u64_copy_sNsM для копирования между локальными переменными с N,M = 0..5), чтобы не тратить время на декодирование индекса. Во-вторых, применяется слияние опкодов (fusion): add и load, за которыми сразу следуют local.set или local.tee, встречаются в реальном Wasm достаточно часто, чтобы выделить под них отдельные инструкции, пишущие одновременно в регистр и в слот.
Аккумуляторы также сохраняются при пересечении границ потока управления. Блоки block, if или loop, чьи результаты (или параметры цикла) заканчиваются на i32 и f32, возвращают их в ireg и freg32: только хвост списка результатов помещается в регистры, а остальное уходит в слоты стека. Это позволяет держать индукционные переменные циклов в регистре. Попытка сделать то же самое для вызовов функций привела к регрессу производительности и в релиз не вошла.
Объекты экземпляров с фиксированным смещением
В версии 1.0 экземпляр содержал по одной аллокации в куче на каждый тип объектов (память, глобальные переменные, таблицы и т. д.), каждая из которых представляла собой список дескрипторов (handles), разрешаемых через хранилище. Для global.get требовалось три зависимые загрузки из памяти. Это было настолько медленно, что для (global 0), используемого скомпилированным в Wasm кодом на C как указатель теневого стека (shadow stack), пришлось делать специальный отдельный путь.
Версия 2.0 исходит из того, что у каждого экземпляра одного модуля раскладка объектов одинакова, поэтому адрес объекта является свойством самого модуля. Структура InstanceEntity стала типом динамического размера (dynamically sized type): фиксированный заголовок, за которым следует единый непрерывный буфер handles в строгом порядке: памяти, глобальные переменные, таблицы, функции, элементы (elems), данные (datas). Памяти идут первыми, поэтому (memory 0) располагается по адресу 0, а адреса памяти укладываются в 16 бит. Сегменты данных идут последними, так как секция data count может быть неизвестна в момент создания модуля. В заголовке кэшируется (table 0) для инструкции call_indirect. Каждый дескриптор содержит прямой указатель на объект в хранилище, заполняемый при инстанцировании; ради этого контейнеры Arena в хранилище заменили на StableArena, которая никогда не перемещает свои элементы в памяти. Теперь IR адресует объекты по их смещению внутри экземпляра, сводя доступ к единственному смещению от указателя instance. Обработчик global_get_u64_r компилируется всего в шесть инструкций aarch64, а специальный путь для (global 0) убран, поскольку (global 1) теперь работает так же быстро.
Wasm3 и Stitch достигают аналогичной скорости иначе: их байт-код привязан к конкретному экземпляру и содержит вшитые прямые указатели на объекты. Wasmi сохраняет байт-код на уровне модуля, разделяемый всеми его экземплярами, что, как отмечается в статье, экономит много памяти, а сопоставимой скорости доступа добивается именно за счёт раскладки данных. Бенчмарк counter-global изолированно тестирует именно этот аспект.
Lock-free карта кода для вызовов
В Wasmi 1.0 все скомпилированные тела функций хранились в одной арене под защитой мьютекса, из-за чего каждый вызов между Wasm-функциями захватывал блокировку. Общий байт-код на уровне модуля делает глобальный адрес функции валидным для любого экземпляра, поэтому в 2.0 стало возможным вшивать указатели на функции прямо в инструкции вызова, как это делают Stitch и Wasm3. Это работает, только если функции никогда не перемещаются, а карта кода при этом растёт по мере компиляции модулей (возможно, прямо во время работы других потоков). В 2.0 функции хранятся в append-only блоках (buckets), которые никогда не реалоцируются. Добавление функций сериализовано, а чтение происходит без блокировок (lock-free). У каждой записи есть собственное атомарное состояние: горячий путь call_internal проверяет, что функция уже скомпилирована, и совершает переход, а ленивая компиляция происходит на холодном пути максимум один раз на функцию.
В бенчмарке fibonacci-rec с высокой частотой вызовов Wasmi, согласно статье, опережает и Stitch, и Wasm3. Автор объясняет это не только картой кода: в обоих упомянутых интерпретаторах стек вызовов и стек значений объединены, что приводит к лишним копированиям, а Wasm3 к тому же копирует пул констант функции при каждом вызове.
Фиксированные 64-битные ячейки стека
При включённом флаге simd Wasmi 1.0 расширял каждую ячейку стека до 128 бит, что стоило 5-10% производительности из-за трафика памяти, расхода кэша и более широких копирований при вызовах. В 2.0 всегда используются 64-битные ячейки, а под каждое значение v128 транслятор выделяет две соседние ячейки. SIMD-инструкции перемещают ровно столько же 64-битных слов, сколько и раньше. На тесте CoreMark версия 1.0 теряет около 8% при включении simd, тогда как у 2.0 результаты с SIMD и без него находятся в пределах погрешности.
Самый большой выигрыш: отмена оптимизационного прохода компилятора
Сравнивая результаты конкурентов, Фрайлер заметил, что результат Stitch в CoreMark упал примерно на 30% (с более чем 3000 до ~2200) при переходе с Rust 1.91 на 1.92. Причиной оказалась DestinationPropagation - оптимизация MIR, включённая по умолчанию в 1.92, которая объединяет локальные переменные с одинаковым значением. В обработчике условного перехода она схлопывала ветки (taken / not-taken) в единый путь диспетчеризации: инструкция csel выбирает следующий ip, а единственный косвенный br выполняет переход.
branch_i32_lt_ri:
ldp w8, w9, [x1, #8]
sxtw x8, w8
add x10, x1, #16
add x8, x1, x8
cmp w9, w6
csel x1, x8, x10, gt ; pick the target
ldr x7, [x1]
br x7 ; one branch site for both outcomes
Общая точка ветвления даёт предсказателю переходов одну запись со смешанной историей. Скорость threaded-интерпретаторов во многом держится на том, что у каждого обработчика есть собственная инструкция перехода, которую предсказатель может обучать изолированно, а этот проход компилятора незаметно объединил две из них. Исправление Stitch вернуло его результат к отметке выше 3000. У собственного обработчика i32.lt в Wasmi была та же проблема; после исправления в нём появились условный переход и две отдельные инструкции br. В итоге результат Wasmi 2.0 в CoreMark вырос с ~2800 до более чем 4200 (примерно на 50%), что автор называет самой важной оптимизацией всего релиза. Улучшение затронуло только режимы диспетчеризации с хвостовыми вызовами.
Это ситуация, противоположная описанной в compiler-codegen-luck, где случайный переход на безветвевой conditional-move ускорил quicksort в шесть раз из-за непредсказуемости ветвления. Здесь же безветвевой вариант привёл к проигрышу: переходы диспетчеризации хорошо предсказываются для каждой отдельной точки, а их слияние разрушило эту предсказуемость.
Что дальше
Цель Wasmi 3.0 - поддержка WebAssembly 3.0, для чего ещё требуются пропозалы function-references, exception-handling и gc. Спонсорство Stellar заканчивается в октябре 2026 года, и автор ищет новые источники финансирования.
О других средах выполнения в базе см. wazero (чистый Golang, интерпретатор плюс компилятор) и wasmer (нативный мультибэкенд). Другой путь к быстрому интерпретатору - автоматический вывод JIT вместо оптимизации диспетчеризации - описан в meta-tracing.