Раздел 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-тяжёлом коде, мелочи и холодном старте, поэтому лучший дизайн это гибрид. Дальше: капстоун, переиспользуемый движок виджетов.

Домашка