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

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

הנחות

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

שינוי המבנה של ה-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 ושום הודעה מהשרשור הראשי לא תאבד. פנים האירוע המאזין, ואז ניתן להמתין להבטחה.

/* 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 שרק מתעניין בטעינה והידור), ואחר כך יועברו אל Web Worker שאחראי על למשימה הזו. הקוד הבא מציג את התהליך הזה.

/* 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 כל הזמן, או ליצור אותו מחדש לפי הצורך. שתי הגישות אפשריים ועם היתרונות והחסרונות שלהם. לדוגמה, שמירת כתובת אתר Worker באופן קבוע עשוי להגדיל את טביעת הרגל הפחמנית של האפליקציה שלך להתמודד עם משימות במקביל, מכיוון שבאופן מסוים צריך למפות את התוצאות שמגיעים מ-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,
  });
});

הדגמות

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

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

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

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

אישורים

מדריך זה נבדק על ידי אנדראס האס, ג'קוב קומרוו, דפטי גנדלורי, אלון זקאי, פרנסיס מקייב, פרנסואה ביופורט, וגם רייצ'ל אנדרו.