Pola performa WebAssembly untuk aplikasi web

Dalam panduan ini, yang ditujukan bagi developer web yang ingin memanfaatkan WebAssembly, Anda akan mempelajari cara memanfaatkan Wasm untuk melakukan outsourcing pada tugas yang menggunakan CPU secara intensif dengan bantuan contoh yang berjalan. Panduan ini mencakup semuanya, mulai dari praktik terbaik untuk memuat modul Wasm hingga mengoptimalkan kompilasi dan pembuatan instance. Bagian ini lebih lanjut membahas pengalihan tugas yang menggunakan CPU ke Pekerja Web dan melihat keputusan implementasi yang akan Anda hadapi, seperti kapan harus membuat Pekerja Web dan apakah akan tetap menggunakannya secara permanen atau mengaktifkannya saat dibutuhkan. Panduan secara berulang mengembangkan pendekatan dan memperkenalkan pola performa satu per satu, hingga menyarankan solusi terbaik untuk masalah tersebut.

Asumsi

Anggaplah Anda memiliki tugas yang sangat padat CPU dan ingin dialihdayakan ke WebAssembly (Wasm) karena performanya yang mendekati native. Tugas intensif CPU yang digunakan sebagai contoh dalam panduan ini menghitung faktorial sebuah angka. Faktorial adalah hasil kali dari bilangan bulat dan semua bilangan bulat di bawahnya. Misalnya, faktorial empat (ditulis sebagai 4!) sama dengan 24 (yaitu, 4 * 3 * 2 * 1). Angka-angka itu menjadi besar dengan cepat. Misalnya, 16! adalah 2,004,189,184. Contoh yang lebih realistis dari tugas yang menggunakan CPU secara intensif dapat berupa memindai kode batang atau melacak gambar raster.

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

Di HTML, ada form dengan input yang dipasangkan dengan output dan button pengiriman. Elemen-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 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 membebani CPU, Anda harus melakukan pramuat file Wasm sedini mungkin. Anda dapat melakukannya dengan pengambilan yang diaktifkan CORS di bagian <head> pada aplikasi Anda.

<link rel="preload" as="fetch" href="factorial.wasm" crossorigin />

Pada kenyataannya, fetch() API bersifat asinkron dan Anda harus await pada hasilnya.

fetch('factorial.wasm');

Selanjutnya, kompilasi dan buat instance modul Wasm. Ada fungsi bernama menarik yang disebut WebAssembly.compile() (plus WebAssembly.compileStreaming()) dan WebAssembly.instantiate() untuk tugas ini, tetapi, metode WebAssembly.instantiateStreaming() mengompilasi dan membuat instance modul Wasm langsung dari sumber yang di-streaming seperti fetch()—tidak perlu await. Ini adalah cara paling efisien dan optimal untuk memuat kode Wasm. Dengan asumsi bahwa 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 menggunakan banyak CPU, Anda berisiko memblokir seluruh aplikasi. Praktik yang umum adalah mengalihkan tugas tersebut ke Pekerja Web.

Perubahan struktur thread utama

Untuk memindahkan tugas yang menggunakan CPU secara intensif ke Web Worker, langkah pertama adalah menyusun ulang aplikasi. Thread utama sekarang 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 pantas

Web Worker 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() merupakan operasi asinkron. Ini berarti kodenya tidak pantas. Dalam kasus terburuk, thread utama mengirimkan data saat Web Worker belum siap, dan Web Worker tidak pernah menerima pesan tersebut.

