טעינה יעילה של מודולי WebAssembly

כשעובדים עם WebAssembly, בדרך כלל רוצים להוריד מודול, לקמפל אותו, ליצור מופע שלו ואז להשתמש בכל מה שהוא מייצא ב-JavaScript. בפוסט הזה נסביר על הגישה המומלצת שלנו להשגת יעילות אופטימלית.

כשעובדים עם WebAssembly, בדרך כלל רוצים להוריד מודול, לקמפל אותו, ליצור מופע שלו ואז להשתמש בכל מה שהוא מייצא ב-JavaScript. הפוסט הזה מתחיל בקטע קוד נפוץ, אבל לא אופטימלי, שמבצע בדיוק את הפעולה הזו. לאחר מכן נדון בכמה אופטימיזציות אפשריות, ובסופו של דבר נציג את הדרך הפשוטה והיעילה ביותר להריץ WebAssembly מ-JavaScript.

קטע הקוד הזה מבצע את כל התהליך של הורדה, הידור ויצירת מופע, אבל באופן לא אופטימלי:

אל תשתמשו בזה!

(async () => {
  const response = await fetch('fibonacci.wasm');
  const buffer = await response.arrayBuffer();
  const module = new WebAssembly.Module(buffer);
  const instance = new WebAssembly.Instance(module);
  const result = instance.exports.fibonacci(42);
  console.log(result);
})();

שימו לב איך אנחנו משתמשים ב-new WebAssembly.Module(buffer) כדי להפוך מאגר תשובות למודול. זהו API סינכרוני, כלומר הוא חוסם את ה-thread הראשי עד שהוא מסתיים. כדי למנוע שימוש בו, Chrome משבית את WebAssembly.Module למאגרים גדולים מ-4KB. כדי לעקוף את מגבלת הגודל, אפשר להשתמש במקום זאת ב-await WebAssembly.compile(buffer):

(async () => {
  const response = await fetch('fibonacci.wasm');
  const buffer = await response.arrayBuffer();
  const module = await WebAssembly.compile(buffer);
  const instance = new WebAssembly.Instance(module);
  const result = instance.exports.fibonacci(42);
  console.log(result);
})();

await WebAssembly.compile(buffer) עדיין לא הגישה האופטימלית, אבל נגיע לזה עוד רגע.

כמעט כל הפעולות בקטע הקוד ששונה הן עכשיו אסינכרוניות, כפי שאפשר לראות מהשימוש ב-await. החריג היחיד הוא new WebAssembly.Instance(module), שבו יש את אותה הגבלה של 4KB על גודל המאגר ב-Chrome. כדי לשמור על עקביות ולאפשר לשרשור הראשי לפעול, אפשר להשתמש ב-WebAssembly.instantiate(module) האסינכרוני.

(async () => {
  const response = await fetch('fibonacci.wasm');
  const buffer = await response.arrayBuffer();
  const module = await WebAssembly.compile(buffer);
  const instance = await WebAssembly.instantiate(module);
  const result = instance.exports.fibonacci(42);
  console.log(result);
})();

נחזור לאופטימיזציה של compile שציינתי קודם. בעזרת הדרכה בסטרימינג, הדפדפן יכול כבר להתחיל להדריך את מודול WebAssembly בזמן שהבייטים של המודול עדיין מורידים. מכיוון שההורדה וההדרכה מתבצעות במקביל, התהליך מהיר יותר – במיוחד כשמדובר בעומסי עבודה גדולים.

כשמשך ההורדה ארוך יותר מזמן הידור המודול של WebAssembly, הידור של WebAssembly.compileStreaming() מסתיים כמעט מיד אחרי הורדת הבייטים האחרונים.

כדי להפעיל את האופטימיזציה הזו, צריך להשתמש ב-WebAssembly.compileStreaming במקום ב-WebAssembly.compile. השינוי הזה מאפשר לנו גם להיפטר ממאגר המערך הביניים, כי עכשיו אנחנו יכולים להעביר ישירות את המופע של Response שהוחזר על ידי await fetch(url).

(async () => {
  const response = await fetch('fibonacci.wasm');
  const module = await WebAssembly.compileStreaming(response);
  const instance = await WebAssembly.instantiate(module);
  const result = instance.exports.fibonacci(42);
  console.log(result);
})();

ה-API של WebAssembly.compileStreaming מקבל גם הבטחה שמתקבלת ממנה מכונה של Response. אם אין צורך ב-response במקום אחר בקוד, אפשר להעביר ישירות את ההבטחה שמוחזרת על ידי fetch, בלי לבצע await מפורש של התוצאה שלה:

(async () => {
  const fetchPromise = fetch('fibonacci.wasm');
  const module = await WebAssembly.compileStreaming(fetchPromise);
  const instance = await WebAssembly.instantiate(module);
  const result = instance.exports.fibonacci(42);
  console.log(result);
})();

אם אתם לא צריכים את התוצאה fetch גם במקום אחר, תוכלו אפילו להעביר אותה ישירות:

(async () => {
  const module = await WebAssembly.compileStreaming(
    fetch('fibonacci.wasm'));
  const instance = await WebAssembly.instantiate(module);
  const result = instance.exports.fibonacci(42);
  console.log(result);
})();

עם זאת, לדעתי קל יותר לקרוא את הקוד אם הוא מופיע בשורה נפרדת.

רואים איך אנחנו אוספים את התשובה למודול ואז יוצרים מופע שלה באופן מיידי? מסתבר ש-WebAssembly.instantiate יכול לבצע הידור ויצירת מופע בבת אחת. ממשק ה-API של WebAssembly.instantiateStreaming עושה זאת בסטרימינג:

(async () => {
  const fetchPromise = fetch('fibonacci.wasm');
  const { module, instance } = await WebAssembly.instantiateStreaming(fetchPromise);
  // To create a new instance later:
  const otherInstance = await WebAssembly.instantiate(module);
  const result = instance.exports.fibonacci(42);
  console.log(result);
})();

אם אתם צריכים רק מכונה אחת, אין טעם לשמור את האובייקט module, כך שתוכלו לפשט את הקוד עוד יותר:

// This is our recommended way of loading WebAssembly.
(async () => {
  const fetchPromise = fetch('fibonacci.wasm');
  const { instance } = await WebAssembly.instantiateStreaming(fetchPromise);
  const result = instance.exports.fibonacci(42);
  console.log(result);
})();

אפשר לסכם את האופטימיזציות שביצענו באופן הבא:

  • שימוש בממשקי API אסינכררוניים כדי למנוע חסימה של ה-thread הראשי
  • שימוש ב-API לסטרימינג כדי לקמפל מודולים של WebAssembly וליצור אותם במהירות רבה יותר
  • לא כותבים קוד שאין בו צורך

תהנו מ-WebAssembly!