Wasm mit Binärdateien kompilieren und optimieren

Binaryen ist ein Compiler und eine Toolchain. Infrastrukturbibliothek für WebAssembly, geschrieben in C++. Ziel ist es, intuitive, schnelle und effektive Kompilierung in WebAssembly. In diesem Beitrag wird mithilfe der Beispiel für eine künstliche Spielzeugsprache namens ExampleScript. Hier erfahren Sie, wie Sie WebAssembly-Module in JavaScript mit der Binaryen.js API. Sie werden die Grundlagen der Modulerstellung, Hinzufügen von Funktionen zum Modul und Exportieren aus dem Modul an. So erhalten Sie ein besseres Verständnis zur Kompilierung tatsächlicher Programmiersprachen in WebAssembly. Außerdem erfahren Sie, wie Sie Wasm-Module sowohl mit Binaryen.js Befehlszeile mit wasm-opt.

Hintergrundinformationen zu Binaryen

Binaryen hat eine intuitive C API in einer einzigen Kopfzeile und kann auch als aus JavaScript verwendet. Die Funktion akzeptiert Eingaben in WebAssembly-Formular akzeptiert aber auch eine allgemeine Kontrollflussdiagramm für Compiler, die das bevorzugen.

Eine Zwischendarstellung (IR) ist die verwendete Datenstruktur oder der verwendete Code intern durch einen Compiler oder eine virtuelle Maschine zur Darstellung von Quellcode. Binaryen Die interne Infrarotstrahlung verwendet kompakte Datenstrukturen und ist für vollständig parallele Angriffe ausgelegt Codegenerierung und -optimierung unter Verwendung aller verfügbaren CPU-Kerne IR von Binaryen wird zu WebAssembly kompiliert, da es sich dabei um eine Untergruppe von WebAssembly handelt.

Das Optimierungstool von Binaryen hat viele Durchläufe, mit denen die Codegröße und -geschwindigkeit verbessert werden kann. Diese Optimierungen zielen darauf ab, Binaryen so leistungsstark zu machen, dass es als Compiler verwendet werden kann. Back-End zu erstellen. Sie umfasst WebAssembly-spezifische Optimierungen (die Compiler für allgemeine Zwecke möglicherweise nicht), was Sie sich als Wasm vorstellen können, zu reduzieren.

AssemblyScript als Beispielnutzer von Binaryen

Binaryen wird von einer Reihe von Projekten verwendet, z. B. AssemblyScript, das Binaryen nutzt, um aus einer TypeScript-ähnlichen Sprache direkt in WebAssembly kompilieren. Beispiel ausprobieren im AssemblyScript-Playground wieder.

AssemblyScript-Eingabe:

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

Entsprechender von Binaryen generierter WebAssembly-Code in Textform:

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

AssemblyScript-Playground mit dem basierend auf dem vorherigen Beispiel generierten WebAssembly-Code

Die Binaryen-Toolchain

Die Binaryen-Toolchain bietet eine Reihe nützlicher Tools sowohl für JavaScript- und Befehlszeilen-Nutzenden. Einige dieser Tools sind in der Folgendes: die vollständige Liste der enthaltenen Tools ist in der README-Datei des Projekts verfügbar.

  • binaryen.js: Eine eigenständige JavaScript-Bibliothek, die Binaryen-Methoden verfügbar macht für Wasm-Module erstellen und optimieren. Informationen zu Builds finden Sie unter binaryen.js auf npm. (oder direkt von GitHub oder unpkg).
  • wasm-opt: Befehlszeilentool, das WebAssembly lädt und Binaryen IR ausführt übergibt.
  • wasm-as und wasm-dis: Befehlszeilentools zum Zusammen- und Auseinanderbauen WebAssembly.
  • wasm-ctor-eval: Befehlszeilentool, das Funktionen (oder Teile von Funktionen) beim Kompilieren.
  • wasm-metadce: Befehlszeilentool zum Entfernen von Teilen von Wasm-Dateien in einer flexiblen Umgebung je nachdem, wie das Modul verwendet wird.
  • wasm-merge: Befehlszeilentool, das mehrere Wasm-Dateien zu einer einzigen zusammenführt -Datei und verknüpft die entsprechenden Importe dabei mit den Exporten. Gefällt mir Bundler für JavaScript, aber für Wasm.

Kompilierung in WebAssembly

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

  • Lexikalische Analyse:Teilen Sie den Quellcode in Tokens auf.
  • Syntaxanalyse:Erstellen Sie eine abstrakte Syntaxstruktur.
  • Semantische Analyse:Sie können nach Fehlern suchen und Sprachregeln erzwingen.
  • Codegenerierung in der Zwischenversion:Erstellen Sie eine abstraktere Darstellung.
  • Codegenerierung:Übersetzen Sie sie in die Zielsprache.
  • Zielspezifische Codeoptimierung: Optimieren Sie für das Ziel.

