Biên dịch và tối ưu hoá Wasm bằng Binaryen

Binaryen là một thư viện cơ sở hạ tầng của chuỗi công cụ và trình biên dịch cho WebAssembly, được viết bằng C++. Thư viện này nhằm mục đích giúp quá trình 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, hãy sử dụng ví dụ về một ngôn ngữ đồ chơi tổng hợp có tên là ExampleScript để tìm hiểu cách viết các mô-đun WebAssembly trong JavaScript bằng API Binaryen.js. Bạn sẽ tìm hiểu các 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ẽ cung cấp cho bạn kiến thức về cơ chế tổng thể của việc biên dịch ngôn ngữ lập trình thực tế cho 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ó API C trực quan trong một tiêu đề duy nhất và cũng có thể được sử dụng từ JavaScript. Công cụ 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 các trình biên dịch ưu tiên điều đó.

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 thị 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 thành 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 có thể cải thiện kích thước và tốc độ mã. Mục đích của các hoạt động tối ưu hoá này là giúp Binaryen đủ mạnh để tự 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à 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 rút gọn Wasm.

AssemblyScript là một ví dụ về người dùng Binaryen

Một số dự án sử dụng Binaryen, ví dụ: AssemblyScript sử dụng Binaryen để biên dịch trực tiếp từ ngôn ngữ giống TypeScript sang WebAssembly. Thử ví dụ trong sân chơi AssemblyScript.

Dữ liệu đầ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
 )
)

Sân chơi BoardScript hiển thị mã WebAssembly được tạo dựa trên ví dụ trước.

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 tập hợp con của các công cụ này được liệt kê trong phần sau; danh sách đầy đủ các công cụ có trong đó có trên tệp README của dự án.

  • binaryen.js: Thư viện JavaScript độc lập hiển thị 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-aswasm-dis: Các công cụ dòng lệnh giúp tập hợp và phân ly 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 hàm) tại thời điểm biên dịch.
  • wasm-metadce: Công cụ dòng lệnh giúp 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 tệp nhập tương ứng với các tệp xuất theo cách tương tự. Giống như một gói cho JavaScript, nhưng cho Wasm.

Biên dịch lên 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 thuật ngữ: Chia mã nguồn thành mã thông báo.
  • Phân tích cú pháp: Tạo 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 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ụ biên dịch thường dùng là lexyacc:

  • lex (Trình tạo trình phân tích cú pháp): lex là một công cụ tạo trình phân tích cú pháp, còn gọi là trình phân tích cú pháp hoặc trình quét. Phương thứ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 dữ liệu đầu vào, đồng thời tạo mã cho một công cụ phân tích từ vựng nhận dạng các mẫu trong mã nguồn đầu vào.
  • yacc (Trình biên dịch trình biên dịch khác): 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. Công cụ này 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 dữ liệu đầu vào và tạo mã cho 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) đại diện cho cấu trúc phân cấp của mã nguồn.

Một ví dụ hiệu quả

Do 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 phải viết mã cho một ví dụ về phép cộng bất kỳ nào, chẳng hạn như 2 + 3.
  • Ví dụ: để viết hàm multiply(), bạn viết 6 * 12.

Theo cảnh báo trước, việc này hoàn toàn vô ích, nhưng đủ đơn giản để trình phân tích từ vựng của công cụ này trở thành 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 một biểu thức chính quy có nhóm thu thập tên: /(?<first_operand>\d+)\s*(?<operator>[\+\-\*\/])\s*(?<second_operand>\d+)/.

Các lệnh ExampleScript là một lệnh trên mỗi dòng, vì vậy, trình phân tích cú pháp có thể xử lý mã theo dòng bằng cách tách các ký tự dòng mới. Điều này đủ để kiểm tra ba bước đầu tiên trong danh sách đầu dòng trước đó, cụ thể là phân tích từ vựng, phân tích cú phápphân tích ngữ nghĩa. Mã cho các bước này có trong danh sách sau đây.

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 cây cú pháp trừu tượng (mặc dù khá đơn giản), bước tiếp theo là tạo một bản trình bày 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 bao gồm firstOperand, operatorsecondOperand. Đối với mỗi toán tử trong số 4 toán tử có thể có trong ExampleScript, tức là +, -, *, /, một hàm mới cần được thêm 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: string, đại diện cho tên của hàm.
  • functionType: Signature, đại diện cho chữ ký của hàm.
  • varTypes: Type[], cho biết các cục bộ khác, theo thứ tự nhất định.
  • body: Expression, nội dung của hàm.

Còn một số thông tin chi tiết khác để thư giãn và phân tích, đồng thời tài liệu về Binaryen có thể giúp bạn điều hướng không gian, 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() dưới dạng một trong nhiều toán tử số nguyên có sẵn. Tính năng cộng yêu cầu hai toán hạng, số hạng đầu tiên và số hạng thứ hai. Để hàm thực sự có thể gọi, 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 chứa 4 phương thức, trong đó 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()divide() ngoại lai dựa trên Module#f64.div() vì ExampleScript cũng hoạt động với kết quả dấu phẩy động.

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ã chết không bao giờ được gọi. Để đưa mã chết vào (sẽ được tối ưu hoá và loại bỏ ở bước sau) trong 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 gần như đã sẵn sàng. Việc này không bắt buộc, nhưng bạn nên xác thực mô-đun bằng phương thức Module#validate().

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