/* 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 kemungkinan pemuatan dan kompilasi yang berlebihan

Salah satu solusi untuk masalah pembuatan instance modul Wasm asinkron adalah memindahkan pemuatan, kompilasi, dan pembuatan instance modul Wasm ke pemroses peristiwa. Namun, hal ini berarti bahwa pekerjaan ini harus dilakukan di setiap pesan yang diterima. Dengan caching HTTP dan cache HTTP dapat meng-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 untuk terpenuhi, melainkan 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 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 });
});

Bagus: Tugas berjalan di Web Worker, serta memuat dan mengompilasi hanya sekali

Hasil dari metode WebAssembly.compileStreaming() statis adalah promise yang di-resolve ke WebAssembly.Module. Salah satu fitur menarik dari objek ini adalah objek ini dapat ditransfer menggunakan postMessage(). Artinya, modul Wasm dapat dimuat dan dikompilasi sekali saja di thread utama (atau bahkan Pekerja Web lain yang hanya berkaitan dengan pemuatan dan kompilasi), lalu ditransfer ke Pekerja Web 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, langkah 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 dibuat instance-nya disimpan di cache dalam sebuah variabel sehingga pekerjaan pembuatan instance hanya perlu terjadi sekali setelah menjalankan 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 dimuat serta dikompilasi hanya sekali

Bahkan dengan penyimpanan HTTP ke cache, mendapatkan (idealnya) kode Web Worker dalam cache dan berpotensi mencapai jaringan adalah hal yang mahal. Trik performa yang umum adalah menyejajarkan Pekerja Web dan memuatnya sebagai URL blob:. Hal ini masih mengharuskan 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 Pekerja Web yang lambat atau bersemangat

Sejauh ini, semua contoh kode menjalankan Web Worker dengan lambat sesuai permintaan, yaitu saat tombol ditekan. Bergantung pada aplikasi Anda, sebaiknya buat Web Worker dengan lebih cepat, misalnya, saat aplikasi sedang tidak ada aktivitas atau bahkan sebagai bagian dari proses bootstrap aplikasi. Oleh karena itu, pindahkan kode pembuatan Pekerja Web 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 Pekerja Web atau tidak

Satu pertanyaan yang mungkin Anda tanyakan pada diri sendiri adalah apakah Anda harus mempertahankan Pekerja Web secara permanen, atau membuatnya kembali kapan pun Anda membutuhkannya. Kedua pendekatan ini mungkin dilakukan serta memiliki kelebihan dan kekurangannya. Misalnya, menyimpan Pekerja Web secara permanen dapat meningkatkan jejak memori aplikasi dan mempersulit penanganan tugas serentak, karena Anda perlu memetakan hasil yang berasal dari Pekerja Web kembali ke permintaan. Di sisi lain, kode bootstrapping Pekerja Web Anda mungkin agak rumit, sehingga mungkin akan ada banyak overhead jika Anda membuat kode baru setiap kali diperlukan. Untungnya, ini adalah sesuatu yang dapat Anda ukur dengan User Timing API.

Contoh kode sejauh ini mempertahankan satu Pekerja Web permanen. Contoh kode berikut membuat ad hoc Pekerja Web baru setiap kali diperlukan. Perhatikan bahwa Anda perlu melacak penghentian Pekerja Web sendiri. (Cuplikan kode akan melewati penanganan error, tetapi jika terjadi error, pastikan untuk menghentikan semuanya dalam situasi seperti 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 untuk Anda coba. Satu dengan Pekerja Web ad hoc (kode sumber) dan satu dengan Pekerja Web 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 ke hasil yang ditampilkan di layar. Tab Jaringan menampilkan permintaan URL blob:. Dalam contoh ini, perbedaan waktu antara ad hoc dan permanen sekitar 3×. Dalam praktiknya, bagi mata manusia, keduanya tidak dapat dibedakan dalam kasus ini. Hasil untuk aplikasi Anda yang sebenarnya kemungkinan besar akan bervariasi.

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

Aplikasi demo Faktorial Wasm dengan Pekerja permanen. Chrome DevTools terbuka. Hanya ada satu blob: Permintaan URL di tab Jaringan dan Konsol menampilkan empat 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 Anda bisa, lakukan outsourcing pada tugas berat performa di Web Worker, dan lakukan tugas pemuatan dan kompilasi Wasm hanya sekali di luar Web Worker. Dengan cara ini, Pekerja Web hanya perlu membuat instance modul Wasm yang diterimanya dari thread utama tempat pemuatan dan kompilasi terjadi dengan WebAssembly.instantiate(), yang berarti instance dapat disimpan dalam cache jika Anda mempertahankan Pekerja Web secara permanen.
  • Ukur dengan cermat apakah masuk akal untuk mempertahankan satu Pekerja Web permanen selamanya, atau membuat Pekerja Web ad hoc kapan pun diperlukan. Juga pikirkan kapan waktu terbaik untuk membuat Pekerja Web. Hal yang perlu dipertimbangkan adalah konsumsi memori, durasi pembuatan instance Web Worker, serta kompleksitas terkait kemungkinan penanganan permintaan serentak.

Dengan mempertimbangkan pola-pola ini, Anda sudah berada di jalur yang benar untuk mendapatkan 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.