Emscripten e npm

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

WebAssembly (wasm) viene spesso interpretato come una primitiva per le prestazioni o come un modo per eseguire il codice base C++ esistente sul web. Con squoosh.app, volevamo dimostrare che esiste almeno una terza prospettiva per wasm: l'utilizzo degli enormi ecosistemi di altri linguaggi di programmazione. Con Emscripten, puoi utilizzare il codice C/C++, Rust ha il supporto di wasm integrato e anche il team di Go ci sta lavorando. Sono sicura che seguiranno molte altre lingue.

In questi scenari, wasm non è il fulcro della tua app, ma piuttosto un elemento di un puzzle: un altro modulo. La tua app ha già JavaScript, CSS, asset immagine, un sistema di compilazione incentrato sul web e forse anche un framework come React. Come si integra WebAssembly in questa configurazione? In questo articolo esamineremo questo metodo con C/C++ ed Emscripten come esempio.

Docker

Ho trovato Docker inestimabile quando lavoro con Emscripten. Le librerie C/C++ vengono spesso scritte per funzionare con il sistema operativo su cui sono basate. È estremamente utile avere un ambiente coerente. Con Docker hai a disposizione 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 preoccuparti di come influisce sulla tua macchina o su altri progetti. Se qualcosa va storto, getta il contenitore e ricomincia. Se funziona una volta, puoi essere certo che continuerà a funzionare e produrrà risultati identici.

Docker Registry ha un'immagine Emscripten di trzeci che utilizzo molto.

Integrazione con npm

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

In generale, gli elementi di compilazione 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 e il file wasm deve essere trattato come qualsiasi altro asset binario più grande, come le immagini.

Di conseguenza, gli elementi di compilazione di Emscripten devono essere compilati prima dell'avvio della procedura di compilazione "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 chiamare direttamente Emscripten, ma come accennato in precedenza, ti consiglio di utilizzare Docker per assicurarti che l'ambiente di compilazione 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 a breve. --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 indica che vuoi che Docker "esegui" la directory corrente ($(pwd)) in /src all'interno del contenitore. Eventuali modifiche apportate ai file nella directory /src all'interno del contenitore verranno applicate al progetto effettivo. Queste directory con mirroring 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 mette la shell in modalità "fail fast". Se uno dei comandi dello script restituisce un errore, l'intero script viene interrotto immediatamente. Questo può essere molto utile perché l'ultimo output dello script sarà sempre un messaggio di successo o l'errore che ha causato il fallimento della compilazione.

Con le istruzioni export, definisci i valori di un paio di variabili di ambiente. Ti 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 nello stesso modo. Esistono alcuni valori possibili per la variabile OPTIMIZE:

  • -O0: non eseguire alcuna ottimizzazione. Non viene eliminato alcun codice inutilizzato ed Emscripten non minimizza nemmeno il codice JavaScript che emette. Ideale per il debug.
  • -O3: esegui un'ottimizzazione aggressiva in funzione del rendimento.
  • -Os: ottimizza in modo aggressivo per le prestazioni e le dimensioni come criterio secondario.
  • -Oz: esegui un'ottimizzazione aggressiva in base alle 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 dovrebbe essere un "sostituzione diretta per compilatori come GCC o clang". Pertanto, è molto probabile che tutti i flag che potresti conoscere da GCC vengano 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 scoraggiante. Ecco un elenco dei flag Emscripten che ritengo più importanti per gli sviluppatori web:

  • --bind attiva embind.
  • -s STRICT=1 non supporta più tutte le opzioni di compilazione ritirate. In questo modo, il codice viene compilato in modo compatibile con le versioni successive.
  • -s ALLOW_MEMORY_GROWTH=1 consente di aumentare automaticamente la memoria, se necessario. Al momento della stesura di questo articolo, Emscripten alloca inizialmente 16 MB di memoria. Quando 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 è autorizzato a espandere la memoria totale per soddisfare l'allocazione.
  • -s MALLOC=... sceglie quale implementazione di malloc() utilizzare. emmalloc è un'implementazione malloc() piccola e veloce specifica per Emscripten. L'alternativa è dlmalloc, un'implementazione completa di malloc(). Devi passare a dlmalloc solo se allochi spesso molti oggetti di piccole dimensioni o se vuoi utilizzare il threading.
  • -s EXPORT_ES6=1 trasformerà il codice JavaScript in un modulo ES6 con un'esportazione predefinita che funziona con qualsiasi bundler. Richiede inoltre l'impostazione di -s MODULARIZE=1.

I seguenti flag non sono sempre necessari o sono utili solo per scopi di debugging:

  • -s FILESYSTEM=0 è un flag relativo a Emscripten e alla sua capacità di eseguire l'emulazione di un file system quando il codice C/C++ utilizza operazioni sul file system. Esegue alcune analisi sul codice che compila per decidere se includere o meno l'emulazione del filesystem nel codice di collegamento. A volte, tuttavia, questa analisi può commettere errori e ti costerà 70 kB di codice aggiuntivo per un'emulazione del file system di cui potresti non avere 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 e inoltre emetta un file di mappe di origine per il modulo wasm. Per saperne di più sul debugging con Emscripten, consulta la sezione sul debugging.

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 compilare tutto, esegui

