Emscripten i npm

Jak zintegrować WebAssembly z tym rozwiązaniem? W tym artykule pokażemy, jak to zrobić w języku C/C++ i Emscripten.

WebAssembly (wasm) jest często przedstawiany jako element zapewniający wydajność lub sposób uruchamiania istniejącego kodu C++ w internecie. Dzięki aplikacji squoosh.app chcieliśmy pokazać, że istnieje co najmniej trzecia perspektywa dla wasm: korzystanie z ogromnych ekosystemów innych języków programowania. W Emscripten możesz używać kodu C/C++, Rust ma wbudowane wsparcie dla wasm, a zespół Go też nad tym pracuje. Jestem przekonany, że wkrótce udostępnimy więcej języków.

W takich przypadkach format wasm nie jest centralnym elementem aplikacji, ale raczej elementem układanki, czyli kolejnym modułem. Twoja aplikacja zawiera już JavaScript, CSS, zasoby graficzne, system kompilacji zorientowany na strony internetowe, a czasem nawet platformę, taką jak React. Jak zintegrować WebAssembly z tym rozwiązaniem? W tym artykule pokażemy, jak to zrobić w języku C/C++ i Emscripten.

Docker

Docker okazał się nieoceniony podczas pracy z Emscriptenem. Biblioteki C/C++ są często tworzone z myślą o systemie operacyjnym, na którym są budowane. Bardzo przydatne jest posiadanie spójnego środowiska. Docker to wirtualny system Linux, który jest już skonfigurowany do współpracy z Emscripten i ma zainstalowane wszystkie narzędzia i zależności. Jeśli czegoś brakuje, możesz to zainstalować bez obaw o to, jak to wpłynie na Twój komputer lub inne projekty. Jeśli coś pójdzie nie tak, wyrzuć kontener i zacznij od nowa. Jeśli działa raz, możesz mieć pewność, że będzie działać dalej i wydawać identyczne wyniki.

Rejestr Dockera zawiera obraz Emscripten, który został stworzony przez trzeci i jest przeze mnie często używany.

Integracja z npm

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

Ogólnie artefakty kompilacji utworzone przez Emscripten (plik .js.wasm) powinny być traktowane jako kolejny moduł JavaScript i kolejne zasoby. Plik JavaScript może być obsługiwany przez narzędzie do tworzenia pakietów, takie jak webpack lub rollup, a plik wasm powinien być traktowany jak każdy inny większy binarny zasób, np. obraz.

Dlatego artefakty kompilacji Emscripten muszą zostać skompilowane 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 wywoływać Emscripten bezpośrednio, ale jak już wspomniałem, zalecam użycie Dockera, aby mieć pewność, że środowisko kompilacji jest spójne.

docker run ... trzeci/emscripten ./build.sh mówi Dockerowi, aby uruchomił nowy kontener za pomocą obrazu trzeci/emscripten i uruchomił polecenie ./build.sh. build.sh to skrypt powłoki, który za chwilę napiszesz. --rm informuje Dockera, aby usunął kontener po zakończeniu jego działania. Dzięki temu z czasem nie będziesz mieć kolekcji nieaktualnych obrazów maszyn. -v $(pwd):/src oznacza, że chcesz, aby Docker „odbywał” bieżący katalog ($(pwd)) w /src w kontenerze. Wszelkie zmiany wprowadzone w plikach w katalogu /src w kontenerze zostaną odzwierciedlone w rzeczywistym projekcie. Te zduplikowane katalogi nazywamy „bind mounts”.

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

set -e powoduje przejście powłoki w tryb „szybkiego niepowodzenia”. Jeśli jakiekolwiek polecenia w skrypcie zwrócą błąd, cały skrypt zostanie natychmiast przerwany. Może to być bardzo przydatne, ponieważ ostatnim wyjściem skryptu będzie zawsze komunikat o udanym zakończeniu lub błąd, który spowodował niepowodzenie kompilacji.

