דפוסי הביצועים של WebAssembly לאפליקציות אינטרנט

המדריך הזה מיועד למפתחי אינטרנט שרוצים ליהנות מהיתרונות של WebAssembly. בעזרת דוגמה פועלת, תלמדו איך להשתמש ב-Wasm כדי להעביר משימות שמתבצעות על המעבד (CPU) לגורם חיצוני. המדריך מכיל מידע על כל מה שצריך לדעת, החל משיטות מומלצות לטעינת מודולים של Wasm ועד לאופטימיזציה של הידור ויצירת מכונות שלהם. בהמשך מוסבר איך להעביר משימות שמתבצעות בסיוע מעבד (CPU) ל-Web Workers, ומנתחים החלטות שצריך לקבל לגבי ההטמעה, כמו מתי ליצור את ה-Web Worker ואם להשאיר אותו פעיל באופן קבוע או להפעיל אותו לפי הצורך. במדריך מוסבר איך לפתח את הגישה באופן איטרטיבי, ומוסבר על דפוס ביצועים אחד בכל פעם, עד שמציעים את הפתרון הטוב ביותר לבעיה.

הנחות

נניח שיש לכם משימה שמשתמשת הרבה ב-CPU ואתם רוצים להעביר אותה ל-WebAssembly‏ (Wasm) בגלל הביצועים הקרובים לביצועים של קוד מקורי. המשימה שמשתמשת ב-CPU באופן אינטנסיבי, שמוצגת כדוגמה במדריך הזה, מחשבת את העצרת של מספר. הפונקציה factorial היא המכפלה של מספר שלם ושל כל המספרים השלמים שמתחתיו. לדוגמה, הפונקציה factorial של ארבע (שנכתבת כ-4!) שווה ל-24 (כלומר, 4 * 3 * 2 * 1). המספרים גדלים במהירות. לדוגמה, 16! הוא 2,004,189,184. דוגמה ריאליסטית למשימה שצורכת מעבד (CPU) יכולה להיות סריקת ברקוד או מעקב אחרי תמונת רסטר.

הטמעה איטרטיבית של הפונקציה factorial() עם ביצועים טובים (ולא רקורסיבי) מוצגת בדוגמת הקוד הבאה שכתובה ב-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;
}

}

בשאר המאמר, נניח שיש מודול 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 למשימה שצורכת הרבה מעבד (CPU), ולכן עליכם לטעון מראש את קובץ ה-Wasm בהקדם האפשרי. כדי לעשות זאת, צריך לבצע אחזור שתומך ב-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.

שינוי המבנה של השרשור הראשי

כדי להעביר את המשימה שמשתמשת ב-CPU באופן אינטנסיבי ל-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, אבל הקוד מוגזם

Web Worker יוצר מופע של מודול Wasm, וכשהוא מקבל הודעה, הוא מבצע את המשימה שמשתמשת ב-CPU באופן אינטנסיבי ושולח את התוצאה בחזרה לשרשור הראשי. הבעיה בגישה הזו היא שיצירת מודול Wasm באמצעות WebAssembly.instantiateStreaming() היא פעולה אסינכרונית. כלומר, הקוד מהיר מדי. במקרה הגרוע ביותר, השרשור הראשי שולח נתונים כש-Web Worker עדיין לא מוכן, ו-Web Worker אף פעם לא מקבל את ההודעה.

