Een hot path in het JavaScript van uw app vervangen door WebAssembly

Het is constant snel, yo

In mijn vorige artikelen heb ik beschreven hoe WebAssembly je in staat stelt om het bibliotheekecosysteem van C/C++ naar het web te brengen. Een app die uitgebreid gebruikmaakt van C/C++-bibliotheken is squoosh , onze webapp waarmee je afbeeldingen kunt comprimeren met diverse codecs die van C++ naar WebAssembly zijn gecompileerd.

WebAssembly is een low-level virtuele machine die de bytecode uitvoert die is opgeslagen in .wasm bestanden. Deze bytecode is sterk getypeerd en zo gestructureerd dat deze veel sneller kan worden gecompileerd en geoptimaliseerd voor het hostsysteem dan JavaScript. WebAssembly biedt een omgeving om code uit te voeren die vanaf het begin rekening heeft gehouden met sandboxing en embedding.

In mijn ervaring worden de meeste prestatieproblemen op het web veroorzaakt door geforceerde lay-out en overmatige tekenstijl, maar zo nu en dan moet een app een rekenintensieve taak uitvoeren die veel tijd kost. WebAssembly kan hierbij helpen.

Het hete pad

In Squoosh hebben we een JavaScript-functie geschreven die een afbeeldingsbuffer met veelvouden van 90 graden roteert. Hoewel OffscreenCanvas hiervoor ideaal zou zijn, wordt het niet ondersteund in de browsers die we beoogden en bevat het een beetje bugs in Chrome .

Deze functie itereert over elke pixel van een invoerafbeelding en kopieert deze naar een andere positie in de uitvoerafbeelding om rotatie te bereiken. Voor een afbeelding van 4094 bij 4096 pixels (16 megapixels) zijn meer dan 16 miljoen iteraties van het interne codeblok nodig, wat we een "hot path" noemen. Ondanks dit vrij grote aantal iteraties voltooien twee van de drie geteste browsers de taak in 2 seconden of minder. Een acceptabele tijdsduur voor dit type interactie.

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;
    }
}

Eén browser doet er echter meer dan 8 seconden over. De manier waarop browsers JavaScript optimaliseren is erg ingewikkeld , en verschillende engines optimaliseren voor verschillende dingen. Sommige optimaliseren voor ruwe uitvoering, andere voor interactie met de DOM. In dit geval hebben we in één browser een niet-geoptimaliseerd pad gevonden.

WebAssembly daarentegen is volledig gebouwd rond pure uitvoeringssnelheid. Dus als we snelle, voorspelbare prestaties in alle browsers willen voor code zoals deze, kan WebAssembly helpen.

WebAssembly voor voorspelbare prestaties

Over het algemeen kunnen JavaScript en WebAssembly dezelfde piekprestaties bereiken. Voor JavaScript kan deze prestatie echter alleen worden bereikt via het "snelle pad", en het is vaak lastig om dat "snelle pad" te behouden. Een belangrijk voordeel van WebAssembly is de voorspelbare prestatie, zelfs in verschillende browsers. De strikte typering en low-level architectuur stellen de compiler in staat om sterkere garanties te bieden, zodat WebAssembly-code slechts één keer hoeft te worden geoptimaliseerd en altijd het "snelle pad" gebruikt.

Schrijven voor WebAssembly

Eerder compileerden we C/C++-bibliotheken naar WebAssembly om hun functionaliteit op het web te gebruiken. We hebben de code van de bibliotheken niet echt aangepast; we schreven slechts kleine stukjes C/C++-code om de brug te vormen tussen de browser en de bibliotheek. Deze keer is onze motivatie anders: we willen iets helemaal opnieuw schrijven met WebAssembly in gedachten, zodat we de voordelen van WebAssembly kunnen benutten.

WebAssembly-architectuur

Wanneer u voor WebAssembly schrijft, is het nuttig om iets meer te begrijpen over wat WebAssembly eigenlijk is.

Om WebAssembly.org te citeren:

Wanneer je een stukje C- of Rust-code compileert naar WebAssembly, krijg je een .wasm -bestand met een moduledeclaratie. Deze declaratie bestaat uit een lijst met "imports" die de module van zijn omgeving verwacht, een lijst met exports die deze module beschikbaar stelt aan de host (functies, constanten, geheugenblokken) en natuurlijk de binaire instructies voor de functies die erin zitten.

