Wzorce wydajności WebAssembly dla aplikacji internetowych

Z tego przewodnika, przeznaczonego dla programistów stron internetowych, którzy chcą korzystać z WebAssembly, dowiesz się, jak używać Wasm do zlecania zadań wymagających dużej mocy obliczeniowej procesora. Pomoże Ci w tym działający przykład. Przewodnik zawiera wszystkie informacje, od sprawdzonych metod wczytywania modułów Wasm po optymalizację ich kompilacji i tworzenia instancji. Omawia też przenoszenie zadań wymagających dużej mocy obliczeniowej procesora na Web Workery i przygląda się decyzjom dotyczącym implementacji, z którymi się spotkasz, np. kiedy utworzyć Web Workera i czy utrzymywać go na stałe, czy uruchamiać w razie potrzeby. Przewodnik stopniowo rozwija podejście i wprowadza po jednym wzorcu wydajności, aż do zaproponowania najlepszego rozwiązania problemu.

Założenia

Załóżmy, że masz zadanie wymagające dużej mocy obliczeniowej procesora, które chcesz zlecić WebAssembly (Wasm) ze względu na wydajność zbliżoną do natywnej. W tym przewodniku jako przykład zadania wymagającego dużej mocy obliczeniowej procesora używamy obliczania silni liczby. Silnia to iloczyn liczby całkowitej i wszystkich liczb całkowitych mniejszych od niej. Na przykład silnia z 4 (zapisywana jako 4!) jest równa 24 (czyli 4 * 3 * 2 * 1). Liczby szybko rosną. Na przykład 16! to 2,004,189,184. Bardziej realistycznym przykładem zadania wymagającego dużej mocy obliczeniowej procesora może być skanowanie kodu kreskowego lub śledzenie obrazu rastrowego.

W poniższym przykładowym kodzie w C++ pokazano wydajną iteracyjną (a nie rekurencyjną) implementację funkcji factorial().

#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 utworzony na podstawie skompilowania tej factorial() funkcji za pomocą Emscripten w pliku o nazwie factorial.wasm z zastosowaniem wszystkich sprawdzonych metod optymalizacji kodu. Aby przypomnieć sobie, jak to zrobić, przeczytaj artykuł Wywoływanie skompilowanych funkcji C z JavaScriptu za pomocą ccall/cwrap. Do skompilowania pliku factorial.wasm jako samodzielnego modułu Wasm użyto tego polecenia:

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

W HTML-u jest tag form z atrybutem input, który jest połączony z atrybutem output i elementem submitbutton. Elementy te są przywoływane 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 zaczniesz korzystać z modułu Wasm, musisz go załadować. W internecie odbywa się to za pomocą interfejsu API fetch(). Wiesz, że Twoja aplikacja internetowa zależy od modułu Wasm w przypadku zadań wymagających dużej mocy obliczeniowej procesora, dlatego musisz wstępnie wczytać plik Wasm jak najwcześniej. Możesz to zrobić za pomocą pobierania z włączonym CORS w sekcji <head> aplikacji.

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

W rzeczywistości interfejs fetch() API jest asynchroniczny i musisz await wynik.

fetch('factorial.wasm');

Następnie skompiluj i utwórz instancję modułu Wasm. Do tych zadań istnieją kuszące nazwy funkcji: WebAssembly.compile() (oraz WebAssembly.compileStreaming()) i WebAssembly.instantiate(). Zamiast nich metoda WebAssembly.instantiateStreaming() kompiluje i tworzy instancję modułu Wasm bezpośrednio ze strumieniowanego źródła bazowego, takiego jak fetch() – nie jest potrzebny await. Jest to najskuteczniejszy i najbardziej zoptymalizowany sposób wczytywania kodu Wasm. Jeśli 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));
});

Przeniesienie zadania do Web Workera

Jeśli wykonasz to w głównym wątku w przypadku zadań wymagających dużej mocy obliczeniowej, ryzykujesz zablokowanie całej aplikacji. Często stosowanym rozwiązaniem jest przeniesienie takich zadań do Web Workera.

