Como compilar e otimizar o Wasm com Binaryen

Binaryen é um compilador e um conjunto de ferramentas; biblioteca de infraestrutura para WebAssembly, escrita em C++. Ele visa tornar e fazer a compilação para o WebAssembly intuitiva, rápida e eficaz. Nesta postagem, usar o exemplo de uma linguagem de brinquedos sintética chamada ExampleScript, aprenda a escrever Módulos WebAssembly em JavaScript usando a API Binaryen.js. Você vai conhecer conceitos básicos sobre criação de módulos, adição de funções ao módulo e exportação do módulo. Isso lhe dará conhecimento sobre o mecânica de compilação de linguagens de programação reais para o WebAssembly. Além disso, você vai aprender a otimizar módulos Wasm com Binaryen.js e na linha de comando com wasm-opt.

Informações sobre Binaryen

O Binaryen tem uma visão intuitiva API C em um único cabeçalho e também podem ser usados com JavaScript. Ele aceita entrada Formulário WebAssembly, mas também aceita uma abordagem gráfico de fluxo de controle para compiladores que preferem isso.

Uma representação intermediária (IR) é a estrutura de dados ou código usado internamente por um compilador ou máquina virtual para representar o código-fonte. de Binaryen as IRs internas usam estruturas de dados compactas e foram projetadas para operações em paralelo e otimização de código usando todos os núcleos de CPU disponíveis. RI de Binaryen é compilado para WebAssembly por ser um subconjunto do WebAssembly.

O otimizador do Binaryen tem muitas passagens que podem melhorar o tamanho e a velocidade do código. Esses têm como objetivo tornar o binárioen poderoso o suficiente para ser usado como um compilador. back-end. Ela inclui otimizações específicas do WebAssembly (que compiladores de uso geral podem não fazer), o que pode considerar o Wasm minificação.

AssemblyScript como um exemplo de usuário do Binaryen

O Binaryen é usado em vários projetos, por exemplo, AssemblyScript, que usa 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 de texto gerado por 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 binário

O conjunto de ferramentas Binário oferece várias ferramentas úteis para JavaScript desenvolvedores e usuários de linha de comando. Um subconjunto dessas ferramentas está listado seguindo as 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 bináriosen. para criação e otimização de módulos Wasm. Para builds, consulte binaryen.js no npm (ou faça o download diretamente GitHub ou unpkg).
  • wasm-opt: ferramenta de linha de comando que carrega o WebAssembly e executa IR binária. passa por ele.
  • 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 ) em tempo de compilação.
  • wasm-metadce: ferramenta de linha de comando para remover partes de arquivos Wasm em um formato flexível maneira que depende de como o módulo é usado.
  • wasm-merge: ferramenta de linha de comando que mescla vários arquivos Wasm em um único , conectando as importações correspondentes às exportações. Como um bundler para JavaScript, mas para Wasm.

Como compilar no WebAssembly

Compilar um idioma para outro geralmente envolve várias etapas, mais importantes estão listados na lista a seguir:

  • Análise léxica:divida o código-fonte em tokens.
  • Análise de sintaxe: 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ária:crie uma representação mais abstrata.
  • Geração de código:traduza para o idioma de destino.
  • Otimização de código específico do destino:otimize para a meta.

No mundo Unix, as ferramentas frequentemente usadas para compilação são lex e yacc:

  • lex (Gerador de analisador léxico): lex é uma ferramenta que gera analisadores, também conhecidos como leitores léxicos ou scanners. É necessário um conjunto expressões e ações correspondentes como entrada e gera código para uma analisador léxico que reconhece padrões no código-fonte de entrada.
  • yacc (Yet Another Compiler Compiler): o yacc é uma ferramenta que gera para análise sintática. É preciso uma descrição gramatical formal de um de programação como entrada e gera código para um analisador. Analisadores normalmente produzem árvores de sintaxe abstratas (ASTs) que representam a estrutura hierárquica do código-fonte.
.

Um exemplo prático

Devido ao escopo desta postagem, é impossível cobrir um resumo completo linguagem natural. Por isso, por uma questão de simplicidade, considere uma abordagem muito limitada uma linguagem de programação sintética chamada ExampleScript, que funciona expressando operações genéricas por meio de exemplos concretos.

  • Para escrever uma função add(), codifique um exemplo de qualquer adição, por exemplo, 2 + 3.
  • Para criar uma função multiply(), escreva 6 * 12, por exemplo.

De acordo com o pré-aviso, completamente inútil, mas simples o suficiente para os termos léxicos analisador como uma única expressão regular: /\d+\s*[\+\-\*\/]\s*\d+\s*/.

