Emscripten i npm

Jak zintegrować WebAssembly z tą konfiguracją? W tym artykule omówimy to na przykładach w językach C/C++ i Emscripten.

Komponent WebAssembly (wasm) często jest używany jako element podstawowy pod względem wydajności lub sposób uruchamiania istniejącej bazy kodu C++ w internecie. W przypadku squoosh.app chcemy pokazać, że istnieje co najmniej trzecia perspektywa, z której korzysta Wasm, czyli wykorzystanie ogromnych ekosystemów innych języków programowania. W Emscripten można użyć kodu C/C++, wbudowanej obsługi Rust Wasm, a zespół Go pracuje nad tym. Wiele innych języków na pewno pójdzie śladem.

W takich przypadkach Wasm nie jest centralnym elementem aplikacji, ale raczej łamigłówką, a jedynie modułem. Twoja aplikacja ma już JavaScript, CSS, zasoby graficzne, internetowy system kompilacji, a może nawet platformę taką jak React. Jak zintegrować WebAssembly z tą konfiguracją? W tym artykule omówimy to na przykładach C/C++ i Emscripten.

Docker

Uważam, że Docker to nieocenione narzędzie do pracy z Emscripten. Biblioteki C/C++ są często tworzone tak, aby działały z systemem operacyjnym. Spójne środowisko jest niezwykle przydatne. Dzięki Dockerowi otrzymujesz wirtualizowany system Linux, który jest już skonfigurowany do pracy z Emscripten i ma zainstalowane wszystkie narzędzia oraz zależności. Jeśli czegoś brakuje, możesz zainstalować bez obaw, że wpłynie to na Twój komputer lub inne projekty. Jeśli coś pójdzie nie tak, wyrzuć kontener i zacznij od początku. Jeśli zadziała to tylko raz, możesz mieć pewność, że będzie nadal działać i uzyskać identyczne wyniki.

W Docker Registry znajduje się często używany obraz Emscripten utworzony przez trzeci.

Integracja z npm

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

Artefakty kompilacji utworzone przez Emscripten (pliki .js i .wasm) powinny być traktowane jak kolejny moduł JavaScript i po prostu inny zasób. Plik JavaScript może być obsługiwany przez program do tworzenia pakietów, taki jak webpack lub Rollup. Plik Wasm powinien być traktowany jak każdy inny większy zasób binarny, np. obraz.

W związku z tym artefakty kompilacji Emscripten muszą zostać utworzone, zanim rozpocznie się Twój „normalny” proces 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łać Emscripten, ale jak wspomniano wcześniej, zalecamy korzystanie z Dockera, aby mieć pewność, że środowisko kompilacji jest spójne.

docker run ... trzeci/emscripten ./build.sh informuje Dockera, że ma uruchomić nowy kontener za pomocą obrazu trzeci/emscripten i uruchomić polecenie ./build.sh. build.sh to skrypt powłoki, który zamierzasz napisać w następnej kolejności. --rm nakazuje Dockerowi usunięcie kontenera po jego uruchomieniu. Dzięki temu unikniesz gromadzenia kolekcji nieaktualnych obrazów maszyn z upływem czasu. -v $(pwd):/src oznacza, że chcesz, aby Docker „powielał” bieżący katalog ($(pwd)) w kontenerze /src. Wszystkie zmiany, które wprowadzisz w plikach znajdujących się w katalogu /src w kontenerze, będą odzwierciedlane w rzeczywistym projekcie. Te powielane katalogi są nazywane „punktami podłączania”.

Przyjrzyjmy się bliżej 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 zbadania!

set -e włącza powłokę w tryb „fail fast”. Jeśli którekolwiek z poleceń w skrypcie zwróci błąd, cały skrypt zostanie natychmiast przerwany. Może to być niezwykle przydatne, ponieważ ostatnie dane wyjściowe skryptu będą zawsze zawierać komunikat o powodzeniu lub błąd, który spowodował błąd kompilacji.

Instrukcje export służą do określania wartości par zmiennych środowiskowych. Pozwalają one przekazywać dodatkowe parametry wiersza poleceń do kompilatora C (CFLAGS), kompilatora C++ (CXXFLAGS) i tagu łączącego (LDFLAGS). Wszystkie te parametry otrzymują ustawienia optymalizatora za pomocą OPTIMIZE, co daje pewność, że wszystko jest optymalizowane w ten sam sposób. Zmienna OPTIMIZE może mieć kilka wartości:

  • -O0: nie wprowadzaj żadnej optymalizacji. Żaden martwy kod nie jest usuwany, a emscripten nie zmniejsza również emitowanego kodu JavaScript. Sprawdza się w przypadku debugowania.
  • -O3: optymalizuj agresywnie pod kątem skuteczności.
  • -Os: optymalizuj agresywnie pod kątem wydajności i rozmiaru jako kryterium dodatkowego.
  • -Oz: optymalizuj skuteczność agresywnie pod kątem rozmiaru i w razie potrzeby obniż skuteczność.

Do korzystania z internetu polecam głównie -Os.

