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.

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.

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 Pekerja permanen. Chrome DevTools terbuka. Hanya ada satu blob: permintaan URL di tab Jaringan dan Konsol 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, 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.