Compiler et optimiser Wasm avec Binaryen

Binaryen est une bibliothèque d'infrastructure de compilateur et de chaîne d'outils pour WebAssembly, écrite en C++. Elle vise à rendre la compilation sur WebAssembly intuitive, rapide et efficace. Dans cet article, en utilisant l'exemple d'un langage synthétique appelé ExampleScript, vous allez apprendre à écrire des modules WebAssembly en JavaScript à l'aide de l'API Binaryen.js. Vous découvrirez les principes de base de la création de modules, de l'ajout de fonctions au module et de l'exportation de fonctions à partir du module. Cela vous donnera des connaissances sur les mécanismes généraux de compilation de langages de programmation réels sur WebAssembly. Vous apprendrez également à optimiser les modules Wasm avec Binaryen.js et dans la ligne de commande avec wasm-opt.

Contexte du binaire

Binaryen dispose d'une API C intuitive dans un seul en-tête et peut également être utilisé à partir de JavaScript. Il accepte les entrées au forme WebAssembly, mais accepte également un graphique de flux de contrôle général 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. Le signal infrarouge interne de Binaryen utilise des structures de données compactes et est conçu pour générer et optimiser du code entièrement en parallèle, grâce à tous les cœurs de processeur disponibles. L'IR de Binaryen se compile en 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 optimisations visent à rendre Binaryen suffisamment puissant pour être utilisé seul comme backend de compilateur. Il inclut des optimisations spécifiques à WebAssembly (que les compilateurs à usage général peuvent ne pas faire) que vous pouvez considérer comme une minimisation de Wasm.

AssemblyScript en tant qu'exemple d'utilisateur de Binaryen

Il est utilisé par un certain nombre de projets, par exemple AssemblyScript, qui l'utilise pour effectuer une compilation à partir d'un langage de type TypeScript directement dans WebAssembly. Essayez l'exemple dans le simulateur d'AssemblyScript.

Entrée AssemblyScript:

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

