Các mẫu hiệu suất WebAssembly cho ứng dụng web

Trong hướng dẫn này dành cho các 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ụ cần nhiều CPU với sự trợ giúp của một ví dụ đang chạy. Hướng dẫn này đề cập mọi thứ, từ các phương pháp hay nhất để tải mô-đun Wasm cho đến việc tối ưu hoá quá trình biên dịch và tạo thực thể. Chủ đề này sẽ thảo luận thêm về việc chuyển các tác vụ cần nhiều CPU sang Trình chạy web và xem xét các quyết định triển khai mà bạn sẽ gặp phải, chẳng hạn như khi nào nên tạo Trình chạy web và liệu nên duy trì hoạt động vĩnh viễn hay xoay vòng khi cần. Hướng dẫn này sẽ phát triển lặp đi lặp lại phương pháp tiếp cận và mỗi lần áp dụng một mẫu hiệu suấ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ụ nặng về CPU và bạn muốn thuê WebAssembly (Wasm) để có hiệu suất gần như gốc. Tác vụ cần nhiều CPU 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. Ví dụ: giai thừa của 4 (được viết là 4!) bằng 24 (nghĩa là 4 * 3 * 2 * 1). Các con số này sẽ trở nên lớn nhanh chóng. Ví dụ: 16!2,004,189,184. Một ví dụ thực tế hơn về tác vụ tốn nhiều CPU có thể là quét mã vạch hoặc theo dõi hình ảnh đường quét.

Cách triển khai lặp lại hiệu suất (thay vì đệ quy) của hàm factorial() được hiển thị trong mã mẫu 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, trong đó sử dụng tất cả các phương pháp hay nhất về tối ưu hoá mã. Để tìm hiểu 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 đây đượ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 ghép nối với outputbutton 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à tạo bản sao mô-đun

Trước khi có thể sử dụng mô-đun Wasm, bạn cần tải mô-đun đó. Trên web, điều này xảy ra thông qua API fetch(). Như bạn đã biết, ứng dụng web của bạn phụ thuộc vào mô-đun Wasm để xử lý tác vụ cần nhiều CPU, bạn nê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 tính năng tìm nạp có kích hoạt CORS trong phần <head> của ứng dụng.

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

Trên thực tế, API fetch() là không đồng bộ và bạn cần phải 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 được gọi 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() biên dịch tạo bản sao mô-đun Wasm trực tiếp từ một nguồn cơ sở được truyền trực tuyến như fetch() mà 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(), 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 tác vụ sang Web Worker

Nếu thực thi việc này trên luồng chính, với các tác vụ thực sự tốn 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ụ đó sang một Trình chạy web.

Điều chỉnh cấu trúc của luồng chính

Để chuyển tác vụ cần nhiều CPU sang một Web Worker, bước đầu tiên là cơ cấu lại ứng dụng. Luồng chính giờ đây sẽ tạo một Worker và ngoài việc đó ra, luồng này chỉ xử lý việc gửi dữ liệu đầu vào đến Web Worker, sau đó nhận đầu ra và hiển thị đầu ra đó.

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

Tệ: Tác vụ chạy trong Web Worker nhưng mã không phù hợp

Web Worker tạo thực thể cho mô-đun Wasm và khi nhận được thông báo, sẽ thực hiện tác vụ cần nhiều CPU rồi gửi kết quả trở lại luồng chính. Vấn đề của phương pháp này là việc tạo thực thể cho 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ã không phù hợp. 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 thừa

Một giải pháp cho vấn đề tạo thực thể mô-đun Wasm không đồng bộ là chuyển tất cả mô-đun Wasm tải, biên dịch và tạo thực thể vào trình nghe sự kiện, nhưng điều này có nghĩa là công việc này cần phải diễn ra trên mọi thư đã nhận. Với tính năng lưu vào bộ nhớ đệm HTTP và bộ nhớ đệm HTTP có thể lưu mã byte Wasm đã biên dịch vào bộ nhớ đệm, đây không phải là giải pháp kém nhất, nhưng vẫn có một cách tốt hơn.

Bằng cách di chuyển mã không đồng bộ lên đầu Web Worker và thực sự không đợi lời hứa thực hiện mà thực sự lưu trữ lời hứa trong một biến, chương trình sẽ ngay lập tức chuyển sang phần trình nghe sự kiện của mã và sẽ không có thông báo nào từ luồng chính bị mất. Bên trong trình nghe sự kiện, bạn có thể chờ lời hứa.

