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

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

רקע בנושא בינארין

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

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

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

שרשרת הכלים הבינארית

'צרור הכלים של Binaryen' מציע מספר כלים שימושיים גם ל-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: כלי שורת הפקודה שיכול להפעיל פונקציות (או חלקים של בזמן הידור (compile).
  • wasm-metadce: כלי שורת הפקודה להסרת חלקים מקובצי Wasm בצורה גמישה שתלויה באופן השימוש במודול.
  • wasm-merge: כלי שורת הפקודה שממזג כמה קובצי Wasm לקובץ אחד וכך לקשר את הייבוא התואם לייצוא בזמן שהוא עושה זאת. לסמן לייק על Bundler ל-JavaScript, אבל ל-Wasm.

הידור ל-WebAssembly

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

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

כלים נפוצים ליצירת הידור (Unix) הם lex ו- yacc:

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

דוגמה לעבודה

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

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

כפי שהוזכר לפני האזהרה, הוא לא שימושי לחלוטין, אבל פשוט מספיק כדי לעמוד בדרישות המילוניות את Analytics כביטוי רגולרי אחד: /\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() של בינארין. הפרמטרים אלה ה-methods של Module#addFunction():

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

יש עוד כמה פרטים להירגע ולפרק מסמכים בינאריים יכול לעזור לך לנווט במרחב, אבל בסופו של דבר, ב+ של 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 compilation ל-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)),
  ]),
);

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

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

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

שפת תרגום לקבל את קוד ה-Wasm שמתקבל, בבינארין יש שתי שיטות להשגת ייצוג טקסטואלי כקובץ .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 היא להפעיל ישירות את השיטה 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, קוטלין/Wasm, dart2wasm, Wasm-pack ועוד.

wasm-opt --help

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

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

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

הדגמה (דמו)

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

מסקנות

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

אישורים

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