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

È sempre veloce

Nella pagina precedente articoli Ho parlato di come WebAssembly ti consente di portare l'ecosistema delle librerie di C/C++ sul web. Un'app che fa ampio uso delle librerie C/C++ è squoosh, il nostro un'app web che ti 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 memorizzato nei file .wasm. Questo byte code è fortemente digitato e strutturato in modo tale che può essere compilato e ottimizzato per il sistema host molto più rapidamente JavaScript no. WebAssembly fornisce un ambiente per eseguire il codice che ha la sandbox e l'incorporamento.

In base alla mia esperienza, la maggior parte dei problemi di rendimento sul web sono causati da e un uso eccessivo della colorazione, ma di tanto in tanto un'app deve eseguire dispendiosa dal punto di vista computazionale, che richiede molto tempo. WebAssembly può aiutarti qui.

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 4094 px di per l'immagine da 4096 px (16 megapixel) sarebbero necessari oltre 16 milioni di iterazioni del un blocco di codice interno, ovvero quello che chiamiamo "hot path". Nonostante sia piuttosto grande numero di iterazioni, due dei tre browser che abbiamo testato completano l'attività in 2 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 è davvero complicato e motori diversi ottimizzano per cose diverse. Alcuni sono ottimizzati per l'esecuzione non elaborata, altri per l'interazione con il DOM. Nella in questo caso, abbiamo individuato 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 su tutti i browser per codice come questo, WebAssembly può aiutarti.

WebAssembly per prestazioni prevedibili

In generale, JavaScript e WebAssembly possono raggiungere lo stesso picco di prestazioni. Tuttavia, per JavaScript questo rendimento può essere raggiunto solo nel "percorso rapido", e spesso è difficile rimanere in questo "percorso rapido". Un vantaggio chiave WebAssembly offre prestazioni prevedibili, anche tra 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 davvero toccato il codice delle librerie, ha appena 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 per poter sfruttare i vantaggi di questa tecnologia.

Architettura di WebAssembly

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

Per citare WebAssembly.org:

Quando compili un frammento di codice C o Rust in WebAssembly, ottieni un file .wasm che contiene 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 prima di aver esaminato questa domanda: lo stack che rende WebAssembly, una "macchina virtuale basata su stack" non viene archiviato nel blocco utilizzata dai moduli WebAssembly. Lo stack è completamente interno a una VM inaccessibili agli sviluppatori web (tranne tramite DevTools). Di conseguenza, è possibile scrivere moduli WebAssembly che non hanno bisogno di memoria aggiuntiva e usano solo lo stack interno alla VM.

Nel nostro caso dovremo usare memoria aggiuntiva per consentire un accesso arbitrario ai pixel dell'immagine e genera una versione ruotata di quell'immagine. È per questo che è stato creato WebAssembly.Memory.

Gestione della memoria

In genere, una volta utilizzata memoria aggiuntiva, dovrai in qualche modo a gestire la memoria. Quali parti della memoria sono in uso? Quali sono gratuiti? 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 WebAssembly e aumenterà le dimensioni del tuo 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 c'è ha visto un'opportunità: tradizionalmente, passavamo il buffer RGBA dell'immagine di input come a una funzione WebAssembly e restituisce l'immagine ruotata come ritorno valore. Per generare questo valore restituito, dobbiamo utilizzare l'allocatore. Ma poiché conosciamo la quantità totale di memoria necessaria (il doppio delle dimensioni della memoria immagine, una volta per l'input e una volta per l'output), possiamo inserire l'immagine di input Memoria WebAssembly utilizzando JavaScript, esegui il modulo WebAssembly per generare un'istanza La seconda immagine ruotata, quindi usa JavaScript per leggere il risultato. Possiamo ottenere senza dover utilizzare alcuna gestione della memoria.

L'imbarazzo della scelta

Se hai esaminato la funzione JavaScript originale che vogliamo fare con WebAssembly-fy, puoi vedere che si tratta di un processo senza API specifiche per JavaScript. Di conseguenza, dovrebbe essere abbastanza semplice portare questo codice in qualsiasi lingua. Abbiamo valutato 3 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 la destinazione di WebAssembly. L'obiettivo di Emscripten è come sostituzione drop-in per i compilatori C più noti come GCC o clang ed è per lo più compatibile con i flag. È una parte fondamentale della missione di Emscripten perché vuole semplificare la compilazione di codice C e C++ esistente in WebAssembly possibile.

L'accesso alla memoria non elaborata è nella stessa natura del linguaggio C e esistono puntatori 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 a partire dall'indirizzo di memoria 0x124, che possiamo usare come qualsiasi altro array, consentendoci di accedere a singoli byte in lettura e scrittura. Nel nostro caso, vediamo il buffer RGBA di un'immagine che vogliamo riordinare 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. Nota che il modulo wasm esegue il file gzip solo a circa 260 byte, mentre il codice della colla è circa 3,5 KB dopo gzip. Dopo un po' di gioco, siamo riusciti a lasciarci il codice glue e creare un'istanza dei moduli WebAssembly con le API Vanilla. Questo è spesso possibile con Emscripten purché non utilizzi nulla dalla libreria C standard.

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 ambiente estremamente ma al momento funziona solo per Rust. Il gruppo sta considerando di aggiungere il supporto per altri linguaggi di destinazione WebAssembly.

