Emscripten e npm

Come puoi integrare WebAssembly in questa configurazione? In questo articolo, vedremo come utilizzare C/C++ e Emscripten come esempio.

WebAssembly (wasm) è spesso in frame come una primitiva per le prestazioni o un modo per eseguire il tuo C++ esistente il codebase sul web. Con squoosh.app, volevamo mostrare che c'è almeno un terzo punto di vista per wasm: sfruttare l'enorme ecosistemi di altri linguaggi di programmazione. Con Emscripten, puoi usare il codice C/C++, Rust ha il supporto wasm integrato e Go se ne sta occupando anche il team. Sono che molte altre lingue seguiranno.

In questi scenari, wasm non è il fulcro della tua app, ma un puzzle pezzi: un altro modulo. La tua app dispone già di asset JavaScript, CSS, immagine, basato sul web e magari anche un framework come React. In che modo integrare WebAssembly in questa configurazione? In questo articolo lavoreremo con C/C++ e Emscripten come esempio.

Docker

Ho trovato Docker preziosa quando lavoravo con Emscripten. C/C++ spesso sono scritte per funzionare con il sistema operativo su cui sono basate. È incredibilmente utile avere un ambiente coerente. Con Docker ottieni sistema Linux virtualizzato che è già configurato per funzionare con Emscripten e ha tutti gli strumenti e le dipendenze installati. Se manca qualcosa, puoi semplicemente installarlo senza doverti preoccupare delle conseguenze che può avere sul tuo computer o sul tuo altri progetti. In caso di problemi, scarta il container e avvia oltre. Se funziona una volta, puoi essere certo che continuerà a funzionare e producono risultati identici.

Il Docker Registry dispone di un parametro Emscripten immagine di trzeci che uso molto.

Integrazione con npm

Nella maggior parte dei casi, il punto di ingresso di un progetto web è il package.json. 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 .js e un .wasm ) devono essere trattati solo come un altro modulo JavaScript e come 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, in formato Docker.

Di conseguenza, gli artefatti di build di Emscripten devono essere creati prima dei tuoi "normali" il processo di compilazione si attiva:

{
    "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 menzionate prima, consiglio di usare Docker per assicurarmi che l'ambiente di build sia coerente.

docker run ... trzeci/emscripten ./build.sh dice a Docker di avviare utilizzando l'immagine trzeci/emscripten ed esegui il comando ./build.sh. build.sh è uno script shell che scriverai successivamente. --rm dice Docker per eliminare il container al termine dell'esecuzione. In questo modo, e creare una raccolta di immagini di macchine obsolete nel tempo. -v $(pwd):/src significa che vuoi che Docker esegua il "mirror" dalla directory corrente ($(pwd)) a /src all'interno nel container. Eventuali modifiche apportate ai file nella directory /src all'interno della verrà rispecchiato nel progetto effettivo. Queste directory sottoposte a mirroring sono chiamati "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 inserisce la shell in "fail fast" . Se ci sono comandi nello script restituiscono un errore, l'intero script viene interrotto immediatamente. Può essere incredibilmente utile perché l'ultimo output dello script sarà sempre un successo o l'errore che ha causato la mancata riuscita della build.

Con le istruzioni export, definisci i valori di un paio di ambienti come la codifica one-hot delle variabili categoriche. Consentono di passare ulteriori parametri della riga di comando all'interfaccia il compilatore (CFLAGS), il compilatore C++ (CXXFLAGS) e il linker (LDFLAGS). Tutti ricevono le impostazioni di ottimizzazione tramite OPTIMIZE per assicurarsi che tutto viene ottimizzato allo stesso modo. Esistono un paio di valori possibili per la variabile OPTIMIZE:

  • -O0: non effettuare alcuna ottimizzazione. Il codice obsoleto non viene eliminato e Emscripten non minimizza 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 secondario criterio.
  • -Oz: ottimizza in modo aggressivo per le dimensioni, sacrificando il rendimento se necessario.

Consiglio principalmente -Os per il web.

Il comando emcc ha una miriade di opzioni proprie. Tieni presente che il campo emcc è dovrebbe essere una "sostituzione drop-in per compilatori come GCC o clang". Quindi tutti i flag che potresti conoscere in GCC saranno probabilmente implementati da emcc come beh. Il flag -s è speciale in quanto consente di configurare Emscripten in particolare. Tutte le opzioni disponibili sono disponibili nel pannello settings.js, ma quel file può essere piuttosto complesso. Ecco un elenco delle segnalazioni Emscripten che credo siano più importanti per gli sviluppatori web:

  • --bind attiva embind.
  • -s STRICT=1 interromperà il supporto per tutte le opzioni di build deprecate. Ciò garantisce che il tuo codice crea in modo compatibile con il futuro.
  • -s ALLOW_MEMORY_GROWTH=1 consente di aumentare automaticamente la memoria se necessaria. Al momento della scrittura, Emscripten allocherà 16 MB di memoria all'inizio. Quando il codice alloca blocchi di memoria, questa opzione stabilisce se queste operazioni faranno in modo che l'intero modulo wasm non funzioni quando la memoria esauriti o se il glue code può espandere la memoria totale gestire l'allocazione.
  • -s MALLOC=... sceglie l'implementazione malloc() da utilizzare. emmalloc è una piccola e rapida implementazione di malloc() specifica per Emscripten. La è dlmalloc, un'implementazione malloc() completa. Devi eseguire passare a dlmalloc se stai allocando molti oggetti di piccole dimensioni spesso o se vuoi usare l'organizzazione in thread.
  • -s EXPORT_ES6=1 trasformerà il codice JavaScript in un modulo ES6 con un un'esportazione predefinita che funziona con qualsiasi bundler. Richiede anche -s MODULARIZE=1 per da impostare.

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

  • -s FILESYSTEM=0 è un flag relativo a Emscripten e alla sua capacità di 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 dell'emulazione del file system o meno nel glue code. A volte, però, l'analisi può sbagliare e paghi 70 kB al massimo di collante per un'emulazione di file system che potrebbe non essere necessaria. Con -s FILESYSTEM=0 puoi forzare Emscripten a non includere questo codice.
  • -g4 consentirà a Emscripten di includere le informazioni di debug in .wasm e emette anche un file di mappe di origine per il modulo wasm. Puoi scoprire di più su il debug con Emscripten nel suo 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 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 sintesi contenente tutti i file.

Per creare tutto, esegui

$ npm install
$ npm run build
$ npm run serve

Se passi a localhost:8080 dovresti visualizzare il seguente output nella Console DevTools:

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 applicazione web, è necessario che il suo codice parte del tuo progetto. Puoi aggiungere il codice manualmente al repository del progetto Oppure puoi usare npm per gestire anche questo tipo di dipendenze. Immaginiamo voglio usare 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 lo installi direttamente usando npm.

Per uscire da questo rompicapo, c'è napa. napa ti consente di installare qualsiasi Git l'URL del repository 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 GitHub libvpx repository in node_modules con il nome libvpx.

Ora puoi estendere lo script di build per creare libvpx. libvpx utilizza configure e make. Fortunatamente, Emscripten può contribuire a garantire che configure e make usano il compilatore di Emscripten. A questo scopo ci sono i wrapper Comandi 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 .h o .hpp) che definiscono le strutture di dati, le classi, le costanti e così via che un che espone e la libreria effettiva (tradizionalmente i file .so o .a). A usare la costante VPX_CODEC_ABI_VERSION della libreria nel codice, devi per 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 per controllare i file di intestazione. Inoltre, devi fornire al compilatore anche file della raccolta 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 .js e un nuovo file .wasm e che la pagina demo restituirà effettivamente la costante:

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

