Binaryen היא ספריית תשתית של מהדר ושרשרת כלים ל-WebAssembly, שנכתבה ב-C++. המטרה שלה היא להפוך את ההידור ל-WebAssembly לאינטואיטיבי, מהיר ויעיל. בפוסט הזה, באמצעות הדוגמה של שפת צעצוע סינתטית בשם ExampleScript, נסביר איך לכתוב מודולים של WebAssembly ב-JavaScript באמצעות Binaryen.js API. תלמדו על היסודות של יצירת מודולים, הוספת פונקציות למודול וייצוא פונקציות מהמודול. כך תוכלו לקבל מידע על המנגנונים הכלליים של הידור שפות תכנות בפועל ל-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
)
)
ערכת הכלים 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. הפרמטרים של ה-methods Module#addFunction()
הם:
-
name
:string
, מייצג את שם הפונקציה. -
functionType
:Signature
, מייצג את החתימה של הפונקציה. -
varTypes
:Type[]
, מציין שפות נוספות, לפי הסדר שצוין. -
body
:Expression
, התוכן של הפונקציה.
יש עוד פרטים שצריך להסביר, ומסמכי Binaryen יכולים לעזור לכם להבין את הנושא. אבל בסופו של דבר, עבור האופרטור +
של 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 עובד גם עם תוצאות של נקודה צפה.
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 היא לקרוא ישירות לשיטה 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
כדי להתאים אישית את המעברים שבהם רוצים להשתמש, אפשר להשתמש בכלי שורת הפקודה wasm-opt
של Binaryen. רשימה מלאה של אפשרויות האופטימיזציה האפשריות זמינה בהודעת העזרה של הכלי. הכלי wasm-opt
הוא כנראה הפופולרי ביותר מבין הכלים, והוא נמצא בשימוש בכמה שרשראות כלים של קומפיילרים כדי לבצע אופטימיזציה של קוד Wasm, כולל Emscripten, J2CL, Kotlin/Wasm, dart2wasm, wasm-pack ועוד.
wasm-opt --help
כדי שתבינו איך הכרטיסים האלה נראים, הנה קטע של כמה כרטיסים שאפשר להבין בלי ידע מקצועי:
- CodeFolding: מאפשר למזג קוד כפול כדי להימנע מכפילות (לדוגמה, אם לשני
if
arms יש כמה הוראות משותפות בסוף שלהם). - DeadArgumentElimination: שלב אופטימיזציה של זמן הקישור להסרת ארגומנטים לפונקציה אם היא תמיד נקראת עם אותם קבועים.
- MinifyImportsAndExports: מצמצם אותם ל-
"a"
,"b"
. - DeadCodeElimination: הסרת קוד מת.
יש מדריך לאופטימיזציה עם כמה טיפים שיעזרו לכם להבין אילו מהדגלים השונים חשובים יותר וכדאי לנסות אותם קודם. לדוגמה, לפעמים הפעלה חוזרת של wasm-opt
מקטינה עוד יותר את הקלט. במקרים כאלה, הפעלת הפקודה עם הדגל --converge
תמשיך לבצע איטרציות עד שלא תתבצע אופטימיזציה נוספת ויושג מצב יציב.
הדגמה (דמו)
כדי לראות את המושגים שמוצגים בפוסט הזה בפעולה, אפשר לשחק עם ההדגמה המוטמעת ולספק לה כל קלט של ExampleScript שעולה על דעתכם. חשוב גם לצפות בקוד המקור של ההדגמה.
מסקנות
Binaryen מספקת ערכת כלים יעילה להידור שפות ל-WebAssembly ולאופטימיזציה של הקוד שמתקבל. ספריית JavaScript וכלי שורת הפקודה שלה מציעים גמישות ונוחות שימוש. בפוסט הזה הדגמנו את עקרונות הליבה של קומפילציית Wasm, והדגשנו את היעילות והפוטנציאל של Binaryen לאופטימיזציה מקסימלית. הרבה מהאפשרויות להתאמה אישית של האופטימיזציות של Binaryen דורשות ידע מעמיק על הפנימיות של Wasm, אבל בדרך כלל הגדרות ברירת המחדל כבר עובדות מצוין. שיהיה לכם בהצלחה בהידור ובאופטימיזציה באמצעות Binaryen!
תודות
הפוסט הזה נבדק על ידי אלון זקאי, תומס לייבלי ורייצ'ל אנדרו.