Complejidad de tiempo de un compilador


54

Estoy interesado en la complejidad temporal de un compilador. Claramente esta es una pregunta muy complicada ya que hay muchos compiladores, opciones de compilador y variables a considerar. Específicamente, estoy interesado en LLVM pero estaría interesado en cualquier pensamiento que las personas tuvieran o lugares para comenzar la investigación. Un google bastante parece traer poco a la luz.

Supongo que hay algunos pasos de optimización que son exponenciales, pero que tienen poco impacto en el tiempo real. Por ejemplo, exponencial basado en el número son argumentos de una función.

Desde lo alto de mi cabeza, diría que generar el árbol AST sería lineal. La generación de IR requeriría pasar por el árbol mientras busca valores en tablas cada vez mayores, por lo que u O ( n log n ) . La generación y vinculación de código sería un tipo de operación similar. Por lo tanto, mi suposición sería O ( n 2 ) , si eliminamos las exponenciales de las variables que no crecen de manera realista.O(n2)O(nlogn)O(n2)

Aunque podría estar completamente equivocado. ¿Alguien tiene alguna idea al respecto?


77
Debe tener cuidado cuando afirma que algo es "exponencial", "lineal", u O ( n log n ) . Al menos para mí, no es del todo obvio cómo mides tu entrada (¿Exponencial en qué? ¿Qué significa n ?)O(n2)O(nlogn)n
Juho

2
Cuando dices LLVM, ¿te refieres a Clang? LLVM es un gran proyecto con varios subproyectos de compilación diferentes, por lo que es un poco ambiguo.
Nate CK

55
Para C # es al menos exponencial para los problemas del peor de los casos (puede codificar el problema SAT completo de NP en C #). Esto no es solo optimización, es necesario para elegir la sobrecarga correcta de una función. Para un lenguaje como C ++ será indecidible, ya que las plantillas se están completando.
CodesInChaos

2
@Zane No entiendo tu punto. La creación de instancias de plantilla ocurre durante la compilación. Puede codificar problemas difíciles en plantillas de una manera que obligue al compilador a resolver ese problema para producir una salida correcta. Podría considerar al compilador un intérprete del lenguaje de programación de plantillas completo de turing.
CodesInChaos

3
La resolución de sobrecarga de C # es bastante complicada cuando combina múltiples sobrecargas con expresiones lambda. Puede usar eso para codificar una fórmula booleana de tal manera que determinar si hay una sobrecarga aplicable requiere el problema NP-complete 3SAT. Para compilar realmente el problema, el compilador tiene que encontrar la solución para esa fórmula, lo que podría ser aún más difícil. Eric Lippert habla de eso en detalle en su publicación de blog Lambda Expressions vs. Anonymous Methods, Part Five
CodesInChaos

Respuestas:


50

El mejor libro para responder a su pregunta probablemente sería: Cooper y Torczon, "Ingeniería de un compilador", 2003. Si tiene acceso a la biblioteca de una universidad, debería poder pedir prestada una copia.

O(n2)n

O(n)O(n)

O(n)O(1)O(s)s

Luego, el árbol de análisis generalmente se "aplana" en un gráfico de flujo de control. Los nodos del gráfico de flujo de control pueden ser instrucciones de 3 direcciones (similar a un lenguaje ensamblador RISC), y el tamaño del gráfico de flujo de control generalmente será lineal en el tamaño del árbol de análisis.

O(d)dO(n)n

O(n2)tiempo en el tamaño del gráfico de flujo de todo el programa, pero esto significa que debe prescindir de la información (y las transformaciones de mejora del programa) que pueden ser costosas de probar. Un ejemplo clásico de esto es el análisis de alias, donde para algunos pares de escrituras de memoria le gustaría probar que las dos escrituras nunca pueden apuntar a la misma ubicación de memoria. (Es posible que desee hacer un análisis de alias para ver si puede mover una instrucción por encima de la otra). Pero para obtener información precisa sobre los alias, es posible que necesite analizar cada ruta de control posible a través del programa, que es exponencial en el número de ramas en el programa (y por lo tanto exponencial en el número de nodos en el gráfico de flujo de control).

Luego ingresas a la asignación de registros. La asignación de registros puede expresarse como un problema de coloración de gráficos, y se sabe que colorear un gráfico con un número mínimo de colores es NP-Hard. Por lo tanto, la mayoría de los compiladores utilizan algún tipo de heurística codiciosa combinada con derrames de registros con el objetivo de reducir la cantidad de derrames de registros lo mejor posible dentro de límites de tiempo razonables.

Finalmente entras en la generación de código. La generación de código generalmente se realiza en un bloque básico máximo en un momento en que un bloque básico es un conjunto de nodos de diagrama de flujo de control conectados linealmente con una sola entrada y una única salida. Esto puede reformularse como un problema de cobertura de gráfico donde el gráfico que está tratando de cubrir es el gráfico de dependencia del conjunto de instrucciones de 3 direcciones en el bloque básico, y está tratando de cubrir con un conjunto de gráficos que representan la máquina disponible instrucciones. Este problema es exponencial en el tamaño del bloque básico más grande (que podría, en principio, ser del mismo orden que el tamaño de todo el programa), por lo que esto se vuelve a hacer típicamente con heurística donde solo un pequeño subconjunto de las posibles cubiertas examinado.


44
Thirded! Por cierto, muchos de los problemas que los compiladores intentan resolver (por ejemplo, la asignación de registros) son NP-hard, pero otros son formalmente indecidibles. Supongamos, por ejemplo, que tiene una llamada p () seguida de una llamada q (). Si p es una función pura, puede reordenar las llamadas de forma segura siempre que p () no se repita infinitamente. Probar esto requiere resolver el problema de detención. Al igual que con los problemas NP-hard, un escritor compilador podría hacer tanto o tan poco esfuerzo para aproximar una solución como sea posible.
Seudónimo

44
Oh, una cosa más: hay algunos sistemas de tipos en uso hoy en día que son muy complejos en teoría. La inferencia de tipo Hindley-Milner es conocida por DEXPTIME-complete, y los lenguajes tipo ML deben implementarla correctamente. Sin embargo, el tiempo de ejecución es lineal en la práctica porque a) los casos patológicos nunca aparecen en los programas del mundo real, yb) los programadores del mundo real tienden a incluir anotaciones de tipo, aunque solo sea para obtener mejores mensajes de error.
Seudónimo

1
Gran respuesta, lo único que parece faltar es la parte simple de la explicación, enunciada en términos simples: compilar un programa se puede hacer en O (n). Optimizar un programa antes de compilar, como lo haría cualquier compilador moderno, es una tarea que es prácticamente ilimitada. El tiempo que realmente lleva no se rige por ningún límite inherente de la tarea, sino por la necesidad práctica de que el compilador termine en algún momento antes de que la gente se canse de esperar. Siempre es un compromiso.
aaaaaaaaaaaa

@Seudónimo, el hecho de que muchas veces el compilador tendría que resolver el problema de detención (o problemas NP muy desagradables) es una de las razones por las que los estándares dan margen al escritor del compilador al suponer que no ocurre un comportamiento indefinido (como bucles infinitos y otros )
vonbrand

15

En realidad, algunos lenguajes (como C ++, Lisp y D) se completan con Turing en el momento de la compilación, por lo que compilarlos es indecidible en general. Para C ++, esto se debe a la instanciación recursiva de plantillas. Para Lisp y D, puede ejecutar casi cualquier código en tiempo de compilación, por lo que puede lanzar el compilador en un bucle infinito si lo desea.


3
Los sistemas de tipos de Haskell (con extensiones) y Scala también son completos de Turing, lo que significa que la verificación de tipos puede llevar una cantidad infinita de tiempo. Scala ahora también tiene macros completas de Turing en la parte superior.
Jörg W Mittag

5

Desde mi experiencia real con el compilador de C #, puedo decir que para ciertos programas el tamaño del binario de salida crece exponencialmente con respecto al tamaño de la fuente de entrada (esto es realmente requerido por la especificación de C # y no se puede reducir), por lo que la complejidad del tiempo debe ser al menos exponencial también.

Se sabe que la tarea de resolución de sobrecarga general en C # es NP-hard (y la complejidad de implementación real es al menos exponencial).

Un procesamiento de comentarios de documentación XML en fuentes de C # también requiere evaluar expresiones arbitrarias de XPath 1.0 en tiempo de compilación, que también es exponencial, AFAIK.


¿Qué hace que los binarios de C # exploten de esa manera? A mí me parece un error de idioma ...
vonbrand

1
Es la forma en que los tipos genéricos se codifican en metadatos. class X<A,B,C,D,E> { class Y : X<Y,Y,Y,Y,Y> { Y.Y.Y.Y.Y.Y.Y.Y.Y y; } }
Vladimir Reshetnikov

-2

Mídalo con bases de código realistas, como un conjunto de proyectos de código abierto. Si traza los resultados como (codeSize, finishTime), puede trazar esos gráficos. Si sus datos f (x) = y son O (n), entonces graficar g = f (x) / x debería darle una línea recta después de que los datos comiencen a crecer.

Trace f (x) / x, f (x) / lg (x), f (x) / (x * lg (x)), f (x) / (x * x), etc. El gráfico se sumerge apagado a cero, aumentar sin límite, o aplanar. Esta idea es útil para situaciones como la medición de tiempos de inserción a partir de una base de datos vacía (es decir, para buscar una 'pérdida de rendimiento' durante un largo período).


1
La medición empírica de los tiempos de ejecución no establece la complejidad computacional. Primero, la complejidad computacional se expresa más comúnmente en términos del peor tiempo de ejecución. En segundo lugar, incluso si quisiera medir algún tipo de caso promedio, necesitaría establecer que sus entradas son "promedio" en ese sentido.
David Richerby

Pues seguro que es solo una estimación. Pero las pruebas empíricas simples con una gran cantidad de datos reales (cada confirmación para un montón de repositorios git) pueden superar un modelo cuidadoso. En cualquier caso, si una función realmente es O (n ^ 3) y traza f (n) / (n n n), debería obtener una línea ruidosa con una pendiente de aproximadamente cero. Si trazaras O (n ^ 3) / (n * n) solamente, verías que aumenta linealmente. Es realmente obvio si sobreestimas y observas cómo la línea cae rápidamente a cero.
Rob

1
Θ(nlogn)Θ(n2)Θ(nlogn)Θ(n2)

Estoy de acuerdo en que es lo que necesita saber si le preocupa recibir una denegación de servicio de un atacante que le da una entrada incorrecta, haciendo un análisis de entrada crítico en tiempo real. La función real que mide los tiempos de compilación será muy ruidosa, y el caso que nos interese será en repositorios de código real.
Rob

1
No. La pregunta se refiere a la complejidad temporal del problema. Esto generalmente se interpreta como el peor tiempo de ejecución, que enfáticamente no es el tiempo de ejecución en el código de los repositorios. Las pruebas que propone le dan una idea razonable de cuánto tiempo puede esperar que el compilador tome una determinada pieza de código, lo cual es algo bueno y útil para saber. Pero no le dicen casi nada sobre la complejidad computacional del problema.
David Richerby
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.