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 vers WebAssembly intuitive, rapide et efficace. Dans cet article, à l'aide d'un exemple de langage de jeu synthétique appelé ExampleScript, découvrez comment é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. Vous en apprendrez davantage sur les mécanismes généraux de compilation de langages de programmation réels en WebAssembly. Vous apprendrez également à optimiser les modules Wasm à la fois avec Binaryen.js et sur la ligne de commande avec wasm-opt.

Informations sur Binaryen

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 format WebAssembly, mais également un graphique de flux de contrôle général pour les compilateurs qui le préfèrent.

Une représentation intermédiaire (IR) désigne la structure de données ou le code utilisé en interne par un compilateur ou une machine virtuelle pour représenter le code source. La fonction IR interne de Binaryen utilise des structures de données compactes et est conçue pour générer et optimiser du code de manière entièrement parallèle, à l'aide de 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é comme backend de compilation à lui seul. Elle inclut des optimisations spécifiques à WebAssembly (que les compilateurs à usage général pourraient ne pas faire), ce que vous pouvez considérer comme une minimisation Wasm.

AssemblyScript comme exemple d'utilisateur de Binaryen

Binaryen est utilisé par un certain nombre de projets, par exemple AssemblyScript, qui utilise Binaryen pour compiler directement à partir d'un langage semblable à TypeScript vers WebAssembly. Essayez l'exemple dans l'espace de jeu AssemblyScript.

Entrée AssemblyScript:

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

Code WebAssembly correspondant au format textuel 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
 )
)

L'espace de jeu AssemblyScript affichant le code WebAssembly généré sur la base de l'exemple précédent.

Chaîne d'outils Binaryen

La chaîne d'outils Binaryen propose un certain nombre d'outils utiles pour les développeurs JavaScript et les utilisateurs de ligne de commande. Un sous-ensemble de ces outils est listé ci-dessous. 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 dessus.
  • 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 des parties de 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 associant les importations correspondantes aux exportations. Comme un bundler pour JavaScript, mais pour Wasm.

Compiler vers WebAssembly

La compilation d'une langue vers une autre implique généralement plusieurs étapes, les plus importantes étant listées ci-dessous:

  • Analyse lexicale : divise le code source en jetons.
  • Analyse syntaxique:créez un arbre syntaxique abstrait.
  • Analyse sémantique : recherchez 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:traduction dans la langue cible.
  • Optimisation du code spécifique à la cible : optimisez pour la cible.

