Iscrizione di una libreria C a Wasm

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

Mi sono prefissato l'obiettivo di capire come compilare del codice C esistente in Wasm. C'è stato un po' di rumore sul backend Wasm di LLVM, quindi ho iniziato a esaminarlo. Anche se puoi compilare programmi semplici in questo modo, non appena vorrai utilizzare la libreria standard di C o persino compilare più file, probabilmente incontrerai problemi. Da qui ho tratto la lezione più importante:

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 fornisce inoltre un'implementazione della libreria standard di C compatibile con Wasm. Utilizza 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 C, la funzione stessa non dovrebbe sorprenderti troppo. Anche se non conosci il linguaggio C, ma conosci JavaScript, dovresti essere in grado di capire cosa succede.

emscripten.h è un file di intestazione fornito da Emscripten. Ne abbiamo bisogno solo per avere accesso alla macro EMSCRIPTEN_KEEPALIVE, ma offre 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 rivolgerci al 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. Fin qui tutto 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 inferiori per ridurre i tempi di compilazione, ma i bundle risultanti saranno più grandi perché 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 nostro modulo Wasm e di fornire un'API più piacevole. 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 invocare 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, 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. Tuttavia, un caso d'uso di base per WebAssembly è prendere l'ecosistema esistente delle 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 in 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 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();
    }

Questo è un buon programma semplice per verificare se riusciamo a compilare il codice sorgente di libwebp, poiché non sono richiesti parametri o strutture di dati complesse per invocare questa funzione.

Per compilare questo programma, dobbiamo dire al compilatore dove può trovare i file di intestazione di libwebp utilizzando il flag -I e anche 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. Sembra che funzioni 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 ci serve solo 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 della correzione nell'output:

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

Importare un'immagine da JavaScript in Wasm

Ottenere il numero di versione del codificatore è fantastico, ma codificare un'immagine reale sarebbe più impressionante, giusto? Facciamo così.

La prima domanda a cui dobbiamo rispondere è: come importiamo l'immagine in 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 farlo, dobbiamo esporre altre due funzioni. Uno che alloca la memoria per l'immagine all'interno di Wasm e uno 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 di quel buffer. Quando il cursore viene restituito a JavaScript, viene trattato come 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);

Gran finale: codifica l'immagine

L'immagine è ora disponibile in Wasm. È arrivato il momento di chiamare l'encoder WebP per svolgere il suo compito. Se consulti la documentazione WebP, WebPEncodeRGBA sembra una 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 che dobbiamo liberare utilizzando WebPFree() al termine dell'elaborazione dell'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. Lo so, non è C pulito (infatti, si basa sul fatto che i puntatori Wasm sono di 32 bit), ma per semplificare le cose, penso che questa sia una scorciatoia ragionevole.

    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 a posto, possiamo chiamare la funzione di codifica, acquisire il cursore e le dimensioni dell'immagine, inserirli in un buffer JavaScript e liberare tutti i buffer Wasm che abbiamo allocato 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 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 è nel messaggio di errore. Dobbiamo solo aggiungere -s ALLOW_MEMORY_GROWTH=1 al nostro 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 è facile far funzionare una libreria C nel browser, ma una volta capito il processo complessivo 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, il calcolo numerico e i giochi. Tieni presente che Wasm non è una soluzione definitiva da applicare a tutto, ma quando riscontri uno di questi colli di bottiglia, 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:

<!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 loro memoria. Per fornire un modulo Wasm con qualsiasi cosa, utilizza 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 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 deve avere un po' di memoria per funzionare. Inserisci WebAssembly.Memory. Rappresenta un blocco di memoria lineare (facoltativamente espandibile). 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 è 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é nel nostro piccolo programma Fibonacci non eseguiamo alcuna gestione dinamica della memoria, possiamo utilizzare l'intera memoria come una pila, quindi STACKTOP = 0.