Раздел 23 · Rust

Мощь и пределы WASM: бенчмарк, размер, SIMD, потоки

lead~30 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Мощь и пределы WASM: бенчмарк, размер, SIMD, потоки

Распорядитель турнира · ярмарочное ристалище

Турнир в окне еле ползёт. Окно на каждом такте спрашивает про каждого рыцаря по три вопроса и каждый раз стучится в дверь. Рыцарей трое, тактов шестьдесят в секунду. Сколько раз в секунду окно стучится и сколько из этих стуков лишние?

Эмулятор у нас крутится в браузере, и соблазн сказать «WASM это просто быстро» велик. Но это полуправда, и за неё больно платят. JavaScript-движок быстр, .wasm бывает неожиданно жирным, а наивный перенос кода в WASM иногда работает медленнее исходного JS. Сегодня разбираемся честно: когда WebAssembly реально выигрывает, как это измерить без самообмана, как ужать бандл, что дают SIMD и потоки, и, главное, где WASM проигрывает. Это длинный урок, зато после него ты перестанешь верить в магию и начнёшь мерить.

WASM не быстрее по волшебству

Первый миф под снос. WebAssembly не «быстрее JavaScript» сам по себе. Движок JS давно не интерпретатор: JIT компилирует горячие функции в нативный код, и установившийся числовой JS почти догоняет Rust. Реальные преимущества WASM тоньше и важнее.

Во-первых, предсказуемость. У WASM нет деоптимизаций: машина состояний задана заранее, нет предположений о типах, которые могут не сбыться в горячем цикле. И нет пауз сборщика мусора: память управляется вручную, кадр эмулятора не дёрнется из-за внезапной чистки кучи. Во-вторых, компактный числовой код: Rust компилируется в плотные инструкции без упаковки чисел в объекты, и тяжёлый счёт (эмулятор, кодек, физика, крипта) идёт ровно и быстро. В-третьих, тот самый Rust, который уже написан и протестирован нативно: переиспользование, а не переписывание на JS.

Цена границы решает всё

Вот ключевая интуиция всего урока, и её надо прочувствовать руками. У переноса работы в WASM есть фиксированный налог: каждое пересечение границы Rust и JS стоит денег. Для числовых аргументов налог мал, но он фиксированный, и платится за каждый вызов. Значит, выгода от WASM зависит не только от того, насколько быстрее считает Rust, но и от того, как часто ты дёргаешь границу.

Отсюда правило, которое мы уже применили в эмуляторе: один вызов runFrame считает целый кадр, а не пиксель за вызов. Покрути ползунки: слева работа на один вызов, справа сколько раз за кадр пересекаешь границу. Найди область, где WASM выигрывает, и область, где он проигрывает, хотя считает быстрее.

Вывод модели тот же, что у системных вызовов и сетевых раундтрипов: пересекай границу редко и крупными порциями. Крупный счёт уноси в WASM, мелочь в цикле оставляй в JS.

Ответ распорядителю отсюда же. Три вопроса на трёх рыцарей шестьдесят раз в секунду это больше пятисот стуков, а нужен один на кадр. Ввод хост может вообще не передавать вызовом: модуль заводит у себя ящик кнопок, отдаёт его адрес один раз, и хост пишет кнопки прямо в линейную память. Ответ так же лежит на стене: табло, которое модуль переписывает на месте после каждого прогона. Буфер не переезжает, поэтому окно хоста над ним живёт, а чтение табло не выделяет ни байта.

Как мерить, чтобы не обмануть себя

Раз «быстрее» зависит от сценария, его надо мерить, а не угадывать. И тут легко соврать самому себе. Три правила честного бенчмарка. Первое: прогрей JIT, иначе первые вызовы JS пойдут по медленному пути и ты сравнишь холодный JS с WASM. Второе: много итераций и медиана, потому что планировщик, сборщик мусора и сброс частоты процессора шумят, и одно измерение ничего не значит. Третье: мерь реальный сценарий целиком, кадр эмулятора или обработку файла, а не синтетический микробенчмарк одной операции.

// Прогрев, потом замер: типичная форма честного бенчмарка.
for (let index = 0; index < 200; index++) runFrameJs();   // прогрели JIT
const start = performance.now();
for (let index = 0; index < 1000; index++) runFrameJs();
const jsMs = (performance.now() - start) / 1000;            // среднее на кадр

Микробенчмарки печально известны враньём: оптимизатор может вовсе выкинуть код без побочных эффектов, а раздельные замеры не видят цену границы. Меряешь то, что реально делает приложение, иначе число красивое и бесполезное.

Размер бандла: WASM бывает жирным

У скорости есть незаметная цена: вес .wasm. Его надо скачать и скомпилировать до первого кадра, и наивная сборка с паникующим форматтером и полным std легко раздувается. Боремся на двух уровнях. Сначала компилятор: режем размер профилем сборки.

[profile.release]
opt-level = "z"     # оптимизировать по размеру, не по скорости
lto = true          # межмодульная оптимизация выкидывает мёртвый код
codegen-units = 1   # одна единица: лучше оптимизация, медленнее сборка
panic = "abort"     # без раскрутки стека: меньше кода паники и форматтера
strip = true        # снять отладочные символы

Потом постобработка. wasm-opt -Oz прогоняет бинарник через оптимизатор binaryen и ужимает то, что не успел компилятор. А чтобы понять, что именно весит, есть twiggy: он показывает, какие функции раздувают бинарник. Частые виновники это форматирующая машинерия (format!, паника с сообщением, Debug), лишние feature-флаги зависимостей и размноженные мономорфизацией дженерики. Поэтому web-sys включают по фичам, а не целиком: меньше биндингов, меньше вес. Правило то же, что с бенчмарком: сначала измерь twiggy, потом режь по факту.

