Wzorce wydajności WebAssembly dla aplikacji internetowych

Ten przewodnik jest skierowany do twórców stron internetowych, którzy chcą korzystać z platformy WebAssembly, dowiesz się, jak za pomocą Wasm zlecić zadanie wymagające dużej mocy obliczeniowej procesora przykład działającego przykładu. W przewodniku omawiamy wiele zagadnień, od sprawdzonych metod wczytywania modułów Wasm w celu optymalizacji ich kompilacji i instancji. it bardziej szczegółowo omawia przeniesienie zadań intensywnie obciążących procesor na platformę Web Worker decyzji wdrożeniowych, jakie musisz podjąć, np. kiedy utworzyć instancji roboczej oraz tego, czy chcesz ją zachować na stałe, czy włączyć w razie potrzeby. iteracyjnie opracowuje podejście i wprowadza jeden wzorzec wydajności jednocześnie, aż zaproponuje najlepsze rozwiązanie problemu.

Założenia

Załóżmy, że masz zadanie obciążające procesor, które chcesz zlecić firmie zewnętrznej. WebAssembly (Wasm) ze względu na niesamowitą wydajność. Zadanie obciążające procesor użytego w przykładzie w tym przewodniku oblicza silnię liczby. silnia to iloczyn liczby całkowitej i wszystkich znajdujących się pod nią liczb całkowitych. Dla: na przykład silnia z czterech (zapisanych jako 4!) równa się 24 (czyli 4 * 3 * 2 * 1). Liczby szybko rosną. Przykład: 16! to 2,004,189,184 Bardziej realistycznym przykładem zadania obciążającego procesor zeskanowania kodu kreskowego lub śledzenia obrazu rastrowego.

Wydajna iteracyjna (a nie rekurencyjna) implementacja elementu factorial() można zobaczyć w poniższym 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 pozostałej części artykułu załóżmy, że istnieje moduł Wasm oparty na kompilacji tę funkcję factorial() z Emscripten w pliku o nazwie factorial.wasm używając wszystkich sprawdzonych metodach dotyczących optymalizacji kodu. Aby przypomnieć sobie, jak to zrobić, przeczytaj artykuł Wywoływanie skompilowanych funkcji C z JavaScriptu za pomocą wywołania ccall/cwrap Za pomocą poniższego polecenia skompilowano factorial.wasm jako samodzielną firmę Wasm.

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

W kodzie HTML jest: form z input sparowanym z output i przesłaniem button JavaScript odwołuje się do tych elementów 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 będzie można użyć modułu Wasm, musisz go załadować. W przeglądarce dzieje się to przez fetch() API. Wiesz już, że Twoja aplikacja internetowa zależy od modułu Wasm na potrzeby zadanie obciążające procesor, dlatego musisz wstępnie wczytać plik Wasm jak najwcześniej. Ty zrób to za pomocą Pobieranie z włączoną 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 trzeba await wynik.

fetch('factorial.wasm');

Następnie skompiluj i utwórz instancję modułu Wasm. Istnieją kuszące nazwy funkcje o nazwie WebAssembly.compile() (plus WebAssembly.compileStreaming()) oraz WebAssembly.instantiate() tych zadań, ale zamiast tego WebAssembly.instantiateStreaming() metoda kompiluje i tworzy instancję modułu Wasm bezpośrednio ze strumienia bazowego źródła takiego jak fetch()await nie jest potrzebny. Jest to najskuteczniejszy sposób i zoptymalizowany sposób wczytywania kodu Wasm. Przy założeniu, że moduł Wasm eksportuje plik factorial(), możesz jej używać od razu.

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 Web Worker

Jeśli wykonasz to w wątku głównym przy zadaniach intensywnie korzystających z CPU, ryzykujesz zablokowanie całej aplikacji. Typową praktyką jest przeniesienie takich zadań do typu Web Zasób roboczy.

Restrukturyzacja wątku głównego

