Wasm mit Binärdateien kompilieren und optimieren

Binaryen ist eine in C++ geschriebene Compiler- und Toolchain-Infrastrukturbibliothek für WebAssembly. Damit soll die Kompilierung mit WebAssembly intuitiv, schnell und effektiv gestaltet werden. In diesem Beitrag erfahren Sie am Beispiel einer synthetischen Spielzeugsprache namens ExampleScript, wie Sie mit der Binaryen.js API WebAssembly-Module in JavaScript schreiben. Sie lernen die Grundlagen der Modulerstellung, das Hinzufügen von Funktionen zum Modul und das Exportieren von Funktionen aus dem Modul kennen. Dadurch lernen Sie die allgemeinen Mechanismen der Kompilierung tatsächlicher Programmiersprachen in WebAssembly kennen. Außerdem erfahren Sie, wie Sie Wasm-Module sowohl mit Binaryen.js als auch in der Befehlszeile mit wasm-opt optimieren.

Hintergrund von Binaryen

Binaryen hat eine intuitive C API in einem einzelnen Header und kann auch aus JavaScript verwendet werden. Er akzeptiert Eingaben in WebAssembly-Form, akzeptiert aber auch eine allgemeine Kontrollflussdiagramm für Compiler, die diese Methode bevorzugen.

Eine Zwischendarstellung (Zwischendarstellung) ist die Datenstruktur oder der Code, der bzw. der von einem Compiler oder einer virtuellen Maschine intern verwendet wird, um Quellcode darzustellen. Die interne IR von Binaryen verwendet kompakte Datenstrukturen und ist für eine vollständig parallele Codegenerierung und -optimierung unter Verwendung aller verfügbaren CPU-Kerne ausgelegt. Der IR von Binaryen wird in WebAssembly kompiliert, da er eine Untergruppe von WebAssembly ist.

Der Optimierer von Binaryen hat viele Durchgänge, mit denen die Codegröße und -geschwindigkeit verbessert werden können. Diese Optimierungen zielen darauf ab, Binärdateien so leistungsfähig zu machen, dass sie als Compiler-Back-End allein verwendet werden können. Sie umfasst WebAssembly-spezifische Optimierungen (die allgemeine Compiler unter Umständen nicht tun können), die Sie sich als Wasm-Minifizierung vorstellen können.

AssemblyScript als Beispielnutzer von Binaryen

Binaryen wird von einer Reihe von Projekten verwendet, z. B. AssemblyScript, das Binaryen verwendet, um aus einer TypeScript-ähnlichen Sprache direkt in WebAssembly zu kompilieren. Versuchen Sie es mit dem Beispiel im AssemblyScript-Playground.

AssemblyScript-Eingabe:

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

Entsprechender WebAssembly-Code in Textform, der von Binaryen generiert wurde:

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

Der AssemblyScript-Playground mit dem generierten WebAssembly-Code, der auf dem vorherigen Beispiel basiert.

Die Binaryen-Toolchain

Die Binaryen Toolchain bietet sowohl für JavaScript-Entwickler als auch Befehlszeilennutzer eine Reihe nützlicher Tools. Im Folgenden sind nur einige dieser Tools aufgeführt. Die vollständige Liste der enthaltenen Tools ist in der Datei README des Projekts verfügbar.

  • binaryen.js: Eine eigenständige JavaScript-Bibliothek, die Binaryen-Methoden zum Erstellen und Optimieren von Wasm-Modulen zur Verfügung stellt. Informationen zu Builds finden Sie unter binaryen.js auf npm (oder laden Sie sie direkt von GitHub oder unpkg herunter).
  • wasm-opt: Befehlszeilentool, das WebAssembly lädt und Binaryen IR Passes ausführt.
  • wasm-as und wasm-dis: Befehlszeilentools, mit denen WebAssembly zusammengefügt und zerlegt wird
  • wasm-ctor-eval: Befehlszeilentool, das Funktionen (oder Teile von Funktionen) bei der Kompilierung ausführen kann.
  • wasm-metadce: Befehlszeilentool zum Entfernen von Teilen von Wasm-Dateien auf flexible Weise, die von der Verwendung des Moduls abhängt.
  • wasm-merge: Befehlszeilentool, das mehrere Wasm-Dateien in einer einzigen Datei zusammenführt und dabei die entsprechenden Importe mit Exporten verbindet. Wie ein Bundler für JavaScript, aber für Wasm.

