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-virtuelle Maschine, die den Bytecode ausführt, der in .wasm-Dateien gespeichert ist. 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 erzwungenes Layout und übermäßiges Painting verursacht. Gelegentlich muss eine App jedoch eine rechenintensive Aufgabe ausführen, die viel Zeit in Anspruch nimmt. WebAssembly kann Ihnen dabei helfen, hier.

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. Buggy 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 über 16 Millionen Iterationen des inneren Codeblock, der als „Hot Path“ bezeichnet wird. Trotz dieser relativ großen Anzahl von Iterationen erledigen zwei der drei von uns getesteten Browser die Aufgabe in weniger als zwei Sekunden. Eine akzeptable Dauer für diese Art von 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 sehr kompliziert und verschiedene Engines optimieren für unterschiedliche Dinge. 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.

WebAssembly hingegen ist vollständig auf die reine Ausführungsgeschwindigkeit ausgelegt. Wenn wir also eine schnelle, vorhersehbare Leistung für Code wie diesen in allen Browsern wünschen, kann WebAssembly helfen.

WebAssembly für vorhersehbare Leistung

Im Allgemeinen können JavaScript und WebAssembly dieselbe Spitzenleistung erzielen. Bei JavaScript kann diese Leistung jedoch nur auf dem „schnellen Pfad“ erreicht werden. Es ist oft schwierig, auf diesem Pfad zu bleiben. 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

Zuvor 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 programmieren, sollten Sie wissen, 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.

Mir war das erst klar geworden, als ich mir das genauer angesehen habe: Der Stack, der WebAssembly zu einer „stackbasierten virtuellen Maschine“ macht, wird nicht im Speicherbereich gespeichert, den WebAssembly-Module verwenden. Der Stack ist vollständig VM-intern und für Webentwickler nicht zugänglich (außer über die DevTools). 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 Arbeitsspeicher verwenden, um beliebigen Zugriff auf die Pixel unseres Bildes zu ermöglichen und eine gedrehte Version dieses Bildes zu generieren. WebAssembly.Memory ist dafür da.

Speicherverwaltung

Wenn Sie zusätzlichen Speicher nutzen, werden Sie in der Regel feststellen, diese Erinnerung zu verwalten. Welche Teile des Arbeitsspeichers sind belegt? 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 Allocators in Ihrem WebAssembly-Modul enthalten sein. Dies 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 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 sahen eine Chance: Traditionell würden wir den RGBA-Puffer des Eingabebilds an eine WebAssembly-Funktion übergeben und das gedrehte Bild als Rückgabe 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 einen 2., gedrehtes Bild und dann JavaScript verwenden, um das Ergebnis zurückzulesen. Wir können ganz ohne Speicherverwaltung auskommen!

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 Emscripten-Philosophie, da das Kompilieren vorhandenen C- und C++-Codes in WebAssembly so einfach wie möglich gemacht werden soll.

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

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

Hier wird die Zahl 0x124 in einen Verweis auf ungesignierte 8‑Bit-Ganzzahlen (oder Bytes) umgewandelt. Dadurch wird die Variable ptr effektiv in ein Array umgewandelt, das an der Speicheradresse 0x124 beginnt und das wir wie jedes andere Array verwenden können. So können wir zum Lesen und Schreiben auf einzelne Byte zugreifen. In unserem Fall sehen wir uns einen RGBA-Puffer eines Bildes an, den wir neu anordnen möchten, um eine Drehung zu erzielen. 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-Codedatei namens c.js und ein WASM-Modul namens c.wasm. Beachten Sie, dass das Wasm-Modul mit gzip nur ~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. Das ist mit Emscripten oft möglich, solange Sie nichts aus der C-Standardbibliothek verwenden.

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 rustwasm-Arbeitsgruppe. wasm-pack in ein für das Web geeignetes Modul umwandelt, mit Bundlern wie Webpack. wasm-pack ist ein extrem aber derzeit nur für Rust funktioniert. 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. Das verstößt gegen das von Rust erzwungene Speichersicherheitsmodell. Um unser Ziel zu erreichen, müssen wir das Schlüsselwort unsafe verwenden, mit dem wir Code schreiben können, der nicht diesem Modell 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 mit kompilieren

$ wasm-pack build

ergibt ein 7,6 KB großes WASM-Modul mit etwa 100 Byte Glue-Code (beides nach gzip).

AssemblyScript

AssemblyScript ist ziemlich junges Projekt, das ein TypeScript-to-WebAssembly-Compiler werden soll. Es wird jedoch 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. Um unsere AssemblyScript-Datei zu kompilieren, müssen wir 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

Die 7,6 KB von Rust sind im Vergleich zu den beiden anderen Sprachen überraschend groß. Es gibt einige Tools im WebAssembly-Ökosystem, mit denen Sie Ihre WebAssembly-Dateien analysieren können (unabhängig von der Sprache, mit der sie erstellt wurden), um herauszufinden, was los ist, und die Situation zu verbessern.

Twiggy

Twiggy ist ein weiteres Tool des WebAssembly-Teams von Rust, mit dem eine Reihe nützlicher Daten aus einem WebAssembly-Modul extrahiert werden. 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. Das geht mit dem Befehl top von Twiggy:

