Rechenlast im Browser

Von Rust zu WebAssembly: der Weg in den Browser

Der Build ist in einer halben Stunde eingerichtet. Ob sich der Umbau lohnt, entscheidet sich daran, wie oft Daten die Grenze zwischen JavaScript und WebAssembly überqueren.

KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Seit 1997 am Markt Kleine Gruppen Präsenz und Live-Online Zertifizierte Trainer
Worum es geht

Der Engpass sitzt selten dort, wo WebAssembly hilft

Der Anlass ist fast immer derselbe: Eine Ansicht ruckelt, ein Export dauert acht Sekunden, ein Filter über zweihunderttausend Zeilen blockiert den Hauptthread. Danach kommt schnell der Vorschlag, den Teil in Rust neu zu schreiben und als WebAssembly einzubinden. Was dabei oft übersprungen wird, ist die Messung: Ein Profil im Browser zeigt in fünf Minuten, ob die Zeit tatsächlich in der Rechnung steckt oder im Neuaufbau des Baums, im Warten auf eine Antwort oder in einem Zustandsspeicher, der bei jeder Änderung alles neu bewertet.

Die zweite Fehlannahme betrifft die Geschwindigkeit selbst. WebAssembly ist nicht automatisch schneller als JavaScript. Die Übersetzer moderner Browser erzeugen für gut geschriebenen Zahlencode sehr guten Maschinencode, und gegen eine eingespielte Schleife über ein typisiertes Feld gewinnt ein WebAssembly-Modul nicht durch bloße Anwesenheit. Was WebAssembly zuverlässig liefert, ist ein vorhersagbarer Verlauf ohne Rückfall auf einen langsameren Pfad und ohne Pausen der Speicherbereinigung, und die Möglichkeit, vorhandenen Rust- oder C-Code unverändert im Browser laufen zu lassen.

Am teuersten ist die dritte Falle, weil sie erst nach der Umstellung auffällt. Wer die Rechnung nach Rust verlagert, aber die Schnittstelle fein zerlegt lässt, also für jedes Element einen Aufruf macht und jedes Mal eine Zeichenkette hinüberreicht, bezahlt für jede Überquerung mit einer Umwandlung und einer Kopie. Am Ende steht dann ein Modul, das mehr Arbeit macht als der ersetzte JavaScript-Code, und die Fehlersuche beginnt an der falschen Stelle.

KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Schritt für Schritt

Der Ablauf, Schritt für Schritt

