Mengompilasi dan mengoptimalkan Wasm dengan Binaryen

Binaryen adalah library infrastruktur compiler dan toolchain untuk WebAssembly, yang ditulis dalam C++. Library ini bertujuan untuk membuat kompilasi ke WebAssembly menjadi 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 dari modul. Hal ini akan memberi Anda pengetahuan tentang mekanisme keseluruhan untuk mengompilasi bahasa pemrograman yang sebenarnya ke WebAssembly. Selain itu, Anda akan mempelajari cara mengoptimalkan modul Wasm dengan Binaryen.js dan di command line dengan wasm-opt.

Latar belakang tentang Binaryen

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

Representasi perantara (IR) adalah struktur data atau kode yang digunakan secara internal oleh compiler atau virtual machine untuk merepresentasikan 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 merupakan subset dari WebAssembly.

Pengoptimal Binaryen memiliki banyak penerusan yang dapat meningkatkan ukuran dan kecepatan kode. Pengoptimalan ini bertujuan untuk membuat Binaryen cukup canggih untuk digunakan sebagai backend compiler itu sendiri. Hal ini mencakup pengoptimalan khusus WebAssembly (yang mungkin tidak dilakukan oleh compiler tujuan umum), yang dapat Anda anggap sebagai pengoptimalan Wasm.

AssemblyScript sebagai contoh pengguna Binaryen

Binaryen digunakan oleh sejumlah project, misalnya, AssemblyScript, yang menggunakan Binaryen untuk mengompilasi dari bahasa seperti TypeScript langsung ke WebAssembly. Coba contohnya di playground AssemblyScript.

Input AssemblyScript:

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

Kode WebAssembly yang sesuai dalam bentuk teks 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 bagi developer JavaScript dan pengguna command line. Sebagian alat ini tercantum di berikut; daftar lengkap alat yang disertakan 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 pass IR Binaryen di dalamnya.
  • 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 dari fungsi) pada waktu kompilasi.
  • wasm-metadce: Alat command line untuk menghapus bagian file Wasm dengan cara yang fleksibel yang bergantung pada cara modul digunakan.
  • wasm-merge: Alat command line yang menggabungkan beberapa file Wasm menjadi satu file, yang menghubungkan impor yang sesuai ke ekspor saat melakukannya. Seperti bundler 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 leksik: Membagi kode sumber menjadi 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.

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

  • lex (Lexical Analyzer Generator): lex adalah alat yang menghasilkan penganalisis leksikal, yang juga dikenal sebagai lexer atau pemindai. Ini memerlukan serangkaian ekspresi reguler dan tindakan yang sesuai sebagai input, dan menghasilkan kode untuk penganalisis leksikografis yang mengenali pola dalam kode sumber input.
  • yacc (Yet Another Compiler Compiler): yacc adalah alat yang menghasilkan parser untuk analisis sintaksis. Model ini menggunakan 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 untuk membahas bahasa pemrograman lengkap, jadi untuk memudahkan, pertimbangkan bahasa pemrograman sintetis yang sangat terbatas dan tidak berguna yang disebut ExampleScript yang berfungsi dengan mengekspresikan operasi umum 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 awal, benar-benar tidak berguna, tetapi cukup sederhana untuk analis leksikal-nya menjadi satu ekspresi reguler: /\d+\s*[\+\-\*\/]\s*\d+\s*/.

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

Perintah ExampleScript adalah satu per baris, sehingga parser dapat memproses kode per baris dengan memisahkan karakter baris baru. Hal ini cukup untuk memeriksa tiga langkah pertama dari daftar berbutir sebelumnya, yaitu analisis leksikografis, 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 menengah

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

const module = new binaryen.Module();

Setiap baris hierarki sintaksis abstrak berisi tiga elemen yang terdiri dari firstOperand, operator, dan secondOperand. Untuk setiap dari empat kemungkinan operator di ExampleScript, yaitu, +, -, *, /, fungsi baru 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 lokalitas tambahan, dalam urutan yang diberikan.
  • body: Expression, konten fungsi.

