haskell - ¿Por qué la versión pointfree de mi función utiliza mucha más memoria?
(1)
Estaba trabajando en un problema de Project Euler y terminé con un archivo de Haskell que incluía una función que se parecía a esto:
matches :: (a -> a -> Bool) -> a -> [(a, Int)] -> Int
matches f cs = foldr (/(cs'', n) a -> fromBool (f cs cs'') * n + a) 0
Con fromBool
importado de Foreign.Marshal.Utils
solo para convertir rápidamente True
a 1
y False
a 0
.
Estaba tratando de sacar un poco más de velocidad de mi solución, así que intenté cambiar de foldr
a foldl''
(cambiando los argumentos en el proceso), ya que asumí que foldr
no tenía mucho sentido usarlos en los números.
El cambio de foldr
a foldl''
hizo que asignara más del doble de memoria de acuerdo con el generador de perfiles de GHC.
Por diversión, también decidí reemplazar la lambda con una versión libre de la función:
matches :: (a -> a -> Bool) -> a -> [(a, Int)] -> Int
matches f cs = foldr ((+) . uncurry ((*) . fromBool . f cs)) 0
Esto hizo que mi asignación de memoria aumentara 20x desde la versión foldr
.
Ahora bien, esto no es un gran problema, ya que incluso en el caso de 20x, la asignación de memoria total fue de solo 135Mb
y el tiempo de ejecución del programa no se vio afectado, en todo caso, las versiones de asignación de memoria más altas se ejecutaron un poco más rápido.
Pero tengo mucha curiosidad por saber cómo podrían ser posibles estos resultados, para que en el futuro pueda elegir la función "correcta" cuando no tenga tanto margen de maniobra.
EDITAR:
GHC versión 7.10.2, compilada con -O2 -prof -fprof-auto
. Ejecutado con +RTS -p
.
EDIT 2:
Bien, parece que esto es demasiado difícil de reproducir para omitir el resto del código, bueno, aquí está el programa completo:
SPOILERS ABAJO:
{-# LANGUAGE NoMonomorphismRestriction #-}
import Control.Monad
import Data.List
import Foreign.Marshal.Utils
data Color = Red | Green | Blue deriving (Eq, Enum, Bounded, Show)
colors :: [Color]
colors = [Red ..]
matches :: (a -> a -> Bool) -> a -> [(a, Int)] -> Int
matches f x = foldr ((+) . uncurry ((*) . fromBool . f x)) 0
-- matches f x = foldr (/(y, n) a -> fromBool (f x y) * n + a) 0
-- matches f x = foldl'' (/a (y, n) -> fromBool (f x y) * n + a) 0
invert :: [([Color], Int)] -> [([Color], Int)]
invert rs = (/cs -> (cs, matches valid cs rs)) <$> choices
where
len = maximum $ length . fst <$> rs
choices = replicateM len colors
valid (x : xs) (y : ys) = x /= y && valid xs ys
valid _ _ = True
expand :: [([Color], Int)] -> [([Color], Int)]
expand rs = (/cs -> (cs, matches valid cs rs)) <$> choices
where
len = maximum $ length . fst <$> rs
choices = replicateM (len + 1) colors
valid (x1 : x2 : xs) (y : ys) = x1 /= y && x2 /= y && valid (x2 : xs) ys
valid _ _ = True
getRow :: Int -> [([Color], Int)]
getRow 1 = flip (,) 1 . pure <$> colors
getRow n = expand . invert $ getRow (n - 1)
result :: Int -> Int
result n = sum $ snd <$> getRow n
main :: IO ()
main = print $ result 8
Nota: Esta publicación está escrita en Haskell. Cópielo en un archivo, guárdelo como * .lhs y compile / cargue con GHC (i). Además, comencé a escribir esta respuesta antes de que hayas editado tu código, pero la lección sigue siendo la misma.
TL; DR
La función Prelude
uncurry
es demasiado perezosa, mientras que la coincidencia del patrón es lo suficientemente estricta.
Una advertencia y un descargo de responsabilidad.
Estamos entrando en un lugar mágico, extraño. Tener cuidado. Además, mis habilidades CORE son rudimentarias. Ahora que he perdido toda mi credibilidad, empecemos.
El codigo probado
Para saber dónde obtenemos los requisitos de memoria adicionales, es útil tener más de dos funciones.
> import Control.Monad (forM_)
Esta es su variante original, sin puntos:
> matches :: (a -> a -> Bool) -> a -> [(a, Int)] -> Int
> matches f cs = foldr (/(cs'', n) a -> fromEnum (f cs cs'') * n + a) 0
Esta es una variante que solo está ligeramente libre de puntos, el parámetro a
es eta-reducido.
> matchesPF'' :: (a -> a -> Bool) -> a -> [(a, Int)] -> Int
> matchesPF'' f cs = foldr (/(cs'', n) -> (+) (fromEnum (f cs cs'') * n)) 0
Esta es una variante que en línea se uncurry
a mano.
> matchesPFI :: (a -> a -> Bool) -> a -> [(a, Int)] -> Int
> matchesPFI f cs = foldr ((+) . (/(cs'', n) -> fromEnum (f cs cs'') * n)) 0
Esta es tu versión pointfree.
> matchesPF :: (a -> a -> Bool) -> a -> [(a, Int)] -> Int
> matchesPF f cs = foldr ((+) . uncurry ((*) . fromEnum . f cs)) 0
Esta es una variante que utiliza un uncurry
personalizado, ver más abajo.
> matchesPFU :: (a -> a -> Bool) -> a -> [(a, Int)] -> Int
> matchesPFU f cs = foldr ((+) . uncurryI ((*) . fromEnum . f cs)) 0
Esta es una variante que utiliza un uncurry
perezoso personalizado, ver más abajo.
> matchesPFL :: (a -> a -> Bool) -> a -> [(a, Int)] -> Int
> matchesPFL f cs = foldr ((+) . uncurryL ((*) . fromEnum . f cs)) 0
Para probar las funciones fácilmente, usamos una lista:
> funcs = [matches, matchesPF'', matchesPF, matchesPFL, matchesPFU, matchesPFI]
Nuestro auto-escrito es uncurry
:
> uncurryI :: (a -> b -> c) -> (a, b) -> c
> uncurryI f (a,b) = f a b
Un lazier sin uncurry
.
> uncurryL :: (a -> b -> c) -> (a, b) -> c
> uncurryL f p = f (fst p) (snd p)
La variante perezosa uncurryL
tiene la misma semántica que la variante en Prelude
, por ejemplo
uncurry (/_ _ -> 0) undefined == 0 == uncurryL (/_ _ -> 0) undefined
mientras que uncurryI
es estricto en la columna vertebral de la pareja.
> main = do
> let f a b = a < b
> forM_ [1..10] $ /i ->
> forM_ funcs $ /m ->
> print $ m f i (zip (cycle [1..10]) [1..i*100000])
La lista [1..i*100000]
depende de i
deliberadamente, por lo que no introducimos un CAF y sesgamos nuestro perfil de asignación.
El código desugared
Antes de profundizar en el perfil, echemos un vistazo al código deshecho de cada función:
==================== Desugar (after optimization) ====================
Result size of Desugar (after optimization)
= {terms: 221, types: 419, coercions: 0}
uncurryL
uncurryL = / @ a @ b @ c f p -> f (fst p) (snd p)
uncurryI
uncurryI = / @ a @ b @ c f ds -> case ds of _ { (a, b) -> f a b }
-- uncurried inlined by hand
matchesPFI =
/ @ a f cs ->
foldr
$fFoldable[]
(. (+ $fNumInt)
(/ ds ->
case ds of _ { (cs'', n) ->
* $fNumInt (fromEnum $fEnumBool (f cs cs'')) n
}))
(I# 0)
-- lazy uncurry
matchesPFL =
/ @ a f cs ->
foldr
$fFoldable[]
(. (+ $fNumInt)
(uncurryL (. (* $fNumInt) (. (fromEnum $fEnumBool) (f cs)))))
(I# 0)
-- stricter uncurry
matchesPFU =
/ @ a f cs ->
foldr
$fFoldable[]
(. (+ $fNumInt)
(uncurryI (. (* $fNumInt) (. (fromEnum $fEnumBool) (f cs)))))
(I# 0)
-- normal uncurry
matchesPF =
/ @ a f cs ->
foldr
$fFoldable[]
(. (+ $fNumInt)
(uncurry (. (* $fNumInt) (. (fromEnum $fEnumBool) (f cs)))))
(I# 0)
-- eta-reduced a
matchesPF'' =
/ @ a f cs ->
foldr
$fFoldable[]
(/ ds ->
case ds of _ { (cs'', n) ->
+ $fNumInt (* $fNumInt (fromEnum $fEnumBool (f cs cs'')) n)
})
(I# 0)
-- non-point-free
matches =
/ @ a f cs ->
foldr
$fFoldable[]
(/ ds a ->
case ds of _ { (cs'', n) ->
+ $fNumInt (* $fNumInt (fromEnum $fEnumBool (f cs cs'')) n) a
})
(I# 0)
Hasta ahora, todo parece estar bien. No hay nada sorprendente en marcha. Las funciones de Typeclass se reemplazan con sus variantes de diccionario, por ejemplo, foldr
convierte en foldr $ fFoldable [] `, ya que lo llamamos en una lista.
El perfil
Mon Jul 18 15:47 2016 Time and Allocation Profiling Report (Final) Prof +RTS -s -p -RTS total time = 1.45 secs (1446 ticks @ 1000 us, 1 processor) total alloc = 1,144,197,200 bytes (excludes profiling overheads) COST CENTRE MODULE %time %alloc matchesPF'' Main 13.6 0.0 matchesPF Main 13.3 11.5 main././ Main 11.8 76.9 main.f Main 10.9 0.0 uncurryL Main 9.5 11.5 matchesPFU Main 8.9 0.0 matchesPFI Main 7.3 0.0 matches Main 6.9 0.0 matchesPFL Main 6.3 0.0 uncurryI Main 5.3 0.0 matchesPF''./ Main 2.6 0.0 matchesPFI./ Main 2.0 0.0 matches./ Main 1.5 0.0 individual inherited COST CENTRE MODULE no. entries %time %alloc %time %alloc MAIN MAIN 44 0 0.0 0.0 100.0 100.0 main Main 89 0 0.0 0.0 100.0 100.0 main./ Main 90 10 0.0 0.0 100.0 100.0 main././ Main 92 60 11.8 76.9 100.0 100.0 funcs Main 93 0 0.0 0.0 88.2 23.1 matchesPFI Main 110 10 7.3 0.0 11.7 0.0 matchesPFI./ Main 111 5500000 2.0 0.0 4.4 0.0 main.f Main 112 5500000 2.4 0.0 2.4 0.0 matchesPFU Main 107 10 8.9 0.0 15.3 0.0 uncurryI Main 108 5500000 5.3 0.0 6.4 0.0 main.f Main 109 5500000 1.1 0.0 1.1 0.0 matchesPFL Main 104 10 6.3 0.0 17.7 11.5 uncurryL Main 105 5500000 9.5 11.5 11.4 11.5 main.f Main 106 5500000 1.9 0.0 1.9 0.0 matchesPF Main 102 10 13.3 11.5 15.4 11.5 main.f Main 103 5500000 2.1 0.0 2.1 0.0 matchesPF'' Main 99 10 13.6 0.0 17.2 0.0 matchesPF''./ Main 100 5500000 2.6 0.0 3.6 0.0 main.f Main 101 5500000 1.0 0.0 1.0 0.0 matches Main 94 10 6.9 0.0 10.9 0.0 matches./ Main 97 5500000 1.5 0.0 4.0 0.0 main.f Main 98 5500000 2.5 0.0 2.5 0.0 CAF Main 87 0 0.0 0.0 0.0 0.0 funcs Main 91 1 0.0 0.0 0.0 0.0 main Main 88 1 0.0 0.0 0.0 0.0 main./ Main 95 0 0.0 0.0 0.0 0.0 main././ Main 96 0 0.0 0.0 0.0 0.0 CAF GHC.IO.Handle.FD 84 0 0.0 0.0 0.0 0.0 CAF GHC.Conc.Signal 78 0 0.0 0.0 0.0 0.0 CAF GHC.IO.Encoding 76 0 0.0 0.0 0.0 0.0 CAF GHC.IO.Handle.Text 75 0 0.0 0.0 0.0 0.0 CAF GHC.IO.Encoding.Iconv 59 0 0.0 0.0 0.0 0.0
Ignora los main/./.
ruido, es sólo la lista. Sin embargo, hay un punto que uno debería notar inmediatamente: matchesPF
y uncurryL
usan el mismo alloc%
:
matchesPF Main 13.3 11.5
uncurryL Main 9.5 11.5
Llegando al CORE
Ahora es el momento de inspeccionar el CORE resultante ( ghc -ddump-simpl
). Notaremos que la mayoría de las funciones se han transformado en envolturas de trabajo, y se ven más o menos iguales ( -dsuppress-all -dsuppress-uniques
):
$wa5
$wa5 =
/ @ a1 w w1 w2 ->
letrec {
$wgo
$wgo =
/ w3 ->
case w3 of _ {
[] -> 0;
: y ys ->
case y of _ { (cs'', n) ->
case $wgo ys of ww { __DEFAULT ->
case w w1 cs'' of _ {
False -> case n of _ { I# y1 -> ww };
True -> case n of _ { I# y1 -> +# y1 ww }
}
}
}
}; } in
$wgo w2
Esta es su envoltura habitual de los trabajadores. $wgo
toma una lista, verifica si está vacía, es estricta en la cabecera ( case y of _ { (cs'', n) ->…
) y perezosa en el resultado recursivo $wgo ys of ww
.
Todas las funciones tienen el mismo aspecto. Bueno, todos excepto matchesPF
(su variante)
-- matchesPF
$wa3 =
/ @ a1 w w1 w2 ->
letrec {
$wgo =
/ w3 ->
case w3 of _ {
[] -> 0;
: y ys ->
case $wgo ys of ww { __DEFAULT ->
case let {
x = case y of _ { (x1, ds) -> x1 } } in
case w w1 x of _ {
False ->
case y of _ { (ds, y1) -> case y1 of _ { I# y2 -> main13 } };
-- main13 is just #I 0
True -> case y of _ { (ds, y1) -> y1 }
}
of _ { I# x ->
+# x ww
}
}
}; } in
$wgo w2
y matchesPFL
(la variante que utiliza el uncurryL
lento perezoso)
-- matchesPFL
$wa2
$wa2 =
/ @ a1 w w1 w2 ->
letrec {
$wgo =
/ w3 ->
case w3 of _ {
[] -> 0;
: y ys ->
case $wgo ys of ww { __DEFAULT ->
case snd y of ww1 { I# ww2 ->
case let {
x = fst y } in
case w w1 x of _ {
False -> main13;
True -> ww1
}
of _ { I# x ->
+# x ww
}
}
}
}; } in
$wgo w2
Son virtualmente los mismos. Y ambos contienen encuadernaciones . Esto creará un thunk y generalmente llevará a requisitos de espacio peores.
La solución
Creo que el culpable en este punto es claro. Es uncurry
GHC quiere imponer la semántica correcta de
uncurry (const (const 0)) undefined
Sin embargo, esto agrega la pereza y thunk adicionales. Su variante no puntual no presenta ese comportamiento, ya que hace un patrón de coincidencia en el par:
foldr (/(cs'', n) a -> …)
¿Todavía no confías en mí? Usa una combinación de patrones perezosos
foldr (/ ~(cs'', n) a -> …)
y te darás cuenta de que las matches
se comportarán igual que las matchesPF
. Por lo tanto, utilice una variante ligeramente más estricta de la uncurry
. uncurryI
es suficiente para dar una pista al analizador de rigor.
Tenga en cuenta que los pares son conocidos por este comportamiento. RWH dedica un capítulo entero a tratar de optimizar el comportamiento de una sola función donde los pares intermedios conducen a problemas.