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
i .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 implementacjimalloc()
ma użyć.emmalloc
to mała i szybka implementacjamalloc()
przeznaczona dla Emscripten. Alternatywnym rozwiązaniem jestdlmalloc
, w pełni funkcjonalna implementacjamalloc()
. Nadlmalloc
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:
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 configure
i make
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łą:
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
, RUN
i ADD
. 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.