Hot Path im JavaScript-Code der App durch WebAssembly ersetzen

Es geht immer schnell.

In meiner vorherigen articles habe ich darüber gesprochen, wie WebAssembly ermöglicht Ihnen, das Bibliotheksökosystem von C/C++ ins Web zu übertragen. Eine App, die C/C++ Bibliotheken in großem Umfang nutzt, ist squoosh, Web-App, mit der Sie Bilder mit einer Vielzahl von Codecs komprimieren können, aus C++ zu WebAssembly kompiliert.

WebAssembly ist eine Low-Level-VM, die den gespeicherten Bytecode ausführt in .wasm Dateien. Dieser Bytecode ist stark typisiert und so strukturiert dass es viel schneller für das Hostsystem kompiliert und optimiert werden kann JavaScript kann. WebAssembly stellt eine Umgebung bereit, in der Code ausgeführt werden kann, Sandboxing und Einbettung von Anfang an im Hinterkopf.

Meiner Erfahrung nach werden die meisten Leistungsprobleme im Web durch erzwungene und übermäßige Farbe, aber hin und wieder muss eine App rechenintensive Aufgabe, die viel Zeit in Anspruch nimmt. WebAssembly kann Ihnen dabei helfen, hier.

The Hot Path

In Squoosh haben wir eine JavaScript-Funktion der einen Bildpuffer um ein Vielfaches von 90 Grad dreht. Während OffscreenCanvas wäre ideal für wird sie in den Ziel-Browsern nicht unterstützt. in Chrome.

Diese Funktion iteriert über jedes Pixel eines Eingabebilds und kopiert es in ein eine andere Position im Ausgabebild, um eine Drehung zu erreichen. Für eine Größe von 4094 Pixeln 4096 Pixel große Bild (16 Megapixel), für den mehr als 16 Millionen Iterationen des inneren Codeblock, der als „Hot Path“ bezeichnet wird. Trotz dieser recht großen zwei von drei getesteten Browsern beenden die Aufgabe in 2 Sekunden oder weniger. Eine akzeptable Dauer für diese Art der Interaktion.

for (let d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
    for (let d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
    const in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
    outBuffer[i] = inBuffer[in_idx];
    i += 1;
    }
}

Ein Browser benötigt jedoch mehr als acht Sekunden. Die Art und Weise, wie Browser JavaScript optimieren ist kompliziert und unterschiedliche Suchmaschinen nehmen unterschiedliche Optimierungen vor. Einige optimieren die Ausführung im Rohformat, andere für die Interaktion mit dem DOM. In In diesem Fall erreichen wir einen nicht optimierten Pfad in einem Browser.

Bei WebAssembly hingegen geht es ausschließlich um Ausführungsgeschwindigkeiten. Also wenn wir für Code wie diesen browserübergreifende schnelle, vorhersehbare Leistung wollen, WebAssembly kann Ihnen dabei helfen.

WebAssembly für vorhersehbare Leistung

Im Allgemeinen können JavaScript und WebAssembly dieselbe Spitzenleistung erzielen. Bei JavaScript ist diese Leistung jedoch nur über den "schnellen Pfad" erreicht, und es ist oft schwierig, dem "schnellen Weg" zu folgen. Ein wesentlicher Vorteil, WebAssembly bietet eine vorhersehbare Leistung, sogar browserübergreifend. Die strenge und die Low-Level-Architektur ermöglichen es dem Compiler, dass WebAssembly-Code nur einmal optimiert werden muss und immer den „schnellen Pfad“.

Für WebAssembly schreiben

Früher haben wir C/C++-Bibliotheken verwendet und sie in WebAssembly kompiliert, um ihre Webfunktionen. Dabei haben wir nicht den Code der Bibliotheken Ich habe gerade kleine Mengen C/C++ Code geschrieben, um eine Brücke zwischen dem Browser und die Bibliothek. Diesmal ist unsere Motivation anders: Wir möchten mit WebAssembly etwas völlig Neues entwickeln, damit wir die Vorteile von WebAssembly.

WebAssembly-Architektur

Wenn Sie für WebAssembly schreiben, ist es nützlich, was WebAssembly eigentlich ist.

