Iscrizione di una libreria C a Wasm

A volte potresti voler utilizzare una libreria disponibile solo come codice C o C++. Tradizionalmente, è qui che bisogna arrendersi. E ora 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. C'era un po' di rumore intorno al backend Wasm di LLVM, quindi ho iniziato a indagare. Anche se puoi ottenere semplici programmi da compilare in questo modo, nel secondo momento in cui vuoi utilizzare la libreria standard di C o persino compilare più file, probabilmente incontrerai problemi. Questo mi ha portato alla lezione principale che ho imparato:

Anche se Emscripten utilizzava per essere un compilatore C-to-asm.js, da allora è maturato per mirare a 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. Trasporta molto lavoro nascosto, emula un file system, offre la gestione della memoria, integra OpenGL con WebGL, tante cose che non è necessario sviluppare autonomamente.

Anche se potrebbe sembrare che tu debba preoccuparti di gonfiore, ma sicuramente mi preoccupavo, il compilatore Emscripten rimuove tutto ciò che non è necessario. Nei miei esperimenti, i moduli Wasm risultanti sono dimensionati in modo appropriato per la logica che contengono e i team Emscripten e WebAssembly stanno lavorando per renderli ancora più piccoli in futuro.

Puoi ricevere Emscripten seguendo le istruzioni sul relativo sito web o tramite Homebrew. Se ti piacciono i comandi Docker come me e non vuoi installare elementi sul tuo sistema solo per giocare con WebAssembly, puoi utilizzare invece 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'no 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 valore C, la funzione in sé non dovrebbe sorprendere. Anche se non conosci il linguaggio C, ma conosci JavaScript, dovresti riuscire a capire cosa sta succedendo.

emscripten.h è un file di intestazione fornito da Emscripten. Ci serve solo per avere accesso alla macro EMSCRIPTEN_KEEPALIVE, che però fornisce molte più funzionalità. Questa macro indica al compilatore di non rimuovere una funzione anche se appare inutilizzata. Se omettessimo questa macro, il compilatore ottimizzerebbe la funzione, perché nessuno la sta usando.

Salviamo tutti questi dati in un file denominato fib.c. Per trasformarlo in un file .wasm, dobbiamo passare al comando di compilazione 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 dice 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. Scopriremo questa funzione più avanti. -O3 indica al compilatore di ottimizzare in modo aggressivo. Puoi scegliere numeri più bassi per ridurre i tempi di compilazione, ma in questo modo i bundle risultanti potrebbero essere più grandi, 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 chiamato 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 modulo Wasm e di fornire un'API migliore. Se necessario, si occuperà anche di configurare lo stack, l'heap e altre funzionalità che di solito devono essere fornite dal sistema operativo durante la scrittura del codice C. Pertanto, il file JavaScript è un po' più grande, con un peso di 19 kB (~5 kB gzip).

Gestire qualcosa di semplice

Il modo più semplice per caricare ed eseguire il modulo è utilizzare il file JavaScript generato. Dopo aver 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 qualcosa di compatibile con il linguaggio C e di richiamare la funzione con wrapping. cwrap prende il nome della funzione, il tipo restituito e i tipi di argomento 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, nella console dovresti vedere "144", che è il dodicesimo numero di Fibonacci.

Il Santo Graal: compilare una biblioteca di C

Finora, il codice C che abbiamo scritto è stato scritto pensando a Wasm. Un caso d'uso principale di WebAssembly, tuttavia, è 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 originario: compilare un codificatore per WebP per Wasm. La fonte del codec WebP è scritta in C e disponibile su GitHub, oltre a tutta una documentazione API completa. Questo è 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 buon programma semplice per verificare se riusciamo a ottenere il codice sorgente di libwebp per la compilazione, dato che non sono necessari parametri o strutture di dati complesse per richiamare questa funzione.

Per compilare questo programma, dobbiamo indicare al compilatore dove può trovare i file di intestazione di libwebp usando il flag -I e trasmettergli tutti i file C di libwebp di cui ha bisogno. In tutta sincerità: ho appena fornito tutti i file C che riuscivo a trovare e ho fatto affidamento sul compilatore per eliminare tutto ciò che era non 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 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>

E vedremo il numero di versione della correzione nell'output:

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

Ottieni un'immagine da JavaScript in Wasm