Noterai anche che il processo di compilazione richiede molto tempo. Il motivo per cui tempi di creazione lunghi possono variare. Nel caso di libvpx, ci vuole molto tempo perché compila un encoder e un decoder sia per VP8 che per VP9 ogni volta che il comando build, anche se i file di origine non sono stati modificati. Anche un piccolo la modifica al tuo my-module.cpp richiederà molto tempo. Sarebbe molto utile per mantenere gli artefatti di build di libvpx una volta che sono stati ha costruito per la prima volta.

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 riepilogo contenente tutti i file.

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

Ora puoi compilare il modulo ma saltando la nuova creazione di libvpx:

$ npm run build:emscripten -- SKIP_LIBVPX=1

Personalizzazione dell'ambiente di build

A volte le librerie dipendono da strumenti aggiuntivi per la loro creazione. Se queste dipendenze mancanti nell'ambiente di build fornito dall'immagine Docker, devi aggiungerli personalmente. Ad esempio, supponiamo che tu voglia creare anche documentazione di libvpx utilizzando doxygen. Il dossigeno non è disponibile all'interno del container Docker, ma puoi installarlo usando apt.

Se lo fai in build.sh, dovrai scaricare e reinstallare l'app ogni volta che vuoi creare la tua raccolta. Non solo sarebbe uno spreco di risorse, ma potrebbe impedirti di lavorare al progetto mentre sei offline.

In questo caso, ha senso creare la tua immagine Docker. Le immagini Docker vengono create scrivendo un Dockerfile che descriva i passaggi di build. I Dockerfile sono abbastanza potente e ha molte comandi, ma la maggior parte dei che puoi usare 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 utilizzare come punto di partenza punto di accesso. Ho scelto trzeci/emscripten come base, l'immagine che stai utilizzando fin dall'inizio. Con RUN, chiedi a Docker di eseguire i comandi shell all'interno containerizzato. Le modifiche apportate da questi comandi al container ora fanno parte l'immagine Docker. per assicurarti che l'immagine Docker sia stata creata disponibili prima di eseguire build.sh, devi modificare package.json 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 contenente tutti i file.)

In questo modo verrà creata la tua immagine Docker, ma solo se non è ancora stata creata. Poi tutto viene eseguito come prima, ma ora l'ambiente di build ha doxygen il comando gcloud, che farà sì che la documentazione di libvpx venga generata come beh.

Conclusione

Non sorprende che il codice C/C++ e npm non siano ideali, ma è possibile farla funzionare senza problemi con alcuni strumenti aggiuntivi e forniti da Docker. Questa configurazione non funziona per tutti i progetti, ma è comunque un punto di partenza utile da adattare alle tue esigenze. Se disponi miglioramenti, condividili.

Appendice: Utilizzo dei livelli di immagine Docker

Una soluzione alternativa è quella di incapsulare più di questi problemi con Docker l'approccio intelligente di Docker alla memorizzazione nella cache. Docker esegue i Dockerfile passo passo assegna al risultato di ogni passaggio una propria immagine. Queste immagini intermedie sono spesso chiamati "livelli". Se un comando in un Dockerfile non è cambiato, Docker non rieseguirà quel passaggio durante la creazione del Dockerfile. Invece riutilizza il livello dall'ultima creazione dell'immagine.

In precedenza, dovevi fare qualche sforzo per non ricreare ogni volta libvpx a creare la tua app. Puoi invece spostare le istruzioni per la creazione di libvpx da build.sh a Dockerfile per utilizzare la memorizzazione nella cache di Docker meccanismo di attenzione:

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 riepilogo contenente tutti i file.)

Tieni presente che dovrai installare manualmente git e clonare libvpx poiché non disponi eseguire il binding dei montaggi durante l'esecuzione di docker build. Di conseguenza, non c'è bisogno di napa.