Emscripten i npm

Jak zintegrować WebAssembly z tą konfiguracją? W tym artykule pokażemy, jak to zrobić na przykładzie języków C/C++ i Emscripten.

WebAssembly (wasm) jest często przedstawiany jako podstawowy element wydajności lub sposób na uruchomienie w internecie istniejącej bazy kodu C++. W przypadku squoosh.app chcieliśmy pokazać, że istnieje co najmniej trzecia perspektywa dotycząca WebAssembly: wykorzystanie ogromnych ekosystemów innych języków programowania. Dzięki Emscripten możesz używać kodu C/C++, Rust ma wbudowaną obsługę wasm, a zespół Go nad tym pracuje. Jestem pewien, że wkrótce dodamy wiele innych języków.

W takich przypadkach WASM nie jest głównym elementem aplikacji, ale raczej częścią układanki: kolejnym modułem. Aplikacja ma już JavaScript, CSS, zasoby obrazów, system kompilacji zorientowany na internet, a może nawet platformę taką jak React. Jak zintegrować WebAssembly z tą konfiguracją? W tym artykule pokażemy, jak to zrobić na przykładzie języków C/C++ i Emscripten.

Docker

Docker okazał się nieoceniony podczas pracy z Emscripten. Biblioteki C/C++ są często pisane tak, aby działały z systemem operacyjnym, w którym zostały utworzone. Spójne środowisko jest niezwykle przydatne. Dzięki Dockerowi uzyskasz zwirtualizowany system Linux, który jest już skonfigurowany do pracy z Emscripten i ma zainstalowane wszystkie narzędzia i zależności. Jeśli czegoś brakuje, możesz to zainstalować bez obaw o wpływ na Twój komputer lub inne projekty. Jeśli coś pójdzie nie tak, wyrzuć pojemnik i zacznij od nowa. Jeśli zadziała raz, możesz mieć pewność, że będzie działać dalej i dawać identyczne wyniki.

W Docker Registry znajduje się obraz Emscripten autorstwa trzeci, z którego często korzystam.

Integracja z npm

W większości przypadków punktem wejścia do projektu internetowego jest package.jsonnpm. Zgodnie z konwencją większość projektów można tworzyć za pomocą npm install && npm run build.

Ogólnie rzecz biorąc, artefakty kompilacji wygenerowane przez Emscripten (plik .js i plik .wasm) należy traktować jako kolejny moduł JavaScript i kolejny zasób. Plik JavaScript może być obsługiwany przez narzędzie do łączenia plików, takie jak webpack lub rollup, a plik wasm powinien być traktowany jak każdy inny większy zasób binarny, np. obrazy.

Dlatego artefakty kompilacji Emscripten muszą zostać utworzone przed rozpoczęciem „normalnego” procesu kompilacji:

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

Nowe zadanie build:emscripten może bezpośrednio wywoływać Emscripten, ale jak wspomnieliśmy wcześniej, zalecamy używanie Dockera, aby zapewnić spójność środowiska kompilacji.

docker run ... trzeci/emscripten ./build.sh instruuje Dockera, aby uruchomił nowy kontener za pomocą obrazu trzeci/emscripten i wykonał polecenie ./build.sh. build.sh to skrypt powłoki, który napiszesz w następnym kroku. --rm informuje Dockera, że po zakończeniu działania kontenera ma go usunąć. Dzięki temu z czasem nie utworzysz kolekcji nieaktualnych obrazów maszyn. -v $(pwd):/src oznacza, że chcesz, aby Docker „odzwierciedlił” bieżący katalog ($(pwd)) w /src wewnątrz kontenera. Wszelkie zmiany wprowadzone w plikach w katalogu /src w kontenerze zostaną odzwierciedlone w Twoim projekcie. Te zduplikowane katalogi nazywamy „montowaniem wiążącym”.

Przyjrzyjmy się 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 "============================================="

Jest tu wiele do omówienia.

set -e przełącza powłokę w tryb „fail fast”. Jeśli którekolwiek polecenie w skrypcie zwróci błąd, cały skrypt zostanie natychmiast przerwany. Może to być bardzo przydatne, ponieważ ostatnim wynikiem skryptu będzie zawsze komunikat o sukcesie lub błąd, który spowodował niepowodzenie kompilacji.

