Wzorce wydajności WebAssembly dla aplikacji internetowych

Z tego przewodnika dla programistów stron internetowych, którzy chcą korzystać z WebAssembly, dowiesz się, jak wykorzystać Wasm do zlecania zadań obciążających procesor z pomocą działającego przykładu. Przewodnik zawiera wiele informacji – od sprawdzonych metod ładowania modułów Wasm po optymalizację ich kompilacji i instancji. Omawiamy w nim szczegółowo przenoszenie zadań wymagających dużej mocy obliczeniowej procesora do zasobów Web Workers i przyjmowane decyzje dotyczące implementacji, takie jak czas utworzenia instancji roboczej i określenie, czy ma ona pozostać trwale aktywna, czy też w razie potrzeby ją uruchamiać. Przewodnik iteracyjnie rozwija podejście i wprowadza pojedynczy wzorzec wydajności w danym momencie, aż zasugeruje najlepsze rozwiązanie problemu.

Założenia

Załóżmy, że masz zadanie bardzo obciążające procesor, które chcesz zlecić firmie WebAssembly (Wasm) o wydajności zbliżonej do natywnej. Używane na przykład w tym przewodniku zadanie wymagające dużej mocy procesora oblicza silnię liczby. Silnia to iloczyn liczby całkowitej i liczby całkowitych znajdujących się pod nią. Na przykład silnia z czwórki (zapisana jako 4!) jest równa 24 (4 * 3 * 2 * 1). Liczby szybko rosną. Na przykład 16! to 2,004,189,184. Bardziej realistycznym przykładem zadania obciążającego procesor może być skanowanie kodu kreskowego lub śledzenie obrazu rastrowego.

Wydajną iteracyjną (zamiast rekurencyjną) implementację funkcji factorial() znajdziesz na przykładzie poniżej w języku 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;
}

}

W pozostałej części artykułu załóżmy, że występuje moduł Wasm oparty na skompilowaniu tej funkcji factorial() za pomocą Emscripten do pliku factorial.wasm przy użyciu wszystkich sprawdzonych metod optymalizacji kodu. Aby przypomnieć sobie, jak to zrobić, przeczytaj artykuł o wywoływaniu skompilowanych funkcji C z JavaScriptu przy użyciu metody ccall/cwrap. Poniższe polecenie zostało użyte do skompilowania factorial.wasm jako samodzielnego Wasm.

emcc -O3 factorial.cpp -o factorial.wasm -s WASM_BIGINT -s EXPORTED_FUNCTIONS='["_factorial"]'  --no-entry

W kodzie HTML występuje element form z elementem input oraz output i prześlij button. Elementy te są wywoływane z JavaScriptu na podstawie nazw.

<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');

Wczytywanie, kompilowanie i tworzenie instancji modułu

Aby korzystać z modułu Wasm, musisz go załadować. W internecie odbywa się to za pomocą interfejsu API fetch(). Skoro wiesz, że Twoja aplikacja internetowa bazuje na module Wasm, w którym może wykonywać bardzo dużo zadań, musisz jak najwcześniej załadować wstępnie plik Wasm. Użyj do tego pobierania z obsługą CORS w sekcji <head> aplikacji.

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

W rzeczywistości interfejs API fetch() jest asynchroniczny i musisz wykonać await w wyniku.

fetch('factorial.wasm');

Następnie skompiluj i utwórz instancję modułu Wasm. Istnieją kuszące funkcje nazwane WebAssembly.compile() (plus WebAssembly.compileStreaming()) i WebAssembly.instantiate() do obsługi tych zadań, ale zamiast tego WebAssembly.instantiateStreaming() metoda kompiluje i tworzy wystąpienia modułu Wasm bezpośrednio ze źródła strumieniowego, takiego jak fetch() – nie potrzeba await. To najwydajniejszy i najbardziej zoptymalizowany sposób ładowania kodu Wasm. Zakładając, że moduł Wasm eksportuje funkcję factorial(), możesz od razu jej użyć.

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));
});

Przenoszenie zadania do instancji roboczej Web Worker

Jeśli wykonujesz je w wątku głównym, a zadania naprawdę obciążające procesor, ryzykujesz zablokowanie całej aplikacji. Typową praktyką jest przenoszenie takich zadań do instancji Web Worker.

Zmiana struktury wątku głównego

Przeniesienie zadania wymagającego dużej mocy obliczeniowej procesora do instancji Web Worker wymaga najpierw zmiany struktury aplikacji. Wątek główny tworzy teraz Worker, a oprócz tego zajmuje się tylko wysyłaniem danych wejściowych do instancji roboczej Web Worker, a następnie odbieraniem i wyświetlaniem ich wyników.