Polecenie emcc zawiera mnóstwo opcji. Pamiętaj, że polecenie emcc ma być „dodatek” zastępującym kompilatory takie jak GCC czy clang. Zatem wszystkie flagi GCC, które możesz znać, zostaną najprawdopodobniej wdrożone również za pomocą polecenia emcc. Flaga -s jest wyjątkowa, ponieważ pozwala nam skonfigurować usługę Emscripten. Wszystkie dostępne opcje znajdziesz w pliku settings.js aplikacji Emscripten, ale ten plik może być dość przytłaczający. Oto lista flag Emscripten, które moim zdaniem są najważniejsze dla programistów stron internetowych:

  • --bind włącza embind.
  • -s STRICT=1 wyłącza obsługę wszystkich wycofanych opcji kompilacji. Dzięki temu Twój kod będzie kompilowany w sposób zgodny z wyprzedzeniem.
  • -s ALLOW_MEMORY_GROWTH=1 umożliwia automatyczne zwiększanie pamięci w razie potrzeby. W momencie tworzenia tego tekstu Emscripten domyślnie przydzieli 16 MB pamięci. Gdy kod przydziela fragmenty pamięci, ta opcja określa, czy te operacje spowodują, że cały moduł Wasm ulegnie awarii w przypadku wyczerpania pamięci, czy też kod glue może zwiększyć łączną ilość pamięci, aby uwzględnić przydział.
  • -s MALLOC=... wybiera implementację malloc(), której ma użyć. emmalloc to mała i szybka implementacja malloc() przeznaczona tylko do Emscripten. Alternatywnym rozwiązaniem jest dlmalloc – w pełni funkcjonalna implementacja malloc(). Na tryb dlmalloc musisz przejść tylko wtedy, gdy często przydzielasz dużo małych obiektów lub chcesz używać podziału na wątki.
  • -s EXPORT_ES6=1 przekształci kod JavaScript w moduł ES6 z domyślnym eksportem, który działa z każdym modułem obsługującym pakiety. Wymaga też ustawienia -s MODULARIZE=1.

Te flagi nie zawsze są niezbędne lub przydają się tylko podczas debugowania:

  • -s FILESYSTEM=0 to flaga związana z Emscripten, która umożliwia emulowanie systemu plików, gdy kod C/C++ używa operacji w systemie plików. Analizuje skompilowany kod, aby zdecydować, czy uwzględnić w kodzie glue emulację systemu plików. Czasami jednak ta analiza może się mylić i opłacasz całkiem sporo 70 KB w dodatkowym kodzie klejowym na potrzeby emulacji systemu plików, którego być może nie potrzebujesz. Za pomocą -s FILESYSTEM=0 możesz wymusić pomijanie w Emscripten tego kodu.
  • -g4 doda do Emscripten informacje o debugowaniu w elemencie .wasm i wygeneruje plik map źródłowych dla modułu Wasm. Więcej informacji o debugowaniu z Emscripten znajdziesz w sekcji debugowania.

I to wszystko. Aby przetestować tę konfigurację, uruchom małe urządzenie 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);
    }

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

(Oto gist zawierająca wszystkie pliki).

Aby utworzyć wszystko, uruchom polecenie

$ npm install
$ npm run build
$ npm run serve

Przejście do „localhost:8080” powinno wyświetlić w konsoli DevTools następujące dane wyjściowe:

Narzędzia deweloperskie z komunikatem wydrukowanym w C++ i Emscripten.

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

Jeśli chcesz utworzyć bibliotekę C/C++ dla swojej aplikacji internetowej, musisz umieścić jej kod w projekcie. Możesz dodać kod do repozytorium projektu ręcznie lub użyć npm do zarządzania tego rodzaju zależnościami. Załóżmy, że chcę użyć libvpx w swojej aplikacji internetowej. libvpx to biblioteka w C++ do kodowania obrazów za pomocą VP8 – kodeka używanego w plikach .webm. Biblioteka libvpx nie znajduje się jednak w npm i nie ma zasobu package.json, więc nie mogę zainstalować go bezpośrednio za pomocą npm.

Aby uniknąć tego zagadki, jest to napa. Napa pozwala zainstalować dowolny adres URL repozytorium Git jako zależność do folderu node_modules.

Zainstaluj napa jako zależność:

$ npm install --save napa

i pamiętaj, aby uruchomić napa jako skrypt instalacyjny:

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

Gdy uruchomisz npm install, napa sklonuje repozytorium libvpx z GitHuba do node_modules pod nazwą libvpx.

Możesz teraz rozszerzyć skrypt kompilacji, aby kompilował libvpx. libvpx do tworzenia używa configure i make. Na szczęście Emscripten może pomóc sprawdzić, czy configure i make korzystają z kompilatora Emscripten. Służy do tego polecenie otoki emconfigure i 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 ...

