È 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 è causato da un layout forzato e da una visualizzazione eccessiva, ma di tanto in tanto un'app deve svolgere un'attività dal punto di vista informatico 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 immagine di 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. Quindi, se vogliamo prestazioni rapide e prevedibili nei vari browser per codice come questo, WebAssembly può essere di aiuto.
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.
Una cosa che non avevo capito 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 a una VM e inaccessibile agli sviluppatori web (tranne che 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. Questo è
a cosa serve 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, esiste 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 andarcene senza usare 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, il trasferimento di questo codice in qualsiasi linguaggio dovrebbe essere piuttosto semplice. Abbiamo valutato tre lingue diverse che vengono compilate in WebAssembly: C/C++, Rust e AssemblyScript. L'unica domanda a cui dobbiamo rispondere per ogni lingua è: come si accede alla memoria non elaborata senza utilizzare le funzioni di gestione della memoria?
C ed Emscripten
Emscripten è un compilatore C per la destinazione di WebAssembly. L'obiettivo di Emscripten è fungere da sostituto drop-in di noti compilatori C come GCC o clang, per lo più compatibile con i 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
diventa un array che parte dall'indirizzo di memoria 0x124
, che possiamo utilizzare come qualsiasi altro array, consentendoci di accedere a 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.
Questo è spesso possibile con Emscripten, purché non utilizzi elementi della 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à principale e il team di Rust ha contribuito con molti strumenti eccellenti all'ecosistema di 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 valutando di aggiungere il supporto per altre lingue di targeting di 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;
}
}
Compila 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 TypeScript presente su WebAssembly, ma significa che non devi imparare un nuovo linguaggio di programmazione per scrivere 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 Vanilla WebAssembly.
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 ti indicano cosa sta succedendo, oltre ad aiutarti a migliorare la tua 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 passano 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 completamente 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 prodotto un modulo wasm da 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 passare alle conclusioni basate solo sulle dimensioni dei file, 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
Nonostante WebAssembly sia un formato bytecode di basso livello, deve comunque essere inviato tramite un compilatore per generare un codice macchina specifico dell'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. Quindi, nella stragrande maggioranza dei casi, non otterremo mai i vantaggi del compilatore ottimizzatore. È importante tenerlo presente quando si esegue il benchmarking. Eseguire i moduli WebAssembly 10.000 volte in un loop fornire risultati non realistici. Per ottenere numeri realistici, dovremmo 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 semplice. 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 dell'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 e Emscripten hanno creato subito un modulo WebAssembly molto piccolo e ad alte prestazioni , ma senza il coraggio di iniziare a utilizzare il codice glue e ridurlo alle indispensabili esigenze, la dimensione totale (modulo WebAssembly + codice glue) risulta molto grande.
Conclusione
Quale linguaggio dovresti usare se hai un percorso attivo di JS e vuoi renderlo più veloce o 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 è dovuta a diversi motivi: tutti i codec forniti finora in Squoosh vengono 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 nei progetti più grandi, Rust avrà maggiori probabilità di produrre codice più veloce senza bisogno di ottimizzazioni manuali del codice. Ma questo non dovrebbe impedirti di utilizzare ciò che ritieni più adatto.
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 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 del tempo di collegamento e
la gestione manuale del panico) ci ha consentito di scrivere codice Rust "normale" e di tornare a utilizzare
Cargo
(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 il loro aiuto in questo percorso.