Compilazione e ottimizzazione di Wasm con Binaryen

Binaryen è una libreria di infrastrutture per toolchain e compilatori per WebAssembly, scritta in C++. Il suo obiettivo è rendere la compilazione in WebAssembly intuitiva, veloce ed efficace. In questo post, utilizzando l'esempio di un linguaggio di programmazione sintetico chiamato ExampleScript, scopri come scrivere moduli WebAssembly in JavaScript utilizzando l'API Binaryen.js. Verranno trattati i principi di base della creazione di moduli, dell'aggiunta di funzioni al modulo e dell'esportazione di funzioni dal modulo. In questo modo, acquisirai conoscenze sulle meccaniche generali di compilazione dei linguaggi di programmazione effettivi in WebAssembly. Inoltre, imparerai a ottimizzare i moduli Wasm sia con Binaryen.js sia dalla riga di comando con wasm-opt.

Informazioni su Binaryen

Binaryen ha un'API C intuitiva in un unico header e può anche essere utilizzato da JavaScript. Accetta input in formato WebAssembly, ma anche un grafico del flusso di controllo generale per i compilatori che lo preferiscono.

Una rappresentazione intermedia (IR) è la struttura di dati o il codice utilizzato internamente da un compilatore o una macchina virtuale per rappresentare il codice sorgente. L'IR interno di Binaryen utilizza strutture di dati compatte ed è progettato per la generazione e l'ottimizzazione del codice completamente parallele, utilizzando tutti i core della CPU disponibili. L'IR di Binaryen viene compilato in WebAssembly perché è un sottoinsieme di WebAssembly.

L'ottimizzatore di Binaryen ha molti passaggi che possono migliorare le dimensioni e la velocità del codice. Queste ottimizzazioni mirano a rendere Binaryen sufficientemente potente da essere utilizzato come backend del compilatore. Include ottimizzazioni specifiche per WebAssembly (che i compilatori generici potrebbero non eseguire), che puoi considerare come la minificazione di Wasm.

AssemblyScript come utente di esempio di Binaryen

Binaryen viene utilizzato da diversi progetti, ad esempio AssemblyScript, che utilizza Binaryen per compilare da un linguaggio simile a TypeScript direttamente in WebAssembly. Prova l'esempio nel playground di AssemblyScript.

Input AssemblyScript:

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

Codice WebAssembly corrispondente in formato testuale generato da 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
 )
)

Il playground AssemblyScript che mostra il codice WebAssembly generato in base all'esempio precedente.

Toolchain Binaryen

La toolchain Binaryen offre una serie di strumenti utili sia per gli sviluppatori JavaScript sia per gli utenti della riga di comando. Un sottoinsieme di questi strumenti è elencato di seguito; l'elenco completo degli strumenti contenuti è disponibile nel file README del progetto.

  • binaryen.js: una libreria JavaScript autonoma che espone i metodi Binaryen per creare e ottimizzare i moduli Wasm. Per le build, vedi binaryen.js su npm (o scaricalo direttamente da GitHub o unpkg).
  • wasm-opt: strumento a riga di comando che carica WebAssembly ed esegue i passaggi di Binaryen IR.
  • wasm-as e wasm-dis: strumenti a riga di comando che assemblano e disassemblano WebAssembly.
  • wasm-ctor-eval: strumento a riga di comando che può eseguire funzioni (o parti di funzioni) in fase di compilazione.
  • wasm-metadce: Strumento a riga di comando per rimuovere parti di file Wasm in modo flessibile a seconda di come viene utilizzato il modulo.
  • wasm-merge: strumento a riga di comando che unisce più file Wasm in un unico file, collegando le importazioni alle esportazioni corrispondenti. Come un bundler per JavaScript, ma per Wasm.

Compilazione in WebAssembly

La compilazione di una lingua in un'altra in genere prevede diversi passaggi, i più importanti sono elencati di seguito:

  • Analisi lessicale:suddivide il codice sorgente in token.
  • Analisi della sintassi:crea un albero della sintassi astratta.
  • Analisi semantica:controlla la presenza di errori e applica le regole linguistiche.
  • Generazione di codice intermedio:crea una rappresentazione più astratta.
  • Generazione di codice:traduci nella lingua di destinazione.
  • Ottimizzazione del codice specifico per il target:ottimizza per il target.

