Zum Hauptinhalt springen

Das Pattern

Ein Rust-Crate, kompiliert zu einem 20-KB-Wasm-Modul, ins Repo eingecheckt und ohne einen einzigen Vite-Plugin geladen. Der Dev-Container braucht nie eine Rust-Toolchain: ein Wegwerf-Builder-Container kompiliert das Crate, und Glue-Code samt Binary werden wie normaler Quellcode committet.

Der Kernel

Die Pixel liegen im linearen Wasm-Speicher. JS schreibt sie einmal durch eine View, Filteraufrufe überqueren die Grenze mit zwei Skalaren, und ein Vitest-Paritätsgate hält die Rust- und JS-Implementierungen byte-identisch.

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 */ }
}

Der Build

Ein Host-Skript, ein ephemerer Rust-Container, committete Artefakte. Reproduzierbarkeit ist gepinnt über ein Digest-gepinntes Builder-Image, rust-toolchain.toml und Cargo.lock — und ein committetes Build-Manifest lässt das Gate rot werden, wenn Quellen und Artefakte auseinanderlaufen.

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.

Der Loader

?url plus ein expliziter init-Aufruf umgeht jede bekannte Dev-vs-Build-Divergenz von Wasm in Vite und SvelteKit — kein Wasm-Plugin, kein Top-Level-Await, nichts läuft während 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;
}

Filter-Labor

Derselbe Filter, Zeile für Zeile in Rust und JavaScript implementiert, über demselben synthetischen Frame. Beide Engines laufen im selben Worker, die Spur-Reihenfolge wechselt jede Runde, und Prüfsummen belegen identische Ausgaben — Algorithmus, Eingabe, Worker und Ausgabe sind kontrolliert; was bleibt, sind die Sprache und ihre Laufzeit.

Das Labor wacht auf — diese Demo läuft vollständig in deinem Browser.

Die Boundary-Steuer

Eine Multiplikation pro Element über eine Million Floats — fast keine Rechenarbeit pro bewegtem Byte. Drei Spuren: pures JS, Wasm, das das Array bei jedem Aufruf über die Grenze kopiert, und Wasm mit residenten Daten im linearen Speicher. Dass Marshalling gegen pures JS verliert, ist der Punkt.

Das Labor wacht auf — diese Demo läuft vollständig in deinem Browser.

Ehrliches Messen

Ein Benchmark, der Wasm schmeichelt, ist leicht: kaltes JS gegen warmes Wasm messen, die Kopien verstecken, den besten Lauf berichten. Jede Zahl auf dieser Seite folgt stattdessen fünf Regeln:

  • Aufwärmrunden lassen beide Engines zuerst ungemessen laufen — nicht aufgewärmtes JS läuft im Interpreter statt im JIT und verliert per Default.
  • Gemessene Runden sind ausbalanciert — welche Engine zuerst läuft, wechselt jede Runde; eine feste Reihenfolge behält Positions-Bias selbst in dedizierten Benchmark-Bibliotheken.
  • Mediane mit Min–Max-Streuung, nie Mittelwerte — eine GC-Pause darf die Schlagzeile nicht bewegen.
  • Die einmaligen Kosten für Fetch + Kompilieren + Instanziieren werden gezeigt, nicht versteckt — sie sind der reale Preis von Wasm.
  • Boundary-Kopien werden als eigene Phasen gemessen — nur Ende-zu-Ende-Siege zählen.

Ergebnisse sind engine-abhängig: dieselbe Arbeit kann sich zwischen Browsern um fast eine Größenordnung unterscheiden. Diese Seite zeigt die Zahlen deiner Engine, keine universelle Wahrheit.

Wann Wasm gewinnt

Wasm ist kein Performance-Feenstaub — auch JavaScript JIT-kompiliert zu Maschinencode, und ein aufgewärmter JIT kann Wasm bei numerischen Schleifen erreichen oder schlagen. Wasm verdient seinen Platz, wenn die Arbeit rechenintensiv ist und die Daten die Grenze selten überqueren.

Greift zu Wasm

  • Dichte numerische Kernel über Typed Arrays — Faltung, Physik, Signalverarbeitung — mit residenten Daten im linearen Speicher.
  • Vorhersagbarkeit: Wasms Streuung von Lauf zu Lauf ist typischerweise enger — Ahead-of-Time-Kompilierung, keine JIT-Stufen oder Deopts, aus denen man fallen kann.
  • Eine bestehende Rust- oder C++-Implementierung wiederverwenden, statt sie von Hand zu portieren.

Bleibt bei JavaScript

  • String-lastige Arbeit — jeder String überquert die Grenze als UTF-16-→-UTF-8-Transcode plus Kopie.
  • Gesprächige APIs, die Buffer pro Aufruf kopieren — die Marshalling-Spur oben zahlt für genau diese Lektion.
  • Allokationslastige Objektgraphen — Engine-GCs sind tief für sie optimiert, während ein Wasm-Modul seinen eigenen Allocator mitbringen und aufwärmen muss.

Dieses Repo betreibt Wasm bereits produktiv — serverseitig: Shikis Oniguruma-Engine hat jeden Codeblock dieser Seite etwa fünfmal schneller hervorgehoben als ihr reiner JS-Fallback.

Geht dieses Pattern noch besser? Sag uns, wie.

Feedback geben