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ń wymagających dużej mocy obliczeniowej procesora na podstawie 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ń obciążających procesor oraz przedstawiamy decyzje dotyczące implementacji, z którymi trzeba się zmierzyć, np. kiedy utworzyć Web Workera i czy utrzymywać go stale czy uruchamiać w razie potrzeby. Przewodnik krok po kroku przedstawia podejście i wprowadza po kolei kolejne wzorce skuteczności, aż do znalezienia najlepszego rozwiązania problemu.
Założenia
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 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 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 procesora, dlatego należy jak najwcześniej 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
uzyskany wynik.
fetch('factorial.wasm');
Następnie skompiluj moduł Wasm i utwórz jego instancję. Do tych zadań dostępne są funkcje o zachęcającej nazwie 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 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 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 przenoszenie takich zadań do WebWorkera.
przeorganizować strukturę 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 nieodpowiedni
Web Worker tworzy instancję modułu Wasm i po otrzymaniu wiadomości wykonuje zadanie wymagające dużej mocy obliczeniowej procesora, a następnie 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 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 asynchronicznej instancjacji modułu Wasm jest przeniesienie wczytywania, kompilacji i instancjowania modułu Wasm do okna odbierającego zdarzenia, 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 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 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 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 buforowaniu HTTP pobieranie (w idealnym przypadku) z 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 leniwego lub wczesnego Web Workera
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;
});
Zachowanie Web Workera
Możesz się zastanawiać, czy zachować Web Workera na stałe, 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ć obsługę równoczesnych zadań, ponieważ musisz w jakiś sposób mapować wyniki pochodzące 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 utrzymują w pamięci 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 ś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ć blob:
żądania adresów URL. W tym przykładzie różnica w czasie między wersją ad hoc a trwałą wynosi około 3 razy. W praktyce oba te tryby są nie do odróżnienia dla ludzkiego oka. Wyniki w Twojej aplikacji będą się prawdopodobnie różnić.
Podsumowanie
W tym poście omówiliśmy kilka wzorów skuteczności związanych z Wasm.
- Zasadniczo zalecamy stosowanie metod strumieniowych (
WebAssembly.compileStreaming()
iWebAssembly.instantiateStreaming()
) zamiast ich odpowiedników niestrumieniowych (WebAssembly.compile()
iWebAssembly.instantiate()
). - Jeśli to możliwe, zleć zadania wymagające dużej mocy obliczeniowej do wykonania przez 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 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. - Dokładnie sprawdź, czy warto utrzymywać jeden stały Web Worker na stałe, czy też tworzyć Web Workery ad hoc, gdy tylko zajdzie taka potrzeba. 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 Beauforta i Rachel Andrew.