Emscripten e npm

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

WebAssembly (wasm) è spesso definito come una primitiva delle prestazioni o un modo per eseguire il codebase C++ esistente sul web. Con squoosh.app, vogliamo 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. Sicuramente verranno usate molte altre lingue.

In questi scenari, wasm non è il fulcro dell'app, ma un pezzo di puzzle: un altro modulo. La tua app dispone già di JavaScript, CSS, asset immagine, un sistema di creazione incentrato sul web e forse anche un framework come React. Come si integra WebAssembly in questa configurazione? In questo articolo spiegheremo come usare C/C++ ed Emscripten.

Docker

Docker è stato prezioso quando lavoro con Emscripten. Le librerie C/C++ sono spesso scritte per funzionare con il sistema operativo su cui sono basate. Avere un ambiente coerente è incredibilmente utile. Con Docker si ottiene un sistema Linux virtualizzato già configurato per il funzionamento con Emscripten e in cui sono installati tutti gli strumenti e le dipendenze. Se manca qualcosa, puoi semplicemente installarlo senza preoccuparti dell'impatto sulla tua macchina o sugli altri tuoi progetti. Se si verifica un problema, getta via il contenitore e ricomincia. Se funziona una volta, puoi avere la certezza che continuerà a funzionare e produca risultati identici.

Il Docker Registry ha un'immagine Emscripten di trzeci che sto usando ampiamente.

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 come un altro asset. Il file JavaScript può essere gestito da un bundler come webpack o di aggregazione e il file wasm deve essere trattato come qualsiasi altro asset binario più grande, come le immagini.

Di conseguenza, gli artefatti di build di Emscripten devono essere creati prima che inizi il tuo "normale" processo di build:

{
    "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",
    // ...
    },
    // ...
}

La nuova attività build:emscripten potrebbe richiamare Emscripten direttamente, ma, come detto 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 successivamente. --rm indica a Docker di eliminare il container al termine dell'esecuzione. In questo modo, non creerai una raccolta di immagini macchina inattive nel tempo. -v $(pwd):/src significa che vuoi che Docker esegua il "Mirroring" della directory attuale ($(pwd)) su /src all'interno del container. Eventuali modifiche apportate ai file nella directory /src all'interno del container verranno sincronizzate nel progetto effettivo. Queste directory con mirroring sono chiamate "montaggi di associazione".

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'è tanto da analizzare qui!

set -e imposta la shell in modalità "fail fast". Se uno o più comandi dello script restituiscono un errore, l'intero script viene interrotto immediatamente. Questo può essere incredibilmente utile perché 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 ulteriori parametri della riga di comando al compilatore C (CFLAGS), al compilatore C++ (CXXFLAGS) e al linker (LDFLAGS). Tutti ricevono le impostazioni di ottimizzazione tramite OPTIMIZE per garantire che tutto venga ottimizzato nello stesso modo. Esistono un paio di possibili valori per la variabile OPTIMIZE:

  • -O0: non eseguire alcuna ottimizzazione. Non viene eliminato nessun codice morto e 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: ottimizza in modo aggressivo per le dimensioni, sacrificando il rendimento se necessario.

Per il web, consiglio principalmente -Os.

Il comando emcc ha una miriade di opzioni. Tieni presente che emcc è da considerarsi una "sostituzione drop-in per compilatori come GCC o clang". Quindi, molto probabilmente tutti i flag che potresti conoscere in GCC verranno implementati anche dall'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 quel file può essere piuttosto complesso. Ecco un elenco delle segnalazioni Emscripten che ritengo siano più importanti per gli sviluppatori web:

  • --bind consente di embinare.
  • -s STRICT=1 non supporta più tutte le opzioni di build deprecate. Ciò garantisce che il codice venga creato in modo compatibile con l'inoltro.
  • -s ALLOW_MEMORY_GROWTH=1 consente di aumentare automaticamente la memoria, se necessario. Al momento della scrittura, Emscripten alloca inizialmente 16 MB di memoria. Man mano che il codice alloca blocchi di memoria, questa opzione determina se queste operazioni rendono l'intero modulo Wasm non riuscito quando la memoria è esaurita o se il codice colla può espandere la memoria totale per soddisfare l'allocazione.
  • -s MALLOC=... sceglie l'implementazione malloc() da utilizzare. emmalloc è un'implementazione malloc() di dimensioni ridotte e veloce specifica per Emscripten. L'alternativa è dlmalloc, un'implementazione malloc() completa. Devi passare a dlmalloc solo se assegni frequentemente molti oggetti di piccole dimensioni o se vuoi utilizzare l'organizzazione in thread.
  • -s EXPORT_ES6=1 trasformerà il codice JavaScript in un modulo ES6 con un'esportazione predefinita compatibile con qualsiasi bundler. È necessario anche impostare -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 ti consente di emulare un file system quando il tuo codice C/C++ utilizza operazioni di file system. Esegue alcune analisi sul codice compilato per decidere se includere o meno l'emulazione del file system nel glue code. Tuttavia, a volte questa analisi può sbagliare e costano 70 kB in più di colla codice per un'emulazione del file system che potrebbe non essere necessaria. Con -s FILESYSTEM=0 puoi forzare Emscripten a non includere questo codice.
  • Con -g4, Emscripten includerà informazioni di debug in .wasm ed emetterà anche un file delle mappe di origine per il modulo wasm. Per ulteriori informazioni sul debug con Emscripten, consulta la relativa sezione sul debug.