Em seguida, é necessário ter um analisador. Na verdade, uma versão muito simplificada da 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 de ExampleScript são um por linha, de modo que o analisador possa processar o código linha, dividindo em caracteres de nova linha. Isso é suficiente para verificar as três etapas da lista com marcadores anteriores, que são análise léxica, sintaxe análise e análise semântica. O código dessas etapas está 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ária

Agora que os programas ExampleScript podem ser representados como uma árvore de sintaxe abstrata (embora bem simplificado), a próxima etapa é criar um representação intermediária. A primeira etapa é Crie um novo módulo em 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 uma das quatro opções em ExampleScript, ou seja, +, -, *, /, uma nova a função precisa ser adicionada ao módulo com o método Module#addFunction() de Binaryen. Os parâmetros do Estes são os métodos Module#addFunction():

  • name: um string representa o nome da função.
  • functionType: um Signature representa a assinatura da função.
  • varTypes: um Type[] indica outros locais, na ordem indicada.
  • body: um Expression, o conteúdo da função.

Há mais alguns detalhes para relaxar e analisar e a Documentação do Binaryen pode ajudar a navegar pelo espaço, mas, eventualmente, para + do ExampleScript operador, o método Module#i32.add() é um dos vários disponível operações de números inteiros. A adição requer dois operandos, o primeiro e o segundo somatório. Para o seja realmente chamável, ela precisa ser exportado 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 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ódigos mortos que nunca for chamado. Para introduzir artificialmente código morto (que será otimizado e eliminado em uma etapa posterior) no exemplo em execução do compilação no 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 prática recomendada para validar o módulo com o método Module#validate().

if (!module.validate()) {
  throw new Error('Validation error');
}

Como encontrar o código Wasm resultante

Para obter o código Wasm resultante, existem dois métodos em Binaryen para conseguir representação textual como um arquivo .wat em S-expression 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 são executados diretamente no navegador. Para conferir se funcionou, registre as exportações 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);

A representação textual completa de um programa ExampleScript com todos os quatro operações está listada abaixo. Observe como o código inativo ainda está lá, mas não é exposto de acordo com a captura de tela 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 do WebAssembly mostrando quatro funções: adicionar, dividir, multiplicar e subtrair (mas não o código inativo 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 um para a linha de comando. O primeiro aplica o conjunto padrão de otimização regras por padrão e permite definir os níveis de otimização e redução, e a por padrão, não usa regras, mas permite personalização completa, o que significa que, com testes suficientes, é possível personalizar as configurações para resultados ideais com base no seu código.

Como otimizar com 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, definindo otimizar e reduzir.

// 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, de modo que representação textual da versão Wasm do brinquedo Exemplo de não já o contém. Observe também como os pares local.set/get são removidos pelo etapas de otimização SimplifyLocals (otimizações relacionadas a locais diversos) e o Aspirador de pó (remove o código obviamente desnecessário), e return é removido RemoveUnusedBrs (remove intervalos 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)
  )
 )
)

Existem várias transmissões de otimização, e Module#optimize() usa os níveis específicos de otimização e redução padrão conjuntos Para personalização completa, é necessário usar a ferramenta de linha de comando wasm-opt.

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

Para a personalização completa dos passes a serem usados, o Binaryen inclui os Ferramenta de linha de comando wasm-opt. Para receber 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 é usado 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 dos passes, aqui está um trecho de alguns dos que sejam compreensíveis sem conhecimento especializado:

  • CodeFolding:evita código duplicado ao mescla-lo (por exemplo, se duas if grupos têm algumas instruções compartilhadas no final).
  • DeadElimination:a otimização de tempo de vinculação é transmitida para remover argumentos. a uma função se ela é sempre chamada com as mesmas constantes.
  • MinifyImportsAndExports:reduz para "a", "b".
  • DeadCodeElimination:remove o código inativo.

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

Demonstração

Para ver os conceitos apresentados nessa postagem em ação, teste a imagem fornecendo a ele qualquer entrada ExampleScript que você imaginar. Não se esqueça de confira o código-fonte da demonstração.

Conclusões

O Binaryen oferece um kit de ferramentas poderoso para a compilação de linguagens para 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 de Compilação Wasm, destacando a eficácia e o potencial do Binaryen para otimização máxima. Embora muitas das opções de personalização dos modelos Binaryen otimizações exigem um profundo conhecimento sobre os componentes internos do Wasm, as configurações padrão já funcionam muito bem. Desejamos a você uma boa compilação e otimização. com a Binaryen!

Agradecimentos

Esta postagem foi revisada por Alon Zakai, Thomas Lively e Rachel André.