È sempre veloce
Nei miei articoli precedenti ho parlato di come WebAssembly consenta di portare l'ecosistema delle librerie di C/C++ sul web. Un'app che fa un uso intensivo delle librerie C/C++ è squoosh, la nostra app web che consente di comprimere le immagini con una serie di codec compilati da C++ a WebAssembly.
WebAssembly è una macchina virtuale di basso livello che esegue il bytecode memorizzato
nei file .wasm
. Questo codice a byte è fortemente tipizzato e strutturato in modo da poter essere compilato e ottimizzato per il sistema host molto più velocemente di JavaScript. WebAssembly fornisce un ambiente per eseguire codice che ha tenuto conto della sandboxing e dell'embedding fin dall'inizio.
Secondo la mia esperienza, la maggior parte dei problemi di prestazioni sul web è causata da un layout forzato e da un eccessivo rendering, ma di tanto in tanto un'app deve eseguire un'attività computazionalmente complessa che richiede molto tempo. WebAssembly può aiutarti in questo caso.
The Hot Path
In squoosh abbiamo scritto una funzione JavaScript che ruota un buffer di immagini per multipli di 90 gradi. Anche se OffscreenCanvas sarebbe ideale per questo, non è supportato nei browser di destinazione e presenta alcuni bug in Chrome.
Questa funzione esegue l'iterazione su ogni pixel di un'immagine di input e lo copia in una posizione diversa nell'immagine di output per ottenere la rotazione. Per un'immagine di 4094 x 4096 px (16 megapixel) sono necessarie oltre 16 milioni di iterazioni del blocco di codice interno, che è ciò che chiamiamo "percorso caldo". Nonostante il numero piuttosto elevato di iterazioni, due browser su tre che abbiamo testato completano l'attività in meno di 2 secondi. Una durata accettabile per questo tipo di interazione.
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;
}
}
Un browser, invece, impiega più di 8 secondi. Il modo in cui i browser ottimizzano JavaScript è molto complicato e i diversi motori ottimizzano per aspetti diversi. Alcuni sono ottimizzati per l'esecuzione non elaborata, altri per l'interazione con il DOM. In questo caso, abbiamo raggiunto un percorso non ottimizzato in un browser.
WebAssembly, invece, è costruito interamente in base alla velocità di esecuzione non elaborata. Pertanto, se vogliamo prestazioni rapide e prevedibili su tutti i browser per codice come questo, WebAssembly può essere utile.
WebAssembly per prestazioni prevedibili
In generale, JavaScript e WebAssembly possono raggiungere le stesse prestazioni di picco. Tuttavia, per JavaScript questo rendimento può essere raggiunto solo nel "percorso rapido", e spesso è difficile rimanere in questo "percorso rapido". Uno dei vantaggi principali di WebAssembly è la prevedibilità delle prestazioni, anche su più browser. La tipizzazione rigorosa e l'architettura a basso livello consentono al compilatore di offrire maggiori garanzie, in modo che il codice WebAssembly debba essere ottimizzato una sola volta e utilizzi sempre il "percorso rapido".
Scrittura per WebAssembly
In precedenza, prendevamo le librerie C/C++ e le compilavamo in WebAssembly per utilizzarne la funzionalità sul web. Non abbiamo toccato il codice delle librerie, abbiamo solo scritto piccole quantità di codice C/C++ per creare il ponte tra il browser e la libreria. Questa volta la nostra motivazione è diversa: vogliamo scrivere qualcosa da zero tenendo presente WebAssembly per poter sfruttare i vantaggi di questa tecnologia.
Architettura WebAssembly
Quando scrivi per WebAssembly, è utile capire un po' di più su cosa sia effettivamente WebAssembly.
Citando WebAssembly.org:
Quando compili un frammento di codice C o Rust in WebAssembly, ottieni un .wasm
file contenente una dichiarazione del modulo. Questa dichiarazione è composta da un elenco di "importazioni" che il modulo si aspetta dal proprio ambiente, un elenco di esportazioni che il modulo rende disponibili all'host (funzioni, costanti, blocchi di memoria) e, naturalmente, le istruzioni binarie effettive per le funzioni al suo interno.
Un aspetto che non avevo notato finché non ho esaminato la questione: lo stack che rende WebAssembly una "macchina virtuale basata su stack" non è memorizzato nel blocco di memoria utilizzato dai moduli WebAssembly. Lo stack è completamente interno alla VM e inaccessibile agli sviluppatori web (tranne tramite DevTools). Di conseguenza, è possibile scrivere moduli WebAssembly che non richiedono alcuna memoria aggiuntiva e utilizzano solo lo stack interno della VM.
Nel nostro caso, dovremo utilizzare un po' di memoria aggiuntiva per consentire l'accesso arbitrario ai pixel della nostra immagine e generare una versione ruotata di quell'immagine. È per questo che è stato creato WebAssembly.Memory
.
Gestione della memoria
In genere, una volta utilizzata la memoria aggiuntiva, dovrai gestirla in qualche modo. Quali parti della memoria sono in uso? Quali sono senza costi?
In C, ad esempio, hai la funzione malloc(n)
che trova uno spazio di memoria
di n
byte consecutivi. Le funzioni di questo tipo sono chiamate anche "allocatori".
Ovviamente, l'implementazione dell'allocatore in uso deve essere inclusa nel modulo WebAssembly e aumenterà le dimensioni del file. Le dimensioni e le prestazioni di queste funzioni di gestione della memoria possono variare in modo significativo a seconda dell'algoritmo utilizzato, motivo per cui molti linguaggi offrono più implementazioni tra cui scegliere ("dmalloc", "emmalloc", "wee_alloc" e così via).
Nel nostro caso, conosciamo le dimensioni dell'immagine di input (e quindi le dimensioni dell'immagine di output) prima di eseguire il modulo WebAssembly. Qui abbiamo visto un'opportunità: in genere, passiamo il buffer RGBA dell'immagine di input come parametro a una funzione WebAssembly e restituiamo l'immagine ruotata come valore restituito. Per generare questo valore restituito, dobbiamo utilizzare l'allocatore. Tuttavia, poiché conosciamo la quantità totale di memoria necessaria (il doppio delle dimensioni dell'immagine di input, una volta per l'input e una volta per l'output), possiamo inserire l'immagine di input nella memoria WebAssembly utilizzando JavaScript, eseguire il modulo WebAssembly per generare una seconda immagine ruotata e poi utilizzare JavaScript per leggere il risultato. Possiamo farcela senza utilizzare alcuna gestione della memoria.
Una scelta ampia
Se hai esaminato la funzione JavaScript originale che vogliamo convertire in WebAssembly, puoi vedere che si tratta di un codice puramente computazionale senza API specifiche per JavaScript. Di conseguenza, dovrebbe essere abbastanza semplice portare questo codice in qualsiasi lingua. Abbiamo valutato tre lingue diverse che vengono compilate in WebAssembly: C/C++, Rust e AssemblyScript. L'unica domanda a cui dobbiamo rispondere per ciascuna delle lingue è: come accediamo alla memoria non elaborata senza utilizzare le funzioni di gestione della memoria?
C ed Emscripten
Emscripten è un compilatore C per il target WebAssembly. Lo scopo di Emscripten è agire come sostituzione plug-in per compilatori C ben noti come GCC o clang ed è compatibile per la maggior parte dei flag. Si tratta di un aspetto fondamentale della missione di Emscripten, poiché vuole semplificare al massimo la compilazione del codice C e C++ esistente in WebAssembly.
L'accesso alla memoria non elaborata è nella natura stessa del linguaggio C e i puntatori esistono per questo motivo:
uint8_t* ptr = (uint8_t*)0x124;
ptr[0] = 0xFF;
Qui stiamo trasformando il numero 0x124
in un puntatore a interi (o byte) non firmati di 8 bit. In questo modo, la variabile ptr
viene trasformata in un array che inizia dall'indirizzo di memoria 0x124
e che possiamo utilizzare come qualsiasi altro array, in modo da accedere ai singoli byte per la lettura e la scrittura. Nel nostro caso, stiamo esaminando un buffer RGBA di un'immagine che vogliamo riordinare per ottenere la rotazione. Per spostare un pixel, in realtà dobbiamo spostare contemporaneamente 4 byte consecutivi (un byte per ogni canale: R, G, B e A). Per semplificare, possiamo creare un array di interi non firmati a 32 bit. Per convenzione, l'immagine di input inizierà all'indirizzo 4 e l'immagine di output inizierà subito dopo la fine dell'immagine di input:
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;
}
}
Dopo aver portato l'intera funzione JavaScript in C, possiamo compilare il file C con emcc
:
$ emcc -O3 -s ALLOW_MEMORY_GROWTH=1 -o c.js rotate.c
Come sempre, emscripten genera un file di codice di collegamento denominato c.js
e un modulo wasm chiamato c.wasm
. Tieni presente che il modulo wasm viene compresso in gzip solo a circa 260 byte, mentre il codice glue è di circa 3,5 KB dopo la compressione. Dopo alcune modifiche, siamo riusciti a eliminare il codice di collegamento e a creare istanze dei moduli WebAssembly con le API standard.
Spesso è possibile con Emscripten, a condizione che non utilizzi nulla
dalla libreria standard C.
Rust
Rust è un nuovo linguaggio di programmazione moderno con un sistema di tipi completo, senza runtime e un modello di proprietà che garantisce la sicurezza della memoria e dei thread. Rust supporta anche WebAssembly come funzionalità di base e il team di Rust ha contribuito con molti strumenti eccellenti all'ecosistema WebAssembly.
Uno di questi strumenti è wasm-pack
, sviluppato dal gruppo di lavoro rustwasm. wasm-pack
trasforma il codice in un modulo web-friendly che funziona subito con bundler come webpack. wasm-pack
è un'esperienza estremamente comoda, ma al momento funziona solo per Rust. Il gruppo sta considerando di aggiungere il supporto per altri linguaggi di destinazione WebAssembly.
In Rust, i slice sono gli array in C. E, proprio come in C, dobbiamo creare
slice che utilizzano i nostri indirizzi di inizio. Ciò va contro il modello di sicurezza della memoria imposto da Rust, quindi per ottenere ciò che vogliamo dobbiamo utilizzare la parola chiave unsafe
, che ci consente di scrivere codice non conforme a questo modello.
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;
}
}
Compilare i file Rust utilizzando
$ wasm-pack build
genera un modulo wasm di 7,6 KB con circa 100 byte di codice di collegamento (entrambi dopo gzip).
AssemblyScript
AssemblyScript è un progetto relativamente giovane che mira a essere un compilatore da TypeScript a WebAssembly. È importante notare, tuttavia, che non consumerà solo TypeScript. AssemblyScript utilizza la stessa sintassi di TypeScript, ma sostituisce la libreria standard con la propria. La loro libreria standard modella le funzionalità di WebAssembly. Ciò significa che non puoi semplicemente compilare qualsiasi codice TypeScript che hai a disposizione in WebAssembly, ma non significa che devi imparare un nuovo linguaggio di programmazione per scrivere codice WebAssembly.
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;
}
}
Considerando la piccola superficie di tipo della nostra funzione rotate()
, è stato abbastanza facile eseguire il porting di questo codice in AssemblyScript. Le funzioni load<T>(ptr:
usize)
e store<T>(ptr: usize, value: T)
sono fornite da AssemblyScript per accedere alla memoria non elaborata. Per compilare il nostro file AssemblyScript, dobbiamo solo installare il pacchetto npm AssemblyScript/assemblyscript
ed eseguire
$ asc rotate.ts -b assemblyscript.wasm --validate -O3
AssemblyScript ci fornirà un modulo wasm di circa 300 byte e nessun codice di collegamento. Il modulo funziona solo con le API WebAssembly standard.
Analisi forense di WebAssembly
I 7,6 KB di Rust sono sorprendentemente grandi rispetto alle altre due lingue. Nell'ecosistema WebAssembly sono disponibili un paio di strumenti che possono aiutarti ad analizzare i tuoi file WebAssembly (indipendentemente dal linguaggio con cui sono stati creati) e a capire cosa sta succedendo, nonché a migliorare la situazione.
Twiggy
Twiggy è un altro strumento del team WebAssembly di Rust che estrae una serie di dati utili da un modulo WebAssembly. Lo strumento non è specifico per Rust e ti consente di ispezionare elementi come il gráfo di chiamate del modulo, determinare le sezioni inutilizzate o superflue e capire quali sezioni contribuiscono alle dimensioni totali del file del modulo. La seconda operazione può essere eseguita con il comando top
di Twiggy:
$ twiggy top rotate_bg.wasm
In questo caso possiamo vedere che la maggior parte delle dimensioni del file è dovuta all'allocatore. È stato sorprendente, dato che il nostro codice non utilizza allocazioni dinamiche. Un altro fattore importante è una sottosezione "Nomi delle funzioni".
wasm-strip
wasm-strip
è uno strumento del WebAssembly Binary Toolkit, o wabt in breve. Contiene un paio di strumenti che ti consentono di ispezionare e manipolare i moduli WebAssembly.
wasm2wat
è un disassembler che trasforma un modulo wasm binario in un formato leggibile. Wabt contiene anche wat2wasm
che ti consente di trasformare nuovamente questo formato leggibile in un modulo wasm binario. Abbiamo usato questi due strumenti complementari per esaminare i nostri file WebAssembly, ma abbiamo trovato wasm-strip
i più utili. wasm-strip
rimuove sezioni e metadati non necessari da un modulo WebAssembly:
$ wasm-strip rotate_bg.wasm
In questo modo, le dimensioni del file del modulo Rust vengono ridotte da 7,5 KB a 6,6 KB (dopo gzip).
wasm-opt
wasm-opt
è uno strumento di Binaryen.
Prende un modulo WebAssembly e cerca di ottimizzarlo sia per dimensioni che per prestazioni in base solo al bytecode. Alcuni strumenti, come Emscripten, eseguono già questo strumento, mentre altri no. In genere, è buona norma provare a risparmiare qualche altro byte utilizzando questi strumenti.
wasm-opt -O3 -o rotate_bg_opt.wasm rotate_bg.wasm
Con wasm-opt
possiamo risparmiare un'altra manciata di byte per ottenere un totale di
6,2 KB dopo gzip.
#![no_std]
Dopo alcune consulenze e ricerche, abbiamo riscritto il codice Rust senza utilizzare la libreria standard di Rust, utilizzando la funzionalità #![no_std]
. Inoltre, vengono disattivate del tutto le allocazioni di memoria dinamiche, rimuovendo il codice dell'allocatore dal nostro modulo. Compila questo file Rust con
$ rustc --target=wasm32-unknown-unknown -C opt-level=3 -o rust.wasm rotate.rs
ha generato un modulo wasm di 1,6 KB dopo wasm-opt
, wasm-strip
e gzip. Anche se è ancora più grande dei moduli generati da C e AssemblyScript, è abbastanza piccolo da essere considerato leggero.
Prestazioni
Prima di trarre conclusioni affrettate in base alle dimensioni dei file, tieni presente che abbiamo intrapreso questo percorso per ottimizzare le prestazioni, non le dimensioni dei file. Come abbiamo misurato il rendimento e quali sono stati i risultati?
Come eseguire il benchmarking
Sebbene WebAssembly sia un formato bytecode di basso livello, deve comunque essere inviato tramite un compilatore per generare codice macchina specifico per l'host. Come JavaScript, il compilatore lavora in più fasi. In parole povere: la prima fase è molto più rapida in fase di compilazione, ma tende a generare codice più lento. Una volta avviato il modulo, il browser osserva quali parti vengono utilizzate di frequente e le invia tramite un compilatore più ottimizzato, ma più lento.
Il nostro caso d'uso è interessante in quanto il codice per ruotare un'immagine verrà utilizzato una volta, forse due. Pertanto, nella maggior parte dei casi non potremo mai usufruire dei vantaggi del compilatore ottimizzatore. È importante tenerlo presente quando si esegue il benchmarking. L'esecuzione dei nostri moduli WebAssembly 10.000 volte in un loop darebbe risultati non realistici. Per ottenere numeri realistici, dobbiamo eseguire il modulo una volta e prendere decisioni in base ai numeri di quella singola esecuzione.
Confronto del rendimento
Questi due grafici sono viste diverse degli stessi dati. Nel primo grafico, il confronto avviene per browser, nel secondo per lingua utilizzata. Tieni conto che ho scelto una scala temporale logaritmica. È inoltre importante che tutti i benchmark abbiano utilizzato la stessa immagine di test da 16 megapixel e la stessa macchina host, ad eccezione di un browser che non poteva essere eseguito sulla stessa macchina.
Senza analizzare troppo questi grafici, è chiaro che abbiamo risolto il nostro problema di prestazioni originale: tutti i moduli WebAssembly vengono eseguiti in circa 500 ms o meno. Questo conferma quanto affermato all'inizio: WebAssembly offre prestazioni prevedibili. Indipendentemente dalla lingua scelta, la varianza tra browser e lingue è minima. Per essere precisi: la deviazione standard di JavaScript su tutti i browser è di circa 400 ms, mentre la deviazione standard di tutti i nostri moduli WebAssembly su tutti i browser è di circa 80 ms.
Impegno
Un'altra metrica è l'impegno che abbiamo dovuto mettere per creare e integrare il nostro modulo WebAssembly in Squoosh. È difficile assegnare un valore numerico all'impegno, quindi non creerò grafici, ma vorrei sottolineare alcune cose:
AssemblyScript è stato facile da usare. Non solo consente di utilizzare TypeScript per scrivere WebAssembly, semplificando la revisione del codice per i miei colleghi, ma produce anche moduli WebAssembly senza glue molto piccoli con prestazioni decenti. È probabile che gli strumenti nell'ecosistema TypeScript, come prettier e tslint, funzioneranno.
Anche Rust in combinazione con wasm-pack
è estremamente pratico, ma eccelle maggiormente per i progetti WebAssembly più grandi in cui sono necessarie le associazioni e la gestione della memoria. Abbiamo dovuto discostarci un po' dal percorso ottimale per ottenere un
file di dimensioni competitive.
C ed Emscripten hanno creato un modulo WebAssembly molto piccolo e ad alte prestazioni out of the box, ma senza il coraggio di passare al codice di collegamento e ridurlo alle minime esigenze, le dimensioni totali (modulo WebAssembly + codice di collegamento) risultano essere piuttosto grandi.
Conclusione
Quindi, quale linguaggio dovresti utilizzare se hai un percorso caldo JS e vuoi accelerarlo o renderlo più coerente con WebAssembly? Come sempre per le domande sul rendimento, la risposta è: dipende. Cosa abbiamo spedito?
Confrontando il compromesso tra dimensioni del modulo e prestazioni dei diversi linguaggi che abbiamo utilizzato, la scelta migliore sembra essere C o AssemblyScript. Abbiamo deciso di rilasciare Rust. Questa decisione è stata presa per diversi motivi: tutti i codec finora forniti in Squoosh sono stati compilati utilizzando Emscripten. Volevamo ampliare le nostre conoscenze sull'ecosistema WebAssembly e utilizzare un linguaggio diverso in produzione. AssemblyScript è un'alternativa valida, ma il progetto è relativamente giovane e il compilatore non è maturo come quello di Rust.
Sebbene la differenza di dimensioni dei file tra Rust e le altre lingue sembri piuttosto drastica nel grafico a dispersione, in realtà non è così importante: caricare 500 B o 1,6 KB anche su 2 G richiede meno di un decimo di secondo. e ci auguriamo che Rust colmi presto il divario in termini di dimensioni dei moduli.
In termini di prestazioni di runtime, Rust ha una media più veloce nei browser rispetto ad AssemblyScript. Soprattutto per i progetti più grandi, Rust avrà maggiori probabilità di produrre codice più veloce senza bisogno di ottimizzazioni manuali. Tuttavia, questo non dovrebbe impedirti di utilizzare ciò che ti è più familiare.
Detto questo, AssemblyScript è stata una grande scoperta. Consente agli sviluppatori web di produrre moduli WebAssembly senza dover imparare un nuovo linguaggio. Il team di AssemblyScript è stato molto reattivo e sta lavorando attivamente per migliorare la propria toolchain. In futuro, continueremo a tenere d'occhio AssemblyScript.
Aggiornamento: Rust
Dopo la pubblicazione di questo articolo, Nick Fitzgerald del team di Rust ci ha segnalato il suo eccellente libro su Rust Wasm, che contiene una sezione sull'ottimizzazione delle dimensioni dei file. Seguire le istruzioni riportate (in particolare l'attivazione delle ottimizzazioni in fase di linking e la gestione manuale dei panic) ci ha permesso di scrivere codice Rust "normale" e di tornare a utilizzare Cargo
(il npm
di Rust) senza aumentare le dimensioni del file. Il modulo Rust termina con 370 B dopo gzip. Per maggiori dettagli, dai un'occhiata al RP che ho aperto su Squoosh.
Un ringraziamento speciale ad Ashley Williams, Steve Klabnik, Nick Fitzgerald e Max Graey per tutto l'aiuto che ci hanno dato in questo percorso.