Iscrizione di una libreria C a Wasm

A volte vuoi utilizzare una libreria disponibile solo come codice C o C++. In genere, è a questo punto che ci si arrende. Beh, non più, perché ora abbiamo Emscripten e WebAssembly (o Wasm).

La toolchain

Mi sono posto l'obiettivo di capire come compilare un codice C esistente in Wasm. Si è parlato molto del backend Wasm di LLVM, quindi ho iniziato a fare ricerche. Sebbene tu possa compilare programmi semplici in questo modo, nel momento in cui vuoi utilizzare la libreria standard di C o persino compilare più file, probabilmente riscontrerai problemi. Questo mi ha portato alla lezione più importante che ho imparato:

Sebbene Emscripten fosse un compilatore da C ad asm.js, da allora è maturato per avere come target Wasm ed è in fase di passaggio interno al backend LLVM ufficiale. Emscripten fornisce anche un'implementazione compatibile con Wasm della libreria standard di C. Utilizza Emscripten. Svolge un sacco di lavoro nascosto, emula un file system, fornisce la gestione della memoria, racchiude OpenGL con WebGL, un sacco di cose che non devi necessariamente sperimentare in prima persona durante lo sviluppo.

Anche se potrebbe sembrare che tu debba preoccuparti del bloatware, come è successo a me, 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 di Emscripten e WebAssembly stanno lavorando per renderli ancora più piccoli 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 elementi sul tuo sistema solo per giocare con WebAssembly, esiste un'immagine Docker ben gestita che puoi utilizzare:

    $ docker pull trzeci/emscripten
    $ docker run --rm -v $(pwd):/src trzeci/emscripten emcc <emcc options here>

Compilazione di un contenuto semplice

Prendiamo l'esempio quasi canonico della scrittura di una funzione in C che calcola l'n-th 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 troppo. Anche se non conosci il linguaggio C, ma conosci JavaScript, spero che riuscirai a capire cosa sta succedendo qui.

emscripten.h è un file di intestazione fornito da Emscripten. Ci serve solo per avere accesso alla macro EMSCRIPTEN_KEEPALIVE, ma fornisce molte altre funzionalità. Questa macro indica al compilatore di non rimuovere una funzione anche se sembra inutilizzata. Se omettessimo questa macro, il compilatore ottimizzerebbe la funzione, visto che nessuno la sta utilizzando.

Salviamo tutto in un file denominato fib.c. Per trasformarlo in un file .wasm, dobbiamo utilizzare il comando del 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 anziché un file asm.js. -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' indica al compilatore di lasciare la funzione cwrap() disponibile nel file JavaScript. Ne parleremo più avanti. -O3 indica al compilatore di eseguire un'ottimizzazione aggressiva. Puoi scegliere numeri più bassi per ridurre il tempo di build, ma questo aumenterà anche le dimensioni dei bundle risultanti, 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 di dimensioni piuttosto ridotte. 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 della configurazione dello stack, dell'heap e di altre funzionalità che di solito ci si aspetta 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 con gzip).

Esecuzione di un'operazione semplice

Il modo più semplice per caricare ed eseguire il modulo è utilizzare il file JavaScript generato. Una volta caricato il file, avrai a disposizione una variabile globale Module. Utilizza cwrap per creare una funzione nativa JavaScript che si occupi di convertire i parametri in un formato compatibile con C e di richiamare la funzione con wrapper. cwrap accetta come argomenti il nome della funzione, il tipo restituito 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, dovresti vedere "144" nella console, che è il dodicesimo numero di Fibonacci.

Il Santo Graal: compilazione di una libreria C

Finora, il codice C che abbiamo scritto è stato scritto tenendo conto di Wasm. Tuttavia, un caso d'uso fondamentale di WebAssembly è 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 altri elementi. Emscripten fornisce la maggior parte di queste funzionalità, anche se esistono 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, insieme a una documentazione completa dell'API. Questo è 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 chiamato webp.c:

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

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

