A volte vuoi utilizzare una libreria disponibile solo come codice C o C++. Tradizionalmente, è qui che si rinuncia. Beh, non più, perché ora abbiamo Emscripten e WebAssembly (o Wasm).
La toolchain
Mi sono posto l'obiettivo di capire come compilare del codice C esistente in Wasm. Si è parlato molto del backend Wasm di LLVM, quindi ho iniziato a esaminarlo. Sebbene tu possa compilare programmi semplici in questo modo, non appena vorrai utilizzare la libreria standard di C o compilare più file, probabilmente incontrerai problemi. Questo mi ha portato alla lezione più importante che ho imparato:
Anche se Emscripten era un compilatore da C ad asm.js, è maturato fino a avere come target Wasm ed è in fase di passaggio al backend LLVM ufficiale internamente. Emscripten fornisce anche un'implementazione della libreria standard di C compatibile con Wasm. Utilizza Emscripten. Svolge molte attività nascoste, emula un file system, fornisce la gestione della memoria, esegue il wrapping di OpenGL con WebGL. Molte cose che non devi sperimentare in prima persona.
Anche se può sembrare che tu debba preoccuparti del bloat, cosa che ho fatto anch'io, il compilatore Emscripten rimuove tutto ciò che non è necessario. Nei miei esperimenti, i moduli Wasm risultanti hanno dimensioni adeguate alla logica che contengono e i team Emscripten e WebAssembly stanno lavorando per renderli ancora più piccoli in futuro.
Puoi ottenere Emscripten seguendo le istruzioni sul sito web o utilizzando Homebrew. Se, come me, sei un fan dei comandi dockerizzati e non vuoi installare elementi sul tuo sistema solo per provare WebAssembly, puoi utilizzare un'immagine Docker ben gestita:
$ docker pull trzeci/emscripten
$ docker run --rm -v $(pwd):/src trzeci/emscripten emcc <emcc options here>
Compilare qualcosa di semplice
Prendiamo l'esempio quasi canonico di scrivere una funzione in C che calcola l'n-esimo numero di Fibonacci:
#include <emscripten.h>
EMSCRIPTEN_KEEPALIVE
int fib(int n) {
if(n <= 0){
return 0;
}
int i, t, a = 0, b = 1;
for (i = 1; i < n; i++) {
t = a + b;
a = b;
b = t;
}
return b;
}
Se conosci C, la funzione in sé non dovrebbe sorprenderti. Anche se non conosci C ma conosci JavaScript, spero che tu riesca a capire cosa sta succedendo qui.
emscripten.h
è un file di intestazione fornito da Emscripten. Ci serve solo per
accedere alla macro EMSCRIPTEN_KEEPALIVE
, ma
offre molte più funzionalità.
Questa macro indica al compilatore di non rimuovere una funzione anche se sembra
inutilizzata. Se omettessimo questa macro, il compilatore ottimizzerebbe la funzione
e nessuno la utilizzerebbe.
Salviamo tutto in un file chiamato fib.c
. Per trasformarlo in un file .wasm
,
dobbiamo ricorrere al comando del compilatore emcc
di Emscripten:
$ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' fib.c
Analizziamo questo comando. emcc
è il compilatore di Emscripten. fib.c
è il nostro file
C. Finora tutto bene. -s WASM=1
indica a Emscripten di fornire un file Wasm
anziché un file asm.js.
-s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]'
indica al compilatore di lasciare la
funzione cwrap()
disponibile nel file JavaScript. Approfondiremo questa funzione
in un secondo momento. -O3
indica al compilatore di eseguire un'ottimizzazione aggressiva. Puoi scegliere numeri
più bassi per ridurre il tempo di compilazione, ma ciò renderà anche i bundle risultanti
più grandi, poiché il compilatore potrebbe non rimuovere il codice inutilizzato.
Dopo aver eseguito il comando, dovresti ottenere un file JavaScript denominato
a.out.js
e un file WebAssembly denominato a.out.wasm
. Il file Wasm (o "modulo") contiene il nostro codice C compilato e dovrebbe essere piuttosto piccolo. Il file
JavaScript si occupa di caricare e inizializzare il nostro modulo Wasm e
di fornire un'API più semplice. Se necessario, si occuperà anche di configurare lo
stack, l'heap e altre funzionalità che in genere ci si aspetta che vengano fornite dal
sistema operativo quando si scrive codice C. Di conseguenza, il file JavaScript è un po'
più grande, con un peso di 19 kB (~5 kB compressi con gzip).
Eseguire qualcosa di semplice
Il modo più semplice per caricare ed eseguire il modulo è utilizzare il file JavaScript generato. Una volta caricato il file, avrai a disposizione un
Module
globale. Utilizza
cwrap
per creare una funzione nativa JavaScript che si occupi di convertire i parametri
in un formato compatibile con C e richiamare la funzione di wrapping. cwrap
accetta il nome della funzione, il tipo restituito e i tipi di argomenti come argomenti, in questo ordine:
<script src="a.out.js"></script>
<script>
Module.onRuntimeInitialized = _ => {
const fib = Module.cwrap('fib', 'number', ['number']);
console.log(fib(12));
};
</script>
Se esegui questo codice, dovresti vedere "144" nella console, che è il 12° numero di Fibonacci.
Il Santo Graal: compilare una libreria C
Finora, il codice C che abbiamo scritto è stato scritto pensando a Wasm. Un caso d'uso principale di WebAssembly, tuttavia, è quello di prendere l'ecosistema esistente di librerie C e consentire agli sviluppatori di utilizzarle sul web. Queste librerie spesso si basano sulla libreria standard di C, su un sistema operativo, su un file system e su altre cose. Emscripten fornisce la maggior parte di queste funzionalità, anche se ci sono alcune limitazioni.
Torniamo al mio obiettivo originale: compilare un codificatore per WebP in Wasm. Il codice sorgente del codec WebP è scritto in C ed è disponibile su GitHub, oltre a una vasta documentazione dell'API. È un ottimo punto di partenza.
$ git clone https://github.com/webmproject/libwebp
Per iniziare in modo semplice, proviamo a esporre WebPGetEncoderVersion()
da
encode.h
a JavaScript scrivendo un file C denominato webp.c
:
#include "emscripten.h"
#include "src/webp/encode.h"
EMSCRIPTEN_KEEPALIVE
int version() {
return WebPGetEncoderVersion();
}
Si tratta di un buon programma semplice per verificare se è possibile compilare il codice sorgente di libwebp, poiché non richiediamo parametri o strutture di dati complesse per richiamare questa funzione.
Per compilare questo programma, dobbiamo indicare al compilatore dove trovare
i file di intestazione di libwebp utilizzando il flag -I
e passare anche tutti i file C di
libwebp di cui ha bisogno. Devo essere onesto: ho semplicemente fornito tutti i file C che ho trovato e ho fatto affidamento sul compilatore per eliminare tutto ciò che non era necessario. Sembrava funzionare alla perfezione.
$ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \
-I libwebp \
webp.c \
libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c
Ora ci servono solo HTML e JavaScript per caricare il nostro nuovo modulo:
<script src="/a.out.js"></script>
<script>
Module.onRuntimeInitialized = async (_) => {
const api = {
version: Module.cwrap('version', 'number', []),
};
console.log(api.version());
};
</script>
Vedremo il numero di versione della correzione nell'output:
Ottenere un'immagine da JavaScript in Wasm
Ottenere il numero di versione del codificatore è fantastico, ma codificare un'immagine reale sarebbe più impressionante, giusto? Allora facciamolo.
La prima domanda a cui dobbiamo rispondere è: come facciamo a inserire l'immagine in Wasm?
Se esamini l'API di codifica di libwebp, ti accorgerai che prevede un array di byte in RGB, RGBA, BGR o BGRA. Fortunatamente, l'API Canvas ha
getImageData()
,
che ci fornisce un
Uint8ClampedArray
contenente i dati dell'immagine in RGBA:
async function loadImage(src) {
// Load image
const imgBlob = await fetch(src).then((resp) => resp.blob());
const img = await createImageBitmap(imgBlob);
// Make canvas same size as image
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
// Draw image onto canvas
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
return ctx.getImageData(0, 0, img.width, img.height);
}
Ora si tratta "solo" di copiare i dati da JavaScript in Wasm. Per questo, dobbiamo esporre due funzioni aggiuntive. Una che alloca la memoria per l'immagine all'interno di Wasm e una che la libera di nuovo:
EMSCRIPTEN_KEEPALIVE
uint8_t* create_buffer(int width, int height) {
return malloc(width * height * 4 * sizeof(uint8_t));
}
EMSCRIPTEN_KEEPALIVE
void destroy_buffer(uint8_t* p) {
free(p);
}
create_buffer
alloca un buffer per l'immagine RGBA, quindi 4 byte per pixel.
Il puntatore restituito da malloc()
è l'indirizzo della prima cella di memoria di
questo buffer. Quando il puntatore viene restituito a JavaScript, viene trattato come
un semplice numero. Dopo aver esposto la funzione a JavaScript utilizzando cwrap
, possiamo
utilizzare questo numero per trovare l'inizio del buffer e copiare i dati dell'immagine.
const api = {
version: Module.cwrap('version', 'number', []),
create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
};
const image = await loadImage('/image.jpg');
const p = api.create_buffer(image.width, image.height);
Module.HEAP8.set(image.data, p);
// ... call encoder ...
api.destroy_buffer(p);
Gran finale: codifica l'immagine
L'immagine è ora disponibile in Wasm. È il momento di chiamare il codificatore WebP per
fare il suo lavoro. Dando un'occhiata alla
documentazione WebP, WebPEncodeRGBA
sembra la soluzione ideale. La funzione accetta un puntatore all'immagine di input e
le relative dimensioni, nonché un'opzione di qualità compresa tra 0 e 100. Inoltre, alloca
un buffer di output per noi, che dobbiamo liberare utilizzando WebPFree()
una volta
terminata l'immagine WebP.
Il risultato dell'operazione di codifica è un buffer di output e la relativa lunghezza. Poiché le funzioni in C non possono avere array come tipi restituiti (a meno che non allochiamo memoria in modo dinamico), ho fatto ricorso a un array globale statico. Lo so, non è C pulito (infatti, si basa sul fatto che i puntatori Wasm sono a 32 bit), ma per semplificare le cose credo che questa sia una scorciatoia accettabile.
int result[2];
EMSCRIPTEN_KEEPALIVE
void encode(uint8_t* img_in, int width, int height, float quality) {
uint8_t* img_out;
size_t size;
size = WebPEncodeRGBA(img_in, width, height, width * 4, quality, &img_out);
result[0] = (int)img_out;
result[1] = size;
}
EMSCRIPTEN_KEEPALIVE
void free_result(uint8_t* result) {
WebPFree(result);
}
EMSCRIPTEN_KEEPALIVE
int get_result_pointer() {
return result[0];
}
EMSCRIPTEN_KEEPALIVE
int get_result_size() {
return result[1];
}
Ora che abbiamo tutto, possiamo chiamare la funzione di codifica, recuperare il puntatore e le dimensioni dell'immagine, inserirli in un buffer JavaScript e rilasciare tutti i buffer Wasm allocati durante il processo.
api.encode(p, image.width, image.height, 100);
const resultPointer = api.get_result_pointer();
const resultSize = api.get_result_size();
const resultView = new Uint8Array(Module.HEAP8.buffer, resultPointer, resultSize);
const result = new Uint8Array(resultView);
api.free_result(resultPointer);
A seconda delle dimensioni dell'immagine, potresti riscontrare un errore in cui Wasm non riesce ad aumentare la memoria a sufficienza per ospitare sia l'immagine di input che quella di output:
Fortunatamente, la soluzione a questo problema si trova nel messaggio di errore. Dobbiamo solo aggiungere -s ALLOW_MEMORY_GROWTH=1
al comando di compilazione.
Ecco fatto! Abbiamo compilato un codificatore WebP e transcodificato un'immagine JPEG in
WebP. Per dimostrare che ha funzionato, possiamo trasformare il buffer dei risultati in un blob e utilizzarlo
in un elemento <img>
:
const blob = new Blob([result], { type: 'image/webp' });
const blobURL = URL.createObjectURL(blob);
const img = document.createElement('img');
img.src = blobURL;
document.body.appendChild(img);
Ecco la bellezza di una nuova immagine WebP.
Conclusione
Non è facile far funzionare una libreria C nel browser, ma una volta compreso il processo generale e il funzionamento del flusso di dati, diventa più semplice e i risultati possono essere sorprendenti.
WebAssembly apre molte nuove possibilità sul web per l'elaborazione, l'analisi numerica e i giochi. Tieni presente che Wasm non è una soluzione magica da applicare a tutto, ma quando si verifica uno di questi colli di bottiglia, Wasm può essere uno strumento incredibilmente utile.
Contenuti bonus: fare qualcosa di semplice nel modo più difficile
Se vuoi provare a evitare il file JavaScript generato, potresti riuscirci. Torniamo all'esempio di Fibonacci. Per caricarlo ed eseguirlo autonomamente, possiamo fare quanto segue:
<!DOCTYPE html>
<script>
(async function () {
const imports = {
env: {
memory: new WebAssembly.Memory({ initial: 1 }),
STACKTOP: 0,
},
};
const { instance } = await WebAssembly.instantiateStreaming(
fetch('/a.out.wasm'),
imports,
);
console.log(instance.exports._fib(12));
})();
</script>
I moduli WebAssembly creati da Emscripten non hanno memoria con cui lavorare
a meno che non ne fornisci una. Il modo in cui fornisci un modulo Wasm con
qualsiasi cosa è utilizzando l'oggetto imports
, il secondo parametro della
funzione instantiateStreaming
. Il modulo Wasm può accedere a tutto ciò che si trova all'interno
dell'oggetto importazioni, ma a nient'altro al di fuori. Per convenzione, i moduli
compilati da Emscripting si aspettano un paio di cose dall'ambiente JavaScript di caricamento:
- Innanzitutto, c'è
env.memory
. Il modulo Wasm non è a conoscenza del mondo esterno, per così dire, quindi ha bisogno di un po' di memoria per funzionare. InserisciWebAssembly.Memory
. Rappresenta un blocco di memoria lineare (facoltativamente espandibile). I parametri di dimensionamento sono espressi in "unità di pagine WebAssembly", il che significa che il codice precedente alloca una pagina di memoria e ogni pagina ha una dimensione di 64 KiB. Senza fornire un'opzionemaximum
, la memoria è teoricamente illimitata (Chrome ha attualmente un limite rigido di 2 GB). La maggior parte dei moduli WebAssembly non dovrebbe richiedere l'impostazione di un valore massimo. env.STACKTOP
definisce il punto in cui deve iniziare a crescere lo stack. Lo stack è necessario per effettuare chiamate di funzione e allocare memoria per le variabili locali. Poiché nel nostro piccolo programma di Fibonacci non eseguiamo alcuna gestione dinamica della memoria, possiamo utilizzare l'intera memoria come stack, quindiSTACKTOP = 0
.