Como compilar e otimizar o Wasm com Binaryen

Binaryen é uma biblioteca de infraestrutura de conjunto de ferramentas e compilador para WebAssembly, escrita em C++. O objetivo dele é 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, você aprenderá a escrever módulos WebAssembly em JavaScript usando a API Binaryen.js. Você verá os conceitos básicos sobre criação de módulos, adição da função ao módulo e exportação de funções do módulo. Isso lhe dará conhecimento sobre a mecânica geral de compilação de linguagens de programação reais para o WebAssembly. Além disso, você aprenderá a otimizar os módulos Wasm com o Binaryen.js e na linha de comando com wasm-opt.

Contexto sobre Binaryen

O Binaryen tem uma API C intuitiva em um único cabeçalho e também pode ser usado no JavaScript. Ela aceita entradas no formato WebAssembly, mas também aceita um fluxograma de fluxo de controle geral para compiladores que preferem isso.

Uma representação intermediária (IR, na sigla em inglês) é a estrutura ou o código de dados usado internamente por um compilador ou uma máquina virtual para representar o código-fonte. A IR interna da 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 é compilado até WebAssembly por ser um subconjunto do WebAssembly.

O otimizador da Binaryen tem muitas passagens que podem melhorar o tamanho e a velocidade do código. O objetivo dessas otimizações é tornar o Binaryen poderoso o suficiente para ser usado como um back-end do compilador. Ela inclui otimizações específicas do WebAssembly (que os compiladores de uso geral podem não fazer), que podem ser consideradas como minificação do Wasm.

AssemblyScript como um exemplo de usuário do Binaryen

O Binaryen é usado por vários projetos, como o AssemblyScript, que usa o Binaryen para compilar de uma linguagem semelhante a TypeScript diretamente para o 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 textual 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 playground AssemblyScript mostrando o código WebAssembly gerado com base no exemplo anterior.

O conjunto de ferramentas Binaryen

O conjunto de ferramentas Binaryen oferece várias ferramentas úteis para desenvolvedores de JavaScript e usuários de linha de comando. Um subconjunto dessas ferramentas está listado a seguir. A lista completa de ferramentas contidas está disponível no arquivo README do projeto.

  • binaryen.js: uma biblioteca JavaScript autônoma 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 do unpkg).
  • wasm-opt: ferramenta de linha de comando que carrega o WebAssembly e executa as transmissões de infravermelho do Binaryen nele.
  • wasm-as e wasm-dis: ferramentas de linha de comando que montam e desmontam o WebAssembly.
  • wasm-ctor-eval: ferramenta de linha de comando que pode executar funções (ou partes de funções) em tempo de compilação.
  • wasm-metadce: ferramenta de linha de comando para remover partes de arquivos Wasm de maneira flexível que depende 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 correspondentes às exportações. Como um bundler para JavaScript, mas para o Wasm.

Como compilar no WebAssembly

A compilação de uma linguagem para outra geralmente envolve várias etapas. As mais importantes estão listadas na lista abaixo:

  • Análise léxica:divida 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 idioma.
  • Geração de código intermediária: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 objetivo.

No universo do Unix, as ferramentas de compilação mais usadas são lex e yacc:

  • lex (Gerador de analisador léxico): lex é uma ferramenta que gera analisadores lexicos, também conhecidos como analisadores léxicos. Ela usa um conjunto de expressões regulares e ações correspondentes como entrada e gera um 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 uma descrição gramatical formal de uma linguagem de programação como entrada e gera um código para um analisador. Os analisadores geralmente produzem árvores de sintaxe abstrata (ASTs, na sigla em inglês) que representam a estrutura hierárquica do código-fonte.

Um exemplo funcional

Considerando o 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 expressa operações genéricas com exemplos concretos.

  • Para criar uma função add(), você codifica um exemplo de qualquer adição, como 2 + 3.
  • Para criar uma função multiply(), crie, por exemplo, 6 * 12.

De acordo com o pré-aviso, 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, é preciso que haja 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 por linha. Portanto, o analisador pode processar o código em linhas, dividindo em caracteres de nova linha. Isso é suficiente para verificar as três primeiras etapas da lista com marcadores, ou seja, análise léxica, análise sintática e análise semântica. O código para essas etapas está na lista 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ária

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 um triplo que consiste em firstOperand, operator e secondOperand. Para cada um dos quatro operadores possíveis no ExampleScript, ou seja, +, -, *, /, 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: uma string, representa o nome da função.
  • functionType: uma Signature representa a assinatura da função.
  • varTypes: um Type[] indica outros locais, na ordem informada.
  • body: uma Expression, o conteúdo da função.