Ottenere il numero di versione del codificatore è ottimo e tutto, ma codificare un'immagine reale sarebbe più straordinario, giusto? D'accordo.

La prima domanda a cui dobbiamo rispondere è: come facciamo a far arrivare l'immagine nella terra di Wasm? Osservando l'API di codifica di libwebp, si aspetta un array di byte in RGB, RGBA, BGR o BGRA. Fortunatamente, l'API Canvas include getImageData(), che fornisce un Uint8ClampedArray contenente i dati 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 una questione di copiare i dati da JavaScript nel territorio Wasm. Per farlo, dobbiamo esporre due funzioni aggiuntive. Uno che alloca la memoria per l'immagine all'interno di Wasm Land e 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 al comando JavaScript, viene considerato solo un 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);

Grand Finale: codifica l'immagine

L'immagine è ora disponibile nel territorio di Wasm. È il momento di chiamare il codificatore WebP per fare il suo lavoro. Osservando la documentazione di WebP, WebPEncodeRGBA sembra la soluzione perfetta. La funzione porta un puntatore all'immagine di input e le sue dimensioni, oltre a un'opzione di qualità compresa tra 0 e 100. Alloca anche un buffer di output, che dobbiamo liberare utilizzando WebPFree() una volta che abbiamo finito con 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 la memoria non venga allocata dinamicamente), ho fatto ricorso a un array globale statico. Non c'è un C pulito (in realtà, si basa sul fatto che i puntatori Wasm sono larghi a 32 bit), ma per semplificare le cose, penso che sia una scorciatoia.

    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 questo è impostato, possiamo chiamare la funzione di codifica, prendere il puntatore e le dimensioni dell'immagine, inserirli nel nostro buffer JavaScript e rilasciare tutti i buffer Wasm-land che abbiamo allocato 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 è in grado di aumentare la memoria a sufficienza da supportare sia l'immagine di input che l'immagine 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 nostro comando di compilazione.

E il gioco è 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 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.

nel riquadro di rete di DevTools e nell&#39;immagine generata.

Conclusione

Non è una passeggiata nel parco per far funzionare una libreria C nel browser, ma una volta compresa la procedura complessiva e il funzionamento del flusso di dati, diventa più semplice e i risultati possono essere strabilianti.

WebAssembly apre molte nuove possibilità sul web per l'elaborazione, l'analisi numerica e il gioco. Tieni presente che Wasm non è una soluzione miracolosa da applicare a tutto, ma quando colpisci uno di questi colli di bottiglia, Wasm può essere uno strumento incredibilmente utile.

Contenuti extra: gestire qualcosa di semplice nel modo più difficile

Se vuoi evitare il file JavaScript generato, potresti riuscire a farlo. Torniamo all'esempio di Fibonacci. Per caricarlo ed eseguirlo autonomamente, 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 ne venga fornita memoria. Per fornire qualsiasi cosa in un modulo Wasm puoi usare l'oggetto imports, il secondo parametro della funzione instantiateStreaming. Il modulo Wasm può accedere a tutti gli elementi all'interno dell'oggetto import, ma nient'altro al di fuori. Per convenzione, i moduli compilati mediante Emscripting si aspettano di ricevere alcune cose dall'ambiente di caricamento JavaScript:

  • Innanzitutto, c'è env.memory. Il modulo Wasm è inconsapevole del mondo esterno, per così dire, quindi ha bisogno di un po' di memoria. Inserisci WebAssembly.Memory. Rappresenta un pezzo di memoria lineare (facoltativamente crescebile). I parametri di dimensionamento sono in "in unità di pagine WebAssembly", il che significa che il codice precedente assegna 1 pagina di memoria, con una dimensione di 64 KiB per ogni pagina. Se non è disponibile un'opzione maximum, la crescita della memoria è teoricamente illimitata (al momento Chrome ha un limite fisso di 2 GB). Per la maggior parte dei moduli WebAssembly non deve essere impostato un valore massimo.
  • env.STACKTOP definisce dove dovrebbe iniziare a crescere lo stack. Lo stack è necessario per effettuare chiamate di funzione e allocare la memoria per le variabili locali. Poiché nel nostro piccolo programma di Fibonacci non ci avvaliamo di alcuna esagerazione per la gestione dinamica della memoria, possiamo semplicemente utilizzare l'intera memoria come uno stack, quindi STACKTOP = 0.