/* 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, chỉ tải và biên dịch một lần

Kết quả của phương thức WebAssembly.compileStreaming() tĩnh là một lời hứa phân giải thành một WebAssembly.Module. Một tính năng thú vị của đối tượng này là bạn có thể truyền đối tượng bằng cách sử dụng postMessage(). Điều này có nghĩa là mô-đun Wasm có thể được tải và biên dịch chỉ một lần trong luồng chính (hoặc thậm chí một Trình chạy web khác chỉ quan tâm đến việc tải và biên dịch), sau đó được chuyển cho Trình chạy web chịu trách nhiệm về tác vụ cần nhiều CPU. Đoạn mã sau đây cho thấy 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,
  });
});

Ở phía Web Worker, tất cả những việc còn lại chỉ là 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 phát 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 đây. Mô-đun tạo bản sao được lưu vào bộ nhớ đệm trong một biến, vì vậy, công việc tạo thực thể chỉ cần xảy 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, chỉ tải và biên dịch một lần

Ngay cả khi lưu vào bộ nhớ đệm HTTP, việc lấy mã Web Worker đã lưu vào bộ nhớ đệm (tốt nhất là) và việc truy cập mạng có khả năng sẽ gây tốn kém. Một mẹo phổ biến về hiệu suất là đưa Web Worker vào nội tuyến và tải dưới dạng URL blob:. Điều này vẫn yêu cầu truyền mô-đun Wasm đã biên dịch đến Web Worker để tạo bản sao, vì ngữ cảnh của Web Worker 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 Trình chạy web lười biếng hoặc háo hức

Cho đến nay, tất cả các mã mẫu đã tách ra từng phần của Web Worker theo yêu cầu, tức là khi người dùng nhấn nút này. Tuỳ thuộc vào ứng dụng, bạn nên tạo Web Worker một cách nghiêm ngặt hơn, chẳng hạn như khi ứng dụng ở trạng thái rảnh hoặc thậm chí là trong quá trình tự khởi động của ứ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;
});

Tiếp tục sử dụng Web Worker

Một câu hỏi mà có thể bạn sẽ tự hỏi là liệu bạn nên giữ lại 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ể áp dụng và có những ưu điểm và nhược điểm riêng. Ví dụ: việc duy trì vĩnh viễn một Trình chạy web 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ằng cách nào đó bạn sẽ cần ánh xạ các kết quả từ Web Worker đến các yêu cầu. Mặt khác, mã tự khởi động của Trình chạy web có thể khá phức tạp, vì vậy, có thể có rất nhiều chi phí nếu bạn tạo một mã mới mỗi lần. May mắn thay, bạn có thể đo lường hoạt động này bằng API Thời gian người dùng.

Cho đến nay, các mã mẫu vẫn giữ lại một Web Worker cố định. Mã mẫu sau đây sẽ tạo một Web Worker mới đặc biệt bất cứ khi nào cần thiết. Xin lưu ý rằng bạn cần theo dõi việc tự chấm dứt Trình chạy web. (Đoạn mã bỏ qua việc xử lý lỗi, nhưng trong trường hợp có sự cố, hãy nhớ chấm dứt trong mọi trường hợp, dù thành công hay không thành công.)

/* 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 thu thử

Bạn có thể thử sử dụng hai bản minh hoạ này. Một công cụ có Trình thực thi web đặc biệt (mã nguồn) và một Trình có Trình chạy web vĩnh viễn (mã nguồn). Nếu mở Công cụ của Chrome cho nhà phát triển và kiểm tra Bảng điều khiển, bạn có thể thấy nhật ký API Thời gian người dùng đo thời gian cần từ khi nhấp vào nút đến kết quả hiển thị trên màn hình. Thẻ Mạng cho thấy(các) yêu cầu URL blob:. Trong ví dụ này, mức chênh lệch thời gian giữa đặc biệt và vĩnh viễn là khoảng 3 lần. Trong thực tế, đối với mắt người, cả hai loại dữ liệu này đề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 rất có thể sẽ khác.

Ứng dụng minh hoạ Factorial Wasm với một Worker đặc biệt. Công cụ của Chrome cho nhà phát triển đang mở. Có hai blob: Các yêu cầu URL trong thẻ Mạng và Bảng điều khiển sẽ hiển thị hai mốc thời gian tính toán.

Ứng dụng minh hoạ Factorial Wasm với một Worker vĩnh viễn. Công cụ của Chrome cho nhà phát triển đang mở. Chỉ có một blob: yêu cầu URL trong thẻ Mạng và Bảng điều khiển sẽ hiển thị 4 mốc thời gian tính toán.

Kết luận

Bài đăng này đã khám phá một số mẫu hiệu suất để xử lý Wasm.

  • Thường thì bạn nên ưu tiên các phương thức phát trực tuyến (WebAssembly.compileStreaming()WebAssembly.instantiateStreaming()) hơn là các phương thức không truyền trực tuyến (WebAssembly.compile()WebAssembly.instantiate()).
  • Nếu có thể, bạn có thể thuê ngoài các tác vụ nặng về hiệu suất trong 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 tạo thực thể cho 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(), nghĩa là thực thể này có thể được lưu vào bộ nhớ đệm nếu bạn giữ Web Worker lâu dài.
  • Đo lường cẩn thận xem bạn nên giữ một Web Worker làm việc vĩnh viễn hay tạo Web Worker đặc biệt bất cứ khi nào cần thiết. Ngoài ra, hãy nghĩ xem khi nào là thời điểm tốt nhất để tạo Web Worker. Những yếu tố cần xem xét là mức sử dụng bộ nhớ, thời lượng tạo thực thể của Web Worker, nhưng cũng là sự 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 các mẫu này thì bạn đang đi đúng hướng để tối ưu hoá hiệu suất Chất lượng.

Xác nhận

Hướng dẫn này có sự xem xét của Andreas Haas, Jakob Kummerow, Deepti Gandluri, Alon Zakai, Francis McCabe, François BeaufortRachel Andrew.