Sieben Stationen, von der Messung bis zur Fehlersuche im Browser. Die Reihenfolge ist nicht beliebig: Wer den Zuschnitt der Grenze erst nach dem ersten Build klärt, baut zweimal.

  1. 1

    Den Kandidaten messen und eingrenzen

    Nimm ein Profil in den Entwicklerwerkzeugen des Browsers auf und suche die Funktion, in der die Zeit tatsächlich verbracht wird. Geeignet ist eine Stelle, die viel rechnet, wenig mit dem Dokument spricht und eine klare Ein- und Ausgabe hat, also etwa ein Bildfilter, ein Parser oder eine Simulationsschleife.

    Geschafft, wenn: Eine benannte Funktion mit gemessener Laufzeit, an der sich später vergleichen lässt.

  2. 2

    Crate anlegen und Ausgabeform festlegen

    cargo new --lib erzeugt das Projekt. In der Cargo.toml setzt du crate-type auf ["cdylib", "rlib"], nimmst wasm-bindgen als Abhängigkeit auf und legst ein Release-Profil mit opt-level, lto und panic gleich mit an. Der rlib-Anteil ist kein Beiwerk, er erlaubt dir, dieselbe Logik in gewöhnlichen Rust-Tests auf dem Rechner zu prüfen.

    Geschafft, wenn: cargo test läuft nativ durch, obwohl das Ziel später der Browser ist.

  3. 3

    Die Grenze definieren

    Setz #[wasm_bindgen] nur an die wenigen Funktionen und Typen, die von außen erreichbar sein sollen. Halte die Signaturen grob: ein Aufruf mit einem &[u8] oder einem Vec<f32> statt vieler Aufrufe mit Einzelwerten. Für Zugriffe auf Browserschnittstellen brauchst du web-sys, und dort ist jede Schnittstelle ein eigenes Feature, das du in der Cargo.toml einschaltest.

    Geschafft, wenn: Die öffentliche Fläche des Moduls passt auf eine Bildschirmseite.

  4. 4

    Mit wasm-pack bauen

    wasm-pack build --release --target bundler erzeugt ein Verzeichnis pkg mit dem Modul, der erzeugten JavaScript-Anbindung, den TypeScript-Deklarationen und einer package.json. Das Ziel entscheidet über die Einbindung: bundler für Vite oder webpack, web für einen direkten Import im Browser mit ausdrücklichem init, nodejs für den Serverbetrieb.

    Geschafft, wenn: Im Verzeichnis pkg liegen .wasm, .js und .d.ts mit den erwarteten Signaturen.

  5. 5

    In das bestehende Projekt einbinden

    Trag das Paket als Abhängigkeit auf den lokalen Pfad ein oder veröffentliche es in eure interne Registry. Beim Ziel bundler übernimmt das Bündelwerkzeug den Rest, beim Ziel web rufst du einmal die exportierte init-Funktion auf und wartest sie ab, bevor du das Modul benutzt. Weil dieser erste Schritt asynchron ist, gehört er in den Ladepfad der Anwendung und nicht mitten in eine Ereignisbehandlung.

    Geschafft, wenn: Die Anwendung startet ohne Konsolenfehler und die TypeScript-Typen greifen in der Entwicklungsumgebung.

  6. 6

    Die Modulgröße drücken

    wasm-pack ruft wasm-opt aus dem Binaryen-Werkzeugkasten bereits mit auf. Den größeren Hebel hast du im Code: opt-level auf "z" oder "s", lto einschalten, panic auf "abort" setzen und Abhängigkeiten meiden, die Formatierung, reguläre Ausdrücke oder Zeitzonendaten mitziehen. twiggy top zeigt dir, welche Symbole tatsächlich Platz belegen, statt dass du raten musst.

    Geschafft, wenn: Die ausgelieferte .wasm-Datei liegt in einer Größenordnung, die du gegenüber dem Frontend-Team vertreten kannst.

  7. 7

    Fehlersuche einrichten

    Binde console_error_panic_hook in den Entwicklungsbuilds ein, damit ein Panic als lesbare Meldung samt Aufrufliste in der Konsole landet statt als unspezifischer Fehler. Für Tests im Browser gibt es wasm-bindgen-test, das die Prüfungen in einem kopflosen Chrome oder Firefox ausführt und damit auch Code abdeckt, der Browserschnittstellen anspricht.

    Geschafft, wenn: Ein absichtlich ausgelöster Panic zeigt Datei und Zeile aus dem Rust-Code in der Browserkonsole.

Sechs Bausteine vom Rust-Crate zum geladenen Modul

  1. 01 Ein Bibliotheks-Crate mit crate-type cdylib bildet den Ausgangspunkt.
  2. 02 wasm-bindgen markiert die wenigen Funktionen, die nach außen sichtbar sind.
  3. 03 wasm-pack baut daraus Modul, JavaScript-Anbindung und Typdeklarationen.
  4. 04 wasm-opt schrumpft das Ergebnis, bevor es in das Bündel wandert.
  5. 05 Das Projekt lädt das Paket wie jede andere npm-Abhängigkeit.
  6. 06 Der Server liefert die Datei als application/wasm, sonst entfällt der schnelle Start.
Was du mitnimmst

Danach schneidest du die Grenze so zu, dass der Gewinn übrig bleibt

Der Werkzeugteil ist gut abgehangen und in wenigen Schritten erledigt. Die Entscheidungen, die den Unterschied machen, betreffen den Zuschnitt: welcher Teil überhaupt hinüberwandert, wie grob die Aufrufe geschnitten sind und wie groß das Modul werden darf, das jeder Besucher lädt.

Vorher messen

Du liest ein Browserprofil und weißt danach, ob die Zeit in der Rechnung liegt oder im Neuaufbau der Oberfläche. Nur im ersten Fall bringt ein Modul überhaupt etwas.

Die Schnittstelle grob schneiden

Du reichst einen großen Puffer einmal hinüber statt zehntausend kleine Werte einzeln, und du weißt, welche Typen dabei kopiert werden und welche nur als Zahl über die Grenze gehen.

Den Build einrichten

Du kennst die Aufgabenteilung zwischen wasm-bindgen und wasm-pack und wählst das passende Ziel, je nachdem ob ein Bündelwerkzeug, ein direkter Import im Browser oder Node das Ergebnis lädt.

Auf die Modulgröße achten

