Ustawienie biblioteki C w Wasm

Czasami chcesz użyć biblioteki, która jest dostępna tylko w postaci kodu C lub C++. Zwykle w tym momencie się poddajesz. Ale to już przeszłość, bo teraz mamy EmscriptenWebAssembly (czyli Wasm)!

Łańcuch narzędzi

Postawiłem sobie za cel opracowanie sposobu kompilowania istniejącego kodu C do Wasm. Wokół backendu LLVM dla Wasm było trochę szumu, więc zacząłem się tym zajmować. W ten sposób możesz skompilować proste programy, ale gdy tylko zechcesz użyć biblioteki standardowej języka C lub skompilować wiele plików, prawdopodobnie napotkasz problemy. Dzięki temu wyciągnąłem najważniejszy wniosek:

Emscripten był kiedyś kompilatorem z C do asm.js, ale od tego czasu rozwinął się i jest w trakcie przechodzenia na oficjalny backend LLVM. Emscripten udostępnia też implementację biblioteki standardowej języka C zgodną z Wasm. Użyj Emscripten. Wykonuje wiele ukrytych zadań, emuluje system plików, zarządza pamięcią i zawiera OpenGL w WebGL. To wiele rzeczy, których nie musisz samodzielnie tworzyć.

Może się wydawać, że musisz się martwić o nadmiar kodu – ja się martwiłem – ale kompilator Emscripten usuwa wszystko, co nie jest potrzebne. W moich eksperymentach powstałe moduły Wasm miały odpowiedni rozmiar w stosunku do zawartej w nich logiki. Zespoły Emscripten i WebAssembly pracują nad tym, aby w przyszłości były jeszcze mniejsze.

Emscripten możesz pobrać, postępując zgodnie z instrukcjami na stronie internetowej projektu lub korzystając z Homebrew. Jeśli tak jak ja lubisz polecenia w kontenerach Dockera i nie chcesz instalować niczego w systemie, aby wypróbować WebAssembly, możesz użyć dobrze utrzymywanego obrazu Dockera:

    $ docker pull trzeci/emscripten
    $ docker run --rm -v $(pwd):/src trzeci/emscripten emcc <emcc options here>

Kompilowanie prostego kodu

Weźmy niemal kanoniczny przykład napisania w C funkcji, która oblicza n-ty wyraz ciągu Fibonacciego:

    #include <emscripten.h>

    EMSCRIPTEN_KEEPALIVE
    int fib(int n) {
      if(n <= 0){
        return 0;
      }
      int i, t, a = 0, b = 1;
      for (i = 1; i < n; i++) {
        t = a + b;
        a = b;
        b = t;
      }
      return b;
    }

Jeśli znasz język C, sama funkcja nie powinna Cię zaskoczyć. Nawet jeśli nie znasz języka C, ale znasz JavaScript, mam nadzieję, że zrozumiesz, co się tu dzieje.

emscripten.h to plik nagłówkowy dostarczony przez Emscripten. Potrzebujemy go tylko po to, aby mieć dostęp do makra EMSCRIPTEN_KEEPALIVE, ale zapewnia on znacznie więcej funkcji. Ten makroinstrukcja informuje kompilator, aby nie usuwał funkcji, nawet jeśli wydaje się nieużywana. Gdybyśmy pominęli to makro, kompilator zoptymalizowałby funkcję, ponieważ nikt jej nie używa.

Zapiszmy to wszystko w pliku o nazwie fib.c. Aby przekształcić go w plik .wasm, musimy użyć polecenia kompilatora Emscripten emcc:

    $ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' fib.c

Przeanalizujmy to polecenie. emcc to kompilator Emscripten. fib.c to nasz plik C. Idzie Ci doskonale. -s WASM=1 informuje Emscripten, że zamiast pliku asm.js chcemy otrzymać plik Wasm. -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' informuje kompilator, aby pozostawił funkcję cwrap() dostępną w pliku JavaScript – więcej informacji o tej funkcji znajdziesz w dalszej części artykułu. -O3 informuje kompilator, że ma przeprowadzić agresywną optymalizację. Możesz wybrać mniejsze liczby, aby skrócić czas kompilacji, ale spowoduje to też powiększenie wynikowych pakietów, ponieważ kompilator może nie usunąć nieużywanego kodu.