$ npm install
$ npm run build
$ npm run serve

Se accedi all'indirizzo localhost:8080, nella console DevTools dovresti visualizzare il seguente output:

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

Aggiunta di codice C/C++ come dipendenza

Se vuoi creare una libreria C/C++ per la tua app web, il codice deve far parte del progetto. Puoi aggiungere il codice al repository del progetto manualmente oppure utilizzare npm per gestire anche questo tipo di dipendenze. Supponiamo che io voglia utilizzare libvpx nella mia web app. 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, c'è 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 di libvpx nel tuo node_modules con il nome libvpx.

Ora puoi estendere lo script di compilazione per compilare libvpx. Per la compilazione, libvpx utilizza configure e make. Fortunatamente, Emscripten può aiutarti ad assicurarti che configure e make utilizzino il compilatore di Emscripten. A questo scopo sono disponibili 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: gli header (in genere file .h o .hpp) che definiscono le strutture di dati, le classi, le costanti e così via esposte da una biblioteca e la libreria effettiva (in genere file .so o .a). Per utilizzare la costante VPX_CODEC_ABI_VERSION della libreria nel 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 quali directory controllare per i file di intestazione. Inoltre, devi fornire al compilatore anche il file della libreria effettiva:

# ... 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 di dimostrazione effettivamente genera la costante:

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

Inoltre, noterai che la procedura di compilazione richiede molto tempo. I motivi per i tempi di compilazione lunghi possono variare. Nel caso di libvpx, l'operazione richiede molto tempo perché compila un codificatore e un decodificatore sia per VP8 che per VP9 ogni volta che esegui il comando di compilazione, anche se i file di origine non sono stati modificati. Anche una piccola variazione al tuo my-module.cpp richiederà molto tempo per essere creata. È molto utile conservare gli artefatti di build di libvpx dopo la prima compilazione.

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 parametri allo script di compilazione. Il comando test salta la compilazione di libvpx se $SKIP_LIBVPX è impostato (su qualsiasi valore).

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

$ npm run build:emscripten -- SKIP_LIBVPX=1

Personalizzazione dell'ambiente di compilazione

A volte le librerie dipendono da strumenti aggiuntivi per la compilazione. Se queste dipendenze mancano nell'ambiente di compilazione fornito dall'immagine Docker, devi aggiungerle autonomamente. 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 in build.sh, dovresti scaricare e reinstallare nuovamente Doxygen ogni volta che vuoi creare la tua raccolta. Non solo sarebbe un impiego sconsiderato di risorse, ma ti impedirebbe anche di lavorare al tuo progetto quando sei offline.

In questo caso ha senso creare la tua immagine Docker. Le immagini Docker vengono create scrivendo un Dockerfile che descrive i passaggi di compilazione. I Dockerfile sono abbastanza potenti e dispongono di molti comandi, ma nella maggior parte dei casi puoi cavartela semplicemente utilizzando FROM, RUN e ADD. In questo caso:

FROM trzeci/emscripten

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

Con FROM, puoi dichiarare l'immagine Docker che vuoi utilizzare come punto di partenza. Ho scelto trzeci/emscripten come base, l'immagine che hai utilizzato da sempre. Con RUN, indichi a Docker di eseguire comandi shell all'interno del container. Qualsiasi modifica apportata al contenitore da questi comandi fa ora parte dell'immagine Docker. Per assicurarti che l'immagine Docker sia stata creata ed sia disponibile prima di eseguire build.sh, devi modificare leggermente 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.)

Verrà creata l'immagine Docker, ma solo se non è ancora stata creata. Poi, tutto funziona come prima, ma ora nell'ambiente di compilazione è disponibile il comando doxygen che comporterà anche la compilazione della documentazione di libvpx.

Conclusione

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

Appendice: utilizzo dei livelli di immagini Docker

Una soluzione alternativa è incapsulare più di questi problemi con Docker e con l'approccio intelligente di Docker alla memorizzazione nella cache. Docker esegue i Dockerfile passo passo e assegna al risultato di ogni passaggio un'immagine propria. Queste immagini intermedie vengono spesso chiamate "livelli". Se un comando in un Dockerfile non è cambiato, Docker non eseguirà di nuovo quel passaggio durante la ricostruzione del Dockerfile. Riutilizza invece il livello dell'ultima compilazione dell'immagine.

In precedenza, era necessario un po' di impegno per non ricostruire libvpx ogni volta che costruisci l'app. Ora puoi spostare le istruzioni di compilazione di libvpx dal tuo build.sh in 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 di unione quando esegui docker build. Come effetto collaterale, non è più necessario napa.