Wzorce wydajności WebAssembly dla aplikacji internetowych

Z tego przewodnika, który jest skierowany do web developerów, którzy chcą korzystać z WebAssembly, dowiesz się, jak używać Wasm do zlecania zadań obciążających procesor za pomocą przykładu. Przewodnik zawiera wszystko, od sprawdzonych metod ładowania modułów Wasm po optymalizację ich kompilacji i instancjonowania. W artykule omawiamy też przenoszenie do Web Workerów zadań wymagających dużej mocy obliczeniowej procesora oraz przedstawiamy decyzje dotyczące implementacji, z którymi się zetkniesz, np. kiedy utworzyć Web Workera i czy ma on być stale aktywny czy uruchamiany w razie potrzeby. Przewodnik krok po kroku przedstawia podejście i wprowadza po kolei kolejne wzorce działania, aż do znalezienia najlepszego rozwiązania problemu.

Załóżmy, że masz zadanie bardzo obciążające procesor, które chcesz zlecić WebAssembly (Wasm), aby uzyskać wydajność zbliżoną do natywnej. W tym przewodniku jako przykład zadania obciążającego procesor podajemy obliczenie silni liczby. Faktorial to iloczyn liczby całkowitej i wszystkich liczb całkowitych poniżej niej. Na przykład czynnikialny 4 (zapisywany jako 4!) jest równy 24 (czyli 4 * 3 * 2 * 1). Liczby szybko stają się duże. Na przykład 16! to 2,004,189,184. Bardziej realistycznym przykładem zadania wymagającego dużej mocy procesora może być skanowanie kodu kreskowego lub śledzenie obrazu rastrowego.

Przykładowa implementacja funkcji factorial(), która jest wydajna i iteracyjna (a nie rekurencyjna), jest pokazana w tym przykładowym kodzie napisanym w 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 dalszej części artykułu zakładamy, że istnieje moduł Wasm oparty na skompilowaniu funkcji factorial() za pomocą Emscripten w pliku o nazwie factorial.wasm, przy użyciu wszystkich sprawdzonych metod optymalizacji kodu. Aby przypomnieć sobie, jak to zrobić, przeczytaj artykuł Wywoływanie skompilowanych funkcji C z JavaScript za pomocą ccall/cwrap. Aby skompilować factorial.wasm jako samodzielny plik Wasm, użyj tego polecenia:

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

W kodzie HTML jest element form z elementem input połączonym z elementem output i elementem przesyłania button. Odwołania do tych elementów w kodzie JavaScript są tworzone na podstawie ich 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;

Zanim użyjesz modułu Wasm, musisz go załadować. W internecie odbywa się to za pomocą interfejsu API fetch(). Jak wiesz, Twoja aplikacja internetowa zależy od modułu Wasm do wykonywania zadań wymagających dużej mocy obliczeniowej. Dlatego należy jak najszybciej przeładować plik Wasm. Możesz to zrobić za pomocą pobierania z obsługą CORS w sekcji <head> aplikacji.

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

W rzeczywistości interfejs API fetch() działa asynchronicznie i musisz await uzyskanego wyniku.

fetch('factorial.wasm');

Następnie skompiluj moduł Wasm i utwórz jego instancję. Do tych zadań dostępne są funkcje o zachęcających nazwach WebAssembly.compile() (oraz WebAssembly.compileStreaming()) i WebAssembly.instantiate(). Zamiast tego metoda WebAssembly.instantiateStreaming() kompiluje instancję modułu Wasm bezpośrednio z przesyłanego strumieniowo źródła podstawowego, takiego jak fetch(). Nie jest potrzebna funkcja await. Jest to najskuteczniejszy i zoptymalizowany sposób wczytywania kodu Wasm. Zakładając, że moduł Wasm eksportuje funkcję factorial(), możesz ją od razu 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));
});

Przeniesienie zadania do Web Workera

Jeśli wykonasz to na głównym wątku, wykonując zadania wymagające dużej mocy obliczeniowej procesora, możesz zablokować całą aplikację. Typową praktyką jest przenoszenie takich zadań do WebWorkera.

Zmiana struktury wątku głównego

