Iscrizione di una libreria C a Wasm

A volte potresti voler utilizzare una libreria disponibile solo come codice C o C++. Tradizionalmente, è qui che si arrende. Ora non più, perché ora abbiamo Emscripten e WebAssembly (o Wasm).

La toolchain

Mi sono prefissato l'obiettivo di capire come compilare del codice C esistente in Wasm. C'è stato un po' di rumore intorno al backend Wasm di LLVM, quindi ho iniziato a studiare in dettaglio. Anche se puoi compilare i programmi semplici in questo modo, nel momento in cui vuoi usare la libreria standard di C o persino compilare più file, probabilmente riscontrerai dei problemi. Questo mi ha portato alla lezione più importante che ho imparato:

Anche se in passato Emscripten era un compilatore C-to-asm.js, da allora è diventato un compilatore per Wasm ed è in procinto di passare al backend LLVM ufficiale internamente. Emscripten offre anche un'implementazione compatibile con Wasm della libreria C standard. Usa Emscripten. Esegue molti compiti nascosti, emula un file system, fornisce gestione della memoria, avvolge OpenGL con WebGL: molte cose che non devi necessariamente sviluppare.

Sembra che tu debba preoccuparti del bloat, come è successo a me, ma il compilatore Emscripten rimuove tutto ciò che non è necessario. Nei miei esperimenti, i moduli Wasm risultanti hanno dimensioni appropriate per la logica che contengono e i team di Emscripten e WebAssembly stanno lavorando per ridurli ulteriormente in futuro.

Puoi scaricare Emscripten seguendo le istruzioni sul relativo sito web o utilizzando Homebrew. Se sei un fan dei comandi dockerizzati come me e non vuoi installare nulla 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 scrittura di una funzione in C che calcola il 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 il C, la funzione in sé non dovrebbe sorprenderti troppo. Anche se non conosci la lingua "C", ma conosci JavaScript, probabilmente sarai in grado di capire cosa sta succedendo.

emscripten.h è un file di intestazione fornito da Emscripten. Ci serve solo per avere accesso alla macro EMSCRIPTEN_KEEPALIVE, ma fornisce molte più funzionalità. Questa macro indica al compilatore di non rimuovere una funzione anche se sembra non utilizzata. Se omettiamo questa macro, il compilatore ottimizzerà la funzione, dato che nessuno la utilizza.

Salvamo tutto in un file denominato fib.c. Per trasformarlo in un file .wasm dobbiamo passare al comando di compilatore di Emscripten emcc:

    $ 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. Stai andando bene. -s WASM=1 indica a Emscripten di fornirci un file Wasm instead of an asm.js file. -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' indica al compilatore di lasciare la funzione cwrap() disponibile nel file JavaScript. Scopri di più su questa funzione più avanti. -O3 indica al compilatore di eseguire un'ottimizzazione aggressiva. Puoi scegliere numeri più bassi per ridurre i tempi di compilazione, ma ciò aumenterà anche i bundle risultanti, in quanto 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 codice C compilato e dovrebbe essere abbastanza piccolo. Il file JavaScript si occupa di caricare e inizializzare il modulo Wasm e di fornire un'API più utile. Se necessario, si occuperà anche di configurare lo stack, l'heap e altre funzionalità di solito fornite dal sistema operativo durante la scrittura di codice C. Di conseguenza, il file JavaScript è un po' più grande e pesa 19 KB (~5 KB compresso 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 occupa di convertire i parametri in qualcosa di compatibile con C e di chiamare la funzione con wrapping. cwrap accetta come argomenti il nome della funzione, il tipo di ritorno e i tipi di 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, nella console dovresti vedere il "144", che è il dodicesimo numero di Fibonacci.

Il Santo Graal: compilazione di una libreria C

Finora, il codice C che abbiamo scritto è stato scritto pensando a Wasm. Un caso d'uso principale per WebAssembly, tuttavia, è quello di utilizzare 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 altro ancora. Emscripten fornisce la maggior parte di queste funzionalità, anche se esistono alcune limitazioni.

Torniamo al mio obiettivo originale: compilare un codificatore per WebP a Wasm. Il codice sorgente del codec WebP è scritto in C ed è disponibile su GitHub, oltre a una vasta documentazione dell'API. È un buon punto di partenza.

    $ git clone https://github.com/webmproject/libwebp

Per iniziare, proviamo a esporre WebPGetEncoderVersion() da encode.h a JavaScript scrivendo un file C chiamato webp.c:

    #include "emscripten.h"
    #include "src/webp/encode.h"

    EMSCRIPTEN_KEEPALIVE
    int version() {
      return WebPGetEncoderVersion();
    }

Questo è un semplice programma valido per verificare se siamo in grado di ottenere il codice sorgente di libwebp per la compilazione, poiché non sono necessari parametri o strutture di dati complesse per richiamare questa funzione.