W instrukcjach export definiujesz wartości kilku zmiennych środowiskowych. Umożliwiają one przekazywanie dodatkowych parametrów wiersza poleceń kompilatorowi C (CFLAGS), kompilatorowi C++ (CXXFLAGS) i linkerowi (LDFLAGS). Wszystkie otrzymują ustawienia optymalizatora za pomocą parametru OPTIMIZE, aby mieć pewność, że wszystko zostanie zoptymalizowane w taki sam sposób. Zmienna OPTIMIZE może mieć kilka wartości:

  • -O0: nie przeprowadzaj optymalizacji. Nie usuwa nieużywanego kodu i nie skraca kodu JavaScript, który generuje. Dobre do debugowania.
  • -O3: agresywna optymalizacja pod kątem wydajności.
  • -Os: agresywne optymalizowanie pod kątem wydajności, a jako kryterium drugorzędowe – rozmiaru.
  • -Oz: agresywna optymalizacja pod kątem rozmiaru, nawet kosztem wydajności.

W przypadku stron internetowych najczęściej polecam -Os.

Polecenie emcc ma mnóstwo własnych opcji. Pamiętaj, że emcc ma być „bezpośrednią alternatywą dla kompilatorów takich jak GCC czy clang”. Wszystkie flagi znane z GCC będą prawdopodobnie stosowane również przez emcc. Flaga -s jest wyjątkowa, ponieważ pozwala nam skonfigurować Emscripten. Wszystkie dostępne opcje znajdziesz w pliku settings.js w Emscripten, ale może on być 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 przestaje obsługiwać wszystkie przestarzałe opcje kompilacji. Dzięki temu kod będzie kompilowany w sposób zgodny z późniejszymi wersjami.
  • -s ALLOW_MEMORY_GROWTH=1 pozwala na automatyczne zwiększanie pamięci w razie potrzeby. W momencie pisania tego tekstu Emscripten przydziela początkowo 16 MB pamięci. Podczas przydzielania przez kod fragmentów pamięci ta opcja decyduje, czy te operacje spowodują błąd całego modułu wasm, gdy zabraknie pamięci, czy też kod łączący może zwiększyć łączną ilość pamięci, aby uwzględnić przydział.
  • -s MALLOC=... wybiera, której implementacji malloc() ma użyć. emmalloc to mała i szybka implementacja malloc() przeznaczona specjalnie do Emscripten. Alternatywą jest dlmalloc, czyli w pełni funkcjonalna implementacja malloc(). Na dlmalloc musisz przejść 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 JavaScriptu w moduł ES6 z domyślnym eksportem, który działa z dowolnym narzędziem do tworzenia pakietów. Wymaga też ustawienia parametru -s MODULARIZE=1.

Te flagi nie zawsze są potrzebne 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. Przeprowadza analizę kompilowanego kodu, aby zdecydować, czy uwzględnić emulację systemu plików w kodzie łączącym. Czasami jednak ta analiza może się mylić i zapłacisz 70 kB za dodatkowy kod łączący, który jest potrzebny do emulacji systemu plików, ale 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 debugowania w .wasm, a także wygeneruje plik map źródłowych dla modułu wasm. Więcej informacji o debugowaniu za pomocą Emscripten znajdziesz w sekcji poświęconej debugowaniu.

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

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ący wszystkie pliki).

Aby utworzyć wszystko, uruchom

$ npm install
$ npm run build
$ npm run serve

Po przejściu na adres localhost:8080 w konsoli DevTools powinien wyświetlić się ten komunikat:

Narzędzia deweloperskie wyświetlają komunikat wydrukowany za pomocą C++ i Emscripten.

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

Jeśli chcesz utworzyć bibliotekę C/C++ dla swojej aplikacji internetowej, musisz dodać jej kod do projektu. Kod możesz dodać do repozytorium projektu ręcznie lub też zarządzać takimi zależnościami za pomocą npm. Załóżmy, że chcę użyć w swojej aplikacji internetowej biblioteki libvpx. libvpx to biblioteka C++, która służy do kodowania obrazów za pomocą kodeka VP8 używanego w plikach .webm. Pakiet libvpx nie jest jednak dostępny w npm i nie ma package.json, więc nie mogę go zainstalować bezpośrednio za pomocą npm.

Aby rozwiązać ten problem, możesz użyć narzędzia napa. Narzędzie to umożliwia instalację 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 napa jest uruchamiany jako skrypt instalacyjny:

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

