Terkadang Anda ingin menggunakan library yang hanya tersedia sebagai kode C atau C++. Biasanya, di sinilah Anda menyerah. Nah, tidak lagi, karena sekarang kita memiliki Emscripten dan WebAssembly (atau Wasm)!
Toolchain
Saya menetapkan sendiri tujuan untuk mencari tahu cara mengompilasi beberapa kode C yang ada ke Wasm. Ada beberapa diskusi tentang backend Wasm LLVM, jadi saya mulai mempelajarinya. Meskipun Anda dapat membuat program sederhana dikompilasi dengan cara ini, begitu Anda ingin menggunakan library standar C atau bahkan mengompilasi beberapa file, Anda mungkin akan mengalami masalah. Hal ini membawa saya pada pelajaran penting yang saya pelajari:
Meskipun dulu Emscripten menggunakan compiler C-ke-asm.js, kini Emscripten telah berkembang untuk menargetkan Wasm dan dalam proses beralih ke backend LLVM resmi secara internal. Emscripten juga menyediakan implementasi library standar C yang kompatibel dengan Wasm. Gunakan Emscripten. Hal ini melakukan banyak pekerjaan tersembunyi, meniru sistem file, menyediakan pengelolaan memori, membungkus OpenGL dengan WebGL — banyak hal yang sebenarnya tidak perlu Anda alami sendiri saat mengembangkan aplikasi.
Meskipun mungkin terdengar seperti Anda harus mengkhawatirkan bloat — saya tentu mengkhawatirkannya — compiler Emscripten menghapus semua yang tidak diperlukan. Dalam eksperimen saya, modul Wasm yang dihasilkan berukuran sesuai untuk logika yang dikandungnya dan tim Emscripten serta WebAssembly sedang berupaya membuatnya lebih kecil lagi di masa mendatang.
Anda bisa mendapatkan Emscripten dengan mengikuti petunjuk di situsnya atau menggunakan Homebrew. Jika Anda menyukai perintah yang di-Docker seperti saya dan tidak ingin menginstal apa pun di sistem Anda hanya untuk mencoba WebAssembly, ada image Docker yang dikelola dengan baik 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 hampir kanonis tentang 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 memahami C, fungsi itu sendiri tidak akan terlalu mengejutkan. Meskipun Anda tidak tahu C, tetapi tahu JavaScript, semoga Anda dapat memahami apa yang terjadi di sini.
emscripten.h
adalah file header yang disediakan oleh Emscripten. Kita hanya memerlukannya agar kita
memiliki akses ke makro EMSCRIPTEN_KEEPALIVE
, tetapi
makro ini menyediakan lebih banyak fungsi.
Makro ini memberi tahu compiler untuk tidak menghapus fungsi meskipun tampaknya tidak digunakan. Jika kita menghilangkan makro tersebut, compiler akan mengoptimalkan fungsi tersebut
— karena tidak ada yang menggunakannya.
Mari kita simpan semuanya dalam file bernama fib.c
. Untuk mengubahnya menjadi file .wasm
, kita perlu menggunakan 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 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 di file JavaScript — informasi selengkapnya tentang fungsi ini
akan dibahas 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 lebih besar karena compiler 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 "modul") berisi kode C yang dikompilasi dan ukurannya harus cukup kecil. File
JavaScript menangani pemuatan dan inisialisasi modul Wasm serta
menyediakan API yang lebih baik. Jika diperlukan, fungsi ini juga akan menangani penyiapan
stack, heap, dan fungsi lain yang biasanya diharapkan disediakan oleh
sistem operasi saat menulis kode C. Oleh karena itu, file JavaScript ini sedikit
lebih besar, yaitu 19 KB (~5 KB setelah di-gzip).
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 Anda gunakan. Gunakan
cwrap
untuk membuat fungsi native JavaScript yang menangani konversi parameter
menjadi sesuatu yang kompatibel dengan C dan memanggil fungsi yang di-wrap. cwrap
menggunakan
nama fungsi, jenis 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 bilangan Fibonacci ke-12.
Tujuan utama: Mengompilasi library C
Hingga saat ini, kode C yang telah kita tulis ditulis dengan mempertimbangkan Wasm. Namun, kasus penggunaan inti untuk WebAssembly adalah memanfaatkan 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-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. Kode sumber codec WebP ditulis dalam C dan tersedia di GitHub serta beberapa dokumentasi API yang ekstensif. Itu adalah titik awal yang cukup baik.
$ 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 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 perlu memberi tahu compiler tempat ia dapat menemukan file header libwebp menggunakan tanda -I
dan juga meneruskan semua file C libwebp yang diperlukan. Terus terang, saya hanya memberikan semua file C yang bisa saya temukan dan mengandalkan compiler untuk menghapus semua yang tidak perlu. Tampaknya berhasil 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 cemerlang:
<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:
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 Wasm?
Melihat
encoding API 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, "hanya" masalah menyalin data dari JavaScript ke Wasm. Untuk itu, kita perlu mengekspos dua fungsi tambahan. Satu yang mengalokasikan memori untuk gambar di dalam Wasm land dan satu yang membebaskannya 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 dari
buffer tersebut. Saat pointer dikembalikan ke JavaScript, pointer 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: Encode gambar
Gambar kini tersedia di Wasm. Saatnya memanggil encoder WebP untuk
melakukan tugasnya. Melihat
dokumentasi WebP, WebPEncodeRGBA
tampaknya sangat cocok. Fungsi ini mengambil pointer ke gambar input dan
dimensinya, serta opsi kualitas antara 0 dan 100. Kode ini juga mengalokasikan
buffer output untuk kita, yang perlu kita bebaskan menggunakan WebPFree()
setelah kita
selesai menggunakan gambar WebP.
Hasil operasi encoding adalah buffer 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 yang bersih (faktanya, ini bergantung pada fakta bahwa pointer Wasm memiliki lebar 32 bit), tetapi agar tetap sederhana, saya rasa 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];
}
Setelah semuanya tersedia, kita dapat memanggil fungsi encoding, mengambil penunjuk dan ukuran gambar, memasukkannya ke dalam buffer JavaScript kita sendiri, dan melepaskan semua buffer Wasm yang telah kita alokasikan dalam proses tersebut.
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 memperbesar memori yang cukup untuk mengakomodasi gambar input dan output:
Untungnya, solusi untuk masalah ini ada di pesan error. Kita hanya perlu
menambahkan -s ALLOW_MEMORY_GROWTH=1
ke perintah kompilasi.
Selesai. Kami mengompilasi encoder WebP dan mentranskode gambar JPEG ke WebP. Untuk membuktikan bahwa perintah tersebut berfungsi, 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, keindahan gambar WebP baru.
Kesimpulan
Membuat library C berfungsi di browser bukanlah hal yang mudah, tetapi setelah Anda memahami keseluruhan proses dan cara kerja alur data, hal ini akan menjadi lebih mudah dan hasilnya bisa sangat mengagumkan.
WebAssembly membuka banyak kemungkinan baru di web untuk pemrosesan, pemrosesan angka, dan game. Perlu diingat bahwa Wasm bukanlah solusi ajaib yang harus diterapkan pada semuanya, tetapi saat Anda mengalami salah satu hambatan tersebut, Wasm dapat menjadi alat yang sangat berguna.
Konten bonus: Melakukan sesuatu yang sederhana dengan cara yang sulit
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 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. Menurut konvensi, modul yang dikompilasi oleh Emscripten mengharapkan beberapa hal dari lingkungan JavaScript pemuatan:
- Pertama, ada
env.memory
. Modul Wasm tidak mengetahui dunia luar, jadi ia perlu mendapatkan beberapa memori untuk digunakan. MasukkanWebAssembly.Memory
. Bagian ini merepresentasikan bagian memori linear (yang dapat bertambah secara opsional). Parameter pengukuran ada di "dalam satuan halaman WebAssembly", yang berarti kode di atas mengalokasikan 1 halaman memori, dengan setiap halaman berukuran 64 KiB. Tanpa memberikan opsimaximum
, memori secara teoretis tidak terbatas dalam pertumbuhan (Chrome saat ini memiliki batas tetap 2 GB). Sebagian besar modul WebAssembly tidak perlu menetapkan maksimum. env.STACKTOP
menentukan tempat tumpukan seharusnya mulai bertambah. Stack diperlukan untuk melakukan panggilan fungsi dan mengalokasikan memori untuk variabel lokal. Karena kita tidak melakukan trik manajemen memori dinamis apa pun dalam program Fibonacci kecil kita, kita dapat menggunakan seluruh memori sebagai stack, sehinggaSTACKTOP = 0
.