Emscripten e npm

Come si integra WebAssembly in questa configurazione? In questo articolo lo scopriremo con C/C++ ed Emscripten come esempio.

WebAssembly (wasm) viene spesso inquadrato come una primitiva di rendimento o un modo per eseguire la tua codebase C++ esistente sul web. Con squoosh.app, volevamo dimostrare che esiste almeno una terza prospettiva per Wasm: sfruttare gli enormi ecosistemi di altri linguaggi di programmazione. Con Emscripten, puoi utilizzare il codice C/C++, Rust ha il supporto WASM integrato e anche il team di Go ci sta lavorando. Sono sicuro che seguiranno molte altre lingue.

In questi scenari, Wasm non è il fulcro della tua app, ma piuttosto un pezzo di puzzle: un altro modulo. La tua app ha già asset JavaScript, CSS e immagini, un sistema di build incentrato sul web e magari anche un framework come React. Come si integra WebAssembly in questa configurazione? In questo articolo, risolveremo questo problema con C/C++ ed Emscripten come esempio.

Docker

Ho trovato Docker di inestimabile valore quando lavoro con Emscripten. Le librerie C/C++ sono spesso scritte per funzionare con il sistema operativo su cui sono basate. È molto utile avere un ambiente coerente. Con Docker ottieni un sistema Linux virtualizzato già configurato per funzionare con Emscripten e con tutti gli strumenti e le dipendenze installati. Se manca qualcosa, puoi semplicemente installarlo senza doverti preoccupare di come influisce sulla tua macchina o sugli altri progetti. In caso di problemi, getta via il contenitore e ricomincia da capo. Se funziona una volta, puoi essere certo che continuerà a funzionare e produrrà risultati identici.

Il Docker Registry ha un'immagine Emscripten di trzeci che ho utilizzato molto.

Integrazione con npm

Nella maggior parte dei casi, il punto di ingresso di un progetto web è package.json di npm. Per convenzione, la maggior parte dei progetti può essere creata con npm install && npm run build.

In generale, gli artefatti di build prodotti da Emscripten (un file .js e un file .wasm) devono essere trattati come un altro modulo JavaScript e un altro asset. Il file JavaScript può essere gestito da un bundler come webpack o rollup, mentre il file wasm deve essere trattato come qualsiasi altro asset binario più grande, come le immagini.

Pertanto, gli artefatti di build di Emscripten devono essere creati prima dell'avvio del processo di build "normale":

{
    "name": "my-worldchanging-project",
    "scripts": {
    "build:emscripten": "docker run --rm -v $(pwd):/src trzeci/emscripten
./build.sh",
    "build:app": "<the old build command>",
    "build": "npm run build:emscripten && npm run build:app",
    // ...
    },
    // ...
}

Il nuovo task build:emscripten potrebbe richiamare Emscripten direttamente, ma come accennato in precedenza, ti consiglio di utilizzare Docker per assicurarti che l'ambiente di build sia coerente.

docker run ... trzeci/emscripten ./build.sh indica a Docker di avviare un nuovo container utilizzando l'immagine trzeci/emscripten ed eseguire il comando ./build.sh. build.sh è uno script shell che scriverai in seguito. --rm indica a Docker di eliminare il contenitore al termine dell'esecuzione. In questo modo, non crei una raccolta di immagini macchina obsolete nel tempo. -v $(pwd):/src significa che vuoi che Docker "duplichi" la directory attuale ($(pwd)) in /src all'interno del container. Le modifiche apportate ai file nella directory /src all'interno del container verranno replicate nel progetto effettivo. Queste directory speculari sono chiamate "mount bind".

Diamo un'occhiata a build.sh:

#!/bin/bash

set -e

export OPTIMIZE="-Os"
export LDFLAGS="${OPTIMIZE}"
export CFLAGS="${OPTIMIZE}"
export CXXFLAGS="${OPTIMIZE}"

echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
(
    # Compile C/C++ code
    emcc \
    ${OPTIMIZE} \
    --bind \
    -s STRICT=1 \
    -s ALLOW_MEMORY_GROWTH=1 \
    -s MALLOC=emmalloc \
    -s MODULARIZE=1 \
    -s EXPORT_ES6=1 \
    -o ./my-module.js \
    src/my-module.cpp

    # Create output folder
    mkdir -p dist
    # Move artifacts
    mv my-module.{js,wasm} dist
)
echo "============================================="
echo "Compiling wasm bindings done"
echo "============================================="

C'è molto da analizzare qui.

