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, vous allez apprendre à écrire des modules WebAssembly en JavaScript à l'aide de l'API Binaryen.js, en utilisant l'exemple d'un langage synthétique appelé ExampleScript. Vous allez découvrir les bases de la création de modules, de l'ajout de fonctions au module et de l'exportation de fonctions à partir du module. Vous acquerrez ainsi des connaissances sur les mécanismes généraux de compilation des langages de programmation réels en WebAssembly. Vous apprendrez également à optimiser les modules Wasm avec Binaryen.js et sur la ligne de commande avec wasm-opt
.
Contexte concernant 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 aussi un graphique de flux de contrôle général pour les compilateurs qui le préfèrent.
Une représentation intermédiaire (RI) est la structure de données ou le code utilisés en interne par un compilateur ou une machine virtuelle pour représenter le code source. La représentation intermédiaire interne de Binaryen utilise des structures de données compactes et est conçue pour la génération et l'optimisation de code entièrement parallèles, en utilisant tous les cœurs de processeur disponibles. L'IR de Binaryen est compilé en WebAssembly, car il s'agit d'un sous-ensemble de WebAssembly.
L'optimiseur de Binaryen comporte de nombreux passages qui peuvent améliorer la taille et la vitesse du code. Ces optimisations visent à rendre Binaryen suffisamment puissant pour être utilisé comme backend de compilateur à part entière. Il inclut des optimisations spécifiques à WebAssembly (que les compilateurs à usage général ne font peut-être pas), que vous pouvez considérer comme une minification Wasm.
AssemblyScript en tant qu'exemple d'utilisateur de Binaryen
Binaryen est utilisé par de nombreux projets, par exemple AssemblyScript, qui utilise Binaryen pour compiler directement à partir d'un langage de type TypeScript vers WebAssembly. Essayez l'exemple dans AssemblyScript Playground.
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
)
)
Chaîne d'outils Binaryen
La chaîne d'outils Binaryen propose un certain nombre d'outils utiles aux développeurs JavaScript et aux utilisateurs de la ligne de commande. Un sous-ensemble de ces outils est listé ci-dessous. La liste complète des outils inclus 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 Binaryen IR dessus.wasm-as
etwasm-dis
: outils de ligne de commande qui assemblent et désassemblent WebAssembly.wasm-ctor-eval
: outil de ligne de commande permettant d'exécuter des fonctions (ou des parties de fonctions) au moment de la compilation.wasm-metadce
: outil en ligne de commande permettant de supprimer des parties de fichiers Wasm de manière flexible en fonction 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 aux exportations correspondantes. Il s'agit d'un outil de regroupement pour JavaScript, mais pour Wasm.
Compiler vers WebAssembly
La compilation d'une langue vers une autre implique généralement plusieurs étapes, dont les plus importantes sont listées ci-dessous :
- Analyse lexicale : le code source est divisé en jetons.
- Analyse syntaxique : créez un arbre syntaxique abstrait.
- Analyse sémantique : vérifiez 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 : traduire dans la langue cible.
- Optimisation du code spécifique à la cible : optimisez le code pour la cible.
Dans le monde Unix, les outils de compilation fréquemment utilisés sont lex
et yacc
:
lex
(Lexical Analyzer Generator) :lex
est un outil qui génère des analyseurs lexicaux, également appelés lexers ou scanners. Il prend en entrée un ensemble d'expressions régulières et d'actions correspondantes, 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 formelle de la grammaire d'un langage de programmation et génère du code pour un analyseur. Les analyseurs syntaxiques produisent généralement des arbres syntaxiques abstraits (ASA) qui représentent la structure hiérarchique du code source.
Exemple
Compte tenu de la portée de cet article, il est impossible de couvrir un langage de programmation complet. Par souci de simplicité, considérons un langage de programmation synthétique très limité et inutile appelé ExampleScript, qui fonctionne en exprimant des opérations génériques à travers des exemples concrets.
- Pour écrire une fonction
add()
, vous codez un exemple d'addition quelconque, par exemple2 + 3
. - Pour écrire une fonction
multiply()
, vous écrivez, par exemple,6 * 12
.
Comme indiqué dans l'avertissement, c'est complètement inutile, mais suffisamment simple pour que son analyseur lexical soit une simple expression régulière : /\d+\s*[\+\-\*\/]\s*\d+\s*/
.
Ensuite, il faut un analyseur. En fait, une version très simplifiée d'un arbre de syntaxe 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 sont sur une seule ligne, ce qui permet à l'analyseur de traiter le code ligne par ligne en le divisant sur les caractères de nouvelle ligne. Cela suffit pour vérifier les trois premières étapes de la liste à puces ci-dessus, à savoir l'analyse lexicale, l'analyse syntaxique et l'analyse sémantique. Le code de ces étapes se trouve 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'un arbre de syntaxe abstraite (bien que très simplifié), 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'arbre de syntaxe 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
:Signature
représente la signature de la fonction.varTypes
:Type[]
, indique des paramètres régionaux supplémentaires, dans l'ordre indiqué.body
:Expression
, contenu de la fonction.
La documentation Binaryen peut vous aider à vous y retrouver, mais vous finirez par arriver à la méthode Module#i32.add()
pour l'opérateur +
d'ExampleScript, qui fait partie des nombreuses opérations sur les nombres entiers disponibles.
L'addition nécessite deux opérandes : le premier et le deuxième terme. Pour que la fonction soit réellement appelable, 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 le traitement de l'arbre syntaxique abstrait, le module contient quatre méthodes, dont trois fonctionnent avec des nombres entiers, à savoir add()
basé sur Module#i32.add()
, subtract()
basé sur Module#i32.sub()
, multiply()
basé sur Module#i32.mul()
et le divide()
basé sur Module#f64.div()
, car ExampleScript fonctionne également avec des 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 manipulez de véritables bases de code, il arrive qu'il y ait du code mort qui n'est jamais appelé. Pour introduire artificiellement du code mort (qui sera optimisé et éliminé lors d'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 fortement recommandé de valider le module avec la méthode Module#validate()
.
if (!module.validate()) {
throw new Error('Validation error');
}
Obtenir le code Wasm résultant
Pour obtenir le code Wasm résultant, deux méthodes existent dans Binaryen pour obtenir la représentation textuelle sous forme de fichier .wat
en S-expression dans un format lisible par l'humain, et la représentation binaire sous forme de fichier .wasm
qui peut s'exécuter directement dans le navigateur. Le code binaire peut être exécuté directement dans le navigateur. Pour vérifier que l'opération a fonctionné, vous pouvez enregistrer 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 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)
)
)
)
Optimiser WebAssembly
Binaryen propose deux façons d'optimiser le code Wasm. L'un dans Binaryen.js lui-même et l'autre pour la ligne de commande. La première 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. La seconde n'utilise 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
La façon la 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 supprime le code mort qui avait été introduit artificiellement auparavant. La représentation textuelle de la version Wasm de l'exemple de jouet ExampleScript ne le contient donc plus. Notez également comment les paires local.set/get
sont supprimées par les étapes d'optimisation SimplifyLocals (optimisations diverses liées aux paramètres régionaux) et Vacuum (supprime le code manifestement inutile), et comment return
est supprimé par RemoveUnusedBrs (supprime les sauts de ligne des emplacements 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 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 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 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 et d'autres.
wasm-opt --help
Pour vous donner une idée des passes, voici un extrait de celles qui sont compréhensibles sans connaissances spécialisées :
- CodeFolding : évite la duplication de code en le fusionnant (par exemple, si deux bras
if
partagent des instructions à la fin). - DeadArgumentElimination : passe d'optimisation au moment de l'édition des liens pour supprimer les arguments d'une fonction si elle est toujours appelée avec les mêmes constantes.
- MinifyImportsAndExports : les réduit à
"a"
et"b"
. - DeadCodeElimination : supprime le code mort.
Un guide d'optimisation est disponible. Il contient plusieurs conseils pour identifier les indicateurs les plus importants et à essayer en premier. Par exemple, l'exécution répétée de wasm-opt
peut réduire davantage l'entrée. Dans ce cas, l'exécution avec l'indicateur --converge
continue d'itérer jusqu'à ce qu'aucune autre 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, testez la démo intégrée en lui fournissant n'importe quelle entrée ExampleScript. Veillez également à afficher le code source de la démo.
Conclusions
Binaryen fournit une boîte à outils puissante pour compiler des langages en WebAssembly et optimiser le code résultant. 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 de Binaryen pour une optimisation maximale. Bien que de nombreuses options de personnalisation des optimisations de Binaryen nécessitent une connaissance approfondie des composants internes de Wasm, les paramètres par défaut fonctionnent généralement très bien. Bonne compilation et optimisation avec Binaryen !
Remerciements
Cet article a été examiné par Alon Zakai, Thomas Lively et Rachel Andrew.