Mengompilasi dan mengoptimalkan Wasm dengan Binaryen

Binaryen adalah compiler dan toolchain library infrastruktur untuk WebAssembly, yang ditulis dalam C++. Pendekatan ini bertujuan untuk membuat mengompilasi ke WebAssemb yang intuitif, cepat, dan efektif. Dalam postingan ini, menggunakan contoh bahasa mainan sintetis yang disebut ExampleScript, pelajari cara menulis Modul WebAssembly di JavaScript menggunakan Binaryen.js API. Anda akan membahas dasar-dasar pembuatan modul, penambahan fungsi ke modul, dan mengekspor fungsi-fungsi lainnya dari modul. Ini akan memberi Anda pengetahuan tentang keseluruhan mekanisme kompilasi bahasa pemrograman sebenarnya ke WebAssembly. Selain itu, Anda akan mempelajari cara mengoptimalkan modul Wasm baik dengan Binaryen.js maupun di command line dengan wasm-opt.

Latar Belakang tentang Biner

Biner memiliki API C dalam satu {i>header<i}, dan juga dapat digunakan dari JavaScript. Model ini menerima input di Formulir WebAssembly, tetapi juga menerima persyaratan grafik alur kontrol untuk kompiler yang lebih memilih opsi tersebut.

Representasi perantara (IR) adalah struktur data atau kode yang digunakan secara internal oleh kompilator atau mesin virtual untuk merepresentasikan kode sumber. Biner IR internal menggunakan struktur data yang ringkas dan dirancang untuk paralel pembuatan dan pengoptimalan kode, menggunakan semua inti CPU yang tersedia. IR Biner dikompilasi ke WebAssembly karena menjadi bagian dari WebAssembly.

Pengoptimal biner memiliki banyak penerusan yang dapat meningkatkan ukuran dan kecepatan kode. Ini pengoptimalan bertujuan untuk membuat Biner cukup handal untuk digunakan sebagai compiler backend itu sendiri. Alat ini mencakup pengoptimalan khusus WebAssembly (yang kompilator serbaguna mungkin tidak bisa dilakukan), yang bisa Anda anggap sebagai Wasm minifikasi.

AssemblyScript sebagai contoh pengguna Binaryen

Biner digunakan oleh sejumlah proyek, misalnya, AssemblyScript, yang menggunakan Binaryen untuk mengompilasi dari bahasa mirip TypeScript langsung ke WebAssembly. Coba contoh di playground AssemblyScript.

Input AssemblyScript:

export function add(a: i32, b: i32): i32 {
  return a + b;
}

Kode WebAssembly yang sesuai dalam bentuk tekstual yang dihasilkan oleh 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
 )
)

Playground AssemblyScript yang menampilkan kode WebAssembly yang dihasilkan berdasarkan contoh sebelumnya.

Toolchain Binaryen

Toolchain Binaryen menawarkan sejumlah alat yang berguna untuk JavaScript pengembang dan pengguna baris perintah. {i>Subset<i} dari alat ini tercantum dalam mengikuti; tindakan daftar lengkap alat yang ada tersedia di file README project.

  • binaryen.js: Library JavaScript mandiri yang mengekspos metode Biner untuk membuat dan mengoptimalkan modul Wasm. Untuk build, lihat binaryen.js di npm (atau mengunduhnya langsung dari GitHub atau unpkg).
  • wasm-opt: Alat command line yang memuat WebAssembly dan menjalankan Binaryen IR meneruskannya.
  • wasm-as dan wasm-dis: Alat command line untuk merakit dan membongkar WebAssembly.
  • wasm-ctor-eval: Alat command line yang dapat menjalankan fungsi (atau bagian dari ) pada waktu kompilasi.
  • wasm-metadce: Alat command line untuk menghapus bagian dari file Wasm secara fleksibel cara yang tergantung pada bagaimana modul digunakan.
  • wasm-merge: Alat command line yang menggabungkan beberapa file Wasm menjadi satu file file, yang menghubungkan impor terkait ke ekspor. Seperti untuk JavaScript, tapi untuk Wasm.

Mengompilasi ke WebAssembly

Mengompilasi satu bahasa ke bahasa lain umumnya melibatkan beberapa langkah, yang paling yang penting tercantum dalam daftar berikut:

  • Analisis leksikal: Pisahkan kode sumber menjadi token.
  • Analisis sintaksis: Membuat hierarki sintaksis abstrak.
  • Analisis semantik: Periksa error dan terapkan aturan bahasa.
  • Pembuatan kode menengah: Membuat representasi yang lebih abstrak.
  • Pembuatan kode: Menerjemahkan ke bahasa target.
  • Pengoptimalan kode khusus target: Mengoptimalkan target.

