Sostituzione di un percorso rapido nel codice JavaScript dell'app con WebAssembly

È sempre veloce,

Nei miei articoli precedenti ho parlato di come WebAssembly ti consenta di portare l'ecosistema bibliotecario di C/C++ sul web. Un'app che fa ampio uso delle librerie C/C++ è squoosh, la nostra app web che consente di comprimere le immagini con una varietà di codec compilati da C++ a WebAssembly.

WebAssembly è una macchina virtuale di basso livello che esegue il bytecode archiviato nei file .wasm. Questo byte code è fortemente digitato e strutturato in modo da poter essere compilato e ottimizzato per il sistema host molto più velocemente di JavaScript. WebAssembly fornisce un ambiente per l'esecuzione del codice che tiene conto del sandbox e dell'incorporamento sin dall'inizio.

In base alla mia esperienza, la maggior parte dei problemi di prestazioni sul web sono causati da un layout forzato e da una visualizzazione eccessiva, ma di tanto in tanto un'app deve eseguire un'attività computazionale costosa che richiede molto tempo. WebAssembly può aiutarti qui.

Il percorso più caldo

In squoosh abbiamo scritto una funzione JavaScript che ruota il buffer di un'immagine di multipli di 90 gradi. Anche se OffscreenCanvas sarebbe l'ideale, non è supportato in tutti i browser che abbiamo scelto come target e presenta un buggy in Chrome.

Questa funzione esegue l'iterazione su ogni pixel di un'immagine di input e la copia in una posizione diversa nell'immagine di output per ottenere la rotazione. Per un'immagine da 4094 x 4096 px (16 megapixel) sono necessarie oltre 16 milioni di iterazioni del blocco di codice interno, che definiamo "hot path". Nonostante il numero piuttosto elevato di iterazioni, due browser su tre che abbiamo testato completano l'attività in due secondi o meno. 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 è davvero complicato e i vari motori ottimizzano per cose diverse. Alcune ottimizzano per l'esecuzione non elaborata, altre per l'interazione con il DOM. In questo caso, abbiamo raggiunto un percorso non ottimizzato in un browser.

WebAssembly, invece, si basa interamente sulla velocità di esecuzione non elaborata. Pertanto, se vogliamo prestazioni veloci e prevedibili nei vari browser per codice come questo, WebAssembly può essere d'aiuto.

WebAssembly per prestazioni prevedibili

In generale, JavaScript e WebAssembly possono raggiungere le stesse prestazioni di picco. Tuttavia, per JavaScript queste prestazioni possono essere raggiunte solo nel "percorso rapido" e spesso è difficile rimanere su quel "percorso rapido". Un vantaggio chiave offerto da WebAssembly è le prestazioni prevedibili, anche tra browser. La rigorosa digitazione e l'architettura di basso livello consentono al compilatore di offrire garanzie più solide in modo che il codice WebAssembly debba essere ottimizzato una sola volta e utilizzi sempre il "percorso rapido".

Scrittura per WebAssembly

Precedentemente abbiamo acquisito le librerie C/C++ e le abbiamo compilate in WebAssembly per utilizzare la loro funzionalità sul web. Non abbiamo davvero toccato il codice delle librerie, abbiamo solo scritto piccole quantità di codice C/C++ per formare il ponte tra il browser e la libreria. Questa volta la nostra motivazione è diversa: vogliamo scrivere qualcosa da zero tenendo presente WebAssembly in modo da poter sfruttare i vantaggi offerti da WebAssembly.

Architettura di WebAssembly

Quando scrivi per WebAssembly, è utile comprendere un po' di più su cosa sia effettivamente WebAssembly.

Per citare WebAssembly.org:

Quando compili una porzione di codice C o Rust in WebAssembly, ottieni un file .wasm che contiene una dichiarazione del modulo. Questa dichiarazione consiste in un elenco di "importazioni" che il modulo prevede dal proprio ambiente, un elenco di esportazioni che questo modulo rende disponibili per l'host (funzioni, costanti, blocchi di memoria) e, naturalmente, le istruzioni binarie effettive per le funzioni contenute al suo interno.

Qualcosa che non avevo capito finché non ho esaminato: lo stack che rende WebAssembly una "macchina virtuale basata su stack" non viene archiviato nel blocco di memoria utilizzato dai moduli WebAssembly. Lo stack è completamente interno alle VM e inaccessibile agli sviluppatori web (tranne tramite DevTools). Di conseguenza, è possibile scrivere moduli WebAssembly che non richiedono memoria aggiuntiva e utilizzano solo lo stack interno della VM.

