דפוסי הביצועים של 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. דוגמה ריאליסטית יותר למשימות שמתבצעות בעיקר על המעבד היא סריקת ברקוד או מעקב אחרי תמונה רסטר.

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

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

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

התוצאה של השיטה הסטטית 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.