Compilazione e ottimizzazione di Wasm con Binaryen

Binaryen è un compilatore e una toolchain di infrastruttura per WebAssembly, scritta in C++. Punta a rendere la compilazione in WebAssembly intuitiva, veloce ed efficace. In questo post, utilizzando esempio di un linguaggio giocattolo sintetico chiamato ExampleScript, impara a scrivere Moduli WebAssembly in JavaScript utilizzando l'API Binaryen.js. Tratteremo nozioni di base sulla creazione di moduli, sull'aggiunta di funzioni al modulo ed sull'esportazione dal modulo. In questo modo, potrai avere tutte le informazioni i meccanismi per compilare linguaggi di programmazione veri e propri in WebAssembly. Inoltre, imparerai a ottimizzare i moduli Wasm sia con Binaryen.js che sul dalla riga di comando wasm-opt.

Informazioni su Binaryen

Binaryen ha un intuitivo API C in una singola intestazione e possono anche da JavaScript. Accetta input in Modulo WebAssembly, ma accetta anche un modello grafico di controllo del flusso per i compilatori che lo preferiscono.

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

L'ottimizzatore di Binaryen ha molti passaggi che possono migliorare le dimensioni e la velocità del codice. Questi le ottimizzazioni mirano a rendere Binaryen abbastanza potente da poter essere usato come compilatore il backend da sé. Include ottimizzazioni specifiche di WebAssembly (che i compilatori generici potrebbero non farlo), cosa che si può pensare come Wasm la minimizzazione.

AssemblyScript come utente di esempio di Binaryen

Binaryen è utilizzato da diversi progetti, ad esempio AssemblyScript, che utilizza Binaryen per da un linguaggio simile a TypeScript, direttamente in WebAssembly. Prova l'esempio nel parco giochi AssemblyScript.

Input di 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 parco giochi AssemblyScript che mostra il codice WebAssembly generato in base all'esempio precedente.

La toolchain di Binaryen

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

  • binaryen.js: una libreria JavaScript autonoma che espone metodi Binaryen della creazione e ottimizzazione di moduli Wasm. Per le build, vedi binaryen.js su npm (o scaricalo direttamente dal GitHub o unpkg).
  • wasm-opt: strumento a riga di comando che carica WebAssembly ed esegue Binaryen IR ci passa sopra.
  • wasm-as e wasm-dis: strumenti a riga di comando che consentono di assemblare e disassemblare WebAssembly.
  • wasm-ctor-eval: strumento a riga di comando in grado di eseguire funzioni (o parti di funzioni) al momento della compilazione.
  • wasm-metadce: strumento a riga di comando per rimuovere parti di file Wasm in un formato dipende da come viene usato il modulo.
  • wasm-merge: strumento a riga di comando che unisce più file Wasm in un unico e collega le importazioni corrispondenti alle esportazioni durante l'operazione. Ad esempio, bundler per JavaScript, ma per Wasm.

Compilazione in WebAssembly

Compilare una lingua a un'altra di solito comporta diversi passaggi, di cui quelle più importanti sono elencate nel seguente elenco:

  • Analisi lexicale: suddivide il codice sorgente in token.
  • Analisi della sintassi: crea un albero della sintassi astratta.
  • Analisi semantica: verifica la presenza di errori e applica le regole relative al linguaggio.
  • Generazione intermedia del codice:crea una rappresentazione più astratta.
  • Generazione del codice:traduci nella lingua di destinazione.
  • Ottimizzazione del codice specifico per target:ottimizza in base al target.

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

  • lex (Lexical Analyzer Builder): lex è uno strumento che genera termini sessuali noti anche come lexer o scanner. Richiede una serie di controlli espressioni e azioni corrispondenti come input e genera il codice per analizzatore lessicale che riconosce pattern nel codice sorgente di input.
  • yacc (Yet Other Compiler Compiler): yacc è uno strumento che genera per l'analisi della sintassi. Richiede una descrizione grammaticale formale di linguaggio di programmazione come input e genera codice per un parser. Parser solitamente producono albero della sintassi astratta (AST) che rappresentano la struttura gerarchica del codice sorgente.