$ twiggy top rotate_bg.wasm
Screenshot der 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 ist ein Abschnitt zu „Funktionsnamen“.

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 Disassembler, der ein binäres WASM-Modul in ein für Menschen lesbares Format umwandelt. 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 Rust-Moduls von 7,5 KB auf 6,6 KB (nach GZIP) reduziert.

wasm-opt

wasm-opt ist ein Tool von Binaryen. Es nimmt ein WebAssembly-Modul und versucht, es sowohl hinsichtlich Größe als auch Leistung nur anhand des Bytecodes zu optimieren. Einige Tools wie Emscripten nutzen dieses Tool bereits, 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 noch ein paar Byte einsparen, sodass nach gzip insgesamt 6,2 KB übrig bleiben.

#![no_std]

Nach einigen Beratungen und Recherchen haben wir unseren Rust-Code ohne die Standardbibliothek von Rust neu geschrieben und dabei die Funktion #![no_std] verwendet. Dadurch werden auch dynamische Arbeitsspeicherzuweisungen deaktiviert, Zuordnungscode aus unserem Modul. Kompilieren von dieser Rust-Datei 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 vorschnell Schlüsse aufgrund der Dateigröße ziehen: Wir haben diese Änderungen vorgenommen, um die Leistung zu optimieren, nicht die Dateigröße. Wie haben wir nun die Leistung und Welche Ergebnisse wurden erzielt?

Benchmarking

Obwohl WebAssembly ein Low-Level-Bytecode-Format ist, muss es trotzdem durch einen Compiler gesendet werden, 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 gestartet wird, beobachtet der Browser, welche Teile häufig verwendet werden, und sendet diese an einen optimierteren, aber langsameren Compiler.

Unser Anwendungsfall ist interessant, da der Code zum Drehen eines Bildes einmal oder vielleicht zweimal verwendet wird. In den meisten Fällen können wir also nie die Vorteile des optimierten Compilers nutzen. Das sollten Sie beim Benchmarking berücksichtigen. 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 diese Grafiken zu sehr zu analysieren, ist klar, dass wir unser ursprüngliches Leistungsproblem gelöst haben: Alle WebAssembly-Module werden in etwa 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. Genauer gesagt: Die Standardabweichung von JavaScript in allen Browsern beträgt etwa 400 ms, während die Standardabweichung aller unserer WebAssembly-Module in allen Browsern etwa 80 ms beträgt.

Aufwand

Ein weiterer Messwert ist der Aufwand, den wir für die Erstellung und Integration unseres WebAssembly-Moduls in Squoosh betreiben mussten. Es ist schwierig, dem Aufwand einen numerischen Wert zuzuweisen. Daher erstelle ich keine Grafiken. Ich möchte aber auf Folgendes hinweisen:

AssemblyScript war unkompliziert. Mit diesem Tool können Sie nicht nur TypeScript zum Schreiben von WebAssembly verwenden, was die Codeüberprüfung für meine Kollegen sehr einfach macht, sondern es werden auch klebstofffreie WebAssembly-Module erstellt, die sehr klein und leistungsstark sind. Die Tools im TypeScript-Ökosystem, wie prettier und tslint, funktionieren wahrscheinlich einfach weiter.

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 haben ein sehr kleines und leistungsstarkes WebAssembly-Modul erstellt, aber ohne den Mut, den Glue-Code auf das Nötigste zu reduzieren. Die Gesamtgröße (WebAssembly-Modul + Glue-Code) ist daher 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?

Vergleichsdiagramm

Vergleich zwischen Modulgröße und Leistung der verschiedenen Sprachen ist entweder C oder AssemblyScript die beste Wahl. Wir haben uns entschieden, Rust zu veröffentlichen. 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 das WebAssembly-Ökosystem erweitern und in der Produktion eine andere Sprache verwenden. AssemblyScript ist eine gute Alternative, aber das Projekt ist relativ jung und der Compiler ist nicht so ausgereift wie der Rust-Compiler.

Auch wenn der Unterschied in der Dateigröße zwischen Rust und den anderen Sprachen im Streudiagramm ziemlich drastisch aussieht, ist er in Wirklichkeit nicht so groß: Das Laden von 500 B oder 1,6 KB dauert selbst über 2 Gbit/s weniger als ein Zehntel einer Sekunde. 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. Insbesondere bei größeren Projekten ist es mit Rust wahrscheinlicher, dass schneller Code generiert wird, ohne dass manuelle Codeoptimierungen erforderlich sind. Aber das sollte Sie nicht davon abhalten, das zu verwenden, womit Sie sich am besten auskennen.

Alles in allem war AssemblyScript eine tolle Entdeckung. So können Webentwickler WebAssembly-Module erstellen, ohne eine neue Sprache lernen zu müssen. Das AssemblyScript-Team ist sehr hilfsbereit und arbeitet aktiv an der Verbesserung seiner Toolchain. Wir werden AssemblyScript in Zukunft im Auge behalten.

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. Durch das Befolgen der Anleitung dort (insbesondere durch die Aktivierung von Optimierungen zur Linkzeit und die manuelle Panikbewältigung) konnten wir „normalen“ Rust-Code schreiben und wieder Cargo (das npm von Rust) verwenden, ohne die Dateigröße zu vergrößern. Das Rostmodul endet mit 370 B nach gzip. Weitere Informationen finden Sie in der PR, die ich bei Squoosh eingereicht habe.

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