Ada beberapa detail lainnya yang perlu diurai dan dipecah, dan dokumentasi Binaryen dapat membantu Anda menjelajahi ruang, tetapi pada akhirnya, untuk operator + ExampleScript, Anda akan berakhir di metode Module#i32.add() sebagai salah satu dari beberapa operasi bilangan bulat yang tersedia. Penambahan memerlukan dua operand, penjumlah pertama dan kedua. Agar fungsi dapat dipanggil, fungsi tersebut 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 ini berisi empat metode, tiga bekerja dengan bilangan bulat, yaitu add() berdasarkan Module#i32.add(), subtract() berdasarkan Module#i32.sub(), multiply() berdasarkan Module#i32.mul(), dan outlier 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 basis kode sebenarnya, terkadang akan ada kode mati yang tidak pernah dipanggil. Untuk memperkenalkan kode mati secara artifisial (yang akan dioptimalkan dan dihilangkan pada langkah berikutnya) dalam contoh kompilasi ExampleScript yang sedang berjalan ke Wasm, menambahkan 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)),
  ]),
);

Sekarang compiler hampir siap. Hal ini tidak mutlak 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 ekspresi S sebagai format yang dapat dibaca manusia, dan representasi biner sebagai file .wasm yang dapat langsung dijalankan di browser. Kode biner dapat berjalan langsung di browser. Untuk melihat apakah proses ini berhasil, logging 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 di bawah ini. Perhatikan bahwa kode mati masih ada, tetapi tidak ditampilkan sesuai 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 Konsol DevTools untuk ekspor modul WebAssembly yang menampilkan empat fungsi: tambah, bagi, kali, dan kurangi (tetapi bukan kode mati yang tidak ditampilkan).

Mengoptimalkan WebAssembly

Binaryen menawarkan dua cara untuk mengoptimalkan kode Wasm. Satu di Binaryen.js itu sendiri, dan satu untuk command line. Yang pertama menerapkan kumpulan aturan pengoptimalan standar secara default dan memungkinkan Anda menetapkan tingkat pengoptimalan dan penyingkatan, dan yang kedua secara default tidak menggunakan aturan, tetapi memungkinkan penyesuaian penuh, yang berarti bahwa dengan eksperimen yang cukup, 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 langsung memanggil metode Module#optimize() dari Binaryen.js, dan secara opsional menetapkan tingkat pengoptimalan dan penyingkatan.

// 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 secara artifisial sebelumnya, sehingga representasi tekstual dari contoh mainan ExampleScript versi Wasm tidak lagi berisinya. Perhatikan juga cara pasangan local.set/get dihapus oleh langkah pengoptimalan SimplifyLocals (pengoptimalan terkait lokalitas lainnya) dan Vacuum (menghapus kode yang 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 putaran pengoptimalan, dan Module#optimize() menggunakan kumpulan default tingkat pengoptimalan dan penyingkatan tertentu. Untuk penyesuaian penuh, 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 optimasi yang mungkin, periksa 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, berikut adalah cuplikan dari beberapa kartu yang dapat dipahami tanpa pengetahuan pakar:

  • CodeFolding: Menghindari kode duplikat dengan menggabungkannya (misalnya, jika dua arm if memiliki beberapa petunjuk bersama di bagian akhirnya).
  • DeadArgumentElimination: Link time optimization pass untuk menghapus argumen ke fungsi jika selalu dipanggil dengan konstanta yang sama.
  • MinifyImportsAndExports: Melakukan minifikasi ke "a", "b".
  • DeadCodeElimination: Menghapus kode mati.

Ada cookbook pengoptimalan yang tersedia dengan beberapa tips untuk mengidentifikasi flag mana yang lebih penting dan layak dicoba terlebih dahulu. Misalnya, terkadang menjalankan wasm-opt berulang kali akan memperkecil input lebih lanjut. Dalam kasus tersebut, menjalankan dengan tanda --converge akan terus melakukan iterasi hingga tidak ada pengoptimalan lebih lanjut dan titik tetap tercapai.

Demo

Untuk melihat penerapan konsep yang diperkenalkan dalam postingan ini, mainkan demo tersemat yang memberikan input ExampleScript apa pun yang dapat Anda pikirkan. Pastikan juga untuk melihat kode sumber demo.

Kesimpulan

Binaryen menyediakan toolkit canggih untuk mengompilasi bahasa ke WebAssembly dan mengoptimalkan kode yang dihasilkan. Library JavaScript dan alat command line-nya menawarkan fleksibilitas dan kemudahan penggunaan. Postingan ini menunjukkan prinsip inti 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 sudah berfungsi dengan baik. Dengan demikian, selamat mengompilasi dan mengoptimalkan dengan Binaryen.

Ucapan terima kasih

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