set -e imposta la shell in modalità "fail fast". Se uno dei comandi nello script restituisce un errore, l'intero script viene interrotto immediatamente. Questo può essere incredibilmente utile, in quanto l'ultimo output dello script sarà sempre un messaggio di operazione riuscita o l'errore che ha causato l'esito negativo della build.

Con le istruzioni export, definisci i valori di un paio di variabili di ambiente. Consentono di passare parametri aggiuntivi della riga di comando al compilatore C (CFLAGS), al compilatore C++ (CXXFLAGS) e al linker (LDFLAGS). Tutti ricevono le impostazioni dell'ottimizzatore tramite OPTIMIZE per assicurarsi che tutto venga ottimizzato allo stesso modo. Esistono due valori possibili per la variabile OPTIMIZE:

  • -O0: non eseguire alcuna ottimizzazione. Nessun codice inutilizzato viene eliminato ed Emscripten non comprime il codice JavaScript che emette. Ideale per il debug.
  • -O3: Ottimizza in modo aggressivo per il rendimento.
  • -Os: Ottimizza in modo aggressivo per prestazioni e dimensioni come criterio secondario.
  • -Oz: esegui l'ottimizzazione in modo aggressivo per le dimensioni, sacrificando le prestazioni se necessario.

Per il web, consiglio principalmente -Os.

Il comando emcc ha una miriade di opzioni proprie. Tieni presente che emcc è pensato per essere un "sostituto diretto di compilatori come GCC o clang". Pertanto, tutti i flag che potresti conoscere di GCC verranno molto probabilmente implementati anche da emcc. Il flag -s è speciale in quanto ci consente di configurare Emscripten in modo specifico. Tutte le opzioni disponibili sono disponibili in settings.js di Emscripten, ma questo file può essere piuttosto complesso. Ecco un elenco dei flag di Emscripten che ritengo più importanti per gli sviluppatori web:

  • --bind consente embind.
  • -s STRICT=1 non supporta più tutte le opzioni di build ritirate. In questo modo, la compilazione del codice avviene in modo compatibile con le versioni future.
  • -s ALLOW_MEMORY_GROWTH=1 consente di aumentare automaticamente la memoria se necessario. Al momento della stesura, Emscripten alloca inizialmente 16 MB di memoria. Man mano che il codice alloca blocchi di memoria, questa opzione decide se queste operazioni causeranno l'errore dell'intero modulo Wasm quando la memoria è esaurita o se il codice di collegamento può espandere la memoria totale per accogliere l'allocazione.
  • -s MALLOC=... sceglie quale implementazione di malloc() utilizzare. emmalloc è un'implementazione malloc() piccola e veloce specificamente per Emscripten. L'alternativa è dlmalloc, un'implementazione completa di malloc(). Devi passare a dlmalloc solo se allochi spesso molti oggetti piccoli o se vuoi utilizzare i thread.
  • -s EXPORT_ES6=1 trasformerà il codice JavaScript in un modulo ES6 con un'esportazione predefinita che funziona con qualsiasi bundler. Richiede anche l'impostazione di -s MODULARIZE=1.

I seguenti flag non sono sempre necessari o sono utili solo per il debug:

  • -s FILESYSTEM=0 è un flag relativo a Emscripten e alla sua capacità di emulare un file system per te quando il tuo codice C/C++ utilizza operazioni del file system. Esegue un'analisi del codice che compila per decidere se includere o meno l'emulazione del file system nel codice di collegamento. A volte, tuttavia, questa analisi può sbagliare e paghi un bel po' di 70 kB di codice glue aggiuntivo per un'emulazione del file system che potresti non aver bisogno. Con -s FILESYSTEM=0 puoi forzare Emscripten a non includere questo codice.
  • -g4 farà in modo che Emscripten includa le informazioni di debug in .wasm ed emetta anche un file di mappe di origine per il modulo Wasm. Per saperne di più sul debug con Emscripten, consulta la sezione dedicata al debug.

Ecco fatto! Per testare questa configurazione, creiamo un piccolo my-module.cpp:

    #include <emscripten/bind.h>

    using namespace emscripten;

    int say_hello() {
      printf("Hello from your wasm module\n");
      return 0;
    }

    EMSCRIPTEN_BINDINGS(my_module) {
      function("sayHello", &say_hello);
    }

E un index.html:

    <!doctype html>
    <title>Emscripten + npm example</title>
    Open the console to see the output from the wasm module.
    <script type="module">
    import wasmModule from "./my-module.js";

    const instance = wasmModule({
      onRuntimeInitialized() {
        instance.sayHello();
      }
    });
    </script>

(Ecco un gist contenente tutti i file.)

Per creare tutto, esegui

$ npm install
$ npm run build
$ npm run serve

