הידור ואופטימיזציה של Wasm עם Binaryen

Binaryen היא ספריית תשתית מהדר ו-toolchain ל-WebAssembly שנכתבה ב-C++. המטרה שלה היא להפוך את ההידור לאינטואיטיבי, מהיר ואפקטיבי ב-WebAssembly. בפוסט הזה, באמצעות דוגמה לשפת צעצוע סינתטית שנקראת ExampleScript, תלמדו איך לכתוב מודולים של WebAssembly ב-JavaScript באמצעות ה-API של Binaryen.js. נסביר את העקרונות הבסיסיים של יצירת מודולים, הוספת פונקציות למודול וייצוא פונקציות מהמודול. כך תכירו את המנגנונים הכלליים של הידור שפות התכנות בפועל ב-WebAssembly. בהמשך נסביר איך לבצע אופטימיזציה למודולים של Wasm גם באמצעות Binaryen.js וגם בשורת הפקודה עם wasm-opt.

רקע על Binaryen

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

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

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

AssemblyScript כמשתמש לדוגמה של Binaryen

המערכת משתמשת ב-Binaryen במספר פרויקטים, למשל AssemblyScript, שמשתמש ב-Binaryen כדי להדר משפה דמוית TypeScript ישירות ל-WebAssembly. נסו את הדוגמה במגרש המשחקים של AssemblyScript.

קלט AssemblyScript:

export function add(a: i32, b: i32): i32 {
  return a + b;
}

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

(module
 (type $0 (func (param i32 i32) (result i32)))
 (memory $0 0)
 (export "add" (func $module/add))
 (export "memory" (memory $0))
 (func $module/add (param $0 i32) (param $1 i32) (result i32)
  local.get $0
  local.get $1
  i32.add
 )
)

מגרש המשחקים של AssemblyScript מציג את קוד WebAssembly שנוצר על סמך הדוגמה הקודמת.

שרשרת הכלים של Binaryen

הכלי Binaryen Toolchain מספק כמה כלים שימושיים למפתחי JavaScript וגם למשתמשי שורת הפקודה. קבוצת משנה של הכלים האלה מפורטת בהמשך. הרשימה המלאה של הכלים הכלולים זמינה בקובץ README של הפרויקט.

  • binaryen.js: ספריית JavaScript עצמאית שחושפת שיטות של Binaryen ליצירה ואופטימיזציה של מודולים של Wasm. למידע על גרסאות build, קראו את המאמר binaryen.js ב-npm (או מורידים אותו ישירות מ-GitHub או unpkg).
  • wasm-opt: כלי שורת הפקודה שטוען את WebAssembly ומריץ בו Binaryen IR.
  • wasm-as ו-wasm-dis: כלי שורת הפקודה שמרכיבים ומפרקים את WebAssembly.
  • wasm-ctor-eval: כלי שורת הפקודה שיכול להפעיל פונקציות (או חלקים מפונקציות) בזמן ההידור.
  • wasm-metadce: כלי שורת הפקודה להסרת חלקים מקובצי Wasm בדרך גמישה שתלויה באופן השימוש במודול.
  • wasm-merge: כלי שורת הפקודה שממזג מספר קובצי Wasm לקובץ יחיד, מחבר את פעולות הייבוא המתאימות לייצוא כמו שהוא עושה זאת. כמו ב-Bundler עבור JavaScript, אבל עבור Wasm.

הידור ל-WebAssembly

בדרך כלל, הידור של שפה אחת לשפה אחרת כרוך בכמה שלבים, והחשובים ביותר מפורטים ברשימה הבאה:

  • ניתוח גמיש: פיצול של קוד המקור לאסימונים.
  • ניתוח תחביר: יצירת עץ תחביר מופשט.
  • ניתוח סמנטי: בדיקת שגיאות ואכיפת כללי שפה.
  • יצירת קוד ברמת ביניים: יצירת ייצוג מופשט יותר.
  • יצירת קוד: מתרגמים לשפת היעד.
  • אופטימיזציה של קוד ספציפי ליעד: אופטימיזציה להשגת היעד.