Aby przenieść zadanie wymagające dużej mocy procesora do Web Workera, najpierw trzeba zmienić strukturę aplikacji. Główny wątek tworzy teraz Worker, a poza tym zajmuje się tylko wysyłaniem danych wejściowych do Web Workera, a następnie odbieraniem danych wyjściowych i ich wyświetlaniem.

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

Nieprawidłowe: zadanie jest wykonywane w ramach skryptu Web Worker, ale kod jest nieefektywny

Web Worker tworzy instancję modułu Wasm, a po otrzymaniu wiadomości wykonuje zadanie wymagające dużej mocy obliczeniowej procesora i przesyła wynik z powrotem do wątku głównego. Problem z tym podejściem polega na tym, że utworzenie instancji modułu Wasm za pomocą funkcji WebAssembly.instantiateStreaming() jest operacją asynchroniczną. Oznacza to, że kod jest nieprzyzwoity. W najgorszym przypadku wątek główny wysyła dane, gdy Web Worker nie jest jeszcze gotowy, i nigdy nie odbiera 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) });
});

Lepiej: zadanie jest wykonywane w Web Worker, ale z możliwym zbędnym wczytywaniem i kompilowaniem.

Jednym z rozwiązań problemu asynchronicznego tworzenia instancji modułu Wasm jest przeniesienie wczytywania, kompilowania i tworzenia instancji modułu Wasm do odbiornika zdarzeń, ale oznaczałoby to, że te operacje musiałyby być wykonywane w przypadku każdej otrzymanej wiadomości. Buforowanie HTTP i możliwość buforowania skompilowanego kodu bajtowego Wasm w pamięci podręcznej HTTP to nie najgorsze rozwiązanie, ale istnieje lepszy sposób.

Przeniesienie kodu asynchronicznego na początek Web Workera i nie czekanie na spełnienie obietnicy, a zamiast tego przechowywanie jej w zmiennej, powoduje, że program od razu przechodzi do części kodu związanej z odbiorcą zdarzenia, a żadne wiadomość z głównego wątku nie zostanie utracona. W słuchaczu zdarzenia można wtedy oczekiwać obietnicy.

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

Dobre rozwiązanie: zadanie działa w ramach Web Workera i ładuje się oraz kompiluje tylko raz.

Wynik statycznej metody WebAssembly.compileStreaming() to obietnica, która zwraca wartość WebAssembly.Module. Jedną z zalet tego obiektu jest to, że można go przenieść za pomocą postMessage(). Oznacza to, że moduł Wasm może być wczytany i skompilowany tylko raz w głównym wątku (lub nawet w innym Web Workerze, który zajmuje się tylko wczytywaniem i kompilowaniem), a następnie przekazany do Web Workera odpowiedzialnego za zadanie wymagające dużej mocy obliczeniowej. Poniższy kod pokazuje 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,
  });
});

Po stronie Web Worker wystarczy wyodrębnić obiekt WebAssembly.Module i utworzyć jego instancję. Ponieważ wiadomość z wartością WebAssembly.Module nie jest przesyłana, kod w Web Worker używa teraz wartości WebAssembly.instantiate(), a nie wcześniejszej wartości instantiateStreaming(). Wygenerowany moduł jest zapisywany w pamięci podręcznej w zmiennej, więc instancjonowanie musi się odbywać tylko raz, gdy uruchamia się 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 });
});

Idealne rozwiązanie: zadanie jest wykonywane w ramach wbudowanego Web Workera, a załadowanie i skompilowanie odbywa się tylko raz.

Nawet przy wykorzystaniu pamięci podręcznej HTTP pobieranie (w najlepszym przypadku) zapisane w pamięci podręcznej kodu Web Workera i potencjalne sięganie do sieci jest kosztowne. Typowym sposobem na zwiększenie wydajności jest umieszczenie kodu Web Worker w kodze źródłowym i wczytanie go jako adres URL blob:. W tym celu nadal trzeba przekazać skompilowany moduł Wasm do Web Workera, aby utworzyć instancję, ponieważ konteksty Web Workera i głównego wątku 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,
  });
});

Tworzenie Web Workera z opóźnieniem lub natychmiastowo

Do tej pory wszystkie przykłady kodu uruchamiały Web Workera leniwie na żądanie, czyli gdy został naciśnięty przycisk. W zależności od aplikacji może być sensowne częstsze tworzenie Web Workera, na przykład wtedy, gdy aplikacja jest nieaktywna lub nawet w ramach procesu uruchamiania aplikacji. Dlatego przełóż kod tworzenia Web Workera poza kod detektorów zdarzeń przycisku.

