Mewarisi library C ke Wasm

Terkadang Anda ingin menggunakan library yang hanya tersedia sebagai kode C atau C++. Secara tradisional, di sinilah Anda menyerah. Tidak lagi, karena sekarang kita memiliki Emscripten dan WebAssembly (atau Wasm).

Toolchain

Tujuan saya adalah untuk mencari cara mengompilasi beberapa kode C yang ada ke Wasm. Ada kebisingan di sekitar backend Wasm LLVM, jadi saya mulai mencari tahu tentang itu. Meskipun Anda bisa mendapatkan program sederhana untuk dikompilasi dengan cara ini, saat Anda ingin menggunakan library standar C atau bahkan mengompilasi beberapa file, Anda mungkin akan mengalami masalah. Hal ini membawa saya pada pelajaran utama yang saya pelajari:

Meskipun Emscripten sebelumnya menjadi compiler C-to-asm.js, compiler ini kini telah matang untuk menargetkan Wasm dan sedang dalam proses beralih ke backend LLVM resmi secara internal. Emscripten juga menyediakan implementasi library standar C yang kompatibel dengan Wasm. Gunakan Emscripten. Library ini melakukan banyak tugas tersembunyi, mengemulasi sistem file, menyediakan manajemen memori, menggabungkan OpenGL dengan WebGL — banyak hal yang tidak Anda perlukan untuk melakukan pengembangan sendiri.

Meskipun sepertinya Anda harus khawatir tentang penggelembungan — saya tentu khawatir — compiler Emscripten menghapus semua yang tidak diperlukan. Dalam eksperimen saya, modul Wasm yang dihasilkan memiliki ukuran yang tepat untuk logika yang ada di dalamnya, dan tim Emscripten serta WebAssembly sedang berupaya untuk membuatnya lebih kecil lagi di masa mendatang.

Anda bisa mendapatkan Emscripten dengan mengikuti petunjuk di situsnya atau menggunakan Homebrew. Jika Anda penggemar perintah yang terdokumentasi seperti saya dan tidak ingin menginstal sesuatu di sistem hanya untuk bermain dengan WebAssembly, tersedia image Docker yang terawat dengan baik sebagai gantinya yang dapat Anda gunakan:

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

Mengompilasi sesuatu yang sederhana

Mari kita ambil contoh yang hampir kanonis dari penulisan fungsi di C yang menghitung bilangan fibonacci ke-n:

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

Jika Anda mengetahui C, {i>function<i} itu sendiri seharusnya tidak terlalu mengejutkan. Meskipun Anda tidak mengenal C tetapi memahami JavaScript, semoga Anda dapat memahami apa yang terjadi di sini.

emscripten.h adalah file header yang disediakan oleh Emscripten. Kita hanya memerlukannya agar memiliki akses ke makro EMSCRIPTEN_KEEPALIVE, tetapi menyediakan lebih banyak fungsi. Makro ini memberi tahu compiler untuk tidak menghapus fungsi meskipun tampak tidak digunakan. Jika kita menghapus makro tersebut, compiler akan mengoptimalkan fungsi — bagaimanapun tidak ada yang menggunakannya.

Mari kita simpan semuanya dalam file bernama fib.c. Untuk mengubahnya menjadi file .wasm, kita perlu beralih ke perintah compiler Emscripten emcc:

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

Mari kita pelajari perintah ini. emcc adalah compiler Emscripten. fib.c adalah file C kita. Sejauh ini, hasilnya bagus. -s WASM=1 memberi tahu Emscripten untuk memberi kita file Wasm, bukan file asm.js. -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' memberi tahu compiler untuk membiarkan fungsi cwrap() tersedia di file JavaScript — pembahasan lebih lanjut tentang fungsi ini nanti. -O3 memberi tahu compiler untuk mengoptimalkan secara agresif. Anda dapat memilih jumlah yang lebih rendah untuk mengurangi waktu build, tetapi juga akan memperbesar paket yang dihasilkan karena compiler mungkin tidak menghapus kode yang tidak digunakan.