Za pomocą instrukcji export definiujesz wartości kilku zmiennych środowiskowych. Umożliwiają one przekazywanie dodatkowych parametrów wiersza poleceń do kompilatora C (CFLAGS), kompilatora C++ (CXXFLAGS) i linkera (LDFLAGS). Wszystkie otrzymują ustawienia optymalizatora za pomocą OPTIMIZE, aby zapewnić, że wszystko zostanie zoptymalizowane w ten sam sposób. Zmienna OPTIMIZE może mieć kilka wartości:

  • -O0: nie optymalizuj. Nie usuwa on martwego kodu ani nie minimalizuje emitowanego kodu JavaScript. Przydatne do debugowania.
  • -O3: agresywna optymalizacja pod kątem skuteczności.
  • -Os: agresywna optymalizacja pod kątem wydajności i rozmiaru jako kryterium dodatkowe.
  • -Oz: agresywna optymalizacja pod kątem rozmiaru, w razie potrzeby kosztem wydajności.

W przypadku internetu najczęściej polecam -Os.

Polecenie emcc ma wiele własnych opcji. Pamiętaj, że emcc ma być „zamiennikiem kompilatorów takich jak GCC czy clang”. Wszystkie flagi znane z GCC są najprawdopodobniej zaimplementowane również w emcc. Flaga -s jest specjalna, ponieważ umożliwia nam skonfigurowanie Emscripten. Wszystkie dostępne opcje znajdziesz w pliku settings.js Emscripten, ale może on być dość przytłaczający. Oto lista flag Emscripten, które moim zdaniem są najważniejsze dla programistów stron internetowych:

  • --bind umożliwia embind.
  • -s STRICT=1 wycofuje obsługę wszystkich wycofanych opcji kompilacji. Dzięki temu kod będzie zgodny z przyszłymi wersjami.
  • -s ALLOW_MEMORY_GROWTH=1 umożliwia automatyczne zwiększanie pamięci w razie potrzeby. W momencie pisania tego artykułu Emscripten przydziela początkowo 16 MB pamięci. Ta opcja określa, czy w przypadku wyczerpania pamięci operacje te spowodują awarię całego modułu WASM, czy też kod łączący będzie mógł zwiększyć całkowitą ilość pamięci, aby pomieścić przydzieloną pamięć.
  • -s MALLOC=... wybiera, której implementacji malloc() użyć. emmalloc to mała i szybka implementacja malloc() przeznaczona specjalnie dla Emscripten. Alternatywą jest dlmalloc, czyli w pełni funkcjonalna implementacja malloc(). Przełączanie na dlmalloc jest konieczne tylko wtedy, gdy często przydzielasz wiele małych obiektów lub chcesz używać wątków.
  • -s EXPORT_ES6=1 przekształci kod JavaScript w moduł ES6 z eksportem domyślnym, który działa z dowolnym narzędziem do łączenia modułów. Wymaga też ustawienia -s MODULARIZE=1.

Te flagi nie zawsze są konieczne lub są przydatne tylko do debugowania:

  • -s FILESYSTEM=0 to flaga związana z Emscripten i jego możliwością emulowania systemu plików, gdy kod C/C++ używa operacji na systemie plików. Analizuje on kompilowany kod, aby zdecydować, czy w kodzie łączącym ma się znaleźć emulacja systemu plików. Czasami jednak ta analiza może się mylić i płacisz za dodatkowy kod o rozmiarze 70 KB, który może nie być Ci potrzebny. Za pomocą -s FILESYSTEM=0 możesz wymusić, aby Emscripten nie uwzględniał tego kodu.
  • -g4 spowoduje, że Emscripten uwzględni informacje o debugowaniu w .wasm, a także wygeneruje plik mapowania źródła dla modułu wasm. Więcej informacji o debugowaniu za pomocą Emscripten znajdziesz w sekcji dotyczącej debugowania.

