11

Estoy trabajando en un lenguaje intermedio y una máquina virtual para ejecutar un lenguaje funcional, con un par de propiedades "problemáticos":¿Cuáles son algunas optimizaciones obvias para una máquina virtual que implementa un lenguaje funcional?

espacios de nombres
  • léxicas (cierres)
  • dinámicamente creciente pila de llamadas
  • Un tipo entero lento (bignums)

El lenguaje intermedio se basa en la pila, con una tabla hash simple para el espacio de nombres actual. Sólo para que pueda obtener una idea de lo que parece, aquí está la función McCarthy91:

# McCarthy 91: M(n) = n - 10 if n > 100 else M(M(n + 11)) 
.sub M 
    args 
    sto n 

    rcl n 
    float 100 
    gt 
    .if 
     .sub 
      rcl n 
      float 10 
      sub 
     .end 
     .sub 
      rcl n 
      float 11 
      add 
      list 1 
      rcl M 
      call-fast 
      list 1 
      rcl M 
      tail 
     .end 
    call-fast 
.end 

El "lazo grande" es sencillo:

  • extraer una instrucción
  • incremento del puntero de instrucción (o contador de programa)
  • evaluar la instrucción

Junto con sto, rcl y mucho más, hay tres instrucciones para llamadas de función:

  • call copia el espacio de nombres (en profundidad) y empuja el puntero de instrucción en la pila de llamadas
  • call-fast es el mismo, pero sólo crea una copia superficial
  • tail es básicamente un 'Goto'

La aplicación es muy sencillo. Para darle una mejor idea, aquí es sólo un fragmento de azar de la mitad del "bucle grande" (actualizado, véase más adelante)

} else if inst == 2 /* STO */ { 
     local[data] = stack[len(stack) - 1] 
     if code[ip + 1][0] != 3 { 
      stack = stack[:len(stack) - 1] 
     } else { 
      ip++ 
     } 
    } else if inst == 3 /* RCL */ { 
     stack = append(stack, local[data]) 
    } else if inst == 12 /* .END */ { 
     outer = outer[:len(outer) - 1] 
     ip = calls[len(calls) - 1] 
     calls = calls[:len(calls) - 1] 
    } else if inst == 20 /* CALL */ { 
     calls = append(calls, ip) 
     cp := make(Local, len(local)) 
     copy(cp, local) 
     outer = append(outer, &cp) 
     x := stack[len(stack) - 1] 
     stack = stack[:len(stack) - 1] 
     ip = x.(int) 
    } else if inst == 21 /* TAIL */ { 
     x := stack[len(stack) - 1] 
     stack = stack[:len(stack) - 1] 
     ip = x.(int) 

El problema es el siguiente: Llamada McCarthy91 16 veces con un valor de -10000 toma , cerca, como no hace ninguna diferencia, 3 segundos (después de optimizar la copia profunda, que agrega casi un segundo).

Mi pregunta es: ¿Cuáles son algunas técnicas comunes para optimizar la interpretación de este tipo de lenguaje? ¿Hay alguna fruta baja?

Usé rodajas para mis listas (argumentos, varias pilas, porción de mapas para los espacios de nombres, ...), así que hago este tipo de cosas por todos lados: call_stack[:len(call_stack) - 1]. En este momento, realmente no tengo ni idea de qué partes de código hacen que este programa sea lento. Cualquier consejo será apreciado, aunque principalmente estoy buscando estrategias generales de optimización.

Aparte:

puedo reducir el tiempo de ejecución de un poco eludiendo mis convenciones de llamada. La instrucción list <n> obtiene n argumentos de la pila y vuelve a colocar una lista de ellos en la pila, la instrucción args aparece en esa lista y vuelve a colocar cada elemento en la pila. Esto es, en primer lugar, comprobar que las funciones se invocan con el número correcto de argumentos y, en segundo lugar, que se pueden llamar funciones con listas de argumentos variables (es decir,(defun f x:xs)). Quitando eso, y también agregando una instrucción sto* <x>, que reemplaza sto <x>; rcl <x>, puedo bajarlo a 2 segundos. Todavía no es brillante, y tengo que tener este negocio list/args de todos modos. :)

Otro lado (esta es una pregunta larga que sé, lo siento):

perfilar el programa con pprof me habló muy poco (soy nuevo en ir en caso de que no es obvio) :-). Estos son los 3 mejores artículos según ha informado pprof:

16 6.1% 6.1%  16 6.1% sweep    pkg/runtime/mgc0.c:745 
    9 3.4% 9.5%  9 3.4% fmt.(*fmt).fmt_qc pkg/fmt/format.go:323 
    4 1.5% 13.0%  4 1.5% fmt.(*fmt).integer pkg/fmt/format.go:248 

Estos son los cambios que he hecho hasta ahora:

  • Quité la tabla hash. En cambio, ahora paso solo punteros a las matrices, y solo copio de manera eficiente el alcance local cuando tengo que hacerlo.
  • Reemplacé los nombres de las instrucciones con códigos de operación enteros. Antes, he perdido bastante tiempo comparando cadenas.
  • La instrucción call-fast se ha ido (el aumento de velocidad no era medible más después de los otros cambios)
  • En lugar de tener "int", "flotar" y las instrucciones "STR", sólo tienen uno eval y evaluar las constantes en tiempo de compilación (compilación del bytecode que es). Entonces, eval simplemente empuja una referencia a ellos.
  • Después de cambiar la semántica de .if, podría deshacerme de estas pseudo-funciones. ahora es .if, .else y .endif, con implosivos gotos y semántica de bloques similar a .sub. (some example code)

Después de implementar el analizador léxico, analizador, y el compilador de código de bytes, la velocidad se redujo un poco, pero no mucho. Al calcular MC (-10000) 16 veces, se evalúan 4,2 millones de instrucciones de código de bytes en 1,2 segundos. Aquí está a sample of the code it generates (desde this).

The whole thing is on github

+4

¡No utilice hashtables ni ningún otro tipo de búsqueda de nombres para los idiomas de ámbito léxico! No tiene ningún sentido en absoluto. Su compilador puede asignar registros estáticamente. Es muy fácil inferir el entorno capturado para cada abstracción lambda. –

+0

Realmente no entiendo a qué te refieres todavía. ¿Podría armar un pequeño ejemplo, digamos "rcl" y "sto"? –

+2

Use las ranuras numéricas para los argumentos, las variables y las variables de cierre. Introduzca códigos de operación como 'ldarg N', 'starg N', 'ldloc N', 'stloc N', 'ldenv N', 'stenv N'. Resuelva los nombres de las variables en los números al compilar. Construya las listas de variables capturadas mediante la comparación de las listas de variables libres y vinculadas para cada punto de abstracción lambda. Introduzca una instrucción especial para construir una instancia de cierre (debería ser similar a una llamada con una lista de variables para capturar). Es la forma en que se implementan la mayoría de los lenguajes funcionales (tanto VM como nativos). Puede ser muy eficiente. –

Respuesta

4

usted debe tener representaciones algorítmicas eficientes para los distintos conceptos de su intérprete. Hacer copias profundas en una tabla hash parece una idea terrible, pero veo que ya has eliminado eso.

(Sí, su operación de apilamiento de pila utilizando segmentos de matriz parece sospechosa. Debe asegurarse de que realmente tengan la complejidad algorítmica esperada, o bien utilizar una estructura de datos dedicada (... una pila). Generalmente soy cauteloso. de usar una estructura de datos multiusos como listas de Python o tablas PHP para este uso, porque no están necesariamente diseñadas para manejar bien este caso de uso en particular, pero es posible que las divisiones garanticen un costo de empuje y estallido O (1) bajo todas las circunstancias.)

La mejor manera de manejar entornos, siempre que no necesiten ser reificados, es usar índices numéricos en lugar de variables (índices de Bruijn (0 para la última variable enlazada), o niveles de Bruijn (0 para la variable enlazada primero). De esta manera, solo puede mantener una matriz de tamaño variable dinámicamente para el entorno y acceder a ella es muy rápido. Si tiene cierres de primera clase, también necesitará capturar el entorno, que será más costoso: tiene que copiar la parte en una estructura dedicada, o usar una estructura no mutable para todo el entorno. Solo el experimento lo dirá, pero mi experiencia es que se trata de una estructura de entorno mutable rápida y de pagar un costo mayor por la construcción del cierre es mejor que tener una estructura inmutable con más contabilidad todo el tiempo; por supuesto, debe hacer un análisis de uso para capturar solo las variables necesarias en sus cierres.

Por último, una vez que haya arrancado de raíz las fuentes de ineficiencia relacionadas con sus opciones de algoritmos, la zona caliente será:

  • recolección de basura (sin duda un tema difícil, si no quiere convertirse en una Experto en GC, debería considerar seriamente reutilizar un tiempo de ejecución existente); puede estar usando el GC de su lenguaje de host (las asignaciones de pila en su lenguaje interpretado se traducen en asignaciones de pila en su lenguaje de implementación, con la misma duración), no está claro en el fragmento de código que ha mostrado; esta estrategia es excelente para obtener algo razonablemente eficiente de una manera simple

  • implementación numérica; hay todo tipo de hacks para ser eficiente cuando los enteros que manipulas son de hecho pequeños. Su mejor opción es reutilizar el trabajo de las personas que han invertido toneladas de esfuerzo en esto, por lo que le recomiendo que vuelva a utilizar, por ejemplo, the GMP library. Por otra parte, también puede reutilizar el soporte de idioma de host para bignum si tiene alguno, en su caso el paquete math/big de Go.

  • el diseño de bajo nivel de su lazo de intérprete. En un lenguaje con "bytecode simple" como el suyo (cada instrucción de bytecode se traduce en un número pequeño de instrucciones nativas, en oposición a códigos de byte complejos que tienen semántica de alto nivel como el bytecode Parrot), el "bucle y despacho" real en bytecodes "El código puede ser un cuello de botella. Ha habido mucha investigación sobre cuál es la mejor manera de escribir tales ciclos de despacho de códigos de bytes, para evitar la cascada de if/then/else (tablas de salto), beneficiarse de la predicción de la rama del procesador de host, simplificar el flujo de control, etc. Esto se llama threaded code y hay muchas técnicas diferentes (bastante simples): enhebrado directo, enhebrado indirecto ... Si desea ver algo de la investigación, hay por ejemplo trabajo de Anton Ertl, The Structure and Performance of Efficient Interpreters en 2003, y más tarde Context threading: A flexible and efficient dispatch technique for virtual machine interpreters. Los beneficios de esas técnicas tienden a ser bastante sensibles al procesador, por lo que debe probar las diversas posibilidades usted mismo.

Mientras que el trabajo STG es interesante (y Peyton-Jones libro sobre la implementación lenguaje de programación es excelente), que está orientada hacia la evaluación un poco perezoso. En cuanto al diseño de bytecode eficiente para lenguajes funcionales estrictos, mi referencia es el trabajo de 1990 de Xavier Leroy en la máquina ZINC: The ZINC experiment: An Economical Implementation of the ML Language, que fue un trabajo pionero para la implementación de lenguajes ML, y todavía está en uso en la implementación del lenguaje OCaml : hay un bytecode y un compilador nativo, pero el bytecode todavía usa una máquina glorificada ZINC.

Cuestiones relacionadas