Ustawienie biblioteki C w Wasm

Czasami chcesz użyć biblioteki dostępnej tylko w postaci kodu C lub C++. Zazwyczaj właśnie wtedy się poddaje. Teraz już nie, ponieważ mamy EmscriptenWebAssembly (lub Wasm).

Celem było dla mnie opracowanie sposobu kompilowania istniejącego kodu C na Wasm. W przypadku LLVM pojawiły się pewne problemy z backendem Wasm, więc zaczęłam się tym zajmować. W ten sposób można skompilować proste programy, ale gdy zechce się użyć standardowej biblioteki C lub nawet skompilować wiele plików, prawdopodobnie pojawią się problemy. Doprowadziło to do najważniejszej lekcji, jaką się nauczyłem:

Chociaż Emscripten był kompilatorem C na asm.js, od tego czasu ewoluował, aby obsługiwać Wasm i jest w trakcie przełączania się na oficjalny backend LLVM. Emscripten udostępnia też implementację standardowej biblioteki C zgodną z Wasm. Użyj Emscripten. Wymaga to wielu ukrytych działań, takich jak emulowanie systemu plików, zarządzanie pamięcią czy opakowanie OpenGL w WebGL. To wiele rzeczy, których nie musisz znać, aby tworzyć.

Może się wydawać, że musisz się martwić o rozdmuchanie kodu, ale kompilator Emscripten usuwa wszystko, co nie jest potrzebne. W moim eksperymencie powstałe moduły Wasm mają odpowiedni rozmiar do logiki, którą zawierają, a zespół Emscripten i WebAssembly pracuje nad tym, aby w przyszłości były jeszcze mniejsze.

Emscripten możesz pobrać, wykonując instrukcje na stronie lub za pomocą Homebrew. Jeśli tak jak ja lubisz polecenia w kontenerach i nie chcesz instalować czegoś w systemie tylko po to, aby pobawić się 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 czegoś prostego

Weźmy pod uwagę prawie kanoniczny przykład funkcji w C, która oblicza n-ty element 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 C, sama funkcja nie powinna być zbyt zaskakująca. Nawet jeśli nie znasz języka C, ale znasz JavaScript, prawdopodobnie zrozumiesz, o co chodzi.

emscripten.h to plik nagłówka dostarczony przez Emscripten. Potrzebujemy go tylko po to, aby mieć dostęp do makra EMSCRIPTEN_KEEPALIVE, który zapewnia znacznie więcej funkcji. To makro informuje kompilator, aby nie usuwał funkcji, nawet jeśli wydaje się, że jest nieużywana. Jeśli pominiemy to makro, kompilator zoptymalizuje funkcję, ponieważ nikt z niej nie korzysta.

Zapisz 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

Przyjrzyjmy się temu poleceniu. emcc to kompilator Emscripten. fib.c to nasz plik w formacie C. Idzie Ci doskonale. -s WASM=1 informuje Emscripten, aby zamiast pliku asm.js przekazał nam plik Wasm. -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' informuje kompilator, aby pozostawił funkcję cwrap() w pliku JavaScriptu. Więcej informacji o tej funkcji znajdziesz poniżej. -O3 informuje kompilator, aby optymalizował agresywnie. Możesz wybrać mniejsze liczby, aby skrócić czas kompilacji, ale spowoduje to również zwiększenie rozmiaru pakietów, ponieważ kompilator może nie usunąć nieużywanego kodu.

Po wykonaniu tego polecenia powinieneś otrzymać plik JavaScript o nazwie a.out.js i plik WebAssembly o nazwie a.out.wasm. Plik Wasm (lub „moduł”) zawiera nasz skompilowany kod C i powinien być dość mały. Plik JavaScript odpowiada za wczytywanie i inicjowanie modułu Wasm oraz udostępnianie przyjaźniejszego interfejsu API. W razie potrzeby zajmie się też konfiguracją stosu, stosu i innych funkcji, które zwykle są dostarczane przez system operacyjny podczas pisania kodu C. W związku z tym plik JavaScript jest nieco większy i ma rozmiar 19 KB (ok. 5 KB w formacie gzip).