Biblioteka C/C++ jest podzielona na 2 części: nagłówki (tradycyjnie pliki .h lub .hpp), które definiują struktury danych, klasy, stałe itp., które biblioteka udostępnia, oraz bibliotekę rzeczywistą (tradycyjnie pliki .so lub .a). Aby użyć w kodzie stałej VPX_CODEC_ABI_VERSION, musisz dodać pliki nagłówka 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ć pliku vpxenc.h. Do tego służy flaga -I. Informuje on kompilator, w których katalogach ma szukać plików nagłówka. Dodatkowo musisz też przekazać 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 skompiluje nowy plik .js i nowy plik .wasm, a strona demonstracyjna rzeczywiście wyświetli stałą:

Narzędzia deweloperskie z wersją ABI biblioteki libvpx wydrukowanej w emscripten.

Zauważysz też, że proces kompilacji trwa bardzo długo. Przyczyny długiego czasu kompilacji mogą być różne. W przypadku biblioteki libvpx kompilacja trwa bardzo długo, ponieważ kompiluje koder i dekoder dla VP8 i VP9 za każdym razem, gdy uruchomisz polecenie kompilacji, mimo że pliki źródłowe nie uległy zmianie. Nawet niewielka zmiana w elemencie my-module.cpp może zająć dużo czasu. Bardzo korzystne byłoby zachowanie artefaktów kompilacji libvpx po ich utworzeniu.

Jednym ze sposobów osiągnięcia tego celu są zmienne środowiskowe.

# ... 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 przekazanie parametrów do skryptu kompilacji. Polecenie test pomija kompilację libvpx, jeśli ustawiona jest wartość $SKIP_LIBVPX (na dowolną wartość).

Teraz możesz skompilować moduł, ale pomiń ponowne kompilowanie libvpx:

$ npm run build:emscripten -- SKIP_LIBVPX=1

Dostosowywanie środowiska kompilacji

Czasami do tworzenia bibliotek wymagane są dodatkowe narzędzia. 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 utworzyć dokumentację pliku libvpx za pomocą doxygen. Doxygen nie jest dostępny w kontenerze Dockera, ale możesz go zainstalować za pomocą apt.

Gdyby to zrobić w build.sh, za każdym razem, gdy chcesz utworzyć bibliotekę, trzeba było ponownie pobierać i instalować doxygen. Byłoby to nie tylko marnotrawstwo, ale też uniemożliwiłoby Ci pracę nad projektem w trybie offline.

W tym miejscu warto utworzyć własny obraz Dockera. Obrazy Dockera tworzy się, pisząc obiekt Dockerfile opisujący kroki kompilacji. Pliki Dockerfiles są dość wydajne i mają wiele poleceń, ale większość czasu można rozwiązać, używając tylko FROM, RUN i ADD. W takim przypadku:

FROM trzeci/emscripten

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

Dzięki FROM możesz zadeklarować, którego obrazu Dockera chcesz używać jako punktu wyjścia. Jako podstawę wybrałem trzeci/emscripten – obraz, którego używasz od zawsze. W RUN instruujesz Dockera, aby uruchamiał polecenia powłoki w kontenerze. Wszelkie zmiany wprowadzone przez te polecenia w kontenerze są teraz częścią obrazu Dockera. Aby mieć pewność, że obraz Dockera został skompilowany i dostępny przed uruchomieniem build.sh, dostosuj 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 utworzenie obrazu Dockera, ale tylko wtedy, gdy nie został jeszcze skompilowany. Następnie wszystko będzie działać tak jak wcześniej, ale teraz w środowisku kompilacji dostępne jest polecenie doxygen, dzięki któremu zostanie też utworzona dokumentacja libvpx.

Podsumowanie

Nie ma w tym nic dziwnego, że kod C/C++ i npm nie pasują do siebie, ale dzięki dodatkowym narzędziom i izolacji zapewnianej przez Dockera mogą one być wygodniejsze. Ta konfiguracja nie sprawdzi się w przypadku każdego projektu, ale stanowi dobry punkt wyjścia, który możesz dostosować do swoich potrzeb. Jeśli masz jakieś poprawki, daj nam znać.

Dodatek: korzystanie z warstw obrazów Dockera

Alternatywnym rozwiązaniem jest objęcie większej liczby takich problemów za pomocą inteligentnego podejścia do buforowania w środowisku Docker i Dockera. Docker krok po kroku uruchamia pliki Dockerfiles i przypisuje wynik każdego kroku własny obraz. Takie obrazy pośrednie są często nazywane „warstwami”. Jeśli polecenie w pliku Dockerfile nie uległo zmianie, Docker nie uruchomi go ponownie podczas ponownej kompilacji pliku Dockerfile. Zamiast tego wykorzystuje ponownie warstwę z momentu ostatniego tworzenia obrazu.

Wcześniej trzeba było podjąć pewne starania, aby nie odbudowywać libvpx za każdym razem, gdy tworzysz aplikację. Zamiast tego możesz przenieść instrukcje kompilacji biblioteki libvpx z build.sh do Dockerfile, aby skorzystać z mechanizmu Dockera buforowania:

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 clone libvpx, ponieważ podczas uruchamiania docker build nie masz powiązań montowania. Dzięki ubocznym efektom nie potrzeba już drzemki.