Há mais alguns detalhes a serem desenrolados, e a documentação de Binaryen pode ajudar a navegar pelo espaço. Mas, em algum momento, para o operador + do ExampleScript, você vai chegar ao método Module#i32.add() como uma das várias operações inteiras disponíveis. A adição requer dois operandos, o primeiro e o segundo somatório. 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() baseado 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ê lidar com bases de código reais, às vezes haverá código inativo que nunca é chamado. Para introduzir artificialmente o código morto (que será otimizado e eliminado em uma etapa posterior) no exemplo em execução da compilação do ExampleScript ao 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 agora. Não é estritamente necessário, mas definitivamente é uma prática recomendada 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 receber o código Wasm resultante, há dois métodos em binário para obter a representação textual como um arquivo .wat na 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 ver se funcionou, registrar as exportações pode ajudar.

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

Veja a seguir a representação textual completa de um programa ExampleScript com as quatro operações. Observe como o código inativo continua 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)
  )
 )
)

Captura de tela do Console do DevTools das exportações do módulo WebAssembly, mostrando quatro funções: adicionar, dividir, multiplicar e subtrair, mas não o código morto não exposto.

Como 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 os níveis de otimização e redução. O último por padrão não usa regras, mas permite a personalização completa, o que significa que, com experimentação suficiente, é possível personalizar as configurações para resultados ideais com base no seu código.

Como otimizar com o Binaryen.js

A maneira mais direta de otimizar um módulo Wasm com Binaryen é chamar diretamente o método Module#optimize() de Binaryen.js e, opcionalmente, definir o otimizar e o nível de 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();

Assim, o código morto que foi introduzido artificialmente antes é removido. Assim, a representação textual da versão Wasm do exemplo de brinquedo ExampleScript não mais a contém. Observe também como os pares de local.set/get são removidos pelas etapas de otimização . termina com otimizações relacionadas a locais, e são removidos? Vacuum?returnRemoveUnusedBrs

 (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 específicos dos níveis de otimização e redução. Para personalização completa, você precisa usar a ferramenta de linha de comando wasm-opt.

Como otimizar com a ferramenta de linha de comando wasm-opt

Para personalização completa dos cartões a serem usados, o Binaryen inclui a ferramenta de linha de comando wasm-opt. Para ver uma lista completa das possíveis opções de otimização, confira a mensagem de ajuda da ferramenta. A ferramenta wasm-opt é provavelmente a mais conhecida das ferramentas e é usada por vários conjuntos de ferramentas de compilador para otimizar o código Wasm, incluindo Emscripten, J2CL, Kotlin/Wasm, dart2wasm, Wasm-pack e outros.

wasm-opt --help

Para você ter uma ideia de como funciona, aqui está um trecho de alguns deles que podem ser compreendidos sem o conhecimento de especialistas:

  • CodeFolding:evita código duplicado, mesclando-o (por exemplo, se dois grupos if tiverem algumas instruções compartilhadas no final).
  • DeadArgumentElimination:transmissão de otimização do tempo de vinculação para remover argumentos de uma função, caso ela seja sempre chamada com as mesmas constantes.
  • MinifyImportsAndExports:as minimiza para "a", "b".
  • DeadCodeElimination:remova o código morto.

Há um manual de otimização (link em inglês) disponível com várias dicas para identificar quais das diversas flags são mais importantes e valem a pena tentar primeiro. Por exemplo, às vezes, executar wasm-opt repetidamente reduz a entrada ainda mais. Nesses casos, a execução com a flag --converge continua iterando até que nenhuma outra otimização aconteça e um ponto fixo seja alcançado.

Demonstração

Para conferir na prática os conceitos apresentados nesta postagem, use a demonstração incorporada e inclua as entradas do ExampleScript. Além disso, consulte o código-fonte da demonstração.

Conclusões

O Binaryen fornece um kit de ferramentas avançado para compilar linguagens para o WebAssembly e otimizar o código resultante. A biblioteca JavaScript e as ferramentas de linha de comando oferecem flexibilidade e facilidade de uso. Essa postagem demonstrou os princípios fundamentais da compilação Wasm, destacando a eficácia do Binaryen e o potencial de otimização máxima. Embora muitas das opções de personalização de otimizações do Binaryen exigem conhecimento profundo sobre os componentes internos do Wasm, geralmente as configurações padrão já funcionam muito bem. Divirta-se compilando e otimizando com o Binaryen.

Agradecimentos

Esta postagem foi revisada por Alon Zakai, Thomas achatado e Rachel Andrew.