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 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 giúp quá trình biên dịch WebAssembly trở nên trực quan, nhanh chóng và hiệu quả. Trong bài đăng này, sử dụng ví dụ về ngôn ngữ đồ chơi tổng hợp có tên là ExampleScript, hãy tìm hiểu cách viết các mô-đun WebAssembly trong JavaScript bằng API Binaryen.js. Bạn sẽ đề cập đến các kiến thức cơ bản về cách tạo mô-đun, bổ sung chức năng vào mô-đun và xuất hàm từ mô-đun. Việc này sẽ giúp bạn nắm được kiến thức về cơ chế tổng thể để biên dịch ngôn ngữ lập trình thực tế lên 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ả với Binaryen.js lẫn trên dòng lệnh với 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. Lớp 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 muốn sử dụng phương thức này.

Biểu diễn trung gian (IR) là cấu trúc dữ liệu hoặc mã mà 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ấ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ả 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ần truyền có thể cải thiện kích thước và tốc độ mã. Mục đích của việc tối ưu hoá này là làm cho Binaryen đủ mạnh để tự dùng làm phần phụ trợ của trình biên dịch. Phiên bản này bao gồm các tính nă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 làm được), mà bạn có thể coi là giảm thiểu Wasm.

hộiScript là người dùng mẫu của Binaryen

Binaryen được sử dụng trong một số dự án, chẳng hạn như AssemblyScript. Dự án này sử dụng Binaryen để biên dịch trực tiếp từ một ngôn ngữ giống như TypeScript lên WebAssembly. Hãy thử ví dụ trong Kotlin Playground.

Phương thức đầu vào FPS:

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
 )
)

