Mengompilasi dan mengoptimalkan Wasm dengan Binaryen

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

Latar belakang di Binaryen

Binaryen memiliki C API yang intuitif dalam satu header, dan juga dapat digunakan dari JavaScript. Metode ini menerima input dalam bentuk WebAssembly, tetapi juga menerima grafik alur kontrol umum untuk compiler yang menginginkannya.

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

Pengoptimal biner memiliki banyak penerusan yang dapat meningkatkan ukuran dan kecepatan kode. Pengoptimalan ini bertujuan membuat Binaryen cukup andal untuk digunakan sebagai backend compiler dengan sendirinya. Ini mencakup pengoptimalan khusus WebAssembly (yang mungkin tidak dilakukan oleh compiler tujuan umum), yang dapat Anda anggap sebagai minifikasi Wasm.

AssemblyScript sebagai contoh pengguna Binaryen

Binaryen digunakan oleh sejumlah project, misalnya, AssemblyScript, yang menggunakan Binaryen untuk mengompilasi dari bahasa yang 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 dibuat 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 menampilkan kode WebAssembly yang dihasilkan berdasarkan contoh sebelumnya.

Toolchain Binaryen

Toolchain Binaryen menawarkan sejumlah alat yang berguna untuk developer JavaScript dan pengguna command line. Salah satu subset alat ini tercantum di berikut; daftar lengkap alat yang ditampung tersedia di file README project.

  • binaryen.js: Library JavaScript mandiri yang mengekspos metode Binaryen untuk membuat dan mengoptimalkan modul Wasm. Untuk build, lihat binaryen.js di npm (atau download langsung dari GitHub atau unpkg).
  • wasm-opt: Alat command line yang memuat WebAssembly dan menjalankan Binaryen IR yang diteruskan padanya.
  • wasm-as dan wasm-dis: Alat command line yang menyusun dan membongkar WebAssembly.
  • wasm-ctor-eval: Alat command line yang dapat menjalankan fungsi (atau bagian fungsi) pada waktu kompilasi.
  • wasm-metadce: Alat command line untuk menghapus bagian file Wasm dengan cara yang fleksibel, bergantung pada cara modul digunakan.
  • wasm-merge: Alat command line yang menggabungkan beberapa file Wasm ke dalam satu file, menghubungkan impor terkait ke ekspor seperti yang dilakukan. Seperti pemaket untuk JavaScript, tetapi untuk Wasm.

Mengompilasi ke WebAssembly

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

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

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

  • lex (Lexical Analyzer Generator): lex adalah alat yang menghasilkan penganalisis leksikal, juga dikenal sebagai lexer atau pemindai. API ini mengambil serangkaian ekspresi reguler dan tindakan yang sesuai sebagai input, serta menghasilkan kode untuk penganalisis leksikal yang mengenali pola dalam kode sumber input.
  • yacc (Yet Another Compiler Compiler): yacc adalah alat yang menghasilkan parser untuk analisis sintaksis. Fungsi ini memerlukan deskripsi tata bahasa formal bahasa pemrograman sebagai input dan menghasilkan kode untuk parser. Parser biasanya menghasilkan pohon sintaksis abstrak (AST) yang merepresentasikan struktur hierarki kode sumber.

Contoh yang berhasil

Mengingat cakupan postingan ini, mustahil untuk membahas bahasa pemrograman yang lengkap. Demi kemudahan, pertimbangkan bahasa pemrograman sintetis yang sangat terbatas dan tidak berguna, yang disebut ExampleScript, yang bekerja dengan mengekspresikan operasi generik melalui contoh konkret.

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

Sesuai dengan pra-peringatan, yang sama sekali tidak berguna, tetapi cukup sederhana sehingga analyzer leksikal menjadi ekspresi reguler tunggal: /\d+\s*[\+\-\*\/]\s*\d+\s*/.

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

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

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 tingkat menengah

Setelah program ExampleScript dapat direpresentasikan sebagai hierarki sintaksis abstrak (meskipun cukup sederhana), langkah selanjutnya adalah membuat representasi menengah abstrak. Langkah pertama adalah membuat modul baru di Binaryen:

const module = new binaryen.Module();

Setiap baris hierarki sintaksis abstrak berisi triple yang terdiri dari firstOperand, operator, dan secondOperand. Untuk masing-masing dari empat kemungkinan operator di ExampleScript, yaitu +, -, *, /, fungsi baru harus 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.