So zitieren Sie WebAssembly.org:

Wenn Sie einen C- oder Rust-Code in WebAssembly kompilieren, erhalten Sie eine .wasm -Datei, die eine Moduldeklaration enthält. Diese Deklaration besteht aus einer Liste von „Importe“ das Modul von seiner Umgebung erwartet, eine Liste der Exporte, das Modul dem Host zur Verfügung stellt (Funktionen, Konstanten, Speicherblöcke) und und natürlich die tatsächlichen binären Anweisungen für die darin enthaltenen Funktionen.

Etwas, das mir erst klar wurde, als ich mir das angeschaut habe: Der Stack, der WebAssembly, eine „stackbasierte virtuelle Maschine“ nicht im Block von Arbeitsspeicher, den die WebAssembly-Module nutzen. Der Stack ist komplett VM-intern für Webentwickler nicht zugänglich (außer über Entwicklertools). Daher ist es möglich, WebAssembly-Module zu schreiben, die überhaupt keinen zusätzlichen Speicher benötigen, und nur den VM-internen Stack verwenden.

In unserem Fall müssen wir zusätzlichen Speicher verwenden, um willkürlichen Zugriff zu ermöglichen. zu den Pixeln des Bildes hinzu und generieren eine gedrehte Version dieses Bildes. Dies ist wofür WebAssembly.Memory ist.

Speicherverwaltung

Wenn Sie zusätzlichen Speicher nutzen, werden Sie in der Regel feststellen, diese Erinnerung zu verwalten. Welche Teile des Arbeitsspeichers werden verwendet? Welche davon sind kostenlos? In C haben Sie beispielsweise die Funktion malloc(n), die nach einem Speicherplatz für von n aufeinanderfolgenden Byte. Funktionen dieser Art werden auch als „Allocators“ bezeichnet. Natürlich muss die Implementierung des verwendeten Zuweisers in Ihrem WebAssembly-Modul und erhöht die Dateigröße. Diese Größe und Leistung dieser Speicherverwaltungsfunktionen können je nach dem verwendeten Algorithmus. Deshalb bieten viele Sprachen mehrere Implementierungen an, zur Auswahl ("dmalloc", "emmalloc", "wee_alloc" usw.).

In unserem Fall kennen wir die Abmessungen des Eingabebildes (also die Größe Abmessungen des Ausgabebilds), bevor wir das WebAssembly-Modul ausführen. Hier haben wir eine Chance gesehen: Traditionell würden wir den RGBA-Puffer des Eingabebilds -Parameter an eine WebAssembly-Funktion und gibt das gedrehte Bild als Rückgabewert zurück. Wert. Um diesen Rückgabewert zu generieren, müssten wir den Allocator verwenden. Da wir aber wissen, wie viel Arbeitsspeicher insgesamt benötigt wird (doppelt so groß wie Bild, einmal für die Eingabe und einmal für die Ausgabe), können wir das Eingabebild in den WebAssembly-Arbeitsspeicher mit JavaScript speichern, führen Sie das WebAssembly-Modul aus, um ein 2., gedrehtes Bild und dann JavaScript verwenden, um das Ergebnis zurückzulesen. Wir können ohne Arbeitsspeicherverwaltung nutzen zu müssen.

Du hast die Wahl

In der ursprünglichen JavaScript-Funktion WebAssembly-Fähigkeiten wollen, können Sie sehen, dass es eine rein computergestützte ohne JavaScript-spezifische APIs. Daher sollte sie ziemlich gerade um diesen Code in eine beliebige Sprache zu übertragen. Wir haben drei verschiedene Sprachen die zu WebAssembly kompilieren: C/C++, Rust und AssemblyScript. Die einzige Frage müssen wir für jede der Sprachen die folgende Frage beantworten: Wie greifen wir auf den unverarbeiteten Arbeitsspeicher ohne Speicherverwaltungsfunktionen zu nutzen?

C und Emscripten

Emscripten ist ein C-Compiler für das WebAssembly-Ziel. Emscriptens Ziel ist es, als Drop-in-Ersatz für bekannte C-Compiler wie GCC oder Clang. und ist größtenteils mit Flags kompatibel. Dies ist ein zentraler Bestandteil der Mission von Emscripten. da die Kompilierung vorhandener C- und C++-Codes in WebAssembly möglich.

