Ustawienie biblioteki C w Wasm

Czasami chcesz użyć biblioteki dostępnej tylko w postaci kodu C lub C++. Tradycyjnie na tym etapie się poddajesz. Teraz już nie, ponieważ mamy Emscripten i WebAssembly (czyli Wasm).

Łańcuch narzędzi

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ć. Choć można w ten sposób skompilować proste programy, w drugim przypadku użycia standardowej biblioteki C lub nawet skompilować wiele plików prawdopodobnie wystąpią problemy. Doprowadziło to do najważniejszej lekcji, jaką się nauczyłem:

Chociaż Emscripten dawniej był kompilatorem C-to-asm.js, z czasem dopracował się do kierowania na Wasm i jest w trakcie wewnętrznego przechodzenia na oficjalny backend LLVM. Emscripten udostępnia również zgodną z Wasm implementację standardowej biblioteki C. Użyj Emscripten. Obejmuje on wiele ukrytych zadań, emuluje system plików, umożliwia zarządzanie pamięcią i uzupełnia OpenGL za pomocą WebGL – jest to wiele rzeczy, których nie trzeba wcielać w życie samodzielnie.

Choć może się wydawać, że masz problem z wzdęciami, to oczywiście się to martwię – kompilator Emscripten usuwa wszystko, co nie jest potrzebne. W moich eksperymentach wynikowe moduły Wasm są odpowiednio dostosowane pod kątem zawartej w nich logiki, a zespoły Emscripten i WebAssembly pracują nad tym, aby w przyszłości je jeszcze bardziej zmniejszyć.

Aby pobrać aplikację Emscripten, postępuj zgodnie z instrukcjami na jej stronie lub użyj aplikacji Homebrew. Jeśli lubisz korzystać z dockerskich poleceń, takich jak ja i nie chcesz instalować elementów w swoim systemie, żeby po prostu wygodnie korzystać z WebAssembly, możesz użyć dobrze obsługiwanego obrazu Dockera:

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

Kompilowanie prostych działań

Weźmy pod uwagę niemal kanoniczny przykład funkcji w C, która oblicza n-ty numer 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 być zbyt zaskakująca. Nawet jeśli nie znasz języka C, ale znasz JavaScript, mam nadzieję, że uda Ci się zrozumieć ten proces.

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 tę funkcję i przecież nikt z niego nie będzie korzystać.

Zapiszmy 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 prosi Emscripten o przesłanie pliku Wasm zamiast pliku asm.js. -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' informuje kompilatora, aby pozostawić funkcję cwrap() dostępną w pliku JavaScript (więcej o tej funkcji dowiesz się później). -O3 nakazuje kompilatorowi agresywną optymalizację. 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 uruchomieniu polecenia powinien wyświetlić się plik JavaScript o nazwie a.out.js oraz plik WebAssembly o nazwie a.out.wasm. Plik Wasm (lub „moduł”) zawiera nasz skompilowany kod C i powinien być dość mały. Plik JavaScript zajmuje się wczytywaniem i inicjowaniem modułu Wasm oraz udostępnianiem bardziej przejrzystego interfejsu API. W razie potrzeby zajmie się też konfiguracją stosu, stosu i innych funkcji, które zwykle mają być zapewniane 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).

Prowadzenie prostych działań

Najłatwiejszym sposobem wczytania 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ę JavaScript, 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>

Po uruchomieniu tego kodu w konsoli powinna się pojawić wartość „144”, czyli 12. numer Fibonacci.

Święty Graal: kompilowanie biblioteki C

Do tej pory kod C był pisany z myślą o Wasm. Głównym przypadkiem użycia WebAssembly jest jednak wykorzystanie istniejącego ekosystemu bibliotek C i umożliwienie deweloperom korzystania z nich w internecie. Te biblioteki często korzystają ze standardowej biblioteki C, systemu operacyjnego, systemu plików i innych elementów. Większość z tych funkcji zapewnia Emscripten, jednak obowiązują pewne ograniczenia.

Wróćmy do pierwotnego celu: skompilowania kodera dla WebP do Wasm. Źródło kodeka WebP jest napisane w języku C i jest dostępne na GitHub oraz w obszernej dokumentacji interfejsu API. To całkiem dobry punkt wyjścia.

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

Na początek spróbujmy ujawnić element WebPGetEncoderVersion() z encode.h w JavaScript, tworząc plik C o nazwie webp.c:

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

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

