Pola performa WebAssembly untuk aplikasi web

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

Anggap Anda memiliki tugas yang sangat intensif CPU yang ingin Anda outsource ke WebAssembly (Wasm) karena performanya yang mendekati native. Tugas yang menggunakan CPU secara intensif 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 bagian lainnya, anggaplah ada modul Wasm yang didasarkan pada 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, proses ini terjadi melalui fetch() API. Seperti yang diketahui bahwa aplikasi web bergantung pada modul Wasm untuk tugas yang menggunakan CPU secara intensif, Anda harus melakukan pramuat 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 menggunakan CPU secara intensif 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 benar-benar menunggu promise untuk terpenuhi, tetapi menyimpan promise dalam variabel, program akan langsung berpindah ke bagian pemroses peristiwa pada 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 menarik 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 sepenuhnya berkaitan dengan pemuatan dan kompilasi), lalu kemudian ditransfer ke Web Worker yang bertanggung jawab atas tugas yang menggunakan CPU secara intensif. 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 cache 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 bootstrap 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 Pekerja Web Anda mungkin cukup rumit, sehingga mungkin akan ada banyak overhead jika Anda membuat yang baru setiap kali diperlukan. 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 ad hoc baru 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 Network menampilkan blob: permintaan URL. 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 kehidupan nyata Anda kemungkinan besar akan bervariasi.

Aplikasi demo Factorial Wasm dengan Pekerja ad hoc. Chrome DevTools terbuka. Ada dua blob: permintaan URL di tab Jaringan dan Konsol menampilkan dua pengaturan waktu penghitungan.

Aplikasi demo Factorial Wasm dengan Worker permanen. Chrome DevTools terbuka. Hanya ada satu blob: permintaan URL di tab Jaringan dan Konsol akan menampilkan empat pengaturan waktu penghitungan.

Kesimpulan

Postingan ini telah mengeksplorasi beberapa pola performa untuk menangani Wasm.

  • Sebagai aturan umum, pilih metode streaming (WebAssembly.compileStreaming() dan WebAssembly.instantiateStreaming()) daripada metode non-streaming (WebAssembly.compile() dan WebAssembly.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, Pekerja Web 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 Pekerja Web secara permanen.
  • Ukur dengan cermat apakah perlu untuk terus menggunakan satu Web Worker permanen, atau membuat Web Worker ad hoc kapan pun diperlukan. Selain itu, pikirkan kapan waktu terbaik untuk membuat Web Worker. Hal-hal yang perlu dipertimbangkan adalah konsumsi memori, durasi pembuatan instance Pekerja Web, tetapi juga kerumitan yang mungkin harus ditangani dengan 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.