Binaryen là một thư viện cơ sở hạ tầng trình biên dịch và chuỗi công cụ cho WebAssembly, được viết bằng C++. Thư viện này nhằm mục đích giúp việc biên dịch sang WebAssembly trở nên trực quan, nhanh chóng và hiệu quả. Trong bài đăng này, bằng cách sử dụng ví dụ về một ngôn ngữ đồ chơi giả tạo có tên là ExampleScript, hãy tìm hiểu cách viết các mô-đun WebAssembly bằng JavaScript bằng cách sử dụng API Binaryen.js. Bạn sẽ tìm hiểu những kiến thức cơ bản về cách tạo mô-đun, thêm hàm vào mô-đun và xuất hàm từ mô-đun. Điều này sẽ giúp bạn hiểu rõ về cơ chế tổng thể của việc biên dịch các ngôn ngữ lập trình thực tế sang WebAssembly. Ngoài ra, bạn sẽ tìm hiểu cách tối ưu hoá các mô-đun Wasm bằng cả Binaryen.js và trên dòng lệnh bằng wasm-opt
.
Thông tin cơ bản về Binaryen
Binaryen có một API C trực quan trong một tiêu đề duy nhất và cũng có thể được dùng từ JavaScript. Nền tảng này chấp nhận dữ liệu đầu vào ở dạng WebAssembly, nhưng cũng chấp nhận biểu đồ luồng điều khiển chung cho những trình biên dịch ưu tiên dữ liệu đầu vào đó.
Biểu diễn trung gian (IR) là cấu trúc dữ liệu hoặc mã được trình biên dịch hoặc máy ảo sử dụng nội bộ để biểu diễn mã nguồn. IR nội bộ của Binaryen sử dụng các cấu trúc dữ liệu nhỏ gọn và được thiết kế để tạo và tối ưu hoá mã hoàn toàn song song, sử dụng tất cả các lõi CPU có sẵn. IR của Binaryen biên dịch xuống WebAssembly do là một tập hợp con của WebAssembly.
Trình tối ưu hoá của Binaryen có nhiều lượt truyền có thể cải thiện kích thước và tốc độ của mã. Những hoạt động tối ưu hoá này nhằm mục đích giúp Binaryen đủ mạnh để tự sử dụng làm phần phụ trợ của trình biên dịch. Công cụ này bao gồm các hoạt động tối ưu hoá dành riêng cho WebAssembly (mà các trình biên dịch đa năng có thể không thực hiện được), bạn có thể coi đây là hoạt động giảm thiểu Wasm.
AssemblyScript là một ví dụ về người dùng Binaryen
Binaryen được một số dự án sử dụng, chẳng hạn như AssemblyScript. Dự án này sử dụng Binaryen để biên dịch từ một ngôn ngữ tương tự như TypeScript trực tiếp sang WebAssembly. Thử ví dụ trong AssemblyScript Playground.
Đầu vào AssemblyScript:
export function add(a: i32, b: i32): i32 {
return a + b;
}
Mã WebAssembly tương ứng ở dạng văn bản do Binaryen tạo:
(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
)
)
Chuỗi công cụ Binaryen
Chuỗi công cụ Binaryen cung cấp một số công cụ hữu ích cho cả nhà phát triển JavaScript và người dùng dòng lệnh. Một số công cụ trong số này được liệt kê trong phần sau; danh sách đầy đủ các công cụ có trong đó có trong tệp README
của dự án.
binaryen.js
: Một thư viện JavaScript độc lập cung cấp các phương thức Binaryen để tạo và tối ưu hoá các mô-đun Wasm. Đối với các bản dựng, hãy xem binaryen.js trên npm (hoặc tải trực tiếp từ GitHub hoặc unpkg).wasm-opt
: Công cụ dòng lệnh tải WebAssembly và chạy các lượt truyền Binaryen IR trên đó.wasm-as
vàwasm-dis
: Công cụ dòng lệnh để lắp ráp và tháo rời WebAssembly.wasm-ctor-eval
: Công cụ dòng lệnh có thể thực thi các hàm (hoặc một phần của các hàm) tại thời gian biên dịch.wasm-metadce
: Công cụ dòng lệnh để xoá các phần của tệp Wasm theo cách linh hoạt tuỳ thuộc vào cách sử dụng mô-đun.wasm-merge
: Công cụ dòng lệnh hợp nhất nhiều tệp Wasm thành một tệp duy nhất, kết nối các mục nhập tương ứng với các mục xuất khi thực hiện. Giống như một trình đóng gói cho JavaScript, nhưng dành cho Wasm.
Biên dịch sang WebAssembly
Việc biên dịch một ngôn ngữ sang ngôn ngữ khác thường bao gồm một số bước, trong đó các bước quan trọng nhất được liệt kê trong danh sách sau:
- Phân tích từ vựng: Chia mã nguồn thành các mã thông báo.
- Phân tích cú pháp: Tạo một cây cú pháp trừu tượng.
- Phân tích ngữ nghĩa: Kiểm tra lỗi và thực thi các quy tắc về ngôn ngữ.
- Tạo mã trung gian: Tạo một bản trình bày trừu tượng hơn.
- Tạo mã: Dịch sang ngôn ngữ đích.
- Tối ưu hoá mã theo mục tiêu cụ thể: Tối ưu hoá cho mục tiêu.
Trong thế giới Unix, các công cụ thường dùng để biên dịch là lex
và yacc
:
lex
(Trình tạo trình phân tích từ vựng):lex
là một công cụ tạo trình phân tích từ vựng, còn được gọi là trình phân tích cú pháp hoặc trình quét. Công cụ này lấy một tập hợp các biểu thức chính quy và các hành động tương ứng làm đầu vào, đồng thời tạo mã cho một trình phân tích từ vựng nhận dạng các mẫu trong mã nguồn đầu vào.yacc
(Yet Another Compiler Compiler):yacc
là một công cụ tạo trình phân tích cú pháp để phân tích cú pháp. Nó lấy nội dung mô tả ngữ pháp chính thức của một ngôn ngữ lập trình làm đầu vào và tạo mã cho một trình phân tích cú pháp. Trình phân tích cú pháp thường tạo ra cây cú pháp trừu tượng (AST) thể hiện cấu trúc phân cấp của mã nguồn.
Ví dụ minh hoạ
Trong phạm vi của bài đăng này, không thể đề cập đến một ngôn ngữ lập trình hoàn chỉnh. Vì vậy, để đơn giản, hãy xem xét một ngôn ngữ lập trình tổng hợp rất hạn chế và vô dụng có tên là ExampleScript. Ngôn ngữ này hoạt động bằng cách thể hiện các thao tác chung thông qua các ví dụ cụ thể.
- Để viết một hàm
add()
, bạn sẽ viết mã cho một ví dụ về phép cộng bất kỳ, chẳng hạn như2 + 3
. - Để viết một hàm
multiply()
, chẳng hạn như bạn viết6 * 12
.
Theo cảnh báo trước, hoàn toàn vô dụng, nhưng đủ đơn giản để trình phân tích từ vựng của nó là một biểu thức chính quy duy nhất: /\d+\s*[\+\-\*\/]\s*\d+\s*/
.
Tiếp theo, cần có một trình phân tích cú pháp. Trên thực tế, bạn có thể tạo một phiên bản rất đơn giản của cây cú pháp trừu tượng bằng cách sử dụng biểu thức chính quy với nhóm chụp có tên: /(?<first_operand>\d+)\s*(?<operator>[\+\-\*\/])\s*(?<second_operand>\d+)/
.
Mỗi dòng chỉ có một lệnh ExampleScript, vì vậy, trình phân tích cú pháp có thể xử lý mã theo từng dòng bằng cách phân tách theo ký tự dòng mới. Điều này là đủ để kiểm tra 3 bước đầu tiên trong danh sách dấu đầu dòng trước đó, cụ thể là phân tích từ vựng, phân tích cú pháp và phân tích ngữ nghĩa. Mã cho các bước này nằm trong danh sách sau.
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),
};
});
}
}
Tạo mã trung gian
Giờ đây, các chương trình ExampleScript có thể được biểu thị dưới dạng một cây cú pháp trừu tượng (mặc dù là một cây khá đơn giản), bước tiếp theo là tạo một biểu thị trung gian trừu tượng. Bước đầu tiên là tạo một mô-đun mới trong Binaryen:
const module = new binaryen.Module();
Mỗi dòng của cây cú pháp trừu tượng chứa một bộ ba gồm firstOperand
, operator
và secondOperand
. Đối với mỗi trong số 4 toán tử có thể có trong ExampleScript, tức là +
, -
, *
, /
, cần thêm một hàm mới vào mô-đun bằng phương thức Module#addFunction()
của Binaryen. Các tham số của phương thức Module#addFunction()
như sau:
name
: mộtstring
, đại diện cho tên của hàm.functionType
: mộtSignature
, đại diện cho chữ ký của hàm.varTypes
: mộtType[]
, cho biết các ngôn ngữ khác theo thứ tự đã cho.body
: mộtExpression
, nội dung của hàm.
Bạn cần tìm hiểu thêm một số thông tin chi tiết và tài liệu Binaryen có thể giúp bạn tìm hiểu về không gian này, nhưng cuối cùng, đối với toán tử +
của ExampleScript, bạn sẽ kết thúc ở phương thức Module#i32.add()
như một trong số các thao tác số nguyên có sẵn.
Phép cộng yêu cầu 2 toán hạng, số hạng thứ nhất và số hạng thứ hai. Để hàm thực sự có thể gọi được, bạn cần xuất hàm bằng 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');
Sau khi xử lý cây cú pháp trừu tượng, mô-đun này sẽ chứa 4 phương thức, 3 phương thức hoạt động với số nguyên, cụ thể là add()
dựa trên Module#i32.add()
, subtract()
dựa trên Module#i32.sub()
, multiply()
dựa trên Module#i32.mul()
và divide()
dựa trên Module#f64.div()
vì ExampleScript cũng hoạt động với kết quả là số thực.
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 `/`.
Nếu bạn xử lý các cơ sở mã thực tế, đôi khi sẽ có mã không dùng đến và không bao giờ được gọi. Để đưa mã không dùng đến một cách giả tạo (sẽ được tối ưu hoá và loại bỏ ở bước sau) vào ví dụ đang chạy về quá trình biên dịch ExampleScript sang Wasm, bạn có thể thêm một hàm không được xuất.
// 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)),
]),
);
Trình biên dịch hiện gần như đã sẵn sàng. Việc này không hoàn toàn cần thiết, nhưng chắc chắn là một cách hay để xác thực mô-đun bằng phương thức Module#validate()
.
if (!module.validate()) {
throw new Error('Validation error');
}
Lấy mã Wasm kết quả
Để lấy mã Wasm thu được, có 2 phương thức trong Binaryen để lấy biểu diễn bằng văn bản dưới dạng tệp .wat
trong S-expression dưới dạng định dạng mà con người có thể đọc được và biểu diễn nhị phân dưới dạng tệp .wasm
có thể chạy trực tiếp trong trình duyệt. Bạn có thể chạy mã nhị phân trực tiếp trong trình duyệt. Để biết rằng thao tác này có hiệu quả, bạn có thể ghi nhật ký các tệp xuất.
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);
Sau đây là phần trình bày bằng văn bản đầy đủ cho một chương trình ExampleScript có cả 4 thao tác. Lưu ý rằng mã không dùng đến vẫn còn đó, nhưng không được hiển thị theo ảnh chụp màn hình của 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)
)
)
)
Tối ưu hoá WebAssembly
Binaryen cung cấp 2 cách để tối ưu hoá mã Wasm. Một trong Binaryen.js và một cho dòng lệnh. Cách đầu tiên áp dụng bộ quy tắc tối ưu hoá tiêu chuẩn theo mặc định và cho phép bạn đặt mức tối ưu hoá và giảm kích thước, còn cách thứ hai theo mặc định không sử dụng quy tắc nào, nhưng thay vào đó cho phép tuỳ chỉnh hoàn toàn. Điều này có nghĩa là với đủ thử nghiệm, bạn có thể điều chỉnh các chế độ cài đặt để có kết quả tối ưu dựa trên mã của mình.
Tối ưu hoá bằng Binaryen.js
Cách đơn giản nhất để tối ưu hoá một mô-đun Wasm bằng Binaryen là trực tiếp gọi phương thức Module#optimize()
của Binaryen.js và tuỳ ý đặt mức tối ưu hoá và mức thu nhỏ.
// 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();
Thao tác này sẽ xoá mã không dùng đến đã được đưa vào một cách giả tạo trước đó, do đó, bản trình bày bằng văn bản của phiên bản Wasm trong ví dụ về đồ chơi ExampleScript sẽ không còn chứa mã đó nữa. Cũng lưu ý cách các cặp local.set/get
bị xoá bằng các bước tối ưu hoá SimplifyLocals (các bước tối ưu hoá khác liên quan đến ngôn ngữ) và Vacuum (xoá mã không cần thiết rõ ràng), còn return
bị xoá bằng RemoveUnusedBrs (xoá các dấu ngắt khỏi những vị trí không cần thiết).
(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)
)
)
)
Có nhiều lượt tối ưu hoá và Module#optimize()
sử dụng các bộ mặc định của mức tối ưu hoá và thu nhỏ cụ thể. Để tuỳ chỉnh đầy đủ, bạn cần sử dụng công cụ dòng lệnh wasm-opt
.
Tối ưu hoá bằng công cụ dòng lệnh wasm-opt
Để tuỳ chỉnh hoàn toàn các lượt truyền sẽ được sử dụng, Binaryen bao gồm công cụ dòng lệnh wasm-opt
. Để biết danh sách đầy đủ các lựa chọn tối ưu hoá có thể có, hãy xem thông báo trợ giúp của công cụ. wasm-opt
có lẽ là công cụ phổ biến nhất trong số các công cụ này và được một số chuỗi công cụ trình biên dịch sử dụng để tối ưu hoá mã Wasm, bao gồm Emscripten, J2CL, Kotlin/Wasm, dart2wasm, wasm-pack và các công cụ khác.
wasm-opt --help
Để giúp bạn hiểu rõ hơn về các lượt truyền, sau đây là một đoạn trích về một số lượt truyền mà bạn có thể hiểu được mà không cần kiến thức chuyên môn:
- CodeFolding: Tránh mã trùng lặp bằng cách hợp nhất mã (ví dụ: nếu hai
if
có một số hướng dẫn chung ở cuối). - DeadArgumentElimination: Truyền tối ưu hoá thời gian liên kết để xoá các đối số cho một hàm nếu hàm đó luôn được gọi bằng các hằng số giống nhau.
- MinifyImportsAndExports: Giảm thiểu chúng thành
"a"
,"b"
. - DeadCodeElimination: Xoá mã không dùng đến.
Có một sách hướng dẫn tối ưu hoá với một số mẹo để xác định cờ nào trong số các cờ là quan trọng hơn và đáng thử trước. Ví dụ: đôi khi việc chạy wasm-opt
nhiều lần sẽ làm giảm thêm kích thước của đầu vào. Trong những trường hợp như vậy, việc chạy với cờ --converge
sẽ tiếp tục lặp lại cho đến khi không có hoạt động tối ưu hoá nào khác và đạt đến một điểm cố định.
Bản minh hoạ
Để xem các khái niệm được giới thiệu trong bài đăng này đang hoạt động, hãy chơi với bản minh hoạ được nhúng bằng cách cung cấp cho bản minh hoạ này bất kỳ đầu vào ExampleScript nào mà bạn có thể nghĩ đến. Ngoài ra, hãy nhớ xem mã nguồn của bản minh hoạ.
Kết luận
Binaryen cung cấp một bộ công cụ mạnh mẽ để biên dịch các ngôn ngữ sang WebAssembly và tối ưu hoá mã kết quả. Thư viện JavaScript và các công cụ dòng lệnh của công cụ này mang đến sự linh hoạt và dễ sử dụng. Bài đăng này minh hoạ các nguyên tắc cốt lõi của quá trình biên dịch Wasm, nêu bật tính hiệu quả và tiềm năng tối đa hoá của Binaryen. Mặc dù nhiều lựa chọn để tuỳ chỉnh các hoạt động tối ưu hoá của Binaryen đòi hỏi kiến thức chuyên sâu về nội bộ của Wasm, nhưng thông thường, các chế độ cài đặt mặc định đã hoạt động rất hiệu quả. Chúc bạn biên dịch và tối ưu hoá thành công với Binaryen!
Lời cảm ơn
Bài đăng này được Alon Zakai, Thomas Lively và Rachel Andrew xem xét.