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

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

הנחות

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

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

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

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

התוצאה של השיטה הסטטית WebAssembly.compileStreaming() היא הבטחה שמובילה ל-WebAssembly.Module. אחת התכונות הנחמדות של האובייקט הזה היא שאפשר להעביר אותו באמצעות postMessage(). כלומר, אפשר לטעון ולהדר את מודול Wasm רק פעם אחת ב-thread הראשי (או אפילו עובד אינטרנט אחר שעוסק רק בטעינה והידור), ואז להעביר אותו ל-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 באופן מדורג לפי דרישה, כלומר כשלוחצים על הלחצן. בהתאם לאפליקציה, כדאי ליצור את Web Worker באופן בולט יותר, למשל כשהאפליקציה לא פעילה או אפילו כחלק מתהליך האתחול של האפליקציה. לכן, צריך להעביר את קוד היצירה של Web Worker אל מחוץ ל-event listener של הלחצן.

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

עד כה בדוגמאות הקוד נשארות עובד אינטרנט קבוע אחד. דוגמת הקוד הבאה יוצרת אד-הוק חדש של 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,
  });
});

הדגמות

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

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

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

מסקנות

הפוסט הזה חקר כמה דפוסי ביצועים להתמודדות עם Wasm.

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

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

אישורים

המדריך הזה נבדק על ידי אנדראס האאס, Jakob Kummerow, Deepti Gandluri, אלון זאקאי, פרנסיס מק'קייב, פרנסואה בופורט רייצ'ל אנדרו.