Aby przenieść zadanie obciążające procesor do instancji Web Worker, pierwszym krokiem jest zmiana struktury aplikacji. Wątek główny tworzy teraz element Worker. Oprócz tego zajmuje się jedynie wysyłaniem danych wejściowych do instancji roboczej sieci Web, a następnie odbieraniem dane wyjściowe i ich wyświetlanie.

/* 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 WebAssembly.instantiateStreaming() to operacja asynchroniczna. Oznacza to, że że kod jest dla dorosłych. W najgorszym przypadku wątek główny wysyła dane, gdy Usługa Web Worker nie jest jeszcze gotowa 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) });
});

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

Jednym z rozwiązań problemu z asynchronicznym wystąpieniem modułu Wasm jest przenieść do zdarzenia wczytywanie modułu Wasm, kompilację i instancję; ale oznaczałoby to, że praca musiałaby następować odebrana wiadomość. Dzięki możliwości buforowania HTTP i HTTP do buforowania skompilowanego kodu bajtowego Wasm, nie jest to najgorsze rozwiązanie, ale istnieje lepsze sposób.

Przenosząc kod asynchroniczny na początek instancji Web Worker, a nie oczekiwanie na spełnienie obietnicy, lecz raczej przechowywanie obietnicy w program natychmiast przechodzi do części detektora zdarzeń w kodzie głównym, tak aby żadna wiadomość z wątku głównego nie została utracona. W środku wydarzenia słuchacza, można więc doczekać się 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 });
});

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

Wynik funkcji statycznej WebAssembly.compileStreaming() jest obietnicą, która kończy się WebAssembly.Module Zaletą tego obiektu jest to, że można go przesyłać za pomocą postMessage() Oznacza to, że moduł Wasm można załadować i skompilować tylko raz w głównym (albo nawet inny pracownik zajmujący się tylko wczytywaniem i kompilacją), a następnie zostać przeniesiony do instancji roboczej odpowiedzialnej za intensywność pracy procesora zadanie. Ten przepływ pokazuje poniższy kod.

/* 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 roboczej pozostało jedynie wyodrębnienie komponentu WebAssembly.Module i utworzyć jego instancję. Ponieważ wiadomość z WebAssembly.Module nie jest nie jest przesyłana strumieniowo, kod instancji Web Worker WebAssembly.instantiate(). zamiast wariantu instantiateStreaming() z poprzedniego. Instancja jest przechowywany w pamięci podręcznej w zmiennej, dlatego należy wykonać tylko po uruchomieniu 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 wbudowanym mechanizmie Web Worker, a wczytuje i kompiluje tylko raz.

Nawet w przypadku buforowania HTTP uzyskanie (najlepiej) zapisanego w pamięci podręcznej kodu instancji Web Worker połączenie z siecią może być kosztowne. Częstą sztuczką w zakresie wydajności jest w stosunku do instancji Web Worker i wczytywać ją jako adres URL blob:. W tym przypadku wymagane jest skompilowany moduł Wasm, który ma zostać przekazany do instancji Web Worker w celu utworzenia instancji, kontekstu instancji Web Worker i wątku głównego są różne, nawet jeśli są 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 roboczej

Jak dotąd wszystkie przykłady kodu uruchamiały Web Worker leniwie na żądanie, po naciśnięciu przycisku. W zależności od aplikacji sensownym rozwiązaniem może być z większym zainteresowaniem tworzy instancje internetowe, np. gdy aplikacja jest bezczynna, lub nawet jako jeden z elementów procesu wczytywania aplikacji. W związku z tym przenieś kreację Web Worker poza detektorem zdarzeń przycisku.

const worker = new Worker(blobURL);

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

Skrypt internetowy nie musi działać

Jedno z pytań, które możesz sobie zadać, brzmi: czy warto zachować aplikację Web Worker przez cały czas lub odtworzyć ją w dowolnym momencie. Oba podejścia oraz mają zalety i wady. Na przykład utworzenie sekcji Web Pracownik na stałe w pobliżu może zwiększyć ilość pamięci zajmowanej przez aplikację i sprawić, radzenia sobie z równoczesnymi zadaniami, bo trzeba w jakiś sposób zmapować wyniki procesów Web Worker w odpowiedzi na żądania. Z drugiej strony witryna Kod wczytywania instancji roboczej może być dość złożony, więc jeśli za każdym razem będziesz tworzyć nowe. Na szczęście możesz to zrobić mierz za pomocą User Timing API.

Do tej pory przykładowe fragmenty kodu pozostawały w pobliżu jeden stały element Web Worker. Poniżej przykładowy kod tworzy doraźną nową usługę Web Worker, gdy jest to konieczne. Pamiętaj, że musisz mieć śledzić zakończenie procesu Web Worker siebie. (Fragment kodu pomija obsługę błędów, ale na wypadek, gdyby coś poszło nie tak źle, pamiętaj, aby zakończyć współpracę we wszystkich przypadkach, niezależnie od tego, czy to się uda 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. Jeden z tagiem ad hoc Web Worker (kod źródłowy) i druga z atrybutem stały Web Worker (kod źródłowy). W Narzędziach deweloperskich w Chrome znajdziesz dane w konsoli Dzienniki interfejsu Timing API, które mierzą czas od kliknięcia przycisku do wynik wyświetlony na ekranie. Na karcie Sieć widać adres URL blob: żądań. W tym przykładzie różnica czasu między doraźnymi a stałymi to około 3×. Ogólnie rzecz biorąc, nie da się ich odróżnić tych kwestii. Wyniki Twojej rzeczywistej aplikacji najprawdopodobniej będą się różnić.

Aplikacja demonstracyjna Factorial Wasm z doraźną instancją roboczą. Narzędzia deweloperskie w Chrome są otwarte. Dostępne są 2 obiekty blob: na karcie Sieć żądania adresów URL, a w konsoli widać 2 czasy obliczeń.

Aplikacja demonstracyjna Factorial Wasm z pracownikiem stałym. Narzędzia deweloperskie w Chrome są otwarte. Jest tylko 1 obiekt blob: na karcie Network (Sieć) żądanie adresu URL, a konsola pokazuje 4 czasy obliczeń.

Podsumowanie

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

  • Ogólnie zalecamy korzystanie z metod strumieniowania (WebAssembly.compileStreaming() i WebAssembly.instantiateStreaming()) w porównaniu z ich odpowiednikami niestrumieniowymi (WebAssembly.compile() i WebAssembly.instantiate()).
  • Jeśli możesz, zleć wykonanie zadań wymagających dużej wydajności w narzędziu Web Worker i przeprowadź proces ładowanie i kompilowanie jest możliwe tylko raz poza platformą Web Worker. W ten sposób Web Worker musi tylko utworzyć instancję modułu Wasm, który otrzymuje z głównego w wątku, w którym zaczęło się wczytywanie i kompilacja WebAssembly.instantiate(), co oznacza, że instancję można przechowywać w pamięci podręcznej, jeśli aby Web Worker działał na stałe.
  • Dobrze zastanów się, czy warto zachować jeden stały serwer internetowy. w dowolnej chwili, a także tworzyć doraźne narzędzia internetowe, gdy tylko są potrzebne. Poza tym i zastanowić się, kiedy jest najlepszy moment na utworzenie instancji Web Worker. Co warto wziąć pod uwagę takie jak wykorzystanie pamięci, czas trwania instancji Web Worker, ale także ze złożonością obsługi równoczesnych żądań.

Jeśli weźmiesz pod uwagę te wzorce, jesteś na dobrej drodze do optymalizacji. Skuteczność Wasm.

Podziękowania

Osoba sprawdzająca ten przewodnik Andreas Haas, Jakob Kummerow Deepti Gandluri Alon Zakai, Francis McCabe, François Beaufort Rachel Andrew