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 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 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.
Geheugenbeheer
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 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 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 top
van Twiggy:
$ twiggy top rotate_bg.wasm
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
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?
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 mogelijk maken van linktijdoptimalisaties 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.
,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 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 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.
Geheugenbeheer
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 alleen een Typescript kunt compileren dat je tegen WebAssembly hebt rondgelopen, 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 type oppervlak dat onze rotate()
-functie heeft, was het vrij eenvoudig om deze code te porten naar assemblyscript. De functies load<T>(ptr: usize)
en store<T>(ptr: usize, value: T)
worden geleverd door AssemblyScript om toegang te krijgen tot RAW -geheugen. Om ons AssemblyScript -bestand te compileren, hoeven we alleen het NPM -pakket AssemblyScript/assemblyscript
te installeren en uit te voeren
$ asc rotate.ts -b assemblyscript.wasm --validate -O3
AssemblyScript biedt ons een ~ 300 bytes wasm -module en geen lijmcode. De module werkt gewoon met de Vanilla WebAssembly API's.
Webassembly forensics
Rust's 7.6KB is verrassend groot in vergelijking met de 2 andere talen. Er zijn een paar tools in het webassembly -ecosysteem die u kunnen helpen uw webassemblybestanden te analyseren (ongeacht de taal waarmee de is 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 Rust's WebAssembly -team dat een aantal inzichtelijke gegevens uit een WebAssembly -module haalt. De tool is niet roestspecifiek en stelt u in staat dingen zoals de oproepgrafiek van de module te inspecteren, ongebruikte of overbodige secties te bepalen en erachter te komen welke secties bijdragen aan de totale bestandsgrootte van uw module. De laatste kan worden gedaan met Twiggy's top
:
$ twiggy top rotate_bg.wasm
In dit geval kunnen we zien dat een meerderheid van onze bestandsgrootte voortkomt uit de allocator. Dat was verrassend omdat onze code geen dynamische toewijzingen gebruikt. Een andere grote bijdragende factor is een subsectie "functienamen".
wasme-strip
wasm-strip
is een hulpmiddel van de webassembly binaire toolkit of kortweg WABT. Het bevat een aantal tools waarmee u webassemblymodules kunt inspecteren en manipuleren. wasm2wat
is een demontage die van een binaire wasme-module verandert in een mens-leesbaar formaat. WABT bevat ook wat2wasm
waarmee je dat mens-leesbare formaat terug kunt veranderen in een binaire wasme-module. Hoewel we deze twee complementaire tools hebben gebruikt om onze webassemblybestanden te inspecteren, vonden we wasm-strip
het nuttigst. wasm-strip
verwijdert onnodige secties en metadata uit een webassemblymodule:
$ wasm-strip rotate_bg.wasm
Dit vermindert de bestandsgrootte van de roestmodule van 7,5 kb tot 6,6 kb (na GZIP).
wasme-opt
wasm-opt
is een hulpmiddel van binaryeen . Het neemt een WebAssembly -module en probeert het zowel te optimaliseren voor grootte als prestaties alleen gebaseerd op de bytecode. Sommige tools zoals Emscripten hebben deze tool al uitgevoerd, sommige anderen niet. Het is meestal een goed idee om te proberen wat extra bytes op te slaan door deze tools te gebruiken.
wasm-opt -O3 -o rotate_bg_opt.wasm rotate_bg.wasm
Met wasm-opt
kunnen we een andere handvol bytes afwijzen om in totaal 6,2 kb na GZIP te laten.
#! [No_std]
Na wat consult en onderzoek hebben we onze Rust-code opnieuw geschreven zonder de standaardbibliotheek van Rust te gebruiken, met behulp van de #![no_std]
-functie. Dit schakelt ook dynamische geheugenallocaties helemaal uit, waardoor de allocatorcode uit onze module wordt verwijderd. Dit roestbestand compileren met
$ rustc --target=wasm32-unknown-unknown -C opt-level=3 -o rust.wasm rotate.rs
leverde een 1,6 kb wasme-module op na wasm-opt
, wasm-strip
en gzip. Hoewel het nog steeds groter is dan de modules die worden gegenereerd door C en AssemblyScript, is het klein genoeg om als een lichtgewicht te worden beschouwd.
Prestatie
Voordat we tot conclusies trekken op basis van alleen bestandsgrootte - gingen we op deze reis om de prestaties te optimaliseren, geen bestandsgrootte. Dus hoe hebben we de prestaties meten 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 host-specifieke 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 om langzamere code te genereren. Zodra de module begint te draaien, observeert de browser welke delen vaak worden gebruikt en verzendt die via een meer optimaliserende maar langzamere compiler.
Onze use-case is interessant omdat de code voor het roteren van een afbeelding eenmaal, misschien twee keer zal worden gebruikt. Dus in de overgrote meerderheid van de gevallen zullen we nooit de voordelen van de optimaliserende compiler krijgen. Dit is belangrijk om in gedachten te houden bij benchmarking. Het uitvoeren van onze WebAssembly -modules 10.000 keer in een lus zou onrealistische resultaten opleveren. Om realistische cijfers te krijgen, moeten we de module eenmaal uitvoeren en beslissingen nemen op basis van de nummers van die enkele run.
Prestatievergelijking
Deze twee grafieken zijn verschillende weergaven op 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 tijdschema heb gekozen. Het is ook belangrijk dat alle benchmarks dezelfde 16 megapixeltestafbeelding en dezelfde hostmachine gebruikten, behalve voor één browser, die niet op dezelfde machine kon worden uitgevoerd.
Zonder deze grafieken te veel te analyseren, is het duidelijk dat we ons oorspronkelijke prestatieprobleem hebben opgelost: alle webassemblymodules worden in ~ 500 ms of minder uitgevoerd. Dit bevestigt wat we in het begin hebben ingedeeld: WebAssembly geeft u voorspelbare prestaties. Welke taal we ook kiezen, de variantie tussen browsers en talen is minimaal. Om precies te zijn: de standaardafwijking van JavaScript over alle browsers is ~ 400ms, terwijl de standaardafwijking van al onze webassemblymodules in alle browsers ~ 80ms is.
Poging
Een andere statistiek is de hoeveelheid inspanning die we moesten doen om onze WebAssembly -module in squoosh te maken en te integreren. Het is moeilijk om een numerieke waarde aan inspanning toe te wijzen, dus ik zal geen grafieken maken, maar er zijn een paar dingen waar ik op zou willen wijzen:
AssemblyScript was wrijvingsloos. Hiermee kunt u niet alleen Typescript gebruiken om WebAssembly te schrijven, waardoor code-review zeer eenvoudig wordt voor mijn collega's, maar het produceert ook lijmvrije webassemblymodules die erg klein zijn met fatsoenlijke prestaties. De tooling in het TypeScript -ecosysteem, zoals Poetier en Tslint, zal waarschijnlijk gewoon werken.
Roest in combinatie met wasm-pack
is ook extreem handig, maar meer uitblinkt in grotere webassemblyprojecten die bindingen waren en geheugenbeheer nodig zijn. We moesten een beetje afwijken van het gelukkige-path om een competitieve bestandsgrootte te bereiken.
C en Emscripten creëerden een zeer kleine en zeer performante webassemblymodule uit de doos, maar zonder de moed om in lijmcode te springen en deze te verminderen tot de kale benodigdheden, wordt de totale grootte (WebAssembly Module + Glue Code) behoorlijk groot.
Conclusie
Dus welke taal moet je gebruiken als je een JS Hot Path hebt en het sneller of consistenter wilt maken met WebAssembly. Zoals altijd met prestatievragen, is het antwoord: het hangt ervan af. Dus wat hebben we verzonden?
Vergelijking van de afweging van de modulegrootte / prestaties van de verschillende talen die we hebben gebruikt, lijkt de beste keuze C- of AssemblyScript te zijn. We hebben besloten roest te verzenden . Er zijn meerdere redenen voor deze beslissing: alle codecs die tot nu toe in squoosh zijn verzonden, zijn samengesteld met EMSCRIPEN. We wilden onze kennis over het webassembly -ecosysteem verbreden en een andere taal in de productie gebruiken. AssemblyScript is een sterk alternatief, maar het project is relatief jong en de compiler is niet zo volwassen als de Rust -compiler.
Hoewel het verschil in bestandsgrootte tussen roest en de grootte van de andere talen er behoorlijk drastisch uitziet in de spreidingsgrafiek, is het in werkelijkheid niet zo groot: het laden van 500b of 1,6 kb zelfs meer dan 2G duurt minder dan 1/10 seconde. En Rust zal hopelijk binnenkort de kloof dichten in termen van modulegrootte.
In termen van runtime -prestaties heeft Rust een sneller gemiddelde over browsers dan AssemblyScript. Vooral bij grotere projecten zal Rust eerder snellere code produceren zonder dat handmatige code -optimalisaties nodig hebben. Maar dat zou je er niet van moeten weerhouden om te gebruiken waar je het meest comfortabel bij bent.
Dat is allemaal gezegd: Assemblyscript is een geweldige ontdekking geweest. Hiermee kunnen webontwikkelaars webassemblymodules produceren zonder een nieuwe taal te leren. Het AssemblyScript -team is zeer responsief geweest en werkt actief aan het verbeteren van hun toolchain. We zullen in de toekomst zeker AssemblyScript 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 . Volgens de instructies daar (met name het inschakelen van linktijdoptimalisaties en handmatige paniekbehandeling) stelde ons in staat om "normale" roestcode te schrijven en terug te gaan naar het gebruik Cargo
(de npm
van roest) zonder de bestandsgrootte op te blazen. De roestmodule eindigt met 370b na GZIP. Kijk voor meer informatie naar de PR die ik heb geopend op squoosh .
Speciale dank aan Ashley Williams , Steve Klabnik , Nick Fitzgerald en Max Graey voor al hun hulp bij deze reis.
,Het is consequent snel, yo
In mijn vorige artikelen heb ik het gehad over hoe je met WebAssembly het bibliotheekecosysteem van C/C ++ naar internet kunt brengen. Een app die uitgebreid gebruik maakt van C/C ++ -bibliotheken is Squoosh , onze web -app waarmee u afbeeldingen kunt comprimeren met een verscheidenheid aan codecs die zijn samengesteld van C ++ naar WebAssembly.
WebAssembly is een virtuele machine op laag niveau die de bytecode uitvoert die is opgeslagen in .wasm
bestanden. Deze byte -code is sterk getypt en gestructureerd op een manier dat het voor het hostsysteem veel sneller kan worden samengesteld en geoptimaliseerd dan JavaScript kan. WebAssembly biedt een omgeving om code uit te voeren met sandboxen en inbedding vanaf het begin.
Naar mijn ervaring worden de meeste prestatieproblemen op internet veroorzaakt door geforceerde lay -out en overmatige verf, maar zo nu en dan moet een app een computationeel dure taak uitvoeren die veel tijd kost. WebAssembly kan hier helpen.
Het hete pad
In Squoosh hebben we een JavaScript -functie geschreven die een beeldbuffer met veelvouden van 90 graden roteert. Hoewel offscreencanvas hier ideaal voor zou zijn, wordt het niet ondersteund in de browsers waarop we ons richtten, en een beetje buggy in Chrome .
Deze functie herhaalt over elke pixel van een invoerafbeelding en kopieert deze naar een andere positie in het uitvoerbeeld om rotatie te bereiken. Voor een 4094px bij 4096px -afbeelding (16 megapixels) zou het meer dan 16 miljoen iteraties van het innerlijke codeblok nodig hebben, wat we een "hot pad" noemen. Ondanks dat nogal grote aantal iteraties, voltooien twee van de drie browsers die we hebben getest de taak in 2 seconden of minder. Een acceptabele duur 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 duurt echter meer dan 8 seconden. De manier waarop browsers JavaScript optimaliseren is echt ingewikkeld en verschillende motoren optimaliseren voor verschillende dingen. Sommigen optimaliseren voor onbewerkte uitvoering, sommigen optimaliseren voor interactie met de DOM. In dit geval hebben we in één browser een ongeoptimaliseerd pad geraakt.
Webassembly daarentegen is volledig gebouwd rond de ruwe uitvoeringssnelheid. Dus als we snel, voorspelbare prestaties tussen browsers voor code zoals deze willen, kan WebAssembly helpen.
Webassembly voor voorspelbare prestaties
Over het algemeen kunnen JavaScript en WebAssembly dezelfde piekprestaties bereiken. Voor JavaScript kan deze prestaties echter alleen worden bereikt op het "snelle pad", en het is vaak lastig om op dat "snelle pad" te blijven. Een belangrijk voordeel dat WebAssembly biedt, zijn voorspelbare prestaties, zelfs tussen browsers. De strikte typen en architectuur op laag niveau stelt de compiler in staat om sterkere garanties te geven, zodat webassemblycode slechts één keer hoeft te worden geoptimaliseerd en altijd het "snelle pad" zal gebruiken.
Schrijven voor webassembly
Eerder namen we C/C ++ bibliotheken en hebben ze samengesteld om hun functionaliteit op internet te gebruiken. We hebben de code van de bibliotheken niet echt aangeraakt, we hebben zojuist kleine hoeveelheden C/C ++ code geschreven om de brug tussen de browser en de bibliotheek te vormen. Deze keer is onze motivatie anders: we willen iets helemaal opnieuw schrijven met WebAssembly in gedachten, zodat we gebruik kunnen maken van de voordelen die Webassembly heeft.
Webassembly -architectuur
Bij het schrijven voor WebAssembly is het nuttig om wat meer te begrijpen over wat WebAssembly eigenlijk is.
Om webassembly.org te citeren:
Wanneer u een stuk C- of roestcode compileert naar WebAssembly, krijgt u een .wasm
-bestand dat een module -verklaring bevat. Deze verklaring bestaat uit een lijst met "import" die de module verwacht van zijn omgeving, een lijst met exports die deze module beschikbaar stelt aan de host (functies, constanten, geheugenbekeningen) en natuurlijk de werkelijke binaire instructies voor de functies die zijn opgenomen in .
Iets dat ik me niet realiseerde totdat ik hiernaar keek: de stapel die WebAssembly een "stack-gebaseerde virtuele machine" maakt, wordt niet opgeslagen in het stuk geheugen dat webassemblymodules gebruiken. De stapel is volledig VM-interne 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 stapel gebruiken.
In ons geval moeten we wat extra geheugen 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.
Geheugenbeheer
Gewoonlijk, als u eenmaal extra geheugen gebruikt, zult u de noodzaak vinden om dat geheugen op de een of andere manier te beheren. Welke delen van het geheugen zijn in gebruik? Welke zijn gratis? In C heeft u bijvoorbeeld de malloc(n)
-functie die een geheugenruimte van n
opeenvolgende bytes vindt. Dit soort functies worden ook wel "allocators" genoemd. Natuurlijk moet de implementatie van de gebruikte allocator worden opgenomen in uw webassemblymodule en zal uw bestandsgrootte verhogen. Deze omvang en prestaties van deze geheugenbeheerfuncties kunnen behoorlijk variëren, afhankelijk van het gebruikte algoritme, en 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 daarom de afmetingen van de uitvoerafbeelding) voordat we de WebAssembly -module uitvoeren. Hier zagen we een kans: traditioneel zouden we de RGBA -buffer van de invoerafbeelding doorgeven als parameter aan een webassemblyfunctie en de geroteerde afbeelding als retourwaarde retourneren. Om die retourwaarde te genereren, zouden we de allocator moeten gebruiken. Maar omdat we weten dat de totale hoeveelheid geheugen nodig is (twee keer zo groot als de invoerafbeelding, eenmaal voor invoer en eenmaal voor uitvoer), kunnen we de invoerafbeelding in het WebAssembly -geheugen plaatsen met behulp van JavaScript , de WebAssembly -module uitvoeren om een 2e te genereren, Geroteerde afbeelding en gebruik vervolgens JavaScript om het resultaat terug te lezen. We kunnen wegkomen zonder geheugenbeheer te gebruiken!
Keuze te over
Als u naar de originele JavaScript-functie kijkt die we willen webassembly-fy, kunt u zien dat het een puur computationele code is zonder JavaScript-specifieke API's. Als zodanig moet het redelijk rechtstreeks naar voren zijn om deze code naar elke taal te porten. We hebben 3 verschillende talen geëvalueerd die samenstellen naar WebAssembly: C/C ++, Rust en AssemblyScript. De enige vraag die we moeten beantwoorden voor elk van de talen is: hoe hebben we toegang tot RAW -geheugen zonder geheugenbeheerfuncties te gebruiken?
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 compatibel met vlag. Dit is een kernonderdeel van de missie van de Emscripten, omdat het het compileren van bestaande C- en C ++ -code naar webassembly zo eenvoudig mogelijk wil maken.
Toegang tot RAW -geheugen is om die reden in de aard van C en pointers bestaan:
uint8_t* ptr = (uint8_t*)0x124;
ptr[0] = 0xFF;
Hier veranderen we het nummer 0x124
in een pointer naar niet-ondertekende, 8-bits 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 hebben tot individuele bytes voor lezen en schrijven. In ons geval kijken we naar een RGBA-buffer van een afbeelding die we opnieuw willen bestellen 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 gemakkelijker te maken, kunnen we een reeks niet-ondertekende, 32-bits gehele getallen maken. Volgens de conventie start onze invoerafbeelding bij adres 4 en onze uitvoerafbeelding begint direct nadat de invoerafbeelding is afgelopen:
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 hele JavaScript -functie naar C hebben geport, kunnen we het C -bestand samenstellen met emcc
:
$ emcc -O3 -s ALLOW_MEMORY_GROWTH=1 -o c.js rotate.c
Zoals altijd genereert EmScripten een lijmcodebestand genaamd c.js
en een WADM -module genaamd c.wasm
. Merk op dat de WADM -module gzips tot slechts ~ 260 bytes geken, terwijl de lijmcode ongeveer 3,5 kb na GZIP is. Na wat prutsen konden we de lijmcode weggooien en de WebAssembly -modules instantiëren met de vanille -API's. Dit is vaak mogelijk met Emscripten zolang u niets gebruikt uit de C -standaardbibliotheek.
Roest
Rust is een nieuwe, moderne programmeertaal met een rijk type systeem, geen runtime en een eigendomsmodel dat geheugenveiligheid en draadveiligheid garandeert. Rust ondersteunt ook WebAssembly als een kernfunctie en het Rust -team heeft veel uitstekende tooling bijgedragen aan het webassembly -ecosysteem.
Een van deze tools is wasm-pack
, door de Rustwasm-werkgroep . wasm-pack
neemt uw code en verandert er een webvriendelijke module van die out-of-the-box werkt met bundlers zoals Webpack. wasm-pack
is een extreem handige ervaring, maar werkt momenteel alleen voor roest. De groep overweegt ondersteuning toe te voegen voor andere talen voor webassembly-targeting.
In Rust zijn plakjes wat arrays zijn in C. En net als in C moeten we plakjes maken die onze startadressen gebruiken. Dit druist in tegen het geheugenveiligheidsmodel dat Rust afdwingt, dus om onze weg te krijgen, moeten we het unsafe
trefwoord 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;
}
}
De roestbestanden compileren met behulp van
$ wasm-pack build
levert een 7,6 kb WADM -module met ongeveer 100 bytes lijmcode (beide na GZIP).
AssemblyScript
AssemblyScript is een vrij jong project dat een typecript-to-webassembly-compiler wil zijn. Het is echter belangrijk op te merken dat het niet alleen een typescript verbruikt. AssemblyScript gebruikt dezelfde syntaxis als TypeScript maar schakelt de standaardbibliotheek voor zichzelf uit. Hun standaardbibliotheek modelleert de mogelijkheden van WebAssembly. Dat betekent dat je niet alleen een Typescript kunt compileren dat je tegen WebAssembly hebt rondgelopen, 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 type oppervlak dat onze rotate()
-functie heeft, was het vrij eenvoudig om deze code te porten naar assemblyscript. De functies load<T>(ptr: usize)
en store<T>(ptr: usize, value: T)
worden geleverd door AssemblyScript om toegang te krijgen tot RAW -geheugen. Om ons AssemblyScript -bestand te compileren, hoeven we alleen het NPM -pakket AssemblyScript/assemblyscript
te installeren en uit te voeren
$ asc rotate.ts -b assemblyscript.wasm --validate -O3
AssemblyScript biedt ons een ~ 300 bytes wasm -module en geen lijmcode. De module werkt gewoon met de Vanilla WebAssembly API's.
Webassembly forensics
Rust's 7.6KB is verrassend groot in vergelijking met de 2 andere talen. Er zijn een paar tools in het webassembly -ecosysteem die u kunnen helpen uw webassemblybestanden te analyseren (ongeacht de taal waarmee de is 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 Rust's WebAssembly -team dat een aantal inzichtelijke gegevens uit een WebAssembly -module haalt. De tool is niet roestspecifiek en stelt u in staat dingen zoals de oproepgrafiek van de module te inspecteren, ongebruikte of overbodige secties te bepalen en erachter te komen welke secties bijdragen aan de totale bestandsgrootte van uw module. De laatste kan worden gedaan met Twiggy's top
:
$ twiggy top rotate_bg.wasm
In dit geval kunnen we zien dat een meerderheid van onze bestandsgrootte voortkomt uit de allocator. Dat was verrassend omdat onze code geen dynamische toewijzingen gebruikt. Een andere grote bijdragende factor is een subsectie "functienamen".
wasme-strip
wasm-strip
is een hulpmiddel van de webassembly binaire toolkit of kortweg WABT. Het bevat een aantal tools waarmee u webassemblymodules kunt inspecteren en manipuleren. wasm2wat
is een demontage die van een binaire wasme-module verandert in een mens-leesbaar formaat. WABT bevat ook wat2wasm
waarmee je dat mens-leesbare formaat terug kunt veranderen in een binaire wasme-module. Hoewel we deze twee complementaire tools hebben gebruikt om onze webassemblybestanden te inspecteren, vonden we wasm-strip
het nuttigst. wasm-strip
verwijdert onnodige secties en metadata uit een webassemblymodule:
$ wasm-strip rotate_bg.wasm
Dit vermindert de bestandsgrootte van de roestmodule van 7,5 kb tot 6,6 kb (na GZIP).
wasme-opt
wasm-opt
is een hulpmiddel van binaryeen . Het neemt een WebAssembly -module en probeert het zowel te optimaliseren voor grootte als prestaties alleen gebaseerd op de bytecode. Sommige tools zoals Emscripten hebben deze tool al uitgevoerd, sommige anderen niet. Het is meestal een goed idee om te proberen wat extra bytes op te slaan door deze tools te gebruiken.
wasm-opt -O3 -o rotate_bg_opt.wasm rotate_bg.wasm
Met wasm-opt
kunnen we een andere handvol bytes afwijzen om in totaal 6,2 kb na GZIP te laten.
#! [No_std]
Na wat consult en onderzoek hebben we onze Rust-code opnieuw geschreven zonder de standaardbibliotheek van Rust te gebruiken, met behulp van de #![no_std]
-functie. Dit schakelt ook dynamische geheugenallocaties helemaal uit, waardoor de allocatorcode uit onze module wordt verwijderd. Dit roestbestand compileren met
$ rustc --target=wasm32-unknown-unknown -C opt-level=3 -o rust.wasm rotate.rs
leverde een 1,6 kb wasme-module op na wasm-opt
, wasm-strip
en gzip. Hoewel het nog steeds groter is dan de modules die worden gegenereerd door C en AssemblyScript, is het klein genoeg om als een lichtgewicht te worden beschouwd.
Prestatie
Voordat we tot conclusies trekken op basis van alleen bestandsgrootte - gingen we op deze reis om de prestaties te optimaliseren, geen bestandsgrootte. Dus hoe hebben we de prestaties meten 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 host-specifieke 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 om langzamere code te genereren. Zodra de module begint te draaien, observeert de browser welke delen vaak worden gebruikt en verzendt die via een meer optimaliserende maar langzamere compiler.
Onze use-case is interessant omdat de code voor het roteren van een afbeelding eenmaal, misschien twee keer zal worden gebruikt. Dus in de overgrote meerderheid van de gevallen zullen we nooit de voordelen van de optimaliserende compiler krijgen. Dit is belangrijk om in gedachten te houden bij benchmarking. Het uitvoeren van onze WebAssembly -modules 10.000 keer in een lus zou onrealistische resultaten opleveren. Om realistische cijfers te krijgen, moeten we de module eenmaal uitvoeren en beslissingen nemen op basis van de nummers van die enkele run.
Prestatievergelijking
Deze twee grafieken zijn verschillende weergaven op 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 tijdschema heb gekozen. Het is ook belangrijk dat alle benchmarks dezelfde 16 megapixeltestafbeelding en dezelfde hostmachine gebruikten, behalve voor één browser, die niet op dezelfde machine kon worden uitgevoerd.
Zonder deze grafieken te veel te analyseren, is het duidelijk dat we ons oorspronkelijke prestatieprobleem hebben opgelost: alle webassemblymodules worden in ~ 500 ms of minder uitgevoerd. Dit bevestigt wat we in het begin hebben ingedeeld: WebAssembly geeft u voorspelbare prestaties. Welke taal we ook kiezen, de variantie tussen browsers en talen is minimaal. Om precies te zijn: de standaardafwijking van JavaScript over alle browsers is ~ 400ms, terwijl de standaardafwijking van al onze webassemblymodules in alle browsers ~ 80ms is.
Poging
Een andere statistiek is de hoeveelheid inspanning die we moesten doen om onze WebAssembly -module in squoosh te maken en te integreren. Het is moeilijk om een numerieke waarde aan inspanning toe te wijzen, dus ik zal geen grafieken maken, maar er zijn een paar dingen waar ik op zou willen wijzen:
AssemblyScript was wrijvingsloos. Hiermee kunt u niet alleen Typescript gebruiken om WebAssembly te schrijven, waardoor code-review zeer eenvoudig wordt voor mijn collega's, maar het produceert ook lijmvrije webassemblymodules die erg klein zijn met fatsoenlijke prestaties. De tooling in het TypeScript -ecosysteem, zoals Poetier en Tslint, zal waarschijnlijk gewoon werken.
Roest in combinatie met wasm-pack
is ook extreem handig, maar meer uitblinkt in grotere webassemblyprojecten die bindingen waren en geheugenbeheer nodig zijn. We moesten een beetje afwijken van het gelukkige-path om een competitieve bestandsgrootte te bereiken.
C en Emscripten creëerden een zeer kleine en zeer performante webassemblymodule uit de doos, maar zonder de moed om in lijmcode te springen en deze te verminderen tot de kale benodigdheden, wordt de totale grootte (WebAssembly Module + Glue Code) behoorlijk groot.
Conclusie
Dus welke taal moet je gebruiken als je een JS Hot Path hebt en het sneller of consistenter wilt maken met WebAssembly. Zoals altijd met prestatievragen, is het antwoord: het hangt ervan af. Dus wat hebben we verzonden?
Vergelijking van de afweging van de modulegrootte / prestaties van de verschillende talen die we hebben gebruikt, lijkt de beste keuze C- of AssemblyScript te zijn. We hebben besloten roest te verzenden . Er zijn meerdere redenen voor deze beslissing: alle codecs die tot nu toe in squoosh zijn verzonden, zijn samengesteld met EMSCRIPEN. We wilden onze kennis over het webassembly -ecosysteem verbreden en een andere taal in de productie gebruiken. AssemblyScript is een sterk alternatief, maar het project is relatief jong en de compiler is niet zo volwassen als de Rust -compiler.
Hoewel het verschil in bestandsgrootte tussen roest en de grootte van de andere talen er behoorlijk drastisch uitziet in de spreidingsgrafiek, is het in werkelijkheid niet zo groot: het laden van 500b of 1,6 kb zelfs meer dan 2G duurt minder dan 1/10 seconde. En Rust zal hopelijk binnenkort de kloof dichten in termen van modulegrootte.
In termen van runtime -prestaties heeft Rust een sneller gemiddelde over browsers dan AssemblyScript. Vooral bij grotere projecten zal Rust eerder snellere code produceren zonder dat handmatige code -optimalisaties nodig hebben. Maar dat zou je er niet van moeten weerhouden om te gebruiken waar je het meest comfortabel bij bent.
Dat is allemaal gezegd: Assemblyscript is een geweldige ontdekking geweest. Hiermee kunnen webontwikkelaars webassemblymodules produceren zonder een nieuwe taal te leren. Het AssemblyScript -team is zeer responsief geweest en werkt actief aan het verbeteren van hun toolchain. We zullen in de toekomst zeker AssemblyScript 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 . Volgens de instructies daar (met name het inschakelen van linktijdoptimalisaties en handmatige paniekbehandeling) stelde ons in staat om "normale" roestcode te schrijven en terug te gaan naar het gebruik Cargo
(de npm
van roest) zonder de bestandsgrootte op te blazen. De roestmodule eindigt met 370b na GZIP. Kijk voor meer informatie naar de PR die ik heb geopend op squoosh .
Speciale dank aan Ashley Williams , Steve Klabnik , Nick Fitzgerald en Max Graey voor al hun hulp bij deze reis.