Di dunia Unix, alat yang sering digunakan untuk mengompilasi adalah lex dan yacc:

  • lex (Lexical Analyzer Generator): lex adalah alat yang menghasilkan leksikal analisa, juga dikenal sebagai lexer atau pemindai. Dibutuhkan serangkaian ekspresi dan tindakan terkait sebagai input, dan menghasilkan kode untuk penganalisis leksikal yang mengenali pola dalam kode sumber input.
  • yacc (Yet Another Compiler Compiler): yacc adalah alat yang membuat parser untuk analisis sintaksis. Dibutuhkan deskripsi tata bahasa formal dari bahasa pemrograman sebagai input dan menghasilkan kode untuk parser. Parser biasanya menghasilkan hierarki sintaksis abstrak (AST) yang mewakili struktur hierarki kode sumber.

Contoh yang berhasil

Mengingat cakupan postingan ini, tidak mungkin membahas pemrograman yang lengkap bahasa, jadi demi kesederhanaan, pertimbangkan bahasa pemrograman sintetis yang disebut ExampleScript yang bekerja dengan mengekspresikan dan operasi generik melalui contoh konkret.

  • Untuk menulis fungsi add(), Anda membuat kode contoh penambahan apa pun, misalnya 2 + 3.
  • Untuk menulis fungsi multiply(), Anda menulis, misalnya, 6 * 12.

Sesuai dengan peringatan dini, sama sekali tidak berguna, namun cukup sederhana sehingga leksikalnya analyzer menjadi ekspresi reguler tunggal: /\d+\s*[\+\-\*\/]\s*\d+\s*/.

Selanjutnya, perlu ada parser. Sebenarnya, versi yang sangat sederhana dari pohon sintaks abstrak dapat dibuat menggunakan ekspresi reguler dengan grup pengambilan bernama: /(?<first_operand>\d+)\s*(?<operator>[\+\-\*\/])\s*(?<second_operand>\d+)/.

Perintah ExampleScript adalah satu perintah per baris, sehingga parser dapat memproses kode berdasarkan baris dengan memisahkan karakter baris baru. Ini cukup untuk memeriksa tiga langkah dari daftar berbutir sebelumnya, yaitu analisis leksikal, sintaksis analisis , dan analisis semantik. Kode untuk langkah-langkah ini ada di mengikuti listingan.

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

Pembuatan kode menengah

Program ExampleScript dapat direpresentasikan sebagai hierarki sintaksis abstrak (meskipun cukup sederhana), langkah selanjutnya adalah membuat dan representasi perantara. Langkah pertama adalah buat modul baru di Binaryen:

const module = new binaryen.Module();

Setiap baris hierarki sintaks abstrak berisi triple yang terdiri dari firstOperand, operator, dan secondOperand. Untuk masing-masing keempat model operator di ExampleScript, yaitu +, -, *, /, fungsi perlu ditambahkan ke modul dengan metode Module#addFunction() Binaryen. Parameter Metode Module#addFunction() adalah sebagai berikut:

  • name: string, mewakili nama fungsi.
  • functionType: Signature, mewakili tanda tangan fungsi.
  • varTypes: Type[], menunjukkan lokal tambahan, dalam urutan tertentu.
  • body: Expression, konten fungsi.

Ada beberapa detail lainnya untuk dilepaskan dan diuraikan dan Dokumentasi biner dapat membantu Anda menavigasi ruang, tetapi pada akhirnya, untuk + ExampleScript Anda akan menggunakan metode Module#i32.add() sebagai salah satu dari tersedia operasi bilangan bulat. Penambahan membutuhkan dua operand, summand pertama dan kedua. Untuk agar benar-benar dapat dipanggil, harus disediakan diekspor dengan 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');

Setelah memproses hierarki sintaks abstrak, modul ini berisi empat metode, tiga model yang menggunakan bilangan bulat, yaitu add() berdasarkan Module#i32.add(), subtract() berdasarkan Module#i32.sub(), multiply() berdasarkan Module#i32.mul(), dan pencilan divide() berdasarkan Module#f64.div() karena ExampleScript juga berfungsi dengan hasil floating point.

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 `/`.

Jika Anda berurusan dengan code base yang sebenarnya, terkadang akan ada kode mati yang tidak akan dipanggil. Untuk memasukkan kode mati secara artifisial (yang akan dioptimalkan dan dihilangkan di langkah berikutnya) dalam contoh berjalan contohScript kompilasi ke Wasm, maka fungsi yang tidak diekspor akan berfungsi.

// 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)),
  ]),
);

Compiler hampir siap sekarang. Hal ini tidak sepenuhnya diperlukan, tetapi pastinya praktik yang baik untuk memvalidasi modul dengan metode Module#validate().

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

Mendapatkan kode Wasm yang dihasilkan

Kepada mendapatkan kode Wasm yang dihasilkan, ada dua metode di Binaryen untuk mendapatkan representasi tekstual sebagai file .wat dalam S-expression sebagai format yang dapat dibaca manusia, dan representasi biner sebagai file .wasm yang dapat langsung dijalankan di browser. Kode biner dapat dijalankan langsung di {i>browser<i}. Untuk melihat bahwa perintah itu berhasil, mencatat ekspor dapat membantu.

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