Gdy uruchomisz npm install, napa skopiuje repozytorium libvpx z GitHuba do repozytorium node_modules o nazwie libvpx.

Teraz możesz rozszerzyć skrypt kompilacji, aby kompilować libvpx. libvpx używa configure i make do kompilacji. Na szczęście Emscripten może pomóc w zapewnieniu, że configuremake używają kompilatora Emscripten. W tym celu możesz użyć poleceń 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: nagłówki (tradycyjnie pliki .h lub .hpp), które definiują struktury danych, klasy, stałe itp., oraz właściwą bibliotekę (tradycyjnie pliki .so lub .a). Aby używać w kodzie stałej VPX_CODEC_ABI_VERSION z biblioteki, musisz uwzględnić 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ć vpxenc.h. 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 oraz że strona demonstracyjna rzeczywiście wygeneruje stałą:

DevTools pokazujący 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 zajmuje to dużo czasu, ponieważ kompiluje ona koder i dekoder dla VP8 i VP9 za każdym razem, gdy wykonasz polecenie kompilacji, nawet jeśli pliki źródłowe się nie zmieniły. Nawet niewielka zmiana w my-module.cpp zajmie dużo czasu. Po pierwszym utworzeniu artefaktów kompilacji libvpx warto je zachować.

Jednym ze sposobów jest użycie 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 nam ustawianie zmiennych środowiskowych przez przekazywanie parametrów do skryptu kompilacji. Polecenie test pominie tworzenie libvpx, jeśli parametr $SKIP_LIBVPX jest ustawiony (na dowolną wartość).

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

$ npm run build:emscripten -- SKIP_LIBVPX=1

Dostosowywanie środowiska kompilacji

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

Jeśli zrobisz to w swoim build.sh, będziesz musiał pobierać i instalować ponownie sterowniki do każdego sterownika, który chcesz dodać do biblioteki. Nie tylko byłoby to nieefektywne, ale też uniemożliwiłoby Ci to pracę nad projektem w trybie offline.

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

FROM trzeci/emscripten

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

Za pomocą FROM możesz określić, którego obrazu Dockera chcesz użyć jako punktu wyjścia. Jako podstawy wybrałem obraz trzeci/emscripten, którego używasz od początku. Za pomocą RUN instruujesz Dockera, aby uruchomił polecenia powłoki w kontenerze. Wszystkie zmiany wprowadzone przez te polecenia w kontenerze są teraz częścią obrazu Dockera. Aby mieć pewność, że obraz Dockera został utworzony i jest dostępny przed uruchomieniem build.sh, musisz 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 utworzenie obrazu Dockera, ale tylko wtedy, gdy nie został on jeszcze utworzony. Następnie wszystko działa tak jak wcześniej, ale środowisko kompilacji ma teraz dostępne polecenie doxygen, które powoduje kompilację dokumentacji libvpx.

Podsumowanie

Nie dziwi, że kod C/C++ i npm nie pasują do siebie, ale możesz sprawić, że będą działać całkiem wygodnie dzięki dodatkowym narzędziom i izolacji oferowanej przez Dockera. 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ś propozycje ulepszeń, daj nam znać.

Załącznik: korzystanie z warstw obrazu Dockera

Alternatywnym rozwiązaniem jest opakowanie większej liczby tych problemów w Dockera i inteligentne podejście Dockera do buforowania. Docker wykonuje Dockerfiles krok po kroku i przypisuje wynik każdego kroku do własnego obrazu. Te pośrednie obrazy są często nazywane „warstwami”. Jeśli polecenie w pliku Dockerfile się nie zmieniło, Docker nie będzie ponownie wykonywać tego kroku podczas ponownego kompilowania pliku Dockerfile. Zamiast tego ponownie używa warstwy z ostatniego utworzenia obrazu.

Wcześniej trzeba było się trochę natrudzić, aby nie budować libvpx za każdym razem, gdy tworzysz aplikację. Teraz możesz przenieść instrukcje tworzenia libvpx z build.sh do Dockerfile, aby korzystać 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 klonować libvpx, ponieważ nie masz możliwości wiązania punktów dokowania podczas uruchamiania docker build. W efekcie ubocznym nie trzeba już używać funkcji anap.