Compiler et optimiser Wasm avec Binaryen

Binaryen est un compilateur et une chaîne d'outils. d'infrastructure pour WebAssembly, écrite en C++. Elle vise à rendre une compilation à WebAssembly intuitive, rapide et efficace. Dans cet article, nous allons utiliser exemple de langage de jouet synthétique appelé ExampleScript, apprenez à écrire modules WebAssembly en JavaScript à l'aide de l'API Binaryen.js. Vous aborderez principes de base de la création de module, de l'ajout de fonctions au module et de l'exportation du module. Cela vous permettra d'en savoir plus sur l'ensemble de compilation de langages de programmation réels dans WebAssembly. En outre, vous apprendrez à optimiser les modules Wasm à l'aide de Binaryen.js et dans le via la ligne de commande wasm-opt.

Contexte sur Binaryen

Binaryen dispose d'une interface intuitive API C dans un seul en-tête et peuvent également être utilisées à partir de JavaScript. Il accepte les entrées Formulaire WebAssembly mais accepte également une règle générale graphique de flux de contrôle pour les compilateurs qui préfèrent cela.

Une représentation intermédiaire (IR) est la structure de données ou le code utilisé en interne par un compilateur ou une machine virtuelle pour représenter le code source. de Binaryen la protection infrarouge interne utilise des structures de données compactes et est conçue pour fonctionner en parallèle la génération et l'optimisation de code, en utilisant tous les cœurs de processeur disponibles. IR de Binaryen se compile vers WebAssembly, car il s'agit d'un sous-ensemble de WebAssembly.

L'optimiseur de Binaryen comporte de nombreuses passes qui peuvent améliorer la taille et la vitesse du code. Ces les optimisations visent à rendre Binaryen suffisamment puissant pour être utilisé comme compilateur seul votre backend. Elle inclut des optimisations spécifiques à WebAssembly (qui que les compilateurs à usage général ne peuvent pas faire), que vous pouvez considérer comme Wasm minimisation.

AssemblyScript comme exemple d'utilisateur de Binaryen

Binaryen est utilisé par un certain nombre de projets, par exemple, AssemblyScript, qui utilise Binaryen pour : compiler à partir d'un langage de type TypeScript directement vers WebAssembly. Essayer l'exemple dans AssemblyScript Playground.

Entrée AssemblyScript:

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

Code WebAssembly correspondant, généré par Binaryen, sous forme textuelle:

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

Terrain de jeu AssemblyScript affichant le code WebAssembly généré à partir de l'exemple précédent.

Chaîne d'outils Binaryen

La chaîne d'outils Binaryen offre un certain nombre d'outils utiles pour JavaScript développeurs et utilisateurs de ligne de commande. Un sous-ensemble de ces outils est présenté dans la section suivis ; la liste complète des outils est disponible dans le fichier README du projet.

  • binaryen.js: bibliothèque JavaScript autonome qui expose les méthodes Binaryen pour créer et optimiser des modules Wasm. Pour les builds, consultez binaryen.js sur npm (ou téléchargez-la directement sur GitHub ou unpkg).
  • wasm-opt: outil de ligne de commande qui charge WebAssembly et exécute Binaryen IR la transmet.
  • wasm-as et wasm-dis: outils de ligne de commande permettant d'assembler et de désassembler WebAssembly.
  • wasm-ctor-eval: outil de ligne de commande capable d'exécuter des fonctions (ou des parties de fonctions) au moment de la compilation.
  • wasm-metadce: outil de ligne de commande permettant de supprimer des parties des fichiers Wasm dans un environnement tout dépend de la façon dont le module est utilisé.
  • wasm-merge: outil de ligne de commande qui fusionne plusieurs fichiers Wasm en un seul , en connectant les importations correspondantes aux exportations. Comme bundler pour JavaScript, mais pour Wasm.

Compiler sur WebAssembly

La compilation d'un langage dans un autre implique généralement plusieurs étapes, les plus les plus importants sont répertoriés dans la liste suivante:

  • Analyse lexicale:divisez le code source en jetons.
  • Analyse syntaxique:créer une arborescence syntaxique abstraite.
  • Analyse sémantique:détectez les erreurs et appliquez les règles linguistiques.
  • Génération de code intermédiaire:créez une représentation plus abstraite.
  • Génération de code:traduisez dans la langue cible.
  • Optimisation du code spécifique à la cible:effectuez une optimisation en fonction de la cible.