Po uruchomieniu polecenia powinien pojawić się plik JavaScript o nazwie a.out.js i plik WebAssembly o nazwie a.out.wasm. Plik Wasm (lub „moduł”) zawiera skompilowany kod C i powinien być dość mały. Plik JavaScript odpowiada za wczytywanie i inicjowanie modułu Wasm oraz udostępnianie wygodniejszego interfejsu API. W razie potrzeby zajmie się też konfiguracją stosu, sterty i innych funkcji, które zwykle są udostępniane przez system operacyjny podczas pisania kodu w języku C. W związku z tym plik JavaScript jest nieco większy i waży 19 KB (po skompresowaniu gzipem około 5 KB).

Uruchamianie prostych zadań

Najprostszym sposobem wczytania i uruchomienia modułu jest użycie wygenerowanego pliku JavaScript. Po wczytaniu tego pliku będziesz mieć do dyspozycji Moduleglobal. Użyj cwrap do utworzenia natywnej funkcji JavaScriptu, która zajmuje się konwertowaniem parametrów na format odpowiedni dla języka C i wywoływaniem opakowanej funkcji. Funkcja cwrap przyjmuje w tej kolejności te argumenty: nazwę funkcji, typ zwracany i typy argumentów:

    <script src="a.out.js"></script>
    <script>
      Module.onRuntimeInitialized = _ => {
        const fib = Module.cwrap('fib', 'number', ['number']);
        console.log(fib(12));
      };
    </script>

Jeśli uruchomisz ten kod, w konsoli powinna się pojawić liczba „144”, czyli 12 liczba ciągu Fibonacciego.

Święty Graal: kompilowanie biblioteki C

Do tej pory pisaliśmy kod C z myślą o Wasm. Jednak podstawowym zastosowaniem WebAssembly jest wykorzystanie istniejącego ekosystemu bibliotek C i umożliwienie deweloperom korzystania z nich w internecie. Biblioteki te często korzystają z biblioteki standardowej języka C, systemu operacyjnego, systemu plików i innych elementów. Emscripten udostępnia większość tych funkcji, ale istnieją pewne ograniczenia.

Wróćmy do mojego pierwotnego celu: skompilowania enkodera WebP do Wasm. Źródło kodeka WebP jest napisane w języku C i dostępne na GitHub. Dostępna jest też obszerna dokumentacja interfejsu API. To całkiem dobry punkt wyjścia.

    $ git clone https://github.com/webmproject/libwebp

Zacznijmy od prostego przykładu. Spróbujmy udostępnić WebPGetEncoderVersion() z encode.h JavaScriptowi, pisząc plik C o nazwie webp.c:

    #include "emscripten.h"
    #include "src/webp/encode.h"

    EMSCRIPTEN_KEEPALIVE
    int version() {
      return WebPGetEncoderVersion();
    }

Jest to prosty program, który pozwala sprawdzić, czy można skompilować kod źródłowy biblioteki libwebp, ponieważ do wywołania tej funkcji nie są potrzebne żadne parametry ani złożone struktury danych.

Aby skompilować ten program, musimy poinformować kompilator, gdzie może znaleźć pliki nagłówkowe biblioteki libwebp, używając flagi -I, a także przekazać mu wszystkie pliki C biblioteki libwebp, których potrzebuje. Przyznam szczerze: po prostu przekazałem mu wszystkie pliki C, jakie udało mi się znaleźć, i liczyłem na to, że kompilator usunie wszystko, co było niepotrzebne. Wydawało się, że działa świetnie.

    $ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \
        -I libwebp \
        webp.c \
        libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c

Teraz potrzebujemy tylko kodu HTML i JavaScript, aby załadować nasz nowy moduł:

<script src="/a.out.js"></script>
<script>
  Module.onRuntimeInitialized = async (_) => {
    const api = {
      version: Module.cwrap('version', 'number', []),
    };
    console.log(api.version());
  };
</script>

Numer wersji poprawki będzie widoczny w danych wyjściowych:

Zrzut ekranu konsoli Narzędzi dla programistów z prawidłowym numerem wersji.

Przesyłanie obrazu z JavaScriptu do Wasm

Uzyskanie numeru wersji kodera jest wspaniałe, ale zakodowanie rzeczywistego obrazu byłoby bardziej imponujące, prawda? W takim razie zróbmy to.

