Emscripten i npm

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

Format WebAssembly (wasm) jest często wykorzystywany jako podstawowy komponent wydajności lub sposób uruchamiania istniejącej bazy kodu C++ w internecie. Używając witryny squoosh.app, chcieliśmy pokazać, że istnieje co najmniej trzecia perspektywa dla ismów: wykorzystanie ogromnych ekosystemów innych języków programowania. Dzięki Emscripten możesz używać kodu w C/C++ i Rust ma wbudowaną obsługę Wasm, a zespół Go pracuje również nad tym. 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: kolejnym modułem. Twoja aplikacja ma już JavaScript, CSS, zasoby graficzne, ukierunkowany na internet system kompilacji, a może nawet platformę, np. React. Jak zintegrować WebAssembly z tą konfiguracją? 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 Emscriptenem i ma zainstalowane wszystkie narzędzia i zależności. Jeśli czegoś brakuje, możesz to po prostu 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 raz się uda, możesz mieć pewność, że będzie nadal działać i przynosić identyczne wyniki.

W Docker Registry znajduje się obraz Emscripten autorstwa Trzeci, z którego intensywnie korzystam.

Integracja z npm

W większości przypadków punktem wejścia projektu internetowego jest npm package.json. Zgodnie z konwencją większość projektów można tworzyć 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 takich jak webpack lub plik o pełnym zakresie i plik Wasm powinien być traktowany jak każdy inny większy zasób binarny, np. obrazy.

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 prosi Dockera o usunięcie kontenera po jego uruchomieniu. Dzięki temu unikniesz tworzenia kolekcji nieaktualnych obrazów maszyn. -v $(pwd):/src oznacza, że chcesz, aby Docker „odbywał” bieżący katalog ($(pwd)) w /src w kontenerze. Wszystkie zmiany, które wprowadzisz w plikach w katalogu /src wewnątrz kontenera, zostaną odzwierciedlone w rzeczywistym projekcie. Te zduplikowane katalogi nazywamy „bind mounts”.

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 "============================================="

Trzeba tu bardzo dokładnie przeanalizować dane!

set -e przełącza powłokę w tryb „szybkości”. Jeśli jakieś polecenia w skrypcie zwrócą błąd, cały skrypt zostanie natychmiast przerwany. Może to być bardzo pomocne, ponieważ ostatnim wynikiem skryptu zawsze będzie komunikat o powodzeniu 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ń 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 ma 2 możliwe wartości:

  • -O0: nie przeprowadzaj optymalizacji. Żadne martwe kody nie są usuwane. Emscripten nie zmniejsza też generowanego przez siebie kodu JavaScript. Dobre do debugowania.
  • -O3: agresywna optymalizacja pod kątem skuteczności.
  • -Os: stosuj agresywną optymalizację pod kątem wydajności i rozmiaru jako kryterium dodatkowego.
  • -Oz: prowadź agresywną optymalizację pod kątem rozmiaru, tracąc skuteczność, jeśli to konieczne.

W internecie zwykle polecam usługę -Os.

Polecenie emcc udostępnia niezliczone opcje. Pamiętaj, że emcc ma być „gotowym zamiennikiem kompilacji takich jak GCC czy clang”. Wszystkie flagi znane z GCC będą prawdopodobnie stosowane również przez emcc. Flaga -s jest wyjątkowa, ponieważ umożliwia nam konfigurowanie rozszerzenia 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 kończy obsługę wszystkich wycofanych opcji kompilacji. Dzięki temu kompilacja kodu będzie kompatybilna z przyszłością.
  • Funkcja -s ALLOW_MEMORY_GROWTH=1 umożliwia automatyczne zwiększanie pamięci. W momencie pisania Emscripten początkowo przydzieli 16 MB pamięci. Ponieważ Twój kod przydziela fragmenty pamięci, ta opcja określa, czy te operacje spowodują niepowodzenie całego modułu Wasm po wyczerpaniu pamięci, czy też kod typu glue może zwiększyć łączną pamięć w celu zapewnienia zgodności z przydziałem.
  • -s MALLOC=... wybiera, której implementacji malloc() ma użyć. emmalloc to mała i szybka implementacja malloc() przeznaczona dla Emscripten. Alternatywnym rozwiązaniem jest dlmalloc, 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 JavaScript w moduł ES6 z domyślnym eksportem, który działa z każdym modułem tworzącym pakiety. Wymaga też ustawienia atrybutu -s MODULARIZE=1.