Du erkennst die Abhängigkeiten, die ein kleines Modul auf mehrere hundert Kilobyte aufblähen, und findest mit twiggy die Funktionen, die den Platz belegen.

Fehler sichtbar machen

Du sorgst dafür, dass ein Panic in der Konsole als lesbare Meldung mit Aufrufliste erscheint, statt als unspezifischer Fehler aus dem Glue-Code.

Den Aufwand abwägen

Du kannst begründen, warum Bildverarbeitung, Simulation, Parser und Kryptografie sich eignen und warum eine formularlastige Ansicht in JavaScript schneller fertig ist.

KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt

Was ein WebAssembly-Modul kann und was nicht

WebAssembly ist ein binäres Befehlsformat, das dieselbe Maschine ausführt, die auch das JavaScript der Seite ausführt. Ein Modul bekommt einen linearen Speicherbereich, ein zusammenhängendes Feld aus Bytes, und mehr sieht es von der Welt zunächst nicht. Es gibt keinen direkten Zugriff auf das Dokument, keine Netzwerkaufrufe und keinen Zugriff auf Speicher außerhalb dieses Bereichs. Jede Berührung mit dem Browser läuft über importierte Funktionen, und genau die erzeugt wasm-bindgen für dich.

Zwei Dinge, die im Serverumfeld selbstverständlich sind, brauchen hier Vorbereitung. Threads sind kein cargo build entfernt: Der Browser stellt SharedArrayBuffer nur bereit, wenn der Server die Seite mit den passenden Isolationskopfzeilen ausliefert, und auf der Rust-Seite kommen die Zielmerkmale atomics und bulk-memory sowie eine eigens übersetzte Standardbibliothek dazu. Rechne das als eigenen Posten ein. Vektorbefehle stehen als SIMD-Erweiterung bereit, müssen aber beim Übersetzen ausdrücklich freigeschaltet werden, und das erzeugte Modul läuft dann nur dort, wo die Erweiterung unterstützt wird.

Nicht Gegenstand dieser Seite, aber der häufigste Anschlussgedanke: Dieselben Module lassen sich außerhalb des Browsers ausführen, mit einer Systemschnittstelle statt der Browserumgebung. Das ist ein eigenes Ziel-Tripel und eine eigene Werkzeugkette, und wer Rust auf dem Server einsetzen will, fährt in aller Regel besser mit einer gewöhnlichen nativen Binärdatei.

wasm-bindgen und wasm-pack, und wer was macht

wasm-bindgen ist zweierlei: eine Crate mit einem Makro und ein Kommandozeilenwerkzeug, das nach dem Übersetzen läuft. Das Makro merkt sich, welche Funktionen und Typen die Grenze überqueren sollen, das Werkzeug erzeugt daraus die JavaScript-Anbindung und die TypeScript-Deklarationen und übernimmt die Umwandlung der Typen in beide Richtungen. Dazu gehören zwei Begleiter: js-sys bildet die eingebauten JavaScript-Typen ab, also Array, Promise, Date und Reflect, und web-sys die Browserschnittstellen von document über fetch bis WebGL.

Bei web-sys stolpern fast alle über dieselbe Stelle. Die Crate deckt praktisch die vollständige Browserschnittstelle ab, und damit euer Modul nicht davon aufgebläht wird, ist jede einzelne Schnittstelle ein Feature. Wer CanvasRenderingContext2d benutzen will, trägt genau dieses Feature in der Cargo.toml ein, sonst meldet der Compiler den Typ als unbekannt. Das wirkt beim ersten Mal umständlich und ist genau der Grund, warum die erzeugten Module klein bleiben.

wasm-pack ist der Dirigent darüber. Ein Aufruf übersetzt für das Ziel wasm32-unknown-unknown, ruft das wasm-bindgen-Werkzeug auf, schickt das Ergebnis durch wasm-opt und schnürt daraus ein npm-Paket mit package.json und Typdeklarationen. Für ein Frontend-Team ist das der entscheidende Teil, denn das Ergebnis lässt sich wie jede andere Abhängigkeit einbinden, ohne dass jemand Rust auf dem Rechner braucht.

An der Grenze verliert man die Geschwindigkeit wieder