Dans le monde Unix, les outils fréquemment utilisés pour la compilation sont lex et yacc:

  • lex (Lexical Analyzer Generator) : lex est un outil qui génère des métriques lexicales analyseurs, aussi appelés lexers ou scanners. Il faut un ensemble de données et les actions correspondantes en entrée, puis génère du code pour un analyseur lexical qui reconnaît des modèles dans le code source d'entrée.
  • yacc (Yet Other Compiler Compiler): yacc est un outil qui génère pour l'analyse syntaxique. Il faut une description grammaticale formelle d'un de programmation en entrée et génère du code pour un analyseur. Analyseurs produit généralement arbres syntaxiques abstraites qui représentent la structure hiérarchique du code source.

Exemple qui a fonctionné

Étant donné le champ d'application de cet article, il nous est impossible d'aborder une programmation complète Par souci de simplicité, envisagez d'utiliser un langage ou ExampleScript, qui fonctionne en exprimant des opérations génériques à travers des exemples concrets.

  • Pour écrire une fonction add(), vous codez un exemple de toute addition, par exemple 2 + 3
  • Pour écrire une fonction multiply(), écrivez, par exemple, 6 * 12.

Comme indiqué dans l'avertissement préalable, cela ne sert à rien, mais assez simple pour sa compréhension lexicale "Analyzer" pour qu'il corresponde à une expression régulière unique: /\d+\s*[\+\-\*\/]\s*\d+\s*/.

Ensuite, il doit y avoir un analyseur. En fait, une version très simplifiée de une arborescence de syntaxe abstraite peut être créée en utilisant une expression régulière avec groupes de capture nommés: /(?<first_operand>\d+)\s*(?<operator>[\+\-\*\/])\s*(?<second_operand>\d+)/

Les commandes ExampleScript étant une par ligne, l'analyseur peut traiter le code au niveau de la ligne en effectuant une division sur les caractères de retour à la ligne. Cela suffit à vérifier Les trois étapes de la liste à puces précédente, à savoir l'analyse lexicale, la syntaxe l'analyse et l'analyse sémantique. Le code de ces étapes se trouve dans le la liste suivante.

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

Génération de code intermédiaire

Maintenant que les programmes ExampleScript peuvent être représentés sous la forme d'une arborescence syntaxique abstraite, (simplifiée), l'étape suivante consiste à créer un schéma représentation intermédiaire. La première étape consiste à Créez un module dans Binaryen:

const module = new binaryen.Module();

Chaque ligne de l'arbre syntaxique abstrait contient un triple composé de firstOperand, operator et secondOperand. Pour chacune des quatre possibilités dans ExampleScript, c'est-à-dire +, -, *, /, une nouvelle la fonction doit être ajoutée au module. avec la méthode Module#addFunction() de Binaryen. Les paramètres de la fonction Les méthodes Module#addFunction() sont les suivantes:

  • name: string, représente le nom de la fonction.
  • functionType: Signature, qui représente la signature de la fonction.
  • varTypes: Type[] indique des éléments locaux supplémentaires, dans l'ordre donné.
  • body: Expression, le contenu de la fonction.

Il y a d'autres détails à vous détendre et Documentation Binaryen peut vous aider à naviguer dans l'espace, mais, à terme, pour le fichier + d'ExampleScript, vous obtenez la méthode Module#i32.add(), qui est l'une des nombreuses disponible entier d'opérations. L'addition nécessite deux opérandes : le premier et le deuxième summand. Pour le pour qu'elle puisse être appelée, elle doit être exporté avec 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');

Après avoir traité l'arborescence syntaxique abstraite, le module contient quatre méthodes : et trois avec des nombres entiers, à savoir add() (basé sur Module#i32.add()), subtract() pour Module#i32.sub() et multiply() pour Module#i32.mul() et la valeur aberrante divide() basée sur Module#f64.div() car ExampleScript fonctionne également avec les résultats à virgule flottante.

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

Si vous avez affaire à de véritables codebases, il peut arriver qu'il y ait du code mort qui n'est jamais est appelé. Pour introduire artificiellement du code mort (qui sera optimisé et éliminés ultérieurement) dans l'exemple du code d'ExampleScript à Wasm, l'ajout d'une fonction non exportée fait le travail.

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

Le compilateur est presque prêt. Ce n'est pas obligatoire, mais certainement bonne pratique valider le module à l'aide de la méthode Module#validate().

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

Obtenir le code Wasm obtenu