Der Zugriff auf den Rohspeicher liegt in der Natur von C und es gibt Hinweise dafür. Grund:

uint8_t* ptr = (uint8_t*)0x124;
ptr[0] = 0xFF;

Hier wandeln wir die Zahl 0x124 in einen Zeiger auf eine vorzeichenlose, 8-Bit- Ganzzahlen (oder Bytes). Dadurch wird die Variable ptr effektiv in ein Array umgewandelt. beginnend bei der Speicheradresse 0x124, die wir wie jedes andere Array verwenden können, sodass wir zum Lesen und Schreiben auf einzelne Bytes zugreifen können. In unserem Fall sehen wir uns den RGBA-Zwischenspeicher eines Bildes an, das neu angeordnet werden soll, Rotation. Zum Verschieben eines Pixels müssen wir 4 aufeinanderfolgende Bytes gleichzeitig verschieben. (ein Byte für jeden Kanal: R, G, B und A). Um dies zu vereinfachen, können wir ein Array von vorzeichenlosen 32-Bit-Ganzzahlen ohne Vorzeichen Konventionsgemäß beginnt unser Eingabebild unter Adresse 4 und unser Ausgabebild beginnt direkt nach dem Eingabebild endet am:

int bpp = 4;
int imageSize = inputWidth * inputHeight * bpp;
uint32_t* inBuffer = (uint32_t*) 4;
uint32_t* outBuffer = (uint32_t*) (inBuffer + imageSize);

for (int d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
    for (int d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
    int in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
    outBuffer[i] = inBuffer[in_idx];
    i += 1;
    }
}

Nach der Portierung der gesamten JavaScript-Funktion in C können wir die C-Datei kompilieren. mit emcc:

$ emcc -O3 -s ALLOW_MEMORY_GROWTH=1 -o c.js rotate.c

Wie immer generiert emscripten eine Glue-Code-Datei namens c.js und ein Wasm-Modul namens c.wasm. Beachten Sie, dass das Wasm-Modul mit gzip nur ca. 260 Byte groß ist, während das Glue Code etwa 3,5 KB nach gzip groß ist. Nach ein wenig Tüfteln gelang es uns, den Glue-Code und instanziieren Sie die WebAssembly-Module mit den Vanilla-APIs. Dies ist häufig bei Emscripten möglich, solange Sie nichts aus der C-Standardbibliothek.

Rust

Rust ist eine neue, moderne Programmiersprache mit einem umfangreichen Schriftsystem, ohne Laufzeit. und ein Eigentumsmodell, das Speicher- und Thread-Sicherheit garantiert. Rost unterstützt WebAssembly als Kernfunktion und das Rust-Team hat hat eine Menge hervorragender Tools zum WebAssembly-Ökosystem beigetragen.

Eines dieser Tools ist wasm-pack von der Arbeitsgruppe Rustwasm. wasm-pack wandelt Code in ein für das Web geeignetes Modul um, mit Bundlern wie Webpack. wasm-pack ist ein extrem aber derzeit nur für Rust. Die Gruppe ist erwägen, weitere WebAssembly-Targeting-Sprachen zu unterstützen.

In Rust sind Segmente das, was Arrays in C sind. Und genau wie in C müssen wir Segmente, die unsere Startadressen verwenden. Dies verstößt gegen das Speichersicherheitsmodell die Rust durchsetzt. Um den richtigen Weg zu finden, müssen wir das Keyword unsafe verwenden. sodass wir Code schreiben können, der diesem Modell nicht entspricht.

let imageSize = (inputWidth * inputHeight) as usize;
let inBuffer: &mut [u32];
let outBuffer: &mut [u32];
unsafe {
    inBuffer = slice::from_raw_parts_mut::<u32>(4 as *mut u32, imageSize);
    outBuffer = slice::from_raw_parts_mut::<u32>((imageSize * 4 + 4) as *mut u32, imageSize);
}