Setelah menjalankan perintah, Anda akan memiliki file JavaScript bernama a.out.js dan file WebAssembly bernama a.out.wasm. File Wasm (atau "modul") berisi kode C yang telah dikompilasi dan ukurannya harus cukup kecil. File JavaScript menangani pemuatan dan inisialisasi modul Wasm serta menyediakan API yang lebih bagus. Jika perlu, Anda juga akan menyiapkan stack, heap, dan fungsi lainnya yang biasanya disediakan oleh sistem operasi saat menulis kode C. Dengan demikian, file JavaScript sedikit lebih besar, dengan ukuran 19 KB (~5 KB gzip'd).

Menjalankan sesuatu yang sederhana

Cara termudah untuk memuat dan menjalankan modul Anda adalah dengan menggunakan file JavaScript yang dihasilkan. Setelah memuat file tersebut, Anda akan memiliki Module global yang dapat digunakan. Gunakan cwrap untuk membuat fungsi native JavaScript yang menangani konversi parameter menjadi sesuatu yang ramah C dan memanggil fungsi yang digabungkan. cwrap menggunakan nama fungsi, jenis nilai yang ditampilkan, dan jenis argumen sebagai argumen, dalam urutan tersebut:

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

Jika menjalankan kode ini, Anda akan melihat "144" di konsol, yang merupakan angka Fibonacci ke-12.

Praktik terbaik: Mengompilasi library C

Hingga saat ini, kode C yang kita tulis ditulis dengan mempertimbangkan Wasm. Namun, kasus penggunaan inti untuk WebAssembly adalah mengambil ekosistem library C yang ada dan mengizinkan developer menggunakannya di web. Library ini sering kali mengandalkan library standar C, sistem operasi, sistem file, dan hal-hal lainnya. Emscripten menyediakan sebagian besar fitur ini, meskipun ada beberapa batasan.

Mari kita kembali ke tujuan awal saya: mengompilasi encoder untuk WebP ke Wasm. Sumber untuk codec WebP ditulis dalam C dan tersedia di GitHub serta beberapa dokumentasi API yang lengkap. Itu adalah titik awal yang bagus.

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

Untuk memulai yang sederhana, mari coba ekspos WebPGetEncoderVersion() dari encode.h ke JavaScript dengan menulis file C bernama webp.c:

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

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

Ini adalah program sederhana yang baik untuk menguji apakah kita bisa mendapatkan kode sumber libwebp untuk dikompilasi, karena kita tidak memerlukan parameter atau struktur data yang kompleks untuk memanggil fungsi ini.

Untuk mengompilasi program ini, kita harus memberi tahu compiler tempat ia dapat menemukan file header libwebp menggunakan flag -I dan juga meneruskan semua file C libwebp yang diperlukan. Jujur, saya memberikan semua file C yang dapat saya temukan dan mengandalkan compiler untuk menghapus semua file yang tidak diperlukan. Tampaknya bekerja dengan sangat baik!

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

Sekarang kita hanya memerlukan beberapa HTML dan JavaScript untuk memuat modul baru yang berkilau:

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

Kita akan melihat nomor versi koreksi di output:

Screenshot konsol DevTools yang menampilkan nomor versi
yang benar.

Mengambil gambar dari JavaScript ke Wasm

Mendapatkan nomor versi encoder memang bagus, dan itu bagus, tetapi melakukan encoding gambar yang sebenarnya akan lebih mengesankan, bukan? Mari kita lakukan itu.

Pertanyaan pertama yang harus kita jawab adalah: Bagaimana kita memasukkan gambar ke tanah Wasm? Dengan melihat encoding API libwebp, ia mengharapkan array byte dalam RGB, RGBA, BGR, atau BGRA. Untungnya, Canvas API memiliki getImageData(), yang memberi kita Uint8ClampedArray yang berisi data gambar dalam 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);
}

Sekarang, "hanya" menyalin data dari JavaScript ke area Wasm. Untuk itu, kita perlu mengekspos dua fungsi tambahan. Fungsi yang mengalokasikan memori untuk gambar di dalam area Wasm dan satu lagi yang mengosongkannya lagi:

    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 mengalokasikan buffer untuk gambar RGBA, sehingga 4 byte per piksel. Pointer yang ditampilkan oleh malloc() adalah alamat sel memori pertama buffer tersebut. Saat ditampilkan ke JavaScript, pointer hanya dianggap sebagai angka. Setelah mengekspos fungsi ke JavaScript menggunakan cwrap, kita dapat menggunakan angka tersebut untuk menemukan awal buffer dan menyalin data gambar.

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

Grand Finale: Mengenkode gambar

Gambar tersebut kini tersedia di tanah Wasm. Saatnya memanggil encoder WebP untuk melakukan tugasnya. Melihat dokumentasi WebP, WebPEncodeRGBA tampaknya sangat cocok. Fungsi ini membawa pointer ke gambar input dan dimensinya, serta opsi kualitas antara 0 dan 100. Kode ini juga mengalokasikan buffer output untuk kita, yang harus kita hapus menggunakan WebPFree() setelah selesai dengan gambar WebP.