Representasi tekstual lengkap untuk program ExampleScript dengan keempatnya tercantum dalam daftar berikut ini. Perhatikan bagaimana kode yang mati masih ada, tetapi tidak ditampilkan sesuai dengan screenshot 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)
  )
 )
)

Screenshot ekspor modul WebAssembly di DevTools Console menunjukkan empat fungsi: menambah, membagi, mengalikan, dan mengurangi (tetapi bukan kode mati yang tidak diekspos).

Mengoptimalkan WebAssembly

Binaryen menawarkan dua cara untuk mengoptimalkan kode Wasm. Satu di Binaryen.js, dan satu untuk baris perintah. Solusi yang pertama menerapkan kumpulan standar pengoptimalan aturan secara default dan memungkinkan Anda menetapkan tingkat pengoptimalan dan penyusutan, kedua secara {i>default<i} tidak menggunakan aturan, tetapi memungkinkan penyesuaian penuh, artinya, dengan eksperimen yang memadai, Anda dapat menyesuaikan setelan untuk hasil yang optimal berdasarkan kode Anda.

Mengoptimalkan dengan Binaryen.js

Cara paling mudah untuk mengoptimalkan modul Wasm dengan Binaryen adalah dengan memanggil metode Module#optimize() Binaryen.js secara langsung, dan jika perlu menyetel optimalkan dan tingkat penyusutan.

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

Cara ini akan menghapus kode mati yang diperkenalkan sebelumnya, sehingga representasi tekstual versi Wasm dari mainan ExampleScript contoh tidak lagi memuatnya. Perhatikan juga bagaimana pasangan local.set/get dihapus oleh langkah pengoptimalan SimplifyLocals (berbagai pengoptimalan terkait lokal) dan Penyedot Debu (menghapus kode yang jelas-jelas tidak diperlukan), dan return dihapus oleh RemoveUnusedBrs (menghapus jeda dari lokasi yang tidak diperlukan).

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

Ada banyak kartu pengoptimalan, dan Module#optimize() menggunakan fitur optimalkan dan penyusutan tertentu, bawaan yang sudah ditetapkan. Untuk penyesuaian sepenuhnya, Anda perlu menggunakan alat command line wasm-opt.

Mengoptimalkan dengan alat command line wasm-opt

Untuk penyesuaian penuh kartu yang akan digunakan, Binaryen menyertakan Alat command line wasm-opt. Untuk mendapatkan daftar lengkap opsi pengoptimalan yang memungkinkan, periksa pesan bantuan alat tersebut. Alat wasm-opt mungkin yang paling populer alat, dan digunakan oleh beberapa toolchain compiler untuk mengoptimalkan kode Wasm, termasuk Emscripten, J2CL, Kotlin/Wasm, dart2wasm, wasm-pack, dan lainnya.

wasm-opt --help

Untuk memberikan gambaran tentang kartu-kartu tersebut, berikut adalah kutipan dari beberapa kartu yang dapat dipahami tanpa pengetahuan ahli:

  • CodeFolding: Menghindari kode duplikat dengan menggabungkannya (misalnya, jika dua if grup memiliki beberapa petunjuk bersama di ujungnya).
  • DeadArgumentElimination: Penerusan pengoptimalan waktu link untuk menghapus argumen ke suatu fungsi jika selalu dipanggil dengan konstanta yang sama.
  • MinifyImportsAndExports: Memperkecilnya ke "a", "b".
  • DeadCodeElimination: Menghapus kode yang mati.

Terdapat buku resep pengoptimalan tersedia dengan beberapa tips untuk mengidentifikasi beragam penanda yang lebih penting dan layak dicoba terlebih dahulu. Misalnya, terkadang menjalankan wasm-opt berulang kali berulang kali akan menyusutkan input tersebut semakin banyak. Dalam kasus semacam itu, menjalankan dengan Flag --converge terus melakukan iterasi sampai tidak ada pengoptimalan lebih lanjut dan titik tetap adalah tercapai.

Demo

Untuk melihat cara kerja konsep yang diperkenalkan dalam postingan ini, coba gunakan fungsi demo yang menyediakan input ExampleScript yang dapat Anda pikirkan. Juga pastikan untuk lihat kode sumber demo.

Kesimpulan

Binaryen menyediakan toolkit yang canggih untuk mengompilasi bahasa ke WebAssembly dan mengoptimalkan kode yang dihasilkan. Library JavaScript dan alat command line-nya menawarkan fleksibilitas dan kemudahan penggunaan. Posting ini menunjukkan prinsip-prinsip inti dari Kompilasi Wasm, yang menyoroti efektivitas dan potensi Binaryen untuk pengoptimalan maksimum. Meskipun banyak opsi untuk menyesuaikan antarmuka pengoptimalan memerlukan pengetahuan mendalam tentang internal Wasm, biasanya pengaturan {i>default<i} sudah bekerja dengan baik. Dengan demikian, selamat mengompilasi dan mengoptimalkan dengan Binaryen!

Ucapan terima kasih

Postingan ini ditinjau oleh Alon Zakai, Thomas Lively dan Rachel Andrew.