Mewarisi library C ke Wasm

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

Saya menetapkan sasaran untuk mempelajari cara mengompilasi beberapa kode C yang ada ke Wasm. Ada beberapa suara di sekitar backend Wasm LLVM, jadi saya mulai mempelajarinya. Meskipun Anda bisa membuat 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 digunakan sebagai compiler C-to-asm.js, compiler ini telah berkembang 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. WebGL melakukan banyak pekerjaan tersembunyi, mengemulasikan sistem file, menyediakan pengelolaan memori, menggabungkan OpenGL dengan WebGL — banyak hal yang sebenarnya tidak perlu Anda kembangkan sendiri.

Meskipun hal itu mungkin terdengar seperti Anda harus khawatir dengan bloat — saya pasti khawatir — compiler Emscripten menghapus semua yang tidak diperlukan. Dalam eksperimen saya, modul Wasm yang dihasilkan berukuran sesuai untuk logika yang dikandungnya dan tim Emscripten dan WebAssembly sedang berupaya membuatnya lebih kecil lagi di masa mendatang.

Anda bisa mendapatkan Emscripten dengan mengikuti petunjuk di situs atau menggunakan Homebrew. Jika Anda penggemar perintah dockerized seperti saya dan tidak ingin menginstal apa pun di sistem hanya untuk bermain dengan WebAssembly, ada image Docker yang dikelola dengan baik yang dapat Anda gunakan sebagai gantinya:

    $ 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 untuk menulis fungsi dalam 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, fungsi itu sendiri seharusnya tidak terlalu mengejutkan. Meskipun tidak mengetahui C, tetapi mengetahui JavaScript, Anda diharapkan 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 makro ini memberikan lebih banyak fungsi. Makro ini memberi tahu compiler untuk tidak menghapus fungsi meskipun tampaknya tidak digunakan. Jika kita menghapus makro tersebut, compiler akan mengoptimalkan fungsi tersebut — tidak ada yang menggunakannya.

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

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

Mari kita bedah perintah ini. emcc adalah compiler Emscripten. fib.c adalah file C kami. 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 dalam file JavaScript — selengkapnya tentang fungsi ini nanti. -O3 memberi tahu compiler untuk mengoptimalkan secara agresif. Anda dapat memilih angka yang lebih rendah untuk mengurangi waktu build, tetapi hal itu juga akan membuat paket yang dihasilkan menjadi lebih besar karena compiler mungkin tidak menghapus kode yang tidak digunakan.

Setelah menjalankan perintah, Anda akan mendapatkan file JavaScript yang disebut a.out.js dan file WebAssembly yang disebut a.out.wasm. File Wasm (atau "modul") berisi kode C yang dikompilasi dan harus cukup kecil. File JavaScript menangani pemuatan dan inisialisasi modul Wasm serta menyediakan API yang lebih baik. Jika diperlukan, kode ini juga akan menangani penyiapan stack, heap, dan fungsi lainnya yang biasanya diharapkan disediakan oleh sistem operasi saat menulis kode C. Dengan demikian, file JavaScript sedikit lebih besar, dengan berat 19 KB (~5 KB gzip'd).

Menjalankan sesuatu yang sederhana

Cara termudah untuk memuat dan menjalankan modul adalah 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 kompatibel dengan C dan memanggil fungsi gabungan. 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 Anda menjalankan kode ini, Anda akan melihat "144" di konsol, yang merupakan angka Fibonacci ke-12.

Holy grail: Mengompilasi library C

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

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

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

Untuk memulai dengan sederhana, mari kita coba mengekspos WebPGetEncoderVersion() dari encode.h ke JavaScript dengan menulis file C yang disebut 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 perlu memberi tahu compiler tempat file header libwebp dapat ditemukan menggunakan flag -I dan juga meneruskan semua file C libwebp yang diperlukan. Sejujurnya: Saya hanya memasukkan semua file C yang dapat saya temukan dan mengandalkan compiler untuk menghapus semua yang tidak diperlukan. Tampaknya berfungsi dengan 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 keren:

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

Dan kita akan melihat nomor versi koreksi di output:

Screenshot konsol DevTools yang menampilkan nomor versi yang benar.

Mendapatkan gambar dari JavaScript ke Wasm

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

Pertanyaan pertama yang harus kita jawab adalah: Bagaimana cara memasukkan gambar ke dalam Wasm? Melihat API encoding libwebp, API ini 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, Anda "hanya" perlu menyalin data dari wilayah JavaScript ke wilayah Wasm. Untuk itu, kita perlu mengekspos dua fungsi tambahan. Satu 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 buffering untuk gambar RGBA — sehingga 4 byte per piksel. Pointer yang ditampilkan oleh malloc() adalah alamat sel memori pertama buffer tersebut. Saat pointer ditampilkan ke platform JavaScript, pointer tersebut diperlakukan sebagai hanya 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

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

Hasil operasi encoding adalah buffering output dan panjangnya. Karena fungsi dalam 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 yang bersih (bahkan, ini bergantung pada fakta bahwa pointer Wasm memiliki lebar 32-bit), tetapi untuk menjaga semuanya tetap sederhana, saya rasa ini adalah pintasan 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];
    }

Sekarang dengan semua hal tersebut, kita dapat memanggil fungsi encoding, mengambil pointer dan ukuran gambar, menempatkannya di buffer land JavaScript kita sendiri, dan melepaskan semua buffer land Wasm yang telah dialokasikan dalam proses.

    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 yang cukup untuk mengakomodasi gambar input dan output:

Screenshot konsol DevTools yang menampilkan error.

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

Seperti itulah. Kita telah mengompilasi encoder WebP dan mentranskode gambar JPEG ke WebP. Untuk membuktikan bahwa ini berfungsi, kita dapat mengubah buffering 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, keagungan gambar WebP baru.

Panel jaringan DevTools dan gambar yang dihasilkan.

Kesimpulan

Memastikan library C berfungsi di browser bukanlah hal yang mudah, tetapi setelah Anda memahami keseluruhan proses dan cara kerja alur data, hal ini menjadi lebih mudah dan hasilnya bisa sangat luar biasa.

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

Konten bonus: Menjalankan sesuatu yang sederhana dengan cara yang sulit

Jika ingin mencoba dan menghindari file JavaScript yang dihasilkan, Anda mungkin dapat melakukannya. Mari kita 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 memberinya memori. Cara Anda 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 ada yang lain di luarnya. Secara konvensional, modul yang dikompilasi oleh Emscripting mengharapkan beberapa hal dari lingkungan JavaScript pemuatan:

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