Nel nostro caso, dovremo utilizzare della memoria aggiuntiva per consentire l'accesso arbitrario ai pixel dell'immagine e generare una versione ruotata dell'immagine. WebAssembly.Memory è a questo scopo.

Gestione della memoria

In genere, una volta utilizzata memoria aggiuntiva, sarà necessario gestire in qualche modo quella memoria. Quali parti della memoria sono in uso? Quali sono senza costi? In C, ad esempio, è presente la funzione malloc(n) che trova uno spazio di memoria di n byte consecutivi. Le funzioni di questo tipo sono chiamate anche "allocatori". Naturalmente, l'implementazione dell'allocatore in uso deve essere inclusa nel modulo WebAssembly per aumentare 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 diverse 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. Abbiamo visto un'opportunità: tradizionalmente, passavamo il buffer RGBA dell'immagine di input come parametro a una funzione WebAssembly e restituiva l'immagine ruotata come valore restituito. Per generare il valore restituito dobbiamo usare l'allocatore. Ma 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, quindi utilizzare JavaScript per leggere il risultato. Possiamo allontanarci senza dover ricorrere alla gestione della memoria.

L'imbarazzo della scelta

Se hai esaminato la funzione JavaScript originale che vogliamo da WebAssembly-fy, puoi vedere che è un codice puramente computazionale senza API specifiche per JavaScript. Pertanto, dovrebbe essere abbastanza semplice trasferire questo codice in qualsiasi linguaggio. Abbiamo valutato 3 diversi linguaggi che si compilano in WebAssembly: C/C++, Rust e AssemblyScript. L'unica domanda che dobbiamo rispondere per ogni lingua è: come si accede alla memoria non elaborata senza utilizzare le funzioni di gestione della memoria?

C e Emscripten

Emscripten è un compilatore C per la destinazione WebAssembly. L'obiettivo di Emscripten è quello di fungere da sostituto per i noti compilatori C come GCC o clang ed è per lo più compatibile con i flag. Questa è una parte fondamentale della missione di Emscripten, in quanto intende semplificare il più possibile la compilazione del codice C e C++ esistente in WebAssembly.

L'accesso alla memoria non elaborata è della stessa natura del C e i puntatori esistono proprio per questo motivo:

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

Qui trasformeremo il numero 0x124 in un puntatore a numeri interi (o byte) a 8 bit non firmati. In questo modo, la variabile ptr viene trasformata in modo efficace in un array a partire dall'indirizzo di memoria 0x124, 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 4 byte consecutivi (un byte per ogni canale: R, G, B e A). Per semplificare questa operazione, possiamo creare un array di numeri interi a 32 bit senza segno. Per convenzione, l'immagine di input inizierà all'indirizzo 4 e l'immagine di output inizierà direttamente 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 trasferito 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 glue denominato c.js e un modulo wasm chiamato c.wasm. Nota che il modulo wasm gzip genera solo circa 260 byte, mentre il codice glue è di circa 3,5 kB dopo gzip. Dopo un po' di gioco, siamo stati in grado di eliminare il codice collante e creare un'istanza dei moduli WebAssembly con le API Vanilla. Questo è spesso possibile con Emscripten, a condizione di non utilizzare nulla della libreria standard C.

Rust

Rust è un nuovo linguaggio di programmazione moderno con un sistema di tipi avanzati, senza runtime e un modello di proprietà che garantisce la sicurezza della memoria e la sicurezza dei thread. Rust supporta anche WebAssembly come funzionalità principale e il team di Rust ha contribuito a molti strumenti eccellenti nell'ecosistema WebAssembly.

Uno di questi strumenti è wasm-pack, del gruppo di lavoro rustwasm. wasm-pack prende il tuo codice e lo trasforma 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 altri linguaggi di targeting WebAssembly.

In Rust, le sezioni sono cosa sono gli array in C. E proprio come in C, dobbiamo creare sezioni che usano i nostri indirizzi di partenza. Questo si oppone al modello di sicurezza della memoria applicato da Rust, quindi per iniziare dobbiamo utilizzare la parola chiave unsafe, consentendoci di scrivere codice non conforme a quel 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;
    }
}

Compilazione dei file Rust usando

$ wasm-pack build