בעולם Unix, הכלים הנפוצים להידור הם lex ו-yacc:

  • lex (מחולל מנתח גמיש): lex הוא כלי שיוצר אנליסטים לקסיים, שנקראים גם לקסרים או סורקים. היא משתמשת בקבוצה של ביטויים רגולריים ופעולות תואמות כקלט, ויוצרת קוד לניתוח מילוני שמזהה דפוסים בקוד מקור הקלט.
  • yacc (Yet another Compiler Compiler): yacc הוא כלי שיוצר מנתחים לניתוח תחביר. הוא מקבל תיאור דקדוקי רשמי של שפת תכנות בתור קלט, ויוצר קוד למנתח. מנתחים בדרך כלל יוצרים עצי תחביר מופשטים (AST) שמייצגים את המבנה ההיררכי של קוד המקור.

דוגמה לעבודה

לאור ההיקף של הפוסט הזה, אי אפשר לכסות שפת תכנות מלאה ולכן, בשביל הפשטות, כדאי להשתמש בשפת תכנות סינתטית מוגבלת מאוד וחסרת תועלת, שנקראת ExampleScript עובדת על ידי הצגת פעולות כלליות באמצעות דוגמאות מוחשיות.

  • כדי לכתוב פונקציית add(), צריך לקודד דוגמה להוספה כלשהי, למשל 2 + 3.
  • כדי לכתוב פונקציה של multiply(), צריך לכתוב, למשל, 6 * 12.

לפי האזהרה המקדימה, מדובר בחסר ערך פשוט, אבל מספיק פשוט כדי שהמנתח המילולי יהיה ביטוי רגולרי אחד: /\d+\s*[\+\-\*\/]\s*\d+\s*/.

לאחר מכן צריך להיות משתמש מנתח. למעשה, אפשר ליצור גרסה פשוטה מאוד של עץ תחביר מופשט באמצעות ביטוי רגולרי עם קבוצות לחילוץ עם שם: /(?<first_operand>\d+)\s*(?<operator>[\+\-\*\/])\s*(?<second_operand>\d+)/.

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

export default class Parser {
  parse(input) {
    input = input.split(/\n/);
    if (!input.every((line) => /\d+\s*[\+\-\*\/]\s*\d+\s*/gm.test(line))) {
      throw new Error('Parse error');
    }

    return input.map((line) => {
      const { groups } =
        /(?<first_operand>\d+)\s*(?<operator>[\+\-\*\/])\s*(?<second_operand>\d+)/gm.exec(
          line,
        );
      return {
        firstOperand: Number(groups.first_operand),
        operator: groups.operator,
        secondOperand: Number(groups.second_operand),
      };
    });
  }
}

יצירת קוד ביניים

עכשיו אפשר לייצג את תוכניות ExampleScript כעץ תחביר מופשט (אף על פי שהוא פשוט למדי), השלב הבא הוא ליצור ייצוג מופשט. השלב הראשון הוא ליצור מודול חדש ב-Binaryen:

const module = new binaryen.Module();

כל שורה בעץ התחביר המופשט מכילה משולש שמכיל את הערכים firstOperand, operator ו-secondOperand. לכל אחד מארבעת האופרטורים האפשריים ב-ExampleScript, כלומר +, -, *, /, צריך להוסיף פונקציה חדשה למודול באמצעות השיטה Module#addFunction() של Binaryen. הפרמטרים של השיטות Module#addFunction() הם:

  • name: string, מייצג את שם הפונקציה.
  • functionType: Signature, מייצג את החתימה של הפונקציה.
  • varTypes: Type[], מציין מיקומים מקומיים נוספים, בסדר הנתון.
  • body: Expression, תוכן הפונקציה.

יש עוד כמה פרטים על רגיעה ופירוט, והתיעוד של Biinaryen יכול לעזור לכם לנווט במרחב. אבל בסופו של דבר, לאופרטור + של ExampleScript, תגיעו לשיטה Module#i32.add() כאחת מתוך כמה פעולות זמינות של מספרים שלמים. לחיבור מחייב שני אופרנדים, הסכום הראשון והשני. כדי שתהיה אפשרות לקרוא לפונקציה, צריך לייצא אותה באמצעות Module#addFunctionExport().

module.addFunction(
  'add', // name: string
  binaryen.createType([binaryen.i32, binaryen.i32]), // params: Type
  binaryen.i32, // results: Type
  [binaryen.i32], // vars: Type[]
  //  body: ExpressionRef
  module.block(null, [
    module.local.set(
      2,
      module.i32.add(
        module.local.get(0, binaryen.i32),
        module.local.get(1, binaryen.i32),
      ),
    ),
    module.return(module.local.get(2, binaryen.i32)),
  ]),
);
module.addFunctionExport('add', 'add');

