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 archiviato in .wasm file. 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.

La via del cuore

In squoosh abbiamo scritto una funzione JavaScript. che ruota un buffer di immagine di multipli di 90 gradi. Mentre OffscreenCanvas è l'ideale per perché non è supportata da tutti i browser scelti come target e un po' buggy in Chrome.

Questa funzione esegue l'iterazione su ogni pixel di un'immagine di input e la copia 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. Alcune sono ottimizzate per l'esecuzione non elaborata, altre per l'interazione con il DOM. Nella 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 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 questa prestazione è raggiungibile solo sul "percorso rapido", e spesso è difficile rimanere sulla "strada veloce". Un vantaggio chiave WebAssembly offre prestazioni prevedibili, anche tra browser. Il livello massimo la digitazione e l'architettura di basso livello consentono al compilatore di rendere in modo che il codice WebAssembly venga ottimizzato una sola volta e usa sempre il "percorso rapido".

Scrittura per WebAssembly

In precedenza prendevamo le librerie C/C++ e le compilavamo in WebAssembly per utilizzare 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 motivazione è diversa: vogliamo scrivere partendo da zero tenendo a mente WebAssembly per poter utilizzare vantaggi offerti da WebAssembly.

Architettura di WebAssembly

Quando scrivi per WebAssembly, è utile conoscere un po' meglio che cos'è WebAssembly.

Per citare WebAssembly.org:

Quando compili una porzione di codice C o Rust in WebAssembly, ottieni un .wasm contenente una dichiarazione del modulo. La presente dichiarazione è costituita da un elenco di "importazioni" il modulo si aspetta dal proprio ambiente, un elenco di esportazioni rende disponibili all’host (funzioni, costanti, blocchi di memoria) e ovviamente le istruzioni binarie effettive per le funzioni contenute al loro 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. Questo è a cosa serve 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 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 WebAssembly e aumenterà le dimensioni del tuo file. Queste dimensioni e questo rendimento di queste funzioni di gestione della memoria possono variare significativamente a seconda l'algoritmo utilizzato, motivo per cui molti linguaggi offrono implementazioni multiple tra cui scegliere ("dmalloc", "emmalloc", "wee_alloc", ecc.).

Nel nostro caso conosciamo le dimensioni dell'immagine di input (e quindi il valore 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 quel valore restituito, dobbiamo usare 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 chiara trasferire questo codice in qualsiasi linguaggio. Abbiamo valutato 3 lingue diverse che vengono compilate in WebAssembly: C/C++, Rust e AssemblyScript. L'unica domanda dobbiamo rispondere per ciascuna delle lingue è: Come accediamo alla memoria non elaborata senza usare le funzioni di gestione della memoria?

C e 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 trasformiamo il numero 0x124 in un puntatore in un modello a 8 bit non firmato numeri interi (o byte). 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 dobbiamo spostare 4 byte consecutivi allo stesso tempo (un byte per ogni canale: R, G, B e A). Per semplificare questa operazione, possiamo creare un array di numeri interi senza segno a 32 bit. Per convenzione, l'immagine di input inizierà all'indirizzo 4 e la nostra immagine di output inizierà subito dopo l'immagine di input termina:

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 code chiamato c.js e un modulo wasm chiamato c.wasm. Nota che il gzip del modulo wasm viene eseguito 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 e moderno linguaggio di programmazione con un sistema avanzato, senza runtime e un modello di proprietà che garantisca la sicurezza della memoria e la sicurezza dei thread. Ruggine supporta anche WebAssembly come funzionalità principale e il team di Rust ha ha contribuito con molti strumenti eccellenti all'ecosistema WebAssembly.

