Web-Engineering

WebAssembly (WASM) in modernen Web-Apps: ein Leitfaden

Wie aus C/C++ und Rust kompilierte Binärdateien im Browser nahezu native Leistung erreichen.

Was WebAssembly genau ist

WebAssembly ist ein binäres Befehlsformat und ein Kompilierungsziel. Von Hand schreibt man es selten. Ein Compiler nimmt C, C++, Rust, Zig oder Go und erzeugt daraus ein Modul, und der Browser validiert dieses Modul und übersetzt es in Maschinencode. Das Format ist bewusst klein und bewusst langweilig: eine Stackmaschine, strukturierter Kontrollfluss statt beliebiger Sprünge und ein Typsystem, das so streng ist, dass die Validierung ein einziger linearer Durchlauf über die Bytes ist. Genau diese Einschränkungen sind der Grund, warum ein Browser ein Modul schon kompilieren kann, während er es noch herunterlädt.

Was es nicht ist: ein Ersatz für JavaScript. Ein Modul hat keinen Zugriff auf das DOM, keinen Netzwerkzugriff und keine Systemaufrufe. Es kann rechnen, seinen eigenen Speicher lesen und beschreiben und Funktionen aufrufen, die der Host ausdrücklich in das Modul importiert. Alles andere bleibt Aufgabe von JavaScript. In der Praxis liefern Sie beides aus: ein Modul, das die Berechnung erledigt, und eine dünne JavaScript-Schicht, die ihm die Bytes zuführt und die Ergebnisse wieder herausholt.

Warum Codec-Arbeit in WebAssembly gehört

Das übliche Argument ist Geschwindigkeit, und diese Sichtweise verdeckt das bessere. Moderne JavaScript-Engines sind schnell, manchmal verblüffend schnell, aber sie erreichen das durch Spekulation. Der JIT beobachtet, welche Typen durch eine Funktion laufen, kompiliert eine spezialisierte Version und sichert sie mit Guards ab. Bekommt er einen Wert, den er nicht erwartet hat, schlägt der Guard fehl, der Code wird deoptimiert, und die Ausführung fällt auf einen langsameren Pfad zurück, bis sie sich erholt. In Anwendungscode merkt man davon nichts. In der inneren Schleife eines Decoders ist es ein Hänger mitten in einem Frame.

Ein WebAssembly-Modul trägt seine Typen im Binärformat mit. Der Compiler kennt die Form jedes Werts, bevor irgendetwas läuft, es gibt also nichts zu raten und später nichts zu verwerfen. Linearer Speicher unterliegt auch keiner Garbage Collection, daher muss der heiße Pfad keine Sammelpausen einplanen. Beim Spitzendurchsatz liegen beide oft näher beieinander, als man denkt. Der Abstand entsteht bei der Gleichmäßigkeit, und Gleichmäßigkeit ist das, was Nutzer tatsächlich spüren, wenn ein Fortschrittsbalken stetig vorankommt, statt zu ruckeln. Die 128-Bit-SIMD-Befehle mit fester Breite helfen dabei ebenfalls, denn Schleifen über Pixel und über Audio-Samples haben genau die Form, die sich gut vektorisieren lässt.

Und dann gibt es den Grund, der mit Leistung nichts zu tun hat. Bild- und Videocodecs, PDF-Renderer, OCR-Engines und Spracherkennungsmodelle gibt es längst als ausgereifte C- und C++-Bibliotheken, die jahrelang Fehlerberichte und Grenzfälle mit fehlerhaften Eingaben aufgefangen haben. libjpeg oder Tesseract in JavaScript neu zu schreiben ist kein Wochenendprojekt, und das Ergebnis wäre lange Zeit schlechter als das Original. Mit WebAssembly kompilieren Sie das Vorhandene und liefern es aus. Deshalb ist der meiste WebAssembly-Code im Produktivbetrieb genau dafür da, und das ist ein belastbarerer Grund als jeder Benchmark.

Linearer Speicher und warum ein Tab eine Obergrenze hat