לאחר העיבוד של עץ התחביר המופשט, המודול מכיל ארבע שיטות, שלוש שיטות עם מספרים שלמים, כלומר add() על סמך Module#i32.add(), subtract() על סמך Module#i32.sub(), multiply() על סמך Module#i32.mul(), והגרסה החיצונית divide() על סמך Module#f64.div(), מכיוון ש-ExampleScript פועל גם עם תוצאות של נקודה צפה (floating-point).

for (const line of parsed) {
      const { firstOperand, operator, secondOperand } = line;

      if (operator === '+') {
        module.addFunction(
          'add', // name: string
          binaryen.createType([binaryen.i32, binaryen.i32]), // params: Type
          binaryen.i32, // results: Type
          [binaryen.i32], // vars: Type[]
          //  body: ExpressionRef
          module.block(null, [
            module.local.set(
              2,
              module.i32.add(
                module.local.get(0, binaryen.i32),
                module.local.get(1, binaryen.i32)
              )
            ),
            module.return(module.local.get(2, binaryen.i32)),
          ])
        );
        module.addFunctionExport('add', 'add');
      } else if (operator === '-') {
        module.subtractFunction(
          // Skipped for brevity.
        )
      } else if (operator === '*') {
          // Skipped for brevity.
      }
      // And so on for all other operators, namely `-`, `*`, and `/`.

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

// This function is added, but not exported,
// so it's effectively dead code.
module.addFunction(
  'deadcode', // name: string
  binaryen.createType([binaryen.i32, binaryen.i32]), // params: Type
  binaryen.i32, // results: Type
  [binaryen.i32], // vars: Type[]
  //  body: ExpressionRef
  module.block(null, [
    module.local.set(
      2,
      module.i32.div_u(
        module.local.get(0, binaryen.i32),
        module.local.get(1, binaryen.i32),
      ),
    ),
    module.return(module.local.get(2, binaryen.i32)),
  ]),
);

המהדר כמעט מוכן עכשיו. זה לא חובה, אבל בהחלט כדאי לאמת את המודול באמצעות השיטה Module#validate().

if (!module.validate()) {
  throw new Error('Validation error');
}

קבלת קוד Wasm שנוצר

כדי לקבל את קוד Wasm שנוצר, יש בBinaryen שתי שיטות לקבלת הייצוג הטקסטואלי כקובץ .wat ב-S-expression כפורמט קריא לאנשים, ואת הייצוג הבינארי כקובץ .wasm שאפשר להריץ ישירות בדפדפן. אפשר להריץ את הקוד הבינארי ישירות בדפדפן. כדי לראות שזה עובד, רישום של פעולות הייצוא יכול לעזור.

const textData = module.emitText();
console.log(textData);

const wasmData = module.emitBinary();
const compiled = new WebAssembly.Module(wasmData);
const instance = new WebAssembly.Instance(compiled, {});
console.log('Wasm exports:\n', instance.exports);

הייצוג הטקסטואלי המלא של תוכנית ExampleScript עם כל ארבע הפעולות, מפורט בהמשך. שימו לב איך הקוד המת עדיין מופיע, אבל הוא לא נחשף בהתאם לצילום המסך של WebAssembly.Module.exports().

(module
 (type $0 (func (param i32 i32) (result i32)))
 (type $1 (func (param f64 f64) (result f64)))
 (export "add" (func $add))
 (export "subtract" (func $subtract))
 (export "multiply" (func $multiply))
 (export "divide" (func $divide))
 (func $add (param $0 i32) (param $1 i32) (result i32)
  (local $2 i32)
  (local.set $2
   (i32.add
    (local.get $0)
    (local.get $1)
   )
  )
  (return
   (local.get $2)
  )
 )
 (func $subtract (param $0 i32) (param $1 i32) (result i32)
  (local $2 i32)
  (local.set $2
   (i32.sub
    (local.get $0)
    (local.get $1)
   )
  )
  (return
   (local.get $2)
  )
 )
 (func $multiply (param $0 i32) (param $1 i32) (result i32)
  (local $2 i32)
  (local.set $2
   (i32.mul
    (local.get $0)
    (local.get $1)
   )
  )
  (return
   (local.get $2)
  )
 )
 (func $divide (param $0 f64) (param $1 f64) (result f64)
  (local $2 f64)
  (local.set $2
   (f64.div
    (local.get $0)
    (local.get $1)
   )
  )
  (return
   (local.get $2)
  )
 )
 (func $deadcode (param $0 i32) (param $1 i32) (result i32)
  (local $2 i32)
  (local.set $2
   (i32.div_u
    (local.get $0)
    (local.get $1)
   )
  )
  (return
   (local.get $2)
  )
 )
)

צילום מסך של קונסולת כלי הפיתוח של הייצוא של מודול WebAssembly שמציג ארבע פונקציות: הוספה, חילוק, כפל וחיסור (אבל לא את הקוד המוות שלא נחשף).

אופטימיזציה של WebAssembly

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

ביצוע אופטימיזציה באמצעות Binaryen.js

הדרך הפשוטה ביותר לבצע אופטימיזציה של מודול Wasm באמצעות Binaryen היא לקרוא ישירות ל-method Module#optimize() של Binaryen.js, ואם רוצים, להגדיר את אופטימיזציה ורמת הכיווץ.

// Assume the `wast` variable contains a Wasm program.
const module = binaryen.parseText(wast);
binaryen.setOptimizeLevel(2);
binaryen.setShrinkLevel(1);
// This corresponds to the `-Os` setting.
module.optimize();

פעולה זו מסירה את הקוד המתים שהוכנס באופן מלאכותי לפני כן, כך שהייצוג הטקסטואלי של גרסת Wasm של דוגמת הצעצוע של ExampleScript לא מכיל אותו יותר. שימו לב גם איך מסירים את צמדי local.set/get באמצעות שלבי האופטימיזציה SimplifyLocals (אופטימיזציות שונות שקשורות למקומיים) ואת שואב האבק (מסיר קוד מיותר), והסרת ה-return על ידי RemoveUnusedBrs (הסרת הפסקות ממיקומים שאין בהם צורך).

 (module
 (type $0 (func (param i32 i32) (result i32)))
 (type $1 (func (param f64 f64) (result f64)))
 (export "add" (func $add))
 (export "subtract" (func $subtract))
 (export "multiply" (func $multiply))
 (export "divide" (func $divide))
 (func $add (; has Stack IR ;) (param $0 i32) (param $1 i32) (result i32)
  (i32.add
   (local.get $0)
   (local.get $1)
  )
 )
 (func $subtract (; has Stack IR ;) (param $0 i32) (param $1 i32) (result i32)
  (i32.sub
   (local.get $0)
   (local.get $1)
  )
 )
 (func $multiply (; has Stack IR ;) (param $0 i32) (param $1 i32) (result i32)
  (i32.mul
   (local.get $0)
   (local.get $1)
  )
 )
 (func $divide (; has Stack IR ;) (param $0 f64) (param $1 f64) (result f64)
  (f64.div
   (local.get $0)
   (local.get $1)
  )
 )
)

יש הרבה אישורי אופטימיזציה, ו-Module#optimize() משתמש בקבוצות ברירת המחדל הספציפיות של האופטימיזציה ושל כיווץ. להתאמה אישית מלאה, צריך להשתמש בכלי שורת הפקודה wasm-opt.

ביצוע אופטימיזציה באמצעות כלי שורת הפקודה Wam-opt

כדי לבצע התאמה אישית מלאה של הכרטיסים לשימוש, Binaryen כוללת את כלי שורת הפקודה wasm-opt. כדי לקבל רשימה מלאה של אפשרויות האופטימיזציה, כדאי לעיין בהודעת העזרה של הכלי. הכלי wasm-opt הוא כנראה הכלי הפופולרי ביותר מבין הכלים, והוא משמש מספר שרשראות של מהדרים לביצוע אופטימיזציה של קוד Wasm, כולל Emscripten, J2CL, Kotlin/Wasm, dart2wasm, wasm-pack ועוד.

wasm-opt --help

כדי לתת לכם מושג לגבי הכרטיסים, הנה כמה דוגמאות שניתן להבין ללא ידע מקצועי:

  • CodeFolding: כדי למנוע קוד כפול על ידי מיזוג שלו (לדוגמה, אם שתי זרועות if כוללות הוראות משותפות בחלקן).
  • DeadArgumentElimination: אם הקישור הזמן של האופטימיזציה מאפשר להסיר ארגומנטים לפונקציה אם היא תמיד נקראת עם אותם קבועים.
  • MinifyImportsAndExports: מקטינה אותם ל-"a", "b".
  • DeadCodeElimination: הסרת קוד מת.

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

הדגמה (דמו)

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

מסקנות

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

אישורים

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