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

Triển khai lặp lại hiệu quả (thay vì đệ quy) của factorial() hàm được hiển thị trong mã mẫu sau đượ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ả định có một mô-đun Wasm dựa trên quá trình biên dịch hàm factorial() này với Emscripten trong một tệp có tên là factorial.wasm đang sử dụng tất cả các phương pháp hay nhất để tối ưu hoá mã. Để tìm hiểu nhanh về cách thực hiện việc này, hãy đọc Gọi các hàm C được biên dịch từ JavaScript bằng ccall/cwrap. Lệnh sau đã được dùng để biên dịch factorial.wasm thành 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 một output và một lượt gửi button. 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

Bạn cần tải 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 fetch() API. Như bạn đã biết, ứng dụng web của bạn phụ thuộc vào mô-đun Wasm cho Nếu dùng nhiều CPU, bạn nên tải trước tệp Wasm càng sớm càng tốt. Bạn làm điều này bằng Tìm nạp qua phương thức CORS (Chia sẻ tài nguyên giữa nhiều nguồn gốc) 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 trò chơi được đặt tên hấp dẫn hàm được gọi WebAssembly.compile() (dấu cộng WebAssembly.compileStreaming()) và WebAssembly.instantiate() cho những tác vụ này, mà thay vào đó là WebAssembly.instantiateStreaming() phương thức biên dịch tạo thực thể cho một mô-đun Wasm trực tiếp từ một luồng nguồn cơ bản như fetch()—không cần await. Đây là cách hiệu quả nhất và tối ưu hoá để tải mã Wasm. Giả sử mô-đun Wasm xuất factorial(), sau đó bạn có thể sử dụng hàm này 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 lệnh này trên luồng chính, với các tác vụ thực sự cần nhiều CPU, bạn sẽ gặp rủi ro chặn toàn bộ ứng dụng. Một phương pháp phổ biến là chuyển những công việc như vậy sang trang web Worker.

Tái cấu trúc luồng chính

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

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

Kém: 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, thực hiện tác vụ tốn nhiều CPU và gửi kết quả trở lại luồng chính. Vấn đề của phương pháp này là tạo thực thể cho mô-đun Wasm bằng WebAssembly.instantiateStreaming() là một hoạt động không đồng bộ. Điều này có nghĩa là cho rằng mã này không phù hợp cho người xem chưa đến tuổi trưởng thành. 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 đề tạo thực thể mô-đun Wasm không đồng bộ là di chuyển tất cả quá trình tải, biên dịch và tạo thực thể mô-đun Wasm vào 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 tin nhắn đã nhận. Với chức năng lưu vào bộ nhớ đệm HTTP và bộ nhớ đệm HTTP, bạn có thể lưu mã byte Wasm được biên dịch, đây không phải là giải pháp tốt nhất, nhưng có một giải pháp 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ờ đợi lời hứa thực hiện, mà là lưu trữ lời hứa trong một thì chương trình sẽ ngay lập tức chuyển sang phần trình nghe sự kiện và không có thông báo nào từ luồng chính bị mất. Bên trong sự kiện người nghe, thì lời hứa có thể được chờ đợi.

/* 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 WebAssembly.compileStreaming() là một lời hứa sẽ phân giải WebAssembly.Module. Một tính năng thú vị của đối tượng này là nó có thể được chuyển bằ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 (hoặc thậm chí một Worker khác chỉ quan tâm đến việc tải và biên dịch), rồi được truyền sang Web Worker chịu trách nhiệm xử lý nhiều CPU công việc. 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, tất cả việc còn lại là trích xuất WebAssembly.Module và tạo thực thể cho đối tượng đó. Vì tin nhắn có WebAssembly.Module không phải được truyền trực tuyến, mã trong Web Worker hiện sử dụng WebAssembly.instantiate() thay vì biến thể instantiateStreaming() trước đó. Thuộc tính mô-đun đượ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 diễn ra sau khi thiết lập 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 cùng dòng, đồng thời chỉ tải và biên dịch một lần

Ngay cả với việc lưu vào bộ nhớ đệm HTTP, việc lấy mã (lý tưởng là) đã lưu vào bộ nhớ đệm Web Worker có thể rất tốn kém. Một thủ thuật hiệu suất phổ biến là đặt nội tuyến Web Worker và tải nó dưới dạng URL blob:. Việc này vẫn đòi hỏi được biên dịch mô-đun Wasm để truyền cho Web Worker nhằm tạo thực thể, dưới dạng ngữ cảnh của Web Worker và luồng chính là khác nhau, ngay cả khi chúng giống nhau 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 chậm rãi hoặc nhanh

Cho đến nay, tất cả các mã mẫu đều xoay quanh 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 của bạn, bạn có thể tạo Web Worker nhanh hơn, ví dụ: khi ứng dụng ở trạng thái rảnh hoặc thậm chí khi thuộc quy trình tự khởi động của ứng dụng. Do đó, hãy di chuyển hoạt động tạo Web Worker bên 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;
});

Giữ Web Worker ở gần hoặc 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 hoặc tạo lại bất cứ khi nào bạn cần. Cả hai phương pháp đều cũng có những ưu điểm và nhược điểm riêng. Ví dụ: duy trì Web Worker xung quanh 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 xử lý các tác vụ đồng thời khó hơn, vì bằng cách nào đó bạn cần phải ánh xạ kết quả từ Web Worker trở lại yêu cầu. Mặt khác, trải nghiệm Web của bạn Mã tự khởi động của worker có thể khá phức tạp, do đó có thể nếu bạn tạo một quảng cáo mới mỗi lần. Thật may, đây là điều bạn có thể đo lường 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 Worker web cố định. Nội dung sau đây mã mẫu sẽ tạo một Worker web 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 chấm dứt Web Worker chính bạn. (Đoạn mã bỏ qua bước xử lý lỗi, nhưng trong trường hợp có lỗi xảy ra sai, hãy nhớ chấm dứt trong mọi trường hợp, 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ử

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

Ứng dụng minh hoạ Wasm giai thừa 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à Bảng điều khiển hiển thị hai thời gian tính toán.

Ứng dụng minh hoạ Wasm giai đoạn có 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 tìm hiểu một số quy luật về hiệu suất khi xử lý Wasm.

  • Theo nguyên tắc chung, hãy ưu tiên các phương thức phát trực tuyến (WebAssembly.compileStreaming()WebAssembly.instantiateStreaming()) so với các phiên bản không phát trực tuyến (WebAssembly.compile()WebAssembly.instantiate()).
  • Nếu có thể, hãy thuê ngoài các công việc đòi hỏi hiệu suất cao trong Web Worker và xử lý Wasm tải và biên dịch công việc 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 nơi việc tải và biên dịch xảy ra với WebAssembly.instantiate(), nghĩa là thực thể này có thể được lưu vào bộ nhớ đệm nếu bạn duy trì Web Worker vĩnh viễn.
  • Đo lường cẩn thận xem có cần giữ một nhân viên web vĩnh viễn hay không vĩnh viễn hoặc tạo Trình chạy web đặ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 điều cần lưu ý các yếu tố cần cân nhắc là mức tiêu thụ bộ nhớ, thời lượng tạo thực thể Web Worker, nhưng cũng là một vấn đề phức tạp của việc có thể phải giải quyết các yêu cầu đồng thời.

Nếu tính đến những quy luật này, bạn đang đi đúng hướng để tối ưu hoá Hiệu suất Wasm.

Xác nhận

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