Quando è automatica la memorizzazione in GHC Haskell?


106

Non riesco a capire perché m1 è apparentemente memoizzato mentre m2 non è nel seguente:

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

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

m1 10000000 impiega circa 1,5 secondi alla prima chiamata, e una frazione di quello alle chiamate successive (presumibilmente memorizza nella cache l'elenco), mentre m2 10000000 impiega sempre la stessa quantità di tempo (ricostruendo l'elenco con ogni chiamata). Qualche idea su cosa sta succedendo? Esistono regole pratiche per stabilire se e quando GHC memorizzerà una funzione? Grazie.

Risposte:


112

GHC non memorizza le funzioni.

Tuttavia, calcola qualsiasi espressione data nel codice al massimo una volta ogni volta che viene inserita l'espressione lambda circostante, o al massimo una volta se si trova al livello superiore. Determinare dove si trovano le espressioni lambda può essere un po 'complicato quando usi lo zucchero sintattico come nel tuo esempio, quindi convertiamole nella sintassi desugared equivalente:

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

(Nota: il rapporto Haskell 98 descrive in realtà una sezione operatore di sinistra (a %)come equivalente a \b -> (%) a b, ma GHC la designa (%) a. Questi sono tecnicamente diversi perché possono essere distinti seq. Penso che potrei aver inviato un ticket GHC Trac su questo.)

Detto questo, puoi vedere che in m1', l'espressione filter odd [1..]non è contenuta in nessuna espressione lambda, quindi verrà calcolata solo una volta per esecuzione del tuo programma, mentre in m2', filter odd [1..]verrà calcolata ogni volta che viene inserita l'espressione lambda, ovvero su ogni chiamata di m2'. Questo spiega la differenza di tempistica che stai vedendo.


In realtà, alcune versioni di GHC, con alcune opzioni di ottimizzazione, condivideranno più valori di quelli indicati nella descrizione sopra. Questo può essere problematico in alcune situazioni. Ad esempio, considera la funzione

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

GHC potrebbe notare che ynon dipende da xe riscrive la funzione

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

In questo caso, la nuova versione è molto meno efficiente perché dovrà leggere circa 1 GB dalla memoria in cui yè archiviato, mentre la versione originale girerebbe in uno spazio costante e starebbe nella cache del processore. In effetti, con GHC 6.12.1, la funzione fè quasi il doppio più veloce quando viene compilata senza ottimizzazioni rispetto a quella con cui viene compilata -O2.


1
Il costo per valutare l'espressione (filtro dispari [1 ..]) è comunque vicino a zero - dopotutto è una lista pigra, quindi il costo reale è nell'applicazione (x !! 10000000) quando l'elenco viene effettivamente valutato. Inoltre, sia m1 che m2 sembrano essere valutati solo una volta con -O2 e -O1 (sul mio ghc 6.12.3) almeno entro il seguente test: (test = m1 10000000 seqm1 10000000). Tuttavia, c'è una differenza quando non viene specificato alcun flag di ottimizzazione. Ed entrambe le varianti della tua "f" hanno una residenza massima di 5356 byte indipendentemente dall'ottimizzazione, tra l'altro (con meno allocazione totale quando si usa -O2).
Ed'ka

1
@ Ed'ka: Prova questo programma di test, con la definizione di cui sopra f: main = interact $ unlines . (show . map f . read) . lines; compilare con o senza -O2; allora echo 1 | ./main. Se scrivi un test simile main = print (f 5), allora ypuò essere raccolto in modo indesiderato mentre viene utilizzato e non c'è differenza tra i due f.
Reid Barton

ehm, dovrebbe essere map (show . f . read), ovviamente. E ora che ho scaricato GHC 6.12.3, vedo gli stessi risultati di GHC 6.12.1. E sì, hai ragione sull'originale m1e m2: le versioni di GHC che eseguono questo tipo di sollevamento con le ottimizzazioni abilitate si trasformeranno m2in m1.
Reid Barton

Sì, ora vedo la differenza (-O2 è decisamente più lento). Grazie per questo esempio!
Ed'ka

29

m1 viene calcolato una sola volta perché è un modulo applicativo costante, mentre m2 non è un CAF, quindi viene calcolato per ogni valutazione.

