Шаблоны производительности WebAssembly для веб-приложений

В этом руководстве, предназначенном для веб-разработчиков, которые хотят получить выгоду от WebAssembly, вы узнаете, как использовать Wasm для передачи на аутсорсинг задач, ресурсоемких ЦП, с помощью работающего примера. Руководство охватывает все: от лучших практик загрузки модулей Wasm до оптимизации их компиляции и создания экземпляров. Далее в нем обсуждается перенос задач, интенсивно использующих ЦП, на веб-работников и рассматриваются решения по реализации, с которыми вы столкнетесь, например, когда создавать веб-работника и следует ли поддерживать его постоянно активным или запускать его при необходимости. Руководство итеративно развивает подход и вводит по одному шаблону производительности, пока не предложит лучшее решение проблемы.

Предположения

Предположим, у вас есть очень ресурсоемкая задача, которую вы хотите передать на аутсорсинг WebAssembly (Wasm), чтобы обеспечить ее производительность, близкую к исходной. Задача с интенсивным использованием ЦП, используемая в качестве примера в этом руководстве, вычисляет факториал числа. Факториал — это произведение целого числа и всех чисел, находящихся ниже него. Например, факториал четырёх (записывается как 4! ) равен 24 (то есть 4 * 3 * 2 * 1 ). Цифры быстро становятся большими. Например, 16! составляет 2,004,189,184 . Более реалистичным примером задачи с интенсивным использованием ЦП может быть сканирование штрих-кода или отслеживание растрового изображения .

Производительная итеративная (а не рекурсивная) реализация функции factorial() показана в следующем примере кода, написанном на 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;
}

}

В оставшейся части статьи предположим, что существует модуль Wasm, основанный на компиляции этой функции factorial() с помощью Emscripten в файле с именем factorial.wasm с использованием всех передовых методов оптимизации кода . Чтобы узнать, как это сделать, прочитайте Вызов скомпилированных функций C из JavaScript с помощью ccall/cwrap . Следующая команда использовалась для компиляции factorial.wasm как автономного Wasm .

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

В HTML есть form с input , output и button отправки. На эти элементы ссылаются из JavaScript на основе их имен.

<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');

Загрузка, компиляция и создание экземпляра модуля

Прежде чем вы сможете использовать модуль Wasm, вам необходимо его загрузить. В Интернете это происходит через API fetch() . Поскольку вы знаете, что ваше веб-приложение зависит от модуля Wasm для выполнения ресурсоемкой задачи, вам следует предварительно загрузить файл Wasm как можно раньше. Вы делаете это с помощью выборки с поддержкой CORS в разделе <head> вашего приложения.

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

На самом деле API fetch() является асинхронным, и вам нужно await результата.

fetch('factorial.wasm');

Затем скомпилируйте и создайте экземпляр модуля Wasm. Для этих задач существуют функции с заманчивыми именами, называемые WebAssembly.compile() (плюс WebAssembly.compileStreaming() ) и WebAssembly.instantiate() , но вместо этого метод WebAssembly.instantiateStreaming() компилирует и создает экземпляр модуля Wasm непосредственно из потокового файла. базовый источник, такой как fetch()await не требуется. Это наиболее эффективный и оптимизированный способ загрузки кода Wasm. Предполагая, что модуль Wasm экспортирует функцию factorial() , вы можете сразу же использовать ее.

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

Переложите задачу веб-работнику

Если вы выполните это в основном потоке с действительно ресурсоемкими задачами, вы рискуете заблокировать все приложение. Обычной практикой является передача таких задач веб-работнику.

Реструктуризация основного потока

Чтобы перенести задачу, интенсивно использующую ЦП, на веб-воркер, первым шагом является реструктуризация приложения. Основной поток теперь создает Worker и, помимо этого, занимается только отправкой входных данных в Web Worker, а затем получением выходных данных и их отображением.

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

Плохо: задача выполняется в Web Worker, но код нестандартный.

Веб-воркер создает экземпляр модуля Wasm и после получения сообщения выполняет задачу, нагружающую ЦП, и отправляет результат обратно в основной поток. Проблема этого подхода заключается в том, что создание экземпляра модуля Wasm с помощью WebAssembly.instantiateStreaming() является асинхронной операцией. Это означает, что код является нестандартным. В худшем случае основной поток отправляет данные, когда веб-воркер еще не готов, и веб-воркер никогда не получает сообщение.

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

Лучше: задача выполняется в Web Worker, но с возможной избыточной загрузкой и компиляцией.

Одним из способов решения проблемы асинхронного создания экземпляра модуля Wasm является перемещение загрузки, компиляции и создания экземпляра модуля Wasm в прослушиватель событий, но это будет означать, что эту работу придется выполнять с каждым полученным сообщением. Благодаря HTTP-кэшированию и HTTP-кешу, способному кэшировать скомпилированный байт-код Wasm, это не самое худшее решение, но есть лучший способ.

Перемещая асинхронный код в начало Web Worker и не дожидаясь выполнения обещания, а сохраняя его в переменной, программа немедленно переходит к части кода, предназначенной для прослушивания событий, и никаких сообщений от основная нить будет потеряна. Внутри прослушивателя событий можно ожидать обещания.

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

Хорошо: задача запускается в Web Worker, загружается и компилируется только один раз.