Pierwsze pytanie, na które musimy odpowiedzieć, brzmi: jak przenieść obraz do Wasm? Zgodnie z interfejsem API kodowania biblioteki libwebp oczekuje ona tablicy bajtów w formacie RGB, RGBA, BGR lub BGRA. Na szczęście interfejs Canvas API ma getImageData(), który daje nam Uint8ClampedArray zawierający dane obrazu w RGBA:

async function loadImage(src) {
  // Load image
  const imgBlob = await fetch(src).then((resp) => resp.blob());
  const img = await createImageBitmap(imgBlob);
  // Make canvas same size as image
  const canvas = document.createElement('canvas');
  canvas.width = img.width;
  canvas.height = img.height;
  // Draw image onto canvas
  const ctx = canvas.getContext('2d');
  ctx.drawImage(img, 0, 0);
  return ctx.getImageData(0, 0, img.width, img.height);
}

Teraz „tylko” trzeba skopiować dane z JavaScriptu do Wasm. W tym celu musimy udostępnić 2 dodatkowe funkcje. Pierwsza przydziela pamięć na obraz w środowisku Wasm, a druga ją zwalnia:

    EMSCRIPTEN_KEEPALIVE
    uint8_t* create_buffer(int width, int height) {
      return malloc(width * height * 4 * sizeof(uint8_t));
    }

    EMSCRIPTEN_KEEPALIVE
    void destroy_buffer(uint8_t* p) {
      free(p);
    }

create_buffer przydziela bufor dla obrazu RGBA, a więc 4 bajty na piksel. Wskaźnik zwrócony przez funkcję malloc() to adres pierwszej komórki pamięci tego bufora. Gdy wskaźnik jest zwracany do kodu JavaScript, jest traktowany jako zwykła liczba. Po udostępnieniu funkcji w JavaScript za pomocą cwrap możemy użyć tej liczby, aby znaleźć początek bufora i skopiować dane obrazu.

const api = {
  version: Module.cwrap('version', 'number', []),
  create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
  destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
};
const image = await loadImage('/image.jpg');
const p = api.create_buffer(image.width, image.height);
Module.HEAP8.set(image.data, p);
// ... call encoder ...
api.destroy_buffer(p);

Wielki finał: kodowanie obrazu

Obraz jest teraz dostępny w środowisku Wasm. Czas wywołać koder WebP, aby wykonał swoją pracę. Z dokumentacji WebP wynika, że WebPEncodeRGBA to idealne rozwiązanie. Funkcja przyjmuje wskaźnik do obrazu wejściowego i jego wymiary, a także opcję jakości z zakresu od 0 do 100. Przydziela też bufor wyjściowy, który musimy zwolnić za pomocą funkcji WebPFree(), gdy skończymy korzystać z obrazu WebP.

Wynikiem operacji kodowania jest bufor wyjściowy i jego długość. Ponieważ funkcje w C nie mogą mieć tablic jako typów zwracanych (chyba że dynamicznie przydzielimy pamięć), zdecydowałem się na statyczną tablicę globalną. Wiem, że to nie jest czysty C (w rzeczywistości opiera się na tym, że wskaźniki Wasm mają 32 bity), ale dla uproszczenia uważam, że to odpowiednie uproszczenie.

    int result[2];
    EMSCRIPTEN_KEEPALIVE
    void encode(uint8_t* img_in, int width, int height, float quality) {
      uint8_t* img_out;
      size_t size;

      size = WebPEncodeRGBA(img_in, width, height, width * 4, quality, &img_out);

      result[0] = (int)img_out;
      result[1] = size;
    }

    EMSCRIPTEN_KEEPALIVE
    void free_result(uint8_t* result) {
      WebPFree(result);
    }

    EMSCRIPTEN_KEEPALIVE
    int get_result_pointer() {
      return result[0];
    }

    EMSCRIPTEN_KEEPALIVE
    int get_result_size() {
      return result[1];
    }

Teraz, gdy wszystko jest gotowe, możemy wywołać funkcję kodowania, pobrać wskaźnik i rozmiar obrazu, umieścić go we własnym buforze w JavaScript i zwolnić wszystkie bufory w Wasm, które zostały przydzielone w tym procesie.

    api.encode(p, image.width, image.height, 100);
    const resultPointer = api.get_result_pointer();
    const resultSize = api.get_result_size();
    const resultView = new Uint8Array(Module.HEAP8.buffer, resultPointer, resultSize);
    const result = new Uint8Array(resultView);
    api.free_result(resultPointer);

