Compilación y optimización de Wasm con Binaryen

Binaryen es un compilador y una cadena de herramientas. de infraestructura para WebAssembly, escrita en C++. Tiene como objetivo hacer compilar en WebAssembly es intuitivo, rápido y eficaz. En esta publicación, usaremos ejemplo de un lenguaje de juguete sintético llamado ExampleScript, aprende a escribir Módulos de WebAssembly en JavaScript con la API de Binaryen.js Abarcarás los básicos de la creación de módulos, la adición de funciones al módulo y la exportación funciones del módulo. Esto te permitirá conocer los aspectos generales de compilación de lenguajes de programación reales para WebAssembly. Además, Aprenderás a optimizar los módulos de Wasm con Binaryen.js y la línea de comandos con wasm-opt.

Información sobre Binaryen

Binaryen tiene un enfoque API de C en un solo encabezado y también se pueden se usan desde JavaScript. Acepta entradas Formulario de WebAssembly, pero también acepta una gráfico de flujo de control para compiladores que lo prefieren.

Una representación intermedia (IR) es la estructura de datos o el código que se usa. internamente por un compilador o máquina virtual para representar el código fuente. de Binaryen la IR interna usa estructuras de datos compactas y está diseñada para procesos la generación y optimización de código con todos los núcleos de CPU disponibles. IR de Binaryen compila a WebAssembly debido a que es un subconjunto de WebAssembly.

El optimizador de Binaryen tiene muchos pases que pueden mejorar el tamaño y la velocidad del código. Estos El objetivo de las optimizaciones es hacer que Binaryen sea lo suficientemente potente para usarse como compilador el backend por sí solo. Incluye optimizaciones específicas de WebAssembly (que es posible que los compiladores de uso general no lo hagan), lo que se puede considerar como Wasm reducción.

AssemblyScript como usuario de ejemplo de Binaryen

Varios proyectos usan Binaryen, por ejemplo, AssemblyScript, que usa Binaryen para compilar a partir de un lenguaje similar a TypeScript directamente a WebAssembly. Prueba el ejemplo en el Playground de AssemblyScript.

Entrada de AssemblyScript:

export function add(a: i32, b: i32): i32 {
  return a + b;
}

Código WebAssembly correspondiente en formato textual generado 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
 )
)

La zona de pruebas de AssemblyScript que muestra el código de WebAssembly generado según el ejemplo anterior.

La cadena de herramientas de Binaryen

La cadena de herramientas de Binaryen ofrece varias herramientas útiles para JavaScript. desarrolladores y usuarios de línea de comandos. Un subconjunto de estas herramientas se enumera en el siguiente; el lista completa de herramientas incluidas está disponible en el archivo README del proyecto.

  • binaryen.js: Una biblioteca de JavaScript independiente que expone los métodos de Binaryen para Crea y optimiza módulos de Wasm. Para compilaciones, consulta binaryen.js en npm (o descárgala directamente en GitHub o unpkg).
  • wasm-opt: Es la herramienta de línea de comandos que carga WebAssembly y ejecuta Binaryen IR. le pase.
  • wasm-as y wasm-dis: Herramientas de línea de comandos que ensamblan y desensamblan WebAssembly
  • wasm-ctor-eval: Herramienta de línea de comandos que puede ejecutar funciones (o partes de funciones) en el tiempo de compilación.
  • wasm-metadce: Herramienta de línea de comandos para quitar partes de los archivos Wasm en un entorno flexible que depende de cómo se use el módulo.
  • wasm-merge: Herramienta de línea de comandos que combina varios archivos Wasm en uno solo conectando las importaciones correspondientes con las exportaciones. Como un agrupador para JavaScript, pero para Wasm.

Cómo compilar en WebAssembly

Compilar un lenguaje a otro suele implicar varios pasos, el más las más importantes se enumeran en la siguiente lista:

  • Análisis léxico: Divide el código fuente en tokens.
  • Análisis sintáctico: Crea un árbol sintáctico abstracto.
  • Análisis semántico: Verifica si hay errores y aplica las reglas del lenguaje.
  • Generación de código intermedio: Crea una representación más abstracta.
  • Generación de código: Traduce al idioma de destino.
  • Optimización de código específico para el objetivo: Optimiza en función del objetivo.

