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

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

רקע על Binaryen

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

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

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

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

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

קלט של 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
 )
)

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

כלי הפיתוח של Binaryen

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

  • binaryen.js: ספריית JavaScript עצמאית שחושפת methods של 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 לקובץ אחד, ומחבר את הייבוא התואם לייצוא תוך כדי כך. כמו חבילה של 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, התוכן של הפונקציה.

יש עוד כמה פרטים שצריך להבין ולפרט, ומסמכי התיעוד של Binaryen יכולים לעזור לכם לנווט במרחב הזה, אבל בסופו של דבר, עבור האופרטור + של ExampleScript, תגיעו ל-method‏ 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 ב-DevTools Console, שבו מוצגות ארבע פונקציות: הוספה, חילוק, כפל וחיסור (אבל לא הקוד המת? שהוא לא חשוף).

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

אופטימיזציה באמצעות כלי שורת הפקודה wasm-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!

תודות

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