In Rust, le sezioni sono gli array in C. E proprio come in C, dobbiamo creare sezioni che utilizzano i nostri indirizzi di partenza. Questo va contro il modello di sicurezza della memoria applicata da Rust, quindi per farci perdonare dobbiamo usare la parola chiave unsafe, permettendoci 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;
    }
}

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'istanza un giovane progetto che mira a essere un compilatore TypeScript-to-WebAssembly. È è importante notare, tuttavia, che non utilizzerà solo alcun 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;
      }
    }

Considerata la piccola superficie di digitazione della funzione rotate(), è è abbastanza facile trasferire questo codice in AssemblyScript. Le funzioni load<T>(ptr: usize) e store<T>(ptr: usize, value: T) sono fornite da AssemblyScript per per accedere alla memoria non elaborata. Per compilare il nostro file AssemblyScript, dobbiamo solo installare il pacchetto AssemblyScript/assemblyscript npm 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

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 a capire cosa sta succedendo, nonché a migliorare la situazione.

Twiggy

Twiggy è un altro strumento di Rust Il team di WebAssembly che estrae una serie di dati approfonditi da un componente WebAssembly. in maggior dettaglio più avanti in questo modulo. 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 la seconda può essere eseguita 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 dimensioni del file è dovuta all'allocatore. È stato sorprendente, dato che il nostro codice non utilizza le allocazioni dinamiche. Un altro fattore determinante è rappresentato dai nomi delle funzioni .

wasm-strip

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 nuovamente questo formato leggibile in un modulo wasm binario. Anche se utilizzavamo questi due strumenti complementari per esaminare i nostri file WebAssembly, abbiamo wasm-strip per essere il più utile. wasm-strip rimuove le sezioni non necessarie e metadati 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. Richiede un modulo WebAssembly e tenta di ottimizzarlo sia per le dimensioni che per solo in base al bytecode. Alcuni strumenti come Emscripten vengono già eseguiti questo strumento, mentre altri no. Di solito è una buona idea risparmiare un po' byte aggiuntivi utilizzando questi strumenti.

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

Con wasm-opt possiamo ridurre ancora una manciata di byte per lasciare un totale 6,2 KB dopo gzip.

#![no_std]

Dopo aver consultato e studiato, abbiamo riscritto il codice Rust senza utilizzare libreria standard di Rust, utilizzando #![no_std] funzionalità. Ciò disabilita anche tutte le allocazioni della memoria dinamica, rimuovendo il codice allocator del nostro modulo. Compilazione di 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 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. Esattamente come JavaScript, il compilatore funziona in più fasi. In parole povere: La prima fase è molto più veloce nella compilazione, ma tende a generare un 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 tenere presente questo aspetto quando il benchmarking. Eseguire i moduli WebAssembly 10.000 volte in un loop offre risultati non realistici. Per ottenere numeri realistici, dovremmo eseguire il modulo una volta 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 effettuiamo il confronto per browser, nel secondo per lingua utilizzata. Tieni conto che ho scelto una scala temporale logaritmica. Inoltre, è importante che tutti i benchmark utilizzavano la stessa immagine di test da 16 megapixel e lo stesso host macchina, ad eccezione di un browser, che non può essere eseguito sullo stesso computer.

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 stabilito all'inizio: WebAssembly offre previsioni delle prestazioni. Indipendentemente dalla lingua scelta, la varianza tra browser e lingue è minima. Per essere precisi: la deviazione standard di JavaScript in tutti i browser è di circa 400 ms, mentre la deviazione standard di tutte le nostre I moduli WebAssembly in tutti i browser richiedono 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. Gli strumenti dell'ecosistema TypeScript, come i modelli probabilmente funzionerà solo.

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 allontanarci un po' dal percorso felice per ottenere una dimensioni del file.

C ed Emscripten hanno creato un modulo WebAssembly molto piccolo e ad alte prestazioni pronte per l'uso, ma senza il coraggio di inserire il codice della colla indispensabili la dimensione totale (modulo WebAssembly + codice glue) abbastanza grande.

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. Che cosa abbiamo spedito?

Grafico di confronto

Confronto con il compromesso tra dimensioni / prestazioni del modulo delle diverse lingue che abbiamo utilizzato, la scelta migliore sembra essere C o AssemblyScript. Abbiamo deciso di rilasciare Rust. Là ci sono diversi motivi per questa decisione: tutti i codec spediti in Squoosh finora vengono compilati utilizzando Emscripten. Volevamo ampliare le nostre conoscenze nell'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à e produrre codice più rapido senza la necessità di ottimizzazioni manuali del codice. Tuttavia, questo non dovrebbe impedirti di utilizzare ciò che ti è più familiare.

Detto questo, AssemblyScript è stata una grande scoperta. Consente al web agli sviluppatori di produrre moduli WebAssembly senza dover apprendere lingua. Il team di AssemblyScript è stato molto reattivo ed è attivamente che lavorano per migliorare la loro catena di strumenti. In futuro, continueremo a tenere d'occhio AssemblyScript.

Aggiornamento: ruggine

Dopo aver pubblicato questo articolo, Nick Fitzgerald del team di Rust ci ha indirizzato al loro eccellente libro Rust Wasm, che contiene una sezione sull'ottimizzazione delle dimensioni dei file. Seguendo le le istruzioni riportate di seguito (in particolare l'attivazione delle ottimizzazioni del tempo di collegamento e delle procedure gestione del panico) ci ha permesso di scrivere codice Rust "normale" e di tornare a usare Cargo (il npm di ruggine) senza gonfiare le dimensioni del file. Il modulo Rust termina fino a 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.