Pola performa WebAssembly untuk aplikasi web

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.

Aplikasi demo Factorial Wasm dengan Worker ad hoc. Chrome DevTools terbuka. Ada dua blob: permintaan URL di tab Jaringan dan Konsol menampilkan dua 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 waktu penghitungan.

Kesimpulan

Postingan ini membahas beberapa pola performa untuk menangani Wasm.

  • Umumnya, pilih metode streaming (WebAssembly.compileStreaming() dan WebAssembly.instantiateStreaming()) dibandingkan partner non-streaming (WebAssembly.compile() dan WebAssembly.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.