Se vai alla pagina localhost:8080, dovresti vedere il seguente output nella console DevTools:

DevTools che mostra un messaggio stampato tramite C++ ed Emscripten.

Aggiungere codice C/C++ come dipendenza

Se vuoi creare una libreria C/C++ per la tua app web, devi includere il codice nel tuo progetto. Puoi aggiungere il codice al repository del tuo progetto manualmente oppure puoi utilizzare npm per gestire anche questo tipo di dipendenze. Supponiamo che io voglia utilizzare libvpx nella mia app web. libvpx è una libreria C++ per codificare le immagini con VP8, il codec utilizzato nei file .webm. Tuttavia, libvpx non è su npm e non ha un package.json, quindi non posso installarlo direttamente utilizzando npm.

Per uscire da questo dilemma, esiste napa. napa ti consente di installare qualsiasi URL del repository git come dipendenza nella cartella node_modules.

Installa napa come dipendenza:

$ npm install --save napa

e assicurati di eseguire napa come script di installazione:

{
// ...
"scripts": {
    "install": "napa",
    // ...
},
"napa": {
    "libvpx": "git+https://github.com/webmproject/libvpx"
}
// ...
}

Quando esegui npm install, napa si occupa di clonare il repository GitHub libvpx in node_modules con il nome libvpx.

Ora puoi estendere lo script di build per creare libvpx. libvpx utilizza configure e make per essere creato. Fortunatamente, Emscripten può contribuire a garantire che configure e make utilizzino il compilatore di Emscripten. A questo scopo, esistono i comandi wrapper emconfigure e emmake:

# ... above is unchanged ...
echo "============================================="
echo "Compiling libvpx"
echo "============================================="
(
    rm -rf build-vpx || true
    mkdir build-vpx
    cd build-vpx
    emconfigure ../node_modules/libvpx/configure \
    --target=generic-gnu
    emmake make
)
echo "============================================="
echo "Compiling libvpx done"
echo "============================================="

echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
# ... below is unchanged ...

Una libreria C/C++ è suddivisa in due parti: le intestazioni (tradizionalmente file .h o .hpp) che definiscono le strutture di dati, le classi, le costanti e così via che una libreria espone e la libreria vera e propria (tradizionalmente file .so o .a). Per utilizzare la costante VPX_CODEC_ABI_VERSION della libreria nel tuo codice, devi includere i file di intestazione della libreria utilizzando un'istruzione #include:

#include "vpxenc.h"
#include <emscripten/bind.h>

int say_hello() {
    printf("Hello from your wasm module with libvpx %d\n", VPX_CODEC_ABI_VERSION);
    return 0;
}

Il problema è che il compilatore non sa dove cercare vpxenc.h. A questo serve il flag -I. Indica al compilatore le directory da controllare per i file di intestazione. Inoltre, devi anche fornire al compilatore il file della libreria effettivo:

# ... above is unchanged ...
echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
(
    # Compile C/C++ code
    emcc \
    ${OPTIMIZE} \
    --bind \
    -s STRICT=1 \
    -s ALLOW_MEMORY_GROWTH=1 \
    -s ASSERTIONS=0 \
    -s MALLOC=emmalloc \
    -s MODULARIZE=1 \
    -s EXPORT_ES6=1 \
    -o ./my-module.js \
    -I ./node_modules/libvpx \
    src/my-module.cpp \
    build-vpx/libvpx.a

# ... below is unchanged ...

Se esegui npm run build ora, vedrai che il processo crea un nuovo file .js e un nuovo file .wasm e che la pagina demo restituirà effettivamente la costante:

DevTools
che mostra la versione ABI di libvpx stampata tramite emscripten.

Noterai anche che il processo di creazione richiede molto tempo. Il motivo dei lunghi tempi di compilazione può variare. Nel caso di libvpx, ci vuole molto tempo perché compila un codificatore e un decodificatore per VP8 e VP9 ogni volta che esegui il comando di compilazione, anche se i file di origine non sono cambiati. Anche una piccola modifica al tuo my-module.cpp richiederà molto tempo per essere implementata. Sarebbe molto vantaggioso conservare gli artefatti di build di libvpx dopo la prima build.

Un modo per farlo è utilizzare le variabili di ambiente.

# ... above is unchanged ...
eval $@

echo "============================================="
echo "Compiling libvpx"
echo "============================================="
test -n "$SKIP_LIBVPX" || (
    rm -rf build-vpx || true
    mkdir build-vpx
    cd build-vpx
    emconfigure ../node_modules/libvpx/configure \
    --target=generic-gnu
    emmake make
)
echo "============================================="
echo "Compiling libvpx done"
echo "============================================="
# ... below is unchanged ...

Ecco un gist contenente tutti i file.

Il comando eval ci consente di impostare le variabili di ambiente passando i parametri allo script di build. Il comando test salterà la creazione di libvpx se $SKIP_LIBVPX è impostato (su qualsiasi valore).

Ora puoi compilare il modulo, ma saltare la ricompilazione di libvpx:

$ npm run build:emscripten -- SKIP_LIBVPX=1

Personalizzazione dell'ambiente di build

A volte le librerie dipendono da strumenti aggiuntivi per la compilazione. Se queste dipendenze non sono presenti nell'ambiente di build fornito dall'immagine Docker, devi aggiungerle manualmente. Ad esempio, supponiamo che tu voglia anche creare la documentazione di libvpx utilizzando doxygen. Doxygen non è disponibile all'interno del container Docker, ma puoi installarlo utilizzando apt.

Se lo facessi nel tuo build.sh, dovresti scaricare e reinstallare doxygen ogni volta che vuoi creare la tua libreria. Non solo sarebbe uno spreco, ma ti impedirebbe anche di lavorare al tuo progetto offline.

In questo caso, è consigliabile creare la tua immagine Docker. Le immagini Docker vengono create scrivendo un Dockerfile che descrive i passaggi di build. I Dockerfile sono piuttosto potenti e hanno molti comandi, ma la maggior parte delle volte puoi cavartela usando solo FROM, RUN e ADD. In questo caso:

FROM trzeci/emscripten

RUN apt-get update && \
    apt-get install -qqy doxygen

Con FROM, puoi dichiarare quale immagine Docker vuoi utilizzare come punto di partenza. Ho scelto trzeci/emscripten come base, ovvero l'immagine che hai utilizzato finora. Con RUN, indichi a Docker di eseguire i comandi della shell all'interno del container. Qualsiasi modifica apportata al container da questi comandi ora fa parte dell'immagine Docker. Per assicurarti che l'immagine Docker sia stata creata e sia disponibile prima di eseguire build.sh, devi modificare leggermente il file package.json:

{
    // ...
    "scripts": {
    "build:dockerimage": "docker image inspect -f '.' mydockerimage || docker build -t mydockerimage .",
    "build:emscripten": "docker run --rm -v $(pwd):/src mydockerimage ./build.sh",
    "build": "npm run build:dockerimage && npm run build:emscripten && npm run build:app",
    // ...
    },
    // ...
}

Ecco un gist contenente tutti i file.

In questo modo verrà creata l'immagine Docker, ma solo se non è ancora stata creata. A questo punto tutto funziona come prima, ma ora l'ambiente di build ha a disposizione il comando doxygen, che farà in modo che venga creata anche la documentazione di libvpx.

Conclusione

Non sorprende che il codice C/C++ e npm non siano una combinazione naturale, ma puoi farli funzionare in modo abbastanza comodo con alcuni strumenti aggiuntivi e l'isolamento fornito da Docker. Questa configurazione non funziona per tutti i progetti, ma è un buon punto di partenza che puoi modificare in base alle tue esigenze. Se hai miglioramenti, condividili.

Appendice: utilizzo dei livelli delle immagini Docker

Una soluzione alternativa consiste nell'incapsulare un maggior numero di questi problemi con Docker e l'approccio intelligente di Docker alla memorizzazione nella cache. Docker esegue i Dockerfile passo dopo passo e assegna a ogni passaggio un'immagine propria. Queste immagini intermedie sono spesso chiamate "livelli". Se un comando in un Dockerfile non è cambiato, Docker non eseguirà nuovamente questo passaggio quando ricompili il Dockerfile. Riutilizza invece il livello dell'ultima volta che è stata creata l'immagine.

In precedenza, dovevi fare un po' di fatica per non ricompilare libvpx ogni volta che creavi l'app. Invece, puoi spostare le istruzioni di compilazione per libvpx da build.sh a Dockerfile per utilizzare il meccanismo di memorizzazione nella cache di Docker:

FROM trzeci/emscripten

RUN apt-get update && \
    apt-get install -qqy doxygen git && \
    mkdir -p /opt/libvpx/build && \
    git clone https://github.com/webmproject/libvpx /opt/libvpx/src
RUN cd /opt/libvpx/build && \
    emconfigure ../src/configure --target=generic-gnu && \
    emmake make

Ecco un gist contenente tutti i file.

Tieni presente che devi installare manualmente git e clonare libvpx perché non hai mount bind quando esegui docker build. Come effetto collaterale, non è più necessario napa.