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

È sempre veloce,

Nel mio precedente articoli ho parlato di come WebAssembly consente di portare sul web l'ecosistema delle librerie di C/C++. 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 che sono stati compilati da C++ a WebAssembly.

WebAssembly è una macchina virtuale di basso livello che esegue il bytecode archiviato in file .wasm. Questo byte code è molto digitato e strutturato in modo da poter essere compilato e ottimizzato per il sistema host molto più velocemente di quanto possa fare JavaScript. WebAssembly fornisce un ambiente per l'esecuzione di codice che ha preso in considerazione il sandboxing e l'incorporamento sin 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 qui.

La via del cuore

In squoosh abbiamo scritto una funzione JavaScript che ruota un buffer di immagine di multipli di 90 gradi. Sebbene OffscreenCanvas sia l'ideale per farlo, non è supportato nei browser scelti come target ed è leggermente bug in Chrome.

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

WebAssembly, invece, si basa interamente sulla 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 lo stesso picco di prestazioni. Tuttavia, per JavaScript questa prestazione può essere raggiunta solo sul "percorso veloce" ed è spesso difficile rimanere su questo "percorso rapido". Un vantaggio chiave offerto da WebAssembly è costituito dalle prestazioni prevedibili, anche su più browser. La digitazione rigorosa e l'architettura di basso livello consentono al compilatore di garantire garanzie più efficaci in modo che il codice WebAssembly venga 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 al fine di utilizzarle 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 motivazione è diversa: vogliamo scrivere qualcosa da zero tenendo presente WebAssembly in modo da poter sfruttare i vantaggi di WebAssembly.

Architettura di WebAssembly

Quando si scrive per WebAssembly, è utile comprendere un po' di più che cos'è WebAssembly.

Per citare WebAssembly.org:

Quando compili una porzione di codice C o Rust in WebAssembly, ottieni un file .wasm contenente una dichiarazione del modulo. Questa dichiarazione è composta da un elenco di "importazioni" che il modulo prevede dal suo ambiente, un elenco di esportazioni che questo modulo rende disponibile all'host (funzioni, costanti, blocchi di memoria) e, ovviamente, le istruzioni binarie effettive per le funzioni contenute al suo interno.

Qualcosa che non ho capito fino a quando non ho esaminato il problema: lo stack che rende WebAssembly una "macchina virtuale basata su stack" non è archiviato 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 hanno bisogno di memoria aggiuntiva e che utilizzano solo lo stack interno alla VM.

Nel nostro caso dovremo utilizzare memoria aggiuntiva per consentire l'accesso arbitrario ai pixel dell'immagine e generare una versione ruotata dell'immagine. Questo è a cosa serve WebAssembly.Memory.

Gestione della memoria

In genere, una volta utilizzata memoria aggiuntiva, dovrai gestirla in qualche modo. 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. 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, prima di eseguire il modulo WebAssembly, conosciamo le dimensioni dell'immagine di input (e quindi le dimensioni dell'immagine di output). Qui abbiamo visto un'opportunità: in passato, dovevamo passare il buffer RGBA dell'immagine di input come parametro a una funzione WebAssembly e restituire l'immagine ruotata come valore restituito. Per generare quel 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 di WebAssembly utilizzando JavaScript, eseguire il modulo WebAssembly per generare una seconda immagine ruotata e quindi utilizzare JavaScript per leggere il risultato. Possiamo andarcene senza usare alcuna gestione della memoria.

L'imbarazzo della scelta

Se hai esaminato la funzione JavaScript originale che vogliamo rendere WebAssembly-fy, puoi vedere che si tratta di un codice puramente computazionale senza API specifiche per JavaScript. Trasferire questo codice in qualsiasi linguaggio deve essere molto semplice. Abbiamo valutato 3 linguaggi diversi che si compilano 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. Questa è una parte fondamentale della missione di Emscripten, in quanto vuole semplificare il più possibile la compilazione del codice C e C++ esistente in WebAssembly.