Der Speicher eines Moduls ist ein einziger zusammenhängender Puffer. Zeiger innerhalb des Moduls sind Offsets in diesen Puffer, der in Seiten von 64 KiB wächst, und unter wasm32 sind diese Zeiger 32 Bit breit, was den adressierbaren Speicher auf 4 GiB begrenzt. Browser hören deutlich früher auf, Smartphones noch viel früher. Daraus folgen drei Konsequenzen, und genau die machen im Produktivbetrieb Ärger.

Wachstum geht praktisch nur in eine Richtung. memory.grow vergrößert den Puffer, aber wenn der Allokator des Moduls einen Block freigibt, geht dieser Block an den Heap des Moduls zurück und nicht an den Browser. Ein einziger großer Auftrag hebt also den Höchststand des Tabs an, solange diese Instanz lebt. Wenn Sie große Dateien direkt hintereinander verarbeiten, ist es zuverlässiger, die Instanz zwischen den Aufträgen zu verwerfen und eine neue zu erzeugen, als darauf zu vertrauen, dass free irgendetwas zurückgibt.

Wachstum braucht außerdem eine zusammenhängende Reservierung. Ein grow kann fehlschlagen, obwohl der Rechner noch reichlich freien Arbeitsspeicher hat, nur weil der Adressraum fragmentiert ist. Das zeigt sich vor allem bei 32-Bit-Builds und auf Smartphones mit wenig Speicher, und es kommt als Exception mitten in einem Vorgang statt als höfliche Absage vorab.

Und schließlich müssen Daten über die Grenze kopiert werden. JavaScript kann einem Modul kein File-Objekt übergeben. Sie lesen die Datei in einen ArrayBuffer, kopieren diese Bytes in den linearen Speicher, führen die Operation aus und kopieren das Ergebnis wieder heraus, um daraus einen Blob zu bauen. Für einen Moment halten Sie die Eingabe doppelt, dazu kommt der Arbeitsspeicher, den der Codec selbst braucht. Planen Sie ein Mehrfaches der Dateigröße ein statt der einfachen Größe, setzen Sie in der Oberfläche eine ausdrückliche Größenbeschränkung und verarbeiten Sie als Stream, wo das Format es zulässt. Eine Absage, die Sie selbst geschrieben haben, ist besser als ein Absturz, den Sie nicht geschrieben haben.

Die Binärdatei, die Sie ausliefern müssen

Ein ernst zu nehmender Codec, nach WebAssembly kompiliert, wird in Megabyte gemessen, und er lässt sich nicht so komprimieren wie JavaScript. Minifiziertes JavaScript ist sich wiederholender Text, und gzip liebt so etwas. Ein kompiliertes Modul ist bereits kompaktes Binärformat, daher ist das Verhältnis schlechter, auch wenn Brotli hilft. Bei Machine-Learning-Modellen ist es noch schwieriger: Neben den Gewichten ist die Laufzeitumgebung ein Rundungsfehler. Die Regel, die daraus folgt, ist einfach, und trotzdem wird sie gebrochen. Laden Sie das Modul nicht beim Laden der Seite. Laden Sie es, wenn sich der Nutzer für die Operation entschieden hat.

Ein paar Mechanismen machen dieses verzögerte Laden weniger schmerzhaft. Liefern Sie die Datei als application/wasm aus, damit WebAssembly.instantiateStreaming kompilieren kann, während die Bytes eintreffen, statt erst den ganzen Download zu puffern, denn ein falscher MIME-Typ nimmt Ihnen genau das, ohne dass es auffällt. Geben Sie der Datei eine URL mit Inhalts-Hash und einen Cache-Header mit immutable, damit ein wiederholter Besuch nichts kostet. Browser cachen bei größeren Modulen außerdem den kompilierten Code, sodass wiederkehrende Besucher neben dem Download auch die Kompilierung überspringen können.

