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

Het is altijd snel, yo

In mijn vorige artikelen heb ik gesproken over hoe je met WebAssembly het bibliotheekecosysteem van C/C++ naar het web kunt brengen. Eén app die uitgebreid gebruik maakt van C/C++-bibliotheken is squoosh , onze webapp waarmee u afbeeldingen kunt comprimeren met een verscheidenheid aan codecs die zijn gecompileerd van C++ tot WebAssembly.

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

In mijn ervaring worden de meeste prestatieproblemen op internet veroorzaakt door een geforceerde lay-out en overmatige verf, maar zo nu en dan moet een app een rekentechnisch dure 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 waarop we ons richtten, en bevat het een kleine bug in Chrome .

Deze functie herhaalt elke pixel van een invoerafbeelding en kopieert deze naar een andere positie in de uitvoerafbeelding om rotatie te bewerkstelligen. Voor een afbeelding van 4094 bij 4096 pixels (16 megapixels) zouden er meer dan 16 miljoen iteraties van het binnenste codeblok nodig zijn, wat we een "hot path" noemen. Ondanks dat vrij grote aantal iteraties voltooien twee van de drie browsers die we hebben getest de taak in twee seconden of minder. Een acceptabele duur voor dit soort 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 duurt echter ruim 8 seconden. De manier waarop browsers JavaScript optimaliseren is erg ingewikkeld , en verschillende zoekmachines optimaliseren voor verschillende dingen. Sommige optimaliseren voor onbewerkte uitvoering, andere optimaliseren voor interactie met de DOM. In dit geval zijn we in één browser op een niet-geoptimaliseerd pad terechtgekomen.

WebAssembly daarentegen is volledig gebouwd rond onbewerkte uitvoeringssnelheid. Dus als we snelle, voorspelbare prestaties in browsers willen voor dit soort code, kan WebAssembly helpen.

WebAssembly voor voorspelbare prestaties

Over het algemeen kunnen JavaScript en WebAssembly dezelfde topprestaties bereiken. Voor JavaScript kunnen deze prestaties echter alleen op het "snelle pad" worden bereikt, en het is vaak lastig om op dat "snelle pad" te blijven. Een belangrijk voordeel dat WebAssembly biedt, zijn voorspelbare prestaties, zelfs in verschillende browsers. Door de strikte typering en low-level architectuur kan de compiler sterkere garanties geven, zodat WebAssembly-code slechts één keer hoeft te worden geoptimaliseerd en altijd het “snelle pad” zal gebruiken.

Schrijven voor WebAssembly

Voorheen hebben we C/C++-bibliotheken gebruikt en deze in WebAssembly gecompileerd om hun functionaliteit op internet te gebruiken. We hebben de code van de bibliotheken niet echt aangeraakt, we hebben slechts kleine hoeveelheden C/C++-code geschreven om de brug te vormen tussen de browser en de bibliotheek. Deze keer is onze motivatie anders: we willen iets vanaf nul schrijven met WebAssembly in gedachten, zodat we gebruik kunnen maken van de voordelen die WebAssembly heeft.

WebAssembly-architectuur

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

Om WebAssembly.org te citeren:

Wanneer u een stukje C- of Rust-code compileert naar WebAssembly, krijgt u een .wasm -bestand dat een moduledeclaratie bevat. 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, stukjes geheugen) en natuurlijk de daadwerkelijke binaire instructies voor de functies die zich daarin bevinden. .

Iets dat ik me niet realiseerde totdat ik dit onderzocht: de stapel die WebAssembly tot een "stack-gebaseerde virtuele machine" maakt, wordt niet opgeslagen in het stuk geheugen dat WebAssembly-modules gebruiken. De stack is volledig VM-intern en ontoegankelijk voor webontwikkelaars (behalve via DevTools). Als zodanig 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 zullen we wat extra geheugen moeten gebruiken om willekeurige toegang tot de pixels van onze afbeelding mogelijk te maken en een geroteerde versie van die afbeelding te genereren. Dit is waar WebAssembly.Memory voor is.

Geheugen management