for d2 in 0..d2Limit {
    for d1 in 0..d1Limit {
    let in_idx = (d1Start + d1 * d1Advance) * d1Multiplier + (d2Start + d2 * d2Advance) * d2Multiplier;
    outBuffer[i as usize] = inBuffer[in_idx as usize];
    i += 1;
    }
}

Rust-Dateien kompilieren mit

$ wasm-pack build

ergibt ein 7,6 KB-Wasm-Modul mit etwa 100 Byte Glue Code (beide nach gzip).

AssemblyScript

AssemblyScript ist eine ziemlich junges Projekt, das ein TypeScript-to-WebAssembly-Compiler werden soll. Es ist Allerdings wird dabei nicht einfach nur TypeScript verwendet. AssemblyScript verwendet die gleiche Syntax wie TypeScript, ersetzt jedoch den Standard für ihre eigene Bibliothek. Ihre Standardbibliothek modelliert die Funktionen WebAssembly. Das heißt, Sie können nicht einfach jede fehlerhafte TypeScript-Datei kompilieren, zu WebAssembly, aber das bedeutet, dass Sie kein neues Programmiersprache WebAssembly schreiben!

    for (let d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
      for (let d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
        let in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
        store<u32>(offset + i * 4 + 4, load<u32>(in_idx * 4 + 4));
        i += 1;
      }
    }

In Anbetracht der kleinen Schriftoberfläche der rotate()-Funktion Code in AssemblyScript zu übertragen. Die Funktionen load<T>(ptr: usize) und store<T>(ptr: usize, value: T) werden von AssemblyScript für auf unformatierten Arbeitsspeicher zugreifen. So kompilieren Sie unsere AssemblyScript-Datei: Wir müssen nur das npm-Paket AssemblyScript/assemblyscript installieren und

$ asc rotate.ts -b assemblyscript.wasm --validate -O3

AssemblyScript liefert uns ein Wasm-Modul mit ca. 300 Byte und keinen Glue Code. Das Modul funktioniert nur mit den einfachen WebAssembly-APIs.

WebAssembly-Forensik

Rust ist mit 7,6 KB im Vergleich zu den beiden anderen Sprachen überraschend groß. Es sind Tools im WebAssembly-Ökosystem, die Ihnen bei der Analyse Ihre WebAssembly-Dateien (unabhängig von der Sprache, mit der sie erstellt wurden) und Ihnen zu sagen, was los ist, und helfen Ihnen, Ihre Situation zu verbessern.

Twiggy

Twiggy ist ein weiteres Tool von Rusts WebAssembly-Team, das eine Menge aufschlussreicher Daten aus einer WebAssembly extrahiert. -Modul. Das Tool ist nicht rostspezifisch und ermöglicht es Ihnen, Dinge wie die des Aufrufdiagramms des Moduls, ermitteln Sie ungenutzte oder überflüssige Abschnitte welche Abschnitte zur Gesamtdateigröße Ihres Moduls beitragen. Die kann mit dem Twiggy-Befehl top ausgeführt werden:

$ twiggy top rotate_bg.wasm
Screenshot: Twiggy-Installation

In diesem Fall geht der Großteil unserer Dateigröße auf das -Allocator. Das war überraschend, da unser Code keine dynamischen Zuweisungen verwendet. Ein weiterer wichtiger Faktor sind „Funktionsnamen“ Unterabschnitt.

Wasm-Streifen

wasm-strip ist ein Tool aus dem WebAssembly Binary Toolkit oder kurz „wabt“. Es enthält ein einige Tools, mit denen Sie WebAssembly-Module überprüfen und bearbeiten können. wasm2wat ist ein Zersetzer, der ein binäres Wasm-Modul in ein menschenlesbares Format. Wabt enthält auch wat2wasm, mit dem Sie dieses menschenlesbare Format in ein binäres Wasm-Modul zurück. Wir haben zwar diese beiden ergänzenden Tools zur Prüfung unserer WebAssembly-Dateien wasm-strip so nützlich wie möglich sein. wasm-strip entfernt unnötige Abschnitte und Metadaten aus einem WebAssembly-Modul:

$ wasm-strip rotate_bg.wasm

