Efektywne wczytywanie modułów WebAssembly

Podczas pracy z WebAssembly często trzeba pobrać moduł, skompilować go, utworzyć jego instancję, a potem użyć tego, co eksportuje w JavaScript. W tym poście opisujemy zalecane podejście zapewniające optymalną wydajność.

Podczas pracy z WebAssembly często trzeba pobrać moduł, skompilować go, utworzyć jego instancję, a potem użyć tego, co eksportuje w JavaScript. W tym wpisie zaczynamy od zwykłego, ale nieoptymalnego fragmentu kodu, który wykonuje dokładnie to, o czym mowa. Następnie omawiamy kilka możliwych optymalizacji, a na końcu pokazujemy najprostszy i najskuteczniejszy sposób uruchamiania WebAssembly z JavaScriptu.

Ten fragment kodu wykonuje pełny taniec pobierania, kompilacji i instancjonowania, ale w nieoptymalny sposób:

Nie używaj tego!

(async () => {
  const response = await fetch('fibonacci.wasm');
  const buffer = await response.arrayBuffer();
  const module = new WebAssembly.Module(buffer);
  const instance = new WebAssembly.Instance(module);
  const result = instance.exports.fibonacci(42);
  console.log(result);
})();

Zwróć uwagę, jak używamy funkcji new WebAssembly.Module(buffer) do przekształcania bufora odpowiedzi w moduł. Jest to interfejs API synchroniczny, co oznacza, że blokuje wątek główny do czasu zakończenia. Aby zniechęcić do korzystania z tego mechanizmu, Chrome wyłącza WebAssembly.Module w przypadku buforów większych niż 4 KB. Aby obejść limit rozmiaru, możemy użyć funkcji await WebAssembly.compile(buffer):

(async () => {
  const response = await fetch('fibonacci.wasm');
  const buffer = await response.arrayBuffer();
  const module = await WebAssembly.compile(buffer);
  const instance = new WebAssembly.Instance(module);
  const result = instance.exports.fibonacci(42);
  console.log(result);
})();

await WebAssembly.compile(buffer) nadal nie jest optymalnym rozwiązaniem, ale o tym za chwilę.

Prawie wszystkie operacje w zmodyfikowanym fragmencie kodu są teraz asynchroniczne, co widać po zastosowaniu instrukcji await. Jedynym wyjątkiem jest new WebAssembly.Instance(module), który ma w Chrome te same ograniczenia rozmiaru bufora (4 KB). Ze względu na spójność i aby zachować porządek w głównym wątku, możemy użyć asynchronicznego WebAssembly.instantiate(module).

(async () => {
  const response = await fetch('fibonacci.wasm');
  const buffer = await response.arrayBuffer();
  const module = await WebAssembly.compile(buffer);
  const instance = await WebAssembly.instantiate(module);
  const result = instance.exports.fibonacci(42);
  console.log(result);
})();

Wróćmy do wspomnianej wcześniej optymalizacji compile. Dzięki kompilacji strumieniowej przeglądarka może zacząć kompilować moduł WebAssembly, gdy jeszcze trwa pobieranie bajtów modułu. Ponieważ pobieranie i kompilacja odbywają się równolegle, jest to szybsze – zwłaszcza w przypadku dużych ładunków.

Jeśli czas pobierania jest dłuższy niż czas kompilacji modułu WebAssembly, funkcja WebAssembly.compileStreaming() kończy kompilację niemal natychmiast po pobraniu ostatnich bajtów.

Aby włączyć tę optymalizację, użyj wartości WebAssembly.compileStreaming zamiast WebAssembly.compile. Ta zmiana pozwala nam też pozbyć się pośredniego bufora tablic, ponieważ możemy teraz bezpośrednio przekazywać instancję Response zwracaną przez funkcję await fetch(url).

(async () => {
  const response = await fetch('fibonacci.wasm');
  const module = await WebAssembly.compileStreaming(response);
  const instance = await WebAssembly.instantiate(module);
  const result = instance.exports.fibonacci(42);
  console.log(result);
})();

Interfejs API WebAssembly.compileStreaming akceptuje też obietnicę, która przekształca się w instancję Response. Jeśli nie potrzebujesz funkcji response w innym miejscu w kodzie, możesz przekazać obietnicę zwracaną przez funkcję fetch bezpośrednio, bez jawnego awaitowania jej wyniku:

(async () => {
  const fetchPromise = fetch('fibonacci.wasm');
  const module = await WebAssembly.compileStreaming(fetchPromise);
  const instance = await WebAssembly.instantiate(module);
  const result = instance.exports.fibonacci(42);
  console.log(result);
})();

Jeśli nie potrzebujesz wyniku fetch w żadnym innym miejscu, możesz go przekazać bezpośrednio:

(async () => {
  const module = await WebAssembly.compileStreaming(
    fetch('fibonacci.wasm'));
  const instance = await WebAssembly.instantiate(module);
  const result = instance.exports.fibonacci(42);
  console.log(result);
})();

Osobiście uważam, że lepiej jest umieścić je w osobnym wierszu.

Czy widzisz, jak kompilujemy odpowiedź w module, a potem natychmiast ją instancjuje? Jak się okazuje, WebAssembly.instantiate może skompilować i utworzyć instancję w jednym kroku. WebAssembly.instantiateStreaming API robi to w sposób strumieniowy:

(async () => {
  const fetchPromise = fetch('fibonacci.wasm');
  const { module, instance } = await WebAssembly.instantiateStreaming(fetchPromise);
  // To create a new instance later:
  const otherInstance = await WebAssembly.instantiate(module);
  const result = instance.exports.fibonacci(42);
  console.log(result);
})();

Jeśli potrzebujesz tylko 1 wystąpienia, nie ma sensu utrzymywać obiektu module, ponieważ kod będzie wtedy jeszcze prostszy:

// This is our recommended way of loading WebAssembly.
(async () => {
  const fetchPromise = fetch('fibonacci.wasm');
  const { instance } = await WebAssembly.instantiateStreaming(fetchPromise);
  const result = instance.exports.fibonacci(42);
  console.log(result);
})();

Zoptymalizowaliśmy kampanię w ten sposób:

  • Używanie interfejsów API asynchronicznych, aby uniknąć blokowania wątku głównego
  • Korzystanie z interfejsów API strumieniowych do szybszego kompilowania i tworzenia instancji modułów WebAssembly
  • Nie pisz kodu, którego nie potrzebujesz

Miłej zabawy z WebAssembly