Diese Website ist genau darauf ausgelegt. Im ersten Seitenaufbau steckt nichts, was nach WebAssembly aussieht. Das OCR-Tool bindet seine Engine erst ein, wenn Sie tatsächlich eine OCR starten, und der Transkriptions-Worker holt sein Spracherkennungsmodell beim ersten Transkribieren und verlässt sich danach auf den Browser-Cache. Wer ein JPG in PNG umwandelt und wieder geht, lädt davon nichts herunter, weil dieser Weg WebAssembly überhaupt nicht berührt. Es ist ein Zeichenaufruf auf einem Canvas und ein Encode-Aufruf, beides erledigt vom nativen Code des Browsers selbst.

Wann WebAssembly die falsche Antwort ist

Es hat keinen Zugriff auf das DOM, also geht jede DOM-Operation über Glue-Code zurück zu JavaScript. Ein UI-Framework, das nach WebAssembly kompiliert wurde, zahlt diesen Zoll bei jeder Interaktion. Der Übergang ist billig, aber nicht kostenlos, und er passiert ständig.

Strings sind aus demselben Grund umständlich. Es gibt keinen gemeinsamen String-Typ, also wird Text an der Grenze kodiert, kopiert und dekodiert. Eine Arbeitslast, die hauptsächlich aus String-Verarbeitung besteht, kann mehr Zeit mit dem Hin- und Herreichen von Daten verbringen als mit Rechnen.

Kurze Aufgaben holen die Investition selten wieder herein. Herunterladen, Kompilieren und Instanziieren kosten echte Zeit. Wenn die Berechnung selbst nur Millisekunden dauert, holt das Modul das nie wieder auf.

Und wenn die Plattform die Aufgabe schon in nativem Code erledigt, ist eine eigene Kopie ein Rückschritt. WebCodecs kodiert und dekodiert Video. Web Audio kümmert sich um Audio-Graphen. Canvas dekodiert, skaliert und kodiert Bilder neu. Einen Resampler in ein Megabyte WebAssembly zu verpacken, um das zu tun, was drawImage bereits kann, ist die deutlichste Form dieses Fehlers.

Ein Punkt ist weniger eine Einschränkung als eine Falle. WebAssembly-Aufrufe sind synchron. Ein lang laufender Aufruf blockiert den Thread, auf dem er läuft, und auf dem Hauptthread heißt das: eine eingefrorene Seite, genau wie bei einer langen JavaScript-Schleife. Schwere Module gehören in einen Worker. Das ist kein optionaler Rat.

Fragen, die Sie vorher beantworten sollten

  • Löst eine ausgereifte native Bibliothek das Problem bereits korrekt? Das ist der stärkste Grund, sie zu kompilieren, und der schwächste, sie in JavaScript neu zu implementieren.
  • Ist die Arbeit numerisch, in sich geschlossen und lang genug, um Download und Kompilierung zu amortisieren? Enge Schleifen über typisierte Puffer sind die Form, die gewinnt.
  • Deckt eine Browser-API das schon ab? Wenn WebCodecs, Web Audio oder Canvas den Fall lösen, nutzen Sie diese und liefern Sie nichts aus.
  • Kann die Binärdatei warten, bis der Nutzer die Operation anfordert, und können Sie mit dem Speicher leben, den die Instanz für den Rest der Lebensdauer des Tabs belegt?
  • Kann es in einem Worker laufen? Wenn es auf dem Hauptthread laufen muss und länger als ein Frame dauert, besteht die Lösung darin, es zu verlagern, nicht darin, es zu optimieren.

Die Technik verliert ihre Dramatik, sobald Sie nicht mehr erwarten, dass sie JavaScript ersetzt. Sie ist ein Weg, vorhandenen nativen Code in einer Sandbox auszuführen, die nie dafür gedacht war, mit einem Speichermodell, das Sie respektieren müssen, und einem Download, den Sie bezahlen müssen. Behandeln Sie diese beiden Kosten vom ersten Tag an als Designvorgaben, und der Rest ist gewöhnliche Ingenieursarbeit.