Iets wat ik me pas realiseerde toen ik me hierin verdiepte: de stack die van WebAssembly een "stackgebaseerde virtuele machine" maakt, wordt niet opgeslagen in het geheugenblok dat WebAssembly-modules gebruiken. De stack is volledig VM-intern en ontoegankelijk voor webontwikkelaars (behalve via DevTools). Hierdoor is het mogelijk om WebAssembly-modules te schrijven die helemaal geen extra geheugen nodig hebben en alleen de VM-interne stack gebruiken.

In ons geval hebben we extra geheugen nodig om willekeurige toegang tot de pixels van onze afbeelding mogelijk te maken en een geroteerde versie van die afbeelding te genereren. Daar is WebAssembly.Memory voor.

Geheugenbeheer

Zodra u extra geheugen gebruikt, zult u merken dat u dat geheugen op de een of andere manier moet beheren. Welke delen van het geheugen worden gebruikt? Welke zijn vrij? In C hebt u bijvoorbeeld de functie malloc(n) die een geheugenruimte van n opeenvolgende bytes vindt. Dergelijke functies worden ook wel "allocators" genoemd. Uiteraard moet de implementatie van de gebruikte allocator in uw WebAssembly-module worden opgenomen en zal uw bestandsgrootte toenemen. De grootte en prestaties van deze geheugenbeheerfuncties kunnen aanzienlijk variëren, afhankelijk van het gebruikte algoritme. Daarom bieden veel programmeertalen meerdere implementaties waaruit u kunt kiezen ("dmalloc", "emmalloc", "wee_alloc", enz.).

In ons geval kennen we de afmetingen van de invoerafbeelding (en dus ook de afmetingen van de uitvoerafbeelding) voordat we de WebAssembly-module uitvoeren. Hier zagen we een kans: traditioneel gaven we de RGBA-buffer van de invoerafbeelding als parameter door aan een WebAssembly-functie en retourneerden we de geroteerde afbeelding als retourwaarde. Om die retourwaarde te genereren, zouden we de allocator moeten gebruiken. Maar omdat we de totale benodigde hoeveelheid geheugen kennen (twee keer de grootte van de invoerafbeelding, één keer voor invoer en één keer voor uitvoer), kunnen we de invoerafbeelding met behulp van JavaScript in het WebAssembly-geheugen plaatsen, de WebAssembly-module uitvoeren om een ​​tweede, geroteerde afbeelding te genereren en vervolgens JavaScript gebruiken om het resultaat terug te lezen. We kunnen nu helemaal geen geheugenbeheer gebruiken!

Keuze te over

Als je kijkt naar de originele JavaScript-functie die we willen WebAssembly-fyen, zie je dat het puur computationele code is zonder JavaScript-specifieke API's. Het zou dan ook vrij eenvoudig moeten zijn om deze code naar elke gewenste taal te porteren. We hebben drie verschillende talen geëvalueerd die naar WebAssembly compileren: C/C++, Rust en AssemblyScript. De enige vraag die we voor elk van de talen moeten beantwoorden, is: hoe krijgen we toegang tot het ruwe geheugen zonder geheugenbeheerfuncties te gebruiken?

C en Emscripten

Emscripten is een C-compiler voor de WebAssembly-doelgroep. Het doel van Emscripten is om te functioneren als een directe vervanger voor bekende C-compilers zoals GCC of clang en is grotendeels vlagcompatibel. Dit is een essentieel onderdeel van de missie van Emscripten, omdat het de compilatie van bestaande C- en C++-code naar WebAssembly zo eenvoudig mogelijk wil maken.

Het verkrijgen van toegang tot ruw geheugen zit in de aard van C en daarom bestaan ​​er pointers:

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

Hier zetten we het getal 0x124 om in een pointer naar unsigned, 8-bits integers (of bytes). Dit zet de variabele ptr in feite om in een array die begint bij geheugenadres 0x124 , die we net als elke andere array kunnen gebruiken, waardoor we toegang hebben tot individuele bytes voor lezen en schrijven. In ons geval kijken we naar een RGBA-buffer van een afbeelding die we opnieuw willen ordenen om rotatie te bereiken. Om een ​​pixel te verplaatsen, moeten we in feite 4 opeenvolgende bytes tegelijk verplaatsen (één byte voor elk kanaal: R, G, B en A). Om dit te vereenvoudigen, kunnen we een array van unsigned, 32-bits integers maken. Volgens afspraak begint onze invoerafbeelding bij adres 4 en start onze uitvoerafbeelding direct nadat de invoerafbeelding is geëindigd:

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;
    }
}