/* 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, והמטמון של HTTP יכול לשמור במטמון את קוד הבייט של Wasm המהדר, זה לא הפתרון הכי גרוע, אבל יש דרך טובה יותר.

על ידי העברת הקוד האסינכרוני להתחלה של Web Worker בלי להמתין בפועל להבטחה, אלא לשמור את ההבטחה במשתנה, התוכנה עוברת מיד לחלק של event listener בקוד, וההודעה מה-thread הראשי לא תאבד. לאחר מכן, אפשר להמתין להבטחה בתוך מאזין האירועים.

/* 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 ונטענת ומבצעת הידור רק פעם אחת

התוצאה של ה-method הסטטי WebAssembly.compileStreaming() היא הבטחה שמפנה ל-WebAssembly.Module. אחת התכונות היפות של האובייקט היא שאפשר להעביר אותו באמצעות postMessage(). פירוש הדבר הוא שאפשר לטעון ולקמפל את מודול ה-Wasm רק פעם אחת בשרשור הראשי (או אפילו ב-Web Worker אחר שמיועד רק לטעינה ולקמפול), ואז להעביר אותו ל-Web Worker שאחראי למשימה שמשתמשת הרבה ב-CPU. הקוד הבא מראה את התהליך הזה.

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

מושלם: המשימה פועלת ב-Web Worker מוטמע, ונטענת ונערך לה קומפילציה רק פעם אחת

גם עם שמירת נתונים במטמון של HTTP, אחזור הקוד של Web Worker (במצב אידיאלי) שנשמר במטמון ואפשרות של קריאה לרשת היא יקרה. טיפ נפוץ לשיפור הביצועים הוא להטמיע את Web Worker בקוד ולטעון אותו ככתובת URL מסוג blob:. עדיין צריך להעביר את מודול ה-Wasm המהדר ל-Web Worker לצורך יצירה, כי ההקשרים של ה-Web Worker וה-thread הראשי שונים, גם אם הם מבוססים על אותו קובץ מקור של 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 ב-lazy או ב-eager

עד עכשיו, כל דוגמאות הקוד הפעילו את 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 בחזרה לבקשות. מצד שני, קוד ה-bootstrap של Web Worker עשוי להיות מורכב למדי, ולכן יכול להיות שתצטבר עלות רבה אם תיצרו קוד חדש בכל פעם. למרבה המזל, אפשר למדוד את זה באמצעות User Timing API.

עד כה, דוגמאות הקוד שמנוהלות על ידי Web Worker קבוע אחד. בדוגמת הקוד הבאה נוצר Web Worker חדש באופן אד-הוק בכל פעם שנדרש. חשוב לזכור שעליכם לעקוב אחרי סיום הפעילות של Web Worker בעצמכם. (קטע הקוד מדלג על טיפול בשגיאות, אבל אם משהו משתבש, חשוב לסיים בכל המקרים, גם אם הפעולה הסתיימה בהצלחה וגם אם לא).

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

הדגמות

יש שתי הדגמות שאפשר לשחק בהן. אחת עם Web Worker אד הוק (קוד מקור) ואחת עם Web Worker קבוע (קוד מקור). אם פותחים את Chrome DevTools ובודקים את המסוף, אפשר לראות את יומני User Timing API שמשמשים למדידת הזמן שחלף מהקליק על הלחצן ועד להצגת התוצאה במסך. בכרטיסייה 'רשת' מוצגות בקשות ה-URL של blob:. בדוגמה הזו, ההבדל בזמנים בין אירוע חד-פעמי לבין אירוע קבוע הוא פי 3 בערך. בפועל, בעין האנושית אי אפשר להבחין בין השניים במקרה הזה. סביר להניח שהתוצאות באפליקציה שלכם בעולם האמיתי יהיו שונות.

אפליקציית הדגמה של Factorial Wasm עם Worker חד-פעמי. כלי הפיתוח ל-Chrome פתוחים. יש שתי בקשות blob: URL בכרטיסייה &#39;רשת&#39;, ובמסוף מוצגים שני זמני חישוב.

אפליקציית הדגמה של Factorial Wasm עם Worker קבוע. כלי הפיתוח ל-Chrome פתוחים. יש רק blob אחד: בקשה לכתובת URL בכרטיסייה &#39;רשת&#39; ובמסוף מוצגות ארבעה תזמוני חישוב.

מסקנות

בפוסט הזה עסקנו בדפוסי ביצועים שונים לטיפול ב-Wasm.

  • ככלל, מומלץ להעדיף את שיטות הסטרימינג (WebAssembly.compileStreaming() ו-WebAssembly.instantiateStreaming()) על פני השיטות ללא סטרימינג (WebAssembly.compile() ו-WebAssembly.instantiate()).
  • אם אפשר, תוכלו לבצע מיקור חוץ למשימות עתירות ביצועים ב-Web Worker, ולבצע את פעולת הטעינה וההידור של Wasm רק פעם אחת מחוץ ל-Web Worker. כך, ל-Web Worker צריך ליצור רק מופע של מודול ה-Wasm שהוא מקבל מהשרשור הראשי שבו התרחשו הטעינה וההדרכה באמצעות WebAssembly.instantiate(). כלומר, אפשר לשמור את המופע במטמון אם משאירים את Web Worker באופן קבוע.
  • בדקו היטב אם הגיוני להשאיר את ה-Web Worker קבוע אחד מסביב לנצח, או ליצור אד-הוק Web Workers במקרה הצורך. כדאי גם לחשוב מתי הכי טוב ליצור את Web Worker. יש כמה דברים שכדאי להביא בחשבון, כמו צריכת הזיכרון, משך הזמן ליצירת מופע של Web Worker, וגם המורכבות של האפשרות שתצטרכו לטפל בבקשות בו-זמנית.

אם מביאים בחשבון את הדפוסים האלה, אתם בדרך הנכונה לביצועים אופטימליים ב-Wasm.

אישורים

המדריך הזה נבדק על ידי Andreas Haas,‏ Jakob Kummerow,‏ Deepti Gandluri,‏ Alon Zakai,‏ Francis McCabe,‏ François Beaufort ו-Rachel Andrew.