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

Binaryen là một trình biên dịch và chuỗi công cụ thư viện cơ sở hạ tầng cho WebAssembly, được viết bằng C++. Hộp cát về quyền riêng tư sẽ giúp biên dịch WebAssemb một cách trực quan, nhanh chóng và hiệu quả. Trong bài đăng này, việc sử dụng ví dụ về một 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 sử dụng API Binaryen.js. Bạn sẽ đề cập đến kiến thức cơ bản về việc tạo mô-đun, thêm hàm vào mô-đun và xuất mô-đun khỏi mô-đun. Việc này sẽ cung cấp cho bạn kiến thức về cơ chế biên dịch ngôn ngữ lập trình thực tế sang WebAssembly. Hơn nữa, 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ó tính năng trực quan API C trong một tiêu đề duy nhất và cũng có thể được dùng từ JavaScript. Hàm này chấp nhận dữ liệu đầu vào trong Biểu mẫu WebAssembly, nhưng cũng chấp nhận các nguyên tắc chung biểu đồ luồng điều khiển cho các trình biên dịch thích điều đó.

Biểu diễn trung gian (IR) là cấu trúc dữ liệu hoặc mã được sử dụng được trình biên dịch hoặc máy ảo nội bộ để biểu thị mã nguồn. của Binaryen IR nội bộ sử dụng cấu trúc dữ liệu nhỏ gọn và được thiết kế để hoàn toàn song song tạo và tối ưu hoá mã, 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ần truyền có thể cải thiện kích thước và tốc độ của mã. Các tối ưu hoá nhằm làm cho Binaryen đủ mạnh để được dùng làm trình biên dịch phần phụ trợ. Nó bao gồm các hoạt động tối ưu hoá dành riêng cho WebAssembly ( 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à Wasm giảm kích thước.

hộiScript, với tư cách người dùng mẫu của Binaryen

Binaryen được sử dụng bởi một số dự án, 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 hội tập lệnh.

Đầu vào MultiplexScript:

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ả JavaScript và người dùng dòng lệnh. Một nhóm nhỏ các công cụ này được liệt kê trong đang theo dõi; thời gian danh sách đầy đủ các công cụ bên trong có sẵn trên 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 nhị phân với tạo và tối ưu hoá các mô-đun Wasm. Đối với 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 chuyển qua.
  • wasm-aswasm-dis: Các công cụ dòng lệnh tập hợ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 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 một cách linh hoạt phụ 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 dữ liệu nhập tương ứng với tệp xuất khi thực hiện việc này. Thích gói cho JavaScript, nhưng dành cho Wasm.

Biên dịch lên 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 nhiều bước, cách quan trọng đượ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 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 được dùng để biên dịch là lexyacc:

  • lex (Trình tạo trình phân tích từ vựng): lex là một công cụ tạo từ vựng trình phân tích, còn được gọi là từ điển hoặc máy quét. Bạn sẽ cần một tập hợp các tham số biểu thức và hành động tương ứng làm đầ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 (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. Mã này sử dụng một đoạn mô tả ngữ pháp chính thức của 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 sản xuất 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, bạn không thể đề cập đến một chương trình hoàn chỉnh ngôn ngữ, vì vậy để đơn giản, hãy cân nhắc một cách rất hạn chế và vô ích ngôn ngữ lập trình tổng hợp có tên là ExampleScript, hoạt động bằng cách biểu thị phép toán chung thông qua ví dụ cụ thể.

  • Để viết hàm add(), bạn viết mã ví dụ về phép cộng bất kỳ nào, giả sử 2 + 3.
  • Để viết một hàm multiply(), bạn có thể viết 6 * 12 chẳng hạn.

Theo cảnh báo trước, việc này hoàn toàn vô ích, nhưng đủ đơn giản để từ vựng trình phân tích 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ế, phiên bản rất đơn giản của bạn có thể tạo 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 tên: /(?<first_operand>\d+)\s*(?<operator>[\+\-\*\/])\s*(?<second_operand>\d+)/.

Các lệnh ExampleScript được nhập trên mỗi dòng một lần, do đó, 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 các ký tự dòng mới. Điều này là đủ để kiểm tra ba bước so với danh sách dấu đầu dòng ở phần trước, cụ thể là phân tích từ vựng, cú pháp Analyticsphân tích ngữ nghĩa. Mã cho các bước này nằm 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 diễn dưới dạng một cây cú pháp trừu tượng (mặc dù là một cách thức khá đơn giản), bước tiếp theo là tạo một bản tóm tắt biểu diễn trung gian. Bước đầu tiên là tạo 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. Cho mỗi yếu tố trong số bốn chỉ số có thể có trong ExampleScript, tức là +, -, *, /, một hàm 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 Các phương thức Module#addFunction() như sau:

  • name: string, đại diện cho tên 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.

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

// 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. Không hoàn toàn cần thiết, nhưng chắc chắn 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ả

Người nhận nhận được mã Wasm kết quả, có hai phương thức trong Binaryen để lấy thông tin biểu thị bằng văn bản dưới dạng tệp .wat trong S-biểu thức dưới dạng định dạng con người có thể đọc được và đại 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ể là chạy trực tiếp trong trình duyệt. Để biết phương pháp này có hoạt động không, hãy ghi nhật ký các tệp xuất có thể của chúng tôi.

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 đủ dạng văn bản cho chương trình ExampleScript với cả bốn hoạt động được liệt kê trong bảng sau. Hãy để ý cách mã chết vẫn còn ở đó, nhưng không được hiển thị như ả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 các tệp xuất mô-đun WebAssembly trên Bảng điều khiển của Công cụ cho nhà phát triển, trong đó cho thấy 4 hàm: cộng, chia, nhân và trừ (nhưng không phải là mã chết không bị lộ).

Tối ưu hoá WebAssembly

Binaryen cung cấp hai cách để tối ưu hoá mã Wasm. Một trong chính Binaryen.js, và một cho dòng lệnh. Phương pháp đầu vào áp dụng tập hợp chuẩn tối ưu hoá các quy tắc này theo mặc định và cho phép bạn đặt mức tối ưu hoá và mức độ thu nhỏ, cũng như 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, có nghĩa là với đủ thử nghiệm, bạn có thể điều chỉnh cài đặt cho phù hợp kết quả tối ưu dựa trên mã của bạn.

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à gọi trực tiếp phương thức Module#optimize() của Binaryen.js, và không bắt buộc thiết lập 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();

Làm như vậy sẽ xoá mã chết đượ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 của ví dụ về đồ chơi ExampleScript không chứa tệp đó lâu hơn. Ngoài ra, xin lưu ý cách cặp local.set/get bị xoá bởi các bước tối ưu hoá SimplifyLocals (các hoạt động tối ưu hoá khác liên quan đến cục bộ) và Hút chân không (xoá mã rõ ràng không cần thiết) và return bị 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ó rất nhiều thẻ tối ưu hoá, và Module#optimize() sử dụng các cấp độ tối ưu hoá và thu nhỏ cụ thể mặc định bộ. Để tuỳ chỉnh hoàn toàn, 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. Để có một 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ụ đó. Công cụ wasm-opt có lẽ là công cụ phổ biến nhất công cụ 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 Emscripten, J2CL, Kotlin/Wasm, dart2wasm, wasm-pack và các thuộc tính khác.

wasm-opt --help

Để giúp bạn hình dung về thẻ và vé, sau đây là phần trích dẫn của một số thẻ và vé đều có thể hiểu được nếu 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 đều có một số hướng dẫn được chia sẻ).
  • DeadArgumentLoại bỏ: Truyền tối ưu hoá thời gian liên kết để xoá đối số đối với một hàm nếu hàm đó luôn được gọi với cùng hằng số.
  • MinifyImportsAndExports: Giảm kích thước chúng xuống còn "a", "b".
  • DeadCodeExclude: Xoá mã lỗi.

Có một sổ tay tối ưu hoá bạn sẽ thấy một số mẹo giúp xác định xem cờ nào hiệu quả hơn quan trọng và đáng thử trước tiên. Ví dụ: đôi khi chạy wasm-opt lặp đi lặp lại sẽ 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 --converge cờ tiếp tục lặp lại cho đến khi không tối ưu hoá thêm nữa và điểm cố định là đạt được.

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 thử nghiệm với các bản minh hoạ cung cấp cho nó bất kỳ đầu vào ExampleScript nào mà bạn có thể nghĩ đến. Đồng thời, 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ã kết quả. Thư viện JavaScript và công cụ dòng lệnh đem lại sự linh hoạt và dễ sử dụng. Bài đăng này đã trình bày các nguyên tắc cốt lõi của Biên dịch Wasm, nêu bật hiệu quả và tiềm năng của Binaryen tối ưu hoá tối đa. Mặc dù có nhiều lựa chọn để tuỳ chỉnh tệp Binaryen tối ưu hoá đòi hỏi kiến thức chuyên sâu về bên trong Wasm, thường là cài đặt mặc định đã hoạt động tuyệt vời. Theo đó, chúc bạn biên dịch và tối ưu hoá thành công với Binaryen!

Xác nhận

Bài đăng này do Alon Zakai xem xét. Thomas BeauRachel Andrew.