di Gemini Advanced.

Esempio corretto

Dato l'ambito di questo post, è impossibile coprire un'intera programmazione quindi, per semplicità, considera un linguaggio molto limitato e inutile un linguaggio di programmazione sintetico chiamato ExampleScript, che funziona esprimendo operazioni generiche tramite esempi concreti.

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

Come da avviso, del tutto inutile, ma abbastanza semplice per il suo lessico analizzatore in modo che sia una singola espressione regolare: /\d+\s*[\+\-\*\/]\s*\d+\s*/.

Poi deve essere presente un parser. In realtà, una versione molto semplificata di è possibile creare una struttura di sintassi astratta utilizzando un'espressione regolare gruppi di acquisizione con nome: /(?<first_operand>\d+)\s*(?<operator>[\+\-\*\/])\s*(?<second_operand>\d+)/.

I comandi ExampleScript sono uno per riga, pertanto l'analizzatore sintattico può elaborare il codice a riga di comando suddividendolo in caratteri di nuova riga. Questo è sufficiente per verificare la prima tre passaggi dall'elenco puntato precedente, ovvero analisi grammaticale, sintassi analisi e analisi semantica. Il codice per questi passaggi è disponibile nella 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 intermedia del codice

Ora che i programmi ExampleScript possono essere rappresentati come un albero di sintassi astratta (anche se semplificata), il passaggio successivo è creare un modello rappresentazione intermedia. Il primo passaggio consiste nel crea 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 ognuno dei quattro possibili in ExampleScript, ovvero +, -, *, /, un nuovo funzione deve essere aggiunta al modulo con il metodo Module#addFunction() di Binaryen. I parametri del parametro I metodi di Module#addFunction() sono i seguenti:

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

Ci sono altri dettagli da esaminare e analizzare Documentazione di Binaryen può aiutarti a spostarti nello spazio, ma alla fine, per ExampleScript + operatore, si finisce nel metodo Module#i32.add() come uno dei vari disponibili operative relative ai numeri interi. L'aggiunta richiede due operandi, il primo e il secondo sommando. Per funzione sia effettivamente richiamabile, deve essere esportato 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 aver elaborato l'albero della sintassi astratta, il modulo contiene quattro metodi: tre che lavorano con numeri interi, ovvero add() in base a Module#i32.add(), subtract() in base a Module#i32.sub(), multiply() in base a Module#i32.mul() e l'outlier divide() in base a Module#f64.div() perché ExampleScript funziona anche con i 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 hai a che fare con codebase reali, a volte può capitare un codice guasto che non viene chiamato. Introdurre artificialmente codice morto (che verrà ottimizzato eliminato in un passaggio successivo) nell'esempio in esecuzione del file ExampleScript in Wasm, l'aggiunta di una funzione non esportata risolve il problema.

// 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 è buona norma convalida il modulo con il metodo Module#validate().

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

Ottenere il codice Wasm risultante

A ottenere il codice Wasm risultante, esistono due metodi in Binaryen per ottenere rappresentazione testuale come file .wat in S-expression in un formato leggibile e rappresentazione binaria come file .wasm eseguibile direttamente nel browser. Il codice binario può essere vengono eseguiti direttamente nel browser. Per vedere se ha funzionato, registrando le esportazioni guida.

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

La rappresentazione testuale completa per un programma ExampleScript con tutti e quattro i programmi di archiviazione sono elencate di seguito. Nota come il codice obsoleto sia ancora lì, ma non viene mostrato come mostrato nello 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 della console DevTools delle esportazioni del modulo WebAssembly che mostra quattro funzioni: add, divide, moltiplica e sottrarre (ma non il codice non esposto non esposto).