I to wszystko. Aby przetestować tę konfigurację, przygotujmy mały 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);
    }

oraz 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>

(Tutaj znajdziesz gist zawierający wszystkie pliki).

Aby wszystko skompilować, uruchom polecenie

$ npm install
$ npm run build
$ npm run serve

Po otwarciu adresu localhost:8080 w konsoli Narzędzi deweloperskich powinny się wyświetlić te dane wyjściowe:

Narzędzia deweloperskie pokazujące wiadomość wydrukowaną za pomocą C++ i Emscripten.

Dodawanie kodu C/C++ jako zależności

Jeśli chcesz utworzyć bibliotekę C/C++ dla aplikacji internetowej, jej kod musi być częścią projektu. Kod możesz dodać do repozytorium projektu ręcznie lub użyć npm do zarządzania tego rodzaju zależnościami. Załóżmy, że chcę użyć w aplikacji internetowej biblioteki libvpx. Jest to biblioteka C++ do kodowania obrazów za pomocą VP8, czyli kodeka używanego w plikach .webm. Biblioteka libvpx nie jest jednak dostępna w npm i nie ma package.json, więc nie mogę jej zainstalować bezpośrednio za pomocą npm.

Aby rozwiązać ten problem, możesz użyć narzędzia napa. Umożliwia ono instalowanie dowolnego adresu URL repozytorium Git jako zależności w folderze node_modules.

Zainstaluj napa jako zależność:

$ npm install --save napa

i upewnij się, że uruchamiasz napa jako skrypt instalacyjny:

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

Gdy uruchomisz polecenie npm install, napa sklonuje repozytorium libvpx GitHub do katalogu node_modules pod nazwą libvpx.

Możesz teraz rozszerzyć skrypt kompilacji, aby skompilować bibliotekę libvpx. Do kompilacji libvpx używane są polecenia configuremake. Na szczęście Emscripten może pomóc w zapewnieniu, że configuremake używają kompilatora Emscripten. W tym celu służą polecenia otaczające emconfigureemmake:

# ... 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 ...

Biblioteka C/C++ jest podzielona na 2 części: pliki nagłówkowe (tradycyjnie pliki .h lub .hpp), które definiują struktury danych, klasy, stałe itp. udostępniane przez bibliotekę, oraz samą bibliotekę (tradycyjnie pliki .so lub .a). Aby użyć stałej VPX_CODEC_ABI_VERSION biblioteki w kodzie, musisz dołączyć pliki nagłówkowe biblioteki za pomocą instrukcji #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;
}

Problem polega na tym, że kompilator nie wie, gdzie szukać vpxenc.h. Właśnie do tego służy flaga -I. Informuje kompilator, w których katalogach ma szukać plików nagłówkowych. Dodatkowo musisz podać kompilatorowi rzeczywisty plik biblioteki:

# ... 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 ...

Jeśli teraz uruchomisz npm run build, zobaczysz, że proces tworzy nowy plik .js i nowy plik .wasm, a strona demonstracyjna rzeczywiście wyświetla stałą:

Narzędzia deweloperskie
wyświetlające wersję ABI biblioteki libvpx wydrukowaną za pomocą emscripten.

Zauważysz też, że proces kompilacji trwa długo. Przyczyny długiego czasu kompilacji mogą być różne. W przypadku libvpx trwa to długo, ponieważ za każdym razem, gdy uruchamiasz polecenie kompilacji, kompiluje on koder i dekoder zarówno dla VP8, jak i VP9, nawet jeśli pliki źródłowe nie uległy zmianie. Nawet niewielka zmiana w my-module.cpp będzie wymagać dużo czasu. Zachowanie artefaktów kompilacji libvpx po ich pierwszym utworzeniu byłoby bardzo korzystne.

Możesz to zrobić za pomocą zmiennych środowiskowych.

# ... 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 ...

(Oto gist zawierający wszystkie pliki).

Polecenie eval umożliwia ustawianie zmiennych środowiskowych przez przekazywanie parametrów do skryptu kompilacji. Polecenie test pominie tworzenie biblioteki libvpx, jeśli zmienna $SKIP_LIBVPX jest ustawiona (na dowolną wartość).