Per compilare questo programma, dobbiamo comunicare al compilatore dove può trovare i file header di libwebp usando il flag -I, oltre a passare tutti i file C di libwebp di cui ha bisogno. Sarò onesto: ho semplicemente fornito tutti i file C che ho trovato e ho fatto affidamento sul compilatore per rimuovere tutto ciò che non era necessario. Sembrava funzionare benissimo!

    $ 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 abbiamo solo bisogno di codice 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:

Screenshot della console DevTools che mostra il numero
di versione corretto.

Recuperare un'immagine da JavaScript a Wasm

Ottenere il numero di versione del codificatore è ottimo, e tutto, ma la codifica di un'immagine reale sarebbe più impressionante, giusto? Facciamo così.

La prima domanda a cui dobbiamo rispondere è: come facciamo a portare l'immagine nella terra di Wasm? Se guardi la API di codifica di libwebp, 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 "basta" copiare i dati dal mondo JavaScript a quello Wasm. Per questo, abbiamo bisogno di esporre due funzioni aggiuntive. Una che alloca la memoria per l'immagine all'interno di Wasm Land e l'altra 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, ovvero 4 byte per pixel. Il puntatore restituito da malloc() è l'indirizzo della prima cella di memoria del buffer. Quando il puntatore viene restituito alla terra JavaScript, viene considerato solo come un numero. Dopo aver esposto la funzione a JavaScript utilizzando cwrap, possiamo utilizzare quel 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 Land. È arrivato il momento di chiamare l'encoder WebP per svolgere il suo compito. Esaminando la documentazione di WebP, risulta che WebPEncodeRGBA sembra la soluzione perfetta. La funzione porta un puntatore all'immagine di input e le sue dimensioni, nonché un'opzione di qualità compresa tra 0 e 100. Alloca anche un buffer di output, che dobbiamo liberare usando WebPFree() una volta finita 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 di ritorno (a meno che non allochiamo la memoria in modo dinamico), ho fatto ricorso a un array globale statico. Non ho una C pulita (in realtà, si basa sul fatto che i puntatori Wasm sono larghi 32 bit), ma per semplificare le cose, penso sia una scorciatoia equa.

    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 con tutto ciò, possiamo chiamare la funzione di codifica, prendere il puntatore e la dimensione dell'immagine, inserirli in un buffer JavaScript-land e rilasciare tutti i buffer Wasm-land che abbiamo assegnato nel 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 in modo sufficiente per adattarsi sia all'immagine di input che a quella di output:

Screenshot della console DevTools che mostra un errore.

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 funziona, possiamo trasformare il buffer dei risultati in un blob e utilizzarlo su 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 gloria di una nuova immagine WebP!

Il riquadro della rete di DevTools e l&#39;immagine generata.

Conclusione

Non è una passeggiata per far funzionare una libreria C nel browser, ma una volta compreso il processo complessivo e il funzionamento del flusso di dati, diventa più semplice e i risultati possono essere sbalorditivi.

WebAssembly offre molte nuove possibilità sul web per l'elaborazione, il calcolo dei numeri e il gaming. Tieni presente che Wasm non è una soluzione miracolosa da applicare a tutto, ma quando riscontri uno di questi colli di bottiglia, Wasm può essere uno strumento incredibilmente utile.

Contenuti extra: eseguire un'operazione semplice nel modo più difficile

Se vuoi provare a evitare il file JavaScript generato, potresti riuscire. 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 tu non fornisca memoria. Il modo in cui fornisci un modulo Wasm qualsiasi cosa è utilizzando l'oggetto imports, il secondo parametro della funzione instantiateStreaming. Il modulo Wasm può accedere a tutto all'interno dell'oggetto imports, ma a nient'altro al di fuori. Per convenzione, i moduli compilati tramite Emscripting prevedono un paio di cose dall'ambiente di caricamento JavaScript:

  • Innanzitutto, c'è env.memory. Il modulo Wasm non è a conoscenza del mondo esterno, per così dire, quindi deve avere a disposizione della memoria per funzionare. Inserisci WebAssembly.Memory. Rappresenta una porzione di memoria lineare (facoltativamente crescere). I parametri di dimensionamento sono in "unità di pagine WebAssembly", il che significa che il codice riportato sopra alloca 1 pagina di memoria, con ogni pagina di dimensioni pari a 64 KiB. Se non fornisci un'opzione maximum, la crescita della memoria è teoricamente illimitata (al momento Chrome ha un limite massimo di 2 GB). Per la maggior parte dei moduli WebAssembly non dovrebbe essere necessario impostare un valore massimo.
  • env.STACKTOP definisce dove dovrebbe iniziare a crescere la serie. Lo stack è necessario per effettuare chiamate di funzione e per allocare memoria per le variabili locali. Poiché non svolgiamo attività di gestione dinamica della memoria nel nostro piccolo programma Fibonacci, possiamo usare l'intera memoria come stack, quindi STACKTOP = 0.