В этом руководстве, предназначенном для веб-разработчиков, которые хотят получить выгоду от 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×. На практике для человеческого глаза в этом случае оба неразличимы. Результаты для вашего реального приложения, скорее всего, будут различаться.
Выводы
В этом посте были рассмотрены некоторые шаблоны производительности для работы с Wasm.
- Как правило, отдавайте предпочтение методам потоковой передачи (
WebAssembly.compileStreaming()
иWebAssembly.instantiateStreaming()
), а не их непотоковым аналогам (WebAssembly.compile()
иWebAssembly.instantiate()
). - Если есть возможность, передайте на аутсорсинг задачи, требующие высокой производительности, в Web Worker и выполняйте загрузку и компиляцию Wasm только один раз за пределами Web Worker. Таким образом, веб-воркеру нужно только создать экземпляр модуля Wasm, который он получает из основного потока, в котором произошла загрузка и компиляция, с помощью
WebAssembly.instantiate()
, что означает, что экземпляр можно кэшировать, если вы постоянно держите веб-воркер. - Тщательно определите, имеет ли смысл держать одного постоянного веб-воркера всегда или создавать специальные веб-воркеры всякий раз, когда они необходимы. Также подумайте, когда лучше всего создавать Web Worker. Следует учитывать потребление памяти, продолжительность создания экземпляра Web Worker, а также сложность возможной обработки параллельных запросов.
Если вы примете эти закономерности во внимание, вы будете на правильном пути к оптимальной производительности Wasm.
Благодарности
Рецензентами этого руководства выступили Андреас Хаас , Якоб Куммеров , Депти Гандлури , Алон Закай , Фрэнсис Маккейб , Франсуа Бофорт и Рэйчел Эндрю .