Code WebAssembly correspondant sous forme textuelle, généré par 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
 )
)

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 aux développeurs JavaScript et aux utilisateurs de ligne de commande. Vous trouverez ci-dessous un sous-ensemble de ces outils. La liste complète des outils contenus 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 compilations, consultez binaryen.js sur npm (ou téléchargez-le directement depuis GitHub ou unpkg).
  • wasm-opt: outil de ligne de commande qui charge WebAssembly et exécute des passes IR Binaryen sur celui-ci.
  • wasm-as et wasm-dis: outils de ligne de commande qui assemblent et désassemblent 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 certaines parties des fichiers Wasm de manière flexible en fonction de l'utilisation du module.
  • wasm-merge: outil de ligne de commande qui fusionne plusieurs fichiers Wasm en un seul fichier, en connectant les importations correspondantes aux exportations, comme c'est le cas pour un 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 importantes sont répertoriées dans la liste suivante:

  • Analyse lexicale:décomposez le code source en jetons.
  • Analyse syntaxique:créez une arborescence syntaxique abstraite.
  • Analyse sémantique:recherchez les erreurs et appliquez des règles de langue.
  • 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:optimisez-la en fonction de la cible.

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

  • lex (générateur d'analyseur lexical) : lex est un outil qui génère des analyseurs lexiques, également appelés lexeurs. Il prend en entrée un ensemble d'expressions régulières et les actions correspondantes, et 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 des analyseurs pour l'analyse syntaxique. Il prend en entrée une description grammaticale formelle d'un langage de programmation et génère le code d'un analyseur. Les analyseurs produisent généralement des arbres syntaxiques abstraits (AST) qui représentent la structure hiérarchique du code source.

Un exemple concret

Étant donné le champ d'application de cet article, il est impossible d'aborder un langage de programmation complet. Par souci de simplicité, nous vous conseillons donc d'utiliser un langage de programmation synthétique très limité et inutile appelé ExampleScript, qui exprime des opérations génériques à l'aide d'exemples concrets.

  • Pour écrire une fonction add(), vous devez coder un exemple de n'importe quel ajout, par exemple 2 + 3.
  • Pour écrire une fonction multiply(), écrivez, par exemple, 6 * 12.

D'après le pré-avertissement, cette information est complètement inutile, mais suffisamment simple pour que son analyseur lexical soit 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 d'une arborescence syntaxique abstraite peut être créée à l'aide d'une expression régulière avec des groupes de capture nommés : /(?<first_operand>\d+)\s*(?<operator>[\+\-\*\/])\s*(?<second_operand>\d+)/.

Les commandes ExampleScript correspondent à une commande par ligne. L'analyseur peut donc traiter le code ligne de code en le divisant en fonction des caractères de retour à la ligne. Cela suffit pour vérifier les trois premières étapes de la liste à puces précédemment, à savoir l'analyse lexicale, l'analyse syntaxique et l'analyse sémantique. Le code correspondant à ces étapes figure dans 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 (bien que très simplifiée), l'étape suivante consiste à créer une représentation intermédiaire abstraite. La première étape consiste à créer un module dans Binaryen:

const module = new binaryen.Module();

Chaque ligne de l'arborescence syntaxique abstraite contient un triple composé de firstOperand, operator et secondOperand. Pour chacun des quatre opérateurs possibles dans ExampleScript, à savoir +, -, * et /, une nouvelle fonction doit être ajoutée au module avec la méthode Module#addFunction() de Binaryen. Les paramètres des méthodes Module#addFunction() sont les suivants:

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

Vous trouverez d'autres informations pour vous détendre et décomposer, et la documentation binaire peut vous aider à naviguer dans l'espace. Cependant, pour l'opérateur + d'ExampleScript, la méthode Module#i32.add() est l'une des nombreuses opérations d'entiers disponibles. L'addition nécessite deux opérandes : le premier et le second. Pour que la fonction puisse être appelée, elle doit être exportée 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, trois utilisant des nombres entiers, à savoir add() basée sur Module#i32.add(), subtract() basée sur Module#i32.sub(), multiply() basée sur 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 gérez de vraies bases de code, il y a parfois du code mort qui n'est jamais appelé. Pour introduire artificiellement du code mort (qui sera optimisé et éliminé ultérieurement) dans l'exemple de compilation d'ExampleScript sur Wasm, l'ajout d'une fonction non exportée permet d'effectuer 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. Il n'est pas strictement nécessaire, mais c'est une bonne pratique de valider le module avec la méthode Module#validate().

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

Obtenir le code Wasm obtenu

Pour obtenir le code Wasm résultant, il existe dans Binaryen deux méthodes permettant d'obtenir la représentation textuelle sous forme de fichier .wat dans S-expression sous forme de format lisible et la représentation binaire sous forme de fichier .wasm pouvant s'exécuter directement dans le navigateur. Le code binaire peut être exécuté directement dans le navigateur. Pour vérifier que cela a fonctionné, il peut être utile de consigner les exportations.

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 avec les quatre opérations est listée ci-dessous. Notez que le code mort est toujours présent, mais qu'il n'est pas exposé, comme le montre la capture d'écran de 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: ajouter, 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 lui-même, et un pour la ligne de commande. Le premier applique l'ensemble standard de règles d'optimisation par défaut et vous permet de définir les niveaux d'optimisation et de réduction. Le second n'utilise par défaut aucune règle, mais permet une personnalisation complète. Avec suffisamment de tests, vous pouvez adapter les paramètres pour obtenir 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 à éventuellement définir le niveau d'optimisation et de réduction.

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

Cette action supprime le code mort qui a été introduit artificiellement précédemment, de sorte que la représentation textuelle de la version Wasm de l'exemple de jouet ExampleScript ne la contient plus. Notez également que les paires local.set/get sont supprimées par les étapes d'optimisation SimplifyLocals (différentes optimisations liées aux zones locales) et Vacuum (qui supprime le code inutilement inutile), et que return est supprimé par RemoveUnusedBrs (qui supprime les pauses dans les établissements inutiles).

 (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 nombreuses passes d'optimisation, et Module#optimize() utilise les ensembles par défaut des niveaux d'optimisation et de réduction spécifiques. Pour une personnalisation complète, vous devez utiliser l'outil de ligne de commande wasm-opt.

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

Pour une personnalisation complète des cartes à utiliser, Binaryen inclut l'outil de ligne de commande wasm-opt. Pour obtenir la liste complète des options d'optimisation possibles, consultez le message d'aide de l'outil. L'outil wasm-opt est probablement le plus populaire. Il 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 une idée de ces différentes cartes, voici quelques-unes de celles qui sont compréhensibles à votre insu:

  • CodeFolding:évite le code en double en le fusionnant (par exemple, si deux bras if ont des instructions partagées de leur côté).
  • DeadArgumentElimination:passe d'optimisation du temps de liaison pour supprimer les arguments d'une fonction si elle est toujours appelée avec les mêmes constantes.
  • MinifyImportsAndExports:réduit la taille de ces éléments à "a", "b".
  • DeadCodeElimination:supprime le code mort.

Un livre de recettes sur l'optimisation est disponible. Il contient plusieurs conseils pour identifier les indicateurs les plus importants et les plus utiles. Par exemple, l'exécution répétée de wasm-opt réduit encore davantage l'entrée. Dans ce cas, l'exécution avec l'indicateur --converge continue d'effectuer des itérations jusqu'à ce qu'aucune autre optimisation ne se produise et qu'un point fixe soit atteint.

Démonstration

Pour voir concrètement les concepts présentés dans cet article, jouez avec la démo intégrée en lui fournissant n'importe quelle entrée ExampleScript possible. Veillez également à afficher le code source de la démonstration.

Conclusions

Binaryen fournit un kit d'outils puissant pour compiler des langages dans WebAssembly et optimiser le code obtenu. Sa bibliothèque JavaScript et ses outils de ligne de commande offrent flexibilité et facilité d'utilisation. Cet article a présenté les principes de base de la compilation Wasm, en soulignant l'efficacité et le potentiel d'optimisation de Binaryen. Bien que de nombreuses options de personnalisation des optimisations de Binaryen nécessitent une connaissance approfondie des mécanismes internes de Wasm, les paramètres par défaut fonctionnent déjà très bien. Nous vous souhaitons une bonne compilation et optimisation avec Binaryen !

Remerciements

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