Uruchamianie czegoś prostego

Najprostszym sposobem załadowania i uruchomienia modułu jest użycie wygenerowanego pliku JavaScript. Po załadowaniu tego pliku będziesz mieć do dyspozycji Module globalny. Użyj funkcji cwrap, aby utworzyć natywną funkcję JavaScriptu, która zajmie się konwersją parametrów na coś odpowiedniego dla C i wywołaniem opakowanej funkcji. Funkcja cwrap przyjmuje jako argumenty nazwę funkcji, typ zwracany i typy argumentów w takiej kolejności:

    <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 powinien pojawić się wynik „144”, czyli 12. liczba Fibonacciego.

Święty Graal: kompilowanie biblioteki C

Do tej pory napisany przez nas kod C był pisany z myślą o Wasm. Głównym zastosowaniem WebAssembly jest jednak wykorzystanie istniejącego ekosystemu bibliotek C i zezwolenie deweloperom na korzystanie z nich w internecie. Te biblioteki często korzystają ze standardowej biblioteki C, systemu operacyjnego, systemu plików i innych elementów. Emscripten zapewnia większość tych funkcji, ale ma pewne ograniczenia.

Wracam do pierwotnego celu: kompilowania kodera WebP do Wasm. Kod źródłowy kodeka WebP jest napisany w C i dostępny na GitHub, podobnie jak obszerna dokumentacja interfejsu API. To całkiem dobry punkt wyjścia.

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

Na początek spróbujmy udostępnić zmienną WebPGetEncoderVersion()encode.h do JavaScriptu, pisząc plik C o nazwie webp.c:

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

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

To prosty program, który pozwala sprawdzić, czy możemy skompilować kod źródłowy libwebp, ponieważ do wywołania tej funkcji nie potrzebujemy żadnych parametrów ani złożonych struktur danych.

Aby skompilować ten program, musimy powiedzieć kompilatorowi, gdzie może znaleźć pliki nagłówka biblioteki libwebp, używając flagi -I, a także przekazać mu wszystkie potrzebne pliki C biblioteki libwebp. Będę szczery: po prostu przekazałem mu wszystkie pliki C, które udało mi się znaleźć, i ufałem, że kompilator usunie wszystko, co nie jest potrzebne. Wygląda na to, ż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, aby załadować nasz nowy moduł, potrzebujemy tylko 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>

wyjściu zobaczymy numer wersji poprawki:

Zrzut ekranu konsoli DevTools z prawidłowym numerem wersji

Przesyłanie obrazu z JavaScript do Wasm

Zdobycie numeru wersji kodera to świetna sprawa, ale zakodowanie rzeczywistego obrazu byłoby bardziej imponujące, prawda? Zróbmy to.

Pierwsze pytanie, na które musimy odpowiedzieć, brzmi: jak wprowadzić obraz do Waszej krainy? Sprawdziliśmy interfejs kodowania libwebp i okazało się, że oczekuje on tablicy bajtów w formacie RGB, RGBA, BGR lub BGRA. Na szczęście interfejs Canvas API udostępnia funkcję getImageData(), która zwraca Uint8ClampedArray zawierający dane 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 wystarczy „tylko” skopiować dane z ziemi JavaScript do Wasmlandu. W tym celu musimy ujawnić 2 dodatkowe funkcje. Jeden z nich przydziela pamięć obrazowi w Wasm land, a drugi zwalnia tę pamięć:

    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, czyli 4 bajty na piksel. Wskaźnik zwracany przez funkcję malloc() to adres pierwszej komórki pamięci tego bufora. Gdy wskaźnik zostanie zwrócony do świata JavaScriptu, jest traktowany tylko jako liczba. Po udostępnieniu funkcji JavaScript za pomocą funkcji 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 Wasm. Nadszedł czas, aby wywołać koder WebP. Po zapoznaniu się z dokumentacją WebP wydaje się, że WebPEncodeRGBA jest idealnym rozwiązaniem. Funkcja przyjmuje wskaźnik do obrazu wejściowego i jego wymiarów, a także opcję jakości z zakresu od 0 do 100. Przydziela nam też bufor wyjściowy, który musimy zwolnić za pomocą funkcji WebPFree(), gdy skończymy z obrazem WebP.

