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