Zmiana struktury wątku głównego

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

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

Źle: zadanie jest wykonywane w skrypcie Web Worker, ale kod jest niebezpieczny

Web Worker tworzy instancję modułu Wasm i po otrzymaniu wiadomości wykonuje zadanie wymagające dużej mocy obliczeniowej procesora, a następnie odsyła wynik do wątku głównego. Problem z tym podejściem polega na tym, że tworzenie instancji modułu Wasm za pomocą funkcji WebAssembly.instantiateStreaming() jest operacją asynchroniczną. Oznacza to, że kod jest podatny na wyścig. W najgorszym przypadku wątek główny wysyła dane, gdy Web Worker nie jest jeszcze gotowy, i 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: zadanie jest wykonywane w usłudze Web Worker, ale z możliwym zbędnym ładowaniem i kompilowaniem

Jednym z rozwiązań problemu z asynchronicznym tworzeniem instancji modułu Wasm jest przeniesienie ładowania, kompilacji i tworzenia instancji modułu Wasm do odbiornika zdarzeń, ale oznaczałoby to, że te działania musiałyby być wykonywane przy każdej odebranej wiadomości. Dzięki buforowaniu HTTP i możliwości buforowania skompilowanego kodu bajtowego Wasm nie jest to najgorsze rozwiązanie, ale istnieje lepszy sposób.

Przenosząc kod asynchroniczny na początek Web Workera i nie czekając na spełnienie obietnicy, ale zapisując ją w zmiennej, program natychmiast przechodzi do części kodu związanej z detektorem zdarzeń i nie traci żadnej wiadomości z głównego wątku. W funkcji nasłuchującej zdarzenia można następnie poczekać 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 });
});

Dobrze: zadanie jest uruchamiane w Web Workerze i wczytywane oraz kompilowane tylko raz.

Wynikiem statycznej metody WebAssembly.compileStreaming() jest obietnica, która jest rozstrzygana jako WebAssembly.Module. Jedną z zalet tego obiektu jest to, że można go przenieść za pomocą postMessage(). Oznacza to, że moduł Wasm można załadować i skompilować tylko raz w głównym wątku (lub nawet w innej instancji roboczej, która zajmuje się wyłącznie ładowaniem i kompilowaniem), a następnie przenieść do instancji roboczej odpowiedzialnej za zadanie wymagające dużej mocy obliczeniowej. Poniższy kod pokazuje ten proces.

