Pola performa WebAssembly untuk aplikasi web

Dalam panduan ini, yang ditujukan bagi developer web yang ingin memanfaatkan WebAssembly, Anda akan mempelajari cara menggunakan Wasm untuk melakukan outsourcing tugas yang intensif CPU dengan bantuan contoh yang sedang berjalan. Panduan ini mencakup semuanya, mulai dari praktik terbaik untuk memuat modul Wasm hingga mengoptimalkan kompilasi dan instansiasinya. Bagian ini selanjutnya membahas pengalihan tugas yang intensif CPU ke Web Worker dan mempelajari keputusan penerapan yang akan Anda hadapi, seperti kapan harus membuat Web Worker dan apakah harus membuatnya tetap aktif secara permanen atau menggunakannya saat diperlukan. Panduan ini mengembangkan pendekatan secara iteratif dan memperkenalkan satu pola performa dalam satu waktu, hingga menyarankan solusi terbaik untuk masalah tersebut.

Asumsi

Misalkan Anda memiliki tugas yang sangat intensif CPU yang ingin Anda alih dayakan ke WebAssembly (Wasm) untuk performa yang mendekati native. Tugas intensif CPU yang digunakan sebagai contoh dalam panduan ini menghitung faktorial suatu bilangan. Faktorial adalah hasil perkalian 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 membesar. Misalnya, 16! adalah 2,004,189,184. Contoh tugas yang lebih realistis dan menggunakan CPU secara intensif adalah memindai kode batang atau melakukan penelusuran gambar raster.

Implementasi iteratif (bukan rekursif) fungsi factorial() yang berperforma tinggi 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 artikel lainnya, asumsikan ada modul Wasm berdasarkan kompilasi fungsi factorial() ini dengan Emscripten dalam file bernama factorial.wasm menggunakan semua praktik terbaik pengoptimalan kode. Untuk mempelajari 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

Di HTML, ada form dengan input yang dipasangkan dengan output dan tombol kirim button. Elemen ini dirujuk 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 instansiasi modul

Sebelum dapat menggunakan modul Wasm, Anda harus memuatnya. Di web, hal ini terjadi melalui fetch() API. Karena Anda tahu bahwa aplikasi web Anda bergantung pada modul Wasm untuk tugas yang intensif CPU, Anda harus memuat file Wasm terlebih dahulu 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 dengan menarik yang disebut WebAssembly.compile() (plus WebAssembly.compileStreaming()) dan WebAssembly.instantiate() untuk tugas ini, tetapi, sebagai gantinya, metode WebAssembly.instantiateStreaming() mengompilasi dan membuat instance modul Wasm langsung dari sumber pokok 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));
});

Mengalihkan tugas ke Web Worker

Jika Anda menjalankan ini di thread utama, dengan tugas yang benar-benar intensif CPU, Anda berisiko memblokir seluruh aplikasi. Praktik umum adalah memindahkan tugas tersebut ke Web Worker.

Restrukturisasi 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 kode tidak sinkron

Web Worker membuat instance modul Wasm dan, setelah menerima pesan, melakukan tugas intensif CPU dan mengirimkan hasilnya kembali ke thread utama. Masalah dengan pendekatan ini adalah bahwa membuat instance modul Wasm dengan WebAssembly.instantiateStreaming() adalah operasi asinkron. Artinya, kode tersebut memiliki kondisi persaingan. 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 instansiasi modul Wasm asinkron adalah memindahkan pemuatan, kompilasi, dan instansiasi modul Wasm ke dalam pemroses peristiwa, tetapi ini berarti pekerjaan ini harus dilakukan pada setiap pesan yang diterima. Dengan penyimpanan cache HTTP dan cache HTTP yang dapat menyimpan cache bytecode Wasm yang dikompilasi, ini bukanlah solusi terburuk, tetapi ada cara yang lebih baik.

