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) 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. L'IR interne de Binaryen utilise des structures de données compactes et est conçu pour la génération et l'optimisation de code complètement parallèles, en utilisant 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. Il inclut des optimisations spécifiques à WebAssembly (que les compilateurs à usage général ne peuvent pas effectuer), que vous pouvez considérer comme une minification Wasm.
AssemblyScript en tant qu'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
)
)
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
etwasm-dis
: outils de ligne de commande qui assemblent et désassemblent WebAssembly.wasm-ctor-eval
: outil de ligne de commande pouvant 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 utilise une description grammaticale formelle d'un langage de programmation comme entrée 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.
Un exemple concret
É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 codez un exemple d'addition, par exemple2 + 3
. - Pour écrire une fonction
multiply()
, vous devez écrire, par exemple,6 * 12
.
Conformément à l'avertissement préalable, complètement inutile, mais suffisamment simple pour que son analyseur lexical soit une seule expression régulière: /\d+\s*[\+\-\*\/]\s*\d+\s*/
.
Ensuite, un analyseur doit être présent. 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 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 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 abstrait (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'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
: unSignature
, représente la signature de la fonction.varTypes
: unType[]
, indique des locaux supplémentaires, dans l'ordre donné.body
:Expression
, contenu de la fonction.
D'autres détails doivent être démêlés et analysés, 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 le traitement de l'arbre de syntaxe abstraite, le module contient quatre méthodes, trois fonctionnant avec des nombres entiers, à savoir add()
basé sur Module#i32.add()
, subtract()
basé sur Module#i32.sub()
, multiply()
basé sur Module#i32.mul()
et l'élément inhabituel 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 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 vers Wasm, l'ajout d'une fonction non exportée suffit.
// 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 strictement nécessaire, mais 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)
)
)
)
Optimiser WebAssembly
Binaryen propose deux méthodes pour optimiser le code Wasm. L'un dans Binaryen.js lui-même et l'autre 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'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
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 comment les paires local.set/get
sont supprimées par les étapes d'optimisation SimplifyLocals (optimisations diverses liées aux locaux) et Vacuum (supprime le code inutile évident), et que return
est supprimé par RemoveUnusedBrs (supprime les coupures des 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 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:étape d'optimisation au moment de la liaison visant à 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, il arrive que l'exécution répétée de wasm-opt
réduise encore l'entrée. Dans ce cas, l'exécution avec l'indicateur --converge
continue d'itérer jusqu'à ce qu'aucune optimisation supplémentaire 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 obtenu. Sa bibliothèque JavaScript et ses outils de ligne de commande offrent une flexibilité et une facilité d'utilisation. 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.