Ustawienie biblioteki C w Wasm

Czasami chcesz użyć biblioteki, która jest dostępna tylko jako kod C lub C++. Tradycyjnie w tym momencie poddajesz się. Teraz już nie, bo mamy Emscripten i WebAssembly (czyli Wasm).

Łańcuch narzędzi

Zastanawiam się nad skompilowaniem istniejącego kodu C w Wasm. Doszło się trochę szumu wokół backendu Wasm LLVM, więc zacząłem się tym zająć. Choć w ten sposób można skompilować proste programy, albo skorzystać z biblioteki standardowej języka C, a nawet skompilować wiele plików, prawdopodobnie napotkasz problemy. Dzięki temu dotarła do mnie ważna lekcja:

Emscripten używał kompilatora C-to-asm.js, ale obecnie jest używany do kierowania na system Wasm i jest w trakcie wewnętrznego przełączania się na oficjalny backend LLVM. Emscripten zapewnia również zgodną z Wasm implementację biblioteki standardowej C. Użyj Emscripten. Oferuje wiele ukrytych zadań, emuluje system plików, umożliwia zarządzanie pamięcią, łączy OpenGL z WebGL – wiele rzeczy, których tak naprawdę nie musisz przeprowadzać.

Choć może się to wydawać niepokojące, kompilator Emscripten usuwa wszystkie niepotrzebne elementy. W moich eksperymentach wynikowe moduły Wasm mają odpowiednią wielkość dla logiki, które się w nich znajdują, a zespoły Emscripten i WebAssembly pracują nad ich jeszcze zmniejszeniem.

Możesz ją pobrać, postępując zgodnie z instrukcjami na jej stronie lub korzystając z narzędzia Homebrew. Jeśli podoba Ci się polecenia Dockera, takie jak ja i nie chcesz instalować innych rzeczy w systemie tylko po to, żeby wypróbować WebAssembly, możesz użyć dobrze obsługiwanego obrazu Dockera, którego możesz użyć:

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

Kompilowanie czegoś prostego

Przyjrzyjmy się prawie kanonicznemu przykładowi funkcji w C, która oblicza n-tą liczbę fibonacci:

    #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 C, sama funkcja nie powinna być zbyt zaskakująca. Nawet jeśli nie znasz się na języku C, a znasz język JavaScript, mamy nadzieję, że będziesz w stanie zrozumieć, o co chodzi.

emscripten.h to plik nagłówka udostępniany przez Emscripten. Potrzebujemy go tylko, aby mieć dostęp do makra EMSCRIPTEN_KEEPALIVE, ale zapewnia ono znacznie więcej funkcji. To makro informuje kompilator, aby nie usuwał funkcji, nawet jeśli wydaje się, że jest nieużywana. W przypadku pominięcia tego makra kompilator zoptymalizowałby tę funkcję – nikt przecież jej nie używa.

Zapiszmy wszystko w pliku 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 jest kompilatorem Emscripten. fib.c to nasz plik C. Idzie Ci doskonale. -s WASM=1 informuje Emscripten, że ma wyświetlić plik Wasm zamiast pliku asm.js. -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' informuje kompilator, aby pozostawiał funkcję cwrap() dostępną w pliku JavaScript. Więcej o tej funkcji dowiesz się później. -O3 informuje kompilator, aby przeprowadził agresywną optymalizację. Możesz wybrać niższe liczby, aby skrócić czas kompilacji, ale spowoduje to też zwiększenie rozmiaru 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 obsługuje wczytywanie i inicjowanie modułu Wasm oraz zapewnia lepszy interfejs API. W razie potrzeby zajmie się też konfiguracją stosu, stosu i innych funkcji, które zwykle zapewnia system operacyjny podczas pisania kodu C. W związku z tym plik JavaScript jest nieco większy i waży 19 KB (ok. 5 KB gzip).

Prowadzenie prostych działań

