¿Cuándo es automática la memorización en GHC Haskell?

106

No puedo entender por qué aparentemente m1 está memorizado mientras que m2 no está en lo siguiente:

m1      = ((filter odd [1..]) !!)

m2 n    = ((filter odd [1..]) !! n)

m1 10000000 tarda aproximadamente 1,5 segundos en la primera llamada, y una fracción de eso en las llamadas posteriores (presumiblemente almacena en caché la lista), mientras que m2 10000000 siempre tarda la misma cantidad de tiempo (reconstruir la lista con cada llamada). ¿Tienes idea de lo que está pasando? ¿Existen reglas generales sobre si GHC memorizará una función y cuándo lo hará? Gracias.

Jordán
fuente

Respuestas:

112

GHC no memoriza funciones.

Sin embargo, calcula cualquier expresión dada en el código como máximo una vez cada vez que se ingresa su expresión lambda circundante, o como máximo una vez si está en el nivel superior. Determinar dónde están las expresiones lambda puede ser un poco complicado cuando usa azúcar sintáctico como en su ejemplo, así que conviértalo a una sintaxis desazucarada equivalente:

m1' = (!!) (filter odd [1..])              -- NB: See below!
m2' = \n -> (!!) (filter odd [1..]) n

(Nota: el informe Haskell 98 en realidad describe una sección de operador izquierda (a %)como equivalente a \b -> (%) a b, pero GHC lo desugar (%) a. Estos son técnicamente diferentes porque se pueden distinguir por seq. Creo que podría haber enviado un ticket de GHC Trac sobre esto).

Dado esto, puede ver que en m1', la expresión filter odd [1..]no está contenida en ninguna expresión lambda, por lo que solo se calculará una vez por ejecución de su programa, mientras que en m2', filter odd [1..]se calculará cada vez que se ingrese la expresión lambda, es decir, en cada llamada de m2'. Eso explica la diferencia en el tiempo que está viendo.


De hecho, algunas versiones de GHC, con ciertas opciones de optimización, compartirán más valores de los que indica la descripción anterior. Esto puede resultar problemático en algunas situaciones. Por ejemplo, considere la función

f = \x -> let y = [1..30000000] in foldl' (+) 0 (y ++ [x])

GHC puede notar que yno depende xy reescribe la función para

f = let y = [1..30000000] in \x -> foldl' (+) 0 (y ++ [x])

En este caso, la nueva versión es mucho menos eficiente porque tendrá que leer alrededor de 1 GB de la memoria donde yse almacena, mientras que la versión original se ejecutaría en un espacio constante y cabría en la caché del procesador. De hecho, en GHC 6.12.1, la función fes casi dos veces más rápida cuando se compila sin optimizaciones de lo que se compila -O2.

Reid Barton
fuente
1
El costo de evaluar (filtro impar [1 ..]) expresión es cercano a cero de todos modos - después de todo es una lista perezosa, por lo que el costo real está en la aplicación (x !! 10000000) cuando la lista se evalúa realmente. Además, tanto m1 como m2 parecen evaluarse solo una vez con -O2 y -O1 (en mi ghc 6.12.3) al menos dentro de la siguiente prueba: (prueba = m1 10000000 seqm1 10000000). Sin embargo, hay una diferencia cuando no se especifica ningún indicador de optimización. Y ambas variantes de su "f" tienen una residencia máxima de 5356 bytes independientemente de la optimización, por cierto (con menos asignación total cuando se usa -O2).
Ed'ka
1
@ Ed'ka: Prueba este programa de prueba, con la definición anterior de f: main = interact $ unlines . (show . map f . read) . lines; compilar con o sin -O2; entonces echo 1 | ./main. Si escribe una prueba como main = print (f 5), entonces yse puede recolectar basura a medida que se usa y no hay diferencia entre los dos f.
Reid Barton
er, eso debería ser map (show . f . read), por supuesto. Y ahora que descargué GHC 6.12.3, veo los mismos resultados que en GHC 6.12.1. Y sí, tiene razón sobre el original m1y m2: las versiones de GHC que realizan este tipo de levantamiento con optimizaciones habilitadas se transformarán m2en m1.
Reid Barton
Sí, ahora veo la diferencia (-O2 definitivamente es más lento). ¡Gracias por este ejemplo!
Ed'ka
29