SIMD: четыре числа за такт

Когда счёт упёрся в число операций, помогает SIMD: одна инструкция обрабатывает сразу несколько чисел. У WebAssembly это пакет на 128-битных векторах, например четыре f32 за раз. Включается целевой фичей, а пишется либо автовекторизацией (компилятор сам распознаёт цикл), либо интринсиками core::arch::wasm32:

RUSTFLAGS="-C target-feature=+simd128" \
  cargo build --release --target wasm32-unknown-unknown
use core::arch::wasm32::*;

/// Сложить два среза f32 по четыре числа за инструкцию. Хвост (длина не кратна 4)
/// дорабатывается скаляром, тут опущен.
pub fn add_f32x4(a: &[f32], b: &[f32], out: &mut [f32]) {
    for index in (0..a.len()).step_by(4) {
        let va = unsafe { v128_load(a.as_ptr().add(index).cast()) };
        let vb = unsafe { v128_load(b.as_ptr().add(index).cast()) };
        let sum = f32x4_add(va, vb);
        unsafe { v128_store(out.as_mut_ptr().add(index).cast(), sum) };
    }
}

SIMD выигрывает на регулярных потоковых данных: микширование аудио APU, обработка пикселей PPU, перемножение векторов. На ветвистом коде с зависимостями между шагами он не помогает, там всё решает кэш и предсказание переходов. Меряй, как всегда: автовекторизация иногда уже сделала всё за тебя.

Потоки: общая память через web workers

Долго казалось, что WASM однопоточный, и наши движки прячут машину в thread_local именно поэтому. Но потоки есть, просто браузерные. Несколько web workers делят одну линейную память (через SharedArrayBuffer), и тогда настоящие потоки Rust ложатся на воркеры: воркер это поток, общая память это разделяемая куча, а синхронизация идёт через атомарные инструкции. Весь твой блок про многопоточность и атомики переносится сюда почти дословно: Arc, атомики, порядки памяти, всё то же, rayon под wasm32 тоже работает.

Цена входа одна, но строгая. Чтобы браузер разрешил SharedArrayBuffer, страница должна быть в режиме cross-origin isolation, а это два HTTP-заголовка (COOP и COEP) и аккуратность со всеми встраиваемыми ресурсами. Поэтому потоки в WASM это решение уровня всего сайта, а не локальная оптимизация виджета, и наши учебные движки сознательно остаются однопоточными.

Где WASM проигрывает

Симметрия честности: назовём случаи, где WebAssembly хуже. DOM-тяжёлый код: если работа это дёргать элементы страницы, каждый такой вызов идёт через границу, и налог съест всю выгоду, тут JS дома. Мелкие задачи: посчитать пару чисел проще и быстрее в JS, чем грузить и инстанцировать модуль. Старт: .wasm надо скачать и скомпилировать, и для короткоживущей страницы это чистый проигрыш. Размер: лишние мегабайты бинарника бьют по времени до первого кадра, особенно на мобильном.

Отсюда итоговое правило выбора. Тяжёлый предсказуемый счёт, числа, переиспользование готового Rust, портируемость одного кода на все платформы это WASM. Дёрганье DOM, мелкие разовые задачи, чувствительный к каждому килобайту старт это JS. А лучший дизайн обычно гибрид, как наш эмулятор: ядро считает Rust в WASM, а тонкая JS-оболочка рисует и слушает ввод.

Что унести из урока

WASM не быстрее JS по волшебству: JIT почти догоняет числовой код, а выигрыш WASM это предсказуемость (нет деоптимизаций и пауз сборщика мусора), компактный счёт и переиспользование готового Rust. Решает цена границы: каждое пересечение Rust и JS платится фиксированно, поэтому крупный счёт уноси целиком, а в тесном цикле границу не дёргай. «Быстрее» зависит от сценария, значит мерь честно: прогрей JIT, бери медиану многих итераций, замеряй реальный кадр, а не микробенчмарк. У скорости есть цена в весе: режь размер профилем (opt-level="z", lto, panic="abort"), прогоняй wasm-opt -Oz, ищи жир через twiggy, включай web-sys по фичам. SIMD даёт несколько чисел за инструкцию на регулярных потоковых данных, а потоки приходят через web workers с общей линейной памятью и атомиками, ценой cross-origin isolation. И WASM честно проигрывает на DOM-тяжёлом коде, мелочи и холодном старте, поэтому лучший дизайн это гибрид. Дальше: финальный проект, переиспользуемый движок виджетов.

Шедевр, этап 4

Четвёртый этап shedevr: турнир из прошлого блока за дверью, и дверь на кадр одна. Кнопки рыцарей хост пишет в ящик по адресу turnir_input_ptr, turnir_run гонит сколько угодно тактов одним вызовом, а табло лежит по turnir_board_ptr. Тесты гоняют турнир по такту за вызов и по семь тактов за вызов и сверяют, что это один и тот же турнир, а отдельный тест считает выделения памяти, пока хост читает табло.

Распорядитель доволен: турнир идёт в окне без рывков. Вадим тем временем приносит последнюю страницу Книги Мастерской Си. Клеймо №7 там записано так, как у автомата записывали места: номер отдали Вадиму, а когда старый узел прислал тот же заказ второй раз, под тот же номер выдали вторую вещь, уже Мастерской. Номер был один, а держателей у него стало двое, и никто не спросил первого. Ферапонт кладёт страницу на стол: «Теперь понятно, что чинить. Не слитки, а номера».

Домашка