Als u eenmaal extra geheugen gebruikt, zult u doorgaans de behoefte ervaren om dat geheugen op de een of andere manier te beheren. Welke delen van het geheugen zijn in gebruik? Welke zijn gratis? In C heb je bijvoorbeeld de malloc(n) -functie die een geheugenruimte van n opeenvolgende bytes vindt. Dit soort 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. Deze omvang en prestaties van deze geheugenbeheerfuncties kunnen behoorlijk variëren, afhankelijk van het gebruikte algoritme. Daarom bieden veel talen meerdere implementaties om uit te kiezen ("dmalloc", "emmalloc", "wee_alloc", enz.).

In ons geval kennen we de afmetingen van de invoerafbeelding (en dus 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 door als parameter aan een WebAssembly-functie en retourneerden we de geroteerde afbeelding als een retourwaarde. Om die retourwaarde te genereren, zouden we gebruik moeten maken van de allocator. Maar omdat we de totale hoeveelheid benodigde geheugen kennen (tweemaal de grootte van de invoerafbeelding, één keer voor invoer en één keer voor uitvoer), kunnen we de invoerafbeelding in het WebAssembly-geheugen plaatsen met behulp van JavaScript en de WebAssembly-module uitvoeren om een ​​tweede, geroteerde afbeelding en gebruik vervolgens JavaScript om het resultaat terug te lezen. We kunnen wegkomen zonder enig geheugenbeheer te gebruiken!

Keuze te over

Als je naar de originele JavaScript-functie hebt gekeken die we met WebAssembly willen aanpassen, kun je zien dat het een puur computationele code is zonder JavaScript-specifieke API's. Als zodanig zou het redelijk eenvoudig moeten zijn om deze code naar welke taal dan ook over te zetten. We hebben 3 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 onbewerkt geheugen zonder gebruik te maken van geheugenbeheerfuncties?

C en Emscripten

Emscripten is een C-compiler voor het WebAssembly-doel. Het doel van Emscripten is om te functioneren als een drop-in vervanging voor bekende C-compilers zoals GCC of clang en is meestal flag-compatibel. Dit is een kernonderdeel van de missie van Emscripten, omdat het het compileren van bestaande C- en C++-code naar WebAssembly zo eenvoudig mogelijk wil maken.

Toegang tot onbewerkt geheugen ligt in de aard van C en er bestaan ​​juist om die reden verwijzingen:

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

Hier veranderen we het getal 0x124 in een verwijzing naar niet-ondertekende, 8-bit gehele getallen (of bytes). Dit verandert de ptr variabele effectief in een array die begint bij geheugenadres 0x124 , die we kunnen gebruiken zoals elke andere array, waardoor we toegang krijgen 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 eigenlijk 4 opeenvolgende bytes tegelijk verplaatsen (één byte voor elk kanaal: R, G, B en A). Om dit eenvoudiger te maken, kunnen we een array van niet-ondertekende, 32-bits gehele getallen maken. Volgens afspraak begint ons invoerbeeld op adres 4 en begint ons uitvoerbeeld direct nadat het invoerbeeld 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 geport, 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 lijmcodebestand met de naam c.js en een wasm-module met de naam c.wasm . Merk op dat de wasm-module slechts ~260 bytes gzipt, terwijl de lijmcode na gzip ongeveer 3,5 KB bedraagt. Na wat gedoe konden we de lijmcode achterwege laten en de WebAssembly-modules instantiëren met de standaard-API's. Vaak is dit mogelijk met Emscripten, zolang je niets uit de C-standaardbibliotheek gebruikt.

Roest

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

Een van deze tools is wasm-pack , van de rustwasm-werkgroep . wasm-pack neemt uw code en verandert deze in een webvriendelijke module die out-of-the-box werkt met bundelaars zoals webpack. wasm-pack is een uiterst handige ervaring, maar werkt momenteel alleen voor Rust. De groep overweegt ondersteuning toe te voegen voor andere WebAssembly-targetingtalen.

In Rust zijn segmenten wat arrays zijn in C. En net als in C moeten we segmenten maken die onze startadressen gebruiken. Dit druist in tegen het geheugenveiligheidsmodel dat Rust afdwingt, dus om onze 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 wasm-module van 7,6 KB op met ongeveer 100 bytes aan lijmcode (beide na gzip).

AssemblyScript

AssemblyScript is een vrij jong project dat een TypeScript-naar-WebAssembly-compiler wil zijn. Het is echter belangrijk op te merken dat het niet zomaar TypeScript verbruikt. AssemblyScript gebruikt dezelfde syntaxis als TypeScript, maar schakelt de standaardbibliotheek over voor hun eigen syntaxis. Hun standaardbibliotheek modelleert de mogelijkheden van WebAssembly. Dat betekent dat je niet zomaar elk TypeScript dat je hebt liggen kunt compileren naar WebAssembly, 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 dat onze functie rotate() heeft, was het vrij eenvoudig om deze code over te zetten naar AssemblyScript. De functies load<T>(ptr: usize) en store<T>(ptr: usize, value: T) worden door AssemblyScript geleverd om toegang te krijgen tot onbewerkt 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 lijmcode. De module werkt gewoon met de standaard WebAssembly API's.

Forensisch onderzoek van WebAssembly

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 u kunnen helpen bij het analyseren van uw WebAssembly-bestanden (ongeacht de taal waarin ze zijn gemaakt) en u vertellen wat er aan de hand is en u ook helpen uw situatie te verbeteren.

Takje

Twiggy is een ander hulpmiddel van het WebAssembly-team van Rust dat een heleboel inzichtelijke gegevens uit een WebAssembly-module haalt. De tool is niet Rust-specifiek en stelt u in staat zaken als de oproepgrafiek van de module te inspecteren, ongebruikte of overbodige secties te bepalen en uit te zoeken welke secties bijdragen aan de totale bestandsgrootte van uw module. Dit laatste kan gedaan worden met het top van Twiggy:

$ twiggy top rotate_bg.wasm
Twiggy-installatieschermafbeelding

In dit geval kunnen we zien dat het grootste deel van onze bestandsgrootte afkomstig is van de allocator. Dat was verrassend omdat onze code geen gebruik maakt van dynamische toewijzingen. Een andere belangrijke factor is de subsectie 'functienamen'.

wasm-strip

wasm-strip is een tool uit de WebAssembly Binary Toolkit , of kortweg wabt. Het bevat een aantal tools waarmee u WebAssembly-modules kunt inspecteren en manipuleren. wasm2wat is een disassembler die een binaire wasm-module omzet in een voor mensen leesbaar formaat. Wabt bevat ook wat2wasm waarmee je dat door mensen 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 nuttigst. wasm-strip verwijdert onnodige secties en metadata uit een WebAssembly-module:

$ wasm-strip rotate_bg.wasm

Dit verkleint de bestandsgrootte van de roestmodule van 7,5 KB naar 6,6 KB (na gzip).

wasm-opt

wasm-opt is een tool van Binaryen . Er is een WebAssembly-module voor nodig en deze probeert deze zowel qua grootte als qua prestaties te optimaliseren, alleen 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 met behulp van deze hulpmiddelen.

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

Met wasm-opt kunnen we nog een handvol bytes afscheren, zodat er na gzip een totaal van 6,2 KB overblijft.

#![no_std]

Na wat overleg en onderzoek hebben we onze Rust-code herschreven zonder gebruik te maken van de standaardbibliotheek van Rust, met behulp van de #![no_std] -functie. Dit schakelt ook de dynamische geheugentoewijzingen volledig uit, waardoor de allocatorcode 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 wasm-module van 1,6 KB op na wasm-opt , wasm-strip en gzip. Hoewel het nog steeds groter is dan de modules die door C en AssemblyScript worden gegenereerd, is het klein genoeg om als een lichtgewicht te worden beschouwd.

Prestatie

Voordat we conclusies trekken op basis van alleen de bestandsgrootte: we zijn op deze reis gegaan om de prestaties te optimaliseren, niet de bestandsgrootte. Hoe hebben we de prestaties gemeten en wat waren de resultaten?

Hoe te benchmarken

Ondanks dat WebAssembly een bytecode-indeling op laag niveau is, moet het nog steeds via een compiler worden verzonden om hostspecifieke machinecode te genereren. Net als JavaScript werkt de compiler in meerdere fasen. Simpel gezegd: de eerste fase is veel sneller bij het compileren, maar heeft de neiging langzamere code te genereren. Zodra de module start, observeert de browser welke onderdelen vaak worden gebruikt en stuurt deze door een meer optimaliserende maar langzamere compiler.

Onze use-case is interessant omdat de code voor het roteren van een afbeelding één, misschien twee keer zal worden gebruikt. 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 het benchmarken. Het 10.000 keer achter elkaar uitvoeren van onze WebAssembly-modules zou onrealistische resultaten opleveren. Om realistische cijfers te krijgen, moeten we de module één keer uitvoeren en beslissingen nemen op basis van de cijfers uit die ene run.

Prestatievergelijking

Snelheidsvergelijking per taal
Snelheidsvergelijking per browser

Deze twee grafieken zijn verschillende weergaven van dezelfde gegevens. In de eerste grafiek vergelijken we per browser, in de tweede grafiek vergelijken we per gebruikte taal. Houd er rekening mee dat ik een logaritmische tijdschaal heb gekozen. Het is ook belangrijk dat alle benchmarks hetzelfde 16 megapixel testbeeld en dezelfde hostmachine gebruikten, met uitzondering van één browser, die niet op dezelfde machine 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 in het begin hebben uiteengezet: WebAssembly biedt u voorspelbare prestaties. Welke taal we ook kiezen, de variantie tussen browsers en talen is minimaal. Om precies te zijn: de standaardafwijking van JavaScript in alle browsers is ~400 ms, terwijl de standaardafwijking 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 maken en integreren van onze WebAssembly-module in squoosh. Het is moeilijk om een ​​numerieke waarde aan inspanning toe te kennen, dus ik zal geen grafieken maken, maar er zijn een paar dingen die ik wil benadrukken:

AssemblyScript verliep probleemloos. Je kunt er niet alleen TypeScript mee gebruiken om WebAssembly te schrijven, wat het beoordelen van code heel gemakkelijk maakt voor mijn collega's, maar het produceert ook lijmvrije WebAssembly-modules die erg klein zijn en behoorlijke prestaties leveren. De tooling in het TypeScript-ecosysteem, zoals mooier en tslint, zal waarschijnlijk gewoon werken.

Rust in combinatie met wasm-pack is ook buitengewoon handig, maar blinkt meer uit bij grotere WebAssembly-projecten waarbij bindingen en geheugenbeheer nodig zijn. We moesten een beetje afwijken van het goede pad om een ​​concurrerende bestandsgrootte te bereiken.

C en Emscripten hebben kant-en-klaar een zeer kleine en zeer performante WebAssembly-module gemaakt, maar zonder de moed om in de lijmcode te springen en deze terug te brengen tot het noodzakelijke, wordt de totale omvang (WebAssembly-module + lijmcode) behoorlijk groot.

Conclusie

Dus welke taal moet u gebruiken als u een JS-hotpath heeft en deze sneller of consistenter wilt maken met WebAssembly. Zoals altijd bij prestatievragen luidt het antwoord: dat hangt ervan af. Dus wat hebben we verzonden?

Vergelijkingsgrafiek

Als we de afweging maken tussen modulegrootte en prestatie van de verschillende talen die we hebben gebruikt, lijkt C of AssemblyScript de beste keuze te zijn. We besloten Rust te verzenden . Er zijn meerdere redenen voor deze beslissing: Alle codecs die tot nu toe in Squoosh zijn verzonden, zijn gecompileerd met Emscripten. We wilden onze kennis over het WebAssembly-ecosysteem verbreden en een andere taal gebruiken in de productie . 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 grootte van andere talen behoorlijk drastisch lijkt in de spreidingsgrafiek, is het in werkelijkheid niet zo'n groot probleem: het laden van 500B of 1,6KB, zelfs over 2G, duurt minder dan een 1/10e van een seconde. En Rust zal hopelijk binnenkort de kloof dichten in termen van modulegrootte.

In termen van runtime-prestaties heeft Rust een sneller gemiddelde in alle browsers dan AssemblyScript. Vooral bij grotere projecten zal Rust waarschijnlijk snellere code produceren zonder dat handmatige code-optimalisaties nodig zijn. Maar dat mag u er niet van weerhouden om te gebruiken waar u zich het prettigst bij voelt.

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

Update: roest

Na het publiceren van dit artikel wees Nick Fitzgerald van het Rust-team ons op hun uitstekende Rust Wasm-boek, dat een sectie bevat over het optimaliseren van de bestandsgrootte . Door de instructies daar te volgen (met name het inschakelen van optimalisaties van de linktijd en handmatige paniekafhandeling) konden we "normale" Rust-code schrijven en teruggaan naar het gebruik van Cargo (de npm van Rust) zonder de bestandsgrootte te vergroten. De Rust-module eindigt met 370B na gzip. Voor details kun je de PR bekijken 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.