Перейти к основному содержимому

Паттерн

Rust-крейт, скомпилированный в wasm-модуль на 20 КБ, закоммиченный в репозиторий и загружаемый без единого Vite-плагина. Dev-контейнеру никогда не нужен Rust-тулчейн: одноразовый контейнер-сборщик компилирует крейт, а сгенерированный glue-код и бинарник коммитятся как обычный исходный код.

Ядро

Пиксели живут в линейной памяти wasm. JS записывает их через представление один раз, вызовы фильтров пересекают границу с двумя скалярами, а vitest-гейт паритета держит реализации на Rust и JS байт-в-байт идентичными.

crates/kernel/src/lib.rs
#[wasm_bindgen]
pub struct PixelKernel {
    width: u32,
    height: u32,
    pixels: Vec<u8>,   // JS writes through a view over pixels_ptr() — once
    scratch: Vec<u8>,  // preallocated: no filter call may grow wasm memory
}

#[wasm_bindgen]
impl PixelKernel {
    /// Filter calls cross the boundary with two scalars, never with the frame.
    pub fn box_blur(&mut self, radius: u32) { /* (2r+1)² taps per pixel */ }
    pub fn grayscale(&mut self) { /* Rec.601 integer luma */ }
}

Сборка

Один скрипт на хосте, один эфемерный Rust-контейнер, артефакты в коммите. Воспроизводимость закреплена образом сборщика, запинованным по digest, rust-toolchain.toml и Cargo.lock — а закоммиченный манифест сборки красит гейт в красный, если исходники и артефакты разошлись.

scripts/wasm/build.sh
# scripts/wasm/build.sh — the v10r container stays Rust-free
RUST_IMAGE="docker.io/library/rust:1.97-slim@sha256:8e8cf8…"  # digest-pinned

podman run --rm -v "$PWD:/work" -w /work/crates/kernel "$RUST_IMAGE" bash -c '
    cargo build --release --target wasm32-unknown-unknown
    wasm-bindgen --target web --out-dir /work/src/lib/wasm/kernel \
      target/wasm32-unknown-unknown/release/v10r_kernel.wasm'

# kernel.js + kernel_bg.wasm (+ .d.ts) are COMMITTED, plus build-manifest.json:
# sha256 of sources and artifacts — a vitest gate recomputes them, so a Rust
# edit without a rebuild goes red. `bun run validate` never needs Rust.

Загрузчик

?url плюс явный вызов init обходит все известные расхождения dev-против-build для wasm в Vite и SvelteKit — без wasm-плагина, без top-level await, ничего не исполняется при SSR.

src/lib/wasm/index.ts
import wasmUrl from './kernel/kernel_bg.wasm?url';

let ready: Promise<Kernel> | null = null;

export function loadKernel(): Promise<Kernel> {
	// ?url + explicit init: no Vite wasm plugins, no top-level await (broken
	// under Svelte 5 — sveltejs/kit#13015), nothing executed during SSR, and
	// the binary ships as a hashed immutable asset.
	if (!ready) {
		ready = import('./kernel/kernel.js').then(async (mod) => {
			const out = await mod.default({ module_or_path: wasmUrl });
			return { mod, memory: out.memory };
		});
	}
	return ready;
}

Лаборатория фильтров

Один и тот же фильтр, реализованный строка в строку на Rust и JavaScript, над одним и тем же синтетическим кадром. Оба движка работают в одном воркере, порядок дорожек меняется каждый раунд, а контрольные суммы доказывают идентичность результата — алгоритм, вход, воркер и выход контролируются; остаются язык и его среда исполнения.

Лаборатория просыпается — эта демонстрация целиком работает в вашем браузере.

Налог на границу

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

Лаборатория просыпается — эта демонстрация целиком работает в вашем браузере.

Честное измерение

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

  • Разогревочные раунды сначала гоняют оба движка без замера — непрогретый JS исполняется в интерпретаторе, а не в JIT, и проигрывает по умолчанию.
  • Измеренные раунды сбалансированы — какой движок идёт первым, меняется каждый раунд; фиксированный порядок сохраняет позиционное смещение даже в специализированных библиотеках бенчмарков.
  • Медианы с разбросом мин–макс, никогда не средние — одна пауза GC не должна двигать главную цифру.
  • Единовременная цена загрузки + компиляции + инстанцирования показана, а не скрыта — это реальная цена внедрения wasm.
  • Копии через границу измеряются как отдельные стадии — считаются только победы от начала до конца.

Результаты зависят от движка: одна и та же работа может отличаться между браузерами почти на порядок. Страница показывает числа вашего движка, а не универсальную истину.

Когда Wasm выигрывает

Wasm — не волшебная пыль производительности: JavaScript тоже JIT-компилируется в машинный код, и прогретый JIT может догнать или обойти wasm на числовых циклах. Wasm оправдан, когда работа вычислительно плотная, а данные редко пересекают границу.

Берёт wasm

  • Плотные числовые ядра над типизированными массивами — свёртка, физика, обработка сигналов — с данными, резидентными в линейной памяти.
  • Предсказуемость: разброс wasm между прогонами обычно уже — компиляция заранее, без ярусов JIT и деоптимизаций, из которых можно выпасть.
  • Переиспользование готовой реализации на Rust или C++ вместо ручного портирования.

Остаётся на JavaScript

  • Работа со строками — каждая строка пересекает границу как транскодирование UTF-16 → UTF-8 плюс копия.
  • Болтливые API, копирующие буферы на каждый вызов — дорожка с маршалингом выше платит ровно за этот урок.
  • Графы объектов с интенсивной аллокацией — сборщики мусора движков глубоко оптимизированы под них, а wasm-модуль везёт и прогревает собственный аллокатор.

Этот репозиторий уже гоняет wasm в продакшене — на сервере: движок Oniguruma в shiki подсветил каждый блок кода на этой странице примерно в пять раз быстрее своего чисто-JS фолбэка.

Думаете, этот паттерн можно сделать лучше? Расскажите как.

Оставить отзыв