In der Unix-Welt werden häufig verwendete Kompilierungstools verwendet: lex und yacc:

  • lex (Lexical Analyzer Generator): lex ist ein Tool, das lexikalische Analysen generiert. Analysatoren, auch Lexer oder Scanner genannt. Dazu sind regelmäßige Ausdrücke und entsprechende Aktionen als Eingabe und generiert Code für ein Lexikalanalysator, der Muster im Eingabequellcode erkennt.
  • yacc (Yet Another Compiler Compiler): yacc ist ein Tool, das Parser für die Syntaxanalyse. Dabei wird eine formelle Grammatikbeschreibung Programmiersprache als Eingabe und generiert Code für einen Parser. Parser produzieren in der Regel abstrakte Syntaxstrukturen (AST), die die hierarchische Struktur des Quellcodes darstellen.

Ein funktionierendes Beispiel

In Anbetracht des Umfangs dieses Beitrags ist es unmöglich, Der Einfachheit halber sollten Sie eine sehr begrenzte und nutzlose die synthetische Programmiersprache ExampleScript, die generische Operationen anhand konkreter Beispiele.

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

Gemäß der Vorwarnung ist das Ganze völlig nutzlos, aber einfach genug für seine lexikalische Sprache. Analyzer zu einem einzelnen regulären Ausdruck: /\d+\s*[\+\-\*\/]\s*\d+\s*/.

Als Nächstes muss ein Parser vorhanden sein. Eine sehr vereinfachte Version von kann ein abstrakter Syntaxbaum mithilfe eines regulären Ausdrucks mit Benannte Erfassungsgruppen: /(?<first_operand>\d+)\s*(?<operator>[\+\-\*\/])\s*(?<second_operand>\d+)/

BeispielScript-Befehle stehen pro Zeile, sodass der Parser den Code verarbeiten kann. zeilenweise durch Aufteilen nach Zeilenumbruchzeichen. Das reicht aus, um die erste drei Schritte aus der Aufzählungsliste zuvor, nämlich lexikalische Analyse, Syntax Analyse und semantische Analyse. Der Code für diese Schritte befindet sich in der folgenden Eintrag.

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

Codegenerierung in der fortgeschrittenen Phase

Da ExampleScript-Programme jetzt als abstrakte Syntaxstruktur dargestellt werden können, Der nächste Schritt besteht darin, eine abstrakte eine Zwischendarstellung. Der erste Schritt besteht darin, Erstellen Sie ein neues Modul in Binaryen:

const module = new binaryen.Module();

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

  • name: Ein string, der den Namen der Funktion darstellt.
  • functionType: Ein Signature, das die Signatur der Funktion darstellt.
  • varTypes: Ein Type[], das zusätzliche Ortsansässige in der angegebenen Reihenfolge angibt.
  • body: ein Expression, der Inhalt der Funktion.

Es gibt einige weitere Details zum Entspannen und Abschalten sowie Binaryen-Dokumentation kann Ihnen bei der Navigation im Gruppenbereich helfen, doch letztendlich ist es für + von ExampleScript hilfreich, wird die Methode Module#i32.add() als eine von mehreren verfügbar Ganzzahlvorgänge. Für die Addition sind zwei Operanden erforderlich: der erste und der zweite Summand. Für die tatsächlich aufrufbar ist, muss sie exportiert mit 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');

Nach der Verarbeitung des abstrakten Syntaxbaums enthält das Modul vier Methoden: drei mit Ganzzahlen arbeiten, nämlich add() basierend auf Module#i32.add(), subtract() basierend auf Module#i32.sub(), multiply() basierend auf Module#i32.mul() und der Ausreißer divide() basierend auf Module#f64.div() weil ExampleScript auch mit Gleitkommaergebnissen funktioniert.

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

Bei einer echten Codebasis gibt es manchmal toten Code, der nie aufgerufen wird. Um toten Code künstlich einzuführen (der dann optimiert und in einem späteren Schritt gelöscht werden) Kompilierung in Wasm durch Hinzufügen einer nicht exportierten Funktion.

// 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 notwendig, bewährte Methode, Modul validieren mit der Methode Module#validate().

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

Den resultierenden Wasm-Code abrufen

