Mewarisi library C ke Wasm

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

Toolchain

Saya menetapkan tujuan saya sendiri bagaimana mengkompilasi beberapa kode C yang ada untuk Wasman. Ada derau di sekitar backend Wasm LLVM, jadi Saya mulai mencari tahu tentang hal itu. Meskipun Anda bisa mendapatkan program sederhana untuk dikompilasi dengan cara ini, saat Anda ingin menggunakan pustaka standar C atau bahkan mengompilasi banyak file, Anda mungkin akan mengalami masalah. Hal ini membawa saya ke posisi utama pelajaran yang saya pelajari:

Meskipun digunakan sebagai compiler C-to-asm.js, Emscripten kini berkembang menjadi menargetkan Wasm dan dalam proses pengalihan ke backend LLVM resmi secara internal. Emscripten juga memberikan Implementasi library standar C yang kompatibel dengan Wasm. Gunakan Emscripten. Ini berisi banyak pekerjaan tersembunyi, mengemulasi sistem file, menyediakan manajemen memori, menggabungkan OpenGL dengan WebGL — hal yang sebenarnya tidak perlu Anda kembangkan untuk diri sendiri.

Meskipun mungkin terdengar seperti Anda perlu khawatir tentang kembung, saya tentu saja khawatir — compiler Emscripten menghapus semua yang tidak diperlukan. Di .... saya eksperimen, modul Wasm yang dihasilkan memiliki ukuran yang tepat sesuai logika yang dimilikinya dan tim Emscripten dan WebAssembly sedang mengerjakan pembuatan dapat menjadi jauh lebih kecil di masa depan.

Anda bisa mendapatkan Emscripten dengan mengikuti petunjuk di situs atau menggunakan Homebrew. Jika Anda adalah penggemar perintah Docker{i> <i}seperti saya dan tidak ingin menginstal banyak hal di sistem Anda hanya bermain dengan WebAssembly, ada sebuah layanan Image Docker 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 hampir kanonik dari penulisan fungsi di C yang menghitung bilangan fibonaccith:

    #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 tahu C, fungsi itu sendiri seharusnya tidak terlalu mengejutkan. Bahkan jika Anda tidak tahu C tapi mengerti JavaScript, Anda diharapkan dapat memahami apa yang terjadi.

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

Mari kita simpan semua itu 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 bahas perintah ini. emcc adalah compiler Emscripten. fib.c adalah 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 keluar dari Fungsi cwrap() tersedia di file JavaScript — selengkapnya tentang fungsi ini nanti. -O3 memberi tahu compiler untuk mengoptimalkan secara agresif. Anda dapat memilih lebih rendah angka untuk mengurangi waktu build, tetapi hal ini juga akan membuat paket yang dihasilkan lebih besar karena kompiler mungkin tidak menghapus kode yang tidak digunakan.

Setelah menjalankan perintah, Anda akan mendapatkan file JavaScript bernama a.out.js dan file WebAssembly bernama a.out.wasm. File Wasm (atau "module") berisi kompilasi kode C kita dan ukurannya akan cukup kecil. Tujuan file JavaScript menangani pemuatan dan inisialisasi modul Wasm dan menyediakan API yang lebih baik. Jika diperlukan, Gemini juga akan menangani pengaturan tumpukan, heap, dan fungsionalitas lain yang biasanya diharapkan disediakan oleh sistem operasi saat menulis kode C. Dengan demikian, file JavaScript sedikit lebih besar, dengan berat 19KB (~5KB gzip).

Menjalankan sesuatu yang sederhana

Cara termudah untuk memuat dan menjalankan modul Anda adalah dengan menggunakan JavaScript yang dihasilkan . Setelah Anda memuat file tersebut, Anda akan memiliki Module global bagi Anda. Gunakan cwrap untuk membuat fungsi native JavaScript yang menangani parameter konversi ke sesuatu yang ramah C dan memanggil fungsi yang digabungkan. cwrap mengambil 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 jalankan kode ini, Anda akan melihat "144" di konsol, yang merupakan angka Fibonacci ke-12.

Tujuan: Mengompilasi {i>library<i} C

Sampai sekarang, kode C yang telah kita tulis ditulis dengan mempertimbangkan Wasm. Inti untuk WebAssembly, bagaimanapun, adalah dengan mengambil ekosistem C perpustakaan dan memungkinkan pengembang menggunakannya di web. Perpustakaan ini sering mengandalkan pustaka standar C, sistem operasi, sistem file dan banyak hal. Emscripten menyediakan sebagian besar fitur ini, meskipun ada beberapa batasan yang ada.

Mari kembali ke tujuan awal saya: mengompilasi encoder untuk WebP ke Wasm. Tujuan untuk codec WebP ditulis dalam C dan tersedia di GitHub serta beberapa ekstensi Dokumentasi API. Itu adalah titik awal yang cukup baik.

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

Untuk memulai yang sederhana, mari kita coba mengekspos 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 panggil fungsi ini.

Untuk mengompilasi program ini, kita perlu memberi tahu kompilator di mana ia dapat menemukan file header libwebp menggunakan flag -I dan juga meneruskan semua file C ke {i>libwebp<i} yang dibutuhkan. Saya akan jujur: Saya memberikan semua huruf C file yang dapat saya temukan dan mengandalkan {i>compiler<i} untuk menghapus semua yang tidak diperlukan. Desainer ini tampaknya bekerja dengan cemerlang!

    $ 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 kita:

<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 menunjukkan versi yang benar
angka

Mendapatkan gambar dari JavaScript ke Wasm

Mendapatkan nomor versi encoder adalah hal yang bagus, tetapi pengkodean akan lebih mengesankan, bukan? Kalau begitu, mari kita lakukan.

Pertanyaan pertama yang harus kita jawab adalah: Bagaimana cara memasukkan gambar ke area Wasm? Melihat encoding API libwebp, fitur 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 "hanya" penyalinan data dari JavaScript akan diarahkan ke Wasm mendarat. Untuk itu, kita perlu mengekspos dua fungsi tambahan. Tugas yang mengalokasikan memori untuk gambar di dalam land Wasm dan 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 — karenanya 4 byte per piksel. Pointer yang ditampilkan oleh malloc() adalah alamat sel memori pertama {i>buffer<i} itu. Saat pointer dikembalikan ke posisi JavaScript, ini diperlakukan sebagai hanya sebuah angka. Setelah mengekspos fungsi ke JavaScript menggunakan cwrap, kita dapat gunakan angka itu untuk menemukan titik 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 tersebut kini tersedia di halaman Wasm. Sekarang saatnya memanggil encoder WebP untuk lakukan tugasnya! Melihat Dokumentasi WebP, WebPEncodeRGBA sepertinya sangat cocok. Fungsi ini membawa pointer ke gambar input dan dimensinya, serta opsi kualitas antara 0 dan 100. {i>Software<i} ini juga mengalokasikan buffer output untuk kita, yang harus kita bebaskan menggunakan WebPFree() setelah dilakukan dengan gambar WebP.

Hasil operasi encoding adalah buffer output dan panjangnya. Karena fungsi di C tidak dapat memiliki {i>array <i}sebagai jenis nilai yang ditampilkan (kecuali jika kita mengalokasikan secara dinamis), saya menggunakan array global statis. Saya tahu, tidak bersih C (faktanya, itu bergantung pada fakta bahwa pointer Wasm lebarnya 32 bit), tetapi untuk menjaga segala sesuatunya sederhana, saya pikir ini adalah 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];
    }

Sekarang dengan semua itu ada, kita bisa memanggil fungsi penyandian, ambil ukuran pointer dan gambar, memasukkannya ke dalam buffering JavaScript kita sendiri, dan melepaskan semua {i>buffer <i}Wasm-land yang telah kita alokasikan 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 akan mengalami error ketika 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.

Demikianlah. Kami mengompilasi encoder WebP dan melakukan transcoding gambar JPEG untuk WebP. Untuk membuktikan bahwa itu berhasil, kita dapat mengubah buffer hasil menjadi blob dan menggunakan di 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, hebatnya gambar WebP baru!

Panel jaringan DevTools dan gambar yang dihasilkan.

Kesimpulan

Tidak perlu berjalan kaki di taman untuk membuat perpustakaan C berfungsi di {i>browser<i}, tetapi sekali Anda memahami keseluruhan proses dan bagaimana aliran data bekerja, itu menjadi lebih mudah dan hasilnya bisa mengejutkan.

WebAssembly membuka banyak kemungkinan baru di web untuk pemrosesan, {i>crunching <i}dan {i>game<i}. Perlu diingat bahwa Wasm bukanlah solusi alternatif yang seharusnya diterapkan pada semuanya, tetapi ketika Anda mencapai salah satu hambatan itu, Wasm dapat alat yang sangat membantu.

Konten bonus: Menjalankan sesuatu yang sederhana dengan cara yang sulit

Jika Anda ingin mencoba dan menghindari file JavaScript yang dihasilkan, Anda mungkin dapat tempat mesin terhubung. Mari kita kembali ke contoh Fibonacci. Untuk memuat dan menjalankannya sendiri, kita bisa lakukan 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 dibuat oleh Emscripten tidak memiliki memori untuk berfungsi kecuali jika Anda menyediakan memori. Cara Anda menyediakan modul Wasm apa pun adalah dengan menggunakan objek imports — parameter kedua Fungsi instantiateStreaming. Modul Wasm dapat mengakses semua yang ada di dalam objek impor, tetapi tidak ada hal lain di luarnya. Berdasarkan konvensi, modul dikompilasi oleh Emscripting, terdapat beberapa hal dari proses pemuatan JavaScript lingkungan:

  • Pertama, ada env.memory. Modul Wasm tidak mengetahui kondisi luar sehingga perlu untuk mendapatkan beberapa memori untuk digunakan. Masuk WebAssembly.Memory Ini merepresentasikan bagian memori linear (yang dapat di-tumbuhkan secara opsional). Ukuran parameter ada di "dalam unit halaman WebAssembly", artinya kode di atas mengalokasikan 1 halaman memori, dengan setiap halaman memiliki ukuran 64 KiB. Tanpa menyediakan maximum , memori secara teoritis tidak terikat dalam pertumbuhan (saat ini Chrome memiliki dengan batas maksimum 2 GB). Sebagian besar modul WebAssembly tidak perlu mengatur maksimum.
  • env.STACKTOP menentukan tempat stack seharusnya mulai berkembang. Stack diperlukan untuk melakukan panggilan fungsi dan mengalokasikan memori untuk variabel lokal. Karena kita tidak melakukan kesalahan manajemen memori dinamis di lingkungan di program Fibonacci, kita bisa menggunakan seluruh memori sebagai tumpukan, sehingga STACKTOP = 0.