L'accesso alla memoria non elaborata è nella natura stessa di C e i puntatori esistono per questo motivo:

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

Qui trasformiamo il numero 0x124 in un puntatore a numeri interi (o byte) a 8 bit non firmati. 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 osservando il buffer RGBA di un'immagine che vogliamo riordinare per ottenere la rotazione. Per spostare un pixel dobbiamo spostare 4 byte consecutivi contemporaneamente (un byte per ogni canale: R, G, B e A). Per semplificare questa operazione, possiamo creare un array di numeri interi a 32 bit non firmati. Per convenzione, l'immagine di input inizia dall'indirizzo 4, mentre 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 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 colla denominato c.js e un modulo wasm denominato c.wasm. Tieni presente che il gzip del modulo wasm viene eseguito solo a circa 260 byte, mentre il codice glue è di circa 3,5 kB dopo gzip. Dopo un po' di gioco, siamo riusciti a distruggere il codice glue e creare un'istanza dei moduli WebAssembly con le API Vanilla. Questo è spesso possibile con Emscripten, purché non utilizzi elementi della libreria standard C.

Rust

Rust è un nuovo e moderno linguaggio di programmazione con un sistema avanzato, nessun runtime e un modello di proprietà che garantisce la sicurezza della memoria e la sicurezza dei thread. Rust supporta inoltre 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, del gruppo di lavoro Rustwasm. wasm-pack prende il tuo codice e lo trasforma in un modulo ottimizzato per il web, che funziona pronto all'uso 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, le sezioni sono gli array in C. Come in C, dobbiamo creare sezioni che usano i nostri indirizzi di partenza. Ciò va contro il modello di sicurezza della memoria applicato da Rust, quindi per capire come fare dobbiamo usare la parola chiave unsafe, che ci consente 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;
    }
}

Compila i file Rust utilizzando

$ wasm-pack build

produce un modulo wasm da 7,6 KB con circa 100 byte di codice glue (entrambi dopo gzip).

AssemblyScript

AssemblyScript è un progetto abbastanza giovane che mira a essere un compilatore TypeScript-to-WebAssembly. È importante notare, tuttavia, che non consumerà solo alcun TypeScript. AssemblyScript utilizza la stessa sintassi di TypeScript, ma disattiva autonomamente la libreria standard. La 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 digitazione della funzione rotate(), è stato abbastanza semplice trasferire 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, devi 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 Vanilla WebAssembly.

Analisi forensi WebAssembly

7,6 KB di Rust è sorprendentemente grande rispetto alle altre 2 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 approfonditi da un modulo WebAssembly. Lo strumento non è specifico per Rust e consente di controllare elementi come il grafico delle chiamate del modulo, determinare le sezioni inutilizzate o superflue e capire quali sezioni contribuiscono alla dimensione totale del file del modulo. Quest'ultima operazione può essere eseguita con il comando top di Twiggy:

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

In questo caso vediamo che la maggior parte delle dimensioni del file deriva dall'allocatore. È stato sorprendente, dato che il nostro codice non utilizza le allocazioni dinamiche. Un altro fattore determinante è rappresentato dalla sottosezione "Nomi funzioni".

striscia di wasm

wasm-strip è uno strumento di WebAssembly Binary Toolkit o, in breve, wabt. Contiene un paio di strumenti che ti consentono di ispezionare e manipolare i moduli WebAssembly. wasm2wat è un disassemblatore che trasforma un modulo wasm binario in un formato leggibile. Wabt contiene anche wat2wasm, che ti consente di trasformare quel formato leggibile da una persona 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

Questo riduce la dimensione del file del modulo antiruggine da 7,5 KB a 6,6 KB (dopo gzip).

wasm-opt

wasm-opt è uno strumento di Binaryen. Prende un modulo WebAssembly e tenta di ottimizzarlo sia per le dimensioni che per le prestazioni in base solo al bytecode. Alcuni strumenti come Emscripten già eseguono questo strumento, altri no. In genere è consigliabile provare a risparmiare alcuni byte in più utilizzando questi strumenti.

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

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