/* 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 Workera pozostaje tylko wyodrębnić WebAssembly.Moduleobiekt i go utworzyć. Ponieważ wiadomość z symbolem WebAssembly.Module nie jest przesyłana strumieniowo, kod w Web Workerze używa teraz WebAssembly.instantiate() zamiast wariantu instantiateStreaming(). Utworzony moduł jest zapisywany w pamięci podręcznej w zmiennej, więc proces tworzenia instancji musi nastąpić tylko raz podczas uruchamiania Web Workera.

/* 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 jest wykonywane w wątku Web Worker wbudowanym w stronę i wczytywane oraz kompilowane tylko raz.

Nawet w przypadku buforowania HTTP uzyskanie (najlepiej) kodu Web Workera z pamięci podręcznej i ewentualne połączenie z siecią jest kosztowne. Częstym trikiem zwiększającym wydajność jest wstawienie Web Workera w kodzie i wczytanie go jako adresu URL blob:. Nadal wymaga to przekazania skompilowanego modułu Wasm do instancji roboczej, ponieważ konteksty instancji roboczej i głównego wątku są różne, nawet jeśli są oparte na tym samym źródłowym pliku 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 Workerów w trybie odroczonym lub natychmiastowym

Do tej pory wszystkie przykłady kodu uruchamiały Web Worker leniwie na żądanie, czyli po naciśnięciu przycisku. W zależności od aplikacji może być sens utworzenia Web Workera wcześniej, np. gdy aplikacja jest nieaktywna lub nawet w ramach procesu uruchamiania aplikacji. Dlatego przenieś kod tworzenia Web Workera poza detektor zdarzeń przycisku.

const worker = new Worker(blobURL);

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

Czy Web Worker ma być aktywny

Jedno z pytań, które możesz sobie zadać, to czy należy na stałe zachować Web Worker, czy odtwarzać go za każdym razem, gdy jest potrzebny. Oba podejścia są możliwe i mają swoje zalety i wady. Na przykład stałe utrzymywanie Web Workera może zwiększyć zużycie pamięci przez aplikację i utrudnić obsługę zadań wykonywanych równolegle, ponieważ w jakiś sposób trzeba będzie mapować wyniki pochodzące z Web Workera z powrotem na żądania. Z drugiej strony kod inicjujący Web Workera może być dość złożony, więc tworzenie nowego za każdym razem może wiązać się z dużym narzutem. Na szczęście możesz to zmierzyć za pomocą interfejsu User Timing API.

W dotychczasowych przykładach kodu utrzymywaliśmy jeden stały Web Worker. Poniższy przykład kodu tworzy nowy element Web Worker ad hoc w razie potrzeby. Pamiętaj, że musisz samodzielnie zakończyć działanie Web Workera. (Fragment kodu pomija obsługę błędów, ale w razie problemów należy zakończyć działanie w każdym przypadku, niezależnie od tego, czy operacja się powiodła 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,
  });
});

Przykłady

Możesz wypróbować 2 wersje demonstracyjne. Jeden z pracownikiem internetowym ad hoc (kod źródłowy), a drugi z trwałym pracownikiem internetowym (kod źródłowy). Jeśli otworzysz Narzędzia deweloperskie w Chrome i sprawdzisz konsolę, zobaczysz logi interfejsu User Timing API, które mierzą czas od kliknięcia przycisku do wyświetlenia wyniku na ekranie. Na karcie Sieć wyświetlają się blob:żądania URL. W tym przykładzie różnica w czasie między rozwiązaniem ad hoc a stałym wynosi około 3-krotność. W praktyce w tym przypadku oba rozwiązania są dla ludzkiego oka nieodróżnialne. Wyniki w przypadku Twojej aplikacji w rzeczywistości będą się najprawdopodobniej różnić.

Aplikacja demonstracyjna Factorial Wasm z pracownikiem ad hoc. Narzędzia deweloperskie w Chrome są otwarte. Na karcie Sieć znajdują się 2 żądania dotyczące adresu URL typu blob, a w konsoli wyświetlane są 2 czasy obliczeń.

Aplikacja demonstracyjna Factorial Wasm ze stałym Workerem. Narzędzia deweloperskie w Chrome są otwarte. Na karcie Sieć jest tylko jeden obiekt blob: żądanie URL, a w konsoli widać 4 czasy obliczeń.

Podsumowanie

W tym poście omówiliśmy niektóre wzorce wydajności związane z Wasm.

  • Zasadniczo preferuj metody przesyłania strumieniowego (WebAssembly.compileStreaming()WebAssembly.instantiateStreaming()) od ich odpowiedników bez przesyłania strumieniowego (WebAssembly.compile()WebAssembly.instantiate()).
  • Jeśli to możliwe, zlecaj zadania wymagające dużej mocy obliczeniowej w instancji roboczej, a ładowanie i kompilowanie Wasm wykonuj tylko raz poza instancją roboczą. Dzięki temu Web Worker musi tylko utworzyć instancję modułu Wasm, który otrzymuje z głównego wątku, w którym nastąpiło ładowanie i kompilacja za pomocą WebAssembly.instantiate(). Oznacza to, że instancję można buforować, jeśli Web Worker jest stale aktywny.
  • Dokładnie rozważ, czy warto utrzymywać jednego stałego Web Workera, czy też tworzyć Web Workery ad hoc, gdy są potrzebne. Zastanów się też, kiedy najlepiej utworzyć Web Worker. Należy wziąć pod uwagę zużycie pamięci, czas tworzenia instancji Web Worker, a także złożoność potencjalnej obsługi jednoczesnych żądań.

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

Podziękowania

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