Ottimizzazione di WebAssembly

Binaryen offre due modi per ottimizzare il codice Wasm. Una nello stesso file Binaryen.js una per la riga di comando. La prima applica l'insieme standard di ottimizzazioni per impostazione predefinita e consente di impostare il livello di ottimizzazione e di restringimento mentre la seconda per impostazione predefinita non utilizza regole, ma consente una personalizzazione completa, il che significa che, con una sperimentazione sufficiente, potrai personalizzare le impostazioni a ottenere risultati ottimali in base al tuo codice.

Ottimizzare con Binaryen.js

Il modo più diretto per ottimizzare un modulo Wasm con Binaryen è chiamare direttamente il metodo Module#optimize() di Binaryen.js e, facoltativamente, l'impostazione del ottimizzare e ridurre il livello.

// 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 morto introdotto artificialmente in precedenza, rappresentazione testuale della versione Wasm del giocattolo ExampleScript, ad esempio no più lungo. Nota anche che le coppie local.set/get vengono rimosse Passaggi per l'ottimizzazione SimplifyLocals (ottimizzazioni varie correlate a locali) e Aspirapolvere (rimuove il codice non necessario) e il parametro return viene rimosso RemoveUnusedBrs (rimuove le pause dalle sedi 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 pass per l'ottimizzazione, e Module#optimize() utilizza i livelli specifici di ottimizzazione e riduzione predefinita set di dati. 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 una completa personalizzazione delle tessere da utilizzare, Binaryen include il parametro strumento a riga di comando wasm-opt. Per ottenere un l'elenco completo delle possibili opzioni di ottimizzazione, consulta il messaggio della guida dello strumento. Lo strumento wasm-opt è probabilmente il più usato degli strumenti e viene utilizzato da diverse catene di strumenti di compilazione per ottimizzare il codice Wasm, tra cui Emscripten, J2CL Kotlin/Wasm dart2wasm, wasm-pack e altri.

wasm-opt --help

Per darti un'idea delle tessere, ecco un estratto di alcune sono comprensibili senza competenze specialistiche:

  • CodeFolding: evita la duplicazione del codice unendolo (ad esempio, se due if gruppo ha istruzioni condivise alla fine).
  • DeadArgumentElimination: pass per l'ottimizzazione del tempo di collegamento per rimuovere gli argomenti. a una funzione se viene sempre chiamata con le stesse costanti.
  • MiniifyImportsAndExports: le minimizza in "a" e "b".
  • DeadCodeElimination: rimuovi il codice obsoleto.

C'è un Libro di ricette per l'ottimizzazione disponibili con diversi suggerimenti per identificare quali delle varie segnalazioni sono più sono importanti e che vale la pena provare prima. Ad esempio, a volte è in esecuzione wasm-opt ripetutamente riduce ulteriormente l'input. In questi casi, la pubblicazione con --converge flag continua fino a quando non vengono eseguite ulteriori ottimizzazioni e non viene stabilito un punto fisso raggiunto.

Demo

Per vedere i concetti introdotti in questo post in azione, gioca con le demo fornendo qualsiasi input di ExampleScript. Assicurati inoltre di visualizza il codice sorgente della demo.

Conclusioni

Binaryen fornisce un potente toolkit per compilare i linguaggi in WebAssembly e per ottimizzare il codice risultante. La libreria JavaScript e gli strumenti a riga di comando offrono flessibilità e facilità d'uso. Questo post illustra i principi fondamentali Compilation di Wasm che evidenzia l'efficacia e il potenziale di Binaryen la massima ottimizzazione. Anche se molte delle opzioni per personalizzare le ottimizzazioni richiedono una conoscenza approfondita degli aspetti interni di Wasm, le impostazioni predefinite funzionano benissimo. Buon lavoro anche per la compilazione e l'ottimizzazione con Binaryen!

Ringraziamenti

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