pereza
No es una "optimización del compilador", pero es algo garantizado por la especificación del lenguaje, por lo que siempre puede contar con que suceda. Esencialmente, esto significa que el trabajo no se realiza hasta que "haga algo" con el resultado. (A menos que haga una de varias cosas para desactivar deliberadamente la pereza).
Esto, obviamente, es un tema completo por derecho propio, y SO ya tiene muchas preguntas y respuestas al respecto.
En mi experiencia limitada, hacer que su código sea demasiado vago o demasiado estricto tiene penalizaciones de rendimiento mucho más grandes (en tiempo y espacio) que cualquiera de las otras cosas de las que voy a hablar ...
Análisis de rigurosidad
La pereza se trata de evitar el trabajo a menos que sea necesario. Si el compilador puede determinar que un resultado dado "siempre" será necesario, entonces no se molestará en almacenar el cálculo y realizarlo más tarde; solo lo realizará directamente, porque eso es más eficiente. Esto se llama "análisis de rigor".
El problema, obviamente, es que el compilador no siempre puede detectar cuándo algo puede hacerse estricto. A veces necesitas darle al compilador pequeños consejos. (No conozco ninguna forma fácil de determinar si el análisis de rigurosidad ha hecho lo que usted cree que ha hecho, aparte de pasar por la salida de Core).
En línea
Si llama a una función y el compilador puede decir a qué función está llamando, puede intentar "en línea" esa función, es decir, reemplazar la llamada a la función con una copia de la función misma. La sobrecarga de una llamada de función suele ser bastante pequeña, pero la alineación a menudo permite que se realicen otras optimizaciones que de otra manera no hubieran sucedido, por lo que la alineación puede ser una gran victoria.
Las funciones solo están en línea si son "lo suficientemente pequeñas" (o si agrega un pragma que solicita específicamente la inserción). Además, las funciones solo pueden integrarse si el compilador puede decir a qué función está llamando. Hay dos formas principales por las que el compilador podría no saberlo:
Si la función que está llamando se pasa desde otro lugar. Por ejemplo, cuando filter
se compila la función, no puede alinear el predicado del filtro, porque es un argumento proporcionado por el usuario.
Si la función que está llamando es un método de clase y el compilador no sabe qué tipo está involucrado. Por ejemplo, cuando sum
se compila la función, el compilador no puede alinear la +
función, porque sum
funciona con varios tipos de números diferentes, cada uno de los cuales tiene una +
función diferente .
En el último caso, puede usar el {-# SPECIALIZE #-}
pragma para generar versiones de una función que están codificadas para un tipo particular. Por ejemplo, {-# SPECIALIZE sum :: [Int] -> Int #-}
compilaría una versión sum
codificada para el Int
tipo, lo que significa que se +
puede incluir en esta versión.
Sin embargo, sum
tenga en cuenta que nuestra nueva función especial solo se llamará cuando el compilador sepa que estamos trabajando Int
. De lo contrario, sum
se llama al polimórfico original . Nuevamente, la sobrecarga de la llamada a la función real es bastante pequeña. Son las optimizaciones adicionales que la alineación puede permitir lo que son beneficiosas.
Eliminación de subexpresión común
Si cierto bloque de código calcula el mismo valor dos veces, el compilador puede reemplazarlo con una sola instancia del mismo cálculo. Por ejemplo, si lo haces
(sum xs + 1) / (sum xs + 2)
entonces el compilador podría optimizar esto para
let s = sum xs in (s+1)/(s+2)
Es de esperar que el compilador siempre haga esto. Sin embargo, aparentemente en algunas situaciones esto puede resultar en un peor rendimiento, no mejor, por lo que GHC no siempre hace esto. Francamente, realmente no entiendo los detalles detrás de este. Pero la conclusión es que, si esta transformación es importante para usted, no es difícil hacerlo manualmente. (Y si no es importante, ¿por qué te preocupas por eso?)
Expresiones de caso
Considera lo siguiente:
foo (0:_ ) = "zero"
foo (1:_ ) = "one"
foo (_:xs) = foo xs
foo ( []) = "end"
Las tres primeras ecuaciones verifican si la lista no está vacía (entre otras cosas). Pero verificar lo mismo tres veces es un desperdicio. Afortunadamente, es muy fácil para el compilador optimizar esto en varias expresiones de caso anidadas. En este caso, algo como
foo xs =
case xs of
y:ys ->
case y of
0 -> "zero"
1 -> "one"
_ -> foo ys
[] -> "end"
Esto es bastante menos intuitivo, pero más eficiente. Debido a que el compilador puede hacer esta transformación fácilmente, no tiene que preocuparse por ello. Simplemente escriba su coincidencia de patrones de la manera más intuitiva posible; el compilador es muy bueno para reordenar y reorganizar esto para que sea lo más rápido posible.
Fusión
El idioma estándar de Haskell para el procesamiento de listas es encadenar funciones que toman una lista y producen una nueva lista. El ejemplo canónico es
map g . map f
Desafortunadamente, aunque la pereza garantiza omitir el trabajo innecesario, todas las asignaciones y desasignaciones para el rendimiento intermedio de la savia de la lista. "Fusión" o "deforestación" es donde el compilador intenta eliminar estos pasos intermedios.
El problema es que la mayoría de estas funciones son recursivas. Sin la recursividad, sería un ejercicio elemental en línea para aplastar todas las funciones en un gran bloque de código, ejecutar el simplificador sobre él y producir un código realmente óptimo sin listas intermedias. Pero debido a la recursividad, eso no funcionará.
Puedes usar {-# RULE #-}
pragmas para arreglar algo de esto. Por ejemplo,
{-# RULES "map/map" forall f g xs. map f (map g xs) = map (f.g) xs #-}
Ahora, cada vez que GHC ve map
aplicado map
, lo divide en un solo paso sobre la lista, eliminando la lista intermedia.
El problema es que esto funciona solo para map
seguidos de map
. Hay muchas otras posibilidades, map
seguidas filter
, filter
seguidas map
, etc. En lugar de codificar manualmente una solución para cada una de ellas, se inventó la llamada "fusión de flujo". Este es un truco más complicado, que no describiré aquí.
En resumen: estos son todos trucos especiales de optimización escritos por el programador . GHC en sí mismo no sabe nada sobre fusión; todo está en la lista de bibliotecas y otras bibliotecas de contenedores. Entonces, qué optimizaciones suceden depende de cómo se escriben las bibliotecas de contenedores (o, de manera más realista, qué bibliotecas elige usar).
Por ejemplo, si trabaja con matrices Haskell '98, no espere ninguna fusión de ningún tipo. Pero entiendo que la vector
biblioteca tiene amplias capacidades de fusión. Se trata de las bibliotecas; El compilador solo proporciona el RULES
pragma. (Lo cual es extremadamente poderoso, por cierto. ¡Como autor de la biblioteca, puede usarlo para reescribir el código del cliente!)
Meta:
Estoy de acuerdo con las personas que dicen "codificar primero, perfilar segundo, optimizar tercero".
También estoy de acuerdo con la gente que dice "es útil tener un modelo mental sobre cuánto cuesta una determinada decisión de diseño".
Equilibrio en todas las cosas, y todo eso ...