Wzorce wydajności WebAssembly dla aplikacji internetowych

Ten przewodnik jest przeznaczony dla twórców stron internetowych, którzy chcą skorzystać z rozwiązań WebAssembly. W tym przewodniku dowiesz się, jak za pomocą własnego przykładu wykorzystać Wasm do zlecania zadań wymagających dużej mocy procesora. 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 rozwija tę metodę iteracyjnie i wprowadza po jednym wzorca wydajności, aż do zaproponowania 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. Zadanie obciążające CPU, które przedstawiamy na przykładzie w tym przewodniku, oblicza 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 obciążającego procesor 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 przedstawiona 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 załóżmy, że istnieje moduł Wasm oparty na skompilowaniu tej funkcji factorial() z użyciem Emscripten w pliku factorial.wasm z wykorzystaniem wszystkich sprawdzonych metod optymalizacji kodu. Aby przypomnieć sobie, jak to zrobić, przeczytaj artykuł Wywoływanie skompilowanych funkcji C z JavaScript za pomocą ccall/cwrap. Za pomocą poniższego polecenia skompilowano factorial.wasm jako samodzielną usługę Wasm.

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 są tworzone w JavaScript 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, że Twoja aplikacja internetowa zależy od modułu Wasm do zadania wymagającego dużej mocy procesora, musisz jak najszybciej wczytać 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 i utwórz instancję modułu Wasm. 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 i tworzy instancję modułu Wasm bezpośrednio z przesyłanego strumieniowo źródła podstawowego, takiego jak fetch(). Nie jest potrzebna funkcja await. Jest to najwydajniejszy i najbardziej 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 Worker

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 przeniesienie takich zadań do WebWorkera.

Zmiana struktury wątku głównego

Aby przenieść zadanie intensywnie obciążające procesor do instancji internetowej, najpierw zmień 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) });
});

Błąd: zadanie działa w Web Worker, ale kod jest niedozwolony

Skrypt Web Worker tworzy instancję modułu Wasm i po otrzymaniu wiadomości wykonuje zadanie obciążające procesor i wysył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 przeznaczony dla dorosłych. 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) });
});

Lepsza: zadania są uruchamiane w narzędziu Web Worker, ale mogą powodować nadmiarowe wczytywanie i kompilację

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, ale przechowywanie jej w zmiennej, powoduje, że program od razu przechodzi do części kodu związanej z odbiorem zdarzenia, a żadne wiadomość z głównego wątku nie zostanie utracona. W detektorze zdarzeń możesz wtedy oczekiwać na obietnicę.

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

Dobra: zadanie uruchamia się w narzędziu Web Worker, a wczytuje i kompiluje tylko raz

Wynik statycznej metody WebAssembly.compileStreaming() to obietnica, która zwraca wartość WebAssembly.Module. Zaletą tego obiektu jest to, że można go przenieść za pomocą postMessage(). Oznacza to, że moduł Wasm może zostać załadowany 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 instancji internetowej pozostaje tylko wyodrębnienie obiektu WebAssembly.Module i utworzenie jego instancji. 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 przechowywany 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 });
});

Idealnie: zadanie uruchamia się we wbudowanej instancji Web Worker, a wczytuje i kompiluje 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 poprawę wydajności jest wstawienie kodu Web Worker 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 tworzenie Web Workera częściej, na przykład wtedy, gdy aplikacja jest nieaktywna lub nawet w ramach procesu uruchamiania aplikacji. Dlatego przełóż kod tworzący 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 pozostawienie rozszerzenia Web Worker na stałe w pobliżu może zwiększyć wykorzystanie pamięci przez aplikację i utrudnić obsługę równoczesnych zadań, ponieważ w jakiś sposób trzeba będzie zmapować wyniki pochodzące z tego procesu 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.

Do tej pory przykładowe fragmenty kodu pozostawały w pobliżu jeden stały element Web Worker. Poniższy przykład kodu tworzy nowy Web Worker ad hoc w razie potrzeby. Pamiętaj, że musisz samodzielnie zakończyć działanie Web Worker. (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

Do wyboru masz 2 wersje demonstracyjne. 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. Karta Sieć pokazuje żądania adresów URL(blob:). W tym przykładzie różnica czasu między doraźnym a stałym czasem jest około 3 razy większa. W praktyce oba te elementy są nie do odróżnienia. Wyniki w Twojej aplikacji będą się prawdopodobnie różnić.

Aplikacja demonstracyjna Factorial Wasm z doraźną instancją roboczą. 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 Factorial Wasm z pracownikiem stałym. 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 zalecamy stosowanie metod strumieniowych (WebAssembly.compileStreaming()WebAssembly.instantiateStreaming()) zamiast ich odpowiedników niestrumieniowych (WebAssembly.compile()WebAssembly.instantiate()).
  • Jeśli możesz, zleć wykonanie zadań wymagających dużej wydajności w narzędziu Web Worker i przeprowadź ładowanie i kompilację Wasm tylko raz poza tą maszyną. 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 wczytanie i skompilowanie 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.
  • Dobrze zastanów się nad istnieniem jednego stałego klienta internetowego, czy też tworzeniem doraźnych procesów internetowych, gdy tylko zajdzie taka potrzeba. Zastanów się też, kiedy jest dobry moment na utworzenie instancji Web Worker. 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 Beauforta i Rachel Andrew.