Quando a memoização é automática no GHC Haskell?

106

Não consigo descobrir por que m1 está aparentemente memorizado enquanto m2 não está no seguinte:

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

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

m1 10000000 leva cerca de 1,5 segundo na primeira chamada e uma fração disso nas chamadas subsequentes (presumivelmente ele armazena a lista em cache), enquanto m2 10000000 sempre leva a mesma quantidade de tempo (reconstruindo a lista a cada chamada). Alguma idéia do que está acontecendo? Existem regras básicas sobre se e quando o GHC memorizará uma função? Obrigado.

Jordânia
fonte

Respostas:

112

GHC não memoriza funções.

No entanto, ele calcula qualquer expressão dada no código no máximo uma vez por vez que a expressão lambda circundante é inserida, ou no máximo uma vez se estiver no nível superior. Determinar onde estão as expressões lambda pode ser um pouco complicado quando você usa açúcar sintático como em seu exemplo, então vamos convertê-los em sintaxe desugared equivalente:

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

(Nota: O relatório Haskell 98 realmente descreve uma seção esquerda do operador como (a %) como equivalente a \b -> (%) a b, mas o GHC a desugou (%) a. Eles são tecnicamente diferentes porque podem ser diferenciados por seq. Acho que devo ter enviado um tíquete do GHC Trac sobre isso.)

Dado isso, você pode ver que em m1', a expressão filter odd [1..]não está contida em nenhuma expressão lambda, então ela só será calculada uma vez por execução de seu programa, enquanto emm2' , filter odd [1..]será computado cada vez que a expressão lambda for inserida, ou seja, em cada chamada de m2'. Isso explica a diferença de tempo que você está vendo.


Na verdade, algumas versões do GHC, com certas opções de otimização, irão compartilhar mais valores do que a descrição acima indica. Isso pode ser problemático em algumas situações. Por exemplo, considere a função

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

GHC pode perceber que ynão depende dex e reescrever a função para

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

Neste caso, a nova versão é muito menos eficiente porque terá que ler cerca de 1 GB da memória onde yestá armazenada, enquanto a versão original rodaria em espaço constante e caberia no cache do processador. Na verdade, no GHC 6.12.1, a função fé quase duas vezes mais rápida quando compilada sem otimizações do que com a qual é compilada -O2.

Reid Barton
fonte
1
O custo para avaliar a expressão (filtro ímpar [1 ..]) é próximo de zero - afinal, é uma lista preguiçosa, então o custo real está no aplicativo (x !! 10000000) quando a lista é realmente avaliada. Além disso, tanto m1 quanto m2 parecem ser avaliados apenas uma vez com -O2 e -O1 (no meu ghc 6.12.3) pelo menos dentro do seguinte teste: (teste = m1 10000000 seqm1 10000000). No entanto, há uma diferença quando nenhum sinalizador de otimização é especificado. E ambas as variantes do seu "f" têm residência máxima de 5356 bytes, independentemente da otimização, a propósito (com menos alocação total quando -O2 é usado).
Ed'ka de
1
@ Ed'ka: Tente este programa de teste, com a definição acima de f: main = interact $ unlines . (show . map f . read) . lines; compilar com ou sem -O2; então echo 1 | ./main. Se você escrever um teste como main = print (f 5), então o ylixo pode ser coletado conforme é usado e não há diferença entre os dois fs.
Reid Barton,
er, isso deveria ser map (show . f . read), é claro. E agora que baixei o GHC 6.12.3, vejo os mesmos resultados do GHC 6.12.1. E sim, você está certo sobre o original m1e m2: as versões do GHC que realizam esse tipo de levantamento com otimizações habilitadas se transformarão m2em m1.
Reid Barton,
Sim, agora vejo a diferença (-O2 é definitivamente mais lento). Obrigado por este exemplo!
Ed'ka de
29

m1 é calculado apenas uma vez porque é um formulário de aplicação constante, enquanto m2 não é um CAF e, portanto, é calculado para cada avaliação.