const worker = new Worker(blobURL);

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

Czy zachować Web Workera

Możesz się zastanawiać, czy Web Worker powinien być stale dostępny czy też tworzyć go za każdym razem, gdy go potrzebujesz. Oba podejścia są możliwe i mają swoje zalety i wady. Na przykład utrzymywanie Web Workera przez cały czas może zwiększyć zapotrzebowanie aplikacji na pamięć i utrudnić jednoczesne wykonywanie zadań, ponieważ musisz w jakiś sposób mapować wyniki z Web Workera z powrotem na żądania. Z drugiej strony kod inicjujący WebWorker może być dość złożony, więc tworzenie nowego za każdym razem może być bardzo pracochłonne. Na szczęście możesz to mierzyć za pomocą interfejsu User Timing API.

Dotychczasowe przykłady kodu zawierały 1 trwały element Web Worker. Poniższy przykład kodu tworzy nowy Web Worker ad hoc w razie potrzeby. Pamiętaj, że musisz samodzielnie śledzić zakończenie działania Web Workera. (fragment kodu pomija obsługę błędów, ale na wypadek, gdyby coś poszło nie tak, należy zakończyć działanie we wszystkich przypadkach, niezależnie od tego, czy operacja zakończyła się powodzeniem czy nie.)

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

Prezentacje

Dostępne są 2 wersje demo. Jedna z ad hoc Web Worker (kod źródłowy) i jedna z trwałego Web Worker (kod źródłowy). Jeśli otworzysz w Chrome Narzędzia deweloperskie i obejrzysz konsolę, zobaczysz logi interfejsu Timing API, które mierzą czas od kliknięcia przycisku do wyświetlenia wyniku na ekranie. Na karcie Sieć widać żądania do adresu URL blob:. W tym przykładzie różnica w czasie między wersją ad hoc a trwałą wynosi około 3 razy. W praktyce oba te czasy są nie do odróżnienia dla ludzkiego oka. Wyniki w Twojej aplikacji będą się prawdopodobnie różnić.

Aplikacja demonstracyjna Wasm firmy Factorial z niestandardowym Workerem Narzędzia deweloperskie w Chrome są otwarte. Na karcie Sieć są 2 bloby: żądania adresów URL, a na Konsole wyświetlają się 2 czasy obliczeń.

Aplikacja demonstracyjna Wasm firmy Factorial z trwałym wątkiem workera. Narzędzia deweloperskie w Chrome są otwarte. Na karcie Sieć jest tylko jeden blok: żądanie adresu URL, a Konsola pokazuje 4 czasy obliczeń.

Podsumowanie

W tym poście omówiliśmy kilka wzorów skuteczności związanych z Wasm.

  • Zasadniczo preferuj metody strumieniowe (WebAssembly.compileStreaming()WebAssembly.instantiateStreaming()) zamiast ich odpowiedników niestrumieniowych (WebAssembly.compile()WebAssembly.instantiate()).
  • Jeśli to możliwe, zleć zadania wymagające dużej mocy obliczeniowej do wykonania w ramach Web Workera, a wczytywanie i kompilowanie Wasm wykonuj tylko raz poza Web Workerem. W ten sposób Web Worker musi tylko utworzyć instancję modułu Wasm, który otrzymuje z głównego wątku, w którym nastąpiło wczytywanie i kompilowanie za pomocą WebAssembly.instantiate(). Oznacza to, że instancja może zostać zapisana w pamięci podręcznej, jeśli Web Worker jest stale dostępny.
  • Dokładnie sprawdź, czy warto utrzymywać jeden stały Web Worker na stałe, czy też tworzyć Web Workery ad hoc w miarę potrzeby. Zastanów się też, kiedy najlepiej utworzyć Web Workera. Należy wziąć pod uwagę zużycie pamięci, czas trwania instancji Web Worker, a także złożoność obsługi ewentualnych żądań równoczesnych.

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

Podziękowania

Ten przewodnik został sprawdzony przez Andreasa Haasa, Jakoba Kummerowa, Deepti Gandluri, Alona Zakaia, Francisa McCabe’a, Françoisa BeaufortaRachel Andrew.