В этом руководстве, предназначенном для веб-разработчиков, желающих воспользоваться преимуществами WebAssembly, вы узнаете, как использовать Wasm для аутсорсинга ресурсоёмких задач на примере работы. В руководстве рассматриваются все аспекты, от лучших практик загрузки модулей Wasm до оптимизации их компиляции и создания экземпляров. Далее обсуждается перенос ресурсоёмких задач на веб-воркеры и рассматриваются решения, с которыми вам придётся столкнуться, например, когда создавать веб-воркер и следует ли поддерживать его постоянно активным или запускать по мере необходимости. Руководство последовательно развивает подход и представляет один шаблон производительности за раз, пока не будет предложено наилучшее решение проблемы.
Предположения
Предположим, у вас есть задача, требующая очень большого процессорного времени, которую вы хотите передать на аутсорсинг WebAssembly (Wasm) из-за её производительности, близкой к нативной. Задача, требующая большого процессорного времени, использованная в качестве примера в этом руководстве, вычисляет факториал числа. Факториал — это произведение целого числа и всех целых чисел, меньших его. Например, факториал четырёх (записывается как 4!
) равен 24
(то есть 4 * 3 * 2 * 1
). Числа быстро растут. Например, 16!
— это 2,004,189,184
Более реалистичным примером задачи, требующей большого процессорного времени, может быть сканирование штрихкода или трассировка растрового изображения .
В следующем примере кода, написанном на C++, показана производительная итеративная (а не рекурсивная) реализация функции factorial()
#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. Это можно сделать с помощью функции fetch с поддержкой 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));
});
Переложить задачу на Web Worker
Если вы выполните это в основном потоке, выполняя задачи, действительно требовательные к ресурсам процессора, вы рискуете заблокировать всё приложение. Распространенная практика — переложить такие задачи на Web Worker.
Реструктуризация основного потока
Чтобы перенести ресурсоёмкую задачу на Web Worker, первым шагом является реструктуризация приложения. Основной поток теперь создаёт 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-кэшированию и возможности кэширования скомпилированного байт-кода Wasm, это не худшее решение, но есть и лучший вариант.
Перемещая асинхронный код в начало веб-воркера и не дожидаясь выполнения обещания, а сохраняя его в переменной, программа сразу переходит к прослушивателю событий, и никакие сообщения из основного потока не теряются. В этом случае внутри прослушивателя событий можно ожидать выполнения обещания.
/* 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 можно загрузить и скомпилировать всего один раз в основном потоке (или даже в другом Web Worker, отвечающем исключительно за загрузку и компиляцию), а затем передать Web Worker, отвечающему за ресурсоёмкую задачу. Следующий код демонстрирует этот процесс.
/* 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 и загрузить его как blob:
URL. Это всё равно требует передачи скомпилированного модуля 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 запускался лениво по требованию, то есть при нажатии кнопки. В зависимости от вашего приложения может иметь смысл создавать 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, с запросами. С другой стороны, код начальной загрузки Web Worker может быть довольно сложным, поэтому создание нового Web Worker каждый раз может привести к значительным накладным расходам. К счастью, это можно измерить с помощью API User Timing .
В примерах кода до сих пор использовался один постоянный веб-воркер. Следующий пример кода создаёт новый веб-воркер по мере необходимости. Обратите внимание, что вам необходимо самостоятельно отслеживать завершение работы веб-воркера . (В этом фрагменте кода обработка ошибок пропускается, но в случае возникновения проблем обязательно завершайте работу во всех случаях, независимо от того, успешно или неудачно.)
/* 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,
});
});
Демо-версии
Вы можете поэкспериментировать с двумя демонстрационными примерами. Один с использованием ad hoc Web Worker ( исходный код ), а другой с использованием постоянного Web Worker ( исходный код ). Если открыть Chrome DevTools и проверить консоль, можно увидеть логи API User Timing, которые измеряют время от нажатия кнопки до отображения результата на экране. На вкладке Network отображаются URL-запросы типа blob:
:. В этом примере разница во времени между ad hoc и постоянным Web Worker составляет примерно 3 раза. На практике, для человеческого глаза, в данном случае оба варианта неразличимы. Результаты для вашего реального приложения, скорее всего, будут отличаться.
Выводы
В этой статье рассматриваются некоторые шаблоны производительности при работе с Wasm.
- Как правило, отдавайте предпочтение потоковым методам (
WebAssembly.compileStreaming()
иWebAssembly.instantiateStreaming()
), а не их непотоковым аналогам (WebAssembly.compile()
иWebAssembly.instantiate()
). - Если возможно, передайте ресурсоёмкие задачи в Web Worker, а загрузку и компиляцию Wasm выполняйте только один раз вне Web Worker. Таким образом, Web Worker нужно будет только создать экземпляр модуля Wasm, полученного из основного потока, где происходили загрузка и компиляция, с помощью
WebAssembly.instantiate()
, что означает, что экземпляр можно кэшировать, если Web Worker постоянно используется. - Тщательно продумайте, имеет ли смысл постоянно поддерживать один постоянный Web Worker или создавать Web Worker по мере необходимости. Также подумайте, когда лучше всего создавать Web Worker. Необходимо учитывать потребление памяти, время создания экземпляра Web Worker, а также возможную сложность обработки одновременных запросов.
Если вы примете эти закономерности во внимание, вы будете на правильном пути к оптимальной производительности Wasm.
Благодарности
Рецензентами этого руководства выступили Андреас Хаас , Якоб Куммеров , Депти Гандлури , Алон Закай , Фрэнсис Маккейб , Франсуа Бофор и Рэйчел Эндрю .