O Binaryen é uma biblioteca de infraestrutura de compilador e cadeia de ferramentas para WebAssembly, escrita em C++. O objetivo é tornar a compilação para WebAssembly intuitiva, rápida e eficaz. Nesta postagem, usando o exemplo de uma linguagem de brinquedo sintética chamada ExampleScript, aprenda a escrever módulos WebAssembly em JavaScript usando a API Binaryen.js. Você vai aprender os
fundamentos da criação de módulos, da adição de funções a eles e da exportação
de funções do módulo. Assim, você vai entender a mecânica geral de compilação de linguagens de programação reais para WebAssembly. Além disso, você vai aprender a otimizar módulos Wasm com Binaryen.js e na linha de comando com wasm-opt
.
Contexto sobre o Binaryen
O Binaryen tem uma API C intuitiva em um único cabeçalho e também pode ser usado em JavaScript. Ele aceita entradas no formato WebAssembly, mas também um gráfico de fluxo de controle geral para compiladores que preferem isso.
Uma representação intermediária (IR) é a estrutura de dados ou o código usado internamente por um compilador ou máquina virtual para representar o código-fonte. A IR interna do Binaryen usa estruturas de dados compactas e foi projetada para geração e otimização de código completamente paralelas, usando todos os núcleos de CPU disponíveis. A IR do Binaryen é compilada para WebAssembly por ser um subconjunto dele.
O otimizador do Binaryen tem várias passagens que podem melhorar o tamanho e a velocidade do código. Essas otimizações visam tornar o Binaryen poderoso o suficiente para ser usado como um back-end de compilador por si só. Ele inclui otimizações específicas do WebAssembly (que compiladores de uso geral podem não fazer), que você pode considerar como minificação do Wasm.
AssemblyScript como um exemplo de usuário do Binaryen
O Binaryen é usado por vários projetos, por exemplo, o AssemblyScript, que usa o Binaryen para compilar de uma linguagem semelhante ao TypeScript diretamente para WebAssembly. Teste o exemplo no playground do AssemblyScript.
Entrada do AssemblyScript:
export function add(a: i32, b: i32): i32 {
return a + b;
}
Código WebAssembly correspondente em formato de texto gerado pelo 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
)
)
O conjunto de ferramentas Binaryen
A cadeia de ferramentas Binaryen oferece várias ferramentas úteis para desenvolvedores de JavaScript
e usuários da linha de comando. Um subconjunto dessas ferramentas está listado abaixo. A lista completa de ferramentas incluídas está disponível no arquivo README
do projeto.
binaryen.js
: uma biblioteca JavaScript independente que expõe métodos Binaryen para criar e otimizar módulos Wasm. Para builds, consulte binaryen.js no npm (ou faça o download diretamente do GitHub ou unpkg).wasm-opt
: ferramenta de linha de comando que carrega o WebAssembly e executa transmissões de IR do Binaryen nele.wasm-as
ewasm-dis
: ferramentas de linha de comando que montam e desmontam WebAssembly.wasm-ctor-eval
: ferramenta de linha de comando que pode executar funções (ou partes de funções) no momento da compilação.wasm-metadce
: ferramenta de linha de comando para remover partes de arquivos Wasm de maneira flexível, dependendo de como o módulo é usado.wasm-merge
: ferramenta de linha de comando que mescla vários arquivos Wasm em um único arquivo, conectando importações e exportações correspondentes. Como um empacotador para JavaScript, mas para Wasm.
Compilando para WebAssembly
A compilação de uma linguagem para outra geralmente envolve várias etapas, e as mais importantes estão listadas abaixo:
- Análise léxica:divide o código-fonte em tokens.
- Análise sintática:crie uma árvore de sintaxe abstrata.
- Análise semântica:verifique se há erros e aplique regras de linguagem.
- Geração de código intermediário:crie uma representação mais abstrata.
- Geração de código:traduza para o idioma de destino.
- Otimização de código específica do destino:otimize para o destino.
No mundo Unix, as ferramentas usadas com frequência para compilação são
lex
e
yacc
:
lex
(gerador de analisador léxico):lex
é uma ferramenta que gera analisadores léxicos, também conhecidos como lexers ou scanners. Ele usa um conjunto de expressões regulares e ações correspondentes como entrada e gera código para um analisador léxico que reconhece padrões no código-fonte de entrada.yacc
(Yet Another Compiler Compiler):yacc
é uma ferramenta que gera analisadores para análise de sintaxe. Ele usa como entrada uma descrição formal da gramática de uma linguagem de programação e gera código para um analisador. Os analisadores geralmente produzem árvores de sintaxe abstrata (ASTs) que representam a estrutura hierárquica do código-fonte.
Um exemplo prático
Devido ao escopo desta postagem, é impossível abordar uma linguagem de programação completa. Portanto, para simplificar, considere uma linguagem de programação sintética muito limitada e inútil chamada ExampleScript, que funciona expressando operações genéricas por exemplos concretos.
- Para escrever uma função
add()
, codifique um exemplo de adição qualquer, digamos2 + 3
. - Para escrever uma função
multiply()
, escreva, por exemplo,6 * 12
.
Conforme o aviso prévio, completamente inútil, mas simples o suficiente para que o analisador
lexical seja uma única expressão regular: /\d+\s*[\+\-\*\/]\s*\d+\s*/
.
Em seguida, é necessário um analisador. Na verdade, uma versão muito simplificada de uma árvore de sintaxe abstrata pode ser criada usando uma expressão regular com grupos de captura nomeados: /(?<first_operand>\d+)\s*(?<operator>[\+\-\*\/])\s*(?<second_operand>\d+)/
.
Os comandos do ExampleScript são um por linha. Assim, o analisador pode processar o código linha por linha dividindo em caracteres de nova linha. Isso é suficiente para verificar as três primeiras etapas da lista com marcadores acima: análise lexical, análise sintática e análise semântica. O código para essas etapas está na listagem a seguir.
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),
};
});
}
}
Geração de código intermediário
Agora que os programas ExampleScript podem ser representados como uma árvore de sintaxe abstrata (embora bastante simplificada), a próxima etapa é criar uma representação intermediária abstrata. A primeira etapa é criar um novo módulo no Binaryen:
const module = new binaryen.Module();
Cada linha da árvore de sintaxe abstrata contém uma tripla composta por
firstOperand
, operator
e secondOperand
. Para cada um dos quatro operadores possíveis no ExampleScript, ou seja, +
, -
, *
e /
, uma nova função precisa ser adicionada ao módulo com o método Module#addFunction()
do Binaryen. Os parâmetros dos métodos
Module#addFunction()
são os seguintes:
name
: umstring
, representa o nome da função.functionType
: umSignature
, representa a assinatura da função.varTypes
: umType[]
, indica outros locais na ordem especificada.body
: umExpression
, o conteúdo da função.
Há mais detalhes para entender e analisar, e a
documentação do Binaryen
pode ajudar você a navegar pelo espaço. No entanto, para o operador +
do ExampleScript, você acaba chegando ao método Module#i32.add()
como uma das várias
operações de números inteiros
disponíveis.
A adição exige dois operandos: o primeiro e o segundo somando. Para que a função seja realmente chamável, ela precisa ser exportada com 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');
Depois de processar a árvore de sintaxe abstrata, o módulo contém quatro métodos, três trabalhando com números inteiros, ou seja, add()
com base em Module#i32.add()
, subtract()
com base em Module#i32.sub()
, multiply()
com base em Module#i32.mul()
e o outlier divide()
com base em Module#f64.div()
, porque o ExampleScript também funciona com resultados de ponto flutuante.
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 `/`.
Se você trabalha com bases de código reais, às vezes há código morto que nunca é chamado. Para introduzir artificialmente código morto (que será otimizado e eliminado em uma etapa posterior) no exemplo em execução da compilação do ExampleScript para Wasm, basta adicionar uma função não exportada.
// 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)),
]),
);
O compilador está quase pronto. Não é estritamente necessário, mas é uma boa prática validar o módulo com o método Module#validate()
.
if (!module.validate()) {
throw new Error('Validation error');
}
Como conseguir o código Wasm resultante
Para obter o código Wasm resultante, existem dois métodos no Binaryen para receber a representação textual como um arquivo .wat
em expressão S como um formato legível por humanos e a representação binária como um arquivo .wasm
que pode ser executado diretamente no navegador. O código binário pode ser
executado diretamente no navegador. Para verificar se funcionou, registre as exportações.
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);
A representação textual completa de um programa ExampleScript com todas as quatro operações está listada abaixo. Observe como o código inativo ainda está lá, mas não é exposto, conforme a captura de tela do 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)
)
)
)
Otimizar o WebAssembly
O Binaryen oferece duas maneiras de otimizar o código Wasm. Um no próprio Binaryen.js e outro para a linha de comando. O primeiro aplica o conjunto padrão de regras de otimização por padrão e permite definir o nível de otimização e redução. O segundo não usa regras por padrão, mas permite personalização total. Isso significa que, com experimentação suficiente, é possível adaptar as configurações para resultados ideais com base no seu código.
Como otimizar com Binaryen.js
A maneira mais simples de otimizar um módulo Wasm com o Binaryen é
chamar diretamente o método Module#optimize()
do Binaryen.js e, opcionalmente,
definir o
nível de otimização e redução.
// 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();
Isso remove o código inativo que foi introduzido artificialmente antes, para que a representação textual da versão Wasm do exemplo de brinquedo ExampleScript não o contenha mais. Observe também como os pares local.set/get
são removidos pelas etapas de otimização SimplifyLocals (várias otimizações relacionadas a locais) e Vacuum (remove códigos obviamente desnecessários), e o return
é removido por RemoveUnusedBrs (remove quebras de locais que não são necessários).
(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)
)
)
)
Há muitas transmissões de otimização, e o Module#optimize()
usa os conjuntos padrão de níveis de otimização e redução específicos. Para personalização completa, use a ferramenta de linha de comando wasm-opt
.
Otimização com a ferramenta de linha de comando wasm-opt
Para personalização completa das transmissões a serem usadas, o Binaryen inclui a
ferramenta de linha de comando wasm-opt
. Para conferir uma lista completa das opções de otimização possíveis, consulte a mensagem de ajuda da ferramenta. A ferramenta wasm-opt
provavelmente é a mais popular e é usada por várias cadeias de ferramentas de compilador para otimizar o código Wasm, incluindo Emscripten, J2CL, Kotlin/Wasm, dart2wasm, wasm-pack e outras.
wasm-opt --help
Para você entender melhor, aqui está um trecho de algumas das transmissões que são compreensíveis sem conhecimento especializado:
- CodeFolding:evita a duplicação de código mesclando-o. Por exemplo, se dois braços
if
tiverem algumas instruções compartilhadas no final. - DeadArgumentElimination:passagem de otimização de tempo de vinculação para remover argumentos de uma função se ela sempre for chamada com as mesmas constantes.
- MinifyImportsAndExports:minimiza para
"a"
,"b"
. - DeadCodeElimination:remove o código inoperante.
Há um
livro de receitas de otimização
disponível com várias dicas para identificar quais das várias flags são mais
importantes e valem a pena testar primeiro. Por exemplo, às vezes, executar wasm-opt
repetidamente reduz ainda mais a entrada. Nesses casos, a execução
com a
flag --converge
continua iterando até que não haja mais otimização e um ponto fixo seja
alcançado.
Demonstração
Para conferir os conceitos apresentados nesta postagem em ação, teste a demonstração incorporada e forneça qualquer entrada do ExampleScript que você quiser. Não se esqueça de conferir o código-fonte da demonstração.
Conclusões
O Binaryen oferece um kit de ferramentas avançado para compilar linguagens em WebAssembly e otimizar o código resultante. A biblioteca JavaScript e as ferramentas de linha de comando oferecem flexibilidade e facilidade de uso. Esta postagem demonstrou os princípios básicos da compilação do Wasm, destacando a eficácia e o potencial do Binaryen para otimização máxima. Embora muitas das opções de personalização das otimizações do Binaryen exijam conhecimento profundo sobre os internos do Wasm, geralmente as configurações padrão já funcionam muito bem. Com isso, boa compilação e otimização com o Binaryen!
Agradecimentos
Esta postagem foi revisada por Alon Zakai, Thomas Lively e Rachel Andrew.