Najprostszym sposobem na wczytanie i uruchomienie modułu jest użycie wygenerowanego pliku JavaScript. Po wczytaniu tego pliku będziesz mieć do dyspozycji globalny Module. Użyj cwrap, aby utworzyć natywną funkcję JavaScriptu, która zajmuje się konwertowaniem parametrów na kod zgodny z C i wywołaniem funkcji opakowanej. cwrap przyjmuje nazwę funkcji, zwracany typ i typy argumentów jako argumenty w tej kolejności:

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

Po uruchomieniu tego kodu w konsoli powinien pojawić się „144”, czyli dwunasty liczba Fibonacci.

Święty Graal: kompilacja biblioteki C

Do tej pory napisany przez nas kod C powstał z myślą o Wasm. Podstawowym przypadkiem użycia WebAssembly jest jednak wykorzystanie obecnego ekosystemu bibliotek bibliotecznych i umożliwienie deweloperom używania ich w internecie. Biblioteki te często korzystają ze standardowej biblioteki C, systemu operacyjnego, systemu plików i innych elementów. Emscripten zapewnia większość tych funkcji, ale są pewne ograniczenia.

Wróćmy do mojego pierwotnego celu: skompilowania do Wasm kodera dla WebP. Źródło kodeka WebP jest napisane w języku C i jest dostępne na GitHub. Znajdziesz w nim też obszerną dokumentację interfejsu API. To całkiem dobry punkt wyjścia.

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

Na początek utwórzmy plik C o nazwie webp.c, aby udostępnić WebPGetEncoderVersion() z elementu encode.h dla JavaScriptu:

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

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

Jest to dobry, prosty program do sprawdzenia, czy możemy skompilować kod źródłowy libwebp. Wywoływanie tej funkcji nie wymaga użycia żadnych parametrów ani złożonych struktur danych.

Aby skompilować ten program, musimy poinformować kompilator, gdzie może znaleźć pliki nagłówka libwebp, używając flagi -I, oraz przekazać mu wszystkie potrzebne pliki C biblioteki libwebp. Będę szczery: po prostu dałem mu wszystkie pliki C, które udało mi się znaleźć, i skorzystałem z kompilatora, który usunie niepotrzebne pliki. Wyglądało na to, że działa rewelacyjnie.

    $ 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 do wczytania nowego modułu wystarczy trochę kodu HTML i JavaScript:

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

A w danych wyjściowych pojawi się numer odpowiedniej wersji:

Zrzut ekranu konsoli Narzędzi deweloperskich z prawidłowym numerem wersji.

Pobieranie obrazu z JavaScriptu do Wasm

Numer wersji kodera to świetna sprawa, ale kodowanie rzeczywistego obrazu byłoby bardziej imponujące, prawda? W takim razie zróbmy to.

Pierwsze pytanie, na które musimy odpowiedzieć, brzmi: jak przenieść to zdjęcie na ziemię Wasm? Jeśli chodzi o interfejs API do kodowania libwebp, oczekuje on tablicy bajtów w zakresie RGB, RGBA, BGR lub BGRA. Na szczęście interfejs Canvas API zawiera właściwość getImageData(), która daje nam obiekt Uint8ClampedArray z danymi obrazu w formacie 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 będzie skopiować dane z języka JavaScript do Wasm. W tym celu musimy udostępnić 2 dodatkowe funkcje. Jeden, który przydziela pamięć obrazu w domu Wasm i pozwala go odzyskać:

    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 do obrazu RGBA, czyli 4 bajty na piksel. Wskaźnik zwracany przez malloc() to adres pierwszej komórki pamięci tego bufora. Po zwróceniu wskaźnika do strony JavaScriptu jest on traktowany jako liczba. Po udostępnieniu funkcji dla JavaScriptu 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ł: zakoduj obraz

Zdjęcie jest teraz dostępne na obszarze Wasm. Czas wywołać koder WebP i wykonać jego zadanie! Jeśli chodzi o dokumentację WebP, wygląda na to, że WebPEncodeRGBA najlepiej sprawdzi się w tej usłudze. Funkcja uwzględnia wskaźnik do obrazu wejściowego i jego wymiarów, a także do opcji jakości z zakresu od 0 do 100. przydzieli nam również bufor danych wyjściowych, który musimy zwolnić za pomocą funkcji WebPFree() po zakończeniu pracy z obrazem WebP.