Dadurch wird die Dateigröße des Rustmoduls von 7,5 KB auf 6,6 KB (nach gzip) reduziert.

wasm-opt

wasm-opt ist ein Tool von Binaryen. Dabei wird mit einem WebAssembly-Modul versucht, es im Hinblick auf Größe und die nur auf dem Bytecode basiert. Einige Tools wie Emscripten werden bereits und andere nicht. Es ist normalerweise ratsam, zusätzliche Byte mit diesen Tools.

wasm-opt -O3 -o rotate_bg_opt.wasm rotate_bg.wasm

Mit wasm-opt können wir eine weitere Handvoll Bytes kürzen, sodass insgesamt 6,2 KB nach gzip.

#![no_std]

Nach einigen Beratungen und Recherchen haben wir unseren Rust-Code neu geschrieben, ohne Rusts Standardbibliothek unter Verwendung des #![no_std] . Dadurch werden auch dynamische Arbeitsspeicherzuweisungen deaktiviert, Zuordnungscode aus unserem Modul. Diese Rust-Datei kompilieren mit

$ rustc --target=wasm32-unknown-unknown -C opt-level=3 -o rust.wasm rotate.rs

liefert nach wasm-opt, wasm-strip und gzip ein 1,6 KB-Wasm-Modul. Während es immer noch größer als die von C und AssemblyScript generierten Module, um als „Leichtgewicht“ betrachtet zu werden.

Leistung

Bevor wir uns allein aufgrund der Dateigröße zu Schlussfolgerungen machen, haben wir uns angeschafft. um die Leistung zu optimieren. Wie haben wir nun die Leistung und Welche Ergebnisse wurden erzielt?

Benchmarks verwenden

Obwohl WebAssembly ein Low-Level-Bytecode-Format ist, muss es dennoch gesendet werden. über einen Compiler, um hostspezifischen Maschinencode zu generieren. Genau wie bei JavaScript arbeitet der Compiler in mehreren Phasen. Einfach gesagt: Die erste Phase schneller kompiliert, generiert jedoch tendenziell langsameren Code. Sobald das Modul beginnt, beobachtet der Browser, welche Teile häufig verwendet werden, und sendet diese langsameren Compiler verwenden.

Unser Anwendungsfall ist insofern interessant, dass der Code zum Drehen eines Bildes verwendet wird. einmal, vielleicht zweimal. In den meisten Fällen werden wir Vorteile des Optimize-Compilers. Das sollten Sie beachten, und Benchmarking durchführen. Wenn wir unsere WebAssembly-Module 10.000 Mal in einer Schleife laufen lassen, unrealistische Ergebnisse. Für realistische Zahlen sollten wir das Modul einmal ausführen und Entscheidungen auf Grundlage der Zahlen aus diesem einzelnen Durchlauf zu treffen.

Leistungsvergleich

Geschwindigkeitsvergleich nach Sprache
Geschwindigkeitsvergleich pro Browser

Diese beiden Grafiken sind unterschiedliche Ansichten derselben Daten. In der ersten Grafik In der zweiten Grafik vergleichen wir die Ergebnisse pro verwendeter Sprache. Bitte dass ich eine logarithmische Zeitskala ausgewählt habe. Es ist auch wichtig, dass alle in den Benchmarks dasselbe 16-Megapixel-Testbild und denselben Host Computer mit Ausnahme eines Browsers, der nicht auf demselben Computer ausgeführt werden kann.

Ohne eine zu umfassende Analyse dieser Grafiken ist klar, dass wir unser ursprüngliches Leistungsproblem: Alle WebAssembly-Module werden in ~500 ms oder weniger ausgeführt. Dieses bestätigt, was wir zu Beginn festgelegt haben: WebAssembly bietet Ihnen vorhersehbare die Leistung. Unabhängig von der gewählten Sprache, können die Unterschiede zwischen den Browsern und Sprachen auf ein Minimum reduziert. Um genau zu sein: Die Standardabweichung von JavaScript bei allen Browsern beträgt ~400 ms, während die Standardabweichung Die Dauer der WebAssembly-Module in allen Browsern beträgt ca. 80 ms.

Aufwand