À obtenir le code Wasm obtenu ; deux méthodes existent dans Binaryen pour obtenir représentation textuelle en tant que fichier .wat dans S-expression ; en tant que format lisible par l'humain, représentation binaire sous forme de fichier .wasm pouvant s'exécuter directement dans le navigateur. Le code binaire peut être directement dans le navigateur. Pour voir que cela a fonctionné, la journalisation des exportations peut pour vous aider.

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 représentation textuelle complète d'un programme ExampleScript contenant les quatre est énumérée ci-dessous. Notez que le code mort est toujours là, mais qui n'est pas visible sur la capture d'écran 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)
  )
 )
)

Capture d&#39;écran de la console DevTools des exportations du module WebAssembly montrant quatre fonctions: add, diviser, multiplier et soustraire (mais pas le code mort non exposé).

Optimiser WebAssembly

Binaryen propose deux façons d'optimiser le code Wasm. un dans Binaryen.js. une pour la ligne de commande. Le premier applique l'ensemble standard d'optimisations par défaut, et vous permet de définir les niveaux d'optimisation et de réduction. La seconde n'utilise par défaut aucune règle, mais permet une personnalisation complète. Cela signifie qu'avec suffisamment d'expérimentation, vous pouvez adapter les paramètres des résultats optimaux en fonction de votre code.

Optimiser avec Binaryen.js

Le moyen le plus simple d'optimiser un module Wasm avec Binaryen consiste à appeler directement la méthode Module#optimize() de Binaryen.js et, si vous le souhaitez, en paramétrant le à optimiser et à réduire.

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

Cela permet de supprimer le code mort introduit de manière artificielle auparavant, représentation textuelle de la version Wasm de l'exemple de jouet ExampleScript non ne le contient plus. Notez également que les paires local.set/get sont supprimées par le étapes d'optimisation SimplifyLocals (diverses optimisations liées aux paramètres locaux) et Aspirateur (supprime le code inutilement inutile), et return est supprimé par RemoveUnusedBrs (il supprime les pauses dans les endroits qui ne sont pas nécessaires).

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

Il existe de nombreux d'optimisation, et Module#optimize() utilise les niveaux d'optimisation et de réduction particuliers par défaut ensembles de données. Pour une personnalisation complète, vous devez utiliser l'outil de ligne de commande wasm-opt.

Optimiser les performances avec l'outil de ligne de commande Wasm-opt

Pour une personnalisation complète des cartes à utiliser, Binaryen inclut le Outil de ligne de commande wasm-opt. Pour obtenir un la liste complète des options d'optimisation possibles ; consultez le message d'aide de l'outil. L'outil wasm-opt est probablement l'outil le plus utilisé et est utilisé par plusieurs chaînes d'outils de compilation pour optimiser le code Wasm. y compris Emscripten, J2CL Kotlin/Wasm, dart2wasm, wasm-pack, etc.

wasm-opt --help

Pour vous donner un aperçu des cartes, voici un extrait de celles sont compréhensibles sans l'expertise d'un expert:

  • CodeFolding:permet d'éviter le code en double en le fusionnant (par exemple, si deux if partagent des instructions).
  • DeadArgumentElimination:transmission d'optimisation du délai d'association pour supprimer des arguments à une fonction si elle est toujours appelée avec les mêmes constantes.
  • MinifyImportsAndExports:réduit leur taille à "a" et "b".
  • DeadCodeElimination:supprimez le code mort.

Il y a un livre de recettes pour l'optimisation ainsi que des conseils pour identifier les options importantes et qui valent la peine d’être essayées en premier. Par exemple, si wasm-opt est parfois exécuté à plusieurs reprises réduit encore davantage l'entrée. Dans de tels cas, l’exécution avec le Option --converge continue d'itérer jusqu'à ce qu'aucune autre optimisation ne se produise et qu'un point fixe soit atteint.

Démo

Pour voir en pratique les concepts présentés dans cet article, amusez-vous avec l'API en lui fournissant toutes les entrées ExampleScript auxquelles vous pouvez penser. Veillez également à afficher le code source de la démonstration.

Conclusions

Binaryen fournit un kit d'outils puissant pour compiler des langages pour WebAssembly et pour optimiser le code obtenu. Sa bibliothèque JavaScript et ses outils de ligne de commande offrent flexibilité et facilité d'utilisation. Cet article a montré les principes fondamentaux de Compilation Wasm mettant en évidence l'efficacité et le potentiel de Binaryen pour une optimisation maximale. Même si la plupart des options de personnalisation les optimisations exigent une connaissance approfondie des composants internes de Wasm, généralement les paramètres par défaut fonctionnent déjà très bien. Nous sommes heureux de vous aider à compiler et optimiser avec Binaryen.

Remerciements

Ce post a été examiné par Alon Zakai, Thomas Lively et Rachel Andrew.