Nadat we de volledige JavaScript-functie naar C hebben geporteerd, kunnen we het C-bestand compileren met emcc :

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

Zoals altijd genereert emscripten een glue-codebestand genaamd c.js en een wasm-module genaamd c.wasm . Merk op dat de wasm-module gzipt naar slechts ~260 bytes, terwijl de glue-code na gzip ongeveer 3,5 KB groot is. Na wat gepruts konden we de glue-code weglaten en de WebAssembly-modules instantiëren met de vanilla API's. Dit is vaak mogelijk met Emscripten, zolang je niets uit de C-standaardbibliotheek gebruikt.

Roest

Rust is een nieuwe, moderne programmeertaal met een uitgebreid typesysteem, geen runtime en een eigendomsmodel dat geheugen- en threadveiligheid garandeert. Rust ondersteunt ook WebAssembly als kernfunctie en het Rust-team heeft veel uitstekende tools aan het WebAssembly-ecosysteem bijgedragen.

Een van deze tools is wasm-pack , van de rustwasm-werkgroep . wasm-pack zet je code om in een webvriendelijke module die direct werkt met bundels zoals webpack. wasm-pack is een extreem handige ervaring, maar werkt momenteel alleen met Rust. De groep overweegt ondersteuning toe te voegen voor andere talen die zich richten op WebAssembly.

In Rust zijn slices hetzelfde als arrays in C. En net als in C moeten we slices aanmaken die onze startadressen gebruiken. Dit druist in tegen het geheugenveiligheidsmodel dat Rust hanteert. Om ons zin te krijgen, moeten we het trefwoord unsafe gebruiken, waardoor we code kunnen schrijven die niet aan dat model voldoet.

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;
    }
}

Het compileren van de Rust-bestanden met behulp van

$ wasm-pack build

levert een 7,6 KB wasm-module op met ongeveer 100 bytes aan glue-code (beide na gzip).

AssemblyScript

AssemblyScript is een vrij jong project dat een TypeScript-naar-WebAssembly-compiler wil zijn. Het is echter belangrijk om te weten dat het niet zomaar TypeScript zal gebruiken. AssemblyScript gebruikt dezelfde syntaxis als TypeScript, maar vervangt de standaardbibliotheek door een eigen bibliotheek. Hun standaardbibliotheek modelleert de mogelijkheden van WebAssembly. Dat betekent dat je niet zomaar elk TypeScript dat je hebt liggen naar WebAssembly kunt compileren, maar het betekent wel dat je geen nieuwe programmeertaal hoeft te leren om WebAssembly te schrijven!

    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;
      }
    }

Gezien het kleine typeoppervlak van onze rotate() functie, was het vrij eenvoudig om deze code naar AssemblyScript te porteren. De functies load<T>(ptr: usize) en store<T>(ptr: usize, value: T) worden door AssemblyScript geleverd om toegang te krijgen tot het ruwe geheugen. Om ons AssemblyScript-bestand te compileren, hoeven we alleen het AssemblyScript/assemblyscript npm-pakket te installeren en uit te voeren.

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

AssemblyScript levert ons een wasm-module van ~300 bytes en geen glue code. De module werkt alleen met de standaard WebAssembly API's.

WebAssembly Forensics

De 7,6 KB van Rust is verrassend groot in vergelijking met de twee andere talen. Er zijn een aantal tools in het WebAssembly-ecosysteem die je kunnen helpen bij het analyseren van je WebAssembly-bestanden (ongeacht de taal waarin ze zijn gemaakt) en die je kunnen vertellen wat er aan de hand is, en je ook kunnen helpen je situatie te verbeteren.

Twiggy

Twiggy is een andere tool van het WebAssembly-team van Rust die een heleboel inzichtelijke data uit een WebAssembly-module haalt. De tool is niet specifiek voor Rust en stelt je in staat om zaken te inspecteren zoals de call graph van de module, ongebruikte of overbodige secties te identificeren en te achterhalen welke secties bijdragen aan de totale bestandsgrootte van je module. Dit laatste kan met Twiggy's top commando:

$ twiggy top rotate_bg.wasm
Twiggy-installatieschermafbeelding

