Trong hướng dẫn này, dành cho những nhà phát triển web muốn hưởng lợi từ WebAssembly, bạn sẽ tìm hiểu cách sử dụng Wasm để thuê ngoài các tác vụ sử dụng nhiều CPU với sự trợ giúp của một ví dụ đang chạy. Hướng dẫn này trình bày mọi thứ, từ các phương pháp hay nhất để tải các mô-đun Wasm cho đến việc tối ưu hoá quá trình biên dịch và khởi tạo các mô-đun đó. Phần này thảo luận thêm về việc chuyển các tác vụ sử dụng nhiều CPU sang Web Worker và xem xét các quyết định triển khai mà bạn sẽ phải đối mặt, chẳng hạn như thời điểm tạo Web Worker và có nên giữ cho Web Worker hoạt động vĩnh viễn hay chỉ khởi động khi cần. Hướng dẫn này từng bước phát triển phương pháp và giới thiệu từng mẫu hiệu suất một, cho đến khi đề xuất giải pháp tốt nhất cho vấn đề.
Các giả định
Giả sử bạn có một tác vụ rất tốn CPU mà bạn muốn thuê ngoài cho WebAssembly (Wasm) vì hiệu suất gần như nguyên bản của nó. Tác vụ sử dụng nhiều CPU được dùng làm ví dụ trong hướng dẫn này sẽ tính giai thừa của một số. Giai thừa là tích của một số nguyên và tất cả các số nguyên bên dưới số đó. Ví dụ: giai thừa của 4 (viết là 4!
) bằng 24
(tức là 4 * 3 * 2 * 1
). Các số này tăng lên rất nhanh. Ví dụ: 16!
là 2,004,189,184
. Một ví dụ thực tế hơn về tác vụ sử dụng nhiều CPU có thể là quét mã vạch hoặc truy tìm hình ảnh raster.
Một cách triển khai lặp đi lặp lại (thay vì đệ quy) hiệu quả của hàm factorial()
được minh hoạ trong mẫu mã sau đây được viết bằng 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;
}
}
Trong phần còn lại của bài viết, giả sử có một mô-đun Wasm dựa trên việc biên dịch hàm factorial()
này bằng Emscripten trong một tệp có tên là factorial.wasm
bằng cách sử dụng tất cả các phương pháp hay nhất để tối ưu hoá mã.
Để ôn lại cách thực hiện việc này, hãy đọc bài viết Gọi các hàm C đã biên dịch từ JavaScript bằng ccall/cwrap.
Lệnh sau được dùng để biên dịch factorial.wasm
dưới dạng Wasm độc lập.
emcc -O3 factorial.cpp -o factorial.wasm -s WASM_BIGINT -s EXPORTED_FUNCTIONS='["_factorial"]' --no-entry
Trong HTML, có một form
với input
được ghép nối với output
và một button
gửi. Các phần tử này được tham chiếu từ JavaScript dựa trên tên của chúng.
<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');
Tải, biên dịch và khởi tạo mô-đun
Bạn cần tải một mô-đun Wasm trước khi có thể sử dụng mô-đun đó. Trên web, điều này xảy ra thông qua API fetch()
. Vì biết rằng ứng dụng web của bạn phụ thuộc vào mô-đun Wasm cho tác vụ sử dụng nhiều CPU, nên bạn cần tải trước tệp Wasm càng sớm càng tốt. Bạn thực hiện việc này bằng một lệnh tìm nạp có hỗ trợ CORS trong phần <head>
của ứng dụng.
<link rel="preload" as="fetch" href="factorial.wasm" crossorigin />
Trên thực tế, fetch()
API là không đồng bộ và bạn cần await
kết quả.
fetch('factorial.wasm');
Tiếp theo, hãy biên dịch và tạo thực thể cho mô-đun Wasm. Có các hàm được đặt tên hấp dẫn là WebAssembly.compile()
(cộng với WebAssembly.compileStreaming()
) và WebAssembly.instantiate()
cho các tác vụ này, nhưng thay vào đó, phương thức WebAssembly.instantiateStreaming()
sẽ biên dịch và khởi tạo một mô-đun Wasm trực tiếp từ một nguồn cơ bản được truyền trực tuyến như fetch()
– không cần await
. Đây là cách hiệu quả và tối ưu nhất để tải mã Wasm. Giả sử mô-đun Wasm xuất một hàm factorial()
, thì bạn có thể sử dụng hàm đó ngay lập tức.
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));
});
Chuyển nhiệm vụ sang Web Worker
Nếu thực thi thao tác này trên luồng chính, với các tác vụ thực sự sử dụng nhiều CPU, bạn có nguy cơ chặn toàn bộ ứng dụng. Một phương pháp phổ biến là chuyển các tác vụ như vậy sang một Web Worker.
Tái cấu trúc luồng chính
Để di chuyển tác vụ sử dụng nhiều CPU sang Web Worker, bước đầu tiên là tái cấu trúc ứng dụng. Giờ đây, luồng chính sẽ tạo ra một Worker
và ngoài ra, chỉ xử lý việc gửi dữ liệu đầu vào đến Web Worker, sau đó nhận dữ liệu đầu ra và hiển thị dữ liệu đó.
/* 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) });
});
Không tốt: Tác vụ chạy trong Web Worker, nhưng mã có tính cạnh tranh
Web Worker khởi tạo mô-đun Wasm và khi nhận được thông báo, sẽ thực hiện tác vụ tiêu tốn nhiều CPU và gửi kết quả trở lại luồng chính.
Vấn đề với phương pháp này là việc khởi tạo một mô-đun Wasm bằng WebAssembly.instantiateStreaming()
là một thao tác không đồng bộ. Điều này có nghĩa là mã có tính cạnh tranh. Trong trường hợp xấu nhất, luồng chính sẽ gửi dữ liệu khi Web Worker chưa sẵn sàng và Web Worker không bao giờ nhận được thông báo.
/* 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) });
});
Tốt hơn: Tác vụ chạy trong Web Worker, nhưng có thể tải và biên dịch dư thừa
Một giải pháp cho vấn đề khởi tạo mô-đun Wasm không đồng bộ là di chuyển tất cả quá trình tải, biên dịch và khởi tạo mô-đun Wasm vào trình nghe sự kiện, nhưng điều này có nghĩa là quá trình này sẽ cần diễn ra trên mọi thông báo nhận được. Với tính năng lưu vào bộ nhớ đệm HTTP và bộ nhớ đệm HTTP có thể lưu vào bộ nhớ đệm mã byte Wasm đã biên dịch, đây không phải là giải pháp tệ nhất, nhưng có một cách tốt hơn.
Bằng cách di chuyển mã không đồng bộ đến đầu Web Worker và không thực sự chờ lời hứa hoàn tất, mà thay vào đó lưu trữ lời hứa trong một biến, chương trình sẽ chuyển ngay sang phần trình nghe sự kiện của mã và không có thông báo nào từ luồng chính bị mất. Trong trình nghe sự kiện, bạn có thể chờ promise.
/* 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 });
});
Tốt: Tác vụ chạy trong Web Worker, đồng thời chỉ tải và biên dịch một lần
Kết quả của phương thức tĩnh WebAssembly.compileStreaming()
là một lời hứa sẽ phân giải thành WebAssembly.Module
.
Một tính năng hay của đối tượng này là bạn có thể chuyển đối tượng bằng cách sử dụng postMessage()
.
Điều này có nghĩa là mô-đun Wasm chỉ có thể được tải và biên dịch một lần trong luồng chính (hoặc thậm chí một Web Worker khác chỉ liên quan đến việc tải và biên dịch), sau đó được chuyển đến Web Worker chịu trách nhiệm về tác vụ sử dụng nhiều CPU. Mã sau đây minh hoạ quy trình này.
/* 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,
});
});
Về phía Web Worker, bạn chỉ cần trích xuất đối tượng WebAssembly.Module
và tạo thực thể cho đối tượng đó. Vì thông báo có WebAssembly.Module
không được truyền trực tuyến, nên mã trong Web Worker hiện sử dụng WebAssembly.instantiate()
thay vì biến thể instantiateStreaming()
như trước. Mô-đun được khởi tạo sẽ được lưu vào bộ nhớ đệm trong một biến, vì vậy, công việc khởi tạo chỉ cần diễn ra một lần khi khởi động 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 });
});
Hoàn hảo: Tác vụ chạy trong Web Worker nội tuyến, đồng thời chỉ tải và biên dịch một lần
Ngay cả khi có bộ nhớ đệm HTTP, việc lấy mã Web Worker được lưu vào bộ nhớ đệm (lý tưởng nhất) và có khả năng truy cập vào mạng là tốn kém. Một thủ thuật phổ biến để cải thiện hiệu suất là nội tuyến Web Worker và tải nó dưới dạng URL blob:
. Điều này vẫn yêu cầu mô-đun Wasm đã biên dịch được truyền đến Trình chạy web để tạo thực thể, vì ngữ cảnh của Trình chạy web và luồng chính là khác nhau, ngay cả khi chúng dựa trên cùng một tệp nguồn 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,
});
});
Tạo Web Worker theo cách trì hoãn hoặc chủ động
Cho đến nay, tất cả các mẫu mã đều khởi động Web Worker một cách chậm trễ theo yêu cầu, tức là khi người dùng nhấn nút. Tuỳ thuộc vào ứng dụng của bạn, bạn có thể tạo Web Worker một cách nhanh chóng hơn, chẳng hạn như khi ứng dụng ở trạng thái rảnh hoặc thậm chí là một phần của quy trình khởi động ứng dụng. Do đó, hãy di chuyển mã tạo Web Worker ra ngoài trình nghe sự kiện của nút.
const worker = new Worker(blobURL);
// Listen for incoming messages and display the result.
worker.addEventListener('message', (e) => {
output.textContent = e.result;
});
Có giữ Web Worker hay không
Một câu hỏi mà bạn có thể tự hỏi là liệu bạn có nên giữ Web Worker vĩnh viễn hay tạo lại bất cứ khi nào bạn cần. Cả hai phương pháp đều có thể thực hiện và có những ưu điểm cũng như nhược điểm riêng. Ví dụ: việc duy trì một Web Worker vĩnh viễn có thể làm tăng mức sử dụng bộ nhớ của ứng dụng và khiến việc xử lý các tác vụ đồng thời trở nên khó khăn hơn, vì bạn cần phải ánh xạ kết quả đến từ Web Worker trở lại các yêu cầu. Mặt khác, mã khởi động của Web Worker có thể khá phức tạp, vì vậy, có thể có nhiều chi phí phát sinh nếu bạn tạo một mã mới mỗi lần. Thật may là bạn có thể đo lường điều này bằng User Timing API.
Cho đến nay, các mẫu mã đã giữ lại một Web Worker vĩnh viễn. Mã mẫu sau đây tạo một Web Worker mới tuỳ ý bất cứ khi nào cần. Xin lưu ý rằng bạn cần tự theo dõi việc chấm dứt Web Worker. (Đoạn mã này bỏ qua việc xử lý lỗi, nhưng trong trường hợp có lỗi xảy ra, hãy nhớ chấm dứt trong mọi trường hợp, dù thành công hay thất bại.)
/* 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,
});
});
Bản minh hoạ
Có 2 bản minh hoạ để bạn chơi thử. Một có Web Worker đặc biệt (mã nguồn) và một có Web Worker cố định (mã nguồn).
Nếu mở Công cụ cho nhà phát triển của Chrome và kiểm tra Bảng điều khiển, bạn có thể thấy nhật ký User Timing API (API Thời gian của người dùng) đo thời gian từ khi người dùng nhấp vào nút cho đến khi kết quả xuất hiện trên màn hình. Thẻ Mạng cho thấy(các) yêu cầu blob:
URL. Trong ví dụ này, sự khác biệt về thời gian giữa ad hoc và permanent là khoảng 3 lần. Trên thực tế, bằng mắt thường, cả hai đều không thể phân biệt được trong trường hợp này. Kết quả cho ứng dụng thực tế của riêng bạn có thể sẽ khác.
Kết luận
Bài đăng này đã khám phá một số mẫu hiệu suất để xử lý Wasm.
- Theo quy tắc chung, hãy ưu tiên các phương thức truyền trực tuyến (
WebAssembly.compileStreaming()
vàWebAssembly.instantiateStreaming()
) hơn các phương thức không truyền trực tuyến (WebAssembly.compile()
vàWebAssembly.instantiate()
). - Nếu có thể, hãy thuê ngoài các tác vụ tiêu tốn nhiều hiệu suất trong một Web Worker và chỉ thực hiện công việc tải và biên dịch Wasm một lần bên ngoài Web Worker. Bằng cách này, Web Worker chỉ cần khởi tạo mô-đun Wasm mà nó nhận được từ luồng chính nơi quá trình tải và biên dịch diễn ra bằng
WebAssembly.instantiate()
, tức là phiên bản có thể được lưu vào bộ nhớ đệm nếu bạn giữ Web Worker vĩnh viễn. - Đo lường cẩn thận xem có nên giữ một Web Worker vĩnh viễn hay tạo Web Worker đặc biệt bất cứ khi nào cần. Ngoài ra, hãy cân nhắc thời điểm thích hợp nhất để tạo Web Worker. Những điều cần cân nhắc là mức tiêu thụ bộ nhớ, thời lượng khởi tạo Web Worker, nhưng cũng là độ phức tạp của việc có thể phải xử lý các yêu cầu đồng thời.
Nếu tính đến những mẫu này, bạn sẽ đi đúng hướng để đạt được hiệu suất Wasm tối ưu.
Lời cảm ơn
Hướng dẫn này được đánh giá bởi Andreas Haas, Jakob Kummerow, Deepti Gandluri, Alon Zakai, Francis McCabe, François Beaufort và Rachel Andrew.