Efficienza della memoria di Haskell: qual è l'approccio migliore?


11

Stiamo implementando una libreria di compressione matrice basata su una sintassi grammaticale bidimensionale modificata. Ora abbiamo due approcci per i nostri tipi di dati: quale sarà meglio in caso di utilizzo della memoria? (vogliamo comprimere qualcosa;)).

Le grammatiche contengono non terminali con esattamente 4 produzioni o un terminale sul lato destro. Avremo bisogno dei nomi di Productions per controlli di uguaglianza e minimizzazione grammaticale.

Il primo:

-- | Type synonym for non-terminal symbols
type NonTerminal = String

-- | Data type for the right hand side of a production
data RightHandSide = DownStep NonTerminal NonTerminal NonTerminal NonTerminal | Terminal Int

-- | Data type for a set of productions
type ProductionMap = Map NonTerminal RightHandSide

data MatrixGrammar = MatrixGrammar {
    -- the start symbol
    startSymbol :: NonTerminal,
    -- productions
    productions :: ProductionMap    
    } 

Qui i nostri dati RightHandSide salvano solo i nomi String per determinare le produzioni successive, e ciò che non sappiamo qui è come Haskell salva queste stringhe. Ad esempio la matrice [[0, 0], [0, 0]] ha 2 produzioni:

a = Terminal 0
aString = "A"
b = DownStep aString aString aString aString
bString = "B"
productions = Map.FromList [(aString, a), (bString, b)]

Quindi la domanda qui è quanto spesso viene salvata la stringa "A"? Una volta in aString, 4 volte in be una volta in produzioni o solo una volta in aString e gli altri hanno solo riferimenti "più economici"?

Il secondo:

data Production = NonTerminal String Production Production Production Production
                | Terminal String Int 

type ProductionMap = Map String Production

qui il termine "Terminale" è un po 'fuorviante perché in realtà è la produzione che ha un terminale come lato destro. La stessa matrice:

a = Terminal "A" 0
b = NonTerminal "B" a a a a
productions = Map.fromList [("A", a), ("B", b)]

e la domanda simile: quanto spesso la produzione viene salvata internamente da Haskell? Probabilmente lasceremo cadere i nomi all'interno delle produzioni se non ne avremo bisogno, ma non siamo sicuri in questo momento.

Diciamo quindi che abbiamo una grammatica con circa 1000 produzioni. Quale approccio consumerà meno memoria?

Finalmente una domanda sugli interi in Haskell: al momento stiamo pensando di avere un nome come stringhe. Ma potremmo facilmente passare a nomi interi perché con 1000 produzioni avremo nomi con più di 4 caratteri (che presumo sia a 32 bit?). Come gestisce Haskell. Un Int è sempre a 32 bit e Integer alloca la memoria di cui ha davvero bisogno?

Ho anche letto questo: Deviante test del valore / semantica di riferimento di Haskell - ma non riesco a capire cosa significhi esattamente per noi - Sono più un bambino imperativo di Java che un buon programmatore funzionale: P

Risposte:


7

È possibile espandere la grammatica della matrice in un ADT con una condivisione perfetta con un po 'di trucco:

{-# LANGUAGE DeriveFunctor, DeriveFoldable, DeriveTraversable #-}

import Data.Map
import Data.Foldable
import Data.Functor
import Data.Traversable

-- | Type synonym for non-terminal symbols
type NonTerminal = String

-- | Data type for the right hand side of a production
data RHS a = DownStep NonTerminal NonTerminal NonTerminal NonTerminal | Terminal a
  deriving (Eq,Ord,Show,Read,Functor, Foldable, Traversable)

data G a = G NonTerminal (Map NonTerminal (RHS a))
  deriving (Eq,Ord,Show,Read,Functor)

data M a = Q (M a) (M a) (M a) (M a) | T a
  deriving (Functor, Foldable, Traversable)

tabulate :: G a -> M a
tabulate (G s pm) = loeb (expand <$> pm) ! s where
  expand (DownStep a11 a12 a21 a22) m = Q (m!a11) (m!a12) (m!a21) (m!a22)
  expand (Terminal a)               _ = T a

loeb :: Functor f => f (f b -> b) -> f b
loeb x = xs where xs = fmap ($xs) x

Qui ho generalizzato le tue grammatiche per consentire qualsiasi tipo di dati, non solo Int, e tabulateprenderò la grammatica e la espanderò piegandola su se stessa usando loeb.

loebè descritto in un articolo di Dan Piponi

L'espansione risultante come ADT non occupa fisicamente più memoria della grammatica originale - in effetti ci vuole un po 'meno, perché non ha bisogno del fattore log aggiuntivo per il dorso della mappa e non ha bisogno di memorizzare le stringhe a tutti.

A differenza dell'espansione ingenua, l'uso loebmi consente di "legare il nodo" e condividere i thunk per tutte le occorrenze dello stesso non terminale.

Se vuoi approfondire maggiormente la teoria di tutto ciò, possiamo vedere che RHSpotrebbe essere trasformato in un funzione base:

data RHS t nt = Q nt nt nt nt | L t

e quindi il mio tipo M è solo il punto fisso di quello Functor.

M a ~ Mu (RHS a)

mentre G asarebbe costituito da una stringa scelta e una mappa dalle stringhe a (RHS String a).

Possiamo quindi espandere Gin Mdal occhiata la voce in una mappa di stringhe espansi pigramente.

Questa è una specie di doppio di ciò che viene fatto nel data-reifypacchetto, che può prendere un tale funzione di base, e qualcosa di simile Me recuperare l'equivalente morale del tuo Gda esso. Usano un tipo diverso per i nomi non terminali, che è fondamentalmente solo un Int.

data Graph e = Graph [(Unique, e Unique)] Unique

e fornire un combinatore

reifyGraph :: MuRef s => s -> IO (Graph (DeRef s))

che può essere utilizzato con un'istanza appropriata sui tipi di dati sopra riportati per ottenere un grafico (MatrixGrammar) da una matrice arbitraria. Non eseguirà la deduplicazione di quadranti identici ma memorizzati separatamente, ma ripristinerà tutta la condivisione presente nel grafico originale.


8

In Haskell, il tipo String è un alias per [Char], che è un normale elenco Haskell di Char, non un vettore o un array. Char è un tipo che contiene un singolo carattere Unicode. I valori letterali di stringa sono, a meno che non si utilizzi un'estensione di lingua, valori di tipo String.

Penso che da quanto sopra si possa immaginare che String non sia una rappresentazione molto compatta o altrimenti efficiente. Rappresentazioni alternative comuni per le stringhe includono i tipi forniti da Data.Text e Data.ByteString.

Per maggiore comodità, è possibile utilizzare -XOverloadedStrings in modo da poter utilizzare valori letterali di stringa come rappresentazioni di un tipo di stringa alternativo, come fornito da Data.ByteString.Char8. Questo è probabilmente il modo più efficiente in termini di spazio per utilizzare comodamente le stringhe come identificatori.

Per quanto riguarda Int, è un tipo a larghezza fissa, ma non vi è alcuna garanzia su quanto sia largo se non che deve essere abbastanza largo da contenere i valori [-2 ^ 29 .. 2 ^ 29-1]. Ciò suggerisce che sono almeno 32 bit, ma non esclude di essere 64 bit. Data.Int ha alcuni tipi più specifici, Int8-Int64, che è possibile utilizzare se è necessaria una larghezza specifica.

Modifica per aggiungere informazioni

Non credo che la semantica di Haskell specifichi nulla sulla condivisione dei dati in entrambi i modi. Non dovresti aspettarti che due letterali String, o due di qualsiasi dato costruito, facciano riferimento allo stesso oggetto "canonico" in memoria. Se dovessi associare un valore costruito a un nuovo nome (con let, una corrispondenza di pattern, ecc.) Entrambi i nomi farebbero molto probabilmente riferimento agli stessi dati, ma se lo fanno o no non è realmente visibile a causa della natura immutabile di Dati Haskell.

Per motivi di efficienza dell'archiviazione, è possibile internare le stringhe, che essenzialmente memorizzano una rappresentazione canonica di ciascuna in una tabella di ricerca di qualche tipo, in genere una tabella di hash. Quando esegui il interning di un oggetto, ottieni un descrittore per esso e puoi confrontare quei descrittori con altri per vedere se sono gli stessi molto più economici di quanto potresti stringhe, e spesso sono anche molto più piccoli.

Per una libreria che esegue interning, è possibile utilizzare https://github.com/ekmett/intern/

Per quanto riguarda decidere quale dimensione intera usare in fase di esecuzione, è abbastanza facile scrivere codice che dipende da classi di tipo Integrale o Num anziché da tipi numerici concreti. L'inferenza del tipo ti darà automaticamente i tipi più generali che può. Potresti quindi avere alcune diverse funzioni con tipi esplicitamente ristretti a specifici tipi numerici di cui puoi scegliere uno in fase di esecuzione per eseguire la configurazione iniziale e, successivamente, tutte le altre funzioni polimorfiche funzionerebbero allo stesso modo su una qualsiasi di esse. Per esempio:

polyConstructor :: Integral a => a -> MyType a
int16Constructor :: Int16 -> MyType Int16
int32Constructor :: Int32 -> MyType Int32

int16Constructor = polyConstructor
int32Constructor = polyConstructor

Modifica : ulteriori informazioni sul interning

Se vuoi solo internare le stringhe, potresti creare un nuovo tipo che avvolge una stringa (preferibilmente un testo o ByteString) e un intero piccolo insieme.

data InternedString = { id :: Int32, str :: Text }
instance Eq InternedString where
    {x, _ } == {y, _ }  =  x == y

intern :: MonadIO m => Text -> m InternedString

Ciò che fa "intern" è cercare la stringa in una HashMap a riferimento debole in cui i testi sono chiavi e InternedStrings sono valori. Se viene trovata una corrispondenza, "intern" restituisce il valore. In caso contrario, crea un nuovo valore InternedString con il testo originale e un ID intero univoco (motivo per cui ho incluso il vincolo MonadIO; potrebbe invece utilizzare una monade di stato o un'operazione non sicura per ottenere l'id univoco; ci sono molte possibilità) e lo memorizza nella mappa prima di restituirlo.

Ora ottieni un confronto rapido basato sull'ID intero e hai solo una copia di ogni stringa univoca.

La biblioteca interna di Edward Kmett applica lo stesso principio, più o meno, in un modo molto più generale, in modo che i termini di dati strutturati interi vengano sottoposti a hash, archiviati in modo univoco e sottoposti a un'operazione di confronto rapido. È un po 'scoraggiante e non particolarmente documentato, ma potrebbe essere disposto ad aiutare se lo chiedi; oppure potresti provare prima la tua implementazione di internamento di stringhe per vedere se aiuta abbastanza.


Grazie per la risposta finora. È possibile determinare quale dimensione int dovremmo usare in fase di esecuzione? Spero che qualcun altro possa dare qualche input sul problema con le copie :)
Dennis Ich

Grazie per le informazioni aggiunte. Darò un'occhiata lì. Giusto per avere ragione, questi descrittori di cui stai parlando sono qualcosa di simile a un riferimento che viene cancellato e può essere confrontato? Hai lavorato con questo te stesso? Puoi forse dire quanto "più complicato" si ottenga con questo perché a prima vista sembra che devo stare molto attento nella definizione delle grammatiche;)
Dennis Ich

1
L'autore di quella libreria è un utente Haskell molto avanzato noto per il lavoro di qualità, ma non ho usato quella particolare libreria. È un'implementazione di "hash contro" molto generica, che memorizzerà e consentirà la condivisione della rappresentazione in qualsiasi tipo di dati costruito, non solo stringhe. Guarda la sua directory di esempio per un problema simile al tuo e puoi vedere come sono implementate le funzioni di uguaglianza.
Levi Pearson,
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.