In dit geval zien we dat het grootste deel van onze bestandsgrootte afkomstig is van de allocator. Dat was verrassend, aangezien onze code geen dynamische toewijzingen gebruikt. Een andere belangrijke bijdragende factor is een subsectie "functienamen".

wasm-strip

wasm-strip is een tool uit de WebAssembly Binary Toolkit , of kortweg wabt. Het bevat een aantal tools waarmee je WebAssembly-modules kunt inspecteren en bewerken. wasm2wat is een disassembler die een binaire wasm-module omzet in een leesbaar formaat. Wabt bevat ook wat2wasm , waarmee je dat leesbare formaat weer kunt omzetten in een binaire wasm-module. Hoewel we deze twee complementaire tools gebruikten om onze WebAssembly-bestanden te inspecteren, vonden we wasm-strip het meest nuttig. wasm-strip verwijdert onnodige secties en metadata uit een WebAssembly-module:

$ wasm-strip rotate_bg.wasm

Hiermee wordt de bestandsgrootte van de rust-module verkleind van 7,5 KB naar 6,6 KB (na gzip).

wasm-opt

wasm-opt is een tool van Binaryen . Het gebruikt een WebAssembly-module en probeert deze te optimaliseren voor zowel grootte als prestaties, uitsluitend op basis van de bytecode. Sommige tools zoals Emscripten gebruiken deze tool al, andere niet. Het is meestal een goed idee om te proberen wat extra bytes te besparen door deze tools te gebruiken.

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

Met wasm-opt kunnen we nog een paar bytes afhalen, zodat we na gzip in totaal 6,2 KB overhouden.

#![geen_standaard]

Na wat overleg en onderzoek hebben we onze Rust-code herschreven zonder de standaardbibliotheek van Rust te gebruiken, met behulp van de functie #![no_std] . Dit schakelt ook dynamische geheugentoewijzing volledig uit, waardoor de toewijzingscode uit onze module wordt verwijderd. Het compileren van dit Rust-bestand met

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

Leverde een 1,6 KB grote wasm-module op na wasm-opt , wasm-strip en gzip. Hoewel hij nog steeds groter is dan de modules die door C en AssemblyScript worden gegenereerd, is hij klein genoeg om als lichtgewicht te worden beschouwd.

Prestatie

Voordat we conclusies trekken op basis van alleen de bestandsgrootte: we zijn op dit traject gegaan om de prestaties te optimaliseren, niet de bestandsgrootte. Dus hoe hebben we de prestaties gemeten en wat waren de resultaten?

Hoe te benchmarken

Hoewel WebAssembly een low-level bytecode-formaat is, moet het toch door een compiler worden gestuurd om hostspecifieke machinecode te genereren. Net als JavaScript werkt de compiler in meerdere fasen. Simpel gezegd: de eerste fase compileert veel sneller, maar genereert doorgaans langzamere code. Zodra de module draait, observeert de browser welke onderdelen vaak worden gebruikt en stuurt deze door een meer geoptimaliseerde, maar tragere compiler.

Onze use-case is interessant omdat de code voor het roteren van een afbeelding één, misschien wel twee keer, gebruikt wordt. In de overgrote meerderheid van de gevallen zullen we dus nooit profiteren van de voordelen van de optimaliserende compiler. Dit is belangrijk om in gedachten te houden bij benchmarking. Het 10.000 keer uitvoeren van onze WebAssembly-modules in een lus zou onrealistische resultaten opleveren. Om realistische cijfers te krijgen, moeten we de module één keer uitvoeren en beslissingen nemen op basis van de cijfers van die ene run.

Prestatievergelijking

Snelheidsvergelijking per taal
Snelheidsvergelijking per browser

Deze twee grafieken geven verschillende weergaven van dezelfde data. In de eerste grafiek vergelijken we per browser, in de tweede per gebruikte taal. Let op: ik heb een logaritmische tijdschaal gekozen. Het is ook belangrijk dat alle benchmarks dezelfde testafbeelding van 16 megapixel en dezelfde hostcomputer gebruikten, met uitzondering van één browser, die niet op dezelfde computer kon worden uitgevoerd.

Zonder deze grafieken al te veel te analyseren, is het duidelijk dat we ons oorspronkelijke prestatieprobleem hebben opgelost: alle WebAssembly-modules draaien in ~500 ms of minder. Dit bevestigt wat we aan het begin al aangaven: WebAssembly biedt voorspelbare prestaties. Ongeacht de taal die we kiezen, is de variantie tussen browsers en talen minimaal. Om precies te zijn: de standaarddeviatie van JavaScript in alle browsers is ~400 ms, terwijl de standaarddeviatie van al onze WebAssembly-modules in alle browsers ~80 ms is.