To dobry, prosty program do sprawdzenia, czy można pobrać kod źródłowy libwebp do skompilowania, ponieważ nie wymaga żadnych parametrów ani złożonych struktur danych do wywołania tej funkcji.

Aby skompilować ten program, musimy za pomocą flagi -I wskazać kompilatorowi, gdzie znajdzie pliki nagłówkowe libwebp, i przekazać mu wszystkie niezbędne pliki C z libwebp. Będę szczery: podałam mu wszystkie pliki C, które udało mi się znaleźć, i korzystałem z kompilatora, aby usunąć wszystko, co było niepotrzebne. Wygląda na to, że działał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 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>

Numer poprawionej wersji pojawi się w danych wyjściowych:

Zrzut ekranu konsoli DevTools z prawidłowym numerem wersji

Przesyłanie obrazu z JavaScript do Wasm

Numer wersji kodera jest i to bardzo dobry, ale kodowanie rzeczywistego obrazu byłoby jeszcze lepsze, prawda? Zróbmy to.

Pierwsze pytanie, na które musimy odpowiedzieć, brzmi: jak przenieść zdjęcie do Wasmland? Patrząc na interfejs API kodowania libwebp, oczekuje on tablicy bajtów w formacie RGB, RGBA, BGR lub BGRA. Na szczęście interfejs Canvas API zawiera zmienną getImageData(), która daje nam obiekt Uint8ClampedArray z danymi obrazów 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 pozostaje tylko skopiowanie danych z obszaru JavaScriptu do Wasmland. W tym celu musimy ujawnić 2 dodatkowe funkcje. Jeden przydziela pamięć dla obrazu na lądzie Wasm i drugi, który ją ponownie uwalnia:

    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);
    }

Funkcja create_buffer przydziela bufor do obrazu RGBA, czyli 4 bajty na piksel. Wskaźnik zwrócony przez malloc() to adres pierwszej komórki pamięci w tym buforze. Gdy wskaźnik jest zwracany do strony JavaScript, jest on traktowany jako tylko 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 zdjęcia

Zdjęcie jest już dostępne w regionie Wasm. Nadszedł czas, aby wywołać koder WebP. Z dokumentacji WebP można się wywnioskować, że WebPEncodeRGBA jest idealnym rozwiązaniem. Funkcja podaje wskaźnik do obrazu wejściowego i jego wymiarów, a także opcję jakości 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ść. Ponieważ funkcje w C nie mogą mieć tablic jako typów zwracanych (chyba że przydzielamy pamięć dynamicznie), skorzystałem z statycznej tablicy globalnej. Wiem, że nie klarowne C (zależy to od tego, że wskaźniki Wasm mają 32-bitową szerokość), ale dla zachowania prostoty uważam, że to dobry 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 pamięci bufora JavaScript i zwolnić wszystkie bufory Wasm-land.

    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 polegający na tym, że Wasm nie może powiększyć pamięci w taki sposób, 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 na format WebP. Aby udowodnić, że wszystko działa, możemy zmienić bufor wyników w 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);

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. Trzeba pamiętać, że Wasm nie jest dobrym rozwiązaniem, które trzeba stosować w każdej sytuacji, ale kiedy trafisz na jedno z wąskich gardeł, Wasm może okazać się niezwykle przydatnym narzędziem.

Dodatkowa treść: uciążliwe prowadzenie czegoś prostego

Jeśli chcesz uniknąć wygenerowanego pliku JavaScript, możesz to zrobić w ten sposób. Wróćmy do przykładu ciągu Fibonacciego. Aby załadować i uruchomić go samodzielnie, 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 w obiekcie importu, ale do niczego poza nim. Zgodnie z konwencją moduły skompilowane przez Emscripting oczekują kilku rzeczy od środowiska wczytywania JavaScriptu:

  • Pierwszy z nich to 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. Jest to (opcjonalnie) kawałek 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. Jeśli nie udostępnisz opcji maximum, wzrost ilości pamięci będzie teoretycznie nieograniczony (obecnie obowiązuje w Chrome stały limit 2 GB). W większości modułów WebAssembly nie trzeba ustawiać wartości maksymalnej.
  • 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. W naszym niewielkim programie Fibonacci nie stosujemy żadnych sztuczek na dynamiczne zarządzanie pamięcią, więc możemy wykorzystać całą pamięć jako stos – dlatego STACKTOP = 0.