Zahlen kosten nichts. Ein f64 oder ein i32 wandert als Wert über die Grenze und ist damit erledigt. Alles andere wird umgeformt. Eine Rust-Zeichenkette liegt als UTF-8 im linearen Speicher, eine JavaScript-Zeichenkette liegt in der Engine und ist anders kodiert, also wird bei jeder Übergabe kopiert und umgewandelt. Ein Vec<u8>, den du zurückgibst, wird in ein neues typisiertes Feld kopiert. Ein struct wird in ein Handle verpackt, hinter dem der eigentliche Wert im Rust-Speicher bleibt. Nichts davon ist teuer bei zehn Aufrufen pro Sekunde, alles davon ist ruinös bei zehntausend.

Die Regel, die daraus folgt: Halte die Zahl der Überquerungen klein und die Datenmenge je Überquerung groß. Statt für jeden Bildpunkt eine Funktion zu rufen, übergibst du den ganzen Puffer einmal und bekommst ihn einmal zurück. Noch besser ist der Weg ganz ohne Kopie: Du legst dir auf JavaScript-Seite eine Sicht auf den linearen Speicher des Moduls an und liest das Ergebnis direkt aus dem Bereich, den Rust beschrieben hat. Für die Bildausgabe auf ein Canvas ist das der übliche Aufbau.

Dabei lauert der Fehler, der die meiste Suchzeit kostet. Wenn das Modul zur Laufzeit mehr Speicher anfordert, wird der zugrunde liegende Puffer ersetzt, und jede JavaScript-Sicht darauf ist danach abgekoppelt und liefert keine Daten mehr. Wer sich die Sicht einmal beim Start merkt und wiederverwendet, bekommt nach der ersten Speichererweiterung leere Bilder oder eine Ausnahme, ohne dass sich am Rust-Code irgendetwas geändert hätte. Die Sicht gehört deshalb nach jeder Anforderung neu geholt, oder der Speicherbedarf wird von vornherein festgelegt.

Ein Modul, das niemand lädt, hat auch nichts beschleunigt

Die Größe entscheidet mit darüber, ob der Umbau am Ende als Verbesserung wahrgenommen wird. Ein reiner Rechenkern ohne Formatierung, ohne Fehlermeldungen und ohne fremde Abhängigkeiten liegt in einer Größenordnung von wenigen zehn Kilobyte. Sobald aber Panic-Meldungen aufbereitet, reguläre Ausdrücke ausgewertet, Zeitzonen aufgelöst oder JSON über eine allgemeine Serialisierung geführt werden, wächst dasselbe Modul schnell um ein Vielfaches. Die Ursache steht selten in eurem Code, sie steht in der Abhängigkeitsliste.

Drei Griffe holen den größten Teil zurück. Im Release-Profil opt-level auf "z" oder "s", lto einschalten und panic auf "abort" setzen, womit die gesamte Abwicklungsmaschinerie entfällt. Danach zeigt twiggy top die Symbole nach Platzbedarf sortiert, und die Liste ist regelmäßig überraschend: Oft stehen ganz oben nicht die Rechenfunktionen, sondern die Aufbereitung von Fehlertexten. Den Panic-Hook, der die Meldungen lesbar macht, willst du deshalb in der Entwicklung, nicht im Auslieferungsbuild.

Der letzte Schritt liegt beim Server und wird oft übersehen. Die Datei muss mit dem Inhaltstyp application/wasm ausgeliefert werden, sonst kann der Browser sie nicht schon beim Herunterladen übersetzen und fällt auf den langsameren Weg zurück. Dazu gehört Kompression über die Leitung, die bei einem Binärformat wie diesem deutlich mehr bringt, als die Zahl im Verzeichnis vermuten lässt.

Wann sich der Aufwand rechnet, und wann nicht

Es lohnt sich, wenn drei Dinge zusammenkommen: eine Rechnung, die spürbar Zeit braucht, eine Schnittstelle, die sich grob schneiden lässt, und ein Ergebnis, das man messen kann. Typische Fälle sind Bild- und Videobearbeitung im Browser, physikalische Simulationen, Parser für eigene oder sperrige Formate, Kompression und Kryptografie. Ein weiterer, oft unterschätzter Fall ist der Umzug von bestehendem Code: Wer eine Bibliothek in Rust oder C bereits hat und sie im Browser braucht, spart sich die Zweitimplementierung und die dauerhafte Pflege zweier Fassungen, die auseinanderlaufen.