Bis den resultierenden Wasm-Code abrufen, In Binaryen gibt es zwei Methoden, Textdarstellung als .wat-Datei in S-Ausdruck in einem visuell lesbaren Format Binärdarstellung als .wasm-Datei, die direkt im Browser ausgeführt werden kann. Der Binärcode kann direkt im Browser ausgeführt werden. Um festzustellen, ob es funktioniert hat, kann das Protokollieren der Exporte Hilfe.

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 Operationen sind im Folgenden aufgeführt. Der tote Code ist immer noch da, aber nicht wie im Screenshot des 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 der Entwicklertools-Konsole mit Exporten des WebAssembly-Moduls mit vier Funktionen: Addieren, Teilen, 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 wird der Standardsatz von Optimierungsmaßnahmen und ermöglicht es Ihnen, die Optimierungs- und Verkleinerungsebene sowie die verwendet standardmäßig keine Regeln, ermöglicht aber eine vollständige Anpassung. Wenn Sie also genug experimentiert haben, können Sie die Einstellungen anhand Ihres Codes optimale Ergebnisse erzielen.

Mit Binaryen.js optimieren

Der einfachste Weg zur Optimierung eines Wasm-Moduls mit Binaryen ist, die Methode Module#optimize() von Binaryen.js direkt aufrufen und optional das Festlegen der Optimierung und Verkleinerungsebene.

// 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 toter Code entfernt, der zuvor künstlich eingefügt wurde, sodass das Textdarstellung der Wasm-Version des Beispielskripts für das Spielzeugbeispiel – Nr. nicht mehr enthält. Beachten Sie auch, wie die local.set/get-Paare durch die Optimierungsschritte SimplifyLocals (sonstige ortsbezogene Optimierungen) und die Staubsauger (löscht offensichtlich nicht benötigten Code) und return wird durch RemoveUnusedBrs Dadurch werden Werbeunterbrechungen an nicht benötigten Orten entfernt.

 (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 Optimierungspässe, Module#optimize() verwendet die entsprechenden Optimierungs- und Verkleinerungsebenen. Standard festgelegt. Für eine vollständige Anpassung müssen Sie das Befehlszeilentool wasm-opt verwenden.

Mit dem Befehlszeilentool wasm-opt optimieren

Für eine vollständige Anpassung der zu verwendenden Karten/Tickets enthält Binaryen die wasm-opt-Befehlszeilentool. Um eine vollständige Liste der möglichen Optimierungsoptionen, sehen Sie sich die Hilfemeldung des Tools an. Das wasm-opt-Tool ist wahrscheinlich am beliebtesten der Tools und wird von mehreren Compiler-Toolchains verwendet, um Wasm-Code zu optimieren. einschließlich Emscripten, J2CL Kotlin/Wasm dart2wasm Wasm-Pack und andere.

wasm-opt --help

Um Ihnen ein Gefühl für die Pässe zu vermitteln, finden Sie hier einen Auszug aus den Karten, die auch ohne Fachwissen verständlich sind:

  • CodeFolding: Vermeidet Codeduplikat durch Zusammenführen von Code (z. B. wenn zwei if Verzweigungen einige gemeinsame Anweisungen am Ende).
  • DeadArgumentElimination:Übergabe zur Optimierung der Linkzeit zum Entfernen von Argumenten wenn sie immer mit denselben Konstanten aufgerufen wird.
  • MinifyImportsAndExports:Reduziert die Anzahl auf "a", "b".
  • DeadCodeElimination:Entfernen Sie fehlerhaften Code.

Es gibt eine Cookbook zur Optimierung Dort finden Sie Tipps dazu, welche Flags am häufigsten verwendet werden, und erst einmal ausprobieren sollten. Wenn z. B. manchmal wasm-opt ausgeführt wird, verkleinert die Eingabe immer wieder. In solchen Fällen führt die Ausführung mit dem --converge-Flag iteriert weiter, bis keine weitere Optimierung erfolgt und ein bestimmter Punkt erreicht haben.

Demo

Um die in diesem Post vorgestellten Konzepte in Aktion zu sehen, spielen Sie mit den eingebetteten Demo, indem Sie beliebige ExampleScript-Eingaben zur Verfügung stellen, die Ihnen einfallen. Achten Sie außerdem darauf, Quellcode der Demo ansehen

Ergebnisse

Binaryen bietet ein leistungsstarkes Toolkit zum Kompilieren von Sprachen in WebAssembly und Optimierung des resultierenden Codes. JavaScript-Bibliothek und Befehlszeilentools Flexibilität und Nutzerfreundlichkeit bieten. In diesem Beitrag wurden die Grundprinzipien Wasm-Kompilierung, die die Wirksamkeit und das Potenzial von Binaryen maximale Optimierung. Während viele der Optionen zum Anpassen der Optimierungen erfordern tiefes Wissen über die internen Strukturen von Wasm, in der Regel Standardeinstellungen funktionieren bereits gut. Viel Spaß beim Kompilieren und Optimieren mit Binaryen!

Danksagungen

Dieser Beitrag wurde von Alon Zakai geprüft. Thomas Lively und Rachel Andrew