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

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ụ nặng về CPU thông qua 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à tạo thực thể của các mô-đun đó. Bài viết này thảo luận thêm về việc chuyển các tác vụ nặng về 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à liệu có nên duy trì Web Worker vĩnh viễn hay khởi động khi cần hay không. Hướng dẫn này phát triển lặp lại phương pháp và giới thiệu từng mẫu hiệu suất cho đến khi đề xuất giải pháp tốt nhất cho vấn đề.

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) để có hiệu suất gần như gốc. Tác vụ nặng về 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ố. Số mũ là tích của một số nguyên và tất cả các số nguyên bên dưới số nguyên đó. Ví dụ: thừa số của 4 (viết là 4!) bằng 24 (tức là 4 * 3 * 2 * 1). Các con số tăng lên nhanh chóng. Ví dụ: 16!2,004,189,184. Một ví dụ thực tế hơn về một tác vụ tốn nhiều CPU có thể là quét mã vạch hoặc truy vết hình ảnh đường quét.

Một cách triển khai lặp lại hiệu quả (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 với 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 đâ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 một input ghép nối với một 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à tạo bản sao của mô-đun

Bạn cần tải mô-đun Wasm trước khi có thể sử dụng mô-đun đó. Trên web, việc này diễn 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 cho tác vụ tốn nhiều CPU, vì vậy, 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 một lệnh tìm nạp hỗ trợ CORS trong phần <head> của ứng dụng.

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

Trong thực tế, API fetch() 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 được gọi là WebAssembly.compile() (cùng với WebAssembly.compileStreaming()) và WebAssembly.instantiate() cho những tác vụ này, nhưng thay vào đó, phương thức WebAssembly.instantiateStreaming() sẽ biên dịch tạo thực thể trực tiếp cho mô-đun Wasm 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ả nhất và được tối ưu hoá để tải mã Wasm. Giả sử mô-đun Wasm xuấ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 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ự nặng về 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 Worker web.

Cấu trúc lại luồng chính

Để di chuyển tác vụ nặng về CPU sang một Worker trên web, bước đầu tiên là tái cấu trúc ứng dụng. Giờ đây, luồng chính sẽ tạo 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 Trình chạy web, nhưng mã bị lỗi

Trình chạy web tạo bản sao của mô-đun Wasm và khi nhận được thông báo, sẽ thực hiện tác vụ nặng về 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 tạo bản sao 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ốc độ nhanh. Trong trường hợp xấu nhất, luồng chính sẽ gửi dữ liệu khi Worker trên web chưa sẵn sàng và Worker trên web sẽ 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 bản sao mô-đun Wasm không đồng bộ là di chuyển quá trình tải, biên dịch và tạo bản sao mô-đun Wasm 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ông báo đã 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 vào bộ nhớ đệm mã byte Wasm đã biên dịch, đây không phải là giải pháp tồi 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 phần đầu của Web Worker và thực sự không đang chờ lời hứa thực hiện mà thay vào đó 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à 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, lời hứa có thể được chờ.

/* 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, tải và biên dịch 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 WebAssembly.Module. Một tính năng thú vị 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í là một Trình chạy web khác chỉ liên quan đến việc tải và biên dịch), sau đó được chuyển sang Trình chạy web chịu trách nhiệm về tác vụ nặng CPU. 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,
  });
});

Về phía Web Worker, bạn chỉ cần trích xuất đối tượng WebAssembly.Module và tạo bản sao 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ẽ sử dụng WebAssembly.instantiate() thay vì biến thể instantiateStreaming() trước đó. Mô-đun được tạo bản sao được lưu vào bộ nhớ đệm trong một biến, vì vậy, quá trình tạo bản sao 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, 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ý tưởng) được lưu vào bộ nhớ đệm và có thể truy cập vào mạng cũng tốn kém. Một mẹo hiệu suất phổ biến là đưa trình chạy web vào cùng dòng và tải trình chạy web đó dưới dạng URL blob:. Việc này vẫn đòi hỏi mô-đun Wasm đã biên dịch phải được truyền đế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 các yếu tố này 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 kiểu tải lười hoặc tải sớm

Cho đến nay, tất cả các mã mẫu đều khởi động Web Worker theo yêu cầu, tức là khi nhấn nút. Tuỳ thuộc vào ứng dụng, bạn nên tạo Web Worker nhanh 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 trong quy 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;
});

Có giữ lại 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ữ lại Web Worker vĩnh viễn hay tạo lại Web Worker bất cứ khi nào bạn cần hay không. Cả hai phương pháp đều có thể áp dụng được và đều có những ưu điểm và nhược điểm riêng. Ví dụ: việc duy trì 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ằng cách nào đó, bạn cần ánh xạ các kết quả từ Web Worker trở lại 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 hao tổn nếu bạn tạo một mã mới mỗi lần. May mắn là bạn có thể đo lường điều này bằng User Timing API.

Các mẫu mã cho đến nay vẫn giữ một Worker web cố định. Mã mẫu sau đây sẽ tạo một Worker web mới 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 Worker trên web. (Đoạn mã này bỏ qua việc xử lý lỗi, nhưng trong trường hợp có lỗi, hãy nhớ chấm dứt trong mọi trường hợp, thành công hoặc 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ử

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

Ứng dụng minh hoạ Factorial Wasm với một Worker cố định. 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 hiển thị 4 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.

  • Theo quy tắc chung, bạn nên ưu tiên các phương thức truyền trực tuyến (WebAssembly.compileStreaming()WebAssembly.instantiateStreaming()) hơn các phương thức không truyền trực tuyến (WebAssembly.compile()WebAssembly.instantiate()).
  • Nếu có thể, hãy thuê ngoài các tác vụ nặng về hiệu suất trong một Trình chạy web và chỉ tải và biên dịch Wasm một lần bên ngoài Trình chạy web. Bằng cách này, trình chạy web chỉ cần tạo bản sao mô-đun Wasm mà trình chạy web nhận được từ luồng chính nơi quá trình tải và biên dịch diễn ra với WebAssembly.instantiate(). Điều này có nghĩa là bản sao có thể được lưu vào bộ nhớ đệm nếu bạn giữ trình chạy web vĩnh viễn.
  • Hãy đo lường cẩn thận xem có nên duy trì một Worker web vĩnh viễn hay không, hoặc tạo Worker web đặc biệt bất cứ khi nào cần. Ngoài ra, hãy nghĩ đến thời điểm tốt nhất để tạo Worker trên web. Những điều cần cân nhắc là mức sử dụng bộ nhớ, thời lượng tạo bản sao Web Worker, nhưng cũng cần xem xét độ 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, bạn đang đi đúng hướng để đạt được hiệu suất tối ưu cho Wasm.

Lời cảm ơn

Hướng dẫn này đã được Andreas Haas, Jakob Kummerow, Deepti Gandluri, Alon Zakai, Francis McCabe, François BeaufortRachel Andrew xem xét.