Vedi il wiki GHC sui CAF: http://www.haskell.org/haskellwiki/Constant_applicative_form


1
La spiegazione "m1 viene calcolato solo una volta perché è una forma applicativa costante" non ha senso per me. Poiché presumibilmente sia m1 che m2 sono variabili di primo livello, penso che queste funzioni vengano calcolate una sola volta, indipendentemente dal fatto che siano CAF o meno. La differenza è se la lista [1 ..]viene calcolata solo una volta durante l'esecuzione di un programma o viene calcolata una volta per applicazione della funzione, ma è correlata a CAF?
Tsuyoshi Ito

1
Dalla pagina collegata: "Un CAF ... può essere compilato su un pezzo di grafico che sarà condiviso da tutti gli utenti o su un codice condiviso che si sovrascriverà con un grafico la prima volta che verrà valutato". Poiché m1è un CAF, il secondo si applica e filter odd [1..](non solo [1..]!) Viene calcolato solo una volta. GHC potrebbe anche notare che si m2riferisce ae filter odd [1..]inserire un collegamento allo stesso thunk utilizzato in m1, ma sarebbe una cattiva idea: potrebbe portare a perdite di memoria di grandi dimensioni in alcune situazioni.
Alexey Romanov

@ Alexey: grazie per la correzione su [1..]e filter odd [1..]. Per il resto, non sono ancora convinto. Se non mi sbaglio, CAF è rilevante solo quando vogliamo sostenere che un compilatore potrebbe sostituire filter odd [1..]in m2con un thunk globale (che può essere anche lo stesso thunk di quello utilizzato in m1). Ma nella situazione del richiedente, il compilatore non ha eseguito questa "ottimizzazione" e non riesco a vedere la sua rilevanza per la domanda.
Tsuyoshi Ito

2
È rilevante che può sostituirlo in m1 , e lo fa.
Alexey Romanov

13

C'è una differenza cruciale tra le due forme: la restrizione del monomorfismo si applica a m1 ma non a m2, perché m2 ha fornito argomenti esplicitamente. Quindi il tipo di m2 è generale ma quello di m1 è specifico. I tipi a cui vengono assegnati sono:

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

La maggior parte dei compilatori e interpreti Haskell (tutti quelli che conosco in realtà) non memorizzano strutture polimorfiche, quindi l'elenco interno di m2 viene ricreato ogni volta che viene chiamato, mentre m1 non lo è.


1
Giocando con questi in GHCi sembra che dipenda anche dalla trasformazione let-floating (uno dei passaggi di ottimizzazione di GHC che non viene utilizzato in GHCi). E naturalmente durante la compilazione di queste semplici funzioni, l'ottimizzatore è in grado di farle comportare comunque in modo identico (secondo alcuni test di criterio che ho eseguito comunque, con le funzioni in un modulo separato e contrassegnate con pragmi NOINLINE). Presumibilmente è perché la generazione dell'elenco e l'indicizzazione vengono comunque fuse in un ciclo super stretto.
mokus

1

Non ne sono sicuro, perché io stesso sono abbastanza nuovo per Haskell, ma sembra che sia perché la seconda funzione è parametrizzata e la prima no. La natura della funzione è che il suo risultato dipende dal valore di input e nel paradigma funzionale, in particolare, dipende SOLO dall'input. L'implicazione ovvia è che una funzione senza parametri restituisce sempre lo stesso valore più e più volte, qualunque cosa accada.

A quanto pare c'è un meccanismo di ottimizzazione nel compilatore GHC che sfrutta questo fatto per calcolare il valore di una tale funzione solo una volta per l'intero runtime del programma. Lo fa pigramente, certo, ma lo fa comunque. L'ho notato io stesso, quando ho scritto la seguente funzione:

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

Poi per provarlo, sono entrato GHCI e ho scritto: primes !! 1000. Ci sono voluti un paio di secondi, ma alla fine ho avuto la risposta: 7927. Poi ho chiamato primes !! 1001e ho ricevuto la risposta immediatamente. Allo stesso modo in un istante ho ottenuto il risultato pertake 1000 primes , perché Haskell ha dovuto calcolare l'intera lista di mille elementi per restituire il 1001 ° elemento prima.

Quindi, se puoi scrivere la tua funzione in modo tale che non richieda parametri, probabilmente lo vuoi. ;)

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.