En el mundo Unix, las herramientas que se usan con frecuencia para compilar son lex y yacc:

  • lex (generador de Lexical Analyzer): lex es una herramienta que genera modelos léxicos analizadores, también conocidos como léxicos o escáneres. Se necesita un conjunto de registros y las acciones correspondientes como entrada, y genera código para un y léxico que reconoce patrones en el código fuente de entrada.
  • yacc (Yet Another Compiler Compiler): yacc es una herramienta que genera de análisis de sintaxis. Consta de una descripción gramatical formal de un de programación como entrada, y genera código para un analizador. Analizadores normalmente producen árboles de sintaxis abstractos (AST) que representan la estructura jerárquica del código fuente.

Un ejemplo trabajado

Dado el alcance de esta publicación, es imposible cubrir una programación completa de lenguaje extenso, así que, para una mayor simplicidad, considera un lenguaje de programación sintético llamado ExampleScript que funciona expresando operaciones genéricas a través de ejemplos concretos.

  • Para escribir una función add(), debes codificar un ejemplo de cualquier suma, como 2 + 3
  • Para escribir una función multiply(), escribe, por ejemplo, 6 * 12.

Según la advertencia previa, es completamente inútil, pero lo suficientemente simple por su comportamiento léxico analizador sea una sola expresión regular: /\d+\s*[\+\-\*\/]\s*\d+\s*/.

Luego, debe haber un analizador. En realidad, una versión muy simplificada de se puede crear un árbol sintáctico abstracto usando una expresión regular con grupos de captura con nombre: /(?<first_operand>\d+)\s*(?<operator>[\+\-\*\/])\s*(?<second_operand>\d+)/

Los comandos de ExampleScript son uno por línea, por lo que el analizador puede procesar el código. línea dividiendo en caracteres de línea nueva. Esto es suficiente para verificar el primer tres pasos de la lista anterior, el análisis léxico, sintaxis análisis y análisis semántico. El código para estos pasos se encuentra en que aparece a continuación.

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

Generación de código intermedia

Ahora que los programas de ExampleScript pueden representarse como un árbol de sintaxis abstracto (aunque sea bastante simplificado), el siguiente paso es crear una imagen intermediaria. El primer paso Crea un módulo nuevo en Binaryen:

const module = new binaryen.Module();

Cada línea del árbol de sintaxis abstracta contiene un triple compuesto por firstOperand, operator y secondOperand. Para cada uno de los cuatro posibles operadores en ExampleScript, es decir, +, -, *, /, un nuevo la función debe agregarse al módulo con el método Module#addFunction() de Binaryen. Los parámetros de la Los métodos Module#addFunction() son los siguientes:

  • name: Es un string, representa el nombre de la función.
  • functionType: Es un Signature, representa la firma de la función.
  • varTypes: Es un Type[], indica locales adicionales, en el orden determinado.
  • body: Es un Expression, el contenido de la función.

Aún hay más detalles para relajarse y desglosarse y la Documentación de Binary puede ayudarte a navegar por el espacio, pero, con el tiempo, para + de ExampleScript, terminas en el método Module#i32.add() como uno de varios disponible operaciones de números enteros. La suma requiere dos operandos, el primero y el segundo. Para el para que se puedan llamar realmente, se debe exportado con 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');

Después de procesar el árbol de sintaxis abstracto, el módulo contiene cuatro métodos: tres que trabajan con números enteros, es decir, add() basado en Module#i32.add(), subtract() basado en Module#i32.sub(); multiply() basado en Module#i32.mul() y el valor atípico divide() según Module#f64.div() ya que ExampleScript también funciona con resultados de punto flotante.

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 trabajas con bases de código reales, a veces habrá código muerto que nunca se llama. Para ingresar artificialmente código no muerto (que se optimizará y se eliminará en un paso posterior) en el ejemplo en ejecución de la API de ExampleScript. compilación a Wasm, agregar una función no exportada hace el trabajo.

// 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)),
  ]),
);

El compilador ya está casi listo. No es estrictamente necesario, una buena práctica para validar el módulo con el método Module#validate().

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

Cómo obtener el código Wasm resultante

Para obtener el código de Wasm resultante en Binaryen existen dos métodos para obtener representación textual como un archivo .wat en S-expression en un formato legible por humanos, y representación binaria como un archivo .wasm que se puede ejecutar directamente en el navegador. El código binario puede ser se ejecuta directamente en el navegador. Para ver que funcionó, puedes registrar ayuda.

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 representación textual completa para un programa ExampleScript con los cuatro las operaciones se enumeran a continuación. Observa que el código muerto sigue ahí, pero no está expuesta según la captura de pantalla 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)
  )
 )
)

Captura de pantalla de la consola de Herramientas para desarrolladores de las exportaciones del módulo WebAssembly que muestra cuatro funciones: agregar, dividir, multiplicar y restar (pero no el código muerto no expuesto).

Optimiza WebAssembly

Binaryen ofrece dos formas de optimizar el código Wasm. uno en Binaryen.js y uno para la línea de comandos. El primero aplica el conjunto estándar de optimización de forma predeterminada y te permite establecer el nivel de optimización y este último no usa reglas, sino que permite una personalización completa. lo que significa que, con suficiente experimentación, puedes adaptar la configuración para resultados óptimos en función de tu código.

Cómo optimizar con Binaryen.js

La forma más directa de optimizar un módulo de Wasm con Binaryen es llamar directamente al método Module#optimize() de Binaryen.js y, de forma opcional, estableciendo optimizar y reducir.

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

Esto quitará el código muerto que se introdujo artificialmente antes, representación textual de la versión de Wasm del ejemplo de juguete ExampleScript no ya que la contiene. Observa también cómo quita los pares local.set/get mediante la pasos de optimización SimplifyLocals (varias optimizaciones relacionadas con lugares locales) y el Aspiradora (quita el código innecesario evidente) y se quita return RemoveUnusedBrs (quita las pausas de las ubicaciones que no son necesarias).

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

Existen muchas pases de optimización, y Module#optimize() usa los niveles de optimización y reducción específicos predeterminado conjuntos. Para una personalización completa, debes usar la herramienta de línea de comandos wasm-opt.

Realiza optimizaciones con la herramienta de línea de comandos wasm-opt

Para una personalización completa de los pases que se usarán, Binaryen incluye Herramienta de línea de comandos de wasm-opt. Para obtener un Lista completa de las opciones de optimización posibles consulta el mensaje de ayuda de la herramienta. La herramienta wasm-opt es probablemente la más popular de las herramientas, y varias cadenas de herramientas de compiladores lo usan para optimizar el código de Wasm. incluidos Emscripten, J2CL, Kotlin/Wasm dart2wasm, wasm-pack y más.

wasm-opt --help

Para que te hagas una idea de los pases, aquí tienes un extracto de algunos de los que son comprensibles sin el conocimiento experto:

  • CodeFolding: Evita el código duplicado combinándolo (por ejemplo, si dos if los grupos tienen algunas instrucciones compartidas al final).
  • DeadArgumentElimination: Pase de optimización del tiempo de vinculación para quitar argumentos. a una función si siempre se llama con las mismas constantes.
  • MinifyImportsAndExports: Los reduce a "a", "b".
  • DeadCodeElimination: Quita el código no alcanzado.

Hay un guía de soluciones de optimización con varios consejos para identificar cuáles de las distintas marcas son más es importante y vale la pena probarlo primero. Por ejemplo, a veces se ejecuta wasm-opt. repetidamente una y otra vez reduce aún más la entrada. En esos casos, ejecutar con el Marca --converge continúa iterando hasta que no se produce más optimización y se alcanza un punto fijo alcanzada.

Demostración

Para ver los conceptos presentados en esta publicación en acción, prueba con los y proporciona cualquier entrada de ExampleScript que se te ocurra. Además, asegúrate de consultar el código fuente de la demostración

Conclusiones

Binaryen ofrece un potente kit de herramientas para compilar lenguajes para WebAssembly y optimizar el código resultante. Su biblioteca de JavaScript y las herramientas de línea de comandos ofrecer flexibilidad y facilidad de uso. Esta publicación demostró que los principios centrales de Compilación de Wasm, en la que se destaca la eficacia de Binaryen y su potencial para lograr la máxima optimización. Aunque muchas de las opciones para personalizar de las optimizaciones requieren un conocimiento profundo de los aspectos internos de Wasm, la configuración predeterminada ya funciona muy bien. Por lo tanto, te deseamos mucho éxito en la compilación y optimización con Binaryen.

Agradecimientos

Alon Zakai revisó esta publicación: Thomas alegre y Rachel Andrew.