Binaryen היא ספריית תשתית של מהדר וערכת כלים ל-WebAssembly, שנכתבה ב-C++. המטרה שלה היא להפוך את הידור הקוד ל-WebAssembly לאינטואיטיבי, מהיר ויעיל. במאמר הזה תלמדו איך לכתוב מודולים של WebAssembly ב-JavaScript באמצעות ה-API של Binaryen.js, באמצעות דוגמה לשפה מלאכותית פשוטה שנקראת ExampleScript. נסביר את העקרונות הבסיסיים של יצירת מודול, הוספת פונקציות למודול וייצוא פונקציות מהמודול. כך תוכלו להבין את המנגנונים הכלליים של הידור שפות תכנות בפועל ל-WebAssembly. בנוסף, תלמדו איך לבצע אופטימיזציה של מודולים של Wasm גם באמצעות Binaryen.js וגם בשורת הפקודה באמצעות wasm-opt
.
רקע בנושא בינארין
ל-Binaryen יש API ל-C אינטואיטיבי בכותרת אחת, ואפשר גם להשתמש בו מ-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
)
)
כלי הפיתוח של Binaryen
ערכת הכלים של 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
: כלי שורת פקודה שיכול להריץ פונקציות (או חלקים מפונקציות) בזמן הידור.wasm-metadce
: כלי שורת פקודה להסרת חלקים מקובצי Wasm באופן גמיש, בהתאם לאופן שבו נעשה שימוש במודול.wasm-merge
: כלי שורת פקודה שממזג כמה קובצי Wasm לקובץ אחד, ומחבר את הייבוא התואם לייצוא תוך כדי כך. כמו חבילה של JavaScript, אבל ל-Wasm.
הידור ל-WebAssembly
בדרך כלל, כדי להמיר קוד משפה אחת לשפה אחרת צריך לבצע כמה שלבים, והשלבים החשובים ביותר מפורטים ברשימה הבאה:
- ניתוח לקסיקל: פירוק קוד המקור לאסימונים.
- ניתוח תחביר: יצירת עץ תחביר מופשט.
- ניתוח סמנטי: בדיקת שגיאות ואכיפת כללי השפה.
- יצירת קוד ביניים: יצירת ייצוג מופשט יותר.
- יצירת קוד: תרגום לשפת היעד.
- אופטימיזציה של קוד ספציפי ליעד: אופטימיזציה להשגת היעד.
כלים ליצירת הידור (compiling) נפוצים בעולם Unix, הם lex
ו-yacc
:
lex
(Lexical Analyzer Generator):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 פועל גם עם תוצאות של נקודות צפות.
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
ב-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
.
אופטימיזציה באמצעות כלי שורת הפקודה 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 compilation, מדגיש את היעילות של Binaryen ואת הפוטנציאל שלה לאופטימיזציה מקסימלית. הרבה מהאפשרויות להתאמה אישית של האופטימיזציות של Binaryen דורשות ידע מעמיק על הרכיבים הפנימיים של Wasm, אבל בדרך כלל ההגדרות שמוגדרות כברירת מחדל כבר עובדות מצוין. ושיהיה לכם זמן טוב בזמן הידור ובביצוע אופטימיזציה באמצעות Binaryen!
תודות
הפוסט הזה נבדק על ידי אלון זקאי, תומאס ליבליי ורייצ'ל אנדרו.