Teraz możesz skompilować moduł, ale pominąć ponowne kompilowanie libvpx:

$ npm run build:emscripten -- SKIP_LIBVPX=1

Dostosowywanie środowiska kompilacji

Czasami biblioteki wymagają dodatkowych narzędzi do tworzenia. Jeśli w środowisku kompilacji udostępnianym przez obraz Dockera brakuje tych zależności, musisz je dodać samodzielnie. Załóżmy na przykład, że chcesz też utworzyć dokumentację biblioteki libvpx za pomocą narzędzia doxygen. Doxygen nie jest dostępny w kontenerze Dockera, ale możesz go zainstalować za pomocą polecenia apt.

Gdybyś to zrobił(a) w build.sh, za każdym razem, gdy chcesz utworzyć bibliotekę, musisz ponownie pobrać i zainstalować doxygen. Byłoby to nie tylko marnotrawstwo, ale też uniemożliwiłoby pracę nad projektem w trybie offline.

W tym przypadku warto skompilować własny obraz Dockera. Obrazy Dockera są tworzone przez napisanie Dockerfile, który opisuje etapy kompilacji. Pliki Dockerfile są dość zaawansowane i mają wiele poleceń, ale w większości przypadków wystarczy użyć poleceń FROM, RUNADD. W tym przypadku:

FROM trzeci/emscripten

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

Za pomocą FROM możesz zadeklarować, którego obrazu Dockera chcesz użyć jako punktu wyjścia. Jako podstawę wybrałem trzeci/emscripten – obraz, którego używasz od samego początku. Za pomocą znaku RUN instruujesz Dockera, aby uruchamiał polecenia powłoki w kontenerze. Wszystkie zmiany wprowadzone w kontenerze przez te polecenia są teraz częścią obrazu Dockera. Aby mieć pewność, że obraz Dockera został utworzony i jest dostępny przed uruchomieniem build.sh, musisz nieco dostosować 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",
    // ...
    },
    // ...
}

(Oto gist zawierający wszystkie pliki).

Spowoduje to skompilowanie obrazu Dockera, ale tylko wtedy, gdy nie został jeszcze skompilowany. Następnie wszystko działa jak wcześniej, ale teraz środowisko kompilacji ma dostępne polecenie doxygen, które spowoduje również utworzenie dokumentacji libvpx.

Podsumowanie

Nie jest zaskoczeniem, że kod C/C++ i npm nie pasują do siebie, ale możesz je wygodnie połączyć za pomocą dodatkowych narzędzi i izolacji, jaką zapewnia Docker. Ta konfiguracja nie sprawdzi się w każdym projekcie, ale jest dobrym punktem wyjścia, który możesz dostosować do swoich potrzeb. Jeśli masz jakieś ulepszenia, podziel się nimi.

Dodatek: korzystanie z warstw obrazu Dockera

Alternatywnym rozwiązaniem jest zamknięcie większej liczby tych problemów w kontenerach Docker i wykorzystanie inteligentnego podejścia Dockera do buforowania. Docker wykonuje pliki Dockerfile krok po kroku i przypisuje wynikowi każdego kroku własny obraz. Te obrazy pośrednie są często nazywane „warstwami”. Jeśli polecenie w pliku Dockerfile nie uległo zmianie, Docker nie uruchomi ponownie tego kroku podczas ponownego tworzenia pliku Dockerfile. Zamiast tego ponownie wykorzystuje warstwę z ostatniego tworzenia obrazu.

Wcześniej trzeba było się trochę natrudzić, aby nie przebudowywać biblioteki libvpx za każdym razem, gdy tworzysz aplikację. Zamiast tego możesz przenieść instrukcje tworzenia biblioteki libvpx z build.sh do Dockerfile, aby skorzystać z mechanizmu buforowania Dockera:

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

(Oto gist zawierający wszystkie pliki).

Pamiętaj, że musisz ręcznie zainstalować git i sklonować libvpx, ponieważ podczas uruchamiania docker build nie masz podłączonych woluminów. W związku z tym nie ma już potrzeby używania biblioteki napa.