Dengan memindahkan kode asinkron ke awal Web Worker dan tidak benar-benar menunggu promise terpenuhi, tetapi menyimpan promise 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 diselesaikan ke WebAssembly.Module. Salah satu fitur menarik dari objek ini adalah objek ini dapat ditransfer menggunakan postMessage(). Artinya, modul Wasm dapat dimuat dan dikompilasi hanya sekali di thread utama (atau bahkan Web Worker lain yang hanya berkaitan dengan 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 sekarang menggunakan WebAssembly.instantiate(), bukan varian instantiateStreaming() dari sebelumnya. Modul yang di-instance di-cache dalam variabel, sehingga pekerjaan pembuatan instance hanya perlu dilakukan sekali saat memulai 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, dan memuat serta mengompilasi hanya sekali

Meskipun dengan caching HTTP, mendapatkan kode Web Worker yang (idealnya) di-cache dan berpotensi mengakses jaringan akan memakan biaya. Trik performa yang umum adalah menyisipkan Web Worker dan memuatnya sebagai URL blob:. Hal ini tetap memerlukan modul Wasm yang dikompilasi untuk diteruskan ke Web Worker untuk instansiasi, 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 memutar Web Worker secara lambat sesuai permintaan, yaitu, saat tombol ditekan. Bergantung pada aplikasi Anda, ada baiknya untuk membuat Web Worker lebih awal, misalnya, saat aplikasi tidak ada aktivitas atau bahkan sebagai bagian dari proses bootstrapping aplikasi. Oleh karena itu, pindahkan kode pembuatan Web Worker di 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

Salah satu pertanyaan yang mungkin Anda ajukan adalah apakah Anda harus mempertahankan Web Worker secara permanen, atau membuatnya ulang setiap kali Anda membutuhkannya. Kedua pendekatan tersebut memungkinkan dan memiliki kelebihan dan kekurangan masing-masing. Misalnya, mempertahankan Web Worker secara permanen dapat meningkatkan jejak memori aplikasi Anda dan membuat penanganan tugas serentak menjadi lebih sulit, karena Anda harus memetakan hasil yang berasal dari Web Worker kembali ke permintaan. Di sisi lain, kode bootstrapping Web Worker Anda mungkin cukup kompleks, sehingga ada banyak overhead jika Anda membuat yang baru setiap kali. Untungnya, Anda dapat mengukur hal ini dengan User Timing API.

Contoh kode sejauh ini telah mempertahankan satu Web Worker permanen. Contoh kode berikut membuat Web Worker ad hoc baru jika diperlukan. Perhatikan bahwa Anda harus melacak penghentian Web Worker sendiri. (Cuplikan kode melewati penanganan error, tetapi jika terjadi kesalahan, 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 bisa Anda coba. Satu dengan Web Worker ad hoc (kode sumber) dan satu dengan Web Worker permanen (kode sumber). Jika Anda membuka Chrome DevTools dan memeriksa Konsol, Anda dapat melihat log User Timing API yang mengukur waktu yang diperlukan dari klik tombol hingga hasil 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 kasus ini. Hasil untuk aplikasi kehidupan nyata Anda sendiri kemungkinan besar akan bervariasi.

Aplikasi demo Wasm faktorial dengan Pekerja ad hoc. Chrome DevTools terbuka. Ada dua permintaan URL blob di tab Network dan Konsol menampilkan dua waktu penghitungan.

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

Kesimpulan

Postingan ini telah membahas 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, alihkan tugas yang berat performanya di Web Worker, dan lakukan pemuatan dan kompilasi Wasm hanya sekali di luar Web Worker. Dengan demikian, Web Worker hanya perlu membuat instance modul Wasm yang diterimanya 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 masuk akal untuk mempertahankan satu Web Worker permanen selamanya, atau membuat Web Worker ad hoc setiap kali diperlukan. Selain itu, pertimbangkan waktu terbaik untuk membuat Web Worker. Hal-hal yang perlu dipertimbangkan adalah konsumsi memori, durasi instansiasi Web Worker, tetapi juga kompleksitas yang mungkin harus dihadapi saat menangani permintaan serentak.

Jika Anda 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.