Dalam panduan ini, yang ditujukan bagi developer web yang ingin mendapatkan manfaat dari WebAssembly, Anda akan mempelajari cara menggunakan Wasm untuk melakukan outsourcing tugas yang intensif CPU dengan bantuan contoh yang sedang berjalan. Panduan ini mencakup segala hal, mulai dari praktik terbaik untuk memuat modul Wasm hingga mengoptimalkan kompilasi dan pembuatan instance-nya. Artikel ini lebih lanjut membahas pengalihan tugas yang intensif CPU ke Web Worker dan melihat keputusan penerapan yang akan Anda hadapi seperti kapan harus membuat Web Worker dan apakah akan tetap aktif secara permanen atau mengaktifkannya saat diperlukan. Panduan ini secara iteratif mengembangkan pendekatan dan memperkenalkan satu pola performa satu per satu, hingga menyarankan solusi terbaik untuk masalah tersebut.
Asumsi
Asumsikan Anda memiliki tugas yang sangat intensif CPU yang ingin Anda outsource ke WebAssembly (Wasm) karena performanya yang mendekati native. Tugas yang intensif CPU
yang digunakan sebagai contoh dalam panduan ini menghitung faktorial suatu angka. Faktorial
adalah hasil dari bilangan bulat dan semua bilangan bulat di bawahnya. Misalnya, faktorial empat (ditulis sebagai 4!
) sama dengan 24
(yaitu,
4 * 3 * 2 * 1
). Angkanya akan cepat menjadi besar. Misalnya, 16!
adalah
2,004,189,184
. Contoh yang lebih realistis dari tugas yang menggunakan CPU secara intensif adalah
memindai kode batang atau
melacak gambar raster.
Implementasi iteratif (bukan rekursif) yang berperforma tinggi dari fungsi factorial()
ditampilkan dalam contoh kode berikut yang ditulis dalam C++.
#include <stdint.h>
extern "C" {
// Calculates the factorial of a non-negative integer n.
uint64_t factorial(unsigned int n) {
uint64_t result = 1;
for (unsigned int i = 2; i <= n; ++i) {
result *= i;
}
return result;
}
}
Untuk sisa artikel ini, asumsikan ada modul Wasm berdasarkan kompilasi
fungsi factorial()
ini dengan Emscripten dalam file bernama factorial.wasm
menggunakan semua
praktik terbaik pengoptimalan kode.
Untuk mengulang materi tentang cara melakukannya, baca
Memanggil fungsi C yang dikompilasi dari JavaScript menggunakan ccall/cwrap.
Perintah berikut digunakan untuk mengompilasi factorial.wasm
sebagai
Wasm mandiri.
emcc -O3 factorial.cpp -o factorial.wasm -s WASM_BIGINT -s EXPORTED_FUNCTIONS='["_factorial"]' --no-entry
Dalam HTML, ada form
dengan input
yang disambungkan dengan output
dan button
kirim. Elemen ini direferensikan dari JavaScript berdasarkan namanya.
<form>
<label>The factorial of <input type="text" value="12" /></label> is
<output>479001600</output>.
<button type="submit">Calculate</button>
</form>
const input = document.querySelector('input');
const output = document.querySelector('output');
const button = document.querySelector('button');
Pemuatan, kompilasi, dan pembuatan instance modul
Sebelum dapat menggunakan modul Wasm, Anda harus memuatnya. Di web, hal ini terjadi
melalui
fetch()
API. Seperti yang Anda ketahui bahwa aplikasi web bergantung pada modul Wasm untuk
tugas yang intensif CPU, Anda harus memuat file Wasm sedini mungkin. Anda
melakukannya dengan
pengambilan yang mendukung CORS
di bagian <head>
aplikasi Anda.
<link rel="preload" as="fetch" href="factorial.wasm" crossorigin />
Pada kenyataannya, fetch()
API bersifat asinkron dan Anda perlu await
hasilnya.
fetch('factorial.wasm');
Selanjutnya, kompilasi dan buat instance modul Wasm. Ada fungsi yang dinamai menarik
yang disebut
WebAssembly.compile()
(ditambah
WebAssembly.compileStreaming()
)
dan
WebAssembly.instantiate()
untuk tugas ini, tetapi, sebagai gantinya, metode
WebAssembly.instantiateStreaming()
mengompilasi dan membuat instance modul Wasm langsung dari sumber
dasar yang di-streaming seperti fetch()
—tidak diperlukan await
. Ini adalah cara yang paling efisien
dan dioptimalkan untuk memuat kode Wasm. Dengan asumsi modul Wasm mengekspor fungsi factorial()
, Anda dapat langsung menggunakannya.
const importObject = {};
const resultObject = await WebAssembly.instantiateStreaming(
fetch('factorial.wasm'),
importObject,
);
const factorial = resultObject.instance.exports.factorial;
button.addEventListener('click', (e) => {
e.preventDefault();
output.textContent = factorial(parseInt(input.value, 10));
});
Memindahkan tugas ke Web Worker
Jika Anda menjalankannya di thread utama, dengan tugas yang benar-benar intensif CPU, Anda berisiko memblokir seluruh aplikasi. Praktik umum adalah mengalihkan tugas tersebut ke Web Worker.
Penyusunan ulang thread utama
Untuk memindahkan tugas yang menggunakan CPU secara intensif ke Web Worker, langkah pertama adalah menyusun ulang
aplikasi. Thread utama kini membuat Worker
, dan, selain itu,
hanya menangani pengiriman input ke Web Worker, lalu menerima
output dan menampilkannya.
/* Main thread. */
let worker = null;
// When the button is clicked, submit the input value
// to the Web Worker.
button.addEventListener('click', (e) => {
e.preventDefault();
// Create the Web Worker lazily on-demand.
if (!worker) {
worker = new Worker('worker.js');
// Listen for incoming messages and display the result.
worker.addEventListener('message', (e) => {
output.textContent = e.result;
});
}
worker.postMessage({ integer: parseInt(input.value, 10) });
});
Buruk: Tugas berjalan di Web Worker, tetapi kodenya cepat
Web Worker membuat instance modul Wasm dan, setelah menerima pesan,
melakukan tugas yang intensif CPU dan mengirim hasilnya kembali ke thread utama.
Masalah dengan pendekatan ini adalah membuat instance modul Wasm dengan
WebAssembly.instantiateStreaming()
adalah operasi asinkron. Artinya,
kode tersebut tidak stabil. Dalam kasus terburuk, thread utama mengirim data saat
Web Worker belum siap, dan Web Worker tidak pernah menerima pesan.
/* Worker thread. */
// Instantiate the Wasm module.
// 🚫 This code is racy! If a message comes in while
// the promise is still being awaited, it's lost.
const importObject = {};
const resultObject = await WebAssembly.instantiateStreaming(
fetch('factorial.wasm'),
importObject,
);
const factorial = resultObject.instance.exports.factorial;
// Listen for incoming messages, run the task,
// and post the result.
self.addEventListener('message', (e) => {
const { integer } = e.data;
self.postMessage({ result: factorial(integer) });
});
Lebih baik: Tugas berjalan di Web Worker, tetapi dengan pemuatan dan kompilasi yang mungkin berlebihan
Salah satu solusi untuk masalah pembuatan instance modul Wasm asinkron adalah memindahkan pemuatan, kompilasi, dan pembuatan instance modul Wasm ke pemroses peristiwa, tetapi ini berarti bahwa pekerjaan ini harus dilakukan pada setiap pesan yang diterima. Dengan cache HTTP dan cache HTTP yang dapat meng-cache bytecode Wasm yang dikompilasi, ini bukan solusi terburuk, tetapi ada cara yang lebih baik.
Dengan memindahkan kode asinkron ke awal Web Worker dan tidak sebenarnya menunggu janji terpenuhi, tetapi menyimpan janji dalam variabel, program akan segera beralih ke bagian pemroses peristiwa dari kode, dan tidak ada pesan dari thread utama yang akan hilang. Di dalam pemroses peristiwa, promise kemudian dapat ditunggu.
/* Worker thread. */
const importObject = {};
// Instantiate the Wasm module.
// 🚫 If the `Worker` is spun up frequently, the loading
// compiling, and instantiating work will happen every time.
const wasmPromise = WebAssembly.instantiateStreaming(
fetch('factorial.wasm'),
importObject,
);
// Listen for incoming messages
self.addEventListener('message', async (e) => {
const { integer } = e.data;
const resultObject = await wasmPromise;
const factorial = resultObject.instance.exports.factorial;
const result = factorial(integer);
self.postMessage({ result });
});
Baik: Tugas berjalan di Web Worker, dan dimuat serta dikompilasi hanya sekali
Hasil metode
WebAssembly.compileStreaming()
statis adalah promise yang di-resolve ke
WebAssembly.Module
.
Salah satu fitur bagus dari objek ini adalah dapat ditransfer menggunakan
postMessage()
.
Artinya, modul Wasm dapat dimuat dan dikompilasi hanya sekali di thread utama (atau bahkan Web Worker lain yang hanya menangani pemuatan dan kompilasi), lalu ditransfer ke Web Worker yang bertanggung jawab atas tugas yang intensif CPU. Kode berikut menunjukkan alur ini.
/* Main thread. */
const modulePromise = WebAssembly.compileStreaming(fetch('factorial.wasm'));
let worker = null;
// When the button is clicked, submit the input value
// and the Wasm module to the Web Worker.
button.addEventListener('click', async (e) => {
e.preventDefault();
// Create the Web Worker lazily on-demand.
if (!worker) {
worker = new Worker('worker.js');
// Listen for incoming messages and display the result.
worker.addEventListener('message', (e) => {
output.textContent = e.result;
});
}
worker.postMessage({
integer: parseInt(input.value, 10),
module: await modulePromise,
});
});
Di sisi Web Worker, yang tersisa hanyalah mengekstrak objek WebAssembly.Module
dan membuat instance-nya. Karena pesan dengan WebAssembly.Module
tidak
di-streaming, kode di Web Worker kini menggunakan
WebAssembly.instantiate()
,
bukan varian instantiateStreaming()
dari sebelumnya. Modul yang dibuat instance-nya
di-cache dalam variabel, sehingga pekerjaan pembuatan instance hanya perlu dilakukan
sekali setelah memutar Web Worker.
/* Worker thread. */
let instance = null;
// Listen for incoming messages
self.addEventListener('message', async (e) => {
// Extract the `WebAssembly.Module` from the message.
const { integer, module } = e.data;
const importObject = {};
// Instantiate the Wasm module that came via `postMessage()`.
instance = instance || (await WebAssembly.instantiate(module, importObject));
const factorial = instance.exports.factorial;
const result = factorial(integer);
self.postMessage({ result });
});
Sempurna: Tugas berjalan di Web Worker inline, serta dimuat dan dikompilasi hanya sekali
Meskipun dengan caching HTTP, mendapatkan kode Web Worker yang di-cache (idealnya) dan
berpotensi memukul jaringan akan mahal. Trik performa yang umum adalah
menyisipkan Web Worker dan memuatnya sebagai URL blob:
. Hal ini masih memerlukan
modul Wasm yang dikompilasi untuk diteruskan ke Web Worker untuk pembuatan instance, karena
konteks Web Worker dan thread utama berbeda, meskipun
didasarkan pada file sumber JavaScript yang sama.
/* Main thread. */
const modulePromise = WebAssembly.compileStreaming(fetch('factorial.wasm'));
let worker = null;
const blobURL = URL.createObjectURL(
new Blob(
[
`
let instance = null;
self.addEventListener('message', async (e) => {
// Extract the \`WebAssembly.Module\` from the message.
const {integer, module} = e.data;
const importObject = {};
// Instantiate the Wasm module that came via \`postMessage()\`.
instance = instance || await WebAssembly.instantiate(module, importObject);
const factorial = instance.exports.factorial;
const result = factorial(integer);
self.postMessage({result});
});
`,
],
{ type: 'text/javascript' },
),
);
button.addEventListener('click', async (e) => {
e.preventDefault();
// Create the Web Worker lazily on-demand.
if (!worker) {
worker = new Worker(blobURL);
// Listen for incoming messages and display the result.
worker.addEventListener('message', (e) => {
output.textContent = e.result;
});
}
worker.postMessage({
integer: parseInt(input.value, 10),
module: await modulePromise,
});
});
Pembuatan Web Worker lambat atau cepat
Sejauh ini, semua contoh kode telah membuat Web Worker secara lambat sesuai permintaan, yaitu, saat tombol ditekan. Bergantung pada aplikasi Anda, sebaiknya buat Web Worker dengan lebih cepat, misalnya, saat aplikasi tidak ada aktivitas atau bahkan sebagai bagian dari proses bootstraping aplikasi. Oleh karena itu, pindahkan kode pembuatan Web Worker ke luar pemroses peristiwa tombol.
const worker = new Worker(blobURL);
// Listen for incoming messages and display the result.
worker.addEventListener('message', (e) => {
output.textContent = e.result;
});
Mempertahankan Web Worker atau tidak
Satu pertanyaan yang mungkin Anda tanyakan pada diri sendiri adalah apakah Anda harus menyimpan Web Worker secara permanen, atau membuatnya ulang setiap kali Anda membutuhkannya. Kedua pendekatan tersebut mungkin dilakukan dan memiliki kelebihan serta kekurangan. Misalnya, mempertahankan Web Worker secara permanen dapat meningkatkan jejak memori aplikasi dan mempersulit menangani tugas serentak, karena Anda harus memetakan hasil yang berasal dari Web Worker kembali ke permintaan. Di sisi lain, kode bootstrap Web Worker Anda mungkin agak rumit, sehingga mungkin ada banyak overhead jika Anda membuat yang baru setiap kali. Untungnya, ini adalah sesuatu yang dapat Anda ukur dengan User Timing API.
Contoh kode sejauh ini telah mempertahankan satu Web Worker permanen. Contoh kode berikut membuat Web Worker baru secara ad hoc setiap kali diperlukan. Perhatikan bahwa Anda harus melacak penghentian Web Worker sendiri. (Cuplikan kode melewati penanganan error, tetapi jika terjadi error, pastikan untuk menghentikan dalam semua kasus, baik berhasil maupun gagal.)
/* Main thread. */
let worker = null;
const modulePromise = WebAssembly.compileStreaming(fetch('factorial.wasm'));
const blobURL = URL.createObjectURL(
new Blob(
[
`
// Caching the instance means you can switch between
// throw-away and permanent Web Worker freely.
let instance = null;
self.addEventListener('message', async (e) => {
// Extract the \`WebAssembly.Module\` from the message.
const {integer, module} = e.data;
const importObject = {};
// Instantiate the Wasm module that came via \`postMessage()\`.
instance = instance || await WebAssembly.instantiate(module, importObject);
const factorial = instance.exports.factorial;
const result = factorial(integer);
self.postMessage({result});
});
`,
],
{ type: 'text/javascript' },
),
);
button.addEventListener('click', async (e) => {
e.preventDefault();
// Terminate a potentially running Web Worker.
if (worker) {
worker.terminate();
}
// Create the Web Worker lazily on-demand.
worker = new Worker(blobURL);
worker.addEventListener('message', (e) => {
worker.terminate();
worker = null;
output.textContent = e.data.result;
});
worker.postMessage({
integer: parseInt(input.value, 10),
module: await modulePromise,
});
});
Demo
Ada dua demo yang dapat Anda coba. Satu dengan
Web Worker ad hoc
(kode sumber)
dan satu lagi dengan
Web Worker permanen
(kode sumber).
Jika membuka Chrome DevTools dan memeriksa Konsol, Anda dapat melihat log User
Timing API yang mengukur waktu yang diperlukan dari klik tombol hingga
hasil yang ditampilkan di layar. Tab Jaringan menampilkan permintaan URL blob:
. Dalam contoh ini, perbedaan waktu antara ad hoc dan permanen
adalah sekitar 3×. Dalam praktiknya, bagi mata manusia, keduanya tidak dapat dibedakan dalam
hal ini. Hasil untuk aplikasi dunia nyata Anda kemungkinan besar akan bervariasi.
Kesimpulan
Postingan ini telah mengeksplorasi beberapa pola performa untuk menangani Wasm.
- Sebagai aturan umum, pilih metode streaming
(
WebAssembly.compileStreaming()
danWebAssembly.instantiateStreaming()
) daripada metode non-streaming (WebAssembly.compile()
danWebAssembly.instantiate()
). - Jika memungkinkan, outsource tugas yang berat performanya di Web Worker, dan lakukan pemuatan serta kompilasi
Wasm hanya sekali di luar Web Worker. Dengan cara ini, Web Worker hanya perlu membuat instance modul Wasm yang diterima dari thread
utama tempat pemuatan dan kompilasi terjadi dengan
WebAssembly.instantiate()
, yang berarti instance dapat di-cache jika Anda mempertahankan Web Worker secara permanen. - Ukur dengan cermat apakah sebaiknya Anda mempertahankan satu Web Worker permanen selamanya, atau membuat Web Worker ad hoc setiap kali diperlukan. Selain itu, pikirkan kapan waktu terbaik untuk membuat Web Worker. Hal yang perlu dipertimbangkan adalah konsumsi memori, durasi pembuatan instance Web Worker, tetapi juga kompleksitas kemungkinan harus menangani permintaan serentak.
Jika mempertimbangkan pola ini, Anda berada di jalur yang tepat untuk performa Wasm yang optimal.
Ucapan terima kasih
Panduan ini ditinjau oleh Andreas Haas, Jakob Kummerow, Deepti Gandluri, Alon Zakai, Francis McCabe, François Beaufort, dan Rachel Andrew.