Poging

Een andere maatstaf is de hoeveelheid moeite die we hebben moeten steken in het creëren en integreren van onze WebAssembly-module in Squoosh. Het is lastig om een ​​numerieke waarde aan de inspanning toe te kennen, dus ik zal geen grafieken maken, maar er zijn een paar dingen die ik wil benadrukken:

AssemblyScript verliep soepel. Het stelt je niet alleen in staat om met TypeScript WebAssembly te schrijven, wat codereview voor mijn collega's erg gemakkelijk maakt, maar het produceert ook lijmvrije WebAssembly-modules die erg klein zijn en toch behoorlijke prestaties leveren. De tools in het TypeScript-ecosysteem, zoals Prettier en Tslint, zullen waarschijnlijk gewoon werken.

Rust in combinatie met wasm-pack is ook extreem handig, maar blinkt meer uit bij grotere WebAssembly-projecten waar bindingen en geheugenbeheer nodig zijn. We moesten iets afwijken van het happy-path om een ​​concurrerende bestandsgrootte te bereiken.

C en Emscripten hebben standaard een heel kleine en zeer krachtige WebAssembly-module ontwikkeld. Maar zonder de moed om in de glue-code te duiken en deze terug te brengen tot het strikt noodzakelijke, is de totale omvang (WebAssembly-module + glue-code) behoorlijk groot.

Conclusie

Dus welke taal moet je gebruiken als je een JS hot path hebt en deze sneller of consistenter met WebAssembly wilt maken? Zoals altijd bij prestatievragen is het antwoord: het hangt ervan af. Dus wat hebben we geleverd?

Vergelijkingsgrafiek

Als we de afweging tussen modulegrootte en prestaties van de verschillende talen die we gebruikten vergelijken, lijkt C of AssemblyScript de beste keuze. We hebben besloten Rust te gebruiken . Er zijn meerdere redenen voor deze beslissing: alle codecs die tot nu toe in Squoosh zijn opgenomen, zijn gecompileerd met Emscripten. We wilden onze kennis over het WebAssembly-ecosysteem verbreden en een andere taal in productie gebruiken. AssemblyScript is een sterk alternatief, maar het project is relatief jong en de compiler is nog niet zo volwassen als de Rust-compiler.

Hoewel het verschil in bestandsgrootte tussen Rust en de andere talen er in de spreidingsgrafiek nogal drastisch uitziet, valt het in werkelijkheid mee: het laden van 500 B of 1,6 KB, zelfs meer dan 2 GB, duurt minder dan een tiende van een seconde. En hopelijk zal Rust het verschil in modulegrootte binnenkort dichten.

Qua runtime-prestaties heeft Rust gemiddeld een hogere snelheid dan AssemblyScript in alle browsers. Vooral bij grotere projecten zal Rust sneller code produceren zonder dat handmatige code-optimalisaties nodig zijn. Maar dat mag je er niet van weerhouden om te gebruiken waar je het meest vertrouwd mee bent.

Dat gezegd hebbende: AssemblyScript is een geweldige ontdekking. Het stelt webontwikkelaars in staat om WebAssembly-modules te produceren zonder een nieuwe taal te hoeven leren. Het AssemblyScript-team is zeer responsief geweest en werkt actief aan het verbeteren van hun toolchain. We zullen AssemblyScript in de toekomst zeker in de gaten houden.

Update: Roest

Na de publicatie van dit artikel verwees Nick Fitzgerald van het Rust-team ons naar hun uitstekende boek Rust Wasm, dat een sectie bevat over het optimaliseren van bestandsgrootte . Door de instructies daarin te volgen (met name het inschakelen van linktijdoptimalisatie en handmatige paniekverwerking) konden we "normale" Rust-code schrijven en teruggaan naar Cargo (de npm van Rust) zonder de bestandsgrootte te vergroten. De Rust-module eindigt na gzip op 370 B. Voor meer informatie, zie de PR die ik op Squoosh heb geopend .

Speciale dank aan Ashley Williams , Steve Klabnik , Nick Fitzgerald en Max Graey voor al hun hulp tijdens deze reis.