Dalam panduan ini, yang ditujukan untuk pengembang web yang ingin mendapatkan manfaat dari WebAssembly, Anda akan mempelajari cara memanfaatkan Wasm untuk melakukan {i>outsource<i} tugas yang intensif CPU dengan bantuan contoh yang berjalan. Panduan ini mencakup segala sesuatu mulai dari praktik terbaik untuk memuat modul Wasm untuk mengoptimalkan kompilasi dan pembuatan instance-nya. Ini membahas lebih lanjut pengalihan tugas yang menggunakan CPU ke Pekerja Web dan mengamati keputusan implementasi yang akan Anda hadapi, seperti kapan membuat Worker dan apakah akan membuatnya aktif secara permanen atau mengaktifkannya saat diperlukan. Tujuan secara iteratif mengembangkan pendekatan dan memperkenalkan satu pola performa hingga Anda menyarankan solusi terbaik untuk masalah tersebut.
Asumsi
Asumsikan Anda memiliki tugas yang menggunakan CPU secara intensif dan Anda ingin mengalihdayakan
WebAssembly (Wasm) untuk performa yang mendekati native. Tugas yang menggunakan CPU secara intensif
akan digunakan sebagai contoh dalam panduan ini untuk menghitung faktorial sebuah angka. Tujuan
faktorial adalah hasil kali bilangan bulat dan semua bilangan bulat di bawahnya. Sebagai
contoh, faktorial empat (ditulis sebagai 4!
) sama dengan 24
(yaitu,
4 * 3 * 2 * 1
). Angka-angkanya menjadi besar dengan cepat. Misalnya, 16!
adalah
2,004,189,184
. Contoh yang lebih realistis dari
tugas yang menggunakan CPU secara intensif bisa
memindai kode batang atau
melacak gambar raster.
Implementasi iteratif berperforma tinggi (bukan rekursif) dari 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;
}
}
Di sepanjang artikel ini, anggaplah ada modul Wasm berdasarkan kompilasi
fungsi factorial()
ini dengan Emscripten dalam file bernama factorial.wasm
menggunakan semua
praktik terbaik pengoptimalan kode.
Untuk mengingat kembali
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 dipasangkan dengan output
dan pengirim
button
. Elemen-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()
Compute Engine API. Seperti yang Anda ketahui bahwa aplikasi web bergantung pada modul Wasm untuk
tugas intensif CPU, Anda harus melakukan pramuat file Wasm sedini mungkin. Anda
lakukan ini dengan
Pengambilan yang diaktifkan CORS
di bagian <head>
pada aplikasi Anda.
<link rel="preload" as="fetch" href="factorial.wasm" crossorigin />
Pada kenyataannya, fetch()
API asinkron dan Anda perlu await
hasil pengujian tersebut.
fetch('factorial.wasm');
Selanjutnya, kompilasi dan buat instance modul Wasm. Ada yang namanya menggoda
fungsi yang disebut
WebAssembly.compile()
(plus
WebAssembly.compileStreaming()
)
dan
WebAssembly.instantiate()
untuk tugas-tugas ini,
tetapi sebaliknya,
WebAssembly.instantiateStreaming()
mengompilasi dan membuat instance modul Wasm langsung dari
sumber yang mendasarinya seperti fetch()
—tidak await
diperlukan. Ini adalah cara yang paling efisien
yang dioptimalkan untuk memuat kode Wasm. Dengan asumsi modul Wasm mengekspor
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));
});
Mengalihkan tugas ke Pekerja Web
Jika Anda menjalankan ini di thread utama, dengan tugas yang benar-benar menggunakan CPU, Anda berisiko memblokir seluruh aplikasi. Praktik yang umum adalah mengalihkan tugas-tugas tersebut ke server web Pekerja.
Mengubah struktur thread utama
Untuk memindahkan tugas yang menggunakan CPU secara intensif ke Web Worker, langkah pertama adalah merestrukturisasi
aplikasi. Thread utama sekarang membuat Worker
, dan selain itu,
hanya berkaitan dengan pengiriman input ke Pekerja Web dan kemudian 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 tidak pantas
Pekerja Web membuat instance modul Wasm dan, setelah menerima pesan,
melakukan tugas yang menggunakan CPU secara intensif dan
mengirimkan hasilnya kembali ke thread utama.
Masalah dengan pendekatan ini adalah membuat instance modul Wasm dengan
WebAssembly.instantiateStreaming()
adalah operasi asinkron. Artinya
kodenya tidak pantas. Dalam kasus terburuk, thread utama akan 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 redundan
Salah satu solusi untuk masalah pembuatan instance modul Wasm asinkron adalah dengan memindahkan pemuatan, kompilasi, dan pembuatan instance modul Wasm ke dalam peristiwa pendengar, tetapi ini berarti pekerjaan ini harus terjadi pada setiap menerima pesan. Dengan {i>caching<i} HTTP dan {i>cache<i} HTTP mampu {i>cache<i} Wasm bytecode yang dikompilasi, ini bukan solusi terburuk, tetapi ada cara yang lebih baik sebelumnya.
Dengan memindahkan kode asinkron ke awal Web Worker dan tidak sebenarnya menunggu promise untuk dipenuhi, tetapi bukan menyimpan promise dalam variabel, program segera berpindah ke bagian pemroses peristiwa kode tertentu, dan tidak ada pesan dari thread utama yang akan hilang. Di dalam acara pemroses, 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: Task berjalan di Web Worker, dan hanya dapat dimuat serta dikompilasi satu kali
Hasil dari
WebAssembly.compileStreaming()
adalah promise yang menyelesaikan
WebAssembly.Module
Salah satu fitur menarik dari objek ini adalah
bahwa ia dapat ditransfer menggunakan
postMessage()
Ini berarti modul Wasm dapat dimuat dan dikompilasi hanya sekali di
thread (atau bahkan Pekerja Web lain yang sepenuhnya terkait dengan pemuatan dan kompilasi),
kemudian ditransfer ke {i>Web Worker<i} yang
bertanggung jawab untuk menjalankan
tugas Anda. 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 WebAssembly.Module
dan buat instance-nya. Karena pesan dengan WebAssembly.Module
tidak
streaming, kode di Web Worker sekarang menggunakan
WebAssembly.instantiate()
bukan varian instantiateStreaming()
dari versi sebelumnya. Instance yang dibuat instance-nya
di-cache dalam suatu variabel, sehingga tugas pembuatan instance hanya perlu dilakukan
pada suatu ketika menjalankan {i>Web Worker<i}.
/* 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, dan hanya dimuat serta dikompilasi satu kali
Bahkan dengan cache HTTP, mendapatkan (idealnya) kode Web Worker yang di-cache dan
yang berpotensi membentur jaringan itu mahal. Trik kinerja yang umum adalah
menjadikan Pekerja Web inline dan memuatnya sebagai URL blob:
. Opsi ini masih memerlukan
modul Wasm yang dikompilasi untuk diteruskan ke Web Worker untuk pembuatan instance, sebagai
konteks Web Worker dan thread utama berbeda, meskipun
berdasarkan 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 yang lambat atau ingin cepat
Sejauh ini, semua contoh kode membuat Pekerja Web dengan lambat sesuai permintaan, yaitu ketika tombol ditekan. Tergantung pada aplikasi Anda, bisa saja masuk akal untuk membuat Web Worker lebih segera, misalnya, ketika aplikasi sedang tidak ada aktivitas atau bahkan bagian dari proses bootstrap aplikasi. Oleh karena itu, pindahkan pembuatan Web Worker kode di luar pemroses peristiwa tombol tersebut.
const worker = new Worker(blobURL);
// Listen for incoming messages and display the result.
worker.addEventListener('message', (e) => {
output.textContent = e.result;
});
Menjaga Web Worker tetap ada atau tidak
Satu pertanyaan yang mungkin Anda tanyakan pada diri sendiri adalah apakah Anda harus menjaga Web Worker secara permanen, atau membuatnya kembali kapan saja Anda membutuhkannya. Kedua pendekatan tersebut adalah serta memiliki kelebihan dan kekurangannya masing-masing. Misalnya, dengan tetap menggunakan Worker secara permanen dapat meningkatkan jejak memori aplikasi dan membuat menangani tugas serentak lebih sulit, karena Anda perlu memetakan hasil yang datang dari Pekerja Web kembali ke permintaan. Di sisi lain, web Anda Kode {i>bootstrap<i} pekerja mungkin agak kompleks, jadi mungkin ada banyak {i>overhead<i} jika Anda membuat yang baru setiap saat. Untungnya, ini adalah sesuatu yang bisa Anda ukur dengan User Timing API.
Contoh kode sejauh ini telah menyimpan satu Web Worker permanen. Hal berikut contoh kode membuat {i>ad hoc <i}Pekerja Web baru bila diperlukan. Perhatikan bahwa Anda memerlukan untuk memantau menghentikan Pekerja Web diri Anda sendiri. (Cuplikan kode melewatkan penanganan error, tetapi jika terjadi error salah, pastikan untuk menghentikan dalam semua kasus, berhasil atau 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
Pekerja Web Tetap
(kode sumber).
Jika Anda membuka Chrome DevTools dan memeriksa Konsol, Anda dapat melihat status
Log Timing API yang mengukur waktu yang dibutuhkan mulai dari klik tombol hingga
hasil yang ditampilkan di layar. Tab Jaringan menampilkan URL blob:
permintaan. Dalam contoh ini, perbedaan waktu antara {i>ad hoc <i}dan
adalah sekitar 3 ×. Dalam praktiknya, di mata manusia, keduanya tidak dapat dibedakan
ini masalahnya atau bukan. Hasil untuk aplikasi kehidupan nyata Anda kemungkinan besar akan bervariasi.
Kesimpulan
Postingan ini membahas beberapa pola performa untuk menangani Wasm.
- Umumnya, pilih metode streaming
(
WebAssembly.compileStreaming()
danWebAssembly.instantiateStreaming()
) dibandingkan partner non-streaming (WebAssembly.compile()
danWebAssembly.instantiate()
). - Jika Anda bisa, lakukan outsourcing untuk tugas berat performa di Web Worker, dan lakukan Wasm
memuat dan mengompilasi pekerjaan hanya
sekali di luar {i>Web Worker<i}. Dengan cara ini,
Web Worker hanya perlu membuat instance modul Wasm yang diterimanya dari
thread tempat terjadinya pemuatan dan kompilasi dengan
WebAssembly.instantiate()
, yang berarti instance dapat di-cache jika Anda menjaga Pekerja Web tetap ada secara permanen. - Ukur dengan cermat apakah perlu untuk menggunakan satu Web Worker permanen selamanya, atau untuk membuat Pekerja Web {i>ad hoc <i}kapan pun mereka butuhkan. Selain itu, berpikir kapan waktu yang tepat untuk membuat {i>Web Worker<i}. Hal-hal yang perlu diperhatikan adalah konsumsi memori, durasi pembuatan instance Web Worker, tetapi juga kerumitan yang mungkin harus menangani permintaan serentak.
Jika Anda mempertimbangkan pola tersebut, Anda berada di jalur yang benar untuk Performa Wasm.
Ucapan terima kasih
Panduan ini telah ditinjau oleh Andreas Haas, Jakob Kummerow, Deepti Gandluri, Alon Zakai, Francis McCabe, François Beaufort, dan Rachel Andrew.