Nhận mã Wasm thu được

Để lấy mã Wasm thu được, có hai phương thức trong Binaryen để lấy biểu diễn dạng văn bản dưới dạng tệp .wat trong biểu thức S ở định dạng mà con người có thể đọc được và biểu diễn dạng 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. Để xem liệu cách này có hiệu quả hay không, bạn có thể ghi lại 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);

Bản trình bày đầy đủ bằng văn bản cho một chương trình ExampleScript với cả bốn thao tác được liệt kê như sau. Lưu ý cách mã chết 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)
  )
 )
)

Ảnh chụp màn hình Bảng điều khiển Công cụ cho nhà phát triển về các mục xuất mô-đun WebAssembly cho thấy 4 hàm: cộng, chia, nhân và trừ (nhưng không phải mã chết không được hiển thị).

Tối ưu hoá WebAssembly

Binaryen cung cấp hai cách để tối ưu hoá mã Wasm. Một mã trong chính Binaryen.js và một dành cho dòng lệnh. Đối tượng thứ nhất áp dụng bộ quy tắc tối ưu hoá tiêu chuẩn theo mặc định, cho phép bạn đặt mức tối ưu hoá và mức độ thu nhỏ, còn đối tượng thứ hai theo mặc định không sử dụng quy tắc nào, nhưng thay vào đó sẽ cho phép tuỳ chỉnh hoàn toàn, nghĩa là khi có đủ thử nghiệm, bạn có thể điều chỉnh 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à gọi trực tiếp phương thức Module#optimize() của Binaryen.js và tuỳ ý đặt tối ưu hoá và mức độ thu gọn.

// 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();

Việc này sẽ xoá mã chết được đưa vào trước đó một cách nhân tạo, vì vậy, bản trình bày văn bản của phiên bản Wasm của ví dụ về đồ chơi ExampleScript không còn chứa mã đó nữa. Ngoài ra, hãy lưu ý cách các bước tối ưu hoá SimplifyLocals (tối ưu hoá khác liên quan đến nội dung cục bộ) và Vacuum (xoá mã rõ ràng là không cần thiết) xoá các cặp local.set/getreturn được xoá bằng RemoveUnusedBrs (xoá các điểm ngắt khỏi các 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 tập hợp mặc định cụ thể của các cấp độ tối ưu hoá và giảm kích thước. Để 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 đầy đủ các thẻ và vé sẽ sử dụng, Binaryen bao gồm công cụ dòng lệnh wasm-opt. Để xem danh sách đầy đủ các tuỳ chọn tối ưu hoá có thể áp dụng, hãy xem thông báo trợ giúp của công cụ này. Công cụ wasm-opt có lẽ là công cụ phổ biến nhất, được một số chuỗi công cụ trình biên dịch dùng để tối ưu hoá mã Wasm, trong đó có Emscripten, J2CL, Kotlin/Wasm, dart2wasm, wasm-pack và nhiều công cụ khác.

wasm-opt --help

Để giúp bạn hiểu rõ hơn về thẻ và vé, sau đây là một số thẻ và vé 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 2 nhóm if có một số hướng dẫn chung ở phía cuối).
  • DeadArgumentElimination: Lệnh 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ùng một hằng số.
  • MinifyImportsAndExports: Rút gọn các tệp này thành "a", "b".
  • DeadCodeExclude: Xoá mã lỗi.

Có một cuốn sách nấu ăn về tối ưu hoá có một số mẹo để xác định cờ nào quan trọng hơn và đáng thử trước tiên. Ví dụ: đôi khi, việc chạy wasm-opt liên tục nhiều lần sẽ làm giảm dữ liệu đầu vào hơn nữa. Trong những trường hợp như vậy, việc chạy bằng 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 xảy ra 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 nó bất kỳ đầu vào ExampleScript nào mà bạn có thể nghĩ ra. 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 ngôn ngữ sang WebAssembly và tối ưu hoá mã thu được. Thư viện JavaScript và các công cụ dòng lệnh của trình duyệt này mang lại 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, làm nổi bật hiệu quả và tiềm năng tối ưu hoá tối đa của Binaryen. Mặc dù nhiều tuỳ chọn để tuỳ chỉnh tính năng tối ưu hoá của Binaryen yêu cầu kiến thức chuyên sâu về nội bộ của Wasm, nhưng thường thì chế độ cài đặt mặc định đã hoạt động rất tốt. Bằng cách đó, hãy biên dịch và tối ưu hoá bằng Binaryen!

Lời cảm ơn

Bài đăng này đã được Alon Zakai, Thomas LivelyRachel Andrew xem xét.