Es lohnt sich nicht, wenn die Arbeit im Dokument liegt. Jeder Zugriff auf das Dokument läuft über die Anbindung zurück nach JavaScript, sodass ein Modul, das viele Knoten anfasst, mehr Überquerungen erzeugt, als es Rechenzeit spart. Ebenso wenig lohnt es sich bei Aufgaben, die von Wartezeit auf das Netz bestimmt sind, bei Verbindungslogik zwischen Bibliotheken und bei kleinen Feldoperationen, für die der eingebaute Übersetzer längst guten Code erzeugt.

Und es lohnt sich nicht, wenn im Team niemand Rust kann. Der Build ist eingerichtet, aber die Wartung liegt danach dauerhaft bei den Leuten, die auch das Frontend pflegen. Ein Modul, das ein halbes Jahr nach dem Einbau niemand mehr anfassen mag, ist teurer als die langsamere Fassung in JavaScript.

Dazu passende Kurse

Die Werkzeuge sind schnell eingerichtet, der Zuschnitt der Schnittstelle nicht, und genau daran arbeiten Rust-Kurse rund um WebAssembly .

Weil die Einbindung am Ende im Bündelwerkzeug und im Ladepfad landet, gehören JavaScript-Kurse für das Team im Frontend in dieselbe Planung.

Wissen prüfen

Wie sicher bist du beim Thema wirklich?

Lesen fühlt sich schnell nach Können an. Ein kurzer Test zeigt dir, was davon schon sitzt und wo sich ein Kurs lohnt. Kostenlos, ohne Anmeldung, mit einer Erklärung zu jeder Antwort.

Sehr angenehmes, gut vorbereitetes Seminar, kann ich nur empfehlen.
Rust Fortgeschrittenenkurs

Häufige Fragen

Ist WebAssembly schneller als JavaScript?
Nicht pauschal. Für gut geschriebenen Zahlencode erzeugen moderne Browser sehr guten Maschinencode, und dagegen gewinnt ein Modul nicht allein dadurch, dass es WebAssembly ist. Verlässlich besser ist der Verlauf: kein Rückfall auf einen langsameren Pfad, keine Pausen der Speicherbereinigung, gleichmäßige Laufzeiten. Der zweite und oft größere Gewinn ist, dass vorhandener Rust- oder C-Code ohne Zweitimplementierung im Browser läuft.
Kann ich aus Rust direkt auf das Dokument zugreifen?
Nicht unmittelbar. WebAssembly sieht nur seinen eigenen linearen Speicher, jeder Zugriff auf das Dokument läuft über importierte JavaScript-Funktionen. Die Crate web-sys stellt diese Aufrufe getypt bereit, sodass es sich im Rust-Code wie ein direkter Zugriff liest. Der Kostenpunkt bleibt trotzdem: Jeder Aufruf ist eine Überquerung, deshalb gehört Arbeit am Dokument in der Regel weiterhin auf die JavaScript-Seite.
Wie groß darf so ein Modul werden?
Das entscheidet euer Ladebudget, nicht die Technik. Wichtiger als eine Zielzahl ist zu wissen, wodurch die Größe entsteht: Panic-Aufbereitung, Formatierung, reguläre Ausdrücke und allgemeine Serialisierung sind die üblichen Verursacher. Mit opt-level "z", lto, panic gleich abort und einem Blick in twiggy top siehst du innerhalb weniger Minuten, welcher Anteil vermeidbar ist.
Brauchen die Leute im Frontend danach Rust auf dem Rechner?
Nein, wenn ihr das Ergebnis als npm-Paket bereitstellt. wasm-pack erzeugt genau das, mit package.json und TypeScript-Deklarationen, und das Paket lässt sich wie jede andere Abhängigkeit einbinden. Rust braucht nur, wer das Modul selbst ändert, und der Bau gehört dann in eure Pipeline und nicht auf einzelne Arbeitsplätze.
Persönlich für dich da

Deine Ansprechpartner

Du bist dir nicht sicher, welcher Kurs oder welches Level zu dir passt? Wir beraten dich persönlich und kostenlos.

Yves Hoppe

Yves Hoppe

Weiterbildung & Beratung

Hilft dir, aus dem Rust-Programm den passenden Kurs für deinen Stand zu finden.

Norbert Jansen

Norbert Jansen

Beratung & Inhouse

Plant mit dir Inhouse-Trainings, die auf eure Abläufe und euren Datenbestand zugeschnitten sind.

Erst die Grenze richtig schneiden, dann rechnet sich das Modul

Wie ein Rechenkern aussieht, der im Browser trägt, und woran du die Überquerungen erkennst, arbeitest du bei cmt an einem echten Beispiel durch.