Te flagi nie zawsze są niezbędne lub są pomocne tylko w przypadku 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 trochę analizy skompilowanego kodu, aby zdecydować, czy uwzględnić w kodzie glue emulację systemu plików. Czasami jednak ta analiza może okazać się niepoprawna i musisz zapłacić dość duże 70 kB dodatkowego kodu typu glue na potrzeby emulacji systemu plików, której być może nie potrzebujesz. Jeśli używasz metody -s FILESYSTEM=0, możesz wymusić, by 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);
    }

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>

(Oto podsumowanie obejmujące wszystkie pliki).

Aby skompilować wszystko, uruchom polecenie

$ npm install
$ npm run build
$ npm run serve

Przejście do localhost:8080 powinno wyświetlić następujące dane wyjściowe w konsoli Narzędzi deweloperskich:

Narzędzia deweloperskie z komunikatem wydrukowanym w językach 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. 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ć 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. Biblioteki libvpx nie ma w npm i nie ma package.json, więc nie mogę zainstalować jej bezpośrednio przy użyciu 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 uruchom napa jako skrypt instalacyjny:

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

Gdy uruchomisz aplikację npm install, napa sklonuje repozytorium GitHub libvpx o nazwie libvpx do Twojego zasobu node_modules.

Teraz możesz rozszerzyć skrypt kompilacji, aby utworzyć libvpx. Do budowania biblioteki libvpx służą configure i make. Na szczęście Emscripten może pomóc w zapewnieniu, że configuremake używają kompilatora Emscripten. Do tego celu dostępne są polecenia kodu 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., oraz właściwą bibliotekę (tradycyjnie pliki .so lub .a). Aby użyć w kodzie stałej VPX_CODEC_ABI_VERSION biblioteki, musisz dołączyć 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ć elementu vpxenc.h. W tym celu 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 uruchomisz teraz polecenie npm run build, zobaczysz, że proces ten tworzy nowy plik .js i .wasm oraz że strona demonstracyjna rzeczywiście zwraca stałą:

DevTools pokazujący wersję ABI biblioteki libvpx wydrukowaną za pomocą emscripten.

Zauważysz również, że proces kompilacji trwa bardzo długo. Przyczyny długiego czasu kompilacji mogą być różne. W przypadku biblioteki libvpx libvpx wymaga dużo czasu, ponieważ przy każdym uruchomieniu polecenia kompilacji kompiluje on koder i dekoder dla VP8 i VP9, nawet jeśli pliki źródłowe nie uległy zmianie. 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 podsumowanie wszystkich plików).

Polecenie eval pozwala nam ustawiać zmienne środowiskowe przez przekazanie parametrów do skryptu kompilacji. Polecenie test pomija tworzenie parametru libvpx, jeśli ustawiona jest wartość $SKIP_LIBVPX (na dowolną wartość).

Teraz możesz skompilować moduł, ale pominąć odbudowywanie 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 na przykład, że chcesz dodatkowo utworzyć dokumentację biblioteki libvpx z wykorzystaniem 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ł ponownie pobierać i instalować doxygen za każdym razem, gdy będziesz chciał tworzyć bibliotekę. Nie tylko byłoby to niepotrzebne, ale też uniemożliwiłoby 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 podstawę wybrałem trzeci/emscripten – obraz, którego używasz przez cały czas. RUN instruujesz Dockera, aby uruchamiać polecenia powłoki wewnątrz kontenera. Wszystkie 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, musisz skorygować 1 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",
    // ...
    },
    // ...
}

(tutaj 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 teraz środowisko kompilacji ma dostępne polecenie doxygen, które powoduje kompilację dokumentacji libvpx.

Podsumowanie

Nie jest zaskoczeniem, że kod w C/C++ i npm nie pasują do siebie, ale możesz zapewnić sobie dość komfortową pracę dzięki dodatkowym narzędziom i odizolowaniu, jakie zapewnia Docker. Taka konfiguracja nie sprawdzi się w przypadku każdego projektu, ale stanowi dobry punkt wyjścia, którą możesz dostosować do swoich potrzeb. Jeśli chcesz, daj nam znać.

Dodatek: korzystanie z warstw obrazów Dockera

Alternatywnym rozwiązaniem jest uwzględnienie większej liczby tych problemów przy użyciu inteligentnego podejścia Dockera i 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 tworzenia pliku Dockerfile. Zamiast tego ponownie używa warstwy z ostatniego utworzenia obrazu.

Wcześniej trzeba było trochę postarać się, aby plik libvpx nie był ponownie kompilowany za każdym razem, gdy tworzysz aplikację. Zamiast tego możesz przenieść instrukcje kompilacji 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 podsumowanie wszystkich plików).

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.