Ecco fatto! Per testare questa configurazione, prepariamo un 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 accedi a localhost:8080, dovresti visualizzare il seguente output nella console di DevTools:

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

Aggiunta del codice C/C++ come dipendenza

Se vuoi creare una libreria C/C++ per la tua applicazione web, il suo 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 usare libvpx nella mia applicazione web. libvpx è una libreria C++ per codificare le immagini con VP8, il codec utilizzato nei file .webm. Tuttavia, libvpx non è in npm e non ha un package.json, quindi non posso installarlo utilizzando direttamente npm.

Per risolvere questo rompicapo, c'è napa, che 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. libvpx utilizza configure e make per la creazione. Fortunatamente, Emscripten può contribuire ad assicurare 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 dei dati, le classi, le costanti e così via esposte dalla 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. Ecco a cosa serve il flag -I. Indica al compilatore le directory da controllare i file di intestazione. Inoltre, devi anche fornire al compilatore l'effettivo file della libreria:

# ... 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 .wasm e che la pagina demo restituirà infatti la costante:

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

Noterai anche che il processo di compilazione richiede molto tempo. I motivi alla base di tempi di compilazione lunghi possono variare. Nel caso di libvpx, ci vuole molto tempo perché compila un codificatore e un decoder sia per VP8 che per VP9 ogni volta che esegui il comando di build, anche se i file di origine non sono cambiati. Anche una piccola modifica al tuo my-module.cpp richiederà molto tempo per la creazione. Sarebbe molto utile mantenere gli artefatti di build di libvpx una volta costruiti per la prima volta.

Un modo per raggiungere questo obiettivo è 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 ignorerà la creazione di libvpx se è impostato $SKIP_LIBVPX (su qualsiasi valore).

Ora puoi compilare il modulo ma puoi evitare di ricreare libvpx:

$ npm run build:emscripten -- SKIP_LIBVPX=1

Personalizzazione dell'ambiente di compilazione

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

Se lo facessi su build.sh, riscarica e reinstalli doxygen ogni volta che vuoi creare la tua libreria. Non solo sarebbe uno spreco, ma ti impedirà anche di lavorare al 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 abbastanza potenti e hanno molti comandi, ma nella maggior parte dei casi puoi semplicemente utilizzare 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, ovvero l'immagine che avete sempre utilizzato. Con RUN, indichi a Docker di eseguire i comandi shell all'interno del container. Qualunque sia la modifica apportata da questi comandi al container, ora fa parte dell'immagine Docker. Per assicurarti che l'immagine Docker sia stata creata e che sia disponibile prima di eseguire build.sh, devi regolare un bit 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.)

L'immagine Docker verrà creata, ma solo se non è stata ancora creata. Poi tutto funziona come prima, ma ora nell'ambiente di build è disponibile il comando doxygen, il che causerà la creazione della documentazione di libvpx.

Conclusione

Non sorprende che il codice C/C++ e npm non siano adatti a te, ma puoi farli funzionare senza problemi 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 adattare alle tue esigenze. Se hai dei miglioramenti, condividili.

Appendice: utilizzo dei livelli di immagini Docker

Una soluzione alternativa è incapsulare un maggior numero di questi problemi con l'approccio intelligente di Docker e Docker alla memorizzazione nella cache. Docker esegue passo passo i Dockerfile e assegna al risultato di ogni passaggio un'immagine personalizzata. Queste immagini intermedie sono spesso chiamate "livelli". Se un comando in un Dockerfile non è cambiato, Docker non eseguirà nuovamente questo passaggio quando viene ricreato il Dockerfile. Riutilizza invece il livello dell'ultima creazione dell'immagine.

In precedenza, dovevi evitare di ricreare libvpx ogni volta che creavi l'app. Puoi invece spostare le istruzioni di creazione di libvpx da build.sh in Dockerfile per utilizzare il meccanismo di memorizzazione 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 montaggi associati durante l'esecuzione di docker build. Come effetto collaterale, non c'è più bisogno di una sosta.