W wyniku operacji kodowania powstaje bufor wyjściowy i jego długość. Funkcje w C nie mogą mieć tablic jako zwracanego typu tablicy (chyba że przydzielamy pamięć dynamicznie, dlatego użyłem statycznej tablicy globalnej). Wiem, że nie ma czystego C (w rzeczywistości wskaźniki Wasm mają 32-bitową szerokość), ale dla uproszczenia myślę, że jest to spory skrót.

    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 możemy wywołać funkcję kodowania, pobrać wskaźnik i rozmiar obrazu, umieścić go w własnym buforze języka JavaScript i zwolnić wszystkie bufory Wasm przydzielone w ramach tego procesu.

    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, który powoduje, że Wasm nie może powiększyć pamięci dostatecznie dużo miejsca na obraz zarówno wejściowy, jak i wyjściowy:

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

Na szczęście rozwiązaniem tego problemu jest komunikat o błędzie. Musimy tylko dodać -s ALLOW_MEMORY_GROWTH=1 do polecenia kompilacji.

I to wszystko! Skompilowaliśmy koder WebP i transkodowaliśmy obraz JPEG do formatu WebP. Aby to sprawdzić, 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 piękno nowego obrazu WebP!

Panel sieci Narzędzi deweloperskich i wygenerowany obraz.

Podsumowanie

Korzystanie z biblioteki C w przeglądarce nie jest niczym zwykłym chodnikiem, ale gdy zrozumiesz cały proces i sposób działania przepływu danych, staje się to znacznie łatwiejsze, a wyniki mogą być oszałamiające.

WebAssembly otwiera wiele nowych możliwości w internecie w zakresie przetwarzania, analizowania liczb i grania. Pamiętaj, że Wasm nie jest idealnym narzędziem, które należy stosować we wszystkim, ale gdy napotkacie takie wąskie gardła, Wasm może okazać się niezwykle przydatnym narzędziem.

Dodatkowa treść: proste zabiegi w trudny sposób

Jeśli chcesz uniknąć wygenerowanego pliku JavaScript, możesz to zrobić. Wróćmy do przykładu Fibonacci. Aby samodzielnie je załadować i uruchomić, 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 do pracy, chyba że ją udostępnisz. Aby udostępnić moduł Wasm cokolwiek, użyj obiektu imports – drugiego parametru funkcji instantiateStreaming. Moduł Wasm ma dostęp do wszystkiego wewnątrz obiektu importu, ale do niczego poza nim. Zgodnie z konwencją moduły skompilowane przez Emscripting oczekują od środowiska wczytywania JavaScriptu kilku rzeczy:

  • Pierwsza z nich to env.memory. Moduł Wasm nie wie o świecie zewnętrznym, dlatego potrzebuje trochę pamięci do pracy. Wpisz WebAssembly.Memory. Stanowi on (opcjonalnie rozwijany) fragment pamięci liniowej. Parametry rozmiaru są podane w „jednostkach stron WebAssembly”, co oznacza, że powyższy kod przydziela 1 stronę pamięci, z których każda ma rozmiar 64 KiB. Bez opcji maximum ilość pamięci teoretycznie rośnie (obecnie Chrome ma stały limit 2 GB). Większość modułów WebAssembly nie wymaga ustawiania maksymalnej wartości.
  • env.STACKTOP określa miejsce, w którym stos ma zacząć się rozwijać. Stos jest potrzebny do wykonywania wywołań funkcji i przydzielania pamięci na potrzeby zmiennych lokalnych. W ramach programu Fibonacci nie stosujemy żadnych dynamicznych elementów związanych z zarządzaniem pamięcią, dlatego możemy wykorzystać całą pamięć jako zbiór. STACKTOP = 0