Questo è un buon programma semplice per verificare se è possibile compilare il codice sorgente di libwebp, poiché non sono necessari 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 passargli anche tutti i file C di libwebp di cui ha bisogno. Devo essere onesto: ho semplicemente fornito tutti i file C che sono riuscito a trovare 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 abbiamo solo bisogno di un po' di 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 corretto nell'output:

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

Trasferisci un'immagine da JavaScript a Wasm

Ottenere il numero di versione dell'encoder è fantastico, ma codificare un'immagine reale sarebbe più impressionante, giusto? Facciamolo, allora.

La prima domanda a cui dobbiamo rispondere è: come facciamo a portare l'immagine nel mondo di Wasm? Se osserviamo l'API di codifica di libwebp, questa 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 a Wasm. Per farlo, dobbiamo esporre altre due funzioni. 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 quel 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 nostro 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 l'encoder WebP per fare il suo lavoro. Consultando la documentazione di WebP, WebPEncodeRGBA sembra essere la soluzione perfetta. La funzione accetta un puntatore all'immagine di input e alle sue 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 sua lunghezza. Poiché le funzioni in C non possono avere array come tipi restituiti (a meno che non allochiamo la memoria in modo dinamico), ho fatto ricorso a un array globale statico. Lo so, non è un C pulito (infatti, si basa sul fatto che i puntatori Wasm sono a 32 bit), ma per semplificare le cose penso che 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 tutto è pronto, possiamo chiamare la funzione di codifica, recuperare il puntatore e le dimensioni dell'immagine, inserirli in un buffer JavaScript personalizzato e rilasciare tutti i buffer Wasm allocati 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 per cui Wasm non riesce ad aumentare la memoria in modo da contenere sia l'immagine di input che quella di output:

Screenshot della console DevTools che mostra un errore.

Fortunatamente, la soluzione a questo problema è nel messaggio di errore. Dobbiamo solo aggiungere -s ALLOW_MEMORY_GROWTH=1 al nostro comando di compilazione.

Ecco fatto! Abbiamo compilato un encoder WebP e transcodificato un'immagine JPEG in WebP. Per dimostrare che ha funzionato, 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 Rete di DevTools e l'immagine creata dall'AI.

Conclusione

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

WebAssembly apre molte nuove possibilità sul web per l'elaborazione, l'elaborazione di numeri e i giochi. Tieni presente che Wasm non è una soluzione magica da applicare a tutto, ma quando ti imbatti in uno di questi colli di bottiglia, Wasm può essere uno strumento incredibilmente utile.

Contenuti bonus: eseguire un'operazione 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, possiamo procedere nel seguente modo:

<!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 venga fornita. Per fornire qualsiasi cosa a un modulo Wasm, devi utilizzare l'oggetto imports, il secondo parametro della funzione instantiateStreaming. Il modulo Wasm può accedere a tutto ciò che si trova all'interno dell'oggetto imports, ma a nient'altro al di fuori di questo. Per convenzione, i moduli compilati da Emscripten 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 deve ottenere della memoria con cui lavorare. Inserisci WebAssembly.Memory. Rappresenta una porzione di memoria lineare (facoltativamente espandibile). I parametri di dimensionamento sono espressi 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. Senza fornire un'opzione maximum, la memoria cresce teoricamente senza limiti (Chrome attualmente ha un limite massimo di 2 GB). La maggior parte dei moduli WebAssembly non dovrebbe aver bisogno di impostare un valore massimo.
  • env.STACKTOP definisce il punto in cui lo stack deve iniziare a crescere. Lo stack è necessario per effettuare chiamate di funzione e allocare memoria per le variabili locali. Poiché non eseguiamo alcuna gestione della memoria dinamica nel nostro piccolo programma di Fibonacci, possiamo utilizzare l'intera memoria come stack, quindi STACKTOP = 0.