Nền tảng hộiScriptScriptScript cung cấp mã WebAssembly đã 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 số công cụ trong số này được liệt kê như sau; danh sách đầy đủ các công cụ chứa có trong tệp README của dự án.

  • binaryen.js: Một thư viện JavaScript độc lập hiển thị các phương thức Binaryen để tạo và tối ưu hoá mô-đun Wasm. Đối với các bản dựng, hãy xem binaryen.js trên npm (hoặc tải xuống trực tiếp từ GitHub hoặc unpkg).
  • wasm-opt: Công cụ dòng lệnh tải WebAssembly và chạy Binaryen IR truyền vào đó.
  • wasm-aswasm-dis: Các công cụ dòng lệnh tập hợp và tách rời WebAssembly.
  • wasm-ctor-eval: Công cụ dòng lệnh có thể thực thi các hàm (hoặc các 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 để 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 vào một tệp duy nhất, kết nối các tệp nhập tương ứng với dữ liệu xuất theo cách này. Giống như một bộ gói cho JavaScript, nhưng dành cho Wasm.

Biên dịch thành WebAssembly

Việc biên dịch một ngôn ngữ sang một ngôn ngữ khác thường bao gồm một số bước. Những bước quan trọng nhất được liệt kê trong danh sách sau đây:

  • Phân tích cú pháp: Chia mã nguồn thành các 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 nội dung 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 được dùng để biên dịch là lexyacc:

  • lex (Trình tạo trình phân tích Lexical Analyzer): lex là một công cụ tạo trình phân tích từ vựng, còn gọi là trình phân tích từ vựng hoặc trình quét. Phương thức này sử dụng một tập hợp các biểu thức chính quy và hành động tương ứng làm dữ liệu đầu vào, đồng thời tạo mã cho một trình phân tích từ vựng giúp 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 ra các trình phân tích cú pháp để phân tích cú pháp. Phương thức này lấy một đoạn 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 một trình phân tích cú pháp. Các trình phân tích cú pháp thường tạo 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ả

Với phạm vi của bài đăng này, chúng tôi không thể đề cập đến một ngôn ngữ lập trình đầy đủ. Vì vậy, để đơn giản, hãy cân nhắc sử dụng 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, hoạt động bằng cách diễn đạt các hoạt động chung thông qua ví dụ cụ thể.

  • Để viết hàm add(), bạn mã hoá một ví dụ về phép bổ sung bất kỳ, chẳng hạn như 2 + 3.
  • Để viết một hàm multiply(), bạn sẽ viết một hàm 6 * 12, chẳng hạn như.

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ó trở thành một biểu thức chính quy duy nhất: /\d+\s*[\+\-\*\/]\s*\d+\s*/.

Tiếp theo, bạn 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 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 thu thập có tên: /(?<first_operand>\d+)\s*(?<operator>[\+\-\*\/])\s*(?<second_operand>\d+)/.

Các lệnh ExampleScript mỗi dòng một lệnh, vì vậy, trình phân tích cú pháp có thể xử lý theo từng dòng mã bằng cách phân tách các ký tự dòng mới. Như vậ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ápphâ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 cây cú pháp trừu tượng (mặc dù là cây cú pháp khá đơn giản), bước tiếp theo là tạo 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à +, -, *, /, bạn 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. Thông số của các phương thức Module#addFunction() như sau:

  • name: string, biểu thị 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ộ bổ sung theo thứ tự nhất định.
  • body: một Expression, nội dung của hàm.

Bạn cũng cần biết thêm một số thông tin chi tiết để giải phóng và phân tích và tài liệu vềBinaryen có thể giúp bạn điều hướng trong 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 bằng phương thức Module#i32.add() dưới dạng một trong số toán tử số nguyên có sẵn. Phép cộng cần có hai toán hạng, tổng thứ nhất và tổng thứ hai. Để thực sự gọi được hàm này, 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 đó 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à ngoại lệ divide() 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ó những mã chết không bao giờ được gọi. Để đưa mã chết một cách giả tạo (sẽ được tối ưu hoá và loại bỏ ở bước sau) trong ví dụ đang chạy của quá trình biên dịch ExampleScript vào Wasm, hãy thêm một hàm không xuất sẽ thực hiện việc này.

// 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 hoàn toàn cần thiết, nhưng chắc chắn là một phương pháp 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ó hai phương thức trong Binaryen để lấy biểu diễn văn bản dưới dạng tệp .wat trong biểu thức S dưới dạng định dạng 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. Mã nhị phân có thể chạy trực tiếp trong trình duyệt. Để đảm bảo hiệu quả hoạt động, việc ghi nhật ký các dữ liệu xuất có thể giúp ích cho bạn.

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

Nội dung trình bày hoàn chỉnh bằng văn bản của một chương trình ExampleScript có cả 4 thao tác được liệt kê trong phần dưới đây. Lưu ý rằng vì sao mã đã tắ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 của dữ liệu xuất mô-đun WebAssembly cho thấy 4 hàm: cộng, chia, nhân và trừ (nhưng phần mã không hoạt động không hiển thị)

Tối ưu hoá WebAssembly

Binaryen cung cấp hai cách để tối ưu hoá mã Wasm. Một tệp trong chính Binaryen.js và một tệp cho dòng lệnh. Quy tắc trước á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à mức rút gọn, còn quy tắc sau mặc định không sử dụng quy tắc nào mà cho phép tuỳ chỉnh toàn bộ, nghĩa là khi thử nghiệm đủ, bạn có thể điều chỉnh chế độ cài đặt để đạt 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ô-đ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 độ rút 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();

Làm như vậy sẽ xoá mã đã chết được giới thiệu một cách giả tạo trước đó, vì vậy, bản trình bày dạng 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 ý thêm cách xoá các cặp local.set/get theo các bước tối ưu hoá SimplifyLocals (Tối ưu hoá khác liên quan đến cục bộ) và Vacuum (xoá mã rõ ràng là không cần thiết) và return được xoá bằng RemoveUnusedBrs (xoá 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áModule#optimize() sử dụng tập hợp mặc định cụ thể của các cấp độ tối ưu hoá và thu gọn. Để tuỳ chỉnh toàn bộ, 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 toàn bộ các thẻ và vé mà bạn sử dụng, Binaryen tích hợp 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ể có, 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 và được một số chuỗi công cụ biên dịch sử dụng để tối ưu hoá mã Wasm, bao gồm cả Emscripten, J2CL, Kotlin/Oncem, dart2wasm, wasm-pack và các công cụ khác.

wasm-opt --help

Để bạn có thể hình dung các thẻ và vé, sau đây là phần trích dẫn một số thẻ cũng dễ hiểu 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 nhóm if có một số hướng dẫn chung ở cuối).
  • DeadArgumentStats: Truyền tối ưu hoá thời gian liên kết để xoá các đối số khỏi một hàm nếu hàm đó luôn được gọi với cùng một hằng số.
  • MinifyImportsAndExports: Rút gọn chúng xuống "a", "b".
  • DeadCodeRemove: Xoá mã bị chết.

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

Xác nhận

Bài đăng này đã được Alon Zakai, Thomas kêu gọiRachel Andrew đánh giá.