Emscripten e npm

Come puoi integrare 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 mostrare che c'è almeno un terzo punto di vista per wasm: usare gli enormi ecosistemi di altri linguaggi di programmazione. Con Emscripten, puoi utilizzare il codice C/C++, il supporto wasm integrato in Ruust 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 un pezzo di puzzle: un altro modulo. La tua app dispone già di JavaScript, CSS, asset immagine, un sistema di build 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 preziosa quando lavoravo con Emscripten. Le librerie C/C++ sono spesso scritte per funzionare con il sistema operativo su cui sono basate. È incredibilmente utile avere un ambiente coerente. Con Docker ottieni un sistema Linux virtualizzato che è già configurato per funzionare con Emscripten e in cui sono installati tutti gli strumenti e le dipendenze. Se manca qualcosa, puoi semplicemente installarlo senza preoccuparti di come influisce sulla tua macchina o su altri progetti. In caso di problemi, scarta il container e ricomincia. Se funziona una volta, puoi essere certo che continuerà a funzionare e a produrre risultati identici.

Il Docker Registry ha un'immagine Emscripten di trzeci che uso molto spesso.

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 creata 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 artefatti di build Emscripten devono essere creati prima che venga attivato il processo 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 significa che vuoi che Docker esegua il "specchietto" della directory corrente ($(pwd)) su /src all'interno del container. Qualsiasi modifica apportata ai file nella directory /src all'interno del contenitore verrà rispecchiata nel progetto effettivo. Queste directory con mirroring sono chiamate "bind mounts".

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 attiva la modalità "fail fast" per la shell. 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 effettuare alcuna ottimizzazione. Non viene eliminato alcun codice inutilizzato ed Emscripten non minimizza nemmeno il codice JavaScript che emette. Ideale per il debug.
  • -O3: ottimizza in modo aggressivo per il rendimento.
  • -Os: ottimizza in modo aggressivo per rendimento e 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 consente di configurare Emscripten in modo specifico. Tutte le opzioni disponibili si trovano in settings.js di Emscripten, ma quel file può essere piuttosto complesso. 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. Ciò garantisce che il codice venga creato in modo compatibile con il forwarding.
  • -s ALLOW_MEMORY_GROWTH=1 consente di aumentare automaticamente la memoria, se necessario. Al momento della scrittura, Emscripten allocherà 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 malloc() completa. Devi passare a dlmalloc solo se allocati spesso molti oggetti di piccole dimensioni 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 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 emulare un file system quando il codice C/C++ utilizza operazioni di 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, però, 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 ulteriori informazioni sul debug con Emscripten, consulta la sezione 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 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 accedi all'indirizzo localhost:8080, nella console DevTools dovresti visualizzare il seguente output:

DevTools mostra un messaggio stampato tramite C++ e 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 puoi usare npm per gestire anche questo tipo di dipendenze. Supponiamo di voler utilizzare libvpx nella mia applicazione 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 usando npm.

Per uscire da questo enigma, esiste napa. napa 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 nel tuo node_modules con il nome libvpx.

Ora puoi estendere lo script di build per creare libvpx. Per creare libvpx utilizza configure e make. Fortunatamente, Emscripten può contribuire a garantire 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++ è divisa in due parti: le intestazioni (tradizionalmente file .h o .hpp) che definiscono le strutture di dati, le classi, le costanti e così via esposte da una libreria e la libreria effettiva (tradizionalmente i 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. Questo è lo scopo del 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 .js e un nuovo file .wasm e che la pagina demo restituirà effettivamente la costante:

DevTools
mostra una versione ABI di libvpx stampata tramite emscripten.

Noterai anche che il processo di compilazione richiede molto tempo. Il motivo dei lunghi tempi di compilazione può 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 trasmettendo parametri allo script di build. 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 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 in build.sh, dovrai scaricare di nuovo e reinstallare doxygen ogni volta che vuoi creare la tua raccolta. Non solo sarebbe uno spreco, ma ti impedirà anche di lavorare al tuo progetto offline.

In questo caso, ha senso creare la tua immagine Docker. Le immagini Docker vengono create scrivendo un Dockerfile che descrive i passaggi di build. I Dockerfile sono molto potenti e hanno molti comandi, ma il più delle volte puoi riuscirci semplicemente usando 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 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 container da questi comandi fa parte dell'immagine Docker. Per assicurarti che l'immagine Docker sia stata creata e sia disponibile prima di eseguire build.sh, devi regolare package.json a bit:

{
    // ...
    "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 riepilogo che contiene tutti i file.

Verrà creata l'immagine Docker, ma solo se non è ancora stata creata. Tutto viene eseguito come prima, ma ora nell'ambiente di build è disponibile il comando doxygen, che causerà la creazione anche della documentazione di libvpx.

Conclusione

Non c'è da stupirsi che il codice C/C++ e npm non siano una soluzione naturale, ma è possibile farla funzionare abbastanza comodamente 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 adattare alle tue esigenze. Se ci sono miglioramenti, condividili.

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 dopo passo e assegna al risultato di ogni passaggio un'immagine personale. Queste immagini intermedie sono spesso denominate "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 dall'ultima creazione dell'immagine.

In precedenza, dovevi fare un po' di lavoro per non ricreare libvpx ogni volta che creavi la tua app. Invece, puoi spostare le istruzioni per la creazione di libvpx da build.sh in Dockerfile per utilizzare il meccanismo di 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 poiché non hai bind mount quando esegui docker build. Come effetto collaterale, non è più necessario napa.