Nel mondo Unix, gli strumenti utilizzati di frequente per la compilazione sono lex e yacc:

  • lex (Lexical Analyzer Generator): lex è uno strumento che genera analizzatori lessicali, noti anche come lexer o scanner. Prende come input un insieme di espressioni regolari e azioni corrispondenti e genera il codice per un analizzatore lessicale che riconosce i pattern nel codice sorgente di input.
  • yacc (Yet Another Compiler Compiler): yacc è uno strumento che genera analizzatori per l'analisi della sintassi. Prende come input una descrizione formale della grammatica di un linguaggio di programmazione e genera il codice per un parser. I parser in genere producono alberi di sintassi astratta (AST) che rappresentano la struttura gerarchica del codice sorgente.

Esempio elaborato

Data l'ampiezza di questo post, è impossibile trattare un linguaggio di programmazione completo, quindi, per semplicità, prendiamo in considerazione un linguaggio di programmazione sintetico molto limitato e inutile chiamato ExampleScript, che funziona esprimendo operazioni generiche attraverso esempi concreti.

  • Per scrivere una funzione add(), codifica un esempio di addizione qualsiasi, ad esempio 2 + 3.
  • Per scrivere una funzione multiply(), scrivi, ad esempio, 6 * 12.

Come da preavviso, completamente inutile, ma abbastanza semplice da consentire all'analizzatore lessicale di essere una singola espressione regolare: /\d+\s*[\+\-\*\/]\s*\d+\s*/.

Successivamente, deve essere presente un parser. In realtà, una versione molto semplificata di un albero della sintassi astratta può essere creata utilizzando un'espressione regolare con gruppi di acquisizione denominati: /(?<first_operand>\d+)\s*(?<operator>[\+\-\*\/])\s*(?<second_operand>\d+)/.

I comandi ExampleScript sono uno per riga, quindi il parser può elaborare il codice riga per riga dividendo le righe in base ai caratteri di nuova riga. Questo è sufficiente per controllare i primi tre passaggi dell'elenco puntato precedente, ovvero analisi lessicale, analisi sintattica e analisi semantica. Il codice per questi passaggi è riportato nel seguente elenco.

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

Generazione di codice intermedio

Ora che i programmi ExampleScript possono essere rappresentati come un albero della sintassi astratta (anche se piuttosto semplificato), il passaggio successivo consiste nel creare una rappresentazione intermedia astratta. Il primo passaggio consiste nel creare un nuovo modulo in Binaryen:

const module = new binaryen.Module();

Ogni riga dell'albero della sintassi astratta contiene una tripla composta da firstOperand, operator e secondOperand. Per ciascuno dei quattro possibili operatori in ExampleScript, ovvero +, -, *, /, è necessario aggiungere una nuova funzione al modulo con il metodo Module#addFunction() di Binaryen. I parametri dei metodi Module#addFunction() sono i seguenti:

  • name: un string, rappresenta il nome della funzione.
  • functionType: un Signature, rappresenta la firma della funzione.
  • varTypes: un Type[], indica altre impostazioni internazionali nell'ordine specificato.
  • body: un Expression, i contenuti della funzione.

Ci sono altri dettagli da analizzare e la documentazione di Binaryen può aiutarti a orientarti, ma alla fine, per l'operatore + di ExampleScript, si arriva al metodo Module#i32.add() come una delle diverse operazioni con numeri interi disponibili. L'addizione richiede due operandi, il primo e il secondo addendo. Affinché la funzione sia effettivamente chiamabile, deve essere esportata con 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');

Dopo l'elaborazione dell'albero della sintassi astratta, il modulo contiene quattro metodi, tre che funzionano con numeri interi, ovvero add() basato su Module#i32.add(), subtract() basato su Module#i32.sub(), multiply() basato su Module#i32.mul() e il valore anomalo divide() basato su Module#f64.div() perché ExampleScript funziona anche con risultati in virgola mobile.

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

Se lavori con basi di codice reali, a volte ci sarà codice inutilizzato che non viene mai chiamato. Per introdurre artificialmente codice inutilizzato (che verrà ottimizzato ed eliminato in un passaggio successivo) nell'esempio di esecuzione della compilazione di ExampleScript in Wasm, l'aggiunta di una funzione non esportata è sufficiente.

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

Il compilatore è quasi pronto. Non è strettamente necessario, ma è sicuramente una buona pratica convalidare il modulo con il metodo Module#validate().

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

Ottenere il codice Wasm risultante

Per ottenere il codice Wasm risultante, in Binaryen esistono due metodi per ottenere la rappresentazione testuale come file .wat in S-expression come formato leggibile e la rappresentazione binaria come file .wasm che può essere eseguito direttamente nel browser. Il codice binario può essere eseguito direttamente nel browser. Per verificare che l'operazione sia riuscita, la registrazione delle esportazioni può essere utile.

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