Hasil operasi encoding adalah buffering output dan panjangnya. Karena fungsi di C tidak dapat memiliki array sebagai jenis nilai yang ditampilkan (kecuali jika kita mengalokasikan memori secara dinamis), saya menggunakan array global statis. Saya tahu, bukan C bersih (faktanya, itu bergantung pada fakta bahwa pointer Wasm lebarnya 32bit), tetapi untuk menjaganya tetap sederhana, saya pikir ini jalan pintas yang adil.

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

Setelah semua itu tersedia, kita bisa memanggil fungsi encoding, mengambil pointer dan ukuran gambar, memasukkannya ke dalam buffer JavaScript-land, dan melepaskan semua buffer Wasm-land yang telah kita alokasikan dalam prosesnya.

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

Bergantung pada ukuran gambar, Anda mungkin mengalami error saat Wasm tidak dapat menambah memori cukup untuk mengakomodasi gambar input dan output:

Screenshot konsol DevTools menampilkan error.

Untungnya, solusi untuk masalah ini ada di pesan error! Kita hanya perlu menambahkan -s ALLOW_MEMORY_GROWTH=1 ke perintah kompilasi.

Demikianlah. Kami mengompilasi encoder WebP dan men-transcoding gambar JPEG ke WebP. Untuk membuktikan bahwa cara ini berhasil, kita dapat mengubah buffer hasil menjadi blob dan menggunakannya pada elemen <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);

Lihatlah keunggulan gambar WebP baru!

Panel jaringan DevTools dan gambar yang dihasilkan.

Kesimpulan

Bukan sekedar membuat library C berfungsi di browser, tetapi begitu Anda memahami keseluruhan proses dan cara kerja aliran data, proses ini akan menjadi lebih mudah dan hasilnya bisa sangat mengejutkan.

WebAssembly membuka banyak kemungkinan baru di web untuk pemrosesan, pembuatan angka, dan game. Perlu diingat bahwa Wasm bukanlah solusi mudah yang harus diterapkan ke dalam semua hal, tetapi saat Anda mengalami salah satu hambatan tersebut, Wasm dapat menjadi alat yang sangat membantu.

Konten bonus: Menjalankan sesuatu yang sederhana dengan keras

Jika ingin mencoba dan menghindari file JavaScript yang dihasilkan, Anda mungkin dapat melakukannya. Mari kembali ke contoh Fibonacci. Untuk memuat dan menjalankannya sendiri, kita dapat melakukan hal berikut:

<!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>

Modul WebAssembly yang telah dibuat oleh Emscripten tidak memiliki memori untuk digunakan kecuali jika Anda menyediakannya dengan memori. Cara menyediakan modul Wasm dengan apa pun adalah dengan menggunakan objek imports — parameter kedua dari fungsi instantiateStreaming. Modul Wasm dapat mengakses semua yang ada di dalam objek impor, tetapi tidak dapat mengakses apa pun di luarnya. Berdasarkan konvensi, modul yang dikompilasi oleh Emscripting memerlukan beberapa hal dari lingkungan JavaScript pemuatan:

  • Pertama, ada env.memory. Modul Wasm tidak menyadari dunia luar, sehingga perlu mendapatkan beberapa memori untuk digunakan. Masukkan WebAssembly.Memory. Class ini mewakili potongan memori linear (yang dapat diperluas secara opsional). Parameter penentuan ukuran berada di "dalam unit halaman WebAssembly", yang berarti kode di atas mengalokasikan 1 halaman memori, dengan setiap halaman memiliki ukuran 64 KiB. Tanpa menyediakan opsi maximum, pertumbuhan memori secara teoritis tidak terbatas (Chrome saat ini memiliki batas maksimal 2 GB). Sebagian besar modul WebAssembly seharusnya tidak perlu menetapkan nilai maksimum.
  • env.STACKTOP menentukan tempat stack seharusnya mulai bertambah. Stack ini diperlukan untuk melakukan panggilan fungsi dan untuk mengalokasikan memori untuk variabel lokal. Karena kita tidak melakukan gangguan manajemen memori dinamis dalam program Fibonacci kecil, kita cukup menggunakan seluruh memori sebagai stack, sehingga kita bisa menggunakan STACKTOP = 0.