Dans l'univers Unix, les outils de compilation couramment utilisés sont lex et yacc :

  • lex (Générateur d'analyseur lexical) : lex est un outil qui génère des analyseurs lexicaux, également appelés analyseurs lexicaux ou analyseurs syntaxiques. Il utilise un ensemble d'expressions régulières et d'actions correspondantes en entrée, et génère du code pour un analyseur lexical qui reconnaît les modèles dans le code source d'entrée.
  • yacc (Yet Another 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 du code pour un analyseur. Les analyseurs génèrent généralement des arbres syntaxiques abstraits (ASA) qui représentent la structure hiérarchique du code source.

Exemple qui a fonctionné

Étant donné la portée de cet article, il est impossible de couvrir un langage de programmation complet. Par souci de simplicité, considérez un langage de programmation synthétique très limité et inutile appelé ExampleScript, qui fonctionne en exprimant des opérations génériques par des exemples concrets.

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

Comme indiqué dans l'avertissement préalable, cette méthode est complètement inutile, mais suffisamment simple pour que son analyseur lexical constitue une expression régulière unique: /\d+\s*[\+\-\*\/]\s*\d+\s*/.

Ensuite, un analyseur doit être présent. En réalité, vous pouvez créer une version très simplifiée d'une arborescence de syntaxe abstraite à 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 sont une par ligne. L'analyseur peut donc traiter le code ligne par ligne en le divisant en caractères de nouvelle ligne. Cela suffit à vérifier les trois premières étapes de la liste à puces précédente, à savoir l'analyse lexicale, l'analyse syntaxique et l'analyse sémantique. Le code de 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 triplet 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ésente le nom de la fonction.
  • functionType: un Signature, représente la signature de la fonction.
  • varTypes : un Type[], indique des locaux supplémentaires, dans l'ordre donné.
  • body : Expression, contenu de la fonction.

Il y a d'autres détails à démêler et à analyser, et la documentation Binaryen peut vous aider à vous y retrouver. Toutefois, pour l'opérateur + d'ExampleScript, vous vous retrouvez avec la méthode Module#i32.add(), qui fait partie des opérations sur les entiers disponibles. L'addition nécessite deux opérandes, le premier et le second terme. 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 de syntaxe abstraite, le module contient quatre méthodes, trois qui fonctionnent avec 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 l'anomalie 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 travaillez avec des bases de code réelles, il peut arriver que du code mort ne soit jamais appelé. Pour introduire artificiellement du code mort (qui sera optimisé et éliminé à une étape ultérieure) dans l'exemple d'exécution de la compilation d'ExampleScript en Wasm, l'ajout d'une fonction non exportée fait l'affaire.

// 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. Bien que ce ne soit pas strictement nécessaire, il est recommandé 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 obtenu, Binaryen propose deux méthodes permettant d'obtenir la représentation textuelle sous la forme d'un fichier .wat au format expression S lisible par l'humain, et la représentation binaire sous la forme d'un fichier .wasm pouvant être exécuté directement dans le navigateur. Le code binaire peut être exécuté directement dans le navigateur. Pour vérifier que cela a fonctionné, l'enregistrement des exportations peut être utile.

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 indiquée ci-dessous. Notez que le code mort est toujours là, 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: add, diviser, multiplier et soustraire (mais pas le code mort non exposé).

Optimiser WebAssembly

Binaryen propose deux méthodes pour optimiser le code Wasm. un dans Binaryen.js 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 le niveau d'optimisation et de réduction. Le second n'applique aucune règle par défaut, mais permet une personnalisation complète. Cela signifie qu'avec suffisamment d'expérimentation, vous pouvez adapter les paramètres pour obtenir des résultats optimaux en fonction de votre code.

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

Cela permet de supprimer le code mort qui a été introduit artificiellement auparavant. La représentation textuelle de la version Wasm de l'exemple ExampleScript ne le contient donc plus. Notez également que les paires local.set/get sont supprimées par les étapes d'optimisation SimplifyLocals (diverses optimisations liées aux locaux) et Vacuum (supprime le code inutilement inutile). return est supprimé par RemoveUnusedBrs (supprime les coupures dans les emplacements 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 avec l'outil de ligne de commande Wasm-opt

Pour personnaliser entièrement les passes à 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 des cartes, voici un extrait de certaines d'entre elles qui sont compréhensibles sans connaissances spécialisées:

  • CodeFolding : évite la duplication de code en le fusionnant (par exemple, si deux branches if partagent des instructions à la fin).
  • DeadArgumentElimination:transmission 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 : les réduit à "a", "b".
  • DeadCodeElimination : supprime le code mort.

Un guide d'optimisation est disponible avec plusieurs conseils pour identifier les différents indicateurs les plus importants et les plus intéressants à essayer en premier. Par exemple, l'exécution répétée de wasm-opt de manière répétée réduit encore davantage l'entrée. Dans ce cas, l'exécution avec l'indicateur --converge continue d'itérer jusqu'à ce qu'aucune optimisation ne soit effectuée et qu'un point fixe soit atteint.

Démo

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

Conclusions

Binaryen fournit un kit d'outils puissant pour compiler des langages en WebAssembly et optimiser le code généré. Sa bibliothèque JavaScript et ses outils de ligne de commande sont flexibles et faciles à utiliser. Cet article a présenté les principes de base de la compilation Wasm, en mettant en avant l'efficacité et le potentiel d'optimisation maximale de Binaryen. Bien que de nombreuses options de personnalisation des optimisations de Binaryen nécessitent des connaissances approfondies sur les composants internes de Wasm, les paramètres par défaut fonctionnent généralement très bien. À vos claviers !

Remerciements

Cet article a été examiné par Alon Zakai, Thomas Lively et Rachel Andrew.