A continuación se muestran 3 funciones que encuentran el último pero segundo elemento en una lista. El que usa last . init
parece mucho más rápido que el resto. Parece que no puedo entender por qué.
Para las pruebas, utilicé una lista de entrada de [1..100000000]
(100 millones). El último se ejecuta casi instantáneamente, mientras que los otros tardan varios segundos.
-- slow
myButLast :: [a] -> a
myButLast [x, y] = x
myButLast (x : xs) = myButLast xs
myButLast _ = error "List too short"
-- decent
myButLast' :: [a] -> a
myButLast' = (!! 1) . reverse
-- fast
myButLast'' :: [a] -> a
myButLast'' = last . init
init
se ha optimizado para evitar "desempacar" la lista varias veces.myButLast
mucho más lento? Parece que no está desempacando ninguna lista, sino solo atravesándola como loinit
hace la función ...[x, y]
es la abreviatura de(x:(y:[]))
, por lo que desempaqueta los contras externos, un segundo contra y comprueba si la cola del segundocons
es[]
. Además, la segunda cláusula descomprimirá la lista nuevamente en(x:xs)
. Sí, desempacar es razonablemente eficiente, pero, por supuesto, si sucede con mucha frecuencia, eso ralentizará el proceso.init
no verifica repetidamente si su argumento es una lista única o una lista vacía. Una vez que comienza la recursividad, solo se supone que el primer elemento se agregará al resultado de la llamada recursiva.myButLast
automática. Creo que es más probable que la fusión de la lista sea la culpable de la aceleración.Respuestas:
Al estudiar la velocidad y la optimización, es muy fácil obtener resultados tremendamente incorrectos . En particular, no puede decir realmente que una variante es más rápida que otra sin mencionar la versión del compilador y el modo de optimización de su configuración de evaluación comparativa. Incluso entonces, los procesadores modernos son tan sofisticados que cuentan con predictores de ramificación basados en redes neuronales, sin mencionar todo tipo de cachés, por lo que, incluso con una configuración cuidadosa, los resultados de la evaluación comparativa serán borrosos.
Habiendo dicho eso...
El benchmarking es nuestro amigo.
criterion
es un paquete que proporciona herramientas avanzadas de evaluación comparativa. Rápidamente redacté un punto de referencia como este:Como puede ver, agregué la variante que coincide explícitamente en dos elementos a la vez, pero de lo contrario es el mismo código literalmente. También ejecuto los puntos de referencia a la inversa, para tener en cuenta el sesgo debido al almacenamiento en caché. Entonces, ¡corramos y veamos!
¡Parece que nuestra versión "lenta" no es lenta en absoluto! Y las complejidades de la coincidencia de patrones no agregan nada. (Una ligera aceleración que vemos entre dos ejecuciones consecutivas de
match2
atribuyo a los efectos del almacenamiento en caché).Hay una manera de obtener más datos "científicos" : podemos
-ddump-simpl
ver la forma en que el compilador ve nuestro código.La inspección de estructuras intermedias es nuestra amiga.
"Core" es un lenguaje interno de GHC. Cada archivo fuente de Haskell se simplifica a Core antes de transformarse en el gráfico funcional final para que se ejecute el sistema de tiempo de ejecución. Si miramos esta etapa intermedia, nos dirá eso
myButLast
ybutLast2
son equivalentes. Hace falta mirar, ya que, en la etapa de cambio de nombre, todos nuestros identificadores agradables se destrozan al azar.Parece que
A1
yA4
son los más parecidos. Una inspección exhaustiva mostrará que, de hecho, el código se estructuraA1
yA4
es idéntico. EsoA2
yA3
son similares también es razonable ya que ambos se definen como una composición de dos funciones.Si va a examinar la
core
salida extensamente, tiene sentido suministrar también indicadores como-dsuppress-module-prefixes
y-dsuppress-uniques
. Hacen que sea mucho más fácil de leer.Una breve lista de nuestros enemigos también.
Entonces, ¿qué puede salir mal con la evaluación comparativa y la optimización?
ghci
, diseñado para el juego interactivo y la iteración rápida, compila la fuente de Haskell con un cierto sabor de código de bytes, en lugar del ejecutable final, y evita optimizaciones costosas a favor de una recarga más rápida.Esto puede parecer triste. Pero en realidad no es lo que debería preocupar a un programador de Haskell, la mayoría de las veces. Historia real: Tengo un amigo que recientemente comenzó a aprender Haskell. Habían escrito un programa para la integración numérica, y fue muy lento. Así que nos sentamos juntos y escribimos una descripción categorial del algoritmo, con diagramas y demás. Cuando reescribieron el código para alinearlo con la descripción abstracta, mágicamente se convirtió en guepardo rápido y delgado en memoria también. Calculamos π en poco tiempo. ¿Moraleja de la historia? Estructura abstracta perfecta, y su código se optimizará solo.
fuente
ghci
parece dar resultados diferentes (en términos de velocidad) en comparación con hacer un exe primero, como dijiste.