Terdapat beberapa detail lebih lanjut yang dapat diuraikan dan dokumentasi Binaryen dapat membantu Anda menjelajahi ruang. Namun, pada akhirnya, untuk operator + ExampleScript, Anda berakhir di metode Module#i32.add() sebagai salah satu dari beberapa operasi bilangan bulat yang tersedia. Penambahan memerlukan dua operand, summand pertama dan kedua. Agar benar-benar dapat dipanggil, fungsi harus 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 sintaksis abstrak, modul berisi empat metode, tiga metode 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 menangani code base yang sebenarnya, terkadang akan ada kode mati yang tidak pernah dipanggil. Untuk secara artifisial memperkenalkan kode yang mati (yang akan dioptimalkan dan dihapus pada langkah berikutnya) dalam contoh kompilasi ExampleScript yang sedang berjalan ke Wasm, penambahan fungsi yang tidak diekspor akan melakukan tugas tersebut.

// 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. Cara ini tidak sepenuhnya diperlukan, tetapi merupakan praktik yang baik untuk memvalidasi modul dengan metode Module#validate().

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

Mendapatkan kode Wasm yang dihasilkan

Untuk 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 browser. Untuk melihat keberhasilannya, pencatatan 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 keempat operasi tercantum dalam daftar berikut. Perhatikan bagaimana kode yang mati masih ada, tetapi tidak diekspos 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 DevTools Console dari ekspor modul WebAssembly yang menunjukkan empat fungsi: menambah, membagi, mengalikan, dan mengurangi (tetapi bukan kode mati yang tidak terekspos).

Mengoptimalkan WebAssembly

Binaryen menawarkan dua cara untuk mengoptimalkan kode Wasm. Satu di Binaryen.js, dan satu lagi untuk command line. Aturan pertama menerapkan kumpulan aturan pengoptimalan standar secara default dan memungkinkan Anda menetapkan tingkat pengoptimalan dan penyingkatan, sedangkan yang terakhir secara default tidak menggunakan aturan, tetapi memungkinkan penyesuaian penuh. Artinya, dengan cukup eksperimen, Anda dapat menyesuaikan setelan untuk hasil optimal berdasarkan kode.

Mengoptimalkan dengan Binaryen.js

Cara paling mudah untuk mengoptimalkan modul Wasm dengan Binaryen adalah dengan langsung memanggil metode Module#optimize() Binaryen.js, serta secara opsional menetapkan tingkat pengoptimalan dan 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();

Tindakan ini akan menghapus kode mati yang diperkenalkan sebelumnya secara artifisial sehingga representasi tekstual dari contoh mainan ExampleScript versi Wasm tidak lagi berisi kode tersebut. Perhatikan juga cara pasangan local.set/get dihapus dengan langkah pengoptimalan SimplifyLocals (berbagai pengoptimalan terkait perangkat lokal) dan Vacuum (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 kumpulan default tingkat pengoptimalan dan penyingkatan tertentu. Untuk penyesuaian sepenuhnya, Anda perlu menggunakan alat command line wasm-opt.

Pengoptimalan dengan alat command line wasm-opt

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

wasm-opt --help

Untuk memberi Anda gambaran tentang kartu ini, berikut kutipan beberapa hal yang dapat dipahami tanpa pengetahuan pakar:

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

Ada buku resep pengoptimalan yang tersedia dengan beberapa tips untuk mengidentifikasi tanda mana yang lebih penting dan patut dicoba terlebih dahulu. Misalnya, terkadang menjalankan wasm-opt berulang kali akan memperkecil input lebih lanjut. Dalam kasus seperti itu, menjalankan dengan tanda --converge akan terus melakukan iterasi sampai tidak ada pengoptimalan lebih lanjut yang terjadi dan titik tetap tercapai.

Demo

Untuk melihat penerapan konsep yang diperkenalkan dalam postingan ini, bereksperimenlah dengan demo tersemat yang menyediakan input ExampleScript apa pun yang dapat Anda pikirkan. Pastikan juga untuk melihat 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 serta kemudahan penggunaan. Postingan ini menunjukkan prinsip-prinsip inti dari kompilasi Wasm, yang menyoroti efektivitas dan potensi Binaryen untuk pengoptimalan maksimum. Meskipun banyak opsi untuk menyesuaikan pengoptimalan Binaryen memerlukan pengetahuan mendalam tentang internal Wasm, biasanya setelan default-nya sudah berfungsi dengan baik. Sekarang, selamat mengompilasi dan mengoptimalkan dengan Binaryen!

Ucapan terima kasih

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