#![nessuna_std]

Dopo alcune consulenze e ricerche, abbiamo riscritto il nostro codice Rust senza utilizzare la libreria standard di Rust, utilizzando la funzionalità #![no_std]. Ciò disabilita anche l'allocazione della memoria dinamica, 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. Sebbene sia ancora più grande dei moduli generati da C e AssemblyScript, è abbastanza piccolo da essere considerato un modello leggero.

Rendimento

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 funziona in più fasi. 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 a un compilatore più ottimizzato, ma più lento.

Il nostro caso d'uso è interessante perché il codice per ruotare un'immagine verrà utilizzato una o due volte. Quindi, nella stragrande maggioranza dei casi, non otterremo mai i vantaggi del compilatore ottimizzatore. Questo è importante da tenere a mente quando si esegue il benchmark. 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

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

Questi due grafici rappresentano visualizzazioni diverse degli stessi dati. Nel primo grafico confrontiamo i dati per browser, nel secondo le lingue utilizzate. Tieni presente che ho scelto una scala di tempo logaritmica. È inoltre importante che tutti i benchmark utilizzino la stessa immagine di test da 16 megapixel e la stessa macchina host, ad eccezione di un browser che non può essere eseguito sulla stessa macchina.

Senza analizzare troppo questi grafici, è chiaro che abbiamo risolto il problema di prestazioni originale: tutti i moduli WebAssembly vengono eseguiti in circa 500 ms o meno. Ciò conferma ciò che abbiamo proposto all'inizio: WebAssembly offre prestazioni prevedibili. Indipendentemente dalla lingua scelta, la variazione tra browser e lingue è minima. Per la precisione: 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 che abbiamo dovuto dedicare alla creazione e all'integrazione del nostro modulo WebAssembly in squoosh. È difficile assegnare un valore numerico allo sforzo, quindi non creerò alcun grafico, 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 colla che sono molto piccoli con prestazioni decenti. Gli strumenti dell'ecosistema TypeScript, come quelli più belli e taglio, probabilmente funzioneranno solo.

Anche Rust in combinazione con wasm-pack è estremamente comoda, ma eccelle di più nei progetti WebAssembly più grandi in cui sono necessarie associazioni e gestione della memoria. Abbiamo dovuto allontanarci un po' dal percorso felice per ottenere dimensioni competitive del file.

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 per le domande sulle prestazioni, la risposta è: dipende. Quindi cosa abbiamo spedito?

Grafico di confronto

Confrontando il compromesso tra dimensioni / prestazioni del modulo dei diversi linguaggi utilizzati, la scelta migliore sembra essere C o AssemblyScript. Abbiamo deciso di spedire 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 efficace, ma il progetto è relativamente recente e il compilatore non è maturo quanto il compilatore Rust.

Anche se la differenza di dimensione dei file tra Rust e quella degli altri linguaggi sembra piuttosto drastica nel grafico a dispersione, non è un problema in realtà: il caricamento di 500 miliardi o 1,6 kB anche su 2G richiede meno di 1/10 di secondo. E speriamo che Rust colma presto il divario in termini di dimensioni del modulo.

In termini di prestazioni di runtime, Rust ha una media più veloce tra i browser rispetto a 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 creare moduli WebAssembly senza dover imparare un nuovo linguaggio. Il team di AssemblyScript è stato molto reattivo e sta lavorando attivamente per migliorare la toolchain. Sicuramente monitoreremo AssemblyScript in futuro.

Aggiornamento: ruggine

Dopo aver pubblicato questo articolo, Nick Fitzgerald del team di Rust ci ha indirizzato all'eccellente libro Rust Wasm, che contiene una sezione sull'ottimizzazione delle dimensioni dei file. Il rispetto delle 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 miliardi dopo gzip. Per i dettagli, consulta il PR che ho aperto su Squoosh.

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