Результатом статического метода WebAssembly.compileStreaming() является обещание, которое разрешается в WebAssembly.Module . Одной из приятных особенностей этого объекта является то, что его можно передать с помощью postMessage() . Это означает, что модуль Wasm можно загрузить и скомпилировать только один раз в основном потоке (или даже в другом веб-воркере, занимающемся исключительно загрузкой и компиляцией), а затем передать его веб-воркеру, ответственному за задачу, нагружающую ЦП. Следующий код демонстрирует этот поток.

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

На стороне Web Worker все, что остается — это извлечь объект WebAssembly.Module и создать его экземпляр. Поскольку сообщение с WebAssembly.Module не передается в потоковом режиме, код в Web Worker теперь использует WebAssembly.instantiate() а не вариант instantiateStreaming() , как раньше. Созданный экземпляр модуля кэшируется в переменной, поэтому работа по созданию экземпляра должна выполняться только один раз при запуске 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 });
});

Идеально: задача запускается во встроенном веб-воркере, загружается и компилируется только один раз.

Даже при использовании HTTP-кэширования получение (в идеале) кэшированного кода Web Worker и потенциальное подключение к сети обходится дорого. Распространенный прием повышения производительности — встроить Web Worker и загрузить его как URL-адрес blob: :. Для этого по-прежнему требуется, чтобы скомпилированный модуль Wasm был передан Web Worker для создания экземпляра, поскольку контексты Web Worker и основного потока различны, даже если они основаны на одном и том же исходном файле 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,
  });
});

Ленивое или нетерпеливое создание Web Worker

До сих пор все примеры кода лениво запускали Web Worker по требованию, то есть при нажатии кнопки. В зависимости от вашего приложения может иметь смысл создавать веб-работника более активно, например, когда приложение простаивает или даже в рамках процесса начальной загрузки приложения. Поэтому переместите код создания веб-воркера за пределы прослушивателя событий кнопки.

const worker = new Worker(blobURL);

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

Держите веб-работника под рукой или нет

Один вопрос, который вы можете задать себе, заключается в том, следует ли вам постоянно поддерживать Web Worker или воссоздавать его всякий раз, когда он вам понадобится. Оба подхода возможны и имеют свои преимущества и недостатки. Например, постоянное хранение Web Worker может увеличить объем памяти вашего приложения и затруднить выполнение параллельных задач, поскольку вам каким-то образом необходимо сопоставить результаты, поступающие от Web Worker, обратно в запросы. С другой стороны, код начальной загрузки вашего Web Worker может быть довольно сложным, поэтому, если вы каждый раз будете создавать новый код, могут возникнуть большие накладные расходы. К счастью, это можно измерить с помощью API User Timing .

В примерах кода до сих пор оставался один постоянный веб-работник. В следующем примере кода при необходимости создается новый веб-воркер. Обратите внимание, что вам необходимо самостоятельно отслеживать завершение работы Web Worker . (Фрагмент кода пропускает обработку ошибок, но если что-то пойдет не так, обязательно завершите работу во всех случаях, независимо от успеха или неудачи.)

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

Демо

Есть две демо-версии, с которыми вы можете поиграть. Один со специальным веб-воркером ( исходный код ) и один с постоянным веб-воркером ( исходный код ). Если вы откроете Chrome DevTools и проверите консоль, вы увидите журналы User Timing API, которые измеряют время, необходимое от нажатия кнопки до отображаемого результата на экране. На вкладке «Сеть» отображается blob: URL-запросы. В этом примере разница во времени между специальным и постоянным режимом составляет около 3×. На практике для человеческого глаза в этом случае оба неотличимы. Результаты для вашего реального приложения, скорее всего, будут различаться.

Демо-приложение Factorial Wasm со специальным воркером. Инструменты разработчика Chrome открыты. Есть два больших двоичных объекта: URL-запросы на вкладке «Сеть», а в консоли отображаются два времени расчета.

Демо-приложение Factorial Wasm с постоянным Worker. Инструменты разработчика Chrome открыты. Существует только один большой объект: запрос URL-адреса на вкладке «Сеть», а консоль показывает четыре времени расчета.

Выводы

В этом посте были рассмотрены некоторые шаблоны производительности для работы с Wasm.

  • Как правило, отдавайте предпочтение методам потоковой передачи ( WebAssembly.compileStreaming() и WebAssembly.instantiateStreaming() ), а не их непотоковым аналогам ( WebAssembly.compile() и WebAssembly.instantiate() ).
  • Если вы можете, передайте на аутсорсинг задачи, требующие высокой производительности, в Web Worker и выполняйте загрузку и компиляцию Wasm только один раз за пределами Web Worker. Таким образом, веб-воркеру нужно только создать экземпляр модуля Wasm, который он получает из основного потока, в котором произошла загрузка и компиляция, с помощью WebAssembly.instantiate() , что означает, что экземпляр можно кэшировать, если вы постоянно держите веб-воркер.
  • Тщательно определите, имеет ли смысл держать одного постоянного веб-воркера всегда или создавать специальные веб-воркеры всякий раз, когда они необходимы. Также подумайте, когда лучше всего создавать Web Worker. Следует учитывать потребление памяти, продолжительность создания экземпляра Web Worker, а также сложность возможной обработки параллельных запросов.

Если вы примете эти закономерности во внимание, вы будете на правильном пути к оптимальной производительности Wasm.

Благодарности

Рецензентами этого руководства выступили Андреас Хаас , Якоб Куммеров , Депти Гандлури , Алон Закай , Фрэнсис Маккейб , Франсуа Бофорт и Рэйчел Эндрю .