restituisce un modulo Wasm da 7,6 KB con circa 100 byte di codice colla (entrambi dopo gzip).

AssemblyScript

AssemblyScript è un progetto piuttosto giovane che mira a essere un compilatore da TypeScript a WebAssembly. È importante notare, tuttavia, che non si limita a consumare TypeScript. AssemblyScript utilizza la stessa sintassi di TypeScript, ma esclude la libreria standard. La libreria standard modella le funzionalità di WebAssembly. Ciò significa che non puoi semplicemente compilare qualsiasi TypeScript che hai in mente in 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 le piccole dimensioni della funzione rotate(), è stato abbastanza facile trasferire questo codice in AssemblyScript. Le funzioni load<T>(ptr: usize) e store<T>(ptr: usize, value: T) vengono 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 glue. Il modulo funziona solo con le API WebAssembly Vanilla.

WebAssembly Forensics

I 7,6 KB di Rust sono sorprendentemente grandi rispetto alle altre 2 lingue. Esistono un paio di strumenti nell'ecosistema WebAssembly che possono aiutarti ad analizzare i file WebAssembly (indipendentemente dal linguaggio con cui sono stati creati) e a indicarti cosa sta succedendo, aiutandoti anche a migliorare la situazione.

Twiggy

Twiggy è un altro strumento del team WebAssembly di Rust che estrae una serie di dati approfonditi da un modulo WebAssembly. Lo strumento non è specifico per Rust e ti consente di esaminare elementi come il grafico delle chiamate del modulo, determinare le sezioni inutilizzate o superflue e capire quali sezioni contribuiscono alle dimensioni totali dei file del modulo. Il secondo può essere eseguito con il comando top di Twiggy:

$ twiggy top rotate_bg.wasm
Screenshot dell&#39;installazione di Twiggy

In questo caso, possiamo vedere che la maggior parte delle nostre dimensioni di file deriva dall'allocatore. È sorprendente, dato che il nostro codice non utilizza le allocazioni dinamiche. Un altro fattore importante è la sottosezione relativa ai nomi delle funzioni.

striscia di wasm

wasm-strip è uno strumento di WebAssembly Binary Toolkit, o wabt. Contiene un paio di strumenti che consentono di esaminare 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 quel formato leggibile di nuovo in un modulo Wabt binario. Anche se abbiamo utilizzato questi due strumenti complementari per esaminare i nostri file WebAssembly, abbiamo scoperto che wasm-strip è il più utile. wasm-strip rimuove le sezioni e i metadati non necessari da un modulo WebAssembly:

$ wasm-strip rotate_bg.wasm

Questo riduce le dimensioni del file del modulo di ruggine da 7,5 KB a 6,6 KB (dopo gzip).

wasm-opt

wasm-opt è uno strumento di Binaryen. Richiede un modulo WebAssembly e cerca di ottimizzarlo per dimensioni e prestazioni in base solo al bytecode. Alcuni strumenti come Emscripten eseguono già questo strumento, altri no. In genere è una buona idea provare a risparmiare alcuni byte in più con questi strumenti.

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

Con wasm-opt possiamo eliminare un'altra manciata di byte per ottenere un totale di 6,2 kB dopo gzip.

#![no_std]

Dopo un po' di consulenza e ricerca, abbiamo riscritto il nostro codice Rust senza utilizzare la libreria standard di Rust, utilizzando la funzionalità #![no_std]. Inoltre, disattiva completamente le allocazioni della memoria dinamica, rimuovendo il codice allocatore dal 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. Sebbene sia ancora più grande dei moduli generati da C e AssemblyScript, è abbastanza piccolo da essere considerato leggero.

Esibizione

Prima di passare alle conclusioni basandoci 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 i risultati?

Come eseguire il benchmark

Nonostante WebAssembly sia un formato bytecode di basso livello, deve comunque essere inviato tramite un compilatore per generare il codice macchina specifico dell'host. Come JavaScript, il compilatore funziona in più fasi. In poche parole: la prima fase è molto più veloce nella compilazione, ma tende a generare codice più lento. Una volta avviata l'esecuzione del 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 perché il codice per la rotazione di un'immagine verrà usato una volta, forse due volte. Quindi, nella stragrande maggioranza dei casi, non avremo mai i vantaggi del compilatore per l'ottimizzazione. È importante tenere a mente questo quando si esegue il benchmark. Eseguire i moduli WebAssembly 10.000 volte in un loop darebbe risultati irrealistici. Per ottenere numeri realistici, dobbiamo eseguire il modulo una volta e prendere decisioni in base ai risultati della singola esecuzione.