Di seguito è riportata la rappresentazione testuale completa di un programma ExampleScript con tutte e quattro le operazioni. Nota come il codice inutilizzato sia ancora presente, ma non sia esposto come nello screenshot di 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 della console DevTools delle esportazioni del modulo WebAssembly che mostra quattro funzioni: add, divide, multiply e subtract (ma non il codice inutilizzato non esposto).

Ottimizzazione di WebAssembly

Binaryen offre due modi per ottimizzare il codice Wasm. Uno in Binaryen.js e uno per la riga di comando. Il primo applica per impostazione predefinita il set standard di regole di ottimizzazione e ti consente di impostare il livello di ottimizzazione e riduzione, mentre il secondo non utilizza regole per impostazione predefinita, ma consente la personalizzazione completa, il che significa che, con una sperimentazione sufficiente, puoi personalizzare le impostazioni per ottenere risultati ottimali in base al tuo codice.

Ottimizzazione con Binaryen.js

Il modo più semplice per ottimizzare un modulo Wasm con Binaryen è chiamare direttamente il metodo Module#optimize() di Binaryen.js e, facoltativamente, impostare optimize e il livello di riduzione.

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

In questo modo viene rimosso il codice inutilizzato introdotto artificialmente in precedenza, quindi la rappresentazione testuale della versione Wasm dell'esempio giocattolo ExampleScript non lo contiene più. Nota anche come le coppie local.set/get vengono rimosse dai passaggi di ottimizzazione SimplifyLocals (ottimizzazioni varie relative ai locali) e da Vacuum (rimuove il codice ovviamente non necessario) e come return viene rimosso da RemoveUnusedBrs (rimuove le interruzioni dalle località non necessarie).

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

Esistono molti passaggi di ottimizzazione e Module#optimize() utilizza i set predefiniti dei particolari livelli di ottimizzazione e riduzione. Per una personalizzazione completa, devi utilizzare lo strumento a riga di comando wasm-opt.

Ottimizzazione con lo strumento a riga di comando wasm-opt

Per la personalizzazione completa dei pass da utilizzare, Binaryen include lo strumento a riga di comando wasm-opt. Per visualizzare un elenco completo delle possibili opzioni di ottimizzazione, controlla il messaggio di aiuto dello strumento. Lo strumento wasm-opt è probabilmente il più popolare e viene utilizzato da diverse toolchain del compilatore per ottimizzare il codice Wasm, tra cui Emscripten, J2CL, Kotlin/Wasm, dart2wasm, wasm-pack e altri.

wasm-opt --help

Per darti un'idea dei passaggi, ecco un estratto di alcuni di quelli che sono comprensibili senza conoscenze specialistiche:

  • CodeFolding: evita il codice duplicato unendolo (ad esempio, se due if bracci hanno alcune istruzioni condivise alla fine).
  • DeadArgumentElimination: passaggio di ottimizzazione del tempo di collegamento per rimuovere gli argomenti di una funzione se viene sempre chiamata con le stesse costanti.
  • MinifyImportsAndExports:li comprime in "a", "b".
  • DeadCodeElimination: rimuove il codice inutilizzato.

È disponibile un ricettario di ottimizzazione con diversi suggerimenti per identificare quali dei vari flag sono più importanti e vale la pena provare per primi. Ad esempio, a volte l'esecuzione ripetuta di wasm-opt riduce ulteriormente l'input. In questi casi, l'esecuzione con il flag --converge continua a iterare finché non si verifica un'ulteriore ottimizzazione e non viene raggiunto un punto fisso.

Demo

Per vedere i concetti introdotti in questo post in azione, prova la demo incorporata fornendo qualsiasi input ExampleScript ti venga in mente. Assicurati anche di visualizzare il codice sorgente della demo.

Conclusioni

Binaryen fornisce un potente toolkit per la compilazione di linguaggi in WebAssembly e l'ottimizzazione del codice risultante. La sua libreria JavaScript e gli strumenti a riga di comando offrono flessibilità e facilità d'uso. Questo post ha dimostrato i principi fondamentali della compilazione Wasm, evidenziando l'efficacia e il potenziale di Binaryen per un'ottimizzazione massima. Sebbene molte delle opzioni per personalizzare le ottimizzazioni di Binaryen richiedano una conoscenza approfondita del funzionamento interno di Wasm, in genere le impostazioni predefinite funzionano già alla perfezione. Con questo, ti auguriamo una buona compilazione e ottimizzazione con Binaryen.

Ringraziamenti

Questo post è stato rivisto da Alon Zakai, Thomas Lively e Rachel Andrew.