Wynikiem operacji kodowania jest bufor wyjściowy i jego długość. Funkcja w C nie może zwracać tablic (chyba że przydzielimy pamięć dynamicznie), więc użyłem statycznej tablicy globalnej. Wiem, że to nie jest czysty C (w zasadzie opiera się na tym, że wskaźniki Wasm mają szerokość 32 bitów), ale aby zachować prostotę, uważam, że to jest odpowiedni 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, gdy wszystko jest gotowe, możemy wywołać funkcję kodowania, pobrać wskaźnik i rozmiar obrazu, umieścić je w buforze JavaScriptu i zwolnić wszystkie bufory Wasm, które zostały przydzielone w trakcie 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, w którym Wasm nie może zwiększyć pamięci na tyle, aby pomieścić obraz wejściowy 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! Złożyliśmy koder WebP i przekodowaliśmy obraz JPEG na WebP. Aby sprawdzić, czy to działa, możemy przekształcić bufor wyników w bloba 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);

Poznaj wspaniałość nowego formatu WebP

Panel sieciowy w Narzędziach dla programistów i wygenerowany obraz

Podsumowanie

Uruchomienie biblioteki C w przeglądarce nie jest łatwe, ale gdy zrozumiesz ogólny proces i sposób działania przepływu danych, stanie się to prostsze, a efekty mogą być niesamowite.

WebAssembly otwiera wiele nowych możliwości w internecie w zakresie przetwarzania, analizy danych i gier. Pamiętaj, że Wasm nie jest panaceum, które można stosować do wszystkiego, ale gdy napotkasz jeden z tych wąskich gardeł, może ono okazać się niezwykle przydatne.

Treści dodatkowe: wykonywanie prostego zadania w skomplikowany sposób

Jeśli chcesz uniknąć wygenerowanego pliku JavaScript, możesz to zrobić w ten sposób. Wróćmy do przykładu ciągu Fibonacciego. Aby go załadować i uruchomić, wykonaj 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ł WebAssembly utworzony przez Emscripten nie ma pamięci do pracy, chyba że ją mu udostępnisz. Aby przekazać modułowi Wasm cokolwiek, użyj obiektu imports, który jest drugim parametrem funkcji instantiateStreaming. Moduł Wasm ma dostęp do wszystkiego, co znajduje się w obiekcie importów, ale nie do niczego poza nim. Zgodnie z konwencją moduły skompilowane przez Emscripting oczekują kilku rzeczy od środowiska wczytywania JavaScriptu:

  • Po pierwsze, env.memory. Moduł Wasm nie ma dostępu do świata zewnętrznego, więc musi mieć pewną ilość pamięci, aby działać. Wpisz WebAssembly.Memory. Odpowiada on (opcjonalnie rozszerzalnej) części pamięci liniowej. Parametry rozmiaru są podawane w „jednostkach stron WebAssembly”, co oznacza, że kod powyżej przydziela 1 stronę pamięci, a każda strona ma rozmiar 64 KiB. Bez opcji maximumpamięć może teoretycznie nie mieć ograniczeń (obecnie Chrome ma twardy limit 2 GB). Większość modułów WebAssembly nie wymaga ustawienia maksymalnej wartości.
  • env.STACKTOP określa, od którego miejsca ma się zacząć wzrost grupy. Stos jest potrzebny do wywoływania funkcji i przydzielania pamięci zmiennym lokalnym. Ponieważ w naszym małym programie Fibonaccim nie używamy żadnych dynamicznych metod zarządzania pamięcią, możemy wykorzystać całą pamięć jako stos, a zatem STACKTOP = 0.