/* 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) });
});

Błąd: zadania działają w Web Worker, ale kod jest przeznaczony dla dorosłych

Web Worker tworzy instancję modułu Wasm, a po otrzymaniu wiadomości wykonuje zadanie obciążające procesor i wysyła wynik z powrotem do wątku głównego. Problem w tym podejściu polega na tym, że utworzenie instancji modułu Wasm za pomocą funkcji WebAssembly.instantiateStreaming() jest operacją asynchroniczną. Oznacza to, że kod jest dla dorosłych. W najgorszym przypadku, jeśli instancja główna nie jest jeszcze gotowa, wysyła dane, gdy instancja robocza nie jest jeszcze gotowa, a instancja internetowa nigdy nie otrzymuje wiadomości.

/* 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) });
});

Lepsze: wykonywanie zadań w środowisku Web Worker, ale przy możliwym nadmiarowym wczytaniu i kompilacji

Jednym z obejścia problemu asynchronicznego tworzenia instancji modułu Wasm jest przeniesienie wczytywania, kompilowania i tworzenia instancji modułu Wasm do odbiornika. Oznacza to jednak, że ta praca będzie musiała zostać wykonana w przypadku każdej odebranej wiadomości. Dzięki buforowaniu HTTP i pamięci podręcznej HTTP można buforować skompilowany kod bajtowy Wasm. Nie jest to najgorsze rozwiązanie, ale istnieje lepszy sposób.

Dzięki przeniesieniu kodu asynchronicznego na początek instancji roboczej Web Worker i nie czekaniu na realizację obietnicy, tylko zapisując ją w zmiennej, program natychmiast przechodzi do części kodu zawierającej odbiornik i żadna wiadomość z wątku głównego nie jest tracona. W ten sposób możesz oczekiwać funkcji nasłuchiwania zdarzeń.

/* 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 });
});

Dobrze: zadanie działa w środowisku Web Worker, a wczytywanie i kompilowanie odbywa się tylko raz

Wynikiem metody statycznej WebAssembly.compileStreaming() jest obietnica, która prowadzi do zdarzenia WebAssembly.Module. Jedną z zalet tego obiektu jest to, że można go przenosić za pomocą postMessage(). Oznacza to, że moduł Wasm można wczytać i skompilować tylko raz w wątku głównym (lub nawet inny zasób roboczy zajmujący się wyłącznie ładowaniem i kompilacją), a potem można go przesłać do instancji roboczej odpowiedzialnej za zadanie pracochłonne. Poniższy kod obrazuje ten przepływ.

/* 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,
  });
});

W przypadku Web Worker zostaje Ci już tylko wyodrębnienie obiektu WebAssembly.Module i utworzenie jego instancji. Ponieważ wiadomość z elementem WebAssembly.Module nie jest przesyłana strumieniowo, kod w Web Worker używa teraz WebAssembly.instantiate() zamiast wariantu instantiateStreaming() z poprzedniego wariantu. Moduł utworzony na podstawie instancji jest przechowywany w zmiennej, więc tworzenie instancji musi nastąpić tylko raz po włączeniu instancji roboczej.

/* 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 });
});

Doskonale: zadanie działa we wbudowanej platformie Web Worker, a wczytywanie i kompilowanie tylko raz

Nawet w przypadku buforowania HTTP uzyskanie (najlepiej) zapisanego w pamięci podręcznej kodu Web Worker i potencjalne nawiązanie połączenia z siecią jest kosztowne. Typowym trikiem związanym z wydajnością jest wbudowanie zasobu Web Worker i wczytanie go jako adresu URL blob:. Wymaga to przekazania skompilowanego modułu Wasm do instancji Web Worker w celu utworzenia instancji, ponieważ konteksty tej instancji roboczej i wątku głównego są różne, nawet jeśli są oparte na tym samym pliku źródłowym JavaScript.

/* 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,
  });
});

Leniwe lub chętne tworzenie instancji roboczych

Do tej pory wszystkie przykłady kodu powodowały leniwe działanie Web Worker na żądanie, czyli po naciśnięciu przycisku. W zależności od aplikacji lepiej jest utworzyć ją szybciej, na przykład gdy jest ona bezczynna lub nawet w ramach jej procesu wczytywania. Dlatego przenieś kod tworzenia instancji Web Worker poza odbiornik.

const worker = new Worker(blobURL);

// Listen for incoming messages and display the result.
worker.addEventListener('message', (e) => {
  output.textContent = e.result;
});

Dostępność Web Worker

Możesz sobie zadać jedno pytanie: czy lepiej korzystać z instancji Web Worker na stałe, czy też włączać ją w razie potrzeby. Oba podejścia są możliwe i mają swoje zalety i wady. Na przykład trwałe stosowanie zasobów Web Worker może zwiększyć wykorzystanie pamięci aplikacji i utrudniać wykonywanie zadań jednocześnie, ponieważ w jakiś sposób trzeba będzie zmapować wyniki pochodzące z instancji roboczej od razu na żądania. Z drugiej strony kod wczytywania Twojego środowiska WebWorker może być dość skomplikowany, więc tworzenie nowego kodu za każdym razem może być narażone na duże nakłady pracy. Na szczęście możesz to mierzyć za pomocą interfejsu User Timing API.

Do tej pory w przykładowym kodzie została zachowana 1 praca internetowa na stałe. Poniższy przykładowy kod pozwala utworzyć doraźnie nową instancję Web Worker. Pamiętaj, że musisz samodzielnie zamknąć instancję roboczą. Fragment kodu pomija obsługę błędów, ale jeśli coś pójdzie nie tak, pamiętaj, by zawsze go zamknąć (niezależnie od tego, czy się uda).

/* 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,
  });
});

Przykłady

Dostępne są 2 wersje demonstracyjne, w których możesz zagrać. Jedna z postacią ad hoc Web Worker (kod źródłowy) i stałym mechanizmem Web Worker (kod źródłowy). Jeśli otworzysz Narzędzia deweloperskie w Chrome i sprawdzisz konsolę, zobaczysz dzienniki interfejsu UserTiming API, które mierzą czas od kliknięcia przycisku do wyniku wyświetlonego na ekranie. Karta Sieć pokazuje żądania adresu URL(blob:). W tym przykładzie różnica czasu między doraźnym a stałym czasem wynosi około 3 razy. W praktyce dla ludzkich oka widać, że w tym przypadku nie można ich odróżnić. Efekty w przypadku Twojej własnej aplikacji najprawdopodobniej będą się różnić.

Aplikacja demonstracyjna Factorial Wasm z pracownikami doraźnymi. Narzędzia deweloperskie w Chrome są otwarte. Istnieją 2 obiekty blob: żądania adresów URL na karcie Sieć, a konsola pokazuje 2 czasy obliczeń.

Wersja demonstracyjna Factorial Wasm ze stałą instancją roboczą. Narzędzia deweloperskie w Chrome są otwarte. Jest tylko jeden obiekt blob: żądanie adresu URL na karcie Sieć, a konsola pokazuje 4 czasy obliczeń.

Podsumowanie

W tym poście przeanalizowaliśmy kilka wzorców skuteczności w radzeniu sobie z Wasm.

  • Ogólnie preferuj metody strumieniowania (WebAssembly.compileStreaming() i WebAssembly.instantiateStreaming()) zamiast ich niestrumieniowych odpowiedników (WebAssembly.compile() i WebAssembly.instantiate()).
  • Jeśli to możliwe, zleć realizację zadań intensywnie związanych z wydajnością w narzędziu Web Worker, a wczytywanie i kompilowanie Wasm będzie wykonywane tylko raz poza tą instancją. Dzięki temu instancja robocza będzie musiała utworzyć tylko instancję modułu Wasm odbieranego z wątku głównego, w której wczytywanie i kompilacja odbywały się za pomocą funkcji WebAssembly.instantiate(), co oznacza, że instancja może być przechowywana w pamięci podręcznej, jeśli będziesz utrzymywać ją na stałe.
  • Zastanów się, czy warto korzystać z jednego stałego Web Worker na stałe, czy też tworzyć doraźne procesy Web Worker, gdy zajdzie taka potrzeba. Zastanów się też, kiedy jest najlepszy moment na utworzenie instancji roboczej. Warto wziąć pod uwagę zużycie pamięci, czas trwania tworzenia instancji Web Worker, ale też złożoność konieczności obsługi żądań równoczesnych.

Jeśli weźmiesz pod uwagę te wzorce, jesteś na dobrej drodze do optymalnej skuteczności Wasm.

Podziękowania

Ten przewodnik napisali Andreas Haas, Jakob Kummerow, Deepti Gandluri, Alon Zakai, Francis McCabe, François Beaufort oraz Rachel Andrew.