Ein weiterer Messwert ist der Aufwand, den wir in die Erstellung und Integration WebAssembly-Moduls in Squoosh. Es ist schwierig, Daher erstelle ich keine Grafiken, aber ich möchte ein paar Dinge weisen Sie darauf hin:

AssemblyScript lief reibungslos. Mit TypeScript können Sie nicht nur WebAssembly zu schreiben, womit die Codeüberprüfung für meine Kollegen ganz einfach ist. produziert klebefreie WebAssembly-Module, die sehr klein sind und die Leistung. Die Tools in der TypeScript-Umgebung wie Prettier und Tslint funktionieren wahrscheinlich einfach.

Rost in Kombination mit wasm-pack ist ebenfalls sehr praktisch, aber hervorragend bei größeren WebAssembly-Projekten waren Bindungen und erforderlich. Wir mussten ein wenig vom Happy Path abweichen, um einen wettbewerbsintensiven Dateigröße.

C und Emscripten erstellten ein sehr kleines und leistungsstarkes WebAssembly-Modul. aber ohne den Mut, in Klebercode zu arbeiten Nötig ist die Gesamtgröße (WebAssembly-Modul + Klebercode) als ziemlich groß.

Fazit

Welche Sprache sollten Sie verwenden, wenn Sie einen JS-Hot Path haben und mit WebAssembly schneller und konsistenter. Wie immer bei der Leistung lautet die Antwort: Das kommt darauf an. Was haben wir versendet?

<ph type="x-smartling-placeholder">
</ph> Vergleichsdiagramm

Vergleich zwischen Modulgröße und Leistung der verschiedenen Sprachen ist entweder C oder AssemblyScript die beste Wahl. Wir haben uns entschlossen, Rust zu versenden. Es sind mehrere Gründe für diese Entscheidung: Alle bisher in Squoosh ausgelieferten Codecs mithilfe von Emscripten kompiliert werden. Wir wollten unser Wissen über die WebAssembly-Ökosystem und verwenden eine andere Sprache in der Produktion. AssemblyScript ist eine gute Alternative, aber das Projekt ist noch relativ jung und ist der Compiler nicht so ausgereift wie der Rust-Compiler.

Der Unterschied in der Dateigröße zwischen Rust und den anderen Sprachen im Streudiagramm ziemlich drastisch aussieht, ist das in Wirklichkeit nicht so groß: Das Laden von 500 Mrd.oder 1,6 KB selbst über 2G dauert weniger als eine Zehntelsekunde. Und Rust wird die Lücke in Bezug auf die Modulgröße hoffentlich bald schließen.

In Bezug auf die Laufzeitleistung erzielt Rust im Browser einen schnelleren Durchschnitt als AssemblyScript. Besonders bei größeren Projekten ist es wahrscheinlicher, schneller Code zu erstellen, ohne manuelle Codeoptimierungen zu benötigen. Aber das sollte Sie nicht davon abhalten, das zu verwenden, womit Sie sich am besten auskennen.

Dennoch ist AssemblyScript eine großartige Entdeckung. Damit lassen sich WebAssembly-Module zu erstellen, ohne Sprache. Das AssemblyScript-Team war sehr reaktionsschnell und aktiv die daran arbeiten, ihre Toolchain zu verbessern. Wir werden auf jeden Fall AssemblyScript.

Aktualisierung: Rost

Nach der Veröffentlichung dieses Artikels hat Nick Fitzgerald des Rust-Teams hat uns auf ihr hervorragendes Buch zu Rust Wasm verwiesen. Es enthält Abschnitt zum Optimieren der Dateigröße. Wenn Sie dem vor allem zur Aktivierung der Zeitoptimierung für die Verknüpfung und manueller konnten wir „normalen“ Rust-Code schreiben und uns dann wieder Cargo (das npm von Rust), ohne die Dateigröße zu überladen. Das Rostmodul endet mit 370 B nach gzip. Weitere Informationen finden Sie in der PR-Präsentation, die ich auf Squoosh eröffnet habe.

Ein besonderer Dank geht an Ashley Williams, Steve Klabnik, Nick Fitzgerald und Max Graey für ihre Hilfe auf diesem Weg.