Veja o wiki do GHC em CAFs: http://www.haskell.org/haskellwiki/Constant_applicative_form

sclv
fonte
1
A explicação “m1 é calculado apenas uma vez porque é uma forma de aplicação constante” não faz sentido para mim. Como provavelmente tanto m1 quanto m2 são variáveis ​​de nível superior, acho que essas funções são calculadas apenas uma vez, independentemente de serem CAFs ou não. A diferença é se a lista [1 ..]é calculada apenas uma vez durante a execução de um programa ou é calculada uma vez por aplicação da função, mas ela está relacionada ao CAF?
Tsuyoshi Ito,
1
Da página vinculada: "Um CAF ... pode ser compilado em um pedaço de gráfico que será compartilhado por todos os usuários ou em algum código compartilhado que se sobrescreverá com algum gráfico na primeira vez que for avaliado". Como m1é um CAF, o segundo se aplica e filter odd [1..](não apenas [1..]!) É calculado apenas uma vez. O GHC também pode observar que m2se refere a filter odd [1..], e colocar um link para o mesmo thunk usado no m1, mas isso seria uma má ideia: poderia levar a grandes vazamentos de memória em algumas situações.
Alexey Romanov,
@Alexey: Obrigado pela correção sobre [1..]e filter odd [1..]. Quanto ao resto, ainda não estou convencido. Se não me engano, CAF só é relevante quando queremos argumentar que um compilador poderia substituir o filter odd [1..]in m2por um thunk global (que pode ser até o mesmo thunk que o usado em m1). Mas na situação do solicitante, o compilador não fez essa “otimização” e não consigo ver sua relevância para a questão.
Tsuyoshi Ito,
2
É relevante que possa substituí-lo no m1 , e ele faz.
Alexey Romanov,
13

Há uma diferença crucial entre as duas formas: a restrição de monomorfismo se aplica a m1, mas não a m2, porque m2 deu argumentos explicitamente. Então o tipo de m2 é geral, mas o tipo m1 é específico. Os tipos a que são atribuídos são:

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

A maioria dos compiladores e interpretadores Haskell (todos eles que eu conheço) não memorizam estruturas polimórficas, então a lista interna de m2 é recriada toda vez que é chamada, enquanto a de m1 não é.

mokus
fonte
1
Jogar com eles no GHCi parece que também depende da transformação let-floating (uma das passagens de otimização do GHC que não é usada no GHCi). E, claro, ao compilar essas funções simples, o otimizador é capaz de fazê-las se comportar de forma idêntica de qualquer maneira (de acordo com alguns testes de critério que executei de qualquer maneira, com as funções em um módulo separado e marcadas com pragmas NOINLINE). Presumivelmente, isso ocorre porque a geração e a indexação da lista se fundem em um loop super apertado de qualquer maneira.
mokus de
1

Não tenho certeza, porque sou bastante novo no Haskell, mas parece que é porque a segunda função está parametrizada e a primeira não. A natureza da função é que, seu resultado depende do valor de entrada e no paradigma funcional especialmente depende SOMENTE da entrada. A implicação óbvia é que uma função sem parâmetros retorna sempre o mesmo valor indefinidamente, não importa o quê.

Aparentemente, existe um mecanismo de otimização no compilador GHC que explora esse fato para calcular o valor de tal função apenas uma vez para o tempo de execução do programa inteiro. Ele faz isso preguiçosamente, com certeza, mas o faz mesmo assim. Eu mesmo percebi, quando escrevi a seguinte função:

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

Em seguida, testá-lo, entrei GHCI e escreveu: primes !! 1000. Demorou alguns segundos, mas finalmente eu tenho a resposta: 7927. Então liguei primes !! 1001e obtive a resposta instantaneamente. Da mesma forma, em um instante obtive o resultado para take 1000 primes, porque Haskell teve que calcular toda a lista de mil elementos para retornar o 1001º elemento antes.

Portanto, se você pode escrever sua função de forma que ela não receba parâmetros, provavelmente você a deseja. ;)

Sventimir
fonte