Confronto del rendimento

Confronto della velocità per lingua
Confronto della velocità per browser

Questi due grafici sono visualizzazioni diverse degli stessi dati. Nel primo grafico vengono confrontati i dati per browser e nel secondo per la lingua utilizzata. Tieni presente che ho scelto una scala temporale logaritmica. È inoltre importante che tutti i benchmark utilizzassero la stessa immagine di test da 16 megapixel e la stessa macchina host, ad eccezione di un browser che non è stato possibile eseguire sulla stessa macchina.

Se non analizziamo 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. Ciò conferma ciò che abbiamo delineato all'inizio: WebAssembly offre prestazioni prevedibili. Indipendentemente dalla lingua scelta, la differenza tra i browser e le lingue è minima. Per essere precisi: la deviazione standard di JavaScript in tutti i browser è di circa 400 ms, mentre la deviazione standard di tutti i nostri moduli WebAssembly in tutti i browser è di circa 80 ms.

Impegno

Un'altra metrica è l'impegno richiesto per creare e integrare il nostro modulo WebAssembly in squoosh. È difficile assegnare un valore numerico allo sforzo, quindi non creerò grafici, ma ci sono alcuni aspetti che vorrei sottolineare:

AssemblyScript è stato privo di problemi. Non solo ti consente di utilizzare TypeScript per scrivere WebAssembly, semplificando la revisione del codice per i miei colleghi, ma produce anche moduli WebAssembly senza colla, molto piccoli con prestazioni buone. È probabile che gli strumenti nell'ecosistema TypeScript, ad esempio più bella e tslint, funzionino.

Rust in combinazione con wasm-pack è estremamente conveniente, ma eccelle per progetti WebAssembly più grandi in cui sono necessarie associazioni e la gestione della memoria. Abbiamo dovuto divergere un po' dal percorso positivo per ottenere una dimensione del file competitiva.

C ed Emscripten hanno creato da subito un modulo WebAssembly molto piccolo e dalle prestazioni elevate, ma senza il coraggio di passare al codice di colla e ridurlo alle semplici necessità, la dimensione totale (modulo WebAssembly + codice colla) diventa abbastanza grande.

Conclusione

Quindi quale linguaggio dovresti usare se hai un percorso JS rapido e vuoi renderlo più veloce o più coerente con WebAssembly. Come sempre per le domande sul rendimento, la risposta è: Dipende. Cosa abbiamo spedito?

Grafico di confronto

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 spedire Rust. I motivi di questa decisione sono molteplici: finora tutti i codec spediti 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 il compilatore Rust.

Anche se la differenza di dimensioni dei file tra Rust e le altre lingue nel grafico a dispersione appare molto drastica, in realtà non è un grosso problema: caricare 500 byte o 1,6 kB anche su 2G richiede meno di un decimo di secondo. E ci auguriamo che Rust possa colmare presto il divario in termini di dimensioni dei moduli.

In termini di prestazioni di runtime, Rust ha una media più veloce nei vari browser rispetto a AssemblyScript. Soprattutto nei progetti più grandi, Rust ha maggiori probabilità di produrre codice più velocemente senza richiedere ottimizzazioni manuali del codice. Ma questo non dovrebbe impedirti di utilizzare ciò con cui ti senti più a tuo agio.

Detto questo: AssemblyScript è stata un'ottima 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 catena di strumenti. Terremo sicuramente d'occhio AssemblyScript in futuro.

Aggiornamento: ruggine

Dopo la pubblicazione di questo articolo, Nick Fitzgerald del team di Rust ci ha segnalato il loro eccellente libro su Rust Wasm, che contiene una sezione sull'ottimizzazione delle dimensioni dei file. Seguire le istruzioni fornite (in particolare per abilitare le ottimizzazioni del tempo di collegamento e la gestione manuale di emergenza) ci ha permesso di scrivere codice Rust "normale" e di tornare a utilizzare Cargo (il npm di Rust) senza gonfiare le dimensioni del file. Il modulo Rust termina con 370B dopo gzip. Per maggiori dettagli, dai un'occhiata al PR che ho aperto su Squoosh.

Un ringraziamento speciale a Ashley Williams, Steve Klabnik, Nick Fitzgerald e Max Graey per il loro aiuto in questo percorso.