W zależności od rozmiaru obrazu może wystąpić błąd, w którym Wasm nie może zwiększyć pamięci na tyle, aby pomieścić zarówno obraz wejściowy, jak i wyjściowy:

Zrzut ekranu konsoli Narzędzi deweloperskich z błędem.

Na szczęście rozwiązanie tego problemu znajduje się w komunikacie o błędzie. Wystarczy, że dodamy -s ALLOW_MEMORY_GROWTH=1 do polecenia kompilacji.

I to wszystko! Skompilowaliśmy koder WebP i przekodowaliśmy obraz JPEG do formatu WebP. Aby udowodnić, że to działa, możemy przekształcić bufor wyników w obiekt blob i użyć go w elemencie <img>:

const blob = new Blob([result], { type: 'image/webp' });
const blobURL = URL.createObjectURL(blob);
const img = document.createElement('img');
img.src = blobURL;
document.body.appendChild(img);

Oto nowy obraz w formacie WebP.

Panel sieci w Narzędziach deweloperskich i wygenerowany obraz.

Podsumowanie

Uruchomienie biblioteki C w przeglądarce nie jest łatwe, ale gdy zrozumiesz cały proces i przepływ danych, stanie się to prostsze, a wyniki mogą być zdumiewające.

WebAssembly otwiera wiele nowych możliwości w internecie w zakresie przetwarzania, obliczeń i gier. Pamiętaj, że Wasm nie jest uniwersalnym rozwiązaniem, które należy stosować do wszystkiego, ale gdy napotkasz jeden z tych wąskich gardeł, Wasm może być niezwykle przydatnym narzędziem.

Treści dodatkowe: proste zadanie wykonane w skomplikowany sposób

Jeśli chcesz uniknąć wygenerowania pliku JavaScript, możesz to zrobić. Wróćmy do przykładu z ciągiem Fibonacciego. Aby załadować i uruchomić go samodzielnie, możemy wykonać te czynności:

<!DOCTYPE html>
<script>
  (async function () {
    const imports = {
      env: {
        memory: new WebAssembly.Memory({ initial: 1 }),
        STACKTOP: 0,
      },
    };
    const { instance } = await WebAssembly.instantiateStreaming(
      fetch('/a.out.wasm'),
      imports,
    );
    console.log(instance.exports._fib(12));
  })();
</script>

Moduły WebAssembly utworzone przez Emscripten nie mają pamięci, z której mogłyby korzystać, chyba że im ją udostępnisz. Sposób, w jaki przekazujesz moduł Wasm cokolwiek, polega na użyciu obiektu imports – drugiego parametru funkcji instantiateStreaming. Moduł Wasm ma dostęp do wszystkich elementów w obiekcie importów, ale nie do niczego innego poza nim. Zgodnie z konwencją moduły skompilowane przez Emscripten oczekują od środowiska JavaScript wczytującego kilka rzeczy:

  • Po pierwsze, jest to env.memory. Moduł Wasm nie ma dostępu do świata zewnętrznego, więc potrzebuje trochę pamięci do działania. Wpisz WebAssembly.Memory. Reprezentuje on (opcjonalnie rozszerzalny) fragment pamięci liniowej. Parametry rozmiaru są podane „w jednostkach stron WebAssembly”, co oznacza, że powyższy kod przydziela 1 stronę pamięci, a każda strona ma rozmiar 64 KiB. Bez podania maximumopcji pamięć może teoretycznie rosnąć bez ograniczeń (Chrome ma obecnie sztywne ograniczenie do 2 GB). Większość modułów WebAssembly nie powinna ustawiać wartości maksymalnej.
  • env.STACKTOP określa, od którego miejsca ma się zacząć powiększanie stosu. Stos jest potrzebny do wywoływania funkcji i przydzielania pamięci zmiennym lokalnym. W naszym małym programie do obliczania ciągu Fibonacciego nie stosujemy żadnych sztuczek związanych z dynamicznym zarządzaniem pamięcią, więc możemy użyć całej pamięci jako stosu, stąd STACKTOP = 0.