Uno di questi strumenti è wasm-pack, di il gruppo di lavoro Rustwasm. wasm-pack prende il tuo codice e lo trasforma in un modulo adatto al web che funziona pronte all'uso con bundler come webpack. wasm-pack è un ambiente estremamente ma al momento funziona solo per Rust. Il gruppo è prendere in considerazione di aggiungere il supporto per altri linguaggi di targeting di 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;
    }
}

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'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 disattiva lo standard raccolta per conto proprio. La libreria standard modella le capacità WebAssembly. Ciò significa che non basta compilare alcun tipo di scripting di tipo WebAssembly, ma significa che non devi imparare un nuovo un 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;
      }
    }

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 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. Là sono un paio di strumenti nell'ecosistema WebAssembly che possono aiutarti ad analizzare i tuoi file WebAssembly (indipendentemente dal linguaggio con cui sono stati creati) e ti dirà cosa sta succedendo e ti aiuterà anche a migliorare la tua 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 Ruggine e ti consente di ispezionare elementi come il grafico delle chiamate al modulo, individuare le sezioni inutilizzate o superflue e 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 vediamo che la maggior parte delle dimensioni del file deriva dalla allocatore. È stato sorprendente, dato che il nostro codice non utilizza le allocazioni dinamiche. Un altro fattore determinante sono i "nomi delle funzioni" .

striscia di wasm

wasm-strip è uno strumento di WebAssembly Binary Toolkit o, in breve, wabt. Contiene un un paio di strumenti che consentono di controllare 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 girare il formato leggibile da una persona 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

Questo riduce le dimensioni del file del modulo antiruggine 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 un po' di consulenza e ricerca, abbiamo riscritto il nostro 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 comunque più grande dei moduli generati da C e AssemblyScript, è di dimensioni abbastanza da essere considerato un settore leggero.

Prestazioni

Prima di passare alle conclusioni basate solo sulla dimensione del file, abbiamo intrapreso questo percorso per ottimizzare le prestazioni, non le dimensioni dei file. Come abbiamo misurato il rendimento Quali sono stati i risultati?

Come eseguire il benchmarking

Nonostante WebAssembly sia un formato bytecode di basso livello, deve comunque essere inviato mediante un compilatore per generare un 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 in esecuzione, il browser osserva quali componenti vengono utilizzati di frequente e li invia con un compilatore più ottimizzato ma più lento.

Il nostro caso d'uso è interessante perché verrà usato il codice per ruotare un'immagine una volta, forse due volte. Quindi, nella stragrande maggioranza dei casi, non otterremo mai e i vantaggi del compilatore ottimizzatore. È importante tenere presente questo aspetto 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 per browser, nel secondo grafico confrontiamo le lingue utilizzate. Non dimenticare di apporre Come puoi notare, 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 problema di prestazioni: tutti i moduli WebAssembly vengono eseguiti in circa 500 ms o meno. Questo conferma quanto stabilito all'inizio: WebAssembly offre previsioni le prestazioni dei dispositivi. Indipendentemente dalla lingua scelta, la varianza tra i browser e l'uso delle lingue è minimo. 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 dedicare alla creazione e all'integrazione il modulo WebAssembly in squoosh. È difficile assegnare un valore numerico a sforzo, quindi non creerò alcun grafico, ma ci sono alcune cose che vorrei sottolinea:

AssemblyScript è stato semplice. Non solo ti consente di usare TypeScript per scrivere WebAssembly, semplificando molto la revisione del codice per i colleghi, ma anche produce moduli WebAssembly senza colla, che sono molto piccoli con le prestazioni dei dispositivi. Gli strumenti dell'ecosistema TypeScript, come i modelli probabilmente funzionerà solo.

Anche Rust in combinazione con wasm-pack è estremamente comodo, ma eccelle nei progetti WebAssembly più grandi erano le associazioni e la gestione della memoria necessaria. 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

Quale linguaggio dovresti usare se hai un percorso JS e vuoi renderlo in modo più rapido o coerente con WebAssembly. Come sempre con il rendimento domande, la risposta è: dipende. Quindi 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 spedire 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 è una valida alternativa, ma il progetto è relativamente giovane e non ha la stessa maturità del compilatore Rust.

Mentre la differenza di dimensioni del file tra Rust e le dimensioni delle altre lingue, sembra abbastanza drastica nel grafico a dispersione, in realtà non è un granché: Il caricamento di 500B o 1,6 KB anche con 2G richiede meno di 1/10 di secondo. e Speriamo che Rust colmerà 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 AssemblyScript. Soprattutto nei progetti più grandi, Rust avrà maggiori probabilità e produrre codice più rapido senza la necessità di ottimizzazioni manuali del codice. Ma quello non dovrebbe impedirti di utilizzare ciò che ti senti più a tuo agio.

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. Terremo sicuramente d'occhio AssemblyScript in futuro.

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.