In WebAssembly kompilieren

Das Kompilieren einer Sprache in eine andere umfasst in der Regel mehrere Schritte. Die wichtigsten sind in der folgenden Liste aufgeführt:

  • Lexikalische Analyse:Teilen Sie den Quellcode in Tokens auf.
  • Syntaxanalyse: Erstellen Sie einen abstrakten Syntaxbaum.
  • Semantische Analyse:Sie können nach Fehlern suchen und Sprachregeln erzwingen.
  • Codegenerierung:Erstellen Sie eine abstraktere Darstellung.
  • Codegenerierung:Übersetzung in die Zielsprache.
  • Zielspezifische Codeoptimierung:Optimieren Sie den Code für das Ziel.

In der Unix-Welt werden zum Kompilieren häufig lex und yacc verwendet:

  • lex (Lexical Analyzer Generator): lex ist ein Tool, das lexikalische Analysatoren generiert, die auch als Lexer oder Scanner bezeichnet werden. Sie verwendet eine Reihe von regulären Ausdrücken und entsprechenden Aktionen als Eingabe und generiert Code für ein lexikalisches Analysetool, das Muster im Eingabequellcode erkennt.
  • yacc (Yet Another Compiler Compiler): yacc ist ein Tool, das Parser für die Syntaxanalyse generiert. Dabei wird eine formale Grammatikbeschreibung einer Programmiersprache als Eingabe verwendet und Code für einen Parser generiert. Parser erstellen in der Regel abstrakte Syntaxstrukturen (ASTs), die die hierarchische Struktur des Quellcodes darstellen.

Ein Praxisbeispiel

Angesichts des Umfangs dieses Beitrags ist es unmöglich, eine vollständige Programmiersprache zu behandeln. Der Einfachheit halber sollten Sie daher der Einfachheit halber eine sehr begrenzte und nutzlose synthetische Programmiersprache namens ExampleScript in Betracht ziehen, bei der generische Operationen durch konkrete Beispiele ausgedrückt werden.

  • Um eine add()-Funktion zu schreiben, codieren Sie ein Beispiel für eine beliebige Ergänzung, zum Beispiel 2 + 3.
  • Um eine multiply()-Funktion zu schreiben, schreiben Sie beispielsweise 6 * 12.

Wie bereits in der Vorwarnung erwähnt, ist die Funktion völlig nutzlos, aber so einfach, dass der lexikalische Analyzer ein einziger regulärer Ausdruck ist: /\d+\s*[\+\-\*\/]\s*\d+\s*/.

Als Nächstes muss ein Parser vorhanden sein. Tatsächlich kann eine sehr vereinfachte Version eines abstrakten Syntaxbaums mit einem regulären Ausdruck mit benannten Erfassungsgruppen erstellt werden: /(?<first_operand>\d+)\s*(?<operator>[\+\-\*\/])\s*(?<second_operand>\d+)/.

ExampleScript-Befehle gibt es pro Zeile, sodass der Parser den Code zeilenweise verarbeiten kann, indem er nach Zeilenumbruchzeichen aufteilt. Dies reicht aus, um die ersten drei Schritte aus der vorhergehenden Aufzählungsliste (lexikalische Analyse, Syntaxanalyse und semantische Analyse) zu prüfen. Den Code für diese Schritte finden Sie in der folgenden Liste.

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

Zwischencodegenerierung

Da ExampleScript-Programme nun als abstrakten Syntaxbaum dargestellt werden können (wenn auch recht vereinfacht), besteht der nächste Schritt darin, eine abstrakte Zwischendarstellung zu erstellen. Der erste Schritt besteht darin, ein neues Modul in Binaryen zu erstellen:

const module = new binaryen.Module();

Jede Zeile des abstrakten Syntaxbaums enthält ein Magisches Dreieck, das aus firstOperand, operator und secondOperand besteht. Für jeden der vier möglichen Operatoren in ExampleScript, also +, -, *, /, muss dem Modul mit der Methode Module#addFunction() von Binaryen eine neue Funktion hinzugefügt werden. Die Parameter der Module#addFunction()-Methoden sind:

  • name: string steht für den Namen der Funktion.
  • functionType: Signature steht für die Signatur der Funktion.
  • varTypes: Type[] gibt zusätzliche lokale Nutzer in der angegebenen Reihenfolge an.
  • body: ein Expression, der Inhalt der Funktion.

Es gibt noch einige weitere Details zum Entspannen und die Binaryen-Dokumentation kann Ihnen bei der Navigation helfen. Mit dem +-Operator von ExampleScript gelangen Sie schließlich zur Methode Module#i32.add() als eine von mehreren verfügbaren Integer-Operationen. Für die Addition sind zwei Operanden erforderlich, der erste und der zweite Summenwert. Damit die Funktion tatsächlich aufgerufen werden kann, muss sie mit Module#addFunctionExport() exportiert werden.

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

Nach der Verarbeitung des abstrakten Syntaxbaums enthält das Modul vier Methoden, drei davon mit Ganzzahlen: add() auf Grundlage von Module#i32.add(), subtract() auf der Grundlage von Module#i32.sub(), multiply() auf Basis von Module#i32.mul() und der Ausreißer divide() auf Basis von Module#f64.div(), da ExampleScript auch mit Gleitkommaergebnissen arbeitet.

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

Wenn Sie mit tatsächlichen Codebasis zu tun haben, gibt es manchmal toten Code, der nie aufgerufen wird. Um im laufenden Beispiel der Kompilierung von ExampleScript in Wasm künstlichen Code einzuführen (der in einem späteren Schritt optimiert und entfernt wird), wird die Aufgabe durch Hinzufügen einer nicht exportierten Funktion ausgeführt.

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

Der Compiler ist jetzt fast fertig. Es ist nicht unbedingt erforderlich, aber definitiv empfehlenswert, das Modul mit der Methode Module#validate() zu validieren.

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

Den resultierenden Wasm-Code erhalten

Zum Abrufen des resultierenden Wasm-Codes gibt es in Binaryen zwei Methoden zum Abrufen der Textdarstellung als .wat-Datei in S-Ausdruck als visuell lesbares Format und der Binärdarstellung als .wasm-Datei, die direkt im Browser ausgeführt werden kann. Der Binärcode kann direkt im Browser ausgeführt werden. Wenn Sie prüfen möchten, ob es funktioniert hat, kann das Logging der Exporte hilfreich sein.

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

Die vollständige Textdarstellung für ein ExampleScript-Programm mit allen vier Vorgängen ist im Folgenden aufgeführt. Beachten Sie, dass der tote Code zwar noch vorhanden ist, aber nicht wie im Screenshot von WebAssembly.Module.exports() offengelegt wird.

(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 der DevTools-Konsole mit den WebAssembly-Modulexporten, die vier Funktionen zeigen: Addieren, Dividieren, Multiplizieren und Subtrahieren (aber nicht der nicht offengelegte tote Code).

WebAssembly optimieren

Binaryen bietet zwei Möglichkeiten, Wasm-Code zu optimieren. eine in Binaryen.js selbst und eine für die Befehlszeile. Bei Ersterem werden standardmäßig die Standardsatz von Optimierungsregeln angewendet und Sie können die Optimierungs- und Verkleinerungsebene festlegen. Bei Letzterem werden standardmäßig keine Regeln verwendet. Stattdessen ist eine vollständige Anpassung möglich, sodass Sie die Einstellungen für optimale Ergebnisse auf der Grundlage Ihres Codes anpassen können.

Mit Binaryen.js optimieren

Die einfachste Möglichkeit, ein Wasm-Modul mit Binaryen zu optimieren, besteht darin, die Methode Module#optimize() von Binaryen.js direkt aufzurufen und optional die Optimierungs- und Verkleinerungsebene festzulegen.

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

Dadurch wird der tote Code entfernt, der zuvor künstlich eingefügt wurde, sodass die Textdarstellung der Wasm-Version des Beispiel-Beispielskripts aus dem Spielzeugbeispiel ihn nicht mehr enthält. Beachten Sie auch, wie die local.set/get-Paare durch die Optimierungsschritte SimplifyLocals (verschiedene Optimierungen, die sich auf lokale Umgebungen beziehen) und den Vacuum (vermutlich nicht benötigter Code entfernt) entfernt werden. Der return wird durch RemoveUnusedBrs entfernt, womit Pausen an nicht benötigten Stellen entfernt werden.

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

Es gibt viele Optimierungsdurchläufe und Module#optimize() verwendet die jeweiligen Standardsätze der Optimierungs- und Verkleinerungsstufen. Für eine vollständige Anpassung müssen Sie das wasm-opt-Befehlszeilentool verwenden.

Optimierung mit dem Wasm-opt-Befehlszeilentool

Zur vollständigen Anpassung der zu verwendenden Karten/Tickets enthält Binaryen das wasm-opt-Befehlszeilentool. Eine vollständige Liste der möglichen Optimierungsoptionen finden Sie in der Hilfe des Tools. Das wasm-opt-Tool ist wahrscheinlich das beliebteste der Tools und wird von mehreren Compiler-Toolchains verwendet, um Wasm-Code zu optimieren, darunter Emscripten, J2CL, Kotlin/Wasm, dart2wasm und wasm-pack.

wasm-opt --help

Hier ein Auszug aus einigen der Karten/Tickets, die auch ohne Fachwissen verständlich sind:

  • CodeFolding:Durch Zusammenführen wird verhindert, dass Code dupliziert wird (z. B. wenn zwei if-Verzweigungen einige gemeinsame Anweisungen haben).
  • DeadArgumentElimination: Die Linkzeitoptimierung wird übergeben, um Argumente für eine Funktion zu entfernen, wenn sie immer mit denselben Konstanten aufgerufen wird.
  • MinifyImportsAndExports:reduziert sie auf "a", "b".
  • DeadCodeElimination:Entfernen Sie toten Code.

Es gibt ein Cookbook zur Optimierung mit mehreren Tipps, wie Sie herausfinden können, welche der verschiedenen Flags wichtiger sind und sich zuerst ausprobieren sollten. Wenn Sie beispielsweise wasm-opt immer wieder ausführen, wird die Eingabe weiter verkleinert. In solchen Fällen wird die Ausführung mit dem Flag --converge fortgesetzt, bis keine weitere Optimierung stattfindet und ein fester Punkt erreicht wird.

Demo

Wenn Sie die in diesem Post vorgestellten Konzepte in Aktion sehen möchten, spielen Sie mit der eingebetteten Demo und bieten Sie eine beliebige ExampleScript-Eingabe, die Sie sich vorstellen. Sehen Sie sich außerdem unbedingt den Quellcode der Demo an.

Ergebnisse

Binaryen bietet ein leistungsstarkes Toolkit, mit dem Sie Sprachen in WebAssembly kompilieren und den resultierenden Code optimieren können. Die JavaScript-Bibliothek und die Befehlszeilentools bieten Flexibilität und Nutzerfreundlichkeit. In diesem Beitrag wurden die Grundprinzipien der Wasm-Kompilierung und die Effektivität von Binaryen sowie das Potenzial für eine maximale Optimierung demonstriert. Viele der Optionen zum Anpassen der Optimierungen von Binaryen erfordern umfassende Kenntnisse über die Interna von Wasm, aber die Standardeinstellungen funktionieren in der Regel bereits gut. Viel Erfolg beim Kompilieren und Optimieren mit Binaryen!

Danksagungen

Dieser Beitrag wurde von Alon Zakai, Thomas Lively und Rachel Andrew bewertet.