m1 se calcula solo una vez porque es una forma de aplicación constante, mientras que m2 no es un CAF, por lo que se calcula para cada evaluación.

Consulte la wiki de GHC sobre CAF: http://www.haskell.org/haskellwiki/Constant_applicative_form

sclv
fuente
1
La explicación “m1 se calcula solo una vez porque es una forma de aplicación constante” no tiene sentido para mí. Debido a que presumiblemente tanto m1 como m2 son variables de nivel superior, creo que estas funciones se calculan solo una vez, sin importar si son CAF o no. La diferencia es si la lista [1 ..]se calcula solo una vez durante la ejecución de un programa o si se calcula una vez por aplicación de la función, pero ¿está relacionada con CAF?
Tsuyoshi Ito
1
Desde la página vinculada: "Un CAF ... se puede compilar en un gráfico que será compartido por todos los usuarios o en algún código compartido que se sobrescribirá con algún gráfico la primera vez que se evalúe". Como m1es un CAF, el segundo se aplica y filter odd [1..](¡no solo [1..]!) Se calcula solo una vez. GHC también podría señalar que se m2refiere filter odd [1..]y colocar un enlace al mismo procesador utilizado en m1, pero eso sería una mala idea: podría provocar grandes pérdidas de memoria en algunas situaciones.
Alexey Romanov
@Alexey: Gracias por la corrección sobre [1..]y filter odd [1..]. Por lo demás, todavía no estoy convencido. Si no me equivoco, CAF solo es relevante cuando queremos argumentar que un compilador podría reemplazar el filter odd [1..]in m2por un procesador global (que puede ser incluso el mismo procesador utilizado en m1). Pero en la situación del autor de la pregunta, el compilador no hizo esa "optimización" y no puedo ver su relevancia para la pregunta.
Tsuyoshi Ito
2
Es relevante que pueda reemplazarlo en m1 , y lo hace.
Alexey Romanov
13

Hay una diferencia crucial entre las dos formas: la restricción de monomorfismo se aplica a m1 pero no a m2, porque m2 ha dado argumentos explícitamente. Entonces, el tipo de m2 es general, pero el de m1 es específico. Los tipos que se les asignan son:

m1 :: Int -> Integer
m2 :: (Integral a) => Int -> a

La mayoría de los compiladores e intérpretes de Haskell (todos los que yo conozco en realidad) no memorizan estructuras polimórficas, por lo que la lista interna de m2 se recrea cada vez que se llama, donde m1 no.

mokus
fuente
1
Al jugar con estos en GHCi, parece que también depende de la transformación de flotación (uno de los pases de optimización de GHC que no se usa en GHCi). Y, por supuesto, al compilar estas funciones simples, el optimizador puede hacer que se comporten de manera idéntica de todos modos (de acuerdo con algunas pruebas de criterio que ejecuté de todos modos, con las funciones en un módulo separado y marcadas con pragmas NOINLINE). Es de suponer que eso se debe a que la generación de listas y la indexación se fusionan en un bucle súper ajustado de todos modos.
mokus
1

No estoy seguro, porque soy bastante nuevo en Haskell, pero parece que es porque la segunda función está parametrizada y la primera no. La naturaleza de la función es que, su resultado depende del valor de entrada y en el paradigma funcional, especialmente, depende SOLAMENTE de la entrada. La implicación obvia es que una función sin parámetros devuelve siempre el mismo valor una y otra vez, pase lo que pase.

Aparentemente, hay un mecanismo de optimización en el compilador de GHC que aprovecha este hecho para calcular el valor de dicha función solo una vez durante todo el tiempo de ejecución del programa. Lo hace con pereza, sin duda, pero no obstante lo hace. Lo noté yo mismo, cuando escribí la siguiente función:

primes = filter isPrime [2..]
    where isPrime n = null [factor | factor <- [2..n-1], factor `divides` n]
        where f `divides` n = (n `mod` f) == 0

Luego de probarlo, entré GHCi y escribí: primes !! 1000. Se tomó unos segundos, pero finalmente obtuvo la respuesta: 7927. Luego llamé primes !! 1001y obtuve la respuesta al instante. De manera similar, en un instante obtuve el resultado de take 1000 primes, porque Haskell tuvo que calcular la lista completa de mil elementos para devolver el elemento 1001 antes.

Por lo tanto, si puede